Skip to content

feat(contexts): export and import menus and grid views - #27

Open
remko48 wants to merge 6 commits into
stack/a2-application-overviewfrom
stack/a3-export-import
Open

remko48 wants to merge 6 commits into
stack/a2-application-overviewfrom
stack/a3-export-import

Conversation

@remko48

@remko48 remko48 commented Oct 14, 2026

Copy link
Copy Markdown
Member

An exported application scheme now carries its menu and the pages with widgets the menu opens, so an application can move between instances with its menu intact.

What changes

  • Export: every menu item names its target by uuid and its parent by technical name. A menu item whose target cannot be loaded is left out of the export.
  • Grid views: the views without a table that the menu points at are exported with their grid; their data widgets name their table or view by uuid as well as by id.
  • Import: grid views are imported first, so the menu can point at them. A grid view whose uuid already exists here is reused, not overwritten. Data widgets whose table or view does not exist here are dropped together with their place in the layout.
  • Menu on import: targets are resolved by uuid; a menu item that is invalid or whose target does not exist here is skipped instead of failing the import, and a child whose parent was skipped becomes a top-level item. The imported menu replaces the application's menu.
  • Validation: the import request is validated into a ContextScheme in the controller; a malformed scheme (no name, wrong types, missing addTables/modifyTables) returns a 400.
  • API: menuItems and gridViews are added to the import scheme endpoint (OpenAPI and TS types regenerated).
  • Tests: unit tests for ContextScheme and for exporting and importing the menu and grid views.

🖼️ 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 exported application scheme carries its menu, with each target named
  by uuid and each parent by technical name, so the scheme can move
  between instances
- the grid views the menu opens travel with the scheme; their data
  widgets name their table or view by uuid as well
- on import, menu items and widgets whose target does not exist here are
  skipped instead of failing the import
- the import request is validated into a ContextScheme at the controller,
  so a malformed scheme gets a 400

Signed-off-by: Remko <remko@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5
@remko48
remko48 added this pull request to stack #32 October 14, 2026 10:03
@remko48
remko48 removed this pull request from stack #32 October 14, 2026 10:21
@remko48
remko48 added this pull request to stack #33 October 14, 2026 10:21
@remko48 remko48 closed this Oct 14, 2026
@remko48 remko48 reopened this Oct 14, 2026
Comment thread lib/Service/ContextService.php Outdated
Comment thread lib/Service/ContextService.php Outdated
Comment thread lib/Service/ContextService.php Outdated
Comment thread lib/Controller/ContextController.php
Comment thread lib/Service/ContextService.php Outdated
Comment thread lib/Service/ContextService.php Outdated
Comment thread lib/Service/ContextService.php
Comment thread lib/Model/ContextScheme.php

@rjzondervan rjzondervan left a comment

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.

Verdict: Request changes (Thorough)

Export and import of menus and grid views works, and access control holds: an import never attaches a table or view the importer cannot manage or read, and ids from the other instance are never used. One regression blocks: importing a scheme without menuItems now deletes the application's menu. The other findings are about the promise that bad items are "skipped instead of failing", and about tests.

🔴 Blockers

🟡 Concerns

🟢 Minor

✅ Verified clean

  • Foreign ids: resolveGridTargets replaces a widget's target id with the one resolved from its uuid, and drops the widget when there is none.
  • Imported menu items get the same checks as a normal menu save (MenuItemInput: http/https plus a host, label/icon length, technical-name pattern).
  • Parents by technical name: positions counted over surviving items only; a skipped or nested parent promotes its children to top level.
  • The import runs inside the controller's transaction; export requires MANAGE and de-duplicates grid views by uuid.
  • ContextScheme: name, tables shape, list types and icon/description types are checked and tested.

Mode: Thorough · Head: 1ee93397 (the merge from stack/a2-application-overview brought only Playwright and Context.vue fixes; the PR diff is unchanged)
CI: green except Playwright; its 3 failures (application.spec.ts ×2, column-relation) also fail on the base branch. The extra failures on some matrix jobs differ between runs.
Not run: local PHPUnit and mutations; the mutation claims in the test finding come from reading the tests.

…hat cannot be used

A scheme without a menu leaves the application's menu alone; only an explicit empty menu clears it. Menu targets the importer cannot manage and widget targets whose rows it cannot read are skipped, an existing grid view is reused only when the importer can manage it and copied otherwise, and a later duplicate technical name gets a derived one. Grid views that are only a resource travel with the export too. Grid view entries are validated, and import errors that the service raises answer 400 or 500 after a rollback. The import wiring is covered by tests through overridable service getters.

