diff --git a/cypress/component/ImportContextSchemePreview.cy.js b/cypress/component/ImportContextSchemePreview.cy.js new file mode 100644 index 0000000000..4cc7fcf787 --- /dev/null +++ b/cypress/component/ImportContextSchemePreview.cy.js @@ -0,0 +1,82 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import ImportContextSchemePreview from '../../src/modules/modals/ImportContextSchemePreview.vue' + +const MENU_SWITCH = '[data-cy="importSchemeMenu"]' +const SKIPPED = '[data-cy="importSchemeMenuSkipped"]' + +const schemeMenu = [ + { label: 'Dashboard', targetType: 'page', targetPage: 'dashboard', technicalName: 'dashboard' }, + { label: 'Docs', targetType: 'url', url: 'https://example.org', technicalName: 'docs', parent: 'dashboard' }, +] + +describe('Import application scheme preview', () => { + it('sends the menu of the scheme with the import', () => { + mountPreview({ from: [{ label: 'Old item' }], to: schemeMenu, skipped: [] }) + .then(({ wrapper }) => { + cy.get(MENU_SWITCH).should('exist') + cy.contains('Old item').should('exist') + cy.contains('Docs').should('exist') + cy.wrap(null).then(() => { + expect(lastPayload(wrapper).menuItems).to.deep.equal(schemeMenu) + }) + }) + }) + + it('leaves the menu out when the switch is turned off', () => { + mountPreview({ from: [], to: schemeMenu, skipped: [] }) + .then(({ wrapper }) => { + cy.get(MENU_SWITCH).click({ force: true }) + cy.wrap(null).then(() => { + expect(lastPayload(wrapper)).to.not.have.property('menuItems') + }) + }) + }) + + it('lists the items the import would skip', () => { + mountPreview({ from: [], to: [], skipped: [{ index: 0, label: 'Missing table', reason: 'its table is not here or cannot be managed by the user' }] }) + + cy.get(SKIPPED).should('contain', 'Missing table') + }) + + it('has no menu section for a preview without a menu', () => { + mountPreview(undefined) + .then(({ wrapper }) => { + cy.get(MENU_SWITCH).should('not.exist') + cy.wrap(null).then(() => { + expect(lastPayload(wrapper)).to.not.have.property('menuItems') + }) + }) + }) +}) + +/** + * @param {object|undefined} menuItems the menu part of the preview response + * @return {Cypress.Chainable} the mount result + */ +function mountPreview(menuItems) { + return cy.mount(ImportContextSchemePreview, { + props: { + previewData: { + name: { from: 'Intake', to: 'Intake' }, + icon: { from: 'star', to: 'star' }, + description: { from: '', to: '' }, + nodes: { from: [], to: [] }, + addTables: {}, + modifyTables: {}, + ...(menuItems === undefined ? {} : { menuItems }), + }, + }, + }) +} + +/** + * @param {object} wrapper the mounted component + * @return {object} the last import body the preview emitted + */ +function lastPayload(wrapper) { + const emitted = wrapper.emitted('update') + return emitted[emitted.length - 1][0] +} diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index 0626ba15d1..fe1d612bb7 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -328,6 +328,10 @@ public function exportScheme(int $contextId): DataResponse { /** * [api v2] Preview the changes that would be applied to a context scheme * + * When the scheme carries `menuItems`, the changes hold `menuItems` with the current + * menu under `from`, the items an import would save under `to` and the items it would + * skip, with the reason, under `skipped`. + * * @param int $contextId ID of the context * @psalm-param TablesContext $updateScheme New scheme of the context * @@ -366,6 +370,7 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR * @param string $description Description of the context * @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 $menuItems Menu of the scheme, replacing the menu of the context: tables and views are named by `targetUuid`, pages by their technical name in `targetPage` and parents by their technical name in `parent`; items that cannot be used here are skipped. Left out, the menu stays as it is * * @return DataResponse, array{}>|DataResponse * @@ -376,12 +381,15 @@ 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): DataResponse { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $menuItems = null): DataResponse { try { $this->db->beginTransaction(); - $context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId); + $context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId, $menuItems); $this->db->commit(); return new DataResponse($context->jsonSerialize()); + } catch (BadRequestError $e) { + $this->db->rollBack(); + return $this->handleBadRequestError($e); } catch (\InvalidArgumentException $e) { $this->db->rollBack(); return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); diff --git a/lib/Db/TableMapper.php b/lib/Db/TableMapper.php index 5a9ad8d368..6765f1011f 100644 --- a/lib/Db/TableMapper.php +++ b/lib/Db/TableMapper.php @@ -53,18 +53,21 @@ public function find(int $id): Table { } /** - * @param string $uuid - * @return Table - * @throws DoesNotExistException + * The table with the given uuid, or null when there is none. + * * @throws Exception * @throws MultipleObjectsReturnedException */ - public function findByUuid(string $uuid): Table { + public function findByUuid(string $uuid): ?Table { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->table) ->where($qb->expr()->eq('uuid', $qb->createNamedParameter($uuid, IQueryBuilder::PARAM_STR))); - return $this->findEntity($qb); + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + return null; + } } /** diff --git a/lib/Db/ViewMapper.php b/lib/Db/ViewMapper.php index f5bbca13e8..251537edaf 100644 --- a/lib/Db/ViewMapper.php +++ b/lib/Db/ViewMapper.php @@ -239,7 +239,8 @@ public function findIdToTitleMap(array $ids): array { } /** - * @throws DoesNotExistException + * The view with the given uuid, or null when there is none. + * * @throws Exception * @throws MultipleObjectsReturnedException */ @@ -249,6 +250,10 @@ public function findByUuid(string $uuid): ?View { ->from($this->table, 'v') ->innerJoin('v', 'tables_tables', 't', 't.id = v.table_id') ->where($qb->expr()->eq('v.uuid', $qb->createNamedParameter($uuid))); - return $this->findEntity($qb); + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + return null; + } } } diff --git a/lib/Model/ContextScheme.php b/lib/Model/ContextScheme.php index 3c511632c4..04627afd0a 100644 --- a/lib/Model/ContextScheme.php +++ b/lib/Model/ContextScheme.php @@ -18,6 +18,7 @@ public function __construct( protected ?array $nodes = [], protected ?array $pages = [], protected ?array $tables = [], + protected ?array $menuItems = [], ) { } @@ -45,6 +46,10 @@ public function getTables(): ?array { return $this->tables; } + public function getMenuItems(): ?array { + return $this->menuItems; + } + public function jsonSerialize(): mixed { return [ 'name' => $this->name, @@ -53,6 +58,7 @@ public function jsonSerialize(): mixed { 'nodes' => $this->nodes, 'pages' => $this->pages, 'tables' => $this->tables, + 'menuItems' => $this->menuItems, ]; } } diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index 0f847212d9..f2b21df771 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -1056,7 +1056,10 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C $page->setTechnicalName($technicalNames[$pageKey]); $page = $this->pageMapper->insert($page); } - $pageMap[$pageArr['id'] ?? null] = $page; + $oldPageId = self::importedKey($pageArr['id'] ?? null); + if ($oldPageId !== null) { + $pageMap[$oldPageId] = $page; + } // insert page conten if (!empty($pageArr['content'])) { @@ -1084,9 +1087,141 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C } } + if (is_array($context['menuItems'] ?? null)) { + $this->importContextMenu($newContext->getId(), $context['menuItems'], $pageMap, $tableOldId, $table->getId()); + } + return $newContext; } + /** + * Inserts the exported menu of an imported application. An item opening the imported + * table or one of the imported pages points at its new id and a link is kept; an item + * opening anything else, or that is not valid, is skipped with a warning. A parent is + * found by its exported id and the menu is nested by {@see self::nestImportedMenu()}. + * At most {@see MenuItem::MAX_ITEMS_PER_APPLICATION} items are kept. + * + * @param array $menuItems exported menu items + * @param array $pageMap imported pages by their exported id + * @throws Exception|InvalidArgumentException + */ + private function importContextMenu(int $contextId, array $menuItems, array $pageMap, int $tableOldId, int $tableId): void { + $kept = []; + foreach (array_values($menuItems) as $index => $item) { + if (!is_array($item)) { + $this->skipImportedMenuItem($index, 'it is not an object'); + continue; + } + $id = $item['id'] ?? null; + $parentId = $item['parentId'] ?? null; + $exportedTargetId = $item['targetId'] ?? null; + if (!self::isIdOrNull($id) || !self::isIdOrNull($parentId) || !self::isIdOrNull($exportedTargetId)) { + $this->skipImportedMenuItem($index, 'its id, parentId or targetId is not a number'); + continue; + } + $targetType = $item['targetType'] ?? null; + $targetId = match ($targetType) { + MenuItem::TARGET_TABLE => $exportedTargetId === $tableOldId ? $tableId : null, + MenuItem::TARGET_PAGE => $exportedTargetId !== null && isset($pageMap[$exportedTargetId]) ? $pageMap[$exportedTargetId]->getId() : null, + default => null, + }; + if (in_array($targetType, [MenuItem::TARGET_TABLE, MenuItem::TARGET_PAGE, MenuItem::TARGET_VIEW], true) && $targetId === null) { + $this->skipImportedMenuItem($index, 'its ' . $targetType . ' is not part of this import'); + continue; + } + if (count($kept) >= MenuItem::MAX_ITEMS_PER_APPLICATION) { + $this->skipImportedMenuItem($index, 'a menu holds at most ' . MenuItem::MAX_ITEMS_PER_APPLICATION . ' items'); + continue; + } + try { + $input = self::importedMenuInput($item, $targetType, $targetId); + } catch (InvalidArgumentException $e) { + $this->skipImportedMenuItem($index, $e->getMessage()); + continue; + } + $kept[] = ['input' => $input, 'ref' => $id === null ? null : (string)$id, 'parent' => $parentId === null ? null : (string)$parentId]; + } + + $menu = $this->nestImportedMenu($kept, 'imported menu item'); + $this->insertMenuItems($contextId, $menu, array_filter(array_map(static fn (MenuItemInput $item): ?string => $item->getTechnicalName(), $menu))); + } + + private static function isIdOrNull(mixed $id): bool { + return $id === null || is_int($id); + } + + private function skipImportedMenuItem(int|string $index, string $reason): void { + $this->logger->warning('Skipping imported menu item ' . $index . ': ' . $reason); + } + + /** + * The menu item of an imported entry. A technical name that is not valid or has the + * automatic form `item_` is dropped, so the item gets a name of its own. + * + * @param array $entry + * @throws InvalidArgumentException when the item is not valid + */ + private static function importedMenuInput(array $entry, mixed $targetType, ?int $targetId): MenuItemInput { + $name = $entry['technicalName'] ?? null; + $keepName = is_string($name) && TechnicalNameValidator::isValid($name) && !MenuItem::isDefaultName($name); + return MenuItemInput::createFromInputArray([ + 'label' => $entry['label'] ?? null, + 'icon' => $entry['icon'] ?? null, + 'description' => $entry['description'] ?? null, + 'targetType' => $targetType, + 'targetId' => $targetId, + 'url' => $entry['url'] ?? null, + 'technicalName' => $keepName ? $name : null, + ]); + } + + /** + * Nests the items of an imported menu one level deep. An item sits under its parent + * when that is a top-level item of the import; a child whose parent was skipped or is + * a child itself moves to the top. A technical name taken by an earlier item is + * dropped. + * + * @param list}> $items in menu order, with the keys the item and its parent are exported under + * @return list + */ + private function nestImportedMenu(array $items, string $what): array { + $keptRefs = []; + foreach ($items as $item) { + if ($item['ref'] !== null) { + $keptRefs[$item['ref']] = true; + } + } + $topLevelPositions = []; + foreach ($items as $position => $item) { + $isTopLevel = $item['parent'] === null || !isset($keptRefs[$item['parent']]); + if ($isTopLevel && $item['ref'] !== null && !isset($topLevelPositions[$item['ref']])) { + $topLevelPositions[$item['ref']] = $position; + } + } + + $menu = []; + $usedNames = []; + foreach ($items as $position => $item) { + $input = $item['input']; + $parentIndex = $item['parent'] !== null ? ($topLevelPositions[$item['parent']] ?? null) : null; + if ($item['parent'] !== null && $parentIndex === null) { + $this->logger->warning('Moving ' . $what . ' ' . $position . ' to the top of the menu: its parent was skipped or is a child itself.'); + } + $technicalName = $input->getTechnicalName(); + if ($technicalName !== null && isset($usedNames[$technicalName])) { + $technicalName = null; + } + if ($technicalName !== null) { + $usedNames[$technicalName] = true; + } + $menu[] = new MenuItemInput( + $input->getLabel(), $input->getIcon(), $input->getDescription(), $input->getTargetType(), + $input->getTargetId(), $input->getUrl(), $technicalName, $parentIndex, + ); + } + return $menu; + } + /** * The technical names of the pages of an imported application, by page key. A valid * exported name is kept unless an earlier page of the import uses it or it has the @@ -1253,9 +1388,157 @@ public function getScheme(int $contextId, string $userId): ContextScheme { array_values($nodes), $this->getPagesWithWidgets($context), array_values($tables), + $this->exportMenu($context, $nodes), ); } + /** + * The menu of an application as a scheme carries it: a table or view by its uuid, a + * page and a parent by their technical name. A broken item is left out. + * + * @param array> $nodes the nodes of the scheme, with their uuid + * @return list + */ + private function exportMenu(Context $context, array $nodes): array { + $uuids = []; + foreach ($nodes as $node) { + if (isset($node['node_uuid'])) { + $uuids[sprintf('t%di%d', $node['node_type'], $node['node_id'])] = $node['node_uuid']; + } + } + $pageNames = []; + foreach ($context->getPages() ?? [] as $pageId => $page) { + $pageNames[(int)$pageId] = $page['technical_name'] ?? null; + } + $names = []; + foreach ($context->getMenuItems() ?? [] as $item) { + $names[$item['id']] = $item['technicalName']; + } + + $menu = []; + foreach ($context->getMenuItems() ?? [] as $item) { + if ($item['broken']) { + continue; + } + $target = self::menuTargetNode($item['targetType'], $item['targetId']); + $targetUuid = $target !== null ? ($uuids[$target[0]] ?? null) : null; + $targetPage = $item['targetType'] === MenuItem::TARGET_PAGE ? ($pageNames[$item['targetId']] ?? null) : null; + if (($target !== null && $targetUuid === null) || ($item['targetType'] === MenuItem::TARGET_PAGE && $targetPage === null)) { + continue; + } + $menu[] = [ + 'label' => $item['label'], + 'icon' => $item['icon'], + 'description' => $item['description'], + 'targetType' => $item['targetType'], + 'technicalName' => $item['technicalName'], + 'url' => $item['url'], + 'targetUuid' => $targetUuid, + 'targetPage' => $targetPage, + 'parent' => $item['parentId'] !== null ? ($names[$item['parentId']] ?? null) : null, + ]; + } + return $menu; + } + + /** + * Checks every item of the menu of a scheme against this instance. Tables and views + * are found by uuid and must be manageable by the user, unless the import adds them; + * pages are found by technical name. An item that is invalid or whose target is not + * here is skipped, with the reason, and so is every item past + * {@see MenuItem::MAX_ITEMS_PER_APPLICATION}. + * + * @param list $schemeMenu + * @param array $addedUuids uuids of the tables and views the import adds + * @return array{kept: list}>, skipped: list} + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + private function resolveSchemeMenu(Context $context, array $schemeMenu, string $userId, array $addedUuids = []): array { + $pageIds = []; + foreach ($context->getPages() ?? [] as $pageId => $page) { + if (is_string($page['technical_name'] ?? null)) { + $pageIds[$page['technical_name']] = (int)$pageId; + } + } + + $kept = []; + $skipped = []; + foreach (array_values($schemeMenu) as $index => $entry) { + $skip = static function (string $reason) use (&$skipped, $index, $entry): void { + $skipped[] = ['index' => $index, 'label' => is_array($entry) ? ($entry['label'] ?? null) : null, 'reason' => $reason]; + }; + if (!is_array($entry) || !is_string($entry['targetType'] ?? null)) { + $skip('it is not an object with a targetType'); + continue; + } + $targetId = null; + if ($entry['targetType'] === MenuItem::TARGET_TABLE || $entry['targetType'] === MenuItem::TARGET_VIEW) { + $uuid = $entry['targetUuid'] ?? null; + if (!is_string($uuid)) { + $skip('its ' . $entry['targetType'] . ' has no uuid'); + continue; + } + if (isset($addedUuids[$uuid])) { + // added by the import, so not here yet; any id passes the check + $targetId = PHP_INT_MAX; + } else { + $node = $entry['targetType'] === MenuItem::TARGET_TABLE ? $this->tableMapper->findByUuid($uuid) : $this->viewMapper->findByUuid($uuid); + $nodeType = $entry['targetType'] === MenuItem::TARGET_TABLE ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW; + if ($node === null || !$this->permissionsService->canManageNodeById($nodeType, $node->getId(), $userId)) { + $skip('its ' . $entry['targetType'] . ' is not here or cannot be managed by the user'); + continue; + } + $targetId = $node->getId(); + } + } elseif ($entry['targetType'] === MenuItem::TARGET_PAGE) { + $targetPage = $entry['targetPage'] ?? null; + $targetId = is_string($targetPage) ? ($pageIds[$targetPage] ?? null) : null; + if ($targetId === null) { + $skip('the application has no page with its technical name'); + continue; + } + } + if (count($kept) >= MenuItem::MAX_ITEMS_PER_APPLICATION) { + $skip('a menu holds at most ' . MenuItem::MAX_ITEMS_PER_APPLICATION . ' items'); + continue; + } + try { + $input = self::importedMenuInput($entry, $entry['targetType'], $targetId); + } catch (InvalidArgumentException $e) { + $skip($e->getMessage()); + continue; + } + $kept[] = [ + 'input' => $input, + 'ref' => is_string($entry['technicalName'] ?? null) ? $entry['technicalName'] : null, + 'parent' => is_string($entry['parent'] ?? null) ? $entry['parent'] : null, + 'entry' => $entry, + ]; + } + return ['kept' => $kept, 'skipped' => $skipped]; + } + + /** + * The menu of a scheme for an application of this instance, see + * {@see self::resolveSchemeMenu()}; every skipped item is logged. A parent is found by + * the technical name it was exported under, an automatic `item_` included, and the + * menu is nested by {@see self::nestImportedMenu()}. + * + * @param list $schemeMenu + * @return list + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + private function importMenu(Context $context, array $schemeMenu, string $userId): array { + ['kept' => $items, 'skipped' => $skipped] = $this->resolveSchemeMenu($context, $schemeMenu, $userId); + foreach ($skipped as $skip) { + $this->logger->warning('Skipping menu item ' . $skip['index'] . ' of an imported scheme: ' . $skip['reason']); + } + + return $this->nestImportedMenu($items, 'scheme menu item'); + } + /** * @param int $contextId * @param array $updateScheme @@ -1322,9 +1605,48 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string } } + if (is_array($updateScheme['menuItems'] ?? null)) { + $changes['menuItems'] = $this->previewMenu($context, $currentScheme, $updateScheme['menuItems'], $updateSchemeTables, array_keys($tablesMap), $userId); + } + return $changes; } + /** + * The menu an import would save, as the menu of the scheme without the items it would + * skip, and the skipped items with the reason. + * + * @param list $schemeMenu + * @param array $schemeTables + * @param list $localTableUuids + * @return array{from: list>, to: list>, skipped: list} + * @throws Exception + * @throws MultipleObjectsReturnedException + */ + private function previewMenu(Context $context, ContextScheme $currentScheme, array $schemeMenu, array $schemeTables, array $localTableUuids, string $userId): 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->viewMapper->findByUuid($view['uuid']) === null) { + $addedUuids[$view['uuid']] = true; + } + } + } + ['kept' => $kept, 'skipped' => $skipped] = $this->resolveSchemeMenu($context, array_values($schemeMenu), $userId, $addedUuids); + return [ + 'from' => $currentScheme->getMenuItems() ?? [], + 'to' => array_column($kept, 'entry'), + 'skipped' => $skipped, + ]; + } + /** * @param int $contextId * @param string $name @@ -1333,6 +1655,7 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string * @param array $nodes * @param array $tables * @param string $userId + * @param list|null $menuItems the menu of the scheme; null leaves the menu alone * * @return Context * @@ -1346,7 +1669,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): Context { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, string $userId, ?array $menuItems = 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'])) { @@ -1383,6 +1706,9 @@ public function importScheme(int $contextId, string $name, string $iconName, str } $tableService->validateTableScheme($tableScheme); $table = $this->tableMapper->findByUuid($tableScheme['uuid']); + if ($table === null) { + throw new BadRequestError('The table ' . $tableScheme['uuid'] . ' to modify does not exist.'); + } $tableService->updateTableStructure($table->getId(), $tableScheme['columns'], $tableScheme['views'], $tableScheme['columnOrder'], $tableScheme['sort'], $userId); } @@ -1391,6 +1717,10 @@ public function importScheme(int $contextId, string $name, string $iconName, str foreach ($nodes as $node) { if ($node['node_type'] === Application::NODE_TYPE_TABLE) { $table = $this->tableMapper->findByUuid($node['node_uuid']); + if ($table === null) { + $this->logger->warning('Skipping a node of an imported scheme: no table has the uuid ' . $node['node_uuid'] . '.'); + continue; + } $resolvedNodes[] = [ 'id' => $table->getId(), 'type' => $node['node_type'], @@ -1398,6 +1728,10 @@ public function importScheme(int $contextId, string $name, string $iconName, str ]; } elseif ($node['node_type'] === Application::NODE_TYPE_VIEW) { $view = $this->viewMapper->findByUuid($node['node_uuid']); + if ($view === null) { + $this->logger->warning('Skipping a node of an imported scheme: no view has the uuid ' . $node['node_uuid'] . '.'); + continue; + } $resolvedNodes[] = [ 'id' => $view->getId(), 'type' => $node['node_type'], @@ -1406,8 +1740,8 @@ public function importScheme(int $contextId, string $name, string $iconName, str } } - $context = $this->update($contextId, $userId, $name, $iconName, $description, $resolvedNodes); + $menu = $menuItems !== null ? $this->importMenu($this->contextMapper->findById($contextId, $userId), $menuItems, $userId) : null; - return $context; + return $this->update($contextId, $userId, $name, $iconName, $description, $resolvedNodes, $menu); } } diff --git a/lib/Service/TableService.php b/lib/Service/TableService.php index adf59034df..ec4bdbfee8 100644 --- a/lib/Service/TableService.php +++ b/lib/Service/TableService.php @@ -309,12 +309,8 @@ public function create(string $title, string $template, ?string $emoji, ?string // which are possible, but not encouraged. // The chance is small, and the user just can click on import once more. $applicableUuid = null; - if ($uuid !== null) { - try { - $this->mapper->findByUuid($uuid); - } catch (DoesNotExistException) { - $applicableUuid = $uuid; - } + if ($uuid !== null && $this->mapper->findByUuid($uuid) === null) { + $applicableUuid = $uuid; } $item->setUuid($applicableUuid); diff --git a/openapi.json b/openapi.json index 050ea9bb08..86a8ec14cf 100644 --- a/openapi.json +++ b/openapi.json @@ -13505,6 +13505,7 @@ "post": { "operationId": "context-preview-scheme-changes", "summary": "[api v2] Preview the changes that would be applied to a context scheme", + "description": "When the scheme carries `menuItems`, the changes hold `menuItems` with the current menu under `from`, the items an import would save under `to` and the items it would skip, with the reason, under `skipped`.", "tags": [ "context" ], @@ -13861,6 +13862,18 @@ } } } + }, + "menuItems": { + "type": "array", + "nullable": true, + "default": null, + "description": "Menu of the scheme, replacing the menu of the context: tables and views are named by `targetUuid`, pages by their technical name in `targetPage` and parents by their technical name in `parent`; items that cannot be used here are skipped. Left out, the menu stays as it is", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } } } } diff --git a/src/modules/modals/ImportContextSchemePreview.vue b/src/modules/modals/ImportContextSchemePreview.vue index a7ac0f1911..f8f1175587 100644 --- a/src/modules/modals/ImportContextSchemePreview.vue +++ b/src/modules/modals/ImportContextSchemePreview.vue @@ -179,6 +179,41 @@ + + +
+
+ + {{ t('tables', 'Menu') }} + +
+
    +
  • + {{ t('tables', 'From') }}: +
  • +
  • + {{ item.label }} +
  • +
