diff --git a/cypress/component/ImportContextSchemePreview.cy.js b/cypress/component/ImportContextSchemePreview.cy.js new file mode 100644 index 0000000000..d834d00442 --- /dev/null +++ b/cypress/component/ImportContextSchemePreview.cy.js @@ -0,0 +1,81 @@ +/** + * 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 = [ + { parent: null, title: 'Reports', displayTitle: 'Reports', icon: null, description: null, target: null }, + { parent: 0, title: null, displayTitle: 'Docs', icon: null, description: null, target: { url: 'https://example.org' } }, +] + +describe('Import application scheme preview', () => { + it('sends the menu of the scheme with the import', () => { + mountPreview({ from: [{ displayTitle: 'Old item' }], to: schemeMenu, skipped: [] }) + .then(({ wrapper }) => { + cy.contains('Old item').should('exist') + cy.contains('Docs').should('exist') + cy.wrap(null).then(() => { + expect(lastPayload(wrapper).menu).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('menu') + }) + }) + }) + + it('lists the items the import would skip', () => { + mountPreview({ from: [], to: [], skipped: [{ index: 0, title: 'Missing table', reason: 'Its table is not part of the application.' }] }) + + 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('menu') + }) + }) + }) +}) + +/** + * @param {object|undefined} menu the menu part of the preview response + * @return {Cypress.Chainable} the mount result + */ +function mountPreview(menu) { + return cy.mount(ImportContextSchemePreview, { + props: { + previewData: { + name: { from: 'Intake', to: 'Intake' }, + icon: { from: 'star', to: 'star' }, + description: { from: '', to: '' }, + nodes: { from: [], to: [] }, + addTables: {}, + modifyTables: {}, + ...(menu === undefined ? {} : { menu }), + }, + }, + }) +} + +/** + * @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 434e2137bb..e7955ce01e 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -317,6 +317,10 @@ public function exportScheme(int $contextId): DataResponse { /** * [api v2] Preview the changes that would be applied to a context scheme * + * When the scheme carries a `menu`, the changes hold `menu` 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 * @@ -355,6 +359,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 $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 * * @return DataResponse, array{}>|DataResponse * @@ -365,10 +370,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): DataResponse { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $menu = 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, $menu); $this->db->commit(); return new DataResponse($context->jsonSerialize()); } catch (\InvalidArgumentException $e) { diff --git a/lib/Model/ContextScheme.php b/lib/Model/ContextScheme.php index 3c511632c4..10e9519e89 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 $menu = [], ) { } @@ -45,6 +46,10 @@ public function getTables(): ?array { return $this->tables; } + public function getMenu(): ?array { + return $this->menu; + } + 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, + 'menu' => $this->menu, ]; } } diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index f2e6c4f155..12ffa5e96a 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -69,6 +69,7 @@ public function __construct( private WidgetService $widgetService, private GridWidgetTypes $gridWidgetTypes, private MenuItemMapper $menuItemMapper, + private MenuService $menuService, ) { } @@ -655,15 +656,20 @@ private function importablePages(array $pages): array { } /** + * 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 + * it was exported with. + * * @param Table $table * @param array $context * @param int $tableOldId + * @param string|null $tableOldUuid * * @return Context|null * * @throws \Exception */ - public function importContext(Table $table, array $context, int $tableOldId): ?Context { + public function importContext(Table $table, array $context, int $tableOldId, ?string $tableOldUuid = null): ?Context { // checking if any node in $context['nodes'] matches the old table id $hasMatchingNode = false; if (!empty($context['nodes'])) { @@ -758,9 +764,48 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C } } + if (is_array($context['menu'] ?? null)) { + $tableRelation = reset($sourceRelMap); + $this->logSkippedMenuItems($this->menuService->import( + $this->contextMapper->findById($newContext->getId()), + $context['menu'], + static fn (string $kind, string $uuid): ?int => $kind === 'table' && $uuid === $tableOldUuid && $tableRelation !== false ? $tableRelation->getId() : null, + )); + } + return $newContext; } + /** + * @param list $skipped + */ + private function logSkippedMenuItems(array $skipped): void { + foreach ($skipped as $item) { + $this->logger->warning('Skipping imported menu item ' . $item['index'] . ': ' . $item['reason']); + } + } + + /** + * 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 { + try { + $node = $kind === 'table' ? $this->tableMapper->findByUuid($uuid) : $this->viewMapper->findByUuid($uuid); + } catch (DoesNotExistException) { + return null; + } + if ($node === null) { + return null; + } + $nodeType = $kind === 'table' ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW; + foreach ($context->getNodes() ?? [] as $relId => $relation) { + if ($relation['node_type'] === $nodeType && $relation['node_id'] === $node->getId()) { + return (int)$relId; + } + } + return null; + } + /** * 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 @@ -926,6 +971,7 @@ public function getScheme(int $contextId, string $userId): ContextScheme { array_values($nodes), $this->getPagesWithWidgets($context), array_values($tables), + $this->menuService->export($context), ); } @@ -995,6 +1041,24 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string } } + if (is_array($updateScheme['menu'] ?? null)) { + $schemeUuids = []; + foreach ($changes['nodes']['to'] ?? [] as $node) { + $kind = ($node['node_type'] ?? null) === Application::NODE_TYPE_VIEW ? 'view' : 'table'; + $schemeUuids[$kind][(string)($node['node_uuid'] ?? '')] = true; + } + ['items' => $items, 'skipped' => $skipped] = $this->menuService->readExported( + $context, + $updateScheme['menu'], + static fn (string $kind, string $uuid): ?int => isset($schemeUuids[$kind][$uuid]) ? 0 : null, + ); + $changes['menu'] = [ + 'from' => $currentScheme->getMenu(), + 'to' => array_column($items, 'entry'), + 'skipped' => $skipped, + ]; + } + return $changes; } @@ -1019,7 +1083,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 $menu = 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'])) { @@ -1080,6 +1144,14 @@ public function importScheme(int $contextId, string $name, string $iconName, str } $context = $this->update($contextId, $userId, $name, $iconName, $description, $resolvedNodes); + 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), + )); + } return $context; } diff --git a/lib/Service/MenuService.php b/lib/Service/MenuService.php index 49a8fed24f..d3561dd9a2 100644 --- a/lib/Service/MenuService.php +++ b/lib/Service/MenuService.php @@ -34,6 +34,7 @@ * Reads need access to the application, writes need manage permissions on it. * * @psalm-import-type TablesMenuItem from ResponseDefinitions + * @psalm-type TablesMenuExportItem = array{parent: ?int, title: ?string, displayTitle: string, icon: ?string, description: ?string, target: array{page: string}|array{table: string}|array{view: string}|array{url: string}|null} */ class MenuService { use TTransactional; @@ -227,6 +228,179 @@ public function saveOrder(int $contextId, array $order, string $userId): array { return $this->findAll($contextId, $userId); } + /** + * The menu as an export carries it: a page by its technical name, a table or view by + * uuid, and the group of an item by the group's position in the exported menu. Each + * item keeps the title it shows, for a preview; an import reads only its own title. + * + * @return list + * @throws Exception + */ + public function export(Context $context): array { + $menu = $this->serialize($context, $this->menuItemMapper->findByContextId($context->getId())); + $pages = $context->getPages() ?? []; + $nodes = $context->getNodes() ?? []; + $nodeIds = [Application::NODE_TYPE_TABLE => [], Application::NODE_TYPE_VIEW => []]; + foreach ($menu as $item) { + $node = isset($item['target']['nodeRelId']) ? $nodes[$item['target']['nodeRelId']] : null; + if ($node !== null) { + $nodeIds[$node['node_type']][] = $node['node_id']; + } + } + $nodesById = [ + Application::NODE_TYPE_TABLE => $this->tableMapper->findMany($nodeIds[Application::NODE_TYPE_TABLE]), + Application::NODE_TYPE_VIEW => $this->viewMapper->findMany($nodeIds[Application::NODE_TYPE_VIEW]), + ]; + $positions = []; + $exported = []; + foreach ($menu as $item) { + $target = $item['target']; + if (isset($target['pageId'])) { + $target = ['page' => (string)$pages[$target['pageId']]['technical_name']]; + } elseif (isset($target['nodeRelId'])) { + $node = $nodes[$target['nodeRelId']]; + $entity = $nodesById[$node['node_type']][$node['node_id']] ?? null; + if ($entity === null) { + continue; + } + $target = [$node['node_type'] === Application::NODE_TYPE_TABLE ? 'table' : 'view' => (string)$entity->getUuid()]; + } + $positions[$item['id']] = count($exported); + $exported[] = [ + 'parent' => $item['parentId'] === null ? null : ($positions[$item['parentId']] ?? null), + 'title' => $item['title'], + 'displayTitle' => $item['displayTitle'], + 'icon' => $item['icon'], + 'description' => $item['description'], + 'target' => $target, + ]; + } + return $exported; + } + + /** + * Replaces the menu of an application with an exported one, as {@see self::readExported()} + * reads it, and returns the items it skipped. Callers check permissions. + * + * @param list $exported + * @param \Closure(string, string): ?int $nodeRelIdOf + * @return list + * @throws Exception + */ + public function import(Context $context, array $exported, \Closure $nodeRelIdOf): array { + ['items' => $items, 'skipped' => $skipped] = $this->readExported($context, $exported, $nodeRelIdOf); + + $this->atomic(function () use ($context, $items): void { + $this->menuItemMapper->deleteByContextId($context->getId()); + $idsByIndex = []; + foreach ($items as $position => ['item' => $item, 'index' => $index, 'parent' => $parent]) { + $item->setOrder($position); + $item->setParentId($parent === null ? null : $idsByIndex[$parent]); + $idsByIndex[$index] = $this->menuItemMapper->insert($item)->getId(); + } + }, $this->dbc); + + return $skipped; + } + + /** + * Reads an exported menu for an application. Every item is checked like the API + * checks it, and skipped, with the reason, when it fails, when its target did not + * come along, or past the {@see MenuItem::MAX_ITEMS_PER_APPLICATION}th item. A page is + * found by its technical name, a table or view by `$nodeRelIdOf`, which gives the node + * relation of the application for the kind (`table` or `view`) and uuid, or null. + * An item sits in the group at the position it names when that group came before it + * and was kept; otherwise it moves to the top. + * + * @param list $exported + * @param \Closure(string, string): ?int $nodeRelIdOf + * @return array{items: list, index: int, parent: ?int}>, skipped: list} + */ + public function readExported(Context $context, array $exported, \Closure $nodeRelIdOf): array { + $pageIds = []; + foreach ($context->getPages() ?? [] as $pageId => $page) { + $pageIds[(string)$page['technical_name']] = (int)$pageId; + } + + $items = []; + $skipped = []; + $groups = []; + foreach (array_values($exported) as $index => $entry) { + try { + if (!is_array($entry)) { + throw new BadRequestError('It is not an object.'); + } + if (count($items) >= MenuItem::MAX_ITEMS_PER_APPLICATION) { + throw new BadRequestError('A menu holds at most ' . MenuItem::MAX_ITEMS_PER_APPLICATION . ' items.'); + } + $item = new MenuItem(); + $item->setContextId($context->getId()); + $item->setTitle($this->validateTitle(self::importedText($entry, 'title'))); + $item->setIcon($this->validateIcon(self::importedText($entry, 'icon'))); + $item->setDescription($this->validateDescription(self::importedText($entry, 'description'))); + if (($entry['target'] ?? null) !== null) { + $item->setTarget($this->importedTarget($entry['target'], $pageIds, $nodeRelIdOf)); + } + $this->assertValid($item); + } catch (BadRequestError $e) { + $skipped[] = ['index' => $index, 'title' => is_array($entry) ? self::importedTitle($entry) : null, 'reason' => $e->getMessage()]; + continue; + } + $parent = $entry['parent'] ?? null; + $parent = !$item->isGroup() && is_int($parent) && isset($groups[$parent]) ? $parent : null; + if ($item->isGroup()) { + $groups[$index] = true; + } + $items[] = ['item' => $item, 'entry' => $entry, 'index' => $index, 'parent' => $parent]; + } + return ['items' => $items, 'skipped' => $skipped]; + } + + /** + * @param \Closure(string, string): ?int $nodeRelIdOf + * @param array $pageIds the pages of the application by technical name + * @return array{pageId: int}|array{nodeRelId: int}|array{url: string} + * @throws BadRequestError + */ + private function importedTarget(mixed $target, array $pageIds, \Closure $nodeRelIdOf): array { + if (is_array($target) && count($target) === 1) { + $kind = (string)array_key_first($target); + $value = $target[$kind]; + if ($kind === 'url' && is_string($value)) { + return ['url' => $this->validateUrl($value)]; + } + if ($kind === 'page' && is_string($value)) { + return isset($pageIds[$value]) ? ['pageId' => $pageIds[$value]] : throw new BadRequestError('The application has no page ' . $value . '.'); + } + if (($kind === 'table' || $kind === 'view') && is_string($value)) { + return ['nodeRelId' => $nodeRelIdOf($kind, $value) ?? throw new BadRequestError('Its ' . $kind . ' ' . $value . ' is not part of the application.')]; + } + } + throw new BadRequestError('Its target is not {page}, {table}, {view} or {url}.'); + } + + /** + * A text field of an exported item, or null when it is left out. + * + * @param array $entry + * @throws BadRequestError + */ + private static function importedText(array $entry, string $field): ?string { + $value = $entry[$field] ?? null; + if ($value !== null && !is_string($value)) { + throw new BadRequestError('Its ' . $field . ' is not a text.'); + } + return $value; + } + + /** + * @param array $entry + */ + private static function importedTitle(array $entry): ?string { + $title = $entry['displayTitle'] ?? $entry['title'] ?? null; + return is_string($title) ? $title : null; + } + /** * @throws NotFoundError * @throws PermissionError diff --git a/lib/UserMigration/TablesMigrator.php b/lib/UserMigration/TablesMigrator.php index 29edfa77b2..89a1780ce0 100644 --- a/lib/UserMigration/TablesMigrator.php +++ b/lib/UserMigration/TablesMigrator.php @@ -10,6 +10,7 @@ namespace OCA\Tables\UserMigration; use OCA\Tables\Db\ColumnMapper; +use OCA\Tables\Db\Context; use OCA\Tables\Db\ContextMapper; use OCA\Tables\Db\ContextNodeRelationMapper; use OCA\Tables\Db\RowCellDatetime; @@ -31,6 +32,7 @@ use OCA\Tables\Service\ColumnService; use OCA\Tables\Service\ContextService; use OCA\Tables\Service\FavoritesService; +use OCA\Tables\Service\MenuService; use OCA\Tables\Service\RowService; use OCA\Tables\Service\ShareService; use OCA\Tables\Service\TableService; @@ -84,6 +86,7 @@ public function __construct( protected RowService $rowService, private ContextService $contextService, private ShareService $shareService, + private MenuService $menuService, ) { } @@ -125,7 +128,10 @@ public function export( foreach ($contexts as $context) { $context->setPages($this->contextService->getPagesWithWidgets($context)); } - $exportDestination->addFileContents(self::FILE_CONTEXTS, json_encode($contexts)); + $exportDestination->addFileContents(self::FILE_CONTEXTS, json_encode(array_map( + fn (Context $context): array => $context->jsonSerialize() + ['menu' => $this->menuService->export($context)], + $contexts, + ))); $contextIds = array_map(fn ($c) => $c->getId(), $contexts); $shares = $this->shareMapper->findAllSharesForTablesAndContexts($tableIds, $contextIds); @@ -440,7 +446,7 @@ private function importRows(IImportSource $importSource, Table $newTable, array */ private function importContexts(array $contexts, Table $newTable, array $table, array &$contextIdMap): void { foreach ($contexts as $context) { - $newContext = $this->contextService->importContext($newTable, $context, $table['id']); + $newContext = $this->contextService->importContext($newTable, $context, $table['id'], $table['uuid'] ?? null); if ($newContext !== null) { $contextIdMap[$context['id']] = $newContext->getId(); } diff --git a/openapi.json b/openapi.json index d7278645e7..3c043c7fea 100644 --- a/openapi.json +++ b/openapi.json @@ -13703,6 +13703,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 a `menu`, the changes hold `menu` 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" ], @@ -14059,6 +14060,18 @@ } } } + }, + "menu": { + "type": "array", + "nullable": true, + "default": null, + "description": "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", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } } } } diff --git a/src/modules/modals/ImportContextSchemePreview.vue b/src/modules/modals/ImportContextSchemePreview.vue index a7ac0f1911..1706971114 100644 --- a/src/modules/modals/ImportContextSchemePreview.vue +++ b/src/modules/modals/ImportContextSchemePreview.vue @@ -179,6 +179,41 @@ + + +
+
+ + {{ t('tables', 'Menu') }} + +
+
    +
  • + {{ t('tables', 'From') }}: +
  • +
  • + {{ item.displayTitle }} +
  • +
+
    +
  • + {{ t('tables', 'To') }}: +
  • +
  • + {{ item.displayTitle ?? item.title }} +
  • +
+
+
    +
  • + {{ t('tables', 'Skipped, not available here') }}: +
  • +
  • + {{ item.title }} +
  • +
+
+
@@ -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 a menu the import leaves the menu of the application as it is + if (this.importMenu && this.previewData.menu) { + payload.menu = this.previewData.menu.to + } this.$emit('update', payload) }, diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 8132ea5b8f..dc24155625 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -891,7 +891,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 a `menu`, the changes hold `menu` 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; @@ -7921,6 +7924,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: 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 + * @default null + */ + readonly menu?: readonly { + readonly [key: string]: Record; + }[] | null; }; }; }; diff --git a/tests/unit/Service/ContextPagesRoundTripTest.php b/tests/unit/Service/ContextPagesRoundTripTest.php index 0765e28533..6548611618 100644 --- a/tests/unit/Service/ContextPagesRoundTripTest.php +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -27,6 +27,7 @@ use OCA\Tables\Helper\UserHelper; 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; @@ -86,6 +87,7 @@ protected function setUp(): void { $this->widgetService, new GridWidgetTypes($l10n), $this->createMock(MenuItemMapper::class), + $this->createMock(MenuService::class), ); } diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index a18c3020da..ba5ae6d882 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -82,6 +82,7 @@ protected function setUp(): void { $this->widgetService, new GridWidgetTypes($l10n), $this->createMock(MenuItemMapper::class), + $this->createMock(MenuService::class), ); } diff --git a/tests/unit/Service/MenuServiceTest.php b/tests/unit/Service/MenuServiceTest.php index 32616ab89c..b3ab1cea99 100644 --- a/tests/unit/Service/MenuServiceTest.php +++ b/tests/unit/Service/MenuServiceTest.php @@ -17,6 +17,7 @@ use OCA\Tables\Db\Page; use OCA\Tables\Db\PageContentMapper; use OCA\Tables\Db\PageMapper; +use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\ViewMapper; use OCA\Tables\Db\WidgetMapper; @@ -35,6 +36,7 @@ use OCA\Tables\Service\Support\AuditLogServiceInterface; 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; @@ -48,6 +50,7 @@ class MenuServiceTest extends DatabaseTestCase { private const OWNER = 'menu-service-owner'; private const TABLE = 4151; + private const TABLE_UUID = 'a5d8e2c4-4151-4cde-9f00-000000004151'; private MenuService $menuService; private ContextService $contextService; @@ -74,6 +77,12 @@ protected function setUp(): void { $this->permissionsService->method('canManageContext')->willReturnCallback(fn (): bool => $this->canManage); $tableMapper = $this->createMock(TableMapper::class); $tableMapper->method('findIdToTitleMap')->willReturnCallback(static fn (array $ids): array => in_array(self::TABLE, $ids, true) ? [self::TABLE => 'Projects'] : []); + $table = new Table(); + $table->id = self::TABLE; + $table->setUuid(self::TABLE_UUID); + $tableMapper->method('findMany')->willReturnCallback(static fn (array $ids): array => in_array(self::TABLE, $ids, true) ? [self::TABLE => $table] : []); + $tableMapper->method('findByUuid')->willReturnCallback(static fn (string $uuid): Table => $uuid === self::TABLE_UUID ? $table : throw new DoesNotExistException('No table')); + $this->menuService = new MenuService($contextMapper, $this->menuItemMapper, $this->permissionsService, $tableMapper, $this->createMock(ViewMapper::class), $this->connectionAdapter); $this->contextService = new ContextService( $contextMapper, @@ -94,9 +103,9 @@ protected function setUp(): void { $widgetService, new GridWidgetTypes($l10n), $this->menuItemMapper, + $this->menuService, ); $this->pageService = new PageService($contextMapper, $pageMapper, $pageContentMapper, $widgetService, $this->permissionsService, new GridWidgetTypes($l10n), $this->connectionAdapter, $this->menuItemMapper); - $this->menuService = new MenuService($contextMapper, $this->menuItemMapper, $this->permissionsService, $tableMapper, $this->createMock(ViewMapper::class), $this->connectionAdapter); } protected function tearDown(): void { @@ -306,6 +315,91 @@ public function testOnlyAManagerChangesTheMenu(\Closure $change): void { $change($this->menuService, $contextId, $itemId); } + public function testAMenuGoesThroughASchemeExportAndImport(): void { + [$contextId, , $pageId, $nodeRelId] = $this->createApplication(); + $group = $this->menuService->create($contextId, 'Links', 'star', 'Elsewhere', null, self::OWNER); + $site = $this->menuService->create($contextId, null, null, null, ['url' => 'https://nextcloud.com'], self::OWNER); + $this->menuService->create($contextId, null, null, null, ['nodeRelId' => $nodeRelId], self::OWNER); + $this->menuService->create($contextId, null, null, null, ['pageId' => $pageId], self::OWNER); + $menu = $this->menuService->findAll($contextId, self::OWNER); + $this->menuService->saveOrder($contextId, array_map(static fn (array $item): array => ['id' => $item['id'], 'parentId' => $item['id'] === $site['id'] ? $group['id'] : null], $menu), self::OWNER); + $exported = $this->menuService->export($this->contextService->findById($contextId, self::OWNER)); + + $imported = $this->contextService->create('Imported', 'tables', '', [['id' => self::TABLE, 'type' => Application::NODE_TYPE_TABLE]], self::OWNER, Application::OWNER_TYPE_USER); + $this->contextIds[] = $imported->getId(); + $this->contextService->importScheme($imported->getId(), 'Imported', 'tables', '', [['node_type' => Application::NODE_TYPE_TABLE, 'node_uuid' => self::TABLE_UUID, 'permissions' => 1]], ['addTables' => [], 'modifyTables' => []], self::OWNER, $exported); + + $shape = static fn (array $items): array => array_map(static fn (array $item): array => [$item['displayTitle'], $item['parent'], $item['target'], $item['icon']], $items); + $expected = $shape($exported); + unset($expected[4]); + $expected[0][0] = 'Imported'; + $this->assertSame(array_values($expected), $shape($this->menuService->export($this->contextService->findById($imported->getId(), self::OWNER)))); + $this->assertSame(['dashboard', 1], [$exported[0]['target']['page'], $exported[2]['parent']]); + } + + public function testAMovedUsersMenuKeepsItsTableItsPagesAndItsLinks(): void { + [$contextId, , $pageId, $nodeRelId] = $this->createApplication(); + $this->menuService->create($contextId, 'Projects', null, null, ['nodeRelId' => $nodeRelId], self::OWNER); + $this->menuService->create($contextId, null, null, null, ['url' => 'https://nextcloud.com'], self::OWNER); + $context = $this->contextService->findById($contextId, self::OWNER); + $exported = json_decode(json_encode($context->jsonSerialize() + ['pages' => $this->contextService->getPagesWithWidgets($context), 'menu' => $this->menuService->export($context)]), true); + $movedTable = new Table(); + $movedTable->id = self::TABLE; + $movedTable->setOwnership(self::OWNER); + $exported['menu'][] = ['title' => 'Elsewhere', 'target' => ['table' => 'f0000000-0000-4000-8000-000000000000']]; + + $moved = $this->contextService->importContext($movedTable, $exported, self::TABLE, self::TABLE_UUID); + $this->contextIds[] = $moved->getId(); + + $menu = $this->menuService->findAll($moved->getId(), self::OWNER); + $movedContext = $this->contextService->findById($moved->getId(), self::OWNER); + $this->assertSame(['Sales', 'Projects', 'https://nextcloud.com'], array_column($menu, 'displayTitle')); + $this->assertSame([['pageId' => (int)array_key_first(array_filter($movedContext->getPages(), static fn (array $page): bool => $page['page_type'] === Page::TYPE_STARTPAGE))], ['nodeRelId' => (int)array_key_first($movedContext->getNodes())]], [$menu[0]['target'], $menu[1]['target']]); + } + + public static function unusableItems(): array { + $link = ['title' => 'Site', 'target' => ['url' => 'https://nextcloud.com']]; + return [ + 'not an object' => [['Reports'], 'It is not an object.'], + 'a page that did not come along' => [[['target' => ['page' => 'missing']]], 'The application has no page missing.'], + 'a table that did not come along' => [[['target' => ['table' => 'f0000000-0000-4000-8000-000000000000']]], 'is not part of the application'], + 'an unknown kind of target' => [[['title' => 'Row', 'target' => ['row' => 'x']]], 'Its target is not'], + 'a link that is not http' => [[['target' => ['url' => 'ftp://nextcloud.com']]], 'A link is an http or https address'], + 'a group without a title' => [[['target' => null]], 'A group needs a title.'], + 'a title that is not a text' => [[['title' => ['Site'], 'target' => ['url' => 'https://nextcloud.com']]], 'Its title is not a text.'], + 'two targets' => [[['target' => ['url' => 'https://nextcloud.com', 'page' => 'dashboard']]], 'Its target is not'], + 'one item past the limit' => [array_fill(0, MenuItem::MAX_ITEMS_PER_APPLICATION + 1, $link), 'A menu holds at most 200 items.'], + ]; + } + + /** + * @dataProvider unusableItems + */ + public function testAnImportSkipsTheItemsItCannotUseWithTheReason(array $exported, string $reason): void { + [$contextId] = $this->createApplication(); + + $skipped = $this->menuService->import($this->contextService->findById($contextId, self::OWNER), $exported, static fn (): ?int => null); + + $this->assertCount(1, $skipped); + $this->assertStringContainsString($reason, $skipped[0]['reason']); + $this->assertCount(count($exported) - 1, $this->menuService->findAll($contextId, self::OWNER)); + } + + public function testAnImportedItemMovesToTheTopWhenItsGroupDidNotComeFirst(): void { + [$contextId] = $this->createApplication(); + $exported = [ + ['title' => 'Site', 'parent' => 1, 'target' => ['url' => 'https://nextcloud.com']], + ['title' => 'Links', 'target' => null], + ['title' => 'Docs', 'parent' => 1, 'target' => ['url' => 'https://docs.nextcloud.com']], + ['title' => 'Help', 'parent' => 0, 'target' => ['url' => 'https://help.nextcloud.com']], + ]; + + $this->menuService->import($this->contextService->findById($contextId, self::OWNER), $exported, static fn (): ?int => null); + + $menu = $this->menuService->findAll($contextId, self::OWNER); + $this->assertSame([null, null, $menu[1]['id'], null], array_column($menu, 'parentId')); + } + /** * An application "Sales" with its start page, a grid page "Overview" and a table. * diff --git a/tests/unit/TablesMigratorTest.php b/tests/unit/TablesMigratorTest.php index 7de0be2617..5786cb3ce0 100644 --- a/tests/unit/TablesMigratorTest.php +++ b/tests/unit/TablesMigratorTest.php @@ -26,6 +26,7 @@ use OCA\Tables\Service\ColumnService; use OCA\Tables\Service\ContextService; use OCA\Tables\Service\FavoritesService; +use OCA\Tables\Service\MenuService; use OCA\Tables\Service\RowService; use OCA\Tables\Service\ShareService; use OCA\Tables\Service\TableService; @@ -60,6 +61,7 @@ class TablesMigratorTest extends TestCase { private $rowService; private $contextService; private $shareService; + private $menuService; protected function setUp(): void { $this->l10n = $this->createMock(IL10N::class); @@ -83,6 +85,7 @@ protected function setUp(): void { $this->contextService = $this->createMock(ContextService::class); $this->shareService = $this->createMock(ShareService::class); + $this->menuService = $this->createMock(MenuService::class); $this->migrator = new TablesMigrator( $this->l10n, $this->tableMapper, @@ -103,7 +106,8 @@ protected function setUp(): void { $this->columnService, $this->rowService, $this->contextService, - $this->shareService + $this->shareService, + $this->menuService, ); } @@ -150,7 +154,7 @@ public function testExportSuccess(): void { $this->assertTrue(true); } - public function testExportWritesTheWidgetsOfEveryContextPage(): void { + public function testExportWritesTheWidgetsAndTheMenuOfEveryContext(): void { $user = $this->createMock(IUser::class); $exportDestination = $this->createMock(IExportDestination::class); $user->method('getUID')->willReturn('user1'); @@ -169,6 +173,9 @@ public function testExportWritesTheWidgetsOfEveryContextPage(): void { $this->contextService->method('getPagesWithWidgets')->with($context) ->willReturn([5 => ['id' => 5, 'page_type' => 'grid', 'content' => [], 'widgets' => [$widget]]]); + $menu = [['parent' => null, 'title' => 'Site', 'displayTitle' => 'Site', 'icon' => null, 'description' => null, 'target' => ['url' => 'https://nextcloud.com']]]; + $this->menuService->method('export')->with($context)->willReturn($menu); + $files = []; $exportDestination->method('addFileContents')->willReturnCallback(static function (string $path, string $content) use (&$files): void { $files[$path] = $content; @@ -177,6 +184,7 @@ public function testExportWritesTheWidgetsOfEveryContextPage(): void { $contexts = json_decode($files['contexts.json'], true); $this->assertSame([$widget], $contexts[0]['pages'][5]['widgets']); + $this->assertSame($menu, $contexts[0]['menu']); } public function testExportFailure(): void {