Compact Menu Tables Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the manual menu editor with three linked compact tables for menus, sections, and items, with inline CRUD and persistent drag sorting.
Architecture: The API gains non-destructive tenant-scoped section CRUD plus exact-order endpoints for sections and items. The web client owns pure selection/reorder helpers, while a table component owns inline row state and connects successful mutations to the existing editor preview.
Tech Stack: NestJS, TypeORM, PostgreSQL, React 19, Next.js, Ant Design 6, @ant-design/pro-components, Vitest, API e2e tests.
Global Constraints
- Preserve tenant isolation and return
404for foreign menu, section, and item identifiers. - Do not run migrations; the current schema already owns
sort_indexand its unique constraints. - Use Ant Design theme tokens and components; no new local palette, spacing scale, or modal dialog.
- Archive/read-only mode disables all mutation and drag controls.
- Create and edit in table rows; Enter saves, explicit cancel restores the row, and deletion uses inline
Popconfirm. - The menu list has CRUD only; sections and items have CRUD plus drag sorting.
- Do not overwrite unrelated uncommitted menu changes in the shared checkout.
Task 1: Non-destructive section CRUD and stable reorder API
Files:
- Modify:
apps/api/src/menu/menu.dto.ts - Modify:
apps/api/src/menu/menu.controller.ts - Modify:
apps/api/src/menu/menu.service.ts - Modify:
apps/api/src/menu/menu.dto.spec.ts - Modify:
apps/api/test/menu.e2e.spec.ts
Interfaces:
Produces
CreateMenuSectionDto,UpdateMenuSectionDto, andReorderMenuSectionsDto { sectionIds: string[] }.Produces
POST /menus/:id/sections,PUT /menus/:id/sections/:sectionId,DELETE /menus/:id/sections/:sectionId, andPUT /menus/:id/sections/reorder.[ ] Step 1: Write failing DTO tests
test('rejects duplicate or non-UUID section ids in a reorder', async () => {
const dto = plainToInstance(ReorderMenuSectionsDto, {
sectionIds: ['not-a-uuid', 'not-a-uuid'],
});
expect(await validate(dto)).not.toHaveLength(0);
});
test('accepts an enabled false section update', async () => {
const dto = plainToInstance(UpdateMenuSectionDto, {
name: 'Завтраки', enabled: false,
});
expect(await validate(dto)).toHaveLength(0);
});- [ ] Step 2: Verify the test is red
Run: bun --cwd apps/api test src/menu/menu.dto.spec.ts
Expected: FAIL because the section DTOs do not exist.
- [ ] Step 3: Add DTOs and routes
export class ReorderMenuSectionsDto {
@IsArray() @ArrayMinSize(1) @ArrayMaxSize(MAX_SECTIONS) @ArrayUnique()
@IsUUID('4', { each: true })
sectionIds: string[];
}
@Put(':id/sections/reorder')
reorderSections(@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ReorderMenuSectionsDto,
@Req() req: Request & { user?: RequestUser }) {
const user = req.user!;
return this.menus.reorderSections(user.tenantId, user.actorId, id, dto);
}Keep sections/reorder before sections/:sectionId. Retain the legacy replacement endpoint for existing callers.
- [ ] Step 4: Verify the DTO test is green
Run: bun --cwd apps/api test src/menu/menu.dto.spec.ts
Expected: PASS.
- [ ] Step 5: Write a failing section API e2e test
test('edits and reorders sections without deleting their items', async () => {
const { owner, locationId } = await ownerWithLocation();
const menu = await createMenu(owner, locationId);
const menuId = menu.body.id as string;
const first = await owner.post(`/menus/${menuId}/sections`).set(...XRW)
.send({ name: 'First', enabled: true }).expect(201);
const second = await owner.post(`/menus/${menuId}/sections`).set(...XRW)
.send({ name: 'Second', enabled: true }).expect(201);
await owner.post(`/menus/${menuId}/items`).set(...XRW)
.send({ sectionId: first.body.id, name: 'Soup' }).expect(201);
await owner.put(`/menus/${menuId}/sections/${first.body.id}`).set(...XRW)
.send({ name: 'Starters' }).expect(200);
await owner.put(`/menus/${menuId}/sections/reorder`).set(...XRW)
.send({ sectionIds: [second.body.id, first.body.id] }).expect(200);
await owner.get(`/menus/${menuId}/items`).expect(200)
.expect(({ body }) => expect(body).toEqual([
expect.objectContaining({ name: 'Soup', sectionId: first.body.id }),
]));
});- [ ] Step 6: Verify the e2e test is red
Run: env -u DATABASE_URL bun --cwd apps/api test test/menu.e2e.spec.ts
Expected: FAIL with 404 for the new section routes.
- [ ] Step 7: Implement transaction-safe methods
const sections = await repository.find({
where: { tenantId, menuId }, order: { sortIndex: 'ASC' },
});
const ids = new Set(sections.map(({ id }) => id));
if (dto.sectionIds.length !== sections.length || dto.sectionIds.some((id) => !ids.has(id))) {
throw new BadRequestException('Section order must contain every menu section once');
}
const offset = Math.max(-1, ...sections.map(({ sortIndex }) => sortIndex)) + sections.length + 1;
await repository.save(sections.map((section) => ({ ...section, sortIndex: section.sortIndex + offset })));
await repository.save(dto.sectionIds.map((id, sortIndex) => ({ ...byId.get(id)!, sortIndex })));Create assigns the next index, update trims supplied names, and deletion rejects a section that still has items. Every mutation locks the menu, calls assertEditable, increments draftRevision, and writes a specific audit action.
- [ ] Step 8: Verify section behavior is green
Run: env -u DATABASE_URL bun --cwd apps/api test test/menu.e2e.spec.ts
Expected: PASS, including the existing replacement-conflict test.
- [ ] Step 9: Commit
Run: git add apps/api/src/menu/menu.dto.ts apps/api/src/menu/menu.controller.ts apps/api/src/menu/menu.service.ts apps/api/src/menu/menu.dto.spec.ts apps/api/test/menu.e2e.spec.ts && git commit -m "feat(api): add menu section CRUD and reorder"
Task 2: Item reorder API and ordering integrity
Files:
- Modify:
apps/api/src/menu/menu.dto.ts - Modify:
apps/api/src/menu/menu.controller.ts - Modify:
apps/api/src/menu/menu.service.ts - Modify:
apps/api/test/menu.e2e.spec.ts
Interfaces:
Produces
ReorderMenuItemsDto { sectionId: string; itemIds: string[] }.Produces
PUT /menus/:id/items/reorder, returning the section items ordered bysortIndex.[ ] Step 1: Write a failing item-reorder e2e test
test('reorders only the items of the requested section', async () => {
const { owner, locationId } = await ownerWithLocation();
const menu = await createMenu(owner, locationId);
const menuId = menu.body.id as string;
const first = await owner.post(`/menus/${menuId}/sections`).set(...XRW)
.send({ name: 'First', enabled: true }).expect(201);
const second = await owner.post(`/menus/${menuId}/sections`).set(...XRW)
.send({ name: 'Second', enabled: true }).expect(201);
const firstSectionId = first.body.id as string;
const firstItem = await owner.post(`/menus/${menuId}/items`).set(...XRW)
.send({ sectionId: firstSectionId, name: 'A' }).expect(201);
const secondItem = await owner.post(`/menus/${menuId}/items`).set(...XRW)
.send({ sectionId: firstSectionId, name: 'B' }).expect(201);
const otherItem = await owner.post(`/menus/${menuId}/items`).set(...XRW)
.send({ sectionId: second.body.id, name: 'C' }).expect(201);
const firstItemId = firstItem.body.id as string;
const secondItemId = secondItem.body.id as string;
const otherSectionItemId = otherItem.body.id as string;
const response = await owner.put(`/menus/${menuId}/items/reorder`).set(...XRW)
.send({ sectionId: firstSectionId, itemIds: [secondItemId, firstItemId] })
.expect(200);
expect(response.body.map((item: { id: string }) => item.id))
.toEqual([secondItemId, firstItemId]);
expect((await owner.get(`/menus/${menuId}/items`).expect(200)).body)
.toEqual(expect.arrayContaining([
expect.objectContaining({ id: otherSectionItemId, sortIndex: 0 }),
]));
});- [ ] Step 2: Verify it is red
Run: env -u DATABASE_URL bun --cwd apps/api test test/menu.e2e.spec.ts
Expected: FAIL because items/reorder is not registered.
- [ ] Step 3: Add the DTO, route, and two-phase ordering update
export class ReorderMenuItemsDto {
@IsUUID('4') sectionId: string;
@IsArray() @ArrayMinSize(1) @ArrayUnique()
@IsUUID('4', { each: true }) itemIds: string[];
}Validate the target section belongs to the selected menu and itemIds is the exact set of that section's item IDs. Apply the temporary-offset approach from Task 1 to avoid transient violation of UQ_menu_items_section_sort, then bump the revision and audit menu_items_reordered.
- [ ] Step 4: Verify it is green
Run: env -u DATABASE_URL bun --cwd apps/api test test/menu.e2e.spec.ts
Expected: PASS.
- [ ] Step 5: Commit
Run: git add apps/api/src/menu/menu.dto.ts apps/api/src/menu/menu.controller.ts apps/api/src/menu/menu.service.ts apps/api/test/menu.e2e.spec.ts && git commit -m "feat(api): support menu item reordering"
Task 3: Web client and selection/reorder view model
Files:
- Modify:
apps/web/ui/app/menus/menuClient.ts - Create:
apps/web/ui/app/menus/menuManagementView.ts - Create:
apps/web/ui/app/menus/menuManagementView.test.ts - Modify:
apps/web/ui/app/menus/menuTypes.ts
Interfaces:
Produces
menuApi.createSection,updateSection,deleteSection,reorderSections, andreorderItems.Produces pure
selectMenu,selectSection, andmoveRowhelpers.[ ] Step 1: Write failing pure-state tests
test('clears section and items when a different menu is selected', () => {
expect(selectMenu({ selectedMenuId: 'menu-a', selectedSectionId: 'section-a', items: [itemA] }, 'menu-b'))
.toEqual({ selectedMenuId: 'menu-b', selectedSectionId: null, items: [] });
});
test('moves a row without mutating the supplied array', () => {
const before = ['a', 'b', 'c'];
expect(moveRow(before, 2, 0)).toEqual(['c', 'a', 'b']);
expect(before).toEqual(['a', 'b', 'c']);
});- [ ] Step 2: Verify it is red
Run: bun --cwd apps/web test ui/app/menus/menuManagementView.test.ts
Expected: FAIL because the helper module does not exist.
- [ ] Step 3: Implement client methods and helpers
reorderSections: (menuId: string, sectionIds: string[]) =>
apiRequest<MenuSection[]>('PUT', `/menus/${menuId}/sections/reorder`, { sectionIds }),
reorderItems: (menuId: string, sectionId: string, itemIds: string[]) =>
apiRequest<MenuItem[]>('PUT', `/menus/${menuId}/items/reorder`, { sectionId, itemIds }),Keep selection reset, row movement, and rollback input pure; React owns only fetching and toast side effects.
- [ ] Step 4: Verify it is green
Run: bun --cwd apps/web test ui/app/menus/menuManagementView.test.ts
Expected: PASS.
- [ ] Step 5: Commit
Run: git add apps/web/ui/app/menus/menuClient.ts apps/web/ui/app/menus/menuTypes.ts apps/web/ui/app/menus/menuManagementView.ts apps/web/ui/app/menus/menuManagementView.test.ts && git commit -m "feat(web): add compact menu management state"
Task 4: Three linked inline-editing tables
Files:
- Create:
apps/web/ui/app/menus/MenuManagementTables.tsx - Create:
apps/web/ui/app/menus/MenuManagementTables.test.tsx - Modify:
apps/web/ui/app/menus/MenuEditorScreen.tsx - Delete:
apps/web/ui/app/menus/MenuContentTree.tsxafter replacement is verified - Modify:
apps/web/messages/ru.json - Modify:
apps/web/messages/en.json - Modify:
apps/web/messages/sr.json
Interfaces:
Consumes Task 3 client/state helpers and loaded editor data.
Produces
MenuManagementTables, refreshing the editor preview only after successful changes.[ ] Step 1: Write failing component tests
test('keeps sections disabled until a menu row is selected', async () => {
render(<MenuManagementTables menus={[menuA]} readOnly={false} />);
expect(screen.getByRole('button', { name: /добавить раздел/i })).toBeDisabled();
await userEvent.click(screen.getByText(menuA.name));
expect(screen.getByRole('button', { name: /добавить раздел/i })).toBeEnabled();
});
test('adds an inline draft item and cancels it without calling the API', async () => {
render(<MenuManagementTables menus={[menuA]} selectedMenuId={menuA.id} selectedSectionId={sectionA.id} readOnly={false} />);
await userEvent.click(screen.getByRole('button', { name: /добавить элемент/i }));
expect(screen.getByRole('textbox', { name: /название элемента/i })).toBeVisible();
await userEvent.click(screen.getByRole('button', { name: /отмена/i }));
expect(screen.queryByRole('textbox', { name: /название элемента/i })).not.toBeInTheDocument();
});- [ ] Step 2: Verify it is red
Run: bun --cwd apps/web test ui/app/menus/MenuManagementTables.test.tsx
Expected: FAIL because the component does not exist.
- [ ] Step 3: Implement tables and inline controls
<DragSortTable<MenuSection>
rowKey="id" dragSortKey="sort" dataSource={sections}
search={false} pagination={false}
onRow={(section) => ({ onClick: () => selectSection(section.id) })}
onDragSortEnd={(before, after) => void reorderSectionRows(before, after)}
/>Use ProTable/DragSortTable and the current SortableCards touch pattern. Each table has a compact header and labelled text actions Изменить, Сохранить, Отмена; delete is inline Popconfirm. One editingRow state permits one draft or edited row per table. Do not render Modal, ModalForm, or Drawer. On a reorder failure restore the literal prior array and use the existing error toast.
- [ ] Step 4: Mount it in manual editor mode and localize it
Replace MenuContentTree in MenuEditorScreen manual mode. Selecting a menu reloads its draft/items/publications with the existing generation guard and resets the selected section. Keep settings, copilot, and preview. Add matching RU/EN/SR text for table headings, empty states, actions, confirmation, errors, and drag-handle aria labels.
- [ ] Step 5: Verify component tests are green
Run: bun --cwd apps/web test ui/app/menus/MenuManagementTables.test.tsx
Expected: PASS.
- [ ] Step 6: Run focused checks and browser smoke
Run: bun --cwd apps/web typecheck && bun --cwd apps/web lint ui/app/menus/MenuManagementTables.tsx ui/app/menus/MenuEditorScreen.tsx && bun --cwd apps/web format:check
Expected: exit 0. In an authenticated browser, confirm three desktop tables, the mobile vertical flow, parent-selection reset, no hidden modal in inline create/cancel, successful drag sorting, and no mutation controls for archived or read-only data.
- [ ] Step 7: Commit
Run: git add apps/web/ui/app/menus/MenuManagementTables.tsx apps/web/ui/app/menus/MenuManagementTables.test.tsx apps/web/ui/app/menus/MenuEditorScreen.tsx apps/web/ui/app/menus/MenuContentTree.tsx apps/web/messages/ru.json apps/web/messages/en.json apps/web/messages/sr.json && git commit -m "feat(web): add inline menu management tables"
Task 5: Full regression and handoff
Files:
Modify:
.agent/memory/working/WORKSPACE.md[ ] Step 1: Run API regression
Run: env -u DATABASE_URL bun --cwd apps/api test test/menu.e2e.spec.ts src/menu/menu.service.spec.ts src/menu/menu.dto.spec.ts
Expected: PASS, including archive, cross-tenant, replacement conflict, and item CRUD coverage.
- [ ] Step 2: Run web regression
Run: bun --cwd apps/web test ui/app/menus
Expected: PASS, including existing input, view, and deletion-confirmation tests.
- [ ] Step 3: Inspect the final diff
Run: git diff --check && git status --short
Expected: no whitespace errors; only feature files and pre-existing user changes.
- [ ] Step 4: Record evidence
Append a dated entry to WORKSPACE.md listing successful API/web commands, responsive smoke results, and that no deployment was performed.
- [ ] Step 5: Commit the verification note if it is the only remaining change
Run: git add .agent/memory/working/WORKSPACE.md && git commit -m "docs: record compact menu editor verification"