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:
Jakub Zych
2026-02-05 15:41:50 +01:00
parent dca89e10cd
commit edbee885ac
6 changed files with 506 additions and 296 deletions

View File

@@ -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>