Skip to content

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 404 for foreign menu, section, and item identifiers.
  • Do not run migrations; the current schema already owns sort_index and 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, and ReorderMenuSectionsDto { sectionIds: string[] }.

  • Produces POST /menus/:id/sections, PUT /menus/:id/sections/:sectionId, DELETE /menus/:id/sections/:sectionId, and PUT /menus/:id/sections/reorder.

  • [ ] Step 1: Write failing DTO tests

ts
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
ts
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
ts
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
ts
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 by sortIndex.

  • [ ] Step 1: Write a failing item-reorder e2e test

ts
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
ts
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, and reorderItems.

  • Produces pure selectMenu, selectSection, and moveRow helpers.

  • [ ] Step 1: Write failing pure-state tests

ts
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
ts
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.tsx after 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

tsx
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
tsx
<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"