Skip to content

feat(contexts): add a menu to applications - #20

Open
remko48 wants to merge 1 commit into
mainfrom
stack/m1-menu-model
Open

remko48 wants to merge 1 commit into
mainfrom
stack/m1-menu-model

Conversation

@remko48

@remko48 remko48 commented Oct 12, 2026

Copy link
Copy Markdown
Member

Applications (contexts) get a menu: an ordered list of items that each open a view, a table or a web link. This PR adds the data model and returns the menu with the application; saving it comes in the next PR.

What changes

  • Migration: new table tables_contexts_menu_item (label, icon, target type + id or url, technical name, order). A technical name is unique within one application, enforced by a unique index.
  • MenuItem entity + MenuItemMapper: the menus of all requested applications are loaded in one query (chunked for Oracle), not one per application.
  • Broken targets: an item whose view or table was deleted is not removed. It comes back with broken: true, worked out in the same query, so the UI can show it and the owner can fix it.
  • ContextMapper: the three copies of the "group joined rows per context" code are replaced by one formatResultSet(). The navigation bar query skips loading menus, since it doesn't show them.
  • Deleting an application also deletes its menu.
  • API: menuItems is added to the context response (OpenAPI and TS types regenerated).

🖼️ Screenshots

Backend only, no UI changes.

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔙 Backport requests are created or not needed: /backport to stableX.X
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

An application (context) gets a menu of items that each open a view, a
table or a link. Items are loaded with the context in one chunked query,
and an item whose view or table no longer exists is reported as broken,
computed in SQL. Deleting an application deletes its menu.

- a menu item's technical name is unique within its application, kept so
  by a unique index on (context_id, technical_name)
- the item target type is typed as view, table or url in the API
- the mapper groups the joined context rows in one place instead of three

Co-authored-by: Ruben van der Linde <ruben@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
@remko48
remko48 added this pull request to stack #24 October 12, 2026 11:39
Comment thread lib/Db/MenuItemMapper.php

$qb = $this->db->getQueryBuilder();
$brokenExpression = sprintf(
'CASE WHEN (m.target_type = %s AND v.id IS NULL) OR (m.target_type = %s AND t.id IS NULL) THEN 1 ELSE 0 END',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The SQL in this PR is not covered by any test

The broken flag and the menu loading only exist in SQL, and the unit tests in this PR do not reach them. In a mutation check against this branch, each of these changes left all tests green:

  • replacing the view half of this CASE (m.target_type = 'view' AND v.id IS NULL) by 1 = 0, so a menu item whose view was deleted is no longer reported as broken;
  • ContextMapper::findAll() calling formatResultSet(..., false), so the list of applications comes without menus;
  • ContextMapper::find() passing [] instead of findByContextIds([$contextId]), so a single application comes without its menu.

The app already has the setup for tests against a real database: tests/unit/Database/DatabaseTestCase.php, used by Row2MapperTest. A MenuItemMapper test on top of it could cover that items pointing at a deleted view or table come back broken (and url items never do), that items are ordered by order and grouped per context, and that ContextMapper::find() / findAll() include the menu.

Comment thread lib/Db/MenuItem.php
'contextId' => $this->getContextId(),
'label' => $this->getLabel() ?? '',
'icon' => $this->getIcon(),
'targetType' => match ($this->targetType) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 A table target is never serialized in the tests

Changing the table arm of this match to return self::TARGET_URL leaves all tests green: testTargetTypesAreTheThreeKnownOnes checks the TARGET_TYPES constant, and testJsonSerializeCarriesEveryField serializes a view target. Adding a table case there (or a data provider over the three target types) would cover it.

* targetType: 'view'|'table'|'url',
* targetId: int|null,
* url: string|null,
* technicalName: string|null,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 technicalName can never be null, but is typed as nullable

The column is notnull in Version2400Date20261012020000 (it has to be, for the unique index to hold on every database), yet the API type here and the @method getTechnicalName(): ?string / setTechnicalName(?string) on MenuItem allow null. Typing it as string tells API clients that every menu item has a name.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants