From 2626b4e152ddaee3cc27da69921720cf058dac1b Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 4 Nov 2026 09:06:37 +0100 Subject: [PATCH 1/2] feat(contexts): carry pages and their widgets through scheme export and import A scheme now names the table or view of every widget source by targetType and targetUuid. Importing it with pages updates the start page and every grid page with the same technical name, adds the others, and replaces the widgets of each grid page. A widget is checked as the API would save it, and its table or view must be a node of the application once the nodes of the scheme are imported; a widget never adds a node. Skipped pages and widgets get a reason, in the log and in the preview, and the menu is read after the pages so it can open a page the same import adds. The import screen has a Pages switch next to the Menu switch. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- .../ImportContextSchemePreview.cy.js | 47 ++- lib/Controller/ContextController.php | 5 +- lib/Service/ContextService.php | 391 ++++++++++++++++-- lib/Service/MenuService.php | 12 +- .../modals/ImportContextSchemePreview.vue | 57 +++ .../Service/ContextPagesRoundTripTest.php | 167 +++++++- tests/unit/Service/ContextServiceTest.php | 80 +++- 7 files changed, 714 insertions(+), 45 deletions(-) diff --git a/cypress/component/ImportContextSchemePreview.cy.js b/cypress/component/ImportContextSchemePreview.cy.js index d834d00442..9e3409abc8 100644 --- a/cypress/component/ImportContextSchemePreview.cy.js +++ b/cypress/component/ImportContextSchemePreview.cy.js @@ -6,6 +6,8 @@ import ImportContextSchemePreview from '../../src/modules/modals/ImportContextSc const MENU_SWITCH = '[data-cy="importSchemeMenu"]' const SKIPPED = '[data-cy="importSchemeMenuSkipped"]' +const PAGES_SWITCH = '[data-cy="importSchemePages"]' +const PAGES_SKIPPED = '[data-cy="importSchemePagesSkipped"]' const schemeMenu = [ { parent: null, title: 'Reports', displayTitle: 'Reports', icon: null, description: null, target: null }, @@ -40,22 +42,64 @@ describe('Import application scheme preview', () => { cy.get(SKIPPED).should('contain', 'Missing table') }) + it('sends the pages of the scheme with the import', () => { + mountPreview(undefined, { from: [{ technicalName: 'dashboard', title: 'Old start' }], to: schemePages, skipped: [] }) + .then(({ wrapper }) => { + cy.get(PAGES_SWITCH).should('exist') + cy.contains('Old start').should('exist') + cy.contains('Overview').should('exist') + cy.wrap(null).then(() => { + expect(lastPayload(wrapper).pages).to.deep.equal(schemePages) + }) + }) + }) + + it('leaves the pages out when the switch is turned off', () => { + mountPreview(undefined, { from: [], to: schemePages, skipped: [] }) + .then(({ wrapper }) => { + cy.get(PAGES_SWITCH).click({ force: true }) + cy.wrap(null).then(() => { + expect(lastPayload(wrapper)).to.not.have.property('pages') + }) + }) + }) + + it('lists the pages and widgets the import would skip', () => { + mountPreview(undefined, { + from: [], + to: [], + skipped: [ + { pageIndex: 0, page: 'Overview', widgetIndex: 2, widget: 'Elsewhere', reason: 'its table is not here or cannot be managed by the user' }, + { pageIndex: 1, page: 'Second start', widgetIndex: null, widget: null, reason: 'an application has one start page' }, + ], + }) + + cy.get(PAGES_SKIPPED).should('contain', 'Overview: Elsewhere').and('contain', 'Second start') + }) + it('has no menu section for a preview without a menu', () => { mountPreview(undefined) .then(({ wrapper }) => { cy.get(MENU_SWITCH).should('not.exist') + cy.get(PAGES_SWITCH).should('not.exist') cy.wrap(null).then(() => { expect(lastPayload(wrapper)).to.not.have.property('menu') + expect(lastPayload(wrapper)).to.not.have.property('pages') }) }) }) }) +const schemePages = [ + { page_type: 'grid', technical_name: 'overview', title: 'Overview', widgets: [{ type: 'data', title: 'Tasks', source: { targetType: 'table', targetUuid: 'table-uuid' } }] }, +] + /** * @param {object|undefined} menu the menu part of the preview response + * @param {object|undefined} pages the pages part of the preview response * @return {Cypress.Chainable} the mount result */ -function mountPreview(menu) { +function mountPreview(menu, pages = undefined) { return cy.mount(ImportContextSchemePreview, { props: { previewData: { @@ -66,6 +110,7 @@ function mountPreview(menu) { addTables: {}, modifyTables: {}, ...(menu === undefined ? {} : { menu }), + ...(pages === undefined ? {} : { pages }), }, }, }) diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index e7955ce01e..d1c6f505ba 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -360,6 +360,7 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR * @param list $nodes Ordered meta data of the related nodes * @psalm-param array{addTables: list, modifyTables: list} $tables Tables to be added or modified * @param list>|null $menu Menu of the scheme, replacing the menu of the context: pages by technical name, tables and views by uuid, the group of an item by its position in the list; items that cannot be used here are skipped. Left out, the menu stays as it is + * @param list>|null $pages Pages of the scheme: the start page updates the start page, a grid page the page with its technical name or it is added, and its widgets replace the widgets of that page; a widget names its table or view by `targetType` and `targetUuid` in its `source`. Pages and widgets that cannot be used here are skipped. Left out, the pages stay as they are * * @return DataResponse, array{}>|DataResponse * @@ -370,10 +371,10 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] - public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $menu = null): DataResponse { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $menu = null, ?array $pages = null): DataResponse { try { $this->db->beginTransaction(); - $context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId, $menu); + $context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId, $menu, $pages); $this->db->commit(); return new DataResponse($context->jsonSerialize()); } catch (\InvalidArgumentException $e) { diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index 12ffa5e96a..56b3d771d1 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -23,6 +23,7 @@ use OCA\Tables\Db\PageMapper; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; +use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; @@ -640,21 +641,34 @@ private function importablePages(array $pages): array { $importable = []; $hasStartPage = false; foreach ($pages as $key => $pageArr) { - $type = is_array($pageArr) ? ($pageArr['page_type'] ?? $pageArr['pageType'] ?? null) : null; - if ($type !== Page::TYPE_STARTPAGE && $type !== Page::TYPE_GRID) { - $this->logger->warning('Skipping an imported page: its type is not ' . Page::TYPE_STARTPAGE . ' or ' . Page::TYPE_GRID . '.'); + $reason = self::pageSkipReason($pageArr, $hasStartPage); + if ($reason !== null) { + $this->logger->warning('Skipping an imported page: ' . $reason . '.'); continue; } - if ($type === Page::TYPE_STARTPAGE && $hasStartPage) { - $this->logger->warning('Skipping an imported page: an application has one start page.'); - continue; - } - $hasStartPage = $hasStartPage || $type === Page::TYPE_STARTPAGE; $importable[$key] = $pageArr; } return $importable; } + /** + * Why an exported page cannot be imported, or null when it can: it must be a start + * page or a grid page, and an application has one start page. + * + * @param bool $hasStartPage whether an earlier page of the import is the start page; set when this one is + */ + private static function pageSkipReason(mixed $pageArr, bool &$hasStartPage): ?string { + $type = is_array($pageArr) ? ($pageArr['page_type'] ?? $pageArr['pageType'] ?? null) : null; + if ($type !== Page::TYPE_STARTPAGE && $type !== Page::TYPE_GRID) { + return 'its type is not ' . Page::TYPE_STARTPAGE . ' or ' . Page::TYPE_GRID; + } + if ($type === Page::TYPE_STARTPAGE && $hasStartPage) { + return 'an application has one start page'; + } + $hasStartPage = $hasStartPage || $type === Page::TYPE_STARTPAGE; + return null; + } + /** * Imports an application of a user's data that holds the given table. Its menu keeps * the links, the items for its pages and the items for the table, found by the uuid @@ -786,14 +800,23 @@ private function logSkippedMenuItems(array $skipped): void { } /** - * The node relation of an application for an exported table or view, found by uuid. + * The table or view (by kind, `table` or `view`) with a uuid, or null when it is not here. + * + * @throws Exception */ - private function nodeRelIdByUuid(Context $context, string $kind, string $uuid): ?int { + private function findNodeByUuid(string $kind, string $uuid): Table|View|null { try { - $node = $kind === 'table' ? $this->tableMapper->findByUuid($uuid) : $this->viewMapper->findByUuid($uuid); + return $kind === 'table' ? $this->tableMapper->findByUuid($uuid) : $this->viewMapper->findByUuid($uuid); } catch (DoesNotExistException) { return null; } + } + + /** + * The node relation of an application for an exported table or view, found by uuid. + */ + private function nodeRelIdByUuid(Context $context, string $kind, string $uuid): ?int { + $node = $this->findNodeByUuid($kind, $uuid); if ($node === null) { return null; } @@ -860,16 +883,9 @@ public function getPagesWithWidgets(Context $context): array { * @throws Exception */ private function importWidget(int $pageId, int $index, mixed $widgetArr, array $nodeRelMap): void { - if (!is_array($widgetArr) || !is_string($widgetArr['type'] ?? null)) { - return; - } try { - $hasSource = $this->gridWidgetTypes->get($widgetArr['type'])['hasSource']; - $settings = $this->gridWidgetTypes->sanitizeSettings($widgetArr['type'], $widgetArr['settings'] ?? null); - $title = $this->gridWidgetTypes->validateTitle($index, $widgetArr['title'] ?? null); - $description = $this->gridWidgetTypes->validateDescription($index, $widgetArr['description'] ?? null); - $position = $this->gridWidgetTypes->validatePosition($index, $widgetArr); - $source = $widgetArr['source'] ?? null; + $widget = $this->readImportedWidget($index, $widgetArr); + $source = $widget['source']; $oldNodeRelId = is_array($source) ? ($source['nodeRelId'] ?? null) : null; if (($source !== null && !is_array($source)) || ($oldNodeRelId !== null && !is_int($oldNodeRelId))) { throw new BadRequestError('The source of widget ' . $index . ' must be an object with a numeric nodeRelId.'); @@ -878,19 +894,41 @@ private function importWidget(int $pageId, int $index, mixed $widgetArr, array $ $this->logger->warning('Skipping an imported widget: ' . $e->getMessage(), ['exception' => $e]); return; } - $newNodeRel = $hasSource && $oldNodeRelId !== null ? ($nodeRelMap[$oldNodeRelId] ?? null) : null; + $newNodeRel = $widget['hasSource'] && $oldNodeRelId !== null ? ($nodeRelMap[$oldNodeRelId] ?? null) : null; $this->widgetService->insert( $pageId, - $widgetArr['type'], - $title, - $position, - $settings, + $widget['type'], + $widget['title'], + $widget['position'], + $widget['settings'], $newNodeRel?->getId(), - $this->importedIcon($widgetArr['icon'] ?? null), - $description, + $widget['icon'], + $widget['description'], ); } + /** + * The fields of an exported widget as the API would save them, and its source as exported. + * + * @return array{type: string, hasSource: bool, title: ?string, icon: ?string, description: ?string, position: array{gridX: int, gridY: int, gridWidth: int, gridHeight: int}, settings: array, source: mixed} + * @throws BadRequestError when the widget would not be saved through the API + */ + private function readImportedWidget(int $index, mixed $widgetArr): array { + if (!is_array($widgetArr) || !is_string($widgetArr['type'] ?? null)) { + throw new BadRequestError('Widget ' . $index . ' must be an object with a type.'); + } + return [ + 'type' => $widgetArr['type'], + 'hasSource' => $this->gridWidgetTypes->get($widgetArr['type'])['hasSource'], + 'title' => $this->gridWidgetTypes->validateTitle($index, $widgetArr['title'] ?? null), + 'icon' => $this->importedIcon($widgetArr['icon'] ?? null), + 'description' => $this->gridWidgetTypes->validateDescription($index, $widgetArr['description'] ?? null), + 'position' => $this->gridWidgetTypes->validatePosition($index, $widgetArr), + 'settings' => $this->gridWidgetTypes->sanitizeSettings($widgetArr['type'], $widgetArr['settings'] ?? null), + 'source' => $widgetArr['source'] ?? null, + ]; + } + /** * An exported id usable as an array key, or null for a value of another type. */ @@ -969,12 +1007,40 @@ public function getScheme(int $contextId, string $userId): ContextScheme { $context->getIcon(), $context->getDescription(), array_values($nodes), - $this->getPagesWithWidgets($context), + self::exportPages($this->getPagesWithWidgets($context), $nodes), array_values($tables), $this->menuService->export($context), ); } + /** + * The pages of an application as a scheme carries them: the source of a widget also + * names its table or view by `targetType` and `targetUuid`, which an import resolves. + * + * @param array> $pages the pages with their widgets + * @param array> $nodes the nodes of the scheme, with their uuid + * @return array> + */ + private static function exportPages(array $pages, array $nodes): array { + $targets = []; + foreach ($nodes as $node) { + if (isset($node['node_uuid'])) { + $targets[$node['id']] = [ + 'targetType' => $node['node_type'] === Application::NODE_TYPE_TABLE ? 'table' : 'view', + 'targetUuid' => $node['node_uuid'], + ]; + } + } + foreach ($pages as $pageId => $page) { + foreach ($page['widgets'] ?? [] as $index => $widget) { + if (is_array($widget['source'] ?? null)) { + $pages[$pageId]['widgets'][$index]['source'] += $targets[$widget['source']['nodeRelId']] ?? []; + } + } + } + return $pages; + } + /** * @param int $contextId * @param array $updateScheme @@ -1041,6 +1107,23 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string } } + $schemePageIds = []; + if (is_array($updateScheme['pages'] ?? null)) { + $addedUuids = $this->addedSchemeUuids($updateSchemeTables, array_keys($tablesMap)); + $schemeNodes = $this->previewSchemeNodes($context, is_array($updateScheme['nodes'] ?? null) ? $updateScheme['nodes'] : [], $userId, $addedUuids); + ['pages' => $pages, 'skipped' => $skipped] = $this->resolveSchemePages($context, array_values($updateScheme['pages']), $schemeNodes); + $changes['pages'] = [ + 'from' => array_values(array_map(static fn (array $page): array => ['technicalName' => $page['technical_name'] ?? null, 'title' => $page['title'] ?? null], $context->getPages() ?? [])), + 'to' => array_column($pages, 'entry'), + 'skipped' => $skipped, + ]; + foreach ($pages as $page) { + if ($page['name'] !== null) { + // a page the import adds is not here yet; any id passes the check + $schemePageIds[$page['name']] = $page['pageId'] ?? 0; + } + } + } if (is_array($updateScheme['menu'] ?? null)) { $schemeUuids = []; foreach ($changes['nodes']['to'] ?? [] as $node) { @@ -1051,6 +1134,7 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string $context, $updateScheme['menu'], static fn (string $kind, string $uuid): ?int => isset($schemeUuids[$kind][$uuid]) ? 0 : null, + $schemePageIds, ); $changes['menu'] = [ 'from' => $currentScheme->getMenu(), @@ -1062,6 +1146,240 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string return $changes; } + /** + * The nodes of a scheme that an import makes nodes of the application, by node type and + * uuid as {@see self::resolveSchemePages()} takes them: a node the application has, one + * the import adds, and one that is here and manageable by the user. + * + * @param array $schemeNodes + * @param array $addedUuids uuids of the tables and views the import adds + * @return array + * @throws Exception + */ + private function previewSchemeNodes(Context $context, array $schemeNodes, string $userId, array $addedUuids): array { + $current = []; + foreach ($context->getNodes() ?? [] as $node) { + $current[sprintf('t%di%d', $node['node_type'], $node['node_id'])] = true; + } + $nodes = []; + foreach ($schemeNodes as $node) { + $nodeType = is_array($node) ? ($node['node_type'] ?? null) : null; + $uuid = is_array($node) ? ($node['node_uuid'] ?? null) : null; + if (($nodeType !== Application::NODE_TYPE_TABLE && $nodeType !== Application::NODE_TYPE_VIEW) || !is_string($uuid)) { + continue; + } + $found = $this->findNodeByUuid($nodeType === Application::NODE_TYPE_TABLE ? 'table' : 'view', $uuid); + if (isset($addedUuids[$uuid]) + || ($found !== null && (isset($current[sprintf('t%di%d', $nodeType, $found->getId())]) || $this->permissionsService->canManageNodeById($nodeType, $found->getId(), $userId)))) { + $nodes[$nodeType . ':' . $uuid] = $found?->getId(); + } + } + return $nodes; + } + + /** + * The uuids of the tables and views of a scheme that are not here, which an import adds. + * + * @param array $schemeTables + * @param list $localTableUuids + * @return array + * @throws Exception + */ + private function addedSchemeUuids(array $schemeTables, array $localTableUuids): array { + $localTableUuids = array_flip($localTableUuids); + $addedUuids = []; + foreach ($schemeTables as $table) { + if (!is_array($table)) { + continue; + } + if (is_string($table['uuid'] ?? null) && !isset($localTableUuids[$table['uuid']])) { + $addedUuids[$table['uuid']] = true; + } + foreach (is_array($table['views'] ?? null) ? $table['views'] : [] as $view) { + if (is_array($view) && is_string($view['uuid'] ?? null) && $this->findNodeByUuid('view', $view['uuid']) === null) { + $addedUuids[$view['uuid']] = true; + } + } + } + return $addedUuids; + } + + /** + * Checks the pages of a scheme against an application of this instance. Pages follow + * {@see self::pageSkipReason()}: the start page of the scheme is the start page of the + * application, and a grid page is the page with its technical name, or a new one. A + * widget is checked as the API would save it, and the table or view it shows, named by + * uuid, must be a node of the application once the nodes of the scheme are imported; a + * widget never adds a node. A widget that does not pass, that sits on the start page or + * that is past {@see GridWidgetTypes::MAX_WIDGETS_PER_PAGE} is skipped with the reason, + * and so is a page that cannot be imported. + * + * Each page comes with the name the scheme gives it, the id of the page it updates or + * the technical name a new page gets, and its entry with the kept widgets only. + * + * @param list $schemePages + * @param array $nodes the nodes of the application after the import, by node type and uuid (`:`), with their id when it is known + * @return array{pages: list, name: ?string, pageId: ?int, technicalName: ?string, isGrid: bool, title: ?string, icon: ?string, description: ?string, widgets: list, source: mixed}, source: ?array{0: int, 1: ?int}}>}>, skipped: list} + */ + private function resolveSchemePages(Context $context, array $schemePages, array $nodes): array { + $startPageId = null; + $pageIdsByName = []; + foreach ($context->getPages() ?? [] as $pageId => $page) { + if (($page['page_type'] ?? null) === Page::TYPE_STARTPAGE) { + $startPageId ??= (int)$pageId; + } + if (is_string($page['technical_name'] ?? null)) { + $pageIdsByName[$page['technical_name']] = (int)$pageId; + } + } + + $pages = []; + $skipped = []; + $usedNames = []; + $hasStartPage = false; + foreach ($schemePages as $pageIndex => $pageArr) { + $skip = static function (string $reason, ?int $widgetIndex = null, mixed $widget = null) use (&$skipped, $pageIndex, $pageArr): void { + $skipped[] = [ + 'pageIndex' => $pageIndex, + 'page' => is_array($pageArr) ? ($pageArr['title'] ?? $pageArr['technical_name'] ?? $pageArr['technicalName'] ?? null) : null, + 'widgetIndex' => $widgetIndex, + 'widget' => is_array($widget) ? ($widget['title'] ?? null) : null, + 'reason' => $reason, + ]; + }; + $reason = self::pageSkipReason($pageArr, $hasStartPage); + if ($reason !== null || !is_array($pageArr)) { + $skip($reason ?? 'it is not an object'); + continue; + } + $name = $pageArr['technical_name'] ?? $pageArr['technicalName'] ?? null; + $name = is_string($name) && TechnicalNameValidator::isValid($name) && !isset($usedNames[$name]) ? $name : null; + $pageId = null; + $technicalName = null; + if (($pageArr['page_type'] ?? $pageArr['pageType'] ?? null) === Page::TYPE_STARTPAGE) { + if ($startPageId === null) { + $skip('the application has no start page'); + continue; + } + $pageId = $startPageId; + } elseif ($name !== null && isset($pageIdsByName[$name])) { + $pageId = $pageIdsByName[$name]; + } elseif ($name !== null && !Page::isDefaultName($name)) { + $technicalName = $name; + } + if ($name !== null) { + $usedNames[$name] = true; + } + $isGrid = $pageId === null || $pageId !== $startPageId; + + $widgets = []; + $keptEntries = []; + foreach (is_array($pageArr['widgets'] ?? null) ? array_values($pageArr['widgets']) : [] as $widgetIndex => $widgetArr) { + if (!$isGrid) { + $skip('only grid pages hold widgets', $widgetIndex, $widgetArr); + continue; + } + if ($widgetIndex >= GridWidgetTypes::MAX_WIDGETS_PER_PAGE) { + $skip('a page holds at most ' . GridWidgetTypes::MAX_WIDGETS_PER_PAGE . ' widgets', $widgetIndex, $widgetArr); + continue; + } + try { + $widget = $this->readImportedWidget($widgetIndex, $widgetArr); + } catch (BadRequestError $e) { + $skip($e->getMessage(), $widgetIndex, $widgetArr); + continue; + } + $source = $widget['hasSource'] ? $widget['source'] : null; + $node = null; + if ($source !== null) { + $targetType = is_array($source) ? ($source['targetType'] ?? null) : null; + $targetUuid = is_array($source) ? ($source['targetUuid'] ?? null) : null; + if (($targetType !== 'table' && $targetType !== 'view') || !is_string($targetUuid)) { + $skip('its source names no table or view by uuid', $widgetIndex, $widgetArr); + continue; + } + $nodeType = $targetType === 'table' ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW; + if (!array_key_exists($nodeType . ':' . $targetUuid, $nodes)) { + $skip('its ' . $targetType . ' is not one of the resources of the application', $widgetIndex, $widgetArr); + continue; + } + $node = [$nodeType, $nodes[$nodeType . ':' . $targetUuid]]; + } + $widgets[] = ['widget' => $widget, 'source' => $node]; + $keptEntries[] = $widgetArr; + } + + $pages[] = [ + 'entry' => ['widgets' => $keptEntries] + $pageArr, + 'name' => $name, + 'pageId' => $pageId, + 'technicalName' => $technicalName, + 'isGrid' => $isGrid, + 'title' => $this->importedText($pageArr['title'] ?? null, Page::TITLE_MAX_LENGTH, 'page title'), + 'icon' => $this->importedIcon($pageArr['icon'] ?? null), + 'description' => $this->importedText($pageArr['description'] ?? null, Page::DESCRIPTION_MAX_LENGTH, 'page description'), + 'widgets' => $widgets, + ]; + } + return ['pages' => $pages, 'skipped' => $skipped]; + } + + /** + * Saves the pages of a scheme as {@see self::resolveSchemePages()} resolved them. A page + * takes the title, icon and description of the scheme, and the widgets of a grid page + * replace its widgets. A new page without a free technical name gets the default name. + * + * @param list, source: mixed}, source: ?array{0: int, 1: ?int}}>, ...}> $pages + * @return array ids of the pages by the technical name the scheme gives them + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + * @throws NotFoundError + */ + private function importSchemePages(int $contextId, array $pages, string $userId): array { + $relIds = []; + foreach ($this->contextMapper->findById($contextId, $userId)->getNodes() ?? [] as $relId => $node) { + $relIds[sprintf('t%di%d', $node['node_type'], $node['node_id'])] = (int)$relId; + } + $pageIds = []; + foreach ($pages as $resolved) { + $page = $resolved['pageId'] !== null ? $this->pageMapper->findById($resolved['pageId']) : new Page(); + $page->setTitle($resolved['title']); + $page->setIcon($resolved['icon']); + $page->setDescription($resolved['description']); + if ($resolved['pageId'] !== null) { + if ($page->getUpdatedFields() !== []) { + $this->pageMapper->update($page); + } + } else { + $page->setContextId($contextId); + $page->setPageType(Page::TYPE_GRID); + if ($resolved['technicalName'] === null) { + $page = $this->pageMapper->insertWithDefaultName($page); + } else { + $page->setTechnicalName($resolved['technicalName']); + $page = $this->pageMapper->insert($page); + } + } + if ($resolved['name'] !== null) { + $pageIds[$resolved['name']] = $page->getId(); + } + if ($resolved['isGrid']) { + $this->widgetService->syncPage($page->getId(), array_map(static fn (array $kept): array => [ + 'id' => null, + 'type' => $kept['widget']['type'], + 'title' => $kept['widget']['title'], + 'icon' => $kept['widget']['icon'], + 'description' => $kept['widget']['description'], + 'position' => $kept['widget']['position'], + 'settings' => $kept['widget']['settings'], + 'nodeRelId' => $kept['source'] !== null ? ($relIds[sprintf('t%di%d', $kept['source'][0], $kept['source'][1])] ?? null) : null, + ], $resolved['widgets'])); + } + } + return $pageIds; + } + /** * @param int $contextId * @param string $name @@ -1070,6 +1388,7 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string * @param array $nodes * @param array $tables * @param string $userId + * @param list|null $pages the pages of the scheme, see {@see self::resolveSchemePages()}; null leaves the pages alone * * @return Context * @@ -1083,7 +1402,7 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string * @throws NotFoundExceptionInterface * @throws PermissionError */ - public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, string $userId, ?array $menu = null): Context { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, string $userId, ?array $menu = null, ?array $pages = null): Context { // Validate the structure of the columns and views arrays if (!isset($tables['addTables']) || !is_array($tables['addTables']) || !isset($tables['modifyTables']) || !is_array($tables['modifyTables'])) { @@ -1125,6 +1444,7 @@ public function importScheme(int $contextId, string $name, string $iconName, str // Resolve node ids for the new tables and views $resolvedNodes = []; + $nodeIds = []; foreach ($nodes as $node) { if ($node['node_type'] === Application::NODE_TYPE_TABLE) { $table = $this->tableMapper->findByUuid($node['node_uuid']); @@ -1133,6 +1453,7 @@ public function importScheme(int $contextId, string $name, string $iconName, str 'type' => $node['node_type'], 'permissions' => $node['permissions'], ]; + $nodeIds[$node['node_type'] . ':' . $node['node_uuid']] = $table->getId(); } elseif ($node['node_type'] === Application::NODE_TYPE_VIEW) { $view = $this->viewMapper->findByUuid($node['node_uuid']); $resolvedNodes[] = [ @@ -1140,19 +1461,29 @@ public function importScheme(int $contextId, string $name, string $iconName, str 'type' => $node['node_type'], 'permissions' => $node['permissions'], ]; + $nodeIds[$node['node_type'] . ':' . $node['node_uuid']] = $view->getId(); } } + $schemePages = null; + if ($pages !== null) { + ['pages' => $schemePages, 'skipped' => $skipped] = $this->resolveSchemePages($this->contextMapper->findById($contextId, $userId), array_values($pages), $nodeIds); + foreach ($skipped as $skip) { + $this->logger->warning('Skipping ' . ($skip['widgetIndex'] === null ? '' : 'widget ' . $skip['widgetIndex'] . ' of ') . 'page ' . $skip['pageIndex'] . ' of an imported scheme: ' . $skip['reason']); + } + } $context = $this->update($contextId, $userId, $name, $iconName, $description, $resolvedNodes); + $schemePageIds = $schemePages !== null ? $this->importSchemePages($contextId, $schemePages, $userId) : []; if ($menu !== null) { $withNodes = $this->contextMapper->findById($contextId, $userId); $this->logSkippedMenuItems($this->menuService->import( $withNodes, $menu, fn (string $kind, string $uuid): ?int => $this->nodeRelIdByUuid($withNodes, $kind, $uuid), + $schemePageIds, )); } - return $context; + return $schemePages !== null ? $this->contextMapper->findById($contextId, $userId) : $context; } } diff --git a/lib/Service/MenuService.php b/lib/Service/MenuService.php index d3561dd9a2..579933b389 100644 --- a/lib/Service/MenuService.php +++ b/lib/Service/MenuService.php @@ -284,11 +284,12 @@ public function export(Context $context): array { * * @param list $exported * @param \Closure(string, string): ?int $nodeRelIdOf + * @param array $schemePageIds see {@see self::readExported()} * @return list * @throws Exception */ - public function import(Context $context, array $exported, \Closure $nodeRelIdOf): array { - ['items' => $items, 'skipped' => $skipped] = $this->readExported($context, $exported, $nodeRelIdOf); + public function import(Context $context, array $exported, \Closure $nodeRelIdOf, array $schemePageIds = []): array { + ['items' => $items, 'skipped' => $skipped] = $this->readExported($context, $exported, $nodeRelIdOf, $schemePageIds); $this->atomic(function () use ($context, $items): void { $this->menuItemMapper->deleteByContextId($context->getId()); @@ -314,12 +315,13 @@ public function import(Context $context, array $exported, \Closure $nodeRelIdOf) * * @param list $exported * @param \Closure(string, string): ?int $nodeRelIdOf + * @param array $schemePageIds ids of the pages of a scheme by the technical name it gives them, ahead of the pages of the application * @return array{items: list, index: int, parent: ?int}>, skipped: list} */ - public function readExported(Context $context, array $exported, \Closure $nodeRelIdOf): array { - $pageIds = []; + public function readExported(Context $context, array $exported, \Closure $nodeRelIdOf, array $schemePageIds = []): array { + $pageIds = $schemePageIds; foreach ($context->getPages() ?? [] as $pageId => $page) { - $pageIds[(string)$page['technical_name']] = (int)$pageId; + $pageIds[(string)$page['technical_name']] ??= (int)$pageId; } $items = []; diff --git a/src/modules/modals/ImportContextSchemePreview.vue b/src/modules/modals/ImportContextSchemePreview.vue index 1706971114..fedb67e8e7 100644 --- a/src/modules/modals/ImportContextSchemePreview.vue +++ b/src/modules/modals/ImportContextSchemePreview.vue @@ -214,6 +214,41 @@ + + +
+
+ + {{ t('tables', 'Pages') }} + +
+
    +
  • + {{ t('tables', 'From') }}: +
  • +
  • + {{ page.title || page.technicalName }} +
  • +
+
    +
  • + {{ t('tables', 'To') }}: +
  • +
  • + {{ page.title || page.technical_name || page.technicalName }} +
  • +
+
+
    +
  • + {{ t('tables', 'Skipped, not available here') }}: +
  • +
  • + {{ skippedPageLabel(item) }} +
  • +
+
+
@@ -272,6 +307,7 @@ export default { importDescription: true, importNodes: true, importMenu: true, + importPages: true, addTablesConfig: [], removeColumnsConfig: [], modifyTablesConfig: [], @@ -314,6 +350,11 @@ export default { this.emitPayload() }, }, + importPages: { + handler() { + this.emitPayload() + }, + }, addTablesConfig: { handler() { this.emitPayload() @@ -351,6 +392,18 @@ export default { })) }, + /** + * @param {object} item a skipped page, or a skipped widget of a page + * @return {string} the page, and the widget when it is one + */ + skippedPageLabel(item) { + const page = item.page || t('tables', 'Untitled page') + if (item.widgetIndex === null) { + return page + } + return t('tables', '{page}: {widget}', { page, widget: item.widget || t('tables', 'Untitled widget') }, undefined, { escape: false }) + }, + getShortDescription(description) { if (!description) { return '' @@ -378,6 +431,10 @@ export default { if (this.importMenu && this.previewData.menu) { payload.menu = this.previewData.menu.to } + // without pages the import leaves the pages of the application as they are + if (this.importPages && this.previewData.pages) { + payload.pages = this.previewData.pages.to + } this.$emit('update', payload) }, diff --git a/tests/unit/Service/ContextPagesRoundTripTest.php b/tests/unit/Service/ContextPagesRoundTripTest.php index 6548611618..3e6bafc6dc 100644 --- a/tests/unit/Service/ContextPagesRoundTripTest.php +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -20,23 +20,29 @@ use OCA\Tables\Db\PageMapper; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; +use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; use OCA\Tables\Db\WidgetMapper; use OCA\Tables\Db\WidgetSourceMapper; use OCA\Tables\Helper\GroupHelper; use OCA\Tables\Helper\UserHelper; +use OCA\Tables\Model\TableScheme; use OCA\Tables\Service\ContextService; use OCA\Tables\Service\GridWidgetTypes; use OCA\Tables\Service\MenuService; use OCA\Tables\Service\PermissionsService; use OCA\Tables\Service\ShareService; use OCA\Tables\Service\Support\AuditLogServiceInterface; +use OCA\Tables\Service\TableService; +use OCA\Tables\Service\ViewService; use OCA\Tables\Service\WidgetService; use OCA\Tables\Tests\Unit\Database\DatabaseTestCase; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\IL10N; use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; /** @@ -51,6 +57,10 @@ class ContextPagesRoundTripTest extends DatabaseTestCase { private ContextNodeRelationMapper $nodeRelMapper; private PageMapper $pageMapper; private WidgetService $widgetService; + private MenuService $menuService; + private PermissionsService&MockObject $permissionsService; + private TableMapper&MockObject $tableMapper; + private ViewMapper&MockObject $viewMapper; /** @var int[] */ private array $contextIds = []; @@ -68,13 +78,18 @@ protected function setUp(): void { new WidgetSourceMapper($this->connectionAdapter), $this->connectionAdapter, ); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->tableMapper = $this->createMock(TableMapper::class); + $this->viewMapper = $this->createMock(ViewMapper::class); + $menuItemMapper = new MenuItemMapper($this->connectionAdapter); + $this->menuService = new MenuService($this->contextMapper, $menuItemMapper, $this->permissionsService, $this->tableMapper, $this->viewMapper, $this->connectionAdapter); $this->service = new ContextService( $this->contextMapper, $this->nodeRelMapper, $this->pageMapper, new PageContentMapper($this->connectionAdapter), $this->createMock(LoggerInterface::class), - $this->createMock(PermissionsService::class), + $this->permissionsService, $this->createMock(IUserManager::class), $this->createMock(AuditLogServiceInterface::class), $this->connectionAdapter, @@ -82,12 +97,12 @@ protected function setUp(): void { false, $this->createMock(INavigationManager::class), $this->createMock(IURLGenerator::class), - $this->createMock(TableMapper::class), - $this->createMock(ViewMapper::class), + $this->tableMapper, + $this->viewMapper, $this->widgetService, new GridWidgetTypes($l10n), - $this->createMock(MenuItemMapper::class), - $this->createMock(MenuService::class), + $menuItemMapper, + $this->menuService, ); } @@ -200,4 +215,146 @@ private function createContextWithGridPage(): array { return [$context, $relation, $page->getId()]; } + + public function testASchemeCarriesPagesWithWidgetsOnTheirTablesAndViewsByUuid(): void { + $this->permissionsService->method('canManageNodeById')->willReturnCallback(static fn (int $type, int $id): bool => $id !== 5199); + $this->permissionsService->method('canManageContext')->willReturn(true); + $this->tableMapper->method('findMany')->willReturnCallback(static fn (array $ids): array => array_combine($ids, array_map(static fn (): Table => new Table(), $ids))); + $node = static fn (int $id, int $type): array => ['id' => $id, 'type' => $type, 'permissions' => Application::PERMISSION_MANAGE]; + $source = $this->service->create('Source', 'tables', '', [$node(4181, Application::NODE_TYPE_TABLE), $node(4182, Application::NODE_TYPE_VIEW)], self::OWNER, Application::OWNER_TYPE_USER); + $this->contextIds[] = $source->getId(); + $relationIds = array_column($this->contextMapper->findById($source->getId(), self::OWNER)->getNodes(), 'id', 'node_id'); + $overview = $this->insertGridPage($source->getId(), 'overview', 'Overview'); + $this->widgetService->insert($overview->getId(), 'header', null, ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2], ['heading' => 'Source'], null); + $this->widgetService->insert($overview->getId(), 'data', 'Tasks', ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], [], $relationIds[4181]); + $this->widgetService->insert($overview->getId(), 'data', 'Open tasks', ['gridX' => 0, 'gridY' => 7, 'gridWidth' => 12, 'gridHeight' => 5], [], $relationIds[4182]); + $notes = $this->insertGridPage($source->getId(), null, 'Notes'); + $this->menuService->create($source->getId(), 'Notes', null, null, ['pageId' => $notes->getId()], self::OWNER); + + $tableService = $this->createMock(TableService::class); + $tableService->method('find')->willReturnCallback(static fn (int $id): Table => self::withUuid(new Table(), $id, 'table-' . $id)); + $tableService->method('getScheme')->willReturn($this->createMock(TableScheme::class)); + $tableService->method('findAll')->willReturn([]); + $viewService = $this->createMock(ViewService::class); + $viewService->method('find')->willReturnCallback(static function (int $id): View { + $view = self::withUuid(new View(), $id, 'view-' . $id); + $view->setTableId(4181); + return $view; + }); + $scheme = $this->withServices([TableService::class => $tableService, ViewService::class => $viewService], fn (): array => json_decode(json_encode($this->service->getScheme($source->getId(), self::OWNER)), true)); + $this->assertSame(['targetType' => 'view', 'targetUuid' => 'view-4182'], array_diff_key(array_values(array_filter($scheme['pages'], static fn (array $page): bool => $page['technical_name'] === 'overview'))[0]['widgets'][2]['source'], ['nodeRelId' => true])); + // the tables of the scheme are not under test here + $scheme['tables'] = []; + $overviewWidgets = &$scheme['pages'][array_key_first(array_filter($scheme['pages'], static fn (array $page): bool => $page['technical_name'] === 'overview'))]['widgets']; + $overviewWidgets[] = ['type' => 'data', 'title' => 'Elsewhere', 'gridX' => 0, 'gridY' => 12, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => ['targetType' => 'table', 'targetUuid' => 'table-4199']]; + // here and manageable, but not a node of the scheme: a widget does not add it + $overviewWidgets[] = ['type' => 'data', 'title' => 'Not a resource', 'gridX' => 0, 'gridY' => 17, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => ['targetType' => 'table', 'targetUuid' => 'table-4198']]; + unset($overviewWidgets); + $target = $this->service->create('Target', 'tables', '', [], self::OWNER, Application::OWNER_TYPE_USER); + $this->contextIds[] = $targetId = $target->getId(); + + $this->tableMapper->method('findByUuid')->willReturnCallback(static fn (string $uuid): Table => match ($uuid) { + 'table-4181' => self::withUuid(new Table(), 5181, $uuid), + 'table-4198' => self::withUuid(new Table(), 5198, $uuid), + 'table-4199' => self::withUuid(new Table(), 5199, $uuid), + default => throw new DoesNotExistException('No table'), + }); + $this->viewMapper->method('findByUuid')->willReturnCallback(static fn (string $uuid): View => $uuid === 'view-4182' ? self::withUuid(new View(), 5182, $uuid) : throw new DoesNotExistException('No view')); + $preview = $this->withServices([TableService::class => $tableService], fn (): array => $this->service->compareSchemeChanges($targetId, $scheme, self::OWNER)); + + $this->assertSame([ + ['Elsewhere', 'its table is not one of the resources of the application'], + ['Not a resource', 'its table is not one of the resources of the application'], + ], array_map(static fn (array $skip): array => [$skip['widget'], $skip['reason']], $preview['pages']['skipped'])); + $this->assertSame([], $preview['menu']['skipped']); + $this->service->addNodeToContext($this->contextMapper->findById($targetId, self::OWNER), 5181, Application::NODE_TYPE_TABLE, Application::PERMISSION_MANAGE); + $this->service->addNodeToContext($this->contextMapper->findById($targetId, self::OWNER), 5182, Application::NODE_TYPE_VIEW, Application::PERMISSION_MANAGE); + $this->service->importScheme($targetId, 'Target', 'tables', '', $scheme['nodes'], ['addTables' => [], 'modifyTables' => []], self::OWNER, $preview['menu']['to'], $scheme['pages']); + + $target = $this->contextMapper->findById($targetId, self::OWNER); + $targetRelationIds = array_column($target->getNodes(), 'id', 'node_id'); + $this->assertEqualsCanonicalizing([5181, 5182], array_keys($targetRelationIds)); + $pages = $this->service->getPagesWithWidgets($target); + $byTitle = array_column(array_filter($pages, static fn (array $page): bool => $page['title'] !== null), null, 'title'); + $this->assertSame('overview', $byTitle['Overview']['technical_name']); + $this->assertSame( + [['header', null], ['data', $targetRelationIds[5181]], ['data', $targetRelationIds[5182]]], + array_map(static fn (array $widget): array => [$widget['type'], $widget['source']['nodeRelId'] ?? null], $byTitle['Overview']['widgets']), + ); + $notesId = $byTitle['Notes']['id']; + $this->assertSame('page_' . $notesId, $byTitle['Notes']['technical_name']); + $this->assertSame([['pageId' => $notesId]], array_column(array_values(array_filter($this->menuService->findAll($targetId, self::OWNER), static fn (array $item): bool => $item['title'] === 'Notes')), 'target')); + } + + public function testAPreviewCountsTheTablesAndViewsTheImportAddsAsResources(): void { + $this->tableMapper->method('findByUuid')->willThrowException(new DoesNotExistException('No table')); + $this->viewMapper->method('findByUuid')->willThrowException(new DoesNotExistException('No view')); + $tableService = $this->createMock(TableService::class); + $tableService->method('findAll')->willReturn([]); + $target = $this->service->create('Target', 'tables', '', [], self::OWNER, Application::OWNER_TYPE_USER); + $this->contextIds[] = $target->getId(); + $widget = static fn (string $title, string $type, string $uuid): array => ['type' => 'data', 'title' => $title, 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => ['targetType' => $type, 'targetUuid' => $uuid]]; + + $preview = $this->withServices([TableService::class => $tableService], fn (): array => $this->service->compareSchemeChanges($target->getId(), [ + 'nodes' => [ + ['node_type' => Application::NODE_TYPE_TABLE, 'node_uuid' => 'new-table', 'permissions' => 1], + ['node_type' => Application::NODE_TYPE_VIEW, 'node_uuid' => 'new-view', 'permissions' => 1], + ['node_type' => Application::NODE_TYPE_VIEW, 'node_uuid' => 'gone-view', 'permissions' => 1], + ], + 'tables' => [['uuid' => 'new-table', 'views' => [['uuid' => 'new-view']]]], + 'pages' => [['page_type' => Page::TYPE_GRID, 'technical_name' => 'overview', 'widgets' => [ + $widget('Added table', 'table', 'new-table'), + $widget('Added view', 'view', 'new-view'), + $widget('Gone', 'view', 'gone-view'), + ]]], + ], self::OWNER)); + + $this->assertSame(['Added table', 'Added view'], array_column($preview['pages']['to'][0]['widgets'], 'title')); + $this->assertSame(['Gone'], array_column($preview['pages']['skipped'], 'widget')); + } + + private function insertGridPage(int $contextId, ?string $technicalName, string $title): Page { + $page = new Page(); + $page->setContextId($contextId); + $page->setPageType(Page::TYPE_GRID); + $page->setTitle($title); + if ($technicalName === null) { + return $this->pageMapper->insertWithDefaultName($page); + } + $page->setTechnicalName($technicalName); + return $this->pageMapper->insert($page); + } + + /** + * @template T of Table|View + * @param T $node + * @return T + */ + private static function withUuid(Table|View $node, int $id, string $uuid): Table|View { + $node->id = $id; + $node->setUuid($uuid); + return $node; + } + + /** + * Runs a callback with services of the server container replaced, as the scheme + * export and preview get them from there. + * + * @param array $services + */ + private function withServices(array $services, callable $callback): mixed { + $container = \OC::$server->getAppContainerForService(TableService::class) ?? \OC::$server; + $real = []; + foreach ($services as $class => $service) { + $real[$class] = \OCP\Server::get($class); + $container->registerService($class, static fn () => $service); + } + try { + return $callback(); + } finally { + foreach ($real as $class => $service) { + $container->registerService($class, static fn () => $service); + } + } + } } diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index ba5ae6d882..d7cedda290 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -43,6 +43,7 @@ final class ContextServiceTest extends TestCase { private MockObject $auditLogService; private MockObject $widgetService; private MockObject $dbConnection; + private MockObject $logger; protected function setUp(): void { parent::setUp(); @@ -51,7 +52,7 @@ protected function setUp(): void { $this->contextNodeRelMapper = $this->createMock(ContextNodeRelationMapper::class); $this->pageMapper = $this->createMock(PageMapper::class); $this->pageContentMapper = $this->createMock(PageContentMapper::class); - $logger = $this->createMock(LoggerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); $permissionsService = $this->createMock(PermissionsService::class); $this->userManager = $this->createMock(IUserManager::class); $this->auditLogService = $this->createMock(AuditLogServiceInterface::class); @@ -68,7 +69,7 @@ protected function setUp(): void { $this->contextNodeRelMapper, $this->pageMapper, $this->pageContentMapper, - $logger, + $this->logger, $permissionsService, $this->userManager, $this->auditLogService, @@ -600,4 +601,79 @@ public function testAnImportedPageKeepsAtMostTheMaximumNumberOfWidgets(): void { self::assertCount(GridWidgetTypes::MAX_WIDGETS_PER_PAGE, $record['widgets']); } + + public function testImportingSchemePagesKeepsWhatCanBeUsedAndSkipsTheRestWithAReason(): void { + $context = new Context(); + $context->id = 7; + $context->setName('Intake portal'); + $context->setOwnerId('user-1'); + $context->setOwnerType(Application::OWNER_TYPE_USER); + $context->setNodes([]); + $context->setPages([ + 301 => ['id' => 301, 'page_type' => Page::TYPE_STARTPAGE, 'technical_name' => 'dashboard', 'content' => []], + 302 => ['id' => 302, 'page_type' => Page::TYPE_GRID, 'technical_name' => 'overview', 'content' => []], + ]); + $this->contextMapper->method('findById')->willReturn($context); + $this->contextMapper->method('update')->willReturnArgument(0); + $this->pageMapper->method('findById')->willReturnCallback(static function (int $id): Page { + $page = new Page(); + $page->id = $id; + $page->resetUpdatedFields(); + return $page; + }); + $updated = []; + $this->pageMapper->method('update')->willReturnCallback(static function (Page $page) use (&$updated): Page { + $updated[] = [$page->getId(), $page->getTitle(), $page->getIcon()]; + return $page; + }); + $inserted = []; + $this->pageMapper->method('insert')->willReturnCallback(static function (Page $page) use (&$inserted): Page { + $page->id = 400 + count($inserted); + $inserted[] = [$page->getTechnicalName(), $page->getTitle(), $page->getPageType()]; + return $page; + }); + $this->pageMapper->method('insertWithDefaultName')->willReturnCallback(static function (Page $page) use (&$inserted): Page { + $page->id = 400 + count($inserted); + $inserted[] = ['page_' . $page->getId(), $page->getTitle(), $page->getPageType()]; + return $page; + }); + $synced = []; + $this->widgetService->method('syncPage')->willReturnCallback(static function (int $pageId, array $widgets) use (&$synced): void { + $synced[$pageId] = array_map(static fn (array $widget): array => [$widget['type'], $widget['title'], $widget['nodeRelId']], $widgets); + }); + $warnings = []; + $this->logger->method('warning')->willReturnCallback(static function (string $message) use (&$warnings): void { + $warnings[] = $message; + }); + $text = static fn (?string $title): array => ['type' => 'text', 'title' => $title, 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => ['text' => 'Hi']]; + + $this->service->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], 'user-1', null, [ + ['page_type' => Page::TYPE_STARTPAGE, 'technical_name' => 'dashboard', 'title' => 'Home', 'icon' => 'star', 'widgets' => [$text('On the start page')]], + ['page_type' => Page::TYPE_STARTPAGE, 'title' => 'Second start'], + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'overview', 'title' => 'Overview', 'widgets' => [ + $text('Kept'), + ['type' => 'data', 'title' => 'Without source', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => null], + ['type' => 'data', 'title' => 'Old source', 'gridX' => 0, 'gridY' => 7, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => ['nodeRelId' => 5]], + ['type' => 'chart', 'title' => 'Unknown type', 'gridX' => 0, 'gridY' => 12, 'gridWidth' => 12, 'gridHeight' => 5], + ]], + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'overview', 'title' => 'Same name'], + 'not a page', + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'reports', 'title' => 'Reports', 'widgets' => array_fill(0, GridWidgetTypes::MAX_WIDGETS_PER_PAGE + 1, $text(null))], + ]); + + $this->assertSame([[301, 'Home', 'star'], [302, 'Overview', null]], $updated); + $this->assertSame([['page_400', 'Same name', Page::TYPE_GRID], ['reports', 'Reports', Page::TYPE_GRID]], $inserted); + $this->assertSame([['text', 'Kept', null], ['data', 'Without source', null]], $synced[302]); + $this->assertSame([], $synced[400]); + $this->assertCount(GridWidgetTypes::MAX_WIDGETS_PER_PAGE, $synced[401]); + $this->assertArrayNotHasKey(301, $synced); + $this->assertSame([ + 'Skipping widget 0 of page 0 of an imported scheme: only grid pages hold widgets', + 'Skipping page 1 of an imported scheme: an application has one start page', + 'Skipping widget 2 of page 2 of an imported scheme: its source names no table or view by uuid', + 'Skipping widget 3 of page 2 of an imported scheme: Unknown widget type "chart", expected one of: header, text, data', + 'Skipping page 4 of an imported scheme: its type is not startpage or grid', + 'Skipping widget 100 of page 5 of an imported scheme: a page holds at most 100 widgets', + ], $warnings); + } } From 8da4db775eb1be1436775be2b61eac411c3b601d Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 4 Nov 2026 09:06:51 +0100 Subject: [PATCH 2/2] chore(api): regenerate the OpenAPI spec and TypeScript types Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- openapi.json | 12 ++++++++++++ src/types/openapi/openapi.ts | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/openapi.json b/openapi.json index 3c043c7fea..751fd7d7ac 100644 --- a/openapi.json +++ b/openapi.json @@ -14072,6 +14072,18 @@ "type": "object" } } + }, + "pages": { + "type": "array", + "nullable": true, + "default": null, + "description": "Pages of the scheme: the start page updates the start page, a grid page the page with its technical name or it is added, and its widgets replace the widgets of that page; a widget names its table or view by `targetType` and `targetUuid` in its `source`. Pages and widgets that cannot be used here are skipped. Left out, the pages stay as they are", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } } } } diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index dc24155625..06d10425a7 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -7931,6 +7931,13 @@ export interface operations { readonly menu?: readonly { readonly [key: string]: Record; }[] | null; + /** + * @description Pages of the scheme: the start page updates the start page, a grid page the page with its technical name or it is added, and its widgets replace the widgets of that page; a widget names its table or view by `targetType` and `targetUuid` in its `source`. Pages and widgets that cannot be used here are skipped. Left out, the pages stay as they are + * @default null + */ + readonly pages?: readonly { + readonly [key: string]: Record; + }[] | null; }; }; };