fix(09): revise plans based on checker feedback
- Plan 02: Added key_link clarifying CmsPageService.render populates PageRenderContext.components before template rendering. Updated Task 2/3 actions to emphasize component initialization flow. - Plan 03: Split into 03 (storage + library core) and 03b (image processing + routes) to reduce scope from 9 files to 7+4 files. Estimated context reduced from ~65% to ~45% each. - Plan 03b: New plan for ImageProcessor and MediaRoutes. Added specific curl command with -F flags and expected JSON response. - Plan 05: Added plugin integration test (MenuPluginIntegrationSpec) demonstrating custom menu item type registration, resolution, and template rendering per CONT-09 requirement. - Plan 06: Reframed must_haves truths from implementation details to user-observable outcomes (e.g., 'Developer edits template file, browser refresh shows changes without server restart') - Roadmap: Updated Phase 9 from 6 to 7 plans.
This commit is contained in:
@@ -13,6 +13,7 @@ files_modified:
|
||||
- summercms/resources/db/migration/V012__navigation_menus.sql
|
||||
- summercms/src/api/admin/MenuRoutes.scala
|
||||
- summercms/src/content/cms/pebble/MenuFunction.scala
|
||||
- summercms/test/content/menu/MenuPluginIntegrationSpec.scala
|
||||
autonomous: true
|
||||
|
||||
must_haves:
|
||||
@@ -20,7 +21,7 @@ must_haves:
|
||||
- "Admin can create navigation menus with unique codes"
|
||||
- "Menu items support URL, CMS page reference, and plugin-generated types"
|
||||
- "Menu items can be nested (parent-child hierarchy)"
|
||||
- "Plugins can register custom menu item types"
|
||||
- "Plugins can register custom menu item types that render correctly"
|
||||
- "Templates can access menus via Pebble function"
|
||||
artifacts:
|
||||
- path: "summercms/src/content/menu/Menu.scala"
|
||||
@@ -35,6 +36,9 @@ must_haves:
|
||||
- path: "summercms/resources/db/migration/V012__navigation_menus.sql"
|
||||
provides: "Menu and item tables"
|
||||
contains: "CREATE TABLE menus"
|
||||
- path: "summercms/test/content/menu/MenuPluginIntegrationSpec.scala"
|
||||
provides: "Plugin integration test"
|
||||
contains: "MenuPluginIntegrationSpec"
|
||||
key_links:
|
||||
- from: "MenuService.resolve"
|
||||
to: "MenuItemTypeRegistry"
|
||||
@@ -44,6 +48,10 @@ must_haves:
|
||||
to: "MenuService"
|
||||
via: "Pebble function fetches resolved menu"
|
||||
pattern: "menuService\\.getResolved"
|
||||
- from: "Plugin registration"
|
||||
to: "MenuItemTypeRegistry.register"
|
||||
via: "plugin boot registers custom type"
|
||||
pattern: "registry\\.register"
|
||||
---
|
||||
|
||||
<objective>
|
||||
@@ -51,7 +59,7 @@ Implement navigation menus with extensible item types
|
||||
|
||||
Purpose: Enable admin to create and manage navigation menus. Menu items support static URLs, CMS page references (dynamic), and plugin-generated items (extensible). Menus accessible in templates via Pebble function.
|
||||
|
||||
Output: Menu/MenuItem models, MenuItemType polymorphism, MenuItemTypeRegistry for plugin extensibility, MenuService, database migration, admin routes, Pebble menu() function.
|
||||
Output: Menu/MenuItem models, MenuItemType polymorphism, MenuItemTypeRegistry for plugin extensibility, MenuService, database migration, admin routes, Pebble menu() function, plugin integration test.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@@ -327,11 +335,12 @@ MenuItemTypeRegistry allows plugins to register custom item types. MenuService r
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create admin routes and Pebble menu function</name>
|
||||
<name>Task 3: Create admin routes, Pebble function, and plugin integration test</name>
|
||||
<files>
|
||||
summercms/src/api/admin/MenuRoutes.scala
|
||||
summercms/src/content/cms/pebble/MenuFunction.scala
|
||||
summercms/src/content/cms/pebble/CmsPebbleExtension.scala
|
||||
summercms/test/content/menu/MenuPluginIntegrationSpec.scala
|
||||
</files>
|
||||
<action>
|
||||
**MenuRoutes.scala:**
|
||||
@@ -341,88 +350,33 @@ object MenuRoutes:
|
||||
val routes: Routes[MenuService, Response] =
|
||||
Routes(
|
||||
// List all menus
|
||||
Method.GET / "admin" / "api" / "menus" -> handler { (req: Request) =>
|
||||
for
|
||||
service <- ZIO.service[MenuService]
|
||||
menus <- service.listMenus
|
||||
yield Response.json(menus.toJson)
|
||||
},
|
||||
Method.GET / "admin" / "api" / "menus" -> handler { ... },
|
||||
|
||||
// Get menu with items
|
||||
Method.GET / "admin" / "api" / "menus" / string("code") -> handler { (code: String, req: Request) =>
|
||||
for
|
||||
service <- ZIO.service[MenuService]
|
||||
menu <- service.getMenu(code)
|
||||
items <- menu.fold(ZIO.succeed(List.empty[MenuItem]))(m =>
|
||||
ZIO.serviceWithZIO[MenuRepository](_.listItems(m.id))
|
||||
)
|
||||
yield menu match
|
||||
case Some(m) => Response.json(MenuWithItems(m, items).toJson)
|
||||
case None => Response.status(Status.NotFound)
|
||||
},
|
||||
Method.GET / "admin" / "api" / "menus" / string("code") -> handler { ... },
|
||||
|
||||
// Create menu
|
||||
Method.POST / "admin" / "api" / "menus" -> handler { (req: Request) =>
|
||||
for
|
||||
body <- req.body.asJson[CreateMenuRequest]
|
||||
service <- ZIO.service[MenuService]
|
||||
menu <- service.createMenu(body.code, body.name)
|
||||
yield Response.json(menu.toJson).status(Status.Created)
|
||||
},
|
||||
Method.POST / "admin" / "api" / "menus" -> handler { ... },
|
||||
|
||||
// Update menu
|
||||
Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) =>
|
||||
for
|
||||
body <- req.body.asJson[UpdateMenuRequest]
|
||||
service <- ZIO.service[MenuService]
|
||||
menu <- service.updateMenu(id, body.name)
|
||||
yield Response.json(menu.toJson)
|
||||
},
|
||||
Method.PUT / "admin" / "api" / "menus" / long("id") -> handler { ... },
|
||||
|
||||
// Delete menu
|
||||
Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { (id: Long, req: Request) =>
|
||||
for
|
||||
service <- ZIO.service[MenuService]
|
||||
_ <- service.deleteMenu(id)
|
||||
yield Response.ok
|
||||
},
|
||||
Method.DELETE / "admin" / "api" / "menus" / long("id") -> handler { ... },
|
||||
|
||||
// Add menu item
|
||||
Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { (menuId: Long, req: Request) =>
|
||||
for
|
||||
body <- req.body.asJson[CreateItemRequest]
|
||||
service <- ZIO.service[MenuService]
|
||||
item <- service.addItem(menuId, body.toMenuItem(menuId))
|
||||
yield Response.json(item.toJson).status(Status.Created)
|
||||
},
|
||||
Method.POST / "admin" / "api" / "menus" / long("menuId") / "items" -> handler { ... },
|
||||
|
||||
// Update menu item
|
||||
Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) =>
|
||||
for
|
||||
body <- req.body.asJson[UpdateItemRequest]
|
||||
service <- ZIO.service[MenuService]
|
||||
item <- service.updateItem(body.toMenuItem(id))
|
||||
yield Response.json(item.toJson)
|
||||
},
|
||||
Method.PUT / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... },
|
||||
|
||||
// Delete menu item
|
||||
Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { (id: Long, req: Request) =>
|
||||
for
|
||||
service <- ZIO.service[MenuService]
|
||||
_ <- service.removeItem(id)
|
||||
yield Response.ok
|
||||
},
|
||||
Method.DELETE / "admin" / "api" / "menus" / "items" / long("id") -> handler { ... },
|
||||
|
||||
// Reorder items
|
||||
Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { (menuId: Long, req: Request) =>
|
||||
for
|
||||
body <- req.body.asJson[ReorderRequest]
|
||||
service <- ZIO.service[MenuService]
|
||||
_ <- service.reorderItems(menuId, body.itemOrders)
|
||||
yield Response.ok
|
||||
},
|
||||
Method.POST / "admin" / "api" / "menus" / long("menuId") / "reorder" -> handler { ... },
|
||||
|
||||
// List available item types
|
||||
// List available item types (includes plugin-registered types)
|
||||
Method.GET / "admin" / "api" / "menus" / "item-types" -> handler { (req: Request) =>
|
||||
for
|
||||
registry <- ZIO.service[MenuItemTypeRegistry]
|
||||
@@ -465,25 +419,124 @@ class MenuFunction(menuService: MenuService, runtime: Runtime[Any]) extends Func
|
||||
```
|
||||
|
||||
**Update CmsPebbleExtension.scala:**
|
||||
Register MenuFunction:
|
||||
```scala
|
||||
class CmsPebbleExtension(menuService: MenuService, runtime: Runtime[Any]) extends Extension:
|
||||
override def getFunctions: java.util.Map[String, Function] =
|
||||
java.util.Map.of(
|
||||
"menu", new MenuFunction(menuService, runtime)
|
||||
)
|
||||
// ... existing token parsers
|
||||
```
|
||||
Register MenuFunction (constructor now needs MenuService).
|
||||
|
||||
Note: Extension now needs MenuService injected at construction time.
|
||||
**MenuPluginIntegrationSpec.scala:**
|
||||
Test demonstrating plugin integration (CONT-09 requirement):
|
||||
```scala
|
||||
class MenuPluginIntegrationSpec extends ZIOSpecDefault:
|
||||
// Example plugin-provided menu item type: "blog-category"
|
||||
// Resolves blog category IDs to category pages
|
||||
|
||||
class BlogCategoryResolver(blogService: BlogService) extends MenuItemResolver:
|
||||
def resolve(item: MenuItem, currentUrl: String): IO[MenuError, ResolvedMenuItem] =
|
||||
for
|
||||
categoryId <- ZIO.fromOption(item.reference.flatMap(_.toLongOption))
|
||||
.orElseFail(MenuError.InvalidReference(item.id, "Expected category ID"))
|
||||
category <- blogService.getCategory(categoryId)
|
||||
.someOrFail(MenuError.ReferenceNotFound(item.id, s"Category $categoryId"))
|
||||
url = s"/blog/category/${category.slug}"
|
||||
yield ResolvedMenuItem(
|
||||
title = item.title.getOrElse(category.name),
|
||||
url = url,
|
||||
isActive = currentUrl == url || currentUrl.startsWith(s"$url/"),
|
||||
cssClass = item.cssClass,
|
||||
items = List.empty
|
||||
)
|
||||
|
||||
def spec = suite("MenuPluginIntegration")(
|
||||
test("plugin can register custom menu item type") {
|
||||
for
|
||||
registry <- ZIO.service[MenuItemTypeRegistry]
|
||||
blogService = TestBlogService()
|
||||
resolver = new BlogCategoryResolver(blogService)
|
||||
|
||||
// Plugin registers its type at boot
|
||||
_ <- registry.register("blog-category", resolver)
|
||||
|
||||
// Verify type is registered
|
||||
types <- registry.listTypes
|
||||
_ <- assertTrue(types.contains("blog-category"))
|
||||
|
||||
// Verify type appears in admin API
|
||||
// GET /admin/api/menus/item-types should include "blog-category"
|
||||
yield assertTrue(types.contains("url"), types.contains("cms-page"), types.contains("blog-category"))
|
||||
},
|
||||
|
||||
test("plugin-registered type resolves correctly in menu") {
|
||||
for
|
||||
registry <- ZIO.service[MenuItemTypeRegistry]
|
||||
menuService <- ZIO.service[MenuService]
|
||||
blogService = TestBlogService.withCategory(1L, "Tech", "tech")
|
||||
resolver = new BlogCategoryResolver(blogService)
|
||||
|
||||
_ <- registry.register("blog-category", resolver)
|
||||
|
||||
// Create menu with plugin item type
|
||||
menu <- menuService.createMenu("test-menu", "Test Menu")
|
||||
_ <- menuService.addItem(menu.id, MenuItem(
|
||||
id = 0,
|
||||
menuId = menu.id,
|
||||
parentId = None,
|
||||
itemType = "blog-category", // Plugin-provided type
|
||||
title = None, // Will inherit from category
|
||||
reference = Some("1"), // Category ID
|
||||
cssClass = None,
|
||||
nestDepth = 0,
|
||||
sortOrder = 0,
|
||||
isHidden = false
|
||||
))
|
||||
|
||||
// Resolve menu - plugin type should work
|
||||
resolved <- menuService.getResolved("test-menu", "/other")
|
||||
yield assertTrue(
|
||||
resolved.isDefined,
|
||||
resolved.get.items.head.title == "Tech",
|
||||
resolved.get.items.head.url == "/blog/category/tech",
|
||||
resolved.get.items.head.isActive == false
|
||||
)
|
||||
},
|
||||
|
||||
test("plugin type renders correctly in Pebble template") {
|
||||
for
|
||||
registry <- ZIO.service[MenuItemTypeRegistry]
|
||||
menuService <- ZIO.service[MenuService]
|
||||
blogService = TestBlogService.withCategory(1L, "Tech", "tech")
|
||||
|
||||
_ <- registry.register("blog-category", new BlogCategoryResolver(blogService))
|
||||
|
||||
// Setup menu with plugin item
|
||||
menu <- menuService.createMenu("nav", "Navigation")
|
||||
_ <- menuService.addItem(menu.id, MenuItem(/* blog-category type */))
|
||||
|
||||
// Render template using menu
|
||||
template = """
|
||||
{% set nav = menu('nav') %}
|
||||
{% for item in nav.items %}
|
||||
<a href="{{ item.url }}">{{ item.title }}</a>
|
||||
{% endfor %}
|
||||
"""
|
||||
rendered <- renderPebbleTemplate(template, Map("currentUrl" -> "/"))
|
||||
yield assertTrue(
|
||||
rendered.contains("""<a href="/blog/category/tech">Tech</a>""")
|
||||
)
|
||||
}
|
||||
).provide(
|
||||
MenuItemTypeRegistry.live,
|
||||
MenuService.live,
|
||||
MenuRepository.live,
|
||||
// ... test dependencies
|
||||
)
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
`./mill summercms.compile` succeeds
|
||||
MenuRoutes provide full CRUD
|
||||
`./mill summercms.test` - MenuPluginIntegrationSpec passes
|
||||
menu('main') function works in templates
|
||||
Plugin-registered type appears in item-types API endpoint
|
||||
</verify>
|
||||
<done>
|
||||
Admin menu routes provide full CRUD for menus and items. Pebble menu() function allows templates to access resolved menus with active state detection. Item types extensible via registry.
|
||||
Admin menu routes provide full CRUD for menus and items. Pebble menu() function allows templates to access resolved menus. Plugin integration test demonstrates custom menu item type registration, resolution, and template rendering.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
@@ -492,21 +545,24 @@ Admin menu routes provide full CRUD for menus and items. Pebble menu() function
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
1. `./mill summercms.compile` - all code compiles
|
||||
2. Migration V012 ready for Flyway
|
||||
3. Test menu CRUD:
|
||||
2. `./mill summercms.test` - plugin integration tests pass
|
||||
3. Migration V012 ready for Flyway
|
||||
4. Test menu CRUD:
|
||||
- Create menu with code "test"
|
||||
- Add URL item, CMS page item
|
||||
- Nest items under parent
|
||||
- Reorder items
|
||||
4. Test menu resolution:
|
||||
5. Test menu resolution:
|
||||
- GET /admin/api/menus/main returns menu with items
|
||||
- Items have correct URLs and active state
|
||||
5. Test Pebble function:
|
||||
6. Test Pebble function:
|
||||
- Template with `{% set nav = menu('main') %}` resolves menu
|
||||
- Loop over items renders navigation
|
||||
6. Test extensibility:
|
||||
- Plugin can register custom item type
|
||||
- Custom type appears in item-types list
|
||||
7. Test plugin extensibility (CONT-09):
|
||||
- Plugin can register custom item type via registry
|
||||
- Custom type appears in GET /admin/api/menus/item-types
|
||||
- Menu items with custom type resolve correctly
|
||||
- Custom type renders correctly in Pebble template
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
@@ -515,8 +571,12 @@ After all tasks complete:
|
||||
- Nested items maintain parent-child hierarchy
|
||||
- Resolved menu includes active state for current URL
|
||||
- Plugins can register custom menu item types via registry
|
||||
- Plugin-registered types appear in item-types API
|
||||
- Plugin types resolve to valid ResolvedMenuItem with correct URLs
|
||||
- Plugin types render correctly in Pebble templates
|
||||
- Templates access menus via menu('code') Pebble function
|
||||
- Admin API provides full menu management
|
||||
- Integration test proves end-to-end plugin type functionality
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
|
||||
Reference in New Issue
Block a user