Signed-off-by: Remko <remko@conduction.nl>
Assisted-by: ClaudeCode:claude-opus-5-5
… through the dialog

The preview rebuilds the scheme it posts from name, icon, description, tables and nodes only, so an import from the dialog dropped the menu and the grid views of the file, and a grid view the instance did not know yet failed the import with a 404. The dialog now adds both from the uploaded file; a file without a menu still leaves the current menu alone.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
Comment thread playwright/e2e/application.spec.ts Outdated
const contextItem = page.locator('[data-cy="navigationContextItem"]').filter({ hasText: 'importing application' }).first()
await contextItem.hover()
await contextItem.getByRole('button', { name: /Actions|Open menu/i }).first().click({ force: true })
await page.locator('[data-cy="navigationContextExportSchemeBtn"]').filter({ hasText: 'Import scheme' }).click({ force: true })

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.

🟡 Concern — The new dialog e2e test fails on stable34

Playwright (stable34) is red on 835fd42. Only one test fails: the one this commit adds (application.spec.ts:104). It fails on all 3 attempts with a timeout while waiting for input[type=file] in the import dialog. The page snapshot shows the application's Actions menu still open with "Import scheme" in it, so the dialog never opened. The other 103 stable34 tests pass. The same test passes on stable33, stable35 and master, and a2a1ac9 (which already has the merged base) was green on all four Playwright jobs.

Impact: CI stays red. The test meant to prove that the dialog keeps the menu does not run on one supported version.

Done when:

  • Playwright (stable34) is green on the head commit, with this test included

Comment thread lib/Controller/ContextController.php
Comment thread lib/Service/ContextService.php
continue;
}
$now = (new \DateTime())->format('Y-m-d H:i:s');
$localIds[$uuid] = $this->viewService()->importView(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.

🟢 Minor — Re-importing a scheme leaves the previous copy behind

When the importer cannot manage the existing grid view, a copy with a fresh uuid is created. The original uuid still belongs to the other user, so every later import of the same file creates a new copy. update() then replaces the application's nodes and menu with the new copy, and the earlier copy stays in the database. It belongs to no application and has no table to open it from. On one instance, user B importing user A's export twice leaves one copy behind each time. Consider reusing a copy that this application already holds, or say in the docblock that this is accepted.

Comment thread src/modules/modals/ImportContextScheme.vue Outdated

@rjzondervan rjzondervan left a comment

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.

Verdict: Approve (Thorough, round 2)

All 8 round-1 findings are resolved; this round adds 1 🟡 and 4 🟢, none blocking under Thorough, with 5 threads open. Before a merge: the PR's own new e2e test is red on Playwright (stable34) (not a required check).

Round Found Status
1 (1ee93397) 🔴 1 (menu wiped) · 🟡 5 (reuse, inaccessible targets, 500 without rollback, export misses grid views, tests) · 🟢 2 all resolved in a2a1ac96, 835fd429
2 (835fd429) — this review 🟡 1 (e2e red on stable34) · 🟢 4 (NotFoundError 500, two guards untested, leftover copies, dialog always replaces the menu) open
Finding Was Now Where
🔴 A scheme without menuItems wipes the application's menu 🔴 round 1 resolved in a2a1ac96 / 835fd429 thread
🟡 Reused grid view is not checked against the importer 🟡 round 1 resolved in a2a1ac96 thread
🟡 Inaccessible targets abort the import instead of being skipped 🟡 round 1 resolved in a2a1ac96 thread
🟡 Grid view import errors return 500 without rollback 🟡 round 1 resolved in a2a1ac96 thread
🟡 Grid views outside the menu are not exported 🟡 round 1 resolved in a2a1ac96 thread
🟡 Grid view import and wiring have no tests 🟡 round 1 resolved in a2a1ac96 thread
🟢 Duplicate technical names fail the whole import 🟢 round 1 resolved in a2a1ac96 thread
🟢 gridViews entries are not validated in ContextScheme 🟢 round 1 resolved in a2a1ac96 thread
🟡 The new dialog e2e test fails on stable34 new new — 🟡 comment
🟢 NotFoundError from the import still ends as a 500 without rollback new new — 🟢 comment
🟢 Two of the new guards can be broken without a test failing new new — 🟢 comment
🟢 Re-importing a foreign scheme leaves the previous copy behind new new — 🟢 comment
🟢 The dialog now always replaces the menu, whatever the preview choices new new — 🟢 comment
Details of this round
  • Scope: the two fix commits a2a1ac96 and 835fd429. The merges of stack/a2-application-overview (1241d15b) are the base branch's code and were not reviewed here.
  • Round-1 checks: each finding's done-when read against the head; the four round-1 mutations each now fail a named test (reuse check, grid views imported before the nodes, the menu passed to update(), de-duplication by uuid), and the controller's 400 path is tested.
  • CI: green except Playwright (stable34): 1 failed (the new application.spec.ts dialog test, all 3 attempts, dialog never opens), 103 passed; the same test passes on stable33, stable35 and master, and a2a1ac96 was green on all four Playwright jobs.
  • Not run: PHPUnit locally (no vendor/, no container); CI PHPUnit is green on all four databases.

Before the next round: this round's findings were mostly a missed sibling or caused by the fix (fix rules on: fix-induced 1 + missed-sibling 2 ≥ new 2), so the next fixes go through the class sweep first:

  • swap canManageNodeById and canReadRowsByElementId in resolveTargetId → a test must go red
  • set the controller default of $menuItems back to [] → a test must go red
  • grep -n NotFoundError lib/Controller/ContextController.php → a catch with rollBack() in importScheme

…l back a missing table

The import dialog gets a Menu switch: switched off, the scheme is posted without its menu and the application keeps the current one, and only the grid views that the chosen menu or nodes open are sent. A grid view the importer cannot manage is copied under a uuid derived from its own and the application, so importing the scheme into the application again reuses that copy instead of leaving the previous one behind. A table that is gone during the import now gives a 404 with a rollback instead of a 500. The e2e test opens the dialog with a regular click, as a forced click could land before the actions menu settled.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: Remko <remko@conduction.nl>
const importResponse = page.waitForResponse(r => r.url().includes('/scheme/import') && r.request().method() === 'POST')
await dialog.getByRole('button', { name: 'Import' }).click()
const response = await importResponse
await expect(dialog).toContainText('Import completed successfully.')

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.

🔴 Blocker — The scheme import returns a 500 in CI on two versions

Now that the dialog opens, the import request itself fails in CI some of the time. The cause is not known.

  • Playwright (stable34), test application.spec.ts:104 (menu on): attempts 1 and 2 fail at this line with "Could not import scheme due to unknown errors.". Attempt 3 passes. In the retry-1 trace, the preview request returns 200, and POST /ocs/v2.php/apps/tables/api/2/contexts/7/scheme/import returns 500 with the generic handleError body ("An unexpected error occurred…"). That body means ContextController::importScheme caught an OCP\DB\Exception, a MultipleObjectsReturnedException or an InternalError.
  • Playwright (stable33), test application.spec.ts:115 (menu off): attempt 1 fails the same way, and the retry passes.

The posted body has the expected shape: one node, one menu item and one grid view, all pointing at a fresh random uuid. That means importGridViews takes the $existing === null branch, so the new derived-uuid code is not on this path. The failure sits in the import path of the PR as a whole. 835fd42 never got this far on stable34, because the dialog did not open there. The server log of the failing attempt was not kept: the job's "Show logs" step fails with No such container: nextcloud-e2e-test-server_tables.

One possibility, not verified: the controller wraps the whole import in beginTransaction(), and update() runs atomicRetry inside it for SQLite's "database is locked". A lock conflict with a parallel worker inside the outer transaction cannot be retried there and would surface as this 500. Can you get the exception from the server log (locally, with several Playwright workers, or with a working log step)? If it is a lock error, the fix is in the transaction handling. If it is something else, it is a bug in the import.

Impact: an import through the dialog can fail with "unknown errors" and roll back. The two tests that cover the new dialog stay flaky, and Playwright (stable34) stays red.

Done when:

  • The exception behind the 500 is named from a server log, and it is either fixed or shown to be an e2e-environment lock with a fix for it.
  • On one head commit, application.spec.ts:104 and application.spec.ts:115 pass on the first attempt on all four Playwright jobs (no "flaky" entry for either).

nodes: this.importNodes ? this.previewData.nodes.to : this.previewData.nodes.from,
}
// Left out, the import keeps the current menu
if (this.menuItems && this.importMenu) {

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.

🟡 Concern — The PR body no longer matches what the PR does

This commit makes the menu optional and changes the dialog. Three statements in the PR body are now wrong:

  • "Menu on import: … The imported menu replaces the application's menu." Since this commit the menu is replaced only when menuItems is sent. The dialog leaves it out when the Menu switch is off (this line), and the controller then keeps the current menu (?array $menuItems = null).
  • "Import: … A grid view whose uuid already exists here is reused, not overwritten." The view is reused only when the importer can manage it. Otherwise it is copied under a uuid derived from the application (ContextService.php:1140-1150).
  • "### 🖼️ Screenshots — Backend only, no UI changes." The import dialog gets a Menu switch with a From/To list (ImportContextSchemePreview.vue:183-208), and the dialog now sends only the grid views in use.

The Tests bullet also leaves out the two Playwright tests.

Impact: reviewers and the changelog read a behaviour that the code does not have: an import that always replaces the menu, and no UI change.

Done when:

  • The PR body says when the menu is replaced (only when menuItems is sent; the dialog's Menu switch), when a grid view is copied instead of reused, and that the import dialog changed, with a screenshot of the switch.
  • gh pr view 27 -R ConductionNL/tables --json body -q .body | grep -c -e "The imported menu replaces the application's menu." -e "reused, not overwritten" -e "Backend only, no UI changes" returns 0.

$importUuid = Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), 'tables-context-' . $contextId . '/' . $uuid)->toRfc4122();
$existing = $this->viewMapper->findByUuid($importUuid);
}
if ($existing !== null && $this->canManageView($existing, $userId)) {

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.

🟡 Concern — The manage check on a reused copy can be removed without a test failing

Line 1144 decides whether a view found under the derived uuid is reused. The view is reused only when the importer can manage it, and otherwise a new copy is made. No test covers a derived-uuid view that the importer cannot manage:

  • testImportCopiesAGridViewTheImporterCannotManage: the lookup of the derived uuid returns null.
  • testImportingAgainIntoTheApplicationReusesItsEarlierCopy: the copy (id 66) can be managed.
  • testImportReusesAGridViewTheImporterCanManage: the original can be managed.

If you change line 1144 to if ($existing !== null) {, all three tests stay green. The import would then reuse a view that another member of the application created and that this importer cannot manage. One example: user B's copy, imported again by user C who manages the application through a share. update() then rejects that view as a node with a 403, and a menu item resolves to it through the $gridViewIds shortcut in resolveTargetId, which skips the permission check. AGENTS.md asks for every changed segment to be covered by a test.

Suggested fix: add a test where findByUuid returns a view for both the original uuid and the derived uuid, and canManageNodeById returns false for both. Assert that importView is called once and that the returned id is the new view's.

Done when:

  • With line 1144 mutated to if ($existing !== null) {, a named test in tests/unit/Service/ContextServiceTest.php (for example testImportCopiesAGridViewWhenItsEarlierCopyBelongsToSomeoneElse) goes red.

$this->assertSame(['view-uuid' => 66], $ids);
}

public function testACopyIntoAnotherApplicationGetsAnotherUuid(): void {

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.

🟢 Minor — testACopyIntoAnotherApplicationGetsAnotherUuid runs no production code

The test compares two results of the private test helper copyUuid(), which repeats the formula from ContextService.php:1141. It passes whatever importGridViews does, and it would only fail if Uuid::v5 itself stopped depending on its name. The property it names ("another application gets another uuid") is already pinned by testImportCopiesAGridViewTheImporterCannotManage, which checks the production uuid against copyUuid(7, …). Either remove it, or make it call importGridViews twice, with context 7 and context 8, and compare the uuid values passed to importView.

$existing = $this->viewMapper->findByUuid($uuid);
if ($existing !== null && !$this->canManageView($existing, $userId)) {
$importUuid = Uuid::v5(Uuid::fromString(Uuid::NAMESPACE_URL), 'tables-context-' . $contextId . '/' . $uuid)->toRfc4122();
$existing = $this->viewMapper->findByUuid($importUuid);

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.

🟢 Minor — Importing a newer scheme again keeps the old copy's grid

Before this commit, every import by someone who cannot manage the original made a fresh copy, with the grid from the file. Now the copy under the derived uuid is reused as it is. Suppose the owner changes the page, exports again, and the other user imports that export into the same application. The application then keeps the old layout and widgets, and nothing tells the user. This matches the existing rule for a view the importer can manage (also reused without update), so it may be intended. If so, the docblock at lines 1124-1128 could say that a reused view, original or copy, is not updated from the file. If not, the reused copy should take the file's grid.

* @param {object} scheme the scheme the preview built
* @return {Array<object>}
*/
usedGridViews(scheme) {

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.

🟢 Minor — gridViews is described as "the views the menu points at", but the dialog now also sends the ones the nodes open

usedGridViews sends the grid views that a menu item or a node opens. With the Menu switch off, it sends only the node ones. The backend handles that (importScheme resolves nodes through $gridViewIds), but the API text still says otherwise. These are the live copies:

  • lib/Controller/ContextController.php:366: $gridViews Views without a table that the menu points at
  • lib/Model/ContextScheme.php:131: Views without a table that the menu points at; …
  • openapi.json:13903 and src/types/openapi/openapi.ts:7697 (generated from the controller docblock)

Something like "Views without a table that the menu or the nodes point at" in the docblocks, then regenerating OpenAPI, would cover all four.

@rjzondervan rjzondervan left a comment

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.

Verdict: Request changes (Strict, round 3)

1 new blocker and 2 new concerns, with 8 threads open (1 🔴, 3 🟡, 4 🟢); 3 of the 5 round-2 findings are resolved. Before a merge: the 🔴 and the three 🟡 (blocking under Strict); Playwright (stable34) and cypress (mysql, master) are red but not required, and the cypress failure is not caused by this PR.

Round Found Status
1 (1ee93397) 🔴 1 (menu wiped) · 🟡 5 · 🟢 2 all resolved in a2a1ac96, 835fd429
2 (835fd429) 🟡 1 (e2e red on stable34) · 🟢 4 (NotFoundError 500, two guards untested, leftover copies, dialog always replaces the menu) 3 resolved in f8ade384; e2e and leftover copies open
3 (f8ade384) — this review 🔴 1 (import 500 in CI) · 🟡 2 (PR body, untested manage check) · 🟢 3 (helper-only test, old grid kept, gridViews description) open
Finding Was Now Where
🟢 NotFoundError from the import ends as a 500 without a rollback 🟢 round 2 resolved in f8ade384 thread
🟢 Two of the new guards can be broken without a test failing 🟢 round 2 resolved in f8ade384 thread
🟢 An import from the dialog always replaces the menu 🟢 round 2 resolved in f8ade384 thread
🟡 The new dialog e2e test fails on stable34 🟡 round 2 still open thread
🟢 Re-importing a scheme leaves the previous copy behind 🟢 round 2 still open (now for a second member only) thread
🔴 The scheme import returns a 500 in CI on two versions new new — 🔴 comment
🟡 The PR body no longer matches what the PR does new new — 🟡 comment
🟡 The manage check on a reused copy can be removed without a test failing new new — 🟡 comment
🟢 testACopyIntoAnotherApplicationGetsAnotherUuid runs no production code new new — 🟢 comment
🟢 Importing a newer scheme again keeps the old copy's grid new new — 🟢 comment
🟢 gridViews is described as "the views the menu points at" new new — 🟢 comment
Details of this round
  • Scope: the one commit since round 2, f8ade384 (7 files, +277/-56). Strict this round because the commit changes which grid views an importer may reuse or copy.
  • Commit-message claims checked: the Menu switch (no menuItems in the payload when off, controller default null, menu kept), reuse of the derived copy by the same importer, the 404 with rollback, and the regular click that now opens the dialog all hold.
  • Still open, what changed:
    • e2e on stable34: the dialog now opens (upload, preview 200 and the import POST are in the trace). The job is still red: context.spec.ts:115 "Share context with resources" fails 3× on the toast after editing a context, outside this delta and already flaky on the a1 base; and application.spec.ts:104 is flaky because of the 500 in the 🔴.
    • Leftover copies: the same importer now reuses its copy. A second member who manages the application through a share cannot manage the first member's copy, so freeUuid finds the derived uuid taken and the new copy gets a random v7 uuid: each of their imports leaves the previous copy behind (unless they own the application).
  • CI on f8ade384: cypress (mysql, master) fails in every spec's beforeEach on Cannot read properties of undefined (reading 'forEach') from apps/viewer/js/viewer-main.mjs (the Viewer app on server master), the same error as on main, so not this PR. Playwright (stable34): see the 🔴 and the e2e row above. The failing import attempts' server log was not kept (No such container: nextcloud-e2e-test-server_tables).
  • Gates: local run, 49 of 50 applicable gates ran — not run: composer-audit. The failing set is the same at 835fd42 and f8ade38, so none comes from this commit; the remaining ones are on the base or not applicable to a nextcloud/tables fork.
  • Not run: PHPUnit and the e2e suite locally.

Before the next round: this round's findings were mostly fix-induced or a missed copy (fix rules on: fix-induced 3 + missed-sibling 0 ≥ new 1; copy-missed 2) — the next fixes go through the class sweep and the pre-push check first:

  • git grep -n 'that the menu points at' → no hit left (4 now)
  • gh pr view 27 -R ConductionNL/tables --json body -q .body | grep -c -e "The imported menu replaces the application's menu." -e "reused, not overwritten" -e "Backend only, no UI changes" → 0 (3 now)
  • change ContextService.php:1144 to if ($existing !== null) { → a named test in ContextServiceTest.php must go red

@rubenvdlinde

Copy link
Copy Markdown
Collaborator

Superseded by #41 (rebuilt on application pages, see #13).

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.

3 participants