+
    +
  • + {{ t('tables', 'To') }}: +
  • +
  • + {{ item.label }} +
  • +
+
+
    +
  • + {{ t('tables', 'Skipped, not available here') }}: +
  • +
  • + {{ item.label }} +
  • +
+
+
@@ -236,6 +271,7 @@ export default { importIcon: true, importDescription: true, importNodes: true, + importMenu: true, addTablesConfig: [], removeColumnsConfig: [], modifyTablesConfig: [], @@ -273,6 +309,11 @@ export default { this.emitPayload() } }, + importMenu: { + handler() { + this.emitPayload() + }, + }, addTablesConfig: { handler() { this.emitPayload() @@ -333,6 +374,10 @@ export default { }, nodes: this.importNodes ? this.previewData.nodes.to : this.previewData.nodes.from, } + // without menuItems the import leaves the menu of the application as it is + if (this.importMenu && this.previewData.menuItems) { + payload.menuItems = this.previewData.menuItems.to + } this.$emit('update', payload) }, diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 03ef2ac968..ba2cb0a524 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -851,7 +851,10 @@ export type paths = { }; readonly get?: never; readonly put?: never; - /** [api v2] Preview the changes that would be applied to a context scheme */ + /** + * [api v2] Preview the changes that would be applied to a context scheme + * @description When the scheme carries `menuItems`, the changes hold `menuItems` with the current menu under `from`, the items an import would save under `to` and the items it would skip, with the reason, under `skipped`. + */ readonly post: operations["context-preview-scheme-changes"]; readonly delete?: never; readonly options?: never; @@ -7688,6 +7691,13 @@ export interface operations { readonly addTables: readonly components["schemas"]["Table"][]; readonly modifyTables: readonly components["schemas"]["Table"][]; }; + /** + * @description Menu of the scheme, replacing the menu of the context: tables and views are named by `targetUuid`, pages by their technical name in `targetPage` and parents by their technical name in `parent`; items that cannot be used here are skipped. Left out, the menu stays as it is + * @default null + */ + readonly menuItems?: readonly { + readonly [key: string]: Record; + }[] | null; }; }; }; diff --git a/tests/unit/Controller/ContextControllerTest.php b/tests/unit/Controller/ContextControllerTest.php index e89f008a30..2e323bfa62 100644 --- a/tests/unit/Controller/ContextControllerTest.php +++ b/tests/unit/Controller/ContextControllerTest.php @@ -11,6 +11,7 @@ use OCA\Tables\Controller\ContextController; use OCA\Tables\Db\Context; +use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Model\MenuItemInput; use OCA\Tables\Service\ColumnService; use OCA\Tables\Service\ContextService; @@ -118,4 +119,25 @@ public function testATakenTechnicalNameIsABadRequest(): void { $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); } + + public function testImportingASchemePassesItsMenu(): void { + $context = new Context(); + $context->setMenuItems([]); + $menu = [['label' => 'Docs', 'targetType' => 'url', 'url' => 'https://example.org']]; + $this->contextService->expects($this->once())->method('importScheme') + ->with(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], 'user-1', $menu) + ->willReturn($context); + + $response = $this->controller->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], $menu); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + } + + public function testImportingASchemeThatCannotBeAppliedIsABadRequest(): void { + $this->contextService->method('importScheme')->willThrowException(new BadRequestError('The table x to modify does not exist.')); + + $response = $this->controller->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []]); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } } diff --git a/tests/unit/Db/FindByUuidTest.php b/tests/unit/Db/FindByUuidTest.php new file mode 100644 index 0000000000..d692b3e04b --- /dev/null +++ b/tests/unit/Db/FindByUuidTest.php @@ -0,0 +1,73 @@ +cleanupTablesData(); + $userHelper = $this->createMock(UserHelper::class); + $this->tableMapper = new TableMapper($this->connectionAdapter, $userHelper); + $this->viewMapper = new ViewMapper($this->connectionAdapter, $userHelper); + } + + public function testATableIsFoundByItsUuid(): void { + $tableId = $this->createTestTable(['uuid' => 'c8a3f1d2-0000-4000-8000-000000000001'])['id']; + + $this->assertSame($tableId, $this->tableMapper->findByUuid('c8a3f1d2-0000-4000-8000-000000000001')?->getId()); + } + + public function testAnUnknownTableUuidFindsNothing(): void { + $this->createTestTable(['uuid' => 'c8a3f1d2-0000-4000-8000-000000000001']); + + $this->assertNull($this->tableMapper->findByUuid('c8a3f1d2-0000-4000-8000-00000000ffff')); + } + + public function testAViewIsFoundByItsUuid(): void { + $tableId = $this->createTestTable()['id']; + $viewId = $this->createView($tableId, 'c8a3f1d2-0000-4000-8000-000000000002'); + + $this->assertSame($viewId, $this->viewMapper->findByUuid('c8a3f1d2-0000-4000-8000-000000000002')?->getId()); + } + + public function testAnUnknownViewUuidFindsNothing(): void { + $this->createView($this->createTestTable()['id'], 'c8a3f1d2-0000-4000-8000-000000000002'); + + $this->assertNull($this->viewMapper->findByUuid('c8a3f1d2-0000-4000-8000-00000000ffff')); + } + + private function createView(int $tableId, string $uuid): int { + $qb = $this->connection->getQueryBuilder(); + $qb->insert('tables_views') + ->values([ + 'table_id' => $qb->createNamedParameter($tableId), + 'title' => $qb->createNamedParameter('Open requests'), + 'description' => $qb->createNamedParameter(''), + 'uuid' => $qb->createNamedParameter($uuid), + 'created_by' => $qb->createNamedParameter('user1'), + 'created_at' => $qb->createNamedParameter(date('Y-m-d H:i:s')), + 'last_edit_by' => $qb->createNamedParameter('user1'), + 'last_edit_at' => $qb->createNamedParameter(date('Y-m-d H:i:s')), + ]) + ->executeStatement(); + return (int)$this->connection->lastInsertId(); + } +} diff --git a/tests/unit/Service/ContextPagesRoundTripTest.php b/tests/unit/Service/ContextPagesRoundTripTest.php index c6664e646e..42f3a2d7b1 100644 --- a/tests/unit/Service/ContextPagesRoundTripTest.php +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -30,6 +30,7 @@ use OCA\Tables\Service\GridWidgetTypes; use OCA\Tables\Service\PermissionsService; use OCA\Tables\Service\ShareService; +use OCA\Tables\Service\TableService; use OCA\Tables\Service\WidgetService; use OCA\Tables\Tests\Unit\Database\DatabaseTestCase; use OCP\EventDispatcher\IEventDispatcher; @@ -163,6 +164,37 @@ public function testAnUnknownIconIsDroppedOnImport(): void { $this->assertSame('Where to start', $gridPages[0]['widgets'][0]['description']); } + public function testTheMenuIsImportedWithItsPagesAndNesting(): void { + [$context, $relation, $gridPageId] = $this->createContextWithGridPage(); + $this->service->update($context->getId(), self::OWNER, null, null, null, null, MenuItemInput::createListFromInputArray([ + ['label' => 'Overview', 'targetType' => 'page', 'targetId' => $gridPageId, 'technicalName' => 'overview'], + ['label' => 'Docs', 'url' => 'https://example.org', 'icon' => 'book', 'description' => 'How it works', 'parentIndex' => 0], + ])); + $table = new Table(); + $table->id = 4244; + $table->setOwnership(self::OWNER); + $exported = json_decode(json_encode(array_merge( + $this->contextMapper->findById($context->getId(), self::OWNER)->jsonSerialize(), + ['pages' => $this->service->getPagesWithWidgets($this->contextMapper->findById($context->getId(), self::OWNER))], + )), true); + $exported['nodes'][$relation->getId()]['node_id'] = 4244; + $this->assertStringStartsWith('item_', $exported['menuItems'][1]['technicalName']); + + $imported = $this->service->importContext($table, $exported, 4244); + $this->assertNotNull($imported); + $this->contextIds[] = $imported->getId(); + + $importedContext = $this->contextMapper->findById($imported->getId(), self::OWNER); + $menu = $importedContext->getMenuItems(); + $importedGridPageId = array_key_first(array_filter($importedContext->getPages(), static fn (array $page): bool => $page['page_type'] === Page::TYPE_GRID)); + $this->assertSame( + [['overview', 'page', $importedGridPageId, null, null], ['item_' . $menu[1]['id'], 'url', null, 'book', 'How it works']], + array_map(static fn (array $item): array => [$item['technicalName'], $item['targetType'], $item['targetId'], $item['icon'], $item['description']], $menu), + ); + $this->assertSame([null, $menu[0]['id']], array_column($menu, 'parentId')); + $this->assertNotSame($gridPageId, $importedGridPageId); + } + public function testRemovingTheNodeKeepsTheWidgetAndDeletingTheContextRemovesIt(): void { [$context, $relation, $gridPageId] = $this->createContextWithGridPage(); @@ -270,4 +302,38 @@ public function testCreateAndUpdateResponsesKeepTheirNodeShape(): void { $this->assertSame(['id', 'pageId', 'nodeRelId', 'order'], array_keys($addedContent[0])); $this->assertSame($added['id'], $addedContent[0]['nodeRelId']); } + + public function testASchemeRoundTripKeepsAChildUnderAnAutomaticallyNamedParent(): void { + $source = $this->service->create('Source', 'tables', '', [], self::OWNER, Application::OWNER_TYPE_USER, []); + $this->contextIds[] = $source->getId(); + $this->service->update($source->getId(), self::OWNER, null, null, null, null, MenuItemInput::createListFromInputArray([ + ['label' => 'Help', 'url' => 'https://example.org/help'], + ['label' => 'Docs', 'url' => 'https://example.org/docs', 'parentIndex' => 0], + ])); + $scheme = json_decode(json_encode($this->service->getScheme($source->getId(), self::OWNER)), true); + $this->assertStringStartsWith('item_', $scheme['menuItems'][0]['technicalName']); + $this->assertSame($scheme['menuItems'][0]['technicalName'], $scheme['menuItems'][1]['parent']); + + $target = $this->service->create('Target', 'tables', '', [], self::OWNER, Application::OWNER_TYPE_USER, []); + $this->contextIds[] = $target->getId(); + // the preview lists all tables through the session user, which a test run has not + $tableService = $this->createMock(TableService::class); + $tableService->method('findAll')->willReturn([]); + $container = \OC::$server->getAppContainerForService(TableService::class) ?? \OC::$server; + $realTableService = \OCP\Server::get(TableService::class); + $container->registerService(TableService::class, static fn () => $tableService); + try { + $preview = $this->service->compareSchemeChanges($target->getId(), ['menuItems' => [...$scheme['menuItems'], ['label' => 'Gone', 'targetType' => 'view', 'targetUuid' => 'no-such-view']]], self::OWNER); + } finally { + $container->registerService(TableService::class, static fn () => $realTableService); + } + $this->service->importScheme($target->getId(), 'Target', 'tables', '', [], ['addTables' => [], 'modifyTables' => []], self::OWNER, $scheme['menuItems']); + + $menu = $this->contextMapper->findById($target->getId(), self::OWNER)->getMenuItems(); + $this->assertSame(['Help', 'Docs'], array_column($menu, 'label')); + $this->assertSame([null, $menu[0]['id']], array_column($menu, 'parentId')); + $this->assertSame(['Help', 'Docs'], array_column($preview['menuItems']['to'], 'label')); + $this->assertSame([], $preview['menuItems']['from']); + $this->assertSame([['index' => 2, 'label' => 'Gone']], array_map(static fn (array $skip): array => ['index' => $skip['index'], 'label' => $skip['label']], $preview['menuItems']['skipped'])); + } } diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index 41ca22f9d0..6107b36ff4 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -26,6 +26,7 @@ use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; +use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Model\MenuItemInput; use OCA\Tables\Tests\Unit\Database\DbErrorStub; @@ -49,6 +50,7 @@ final class ContextServiceTest extends TestCase { private MockObject $menuItemMapper; private MockObject $permissionsService; private MockObject $shareService; + private MockObject $logger; private MockObject $dbConnection; private MockObject $tableMapper; private MockObject $viewMapper; @@ -60,7 +62,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 = $logger = $this->createMock(LoggerInterface::class); $this->permissionsService = $this->createMock(PermissionsService::class); $this->tableMapper = $this->createMock(TableMapper::class); $this->viewMapper = $this->createMock(ViewMapper::class); @@ -1031,6 +1033,131 @@ public function testCreateStoresChildrenUnderTheStartPageItem(): void { $this->assertSame([3, null], array_map(static fn (MenuItem $item) => $item->getTargetId(), $inserted)); } + public function testTheSchemeCarriesTheMenuByUuidAndTechnicalName(): void { + $context = $this->contextWithMenu(7, [ + ['id' => 1, 'label' => 'Dashboard', 'icon' => 'home', 'description' => null, 'targetType' => 'page', 'targetId' => 301, 'url' => null, 'technicalName' => 'dashboard', 'order' => 10, 'parentId' => null, 'broken' => false], + ['id' => 2, 'label' => 'Docs', 'icon' => null, 'description' => 'How it works', 'targetType' => 'url', 'targetId' => null, 'url' => 'https://example.org', 'technicalName' => 'docs', 'order' => 20, 'parentId' => 1, 'broken' => false], + ['id' => 3, 'label' => 'Gone', 'icon' => null, 'description' => null, 'targetType' => 'view', 'targetId' => 99, 'url' => null, 'technicalName' => 'gone', 'order' => 30, 'parentId' => null, 'broken' => true], + ['id' => 4, 'label' => 'Not exported', 'icon' => null, 'description' => null, 'targetType' => 'table', 'targetId' => 98, 'url' => null, 'technicalName' => 'hidden', 'order' => 40, 'parentId' => null, 'broken' => false], + ]); + $context->setPages([301 => ['id' => 301, 'page_type' => Page::TYPE_STARTPAGE, 'technical_name' => 'dashboard', 'content' => []]]); + $this->contextMapper->method('findById')->willReturn($context); + + $menu = $this->service->getScheme(7, 'user-1')->getMenuItems(); + + $this->assertSame([ + ['label' => 'Dashboard', 'icon' => 'home', 'description' => null, 'targetType' => 'page', 'technicalName' => 'dashboard', 'url' => null, 'targetUuid' => null, 'targetPage' => 'dashboard', 'parent' => null], + ['label' => 'Docs', 'icon' => null, 'description' => 'How it works', 'targetType' => 'url', 'technicalName' => 'docs', 'url' => 'https://example.org', 'targetUuid' => null, 'targetPage' => null, 'parent' => 'dashboard'], + ], $menu); + } + + public function testImportingASchemeReplacesTheMenuWithWhatCanBeUsedHere(): void { + $context = $this->contextWithMenu(7, []); + $context->setPages([301 => ['id' => 301, 'page_type' => Page::TYPE_STARTPAGE, 'technical_name' => 'dashboard', 'content' => []]]); + $this->contextMapper->method('findById')->willReturn($context); + $this->contextMapper->method('update')->willReturnArgument(0); + $relations = []; + $this->expectNodeInsertion($relations); + $this->expectExistingTargets([], [4 => new Table()]); + $this->pageContentMapper->method('insert')->willReturnCallback(static function (PageContent $content): PageContent { + $content->id = 501; + return $content; + }); + $table = new Table(); + $table->id = 4; + $this->tableMapper->method('findByUuid')->willReturnMap([['table-uuid', $table], ['missing-uuid', null]]); + $this->viewMapper->method('findByUuid')->willReturn(null); + $this->permissionsService->method('canManageNodeById')->willReturn(true); + $inserted = []; + $this->expectMenuReplacement(7, $inserted); + + $this->service->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], 'user-1', [ + ['label' => 'Dashboard', 'targetType' => 'page', 'targetPage' => 'dashboard', 'technicalName' => 'dashboard'], + ['label' => 'Requests', 'targetType' => 'table', 'targetUuid' => 'table-uuid', 'technicalName' => 'requests', 'parent' => 'dashboard'], + ['label' => 'Missing table', 'targetType' => 'table', 'targetUuid' => 'missing-uuid'], + ['label' => 'Missing view', 'targetType' => 'view', 'targetUuid' => 'view-uuid'], + ['label' => 'Missing page', 'targetType' => 'page', 'targetPage' => 'overview'], + ['label' => 'Bad link', 'targetType' => 'url', 'url' => 'javascript:alert(1)'], + ['label' => 'Docs', 'targetType' => 'url', 'url' => 'https://example.org', 'technicalName' => 'requests', 'parent' => 'missing'], + ['label' => 'Guide', 'targetType' => 'url', 'url' => 'https://example.org/guide', 'technicalName' => 'item_4'], + ]); + + $this->assertSame([ + ['Dashboard', 'page', 301, 'dashboard', null], + ['Docs', 'url', null, 'item_2', null], + ['Guide', 'url', null, 'item_3', null], + ['Requests', 'table', 4, 'requests', null], + ], self::describeMenu($inserted)); + $this->assertSame([null, null, null, 1], array_map(static fn (MenuItem $item) => $item->getParentId(), $inserted)); + $this->assertSame([10, 30, 40, 20], array_map(static fn (MenuItem $item) => $item->getOrder(), $inserted)); + $this->assertSame([[4, Application::NODE_TYPE_TABLE]], array_map(static fn (ContextNodeRelation $relation): array => [$relation->getNodeId(), $relation->getNodeType()], $relations)); + } + + public function testImportingASchemeWithoutAMenuLeavesTheMenuAlone(): void { + $this->contextMapper->method('findById')->willReturn($this->contextWithMenu(7, [])); + $this->contextMapper->method('update')->willReturnArgument(0); + $this->menuItemMapper->expects($this->never())->method('deleteAllByContextId'); + + $this->service->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], 'user-1'); + } + + public function testImportingASchemeRefusesToModifyATableThatIsNotHere(): void { + $this->tableMapper->method('findByUuid')->willReturn(null); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('does not exist'); + $this->service->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => [['uuid' => 'missing-uuid', 'title' => 'T', 'columns' => [], 'views' => [], 'columnOrder' => [], 'sort' => []]]], 'user-1'); + } + + public function testAnImportedApplicationKeepsTheMenuItemsItCanUse(): void { + $table = new Table(); + $table->id = 40; + $table->setOwnership('user-1'); + $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context { + $context->id = 8; + return $context; + }); + $this->contextNodeRelMapper->method('insert')->willReturnArgument(0); + $this->pageMapper->method('insert')->willReturnCallback(static function (Page $page): Page { + $page->id = 80; + return $page; + }); + $inserted = []; + $this->menuItemMapper->method('insert')->willReturnCallback(static function (MenuItem $item) use (&$inserted): MenuItem { + $item->id = 900 + count($inserted); + $inserted[] = $item; + return $item; + }); + $this->menuItemMapper->method('insertWithDefaultName')->willReturnCallback(static function (MenuItem $item) use (&$inserted): MenuItem { + $item->id = 900 + count($inserted); + $item->setTechnicalName('item_' . $item->getId()); + $inserted[] = $item; + return $item; + }); + + $this->service->importContext($table, [ + 'name' => 'Projects', + 'nodes' => [['id' => 1, 'node_id' => 4, 'node_type' => 0]], + 'pages' => [3 => ['id' => 3, 'page_type' => Page::TYPE_STARTPAGE, 'technical_name' => 'dashboard']], + 'menuItems' => [ + ['id' => 11, 'label' => 'Dashboard', 'targetType' => 'page', 'targetId' => 3, 'technicalName' => 'dashboard', 'parentId' => null], + ['id' => 12, 'label' => 'Tasks', 'targetType' => 'table', 'targetId' => 4, 'technicalName' => 'tasks', 'parentId' => 11], + ['id' => 13, 'label' => 'Other table', 'targetType' => 'table', 'targetId' => 5, 'technicalName' => 'other', 'parentId' => null], + ['id' => 14, 'label' => 'A view', 'targetType' => 'view', 'targetId' => 6, 'technicalName' => 'a_view', 'parentId' => null], + ['id' => 15, 'label' => 'Docs', 'targetType' => 'url', 'url' => 'https://example.org', 'icon' => 'book', 'technicalName' => 'tasks', 'parentId' => 13], + ['id' => 16, 'label' => 'Guide', 'targetType' => 'url', 'url' => 'https://example.org/guide', 'technicalName' => 'item_903', 'parentId' => null], + ], + ], 4); + + $this->assertSame([ + ['Dashboard', 'page', 80, 'dashboard', null], + ['Docs', 'url', null, 'item_901', 'book'], + ['Guide', 'url', null, 'item_902', null], + ['Tasks', 'table', 40, 'tasks', null], + ], self::describeMenu($inserted)); + $this->assertSame([null, null, null, 900], array_map(static fn (MenuItem $item) => $item->getParentId(), $inserted)); + } + private function buildPageContent(int $id, int $pageId, int $nodeRelId, int $order): PageContent { $pageContent = new PageContent(); $pageContent->id = $id; @@ -1251,4 +1378,167 @@ public function testAMenuOfTheMaximumNumberOfItemsIsSaved(): void { $this->service->update(7, 'user-1', null, null, null, null, MenuItemInput::createListFromInputArray($items)); } + + /** + * Mocks the inserts of an imported application and collects its menu items. + * + * @param list $inserted + */ + private function recordImportedMenu(array &$inserted): Table { + $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context { + $context->id = 8; + return $context; + }); + $this->contextNodeRelMapper->method('insert')->willReturnArgument(0); + $this->pageMapper->method('insert')->willReturnCallback(static function (Page $page): Page { + $page->id = 80; + return $page; + }); + $record = static function (MenuItem $item) use (&$inserted): MenuItem { + $item->id = 900 + count($inserted); + $item->setTechnicalName($item->getTechnicalName() ?? 'item_' . $item->getId()); + $inserted[] = $item; + return $item; + }; + $this->menuItemMapper->method('insert')->willReturnCallback($record); + $this->menuItemMapper->method('insertWithDefaultName')->willReturnCallback($record); + $table = new Table(); + $table->id = 40; + $table->setOwnership('user-1'); + return $table; + } + + public function testAnImportedApplicationMenuStaysOneLevelDeep(): void { + $inserted = []; + $table = $this->recordImportedMenu($inserted); + $url = static fn (int $id, ?int $parentId): array => ['id' => $id, 'label' => 'Item ' . $id, 'targetType' => 'url', 'url' => 'https://example.org/' . $id, 'parentId' => $parentId]; + + $this->service->importContext($table, [ + 'name' => 'Projects', + 'nodes' => [['id' => 1, 'node_id' => 4, 'node_type' => 0]], + 'menuItems' => [$url(11, null), $url(12, 11), $url(13, 12), $url(14, 13)], + ], 4); + + $byLabel = []; + foreach ($inserted as $item) { + $byLabel[$item->getLabel()] = $item; + } + $this->assertNull($byLabel['Item 11']->getParentId()); + $this->assertSame($byLabel['Item 11']->getId(), $byLabel['Item 12']->getParentId()); + $this->assertNull($byLabel['Item 13']->getParentId()); + $this->assertNull($byLabel['Item 14']->getParentId()); + } + + public function testABrokenMenuInAnExportFileIsSkippedWithAReason(): void { + $inserted = []; + $table = $this->recordImportedMenu($inserted); + $warnings = []; + $this->logger->method('warning')->willReturnCallback(static function (string $message) use (&$warnings): void { + $warnings[] = $message; + }); + + $this->service->importContext($table, [ + 'name' => 'Projects', + 'nodes' => [['id' => 1, 'node_id' => 4, 'node_type' => 0]], + 'pages' => [['id' => [3], 'page_type' => Page::TYPE_STARTPAGE, 'technical_name' => 'dashboard']], + 'menuItems' => [ + 'not an item', + ['id' => [11], 'label' => 'Array id', 'targetType' => 'url', 'url' => 'https://example.org'], + ['id' => 12, 'label' => 'Array parent', 'targetType' => 'url', 'url' => 'https://example.org', 'parentId' => ['x']], + ['id' => 13, 'label' => 'Array target', 'targetType' => 'page', 'targetId' => [3]], + ['id' => 14, 'label' => 'Other table', 'targetType' => 'table', 'targetId' => 5], + ['id' => 15, 'label' => 'Kept', 'targetType' => 'url', 'url' => 'https://example.org'], + ], + ], 4); + + $this->assertSame(['Kept'], array_map(static fn (MenuItem $item): string => $item->getLabel(), $inserted)); + $skips = array_values(array_filter($warnings, static fn (string $message): bool => str_starts_with($message, 'Skipping imported menu item'))); + $this->assertCount(5, $skips); + $this->assertStringContainsString('not a number', $skips[1]); + $this->assertStringContainsString('table is not part of this import', $skips[4]); + } + + public function testAnImportedMenuKeepsAtMostTheMaximumNumberOfItems(): void { + $inserted = []; + $table = $this->recordImportedMenu($inserted); + $items = []; + for ($i = 1; $i <= MenuItem::MAX_ITEMS_PER_APPLICATION + 3; $i++) { + $items[] = ['id' => $i, 'label' => 'Item ' . $i, 'targetType' => 'url', 'url' => 'https://example.org/' . $i]; + } + + $this->service->importContext($table, ['name' => 'Projects', 'nodes' => [['id' => 1, 'node_id' => 4, 'node_type' => 0]], 'menuItems' => $items], 4); + + $this->assertCount(MenuItem::MAX_ITEMS_PER_APPLICATION, $inserted); + } + + public function testASchemeMenuStaysOneLevelDeepAndKeepsAutomaticallyNamedParents(): void { + $context = $this->contextWithMenu(7, []); + $this->contextMapper->method('findById')->willReturn($context); + $this->contextMapper->method('update')->willReturnArgument(0); + $inserted = []; + $this->expectMenuReplacement(7, $inserted); + $url = static fn (string $label, string $name, ?string $parent): array => ['label' => $label, 'targetType' => 'url', 'url' => 'https://example.org/' . $label, 'technicalName' => $name, 'parent' => $parent]; + + $this->service->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], 'user-1', [ + $url('A', 'item_7', null), + $url('B', 'item_8', 'item_7'), + $url('C', 'item_9', 'item_8'), + ]); + + $byLabel = []; + foreach ($inserted as $item) { + $byLabel[$item->getLabel()] = $item; + } + $this->assertSame($byLabel['A']->getId(), $byLabel['B']->getParentId()); + $this->assertNull($byLabel['C']->getParentId()); + $this->assertNull($byLabel['A']->getParentId()); + } + + public function testASchemeItemWithAnInvalidNameIsKeptUnderANameOfItsOwn(): void { + $context = $this->contextWithMenu(7, []); + $this->contextMapper->method('findById')->willReturn($context); + $this->contextMapper->method('update')->willReturnArgument(0); + $inserted = []; + $this->expectMenuReplacement(7, $inserted); + + $this->service->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], 'user-1', [ + ['label' => 'Docs', 'targetType' => 'url', 'url' => 'https://example.org', 'technicalName' => 'Not Valid!'], + ]); + + $this->assertSame([['Docs', 'url', null, 'item_1', null]], self::describeMenu($inserted)); + } + + public function testASchemeItemWhoseParentWasSkippedMovesUpAndCanBeAParent(): void { + $context = $this->contextWithMenu(7, []); + $this->contextMapper->method('findById')->willReturn($context); + $this->contextMapper->method('update')->willReturnArgument(0); + $inserted = []; + $this->expectMenuReplacement(7, $inserted); + $url = static fn (string $label, string $name, ?string $parent): array => ['label' => $label, 'targetType' => 'url', 'url' => 'https://example.org/' . $label, 'technicalName' => $name, 'parent' => $parent]; + + $this->service->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], 'user-1', [ + $url('A', 'a', 'missing'), + $url('B', 'b', 'a'), + ]); + + $byLabel = []; + foreach ($inserted as $item) { + $byLabel[$item->getLabel()] = $item; + } + $this->assertNull($byLabel['A']->getParentId()); + $this->assertSame($byLabel['A']->getId(), $byLabel['B']->getParentId()); + } + + public function testASchemeMenuKeepsAtMostTheMaximumNumberOfItems(): void { + $context = $this->contextWithMenu(7, []); + $this->contextMapper->method('findById')->willReturn($context); + $this->contextMapper->method('update')->willReturnArgument(0); + $inserted = []; + $this->expectMenuReplacement(7, $inserted); + $items = array_fill(0, MenuItem::MAX_ITEMS_PER_APPLICATION + 2, ['label' => 'Docs', 'targetType' => 'url', 'url' => 'https://example.org']); + + $this->service->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], 'user-1', $items); + + $this->assertCount(MenuItem::MAX_ITEMS_PER_APPLICATION, $inserted); + } }