From 37ee83def16867281690c870e9ed53c8d8ea75b9 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 16 Oct 2026 11:05:11 +0200 Subject: [PATCH] feat(contexts): export and import menus and pages An exported application scheme carries its menu: tables and views by uuid, pages by technical name, parents by technical name; an item whose target is gone is left out. The import validates the scheme in the controller, then sets the nodes, then the pages through importSchemePages (matched by technical name), then the menu, skipping items whose target does not exist here or cannot be managed. Grid views of an older scheme become grid pages; a data widget without a usable table keeps its place without a source. The import dialog gets a "Menu and pages" switch. Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Controller/ContextController.php | 22 +- lib/Model/ContextScheme.php | 89 +++++ lib/Service/ContextSchemeService.php | 325 ++++++++++++++++++ lib/Service/ContextService.php | 87 +++-- openapi.json | 58 +++- playwright/e2e/application.spec.ts | 159 ++++++++- src/modules/modals/ImportContextScheme.vue | 35 +- .../modals/ImportContextSchemePreview.vue | 69 ++++ src/types/openapi/openapi.ts | 24 +- .../unit/Controller/ContextControllerTest.php | 65 +++- tests/unit/Model/ContextSchemeTest.php | 125 +++++++ .../Service/ContextPagesRoundTripTest.php | 2 + .../unit/Service/ContextSchemeServiceTest.php | 187 ++++++++++ tests/unit/Service/ContextServiceTest.php | 116 ++++++- 14 files changed, 1326 insertions(+), 37 deletions(-) create mode 100644 lib/Service/ContextSchemeService.php create mode 100644 tests/unit/Model/ContextSchemeTest.php create mode 100644 tests/unit/Service/ContextSchemeServiceTest.php diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index 2aaf8b734f..2b2557e325 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -16,6 +16,7 @@ use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Middleware\Attribute\RequirePermission; +use OCA\Tables\Model\ContextScheme; use OCA\Tables\Model\MenuItemInput; use OCA\Tables\ResponseDefinitions; use OCA\Tables\Service\ColumnService; @@ -361,7 +362,9 @@ 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 $pages Pages of the scheme; each updates the page with the same technical name or is added, with its widgets + * @param list>|null $pages Pages of the scheme; each updates the page with the same technical name or is added, with its widgets; leave it out to keep the current pages + * @psalm-param ?list $menuItems Menu of the context: tables and views by uuid, pages by technical name, parents by technical name; leave it out to keep the current menu + * @param list> $gridViews Views without a table from a scheme of an older version; each becomes a page * * @return DataResponse, array{}>|DataResponse * @@ -372,16 +375,24 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] - public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $pages = null): DataResponse { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $pages = null, ?array $menuItems = null, array $gridViews = []): DataResponse { + try { + $scheme = ContextScheme::createFromInputArray(['name' => $name, 'icon' => $iconName, 'description' => $description, 'nodes' => $nodes, 'tables' => $tables, 'menuItems' => $menuItems, 'pages' => $pages, 'gridViews' => $gridViews]); + } catch (\InvalidArgumentException $e) { + return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); + } try { $this->db->beginTransaction(); - $context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId, $pages === null ? null : array_values($pages)); + $context = $this->contextService->importScheme($contextId, $scheme, $this->userId); $this->db->commit(); return new DataResponse($context->jsonSerialize()); } catch (\InvalidArgumentException $e) { $this->db->rollBack(); return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); - } catch (Exception|MultipleObjectsReturnedException $e) { + } catch (BadRequestError $e) { + $this->db->rollBack(); + return $this->handleBadRequestError($e); + } catch (Exception|MultipleObjectsReturnedException|InternalError $e) { $this->db->rollBack(); return $this->handleError($e); } catch (PermissionError $e) { @@ -390,6 +401,9 @@ public function importScheme(int $contextId, string $name, string $iconName, str } catch (DoesNotExistException $e) { $this->db->rollBack(); return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e)); + } catch (NotFoundError $e) { + $this->db->rollBack(); + return $this->handleNotFoundError($e); } } diff --git a/lib/Model/ContextScheme.php b/lib/Model/ContextScheme.php index 3c511632c4..6a07aa675e 100644 --- a/lib/Model/ContextScheme.php +++ b/lib/Model/ContextScheme.php @@ -7,6 +7,7 @@ namespace OCA\Tables\Model; +use InvalidArgumentException; use JsonSerializable; class ContextScheme implements JsonSerializable { @@ -18,9 +19,76 @@ public function __construct( protected ?array $nodes = [], protected ?array $pages = [], protected ?array $tables = [], + protected ?array $menuItems = [], + protected array $gridViews = [], ) { } + /** + * Builds the scheme from an import request and refuses what the import could not handle. + * + * @param array $data + * @throws InvalidArgumentException + */ + public static function createFromInputArray(array $data): self { + $tables = $data['tables'] ?? null; + if (!is_array($tables) || !isset($tables['addTables'], $tables['modifyTables']) || !is_array($tables['addTables']) || !is_array($tables['modifyTables'])) { + throw new InvalidArgumentException('Invalid tables structure provided: expected addTables and modifyTables lists.'); + } + foreach (['nodes', 'pages', 'menuItems', 'gridViews'] as $list) { + if (isset($data[$list]) && !is_array($data[$list])) { + throw new InvalidArgumentException('The scheme property "' . $list . '" must be a list.'); + } + } + if (!is_string($data['name'] ?? null) || trim($data['name']) === '') { + throw new InvalidArgumentException('A scheme needs a name.'); + } + foreach (['icon', 'description'] as $optional) { + if (isset($data[$optional]) && !is_string($data[$optional])) { + throw new InvalidArgumentException('The scheme property "' . $optional . '" must be a string.'); + } + } + foreach ($data['pages'] ?? [] as $page) { + if (!is_array($page) || (isset($page['widgets']) && !is_array($page['widgets']))) { + throw new InvalidArgumentException('Every page of a scheme must be an object, with its widgets as a list.'); + } + } + self::assertGridViewsValid($data['gridViews'] ?? []); + return new self( + name: $data['name'], + icon: $data['icon'] ?? null, + description: $data['description'] ?? null, + nodes: array_values($data['nodes'] ?? []), + // A scheme without pages or without a menu leaves those of the application alone + pages: isset($data['pages']) ? array_values($data['pages']) : null, + tables: $tables, + menuItems: isset($data['menuItems']) ? array_values($data['menuItems']) : null, + gridViews: array_values($data['gridViews'] ?? []), + ); + } + + /** + * Grid views come from schemes of an older version, which kept pages in views without a table. + * Every one needs a uuid and a title, and its grid is an object. + * + * @throws InvalidArgumentException + */ + private static function assertGridViewsValid(array $gridViews): void { + foreach ($gridViews as $gridView) { + if (!is_array($gridView)) { + throw new InvalidArgumentException('Every entry of "gridViews" must be an object.'); + } + foreach (['uuid', 'title'] as $required) { + if (!is_string($gridView[$required] ?? null) || trim($gridView[$required]) === '') { + throw new InvalidArgumentException('Every entry of "gridViews" needs a "' . $required . '".'); + } + } + if (isset($gridView['grid']) && !is_array($gridView['grid'])) { + throw new InvalidArgumentException('The grid view property "grid" must be an object.'); + } + } + } + public function getName(): ?string { return $this->name ?? ''; } @@ -37,6 +105,10 @@ public function getNodes(): ?array { return $this->nodes; } + /** + * Pages with their widgets; a data widget names its node by its id in the nodes of the scheme. + * Null when the scheme has no pages, which leaves the pages of the application as they are. + */ public function getPages(): ?array { return $this->pages; } @@ -45,6 +117,22 @@ public function getTables(): ?array { return $this->tables; } + /** + * Menu items point at tables and views by uuid and at pages by technical name, so a scheme can + * be imported on another instance. Null when the scheme has no menu, which leaves the + * application's menu as it is. + */ + public function getMenuItems(): ?array { + return $this->menuItems; + } + + /** + * Views without a table from a scheme of an older version; the import turns each into a page. + */ + public function getGridViews(): array { + return $this->gridViews; + } + public function jsonSerialize(): mixed { return [ 'name' => $this->name, @@ -53,6 +141,7 @@ public function jsonSerialize(): mixed { 'nodes' => $this->nodes, 'pages' => $this->pages, 'tables' => $this->tables, + 'menuItems' => $this->menuItems, ]; } } diff --git a/lib/Service/ContextSchemeService.php b/lib/Service/ContextSchemeService.php new file mode 100644 index 0000000000..214b2eb410 --- /dev/null +++ b/lib/Service/ContextSchemeService.php @@ -0,0 +1,325 @@ +> + */ + public function exportMenuItems(Context $context): array { + $menuItems = []; + $pages = $context->getPages() ?? []; + $technicalNamesById = array_column($context->getMenuItems() ?? [], 'technicalName', 'id'); + foreach ($context->getMenuItems() ?? [] as $item) { + if ($item['broken'] ?? false) { + continue; + } + $exported = [ + 'label' => $item['label'], + 'icon' => $item['icon'], + 'targetType' => $item['targetType'], + 'targetUuid' => null, + 'targetPageTechnicalName' => null, + 'url' => $item['url'], + 'technicalName' => $item['technicalName'], + 'parentTechnicalName' => $item['parentId'] !== null ? ($technicalNamesById[$item['parentId']] ?? null) : null, + ]; + try { + match ($item['targetType']) { + MenuItem::TARGET_TABLE => $exported['targetUuid'] = $this->tableMapper->find((int)$item['targetId'])->getUuid(), + MenuItem::TARGET_VIEW => $exported['targetUuid'] = $this->viewMapper->find((int)$item['targetId'])->getUuid(), + MenuItem::TARGET_PAGE => $exported['targetPageTechnicalName'] = $pages[$item['targetId']]['technical_name'] ?? throw new DoesNotExistException('Page ' . $item['targetId'] . ' is not part of the application'), + default => null, + }; + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception $e) { + $this->logger->warning('Menu item target could not be exported, the item is skipped', ['exception' => $e]); + continue; + } + $menuItems[] = $exported; + } + return $menuItems; + } + + /** + * The grid views of a scheme of an older version as scheme pages. Each page is named after + * the grid view's technical name, else its title, apart from the pages the scheme already + * names. Its widgets keep their place; a data widget names its table or view by uuid, which + * becomes its source: a node of the scheme with that uuid, else a node to add when the importer + * can manage it. A data widget without either stays without a source and asks for a new table. + * + * @param list> $gridViews + * @param list> $schemeNodes the nodes of the scheme, with their exported relation id + * @param array $reserved technical names the pages of the scheme use + * @return array{pages: list>, nodes: list, technicalNames: array} the pages, the nodes to add, and the technical name of the page of every grid view by its uuid + */ + public function pagesFromGridViews(array $gridViews, array $schemeNodes, array $reserved, string $userId): array { + $names = []; + foreach ($gridViews as $key => $gridView) { + $technicalName = $gridView['technicalName'] ?? $gridView['technical_name'] ?? null; + $names[$key] = [ + 'name' => is_string($technicalName) ? $technicalName : null, + 'fallback' => TechnicalNameHelper::pageFallback(false, $gridView['title']), + ]; + } + $names = TechnicalNameHelper::assign($names, $reserved); + + $nodeIdsByUuid = []; + foreach ($schemeNodes as $node) { + if (isset($node['id']) && is_string($node['node_uuid'] ?? null)) { + $nodeIdsByUuid[(int)$node['node_type'] . ':' . $node['node_uuid']] = (int)$node['id']; + } + } + $addedNodes = []; + $pages = []; + $technicalNames = []; + foreach ($gridViews as $key => $gridView) { + $widgets = []; + foreach ($this->gridViewWidgets($gridView['grid'] ?? null) as $widget) { + if (array_key_exists('target', $widget)) { + $target = $widget['target']; + unset($widget['target']); + $nodeRelId = $target === null ? null : $this->gridViewSourceNode($target, $nodeIdsByUuid, $addedNodes, $userId); + $widget['source'] = $nodeRelId === null ? null : ['nodeRelId' => $nodeRelId]; + } + $widgets[] = $widget; + } + $pages[] = [ + 'page_type' => Page::TYPE_GRID, + 'title' => mb_substr(trim($gridView['title']), 0, self::PAGE_TITLE_MAX_LENGTH), + 'emoji' => is_string($gridView['emoji'] ?? null) && $gridView['emoji'] !== '' ? $gridView['emoji'] : null, + 'description' => is_string($gridView['description'] ?? null) && $gridView['description'] !== '' ? $gridView['description'] : null, + 'technical_name' => $names[$key], + 'widgets' => $widgets, + ]; + $technicalNames[$gridView['uuid']] = $names[$key]; + } + return ['pages' => $pages, 'nodes' => array_values($addedNodes), 'technicalNames' => $technicalNames]; + } + + /** + * Menu items of a scheme with their targets turned into local ids. Items that are invalid, or + * whose target does not exist here or cannot be managed by the importer, are skipped. An item + * of an older scheme that opened a grid view opens the page that grid view became. A child + * names its parent by technical name; when the parent was skipped, the child moves to the top. + * A technical name that an earlier item already uses is dropped, so the item gets a derived one. + * + * @param list $menuItems + * @param array $pageIds local page ids by technical name + * @param array $gridViewPages technical names of the pages that grid views became, by grid view uuid + * @return list + */ + public function resolveMenuItems(array $menuItems, string $userId, array $pageIds, array $gridViewPages): array { + $prepared = []; + $usedTechnicalNames = []; + foreach ($menuItems as $item) { + if (!is_array($item)) { + continue; + } + $targetType = $item['targetType'] ?? MenuItem::TARGET_URL; + $targetUuid = is_string($item['targetUuid'] ?? null) ? $item['targetUuid'] : null; + if ($targetType === MenuItem::TARGET_VIEW && $targetUuid !== null && isset($gridViewPages[$targetUuid])) { + $item['targetType'] = MenuItem::TARGET_PAGE; + $item['targetId'] = $pageIds[$gridViewPages[$targetUuid]] ?? null; + } elseif ($targetType === MenuItem::TARGET_PAGE) { + $pageName = $item['targetPageTechnicalName'] ?? null; + $item['targetId'] = is_string($pageName) ? ($pageIds[$pageName] ?? null) : null; + } elseif ($targetType !== MenuItem::TARGET_URL) { + $item['targetId'] = is_string($targetType) ? $this->resolveTargetId($targetType, $targetUuid, $userId) : null; + } + $parentTechnicalName = is_string($item['parentTechnicalName'] ?? null) ? $item['parentTechnicalName'] : null; + unset($item['targetUuid'], $item['targetPageTechnicalName'], $item['parentTechnicalName'], $item['parentIndex']); + try { + MenuItemInput::createFromInputArray($item); + } catch (InvalidArgumentException $e) { + $this->logger->info('Menu item skipped on import: {reason}', ['reason' => $e->getMessage()]); + continue; + } + $technicalName = $item['technicalName'] ?? null; + if (is_string($technicalName) && isset($usedTechnicalNames[$technicalName])) { + unset($item['technicalName']); + } elseif (is_string($technicalName)) { + $usedTechnicalNames[$technicalName] = true; + } + $prepared[] = ['item' => $item, 'parent' => $parentTechnicalName]; + } + + $parentPositions = []; + foreach ($prepared as $position => $entry) { + $technicalName = $entry['item']['technicalName'] ?? null; + if ($entry['parent'] === null && is_string($technicalName) && !isset($parentPositions[$technicalName])) { + $parentPositions[$technicalName] = $position; + } + } + $items = []; + foreach ($prepared as $entry) { + $entry['item']['parentIndex'] = $entry['parent'] === null ? null : ($parentPositions[$entry['parent']] ?? null); + $items[] = $entry['item']; + } + return MenuItemInput::createListFromInputArray($items); + } + + /** + * The widgets of a grid view, in the form of an exported page widget, placed as its layout + * placed them. A data widget carries its table or view as `target` ({type, uuid} or null). + * + * @return list> + */ + private function gridViewWidgets(mixed $grid): array { + $layout = []; + foreach (is_array($grid) && is_array($grid['layout'] ?? null) ? $grid['layout'] : [] as $item) { + if (is_array($item) && isset($item['widgetId'])) { + $layout[(string)$item['widgetId']] = $item; + } + } + $widgets = []; + foreach (is_array($grid) && is_array($grid['widgets'] ?? null) ? $grid['widgets'] : [] as $gridWidget) { + $position = is_array($gridWidget) ? ($layout[(string)($gridWidget['id'] ?? '')] ?? null) : null; + if ($position === null || !is_string($gridWidget['type'] ?? null)) { + continue; + } + $configuration = is_array($gridWidget['configuration'] ?? null) ? $gridWidget['configuration'] : []; + $content = is_array($gridWidget['content'] ?? null) ? $gridWidget['content'] : []; + $widget = [ + 'type' => $gridWidget['type'], + 'title' => $this->gridViewWidgetTitle($gridWidget['type'], $configuration), + 'gridX' => (int)($position['gridX'] ?? 0), + 'gridY' => (int)($position['gridY'] ?? 0), + 'gridWidth' => (int)($position['gridWidth'] ?? 1), + 'gridHeight' => (int)($position['gridHeight'] ?? 1), + ]; + switch ($gridWidget['type']) { + case GridWidgetTypes::TYPE_HEADER: + $widget['settings'] = array_filter([ + 'heading' => $content['title'] ?? null, + 'subheading' => $content['subtitle'] ?? null, + 'textAlign' => $content['textAlign'] ?? null, + 'backgroundColor' => $content['backgroundColor'] ?? null, + 'textColor' => $content['textColor'] ?? null, + ], static fn (mixed $value): bool => $value !== null); + break; + case GridWidgetTypes::TYPE_TEXT: + $widget['settings'] = array_filter(['text' => $content['text'] ?? null], static fn (mixed $value): bool => $value !== null); + break; + case GridWidgetTypes::TYPE_DATA: + $target = is_array($content['target'] ?? null) ? $content['target'] : []; + $widget['settings'] = []; + $widget['target'] = in_array($target['type'] ?? null, [MenuItem::TARGET_TABLE, MenuItem::TARGET_VIEW], true) && is_string($target['uuid'] ?? null) + ? ['type' => $target['type'], 'uuid' => $target['uuid']] + : null; + break; + default: + $widget['settings'] = $content; + } + $widgets[] = $widget; + } + return $widgets; + } + + /** + * The title a grid view widget showed: its configured title, when the widget showed one. + * Text widgets showed their title unless told not to, the other types only when told to. + * + * @param array $configuration + */ + private function gridViewWidgetTitle(string $type, array $configuration): ?string { + $title = is_string($configuration['title'] ?? null) ? trim($configuration['title']) : ''; + $showTitle = $configuration['showTitle'] ?? ($type === GridWidgetTypes::TYPE_TEXT); + return $title !== '' && $showTitle === true ? $title : null; + } + + /** + * The scheme node a grid view data widget shows: the node of the scheme with the uuid of its + * target, else a node added for it when the target exists here and the importer can manage it. + * + * @param array{type: string, uuid: string} $target + * @param array $nodeIdsByUuid scheme node ids by node type and uuid + * @param array $addedNodes the nodes added so far, by node type and uuid + */ + private function gridViewSourceNode(array $target, array &$nodeIdsByUuid, array &$addedNodes, string $userId): ?int { + $nodeType = $target['type'] === MenuItem::TARGET_TABLE ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW; + $key = $nodeType . ':' . $target['uuid']; + if (isset($nodeIdsByUuid[$key])) { + return $nodeIdsByUuid[$key]; + } + $nodeId = $this->resolveTargetId($target['type'], $target['uuid'], $userId); + if ($nodeId === null) { + $this->logger->info('Grid view widget imported without a source, its table or view is not available here'); + return null; + } + // Scheme node ids are positive; the added nodes count down from -1 so they never collide + $id = -1 - count($addedNodes); + $addedNodes[$key] = ['id' => $id, 'node_type' => $nodeType, 'node_uuid' => $target['uuid'], 'permissions' => Application::PERMISSION_READ]; + $nodeIdsByUuid[$key] = $id; + return $id; + } + + /** + * The local id of a table or view named by uuid, or null when it does not exist here or the + * importer cannot manage it. + */ + private function resolveTargetId(string $targetType, ?string $uuid, string $userId): ?int { + $id = $uuid === null ? null : $this->localId($targetType, $uuid); + if ($id === null) { + return null; + } + $nodeType = $targetType === MenuItem::TARGET_TABLE ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW; + return $this->permissionsService->canManageNodeById($nodeType, $id, $userId) ? $id : null; + } + + /** + * The id of the table or view with the given uuid here, or null. + */ + private function localId(string $targetType, string $uuid): ?int { + if ($uuid === '') { + return null; + } + try { + return match ($targetType) { + MenuItem::TARGET_TABLE => $this->tableMapper->findByUuid($uuid)->getId(), + MenuItem::TARGET_VIEW => $this->viewMapper->findByUuid($uuid)?->getId(), + default => null, + }; + } catch (DoesNotExistException|MultipleObjectsReturnedException|Exception) { + return null; + } + } +} diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index 1a40b2e722..ae93bfc718 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -71,6 +71,7 @@ public function __construct( private GridWidgetTypes $gridWidgetTypes, private MenuItemMapper $menuItemMapper, private IL10N $l10n, + private ContextSchemeService $contextSchemeService, ) { } @@ -1049,7 +1050,7 @@ private function readImportedWidget(mixed $widgetArr, array $relIdMap): ?array { * the application with the same technical name, or is added as a new page; its * widgets replace the widgets of that page. Pages the scheme does not name stay. * - * @param list $schemeNodes the nodes of the scheme with their exported relation id + * @param list}> $schemeNodes the nodes of the scheme with their exported relation id * @param array $schemePages * @throws Exception * @throws NotFoundError @@ -1135,8 +1136,8 @@ public function importSchemePages(int $contextId, array $schemeNodes, array $sch */ public function getScheme(int $contextId, string $userId): ContextScheme { $context = $this->contextMapper->findById($contextId); - $tableService = \OCP\Server::get(TableService::class); - $viewService = \OCP\Server::get(ViewService::class); + $tableService = $this->tableService(); + $viewService = $this->viewService(); $nodes = $context->getNodes(); $tables = []; @@ -1174,6 +1175,7 @@ public function getScheme(int $contextId, string $userId): ContextScheme { array_values($nodes), $this->getPagesWithWidgets($context), array_values($tables), + $this->contextSchemeService->exportMenuItems($context), ); } @@ -1194,7 +1196,7 @@ public function getScheme(int $contextId, string $userId): ContextScheme { public function compareSchemeChanges(int $contextId, array $updateScheme, string $userId): array { $context = $this->contextMapper->findById($contextId); $currentScheme = $this->getScheme($contextId, $userId); - $tableService = \OCP\Server::get(TableService::class); + $tableService = $this->tableService(); $changes = [ 'name' => [ @@ -1247,15 +1249,10 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string } /** - * @param int $contextId - * @param string $name - * @param string $iconName - * @param string $description - * @param array $nodes - * @param array $tables - * @param string $userId - * - * @return Context + * Imports a scheme into an application: its tables, then its nodes, then its pages, whose data + * widgets show those nodes, then its menu, which opens those pages. A scheme without pages or + * without a menu leaves those of the application as they are. The grid views of a scheme of an + * older version become pages. * * @throws BadRequestError * @throws ContainerExceptionInterface @@ -1267,16 +1264,32 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string * @throws NotFoundExceptionInterface * @throws PermissionError */ - public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, string $userId, ?array $pages = null): Context { - // Validate the structure of the columns and views arrays - if (!isset($tables['addTables']) || !is_array($tables['addTables']) - || !isset($tables['modifyTables']) || !is_array($tables['modifyTables'])) { - throw new BadRequestError('Invalid tables structure provided.'); + public function importScheme(int $contextId, ContextScheme $scheme, string $userId): Context { + $tables = $scheme->getTables() ?? ['addTables' => [], 'modifyTables' => []]; + $nodes = $scheme->getNodes() ?? []; + $pages = $scheme->getPages(); + $schemeService = $this->contextSchemeService; + + $gridViewPages = ['pages' => [], 'nodes' => [], 'technicalNames' => []]; + if ($scheme->getGridViews() !== []) { + $reserved = []; + foreach ($pages ?? [] as $page) { + $name = $page['technical_name'] ?? $page['technicalName'] ?? null; + if (is_string($name)) { + $reserved[$name] = true; + } + } + $gridViewPages = $schemeService->pagesFromGridViews($scheme->getGridViews(), $nodes, $reserved, $userId); + $pages = array_merge($pages ?? [], $gridViewPages['pages']); } + // A node of an older scheme that pointed at a grid view is that grid view's page now + $gridViewUuids = array_column($scheme->getGridViews(), 'uuid'); + $nodes = array_values(array_filter($nodes, static fn (array $node): bool => !in_array($node['node_uuid'] ?? null, $gridViewUuids, true))); + $nodes = array_merge($nodes, $gridViewPages['nodes']); - $tableService = \OCP\Server::get(TableService::class); - $viewService = \OCP\Server::get(ViewService::class); - $columnService = \OCP\Server::get(ColumnService::class); + $tableService = $this->tableService(); + $viewService = $this->viewService(); + $columnService = $this->columnService(); foreach ($tables['addTables'] as $tableScheme) { $tableService->validateTableScheme($tableScheme); @@ -1319,6 +1332,9 @@ 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) { + throw new DoesNotExistException('View ' . $node['node_uuid'] . ' does not exist'); + } $resolvedNodes[] = [ 'id' => $view->getId(), 'type' => $node['node_type'], @@ -1327,12 +1343,35 @@ public function importScheme(int $contextId, string $name, string $iconName, str } } - $context = $this->update($contextId, $userId, $name, $iconName, $description, $resolvedNodes); + $this->update($contextId, $userId, $scheme->getName(), $scheme->getIcon() ?? '', $scheme->getDescription() ?? '', $resolvedNodes); if ($pages !== null) { $this->importSchemePages($contextId, $nodes, $pages); - $context = $this->contextMapper->findById($contextId, $userId); + } + if ($scheme->getMenuItems() !== null) { + $pageIds = []; + foreach ($this->contextMapper->findById($contextId, $userId)->getPages() ?? [] as $pageId => $page) { + $pageIds[$page['technical_name']] = (int)$pageId; + } + $menuItems = $schemeService->resolveMenuItems($scheme->getMenuItems(), $userId, $pageIds, $gridViewPages['technicalNames']); + $this->update($contextId, $userId, null, null, null, null, $menuItems); } - return $context; + return $this->contextMapper->findById($contextId, $userId); + } + + /** + * The table, view and column services of the scheme export and import. They come from the server + * container because they depend on this service themselves; a test can replace them here. + */ + protected function tableService(): TableService { + return \OCP\Server::get(TableService::class); + } + + protected function viewService(): ViewService { + return \OCP\Server::get(ViewService::class); + } + + protected function columnService(): ColumnService { + return \OCP\Server::get(ColumnService::class); } } diff --git a/openapi.json b/openapi.json index cdaf5c5e09..a39371e556 100644 --- a/openapi.json +++ b/openapi.json @@ -13848,7 +13848,63 @@ "type": "array", "nullable": true, "default": null, - "description": "Pages of the scheme; each updates the page with the same technical name or is added, with its widgets", + "description": "Pages of the scheme; each updates the page with the same technical name or is added, with its widgets; leave it out to keep the current pages", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "menuItems": { + "type": "array", + "nullable": true, + "default": null, + "description": "Menu of the context: tables and views by uuid, pages by technical name, parents by technical name; leave it out to keep the current menu", + "items": { + "type": "object", + "required": [ + "label", + "targetType" + ], + "properties": { + "label": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "targetType": { + "type": "string", + "enum": [ + "view", + "table", + "url", + "page" + ] + }, + "targetUuid": { + "type": "string" + }, + "targetPageTechnicalName": { + "type": "string" + }, + "url": { + "type": "string" + }, + "technicalName": { + "type": "string" + }, + "parentTechnicalName": { + "type": "string" + } + } + } + }, + "gridViews": { + "type": "array", + "default": [], + "description": "Views without a table from a scheme of an older version; each becomes a page", "items": { "type": "object", "additionalProperties": { diff --git a/playwright/e2e/application.spec.ts b/playwright/e2e/application.spec.ts index 3f95958fda..de77463b3f 100644 --- a/playwright/e2e/application.spec.ts +++ b/playwright/e2e/application.spec.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { APIRequestContext } from '@playwright/test' +import type { APIRequestContext, Page } from '@playwright/test' import { test, expect } from '../support/fixtures' import { ocsRequest, type TestUser } from '../support/api' @@ -222,4 +222,161 @@ test.describe('Applications', () => { await expect(page).toHaveURL(/\/menu\/reports$/) await expect(page.locator('[data-cy="gridPage"]')).toBeVisible() }) + test('A scheme imported through the dialog brings its menu and its pages', async ({ userPage: { page, user } }) => { + const { contextId: sourceId } = await createApplicationWithMenu(page.request, user, 'exported application') + const scheme = await exportScheme(page.request, user, sourceId) + const targetId = await createEmptyApplication(page.request, user, 'importing application') + + const response = await importThroughDialog(page, targetId, JSON.stringify(scheme), true) + + expect(response.status()).toBe(200) + const target = await getApplication(page.request, user, targetId) + expect(target.menuItems.map((item: { label: string }) => item.label)).toEqual(['Dashboard', 'Front page', 'Cases', 'Handbook']) + const frontPageItem = target.menuItems.find((item: { label: string }) => item.label === 'Front page') + const frontPage = Object.values(target.pages as Record).find(entry => entry.title === 'Front page') + expect(frontPageItem.targetId).toBe(frontPage!.id) + expect(frontPageItem.broken).toBe(false) + + await page.goto(`/index.php/apps/tables/app/${targetId}`) + // The scheme's start page fills the start page here, its widgets included + await expect(page.getByText('Welcome on the dashboard')).toBeVisible({ timeout: 30000 }) + await page.locator('[data-cy="application-nav-item"]').filter({ hasText: 'Front page' }).getByRole('link').click() + await expect(page.getByText('Welcome on the front page')).toBeVisible() + }) + + test('A scheme imported twice matches its pages by technical name and adds none again', async ({ userPage: { page, user } }) => { + const { contextId: sourceId } = await createApplicationWithMenu(page.request, user, 'exported application') + const scheme = await exportScheme(page.request, user, sourceId) + const targetId = await createEmptyApplication(page.request, user, 'importing application') + + for (let run = 0; run < 2; run++) { + const response = await ocsRequest(page.request, user, { + method: 'POST', + url: `${API}/contexts/${targetId}/scheme/import?format=json`, + data: { name: 'importing application', iconName: 'star', description: '', nodes: [], tables: { addTables: [], modifyTables: [] }, menuItems: scheme.menuItems, pages: scheme.pages }, + }) + expect(response.status()).toBe(200) + } + + const target = await getApplication(page.request, user, targetId) + const names = Object.values(target.pages as Record).map(entry => entry.technical_name).sort() + const sourceNames = Object.values((await getApplication(page.request, user, sourceId)).pages as Record).map(entry => entry.technical_name).sort() + expect(names).toEqual(sourceNames) + }) + + test('A scheme imported with its menu and pages switched off keeps the current menu', async ({ userPage: { page, user } }) => { + const { contextId: sourceId } = await createApplicationWithMenu(page.request, user, 'exported application') + const scheme = await exportScheme(page.request, user, sourceId) + const targetId = await createEmptyApplication(page.request, user, 'importing application') + + const response = await importThroughDialog(page, targetId, JSON.stringify(scheme), false) + + expect(response.status()).toBe(200) + expect(response.request().postDataJSON()).not.toHaveProperty('menuItems') + expect(response.request().postDataJSON()).not.toHaveProperty('pages') + const target = await getApplication(page.request, user, targetId) + expect(target.menuItems.map((item: { label: string }) => item.label)).toEqual(['Dashboard']) + expect(Object.keys(target.pages)).toHaveLength(1) + }) + + test('A scheme of an older version turns its grid views into pages', async ({ userPage: { page, user } }) => { + const tableResponse = await ocsRequest(page.request, user, { method: 'POST', url: '/index.php/apps/tables/api/1/tables', data: { title: 'old scheme cases' } }) + const table = await tableResponse.json() + const targetId = await createEmptyApplication(page.request, user, 'old scheme application') + + const response = await ocsRequest(page.request, user, { + method: 'POST', + url: `${API}/contexts/${targetId}/scheme/import?format=json`, + data: { + name: 'old scheme application', + iconName: 'star', + description: '', + nodes: [], + tables: { addTables: [], modifyTables: [] }, + menuItems: [{ label: 'Intake home', targetType: 'view', targetUuid: 'aaaaaaaa-0000-4000-8000-000000000001', technicalName: 'intake_home' }], + gridViews: [{ + uuid: 'aaaaaaaa-0000-4000-8000-000000000001', + title: 'Intake home', + grid: { + widgets: [ + { id: 'w1', type: 'header', configuration: { title: '', showTitle: false }, content: { title: 'Welcome from an older scheme' } }, + { id: 'w2', type: 'data', configuration: { title: '', showTitle: false }, content: { target: { type: 'table', id: 1, uuid: table.uuid } } }, + ], + layout: [ + { widgetId: 'w1', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2 }, + { widgetId: 'w2', gridX: 0, gridY: 2, gridWidth: 12, gridHeight: 5 }, + ], + }, + }], + }, + }) + + expect(response.status()).toBe(200) + const target = await getApplication(page.request, user, targetId) + const intake = Object.values(target.pages as Record) + .find(entry => entry.title === 'Intake home') + expect(intake!.page_type).toBe('grid') + expect(intake!.technical_name).toBe('intake_home') + const widgets = (await (await ocsRequest(page.request, user, { method: 'GET', url: `${API}/contexts/${targetId}/pages/${intake!.id}?format=json` })).json()).ocs.data.widgets + expect(widgets.map((widget: { type: string }) => widget.type).sort()).toEqual(['data', 'header']) + const dataWidget = widgets.find((widget: { type: string }) => widget.type === 'data') + expect(target.nodes[dataWidget.source.nodeRelId].node_id).toBe(table.id) + expect(target.menuItems).toHaveLength(1) + expect(target.menuItems[0]).toMatchObject({ label: 'Intake home', targetType: 'page', targetId: intake!.id, broken: false }) + const nodeIds = Object.values(target.nodes as Record).map(node => node.node_id) + expect(nodeIds).toContain(table.id) + + await page.goto(`/index.php/apps/tables/app/${targetId}`) + await expect(page).toHaveURL(/\/menu\/intake_home$/) + await expect(page.getByText('Welcome from an older scheme')).toBeVisible() + await expect(page.locator('h1', { hasText: 'old scheme cases' })).toBeVisible() + }) }) + +async function getApplication(request: APIRequestContext, user: TestUser, contextId: number) { + const response = await ocsRequest(request, user, { method: 'GET', url: `${API}/contexts/${contextId}?format=json` }) + return (await response.json()).ocs.data +} + +async function exportScheme(request: APIRequestContext, user: TestUser, contextId: number) { + const response = await ocsRequest(request, user, { method: 'GET', url: `${API}/contexts/${contextId}/scheme/export?format=json` }) + return (await response.json()).ocs.data +} + +async function createEmptyApplication(request: APIRequestContext, user: TestUser, name: string): Promise { + const response = await ocsRequest(request, user, { method: 'POST', url: `${API}/contexts?format=json`, data: { name, iconName: 'star' } }) + return (await response.json()).ocs.data.id +} + +/** + * Imports a scheme file into an application through the "Import a scheme" action of its overview. + * + * @param page the page of the test user + * @param contextId the application to import into + * @param fileContent the scheme file + * @param withMenuAndPages whether the "Menu and pages" switch of the preview stays on + */ +async function importThroughDialog(page: Page, contextId: number, fileContent: string, withMenuAndPages: boolean) { + await page.goto(`/index.php/apps/tables/#/application/${contextId}/overview`) + await page.locator('[data-cy="context-actions"]').getByRole('button').first().click() + await page.locator('[data-cy="context-import-scheme"]').click() + + const dialog = page.getByRole('dialog', { name: /application scheme/ }) + await expect(dialog).toBeVisible() + await dialog.locator('input[type=file]').setInputFiles({ + name: 'exported-application.json', + mimeType: 'application/json', + buffer: Buffer.from(fileContent), + }) + await dialog.getByRole('button', { name: 'Preview' }).click() + const menuSection = dialog.locator('[data-cy="importSchemeMenu"]') + await expect(menuSection).toBeVisible() + if (!withMenuAndPages) { + await menuSection.getByText('Menu and pages', { exact: true }).click() + } + const importResponse = page.waitForResponse(r => r.url().includes('/scheme/import') && r.request().method() === 'POST') + await dialog.getByRole('button', { name: 'Import' }).click() + const response = await importResponse + await expect(dialog).toContainText('Import completed successfully.') + return response +} diff --git a/src/modules/modals/ImportContextScheme.vue b/src/modules/modals/ImportContextScheme.vue index 57b695b996..ef9db3b61b 100644 --- a/src/modules/modals/ImportContextScheme.vue +++ b/src/modules/modals/ImportContextScheme.vue @@ -41,7 +41,12 @@
- +
@@ -145,6 +150,8 @@ export default { importFinished: false, preview: null, schemeData: null, + // The scheme as read from the file; the preview rebuilds schemeData from the parts chosen + uploadedScheme: null, waitForReload: false, mimeTypes: [ 'application/json', @@ -156,6 +163,18 @@ export default { computed: { ...mapState(useTablesStore, ['getContext', 'tables', 'views', 'activeContextId']), + /** + * The pages of the uploaded scheme as a list; a file from an older version keys them by id. + * + * @return {Array|null} + */ + uploadedPages() { + const pages = this.uploadedScheme?.pages + if (!pages || typeof pages !== 'object') { + return null + } + return Array.isArray(pages) ? pages : Object.values(pages) + }, importFileName() { const fileName = this.selectedUploadFile ? this.selectedUploadFile.name : '' @@ -241,8 +260,17 @@ export default { this.loading = false } }, + /** + * Takes the scheme as chosen in the preview. When it brings the pages, the grid views of a file + * from an older version come along, as each becomes a page. + * + * @param {object} newScheme the scheme the preview built + */ onUpdateScheme(newScheme) { - this.schemeData = newScheme + const gridViews = this.uploadedScheme?.gridViews + this.schemeData = newScheme.pages && Array.isArray(gridViews) && gridViews.length > 0 + ? { ...newScheme, gridViews } + : newScheme }, async actionImport() { if (!this.schemeData) { @@ -300,7 +328,8 @@ export default { reader.readAsText(this.selectedUploadFile, 'UTF-8') reader.onload = (evt) => { try { - this.schemeData = JSON.parse(evt.target.result) + this.uploadedScheme = JSON.parse(evt.target.result) + this.schemeData = this.uploadedScheme } catch (parseError) { this.importFailed = true this.errorMessage = t('tables', 'The selected file is not valid JSON.') diff --git a/src/modules/modals/ImportContextSchemePreview.vue b/src/modules/modals/ImportContextSchemePreview.vue index a7ac0f1911..5f3821e05b 100644 --- a/src/modules/modals/ImportContextSchemePreview.vue +++ b/src/modules/modals/ImportContextSchemePreview.vue @@ -179,6 +179,44 @@ + + +
+
+ + {{ t('tables', 'Menu and pages') }} + +
+
    +
  • + {{ t('tables', 'From') }}: +
  • +
  • + {{ item.label }} +
  • +
+
    +
  • + {{ t('tables', 'To') }}: +
  • +
  • + {{ item.label }} +
  • +
+
    +
  • + {{ t('tables', 'Pages') }}: +
  • +
  • + {{ pageTitle(page) }} +
  • +
+

+ {{ t('tables', 'Pages come along with the resources they show.') }} +

+
+
+
@@ -186,6 +224,7 @@ import { translate as t } from '@nextcloud/l10n' import { NcCheckboxRadioSwitch } from '@nextcloud/vue' import ImportTableSchemePreview from './ImportTableSchemePreview.vue' +import { pageTitle } from '../../shared/utils/menuItems.js' import svgHelper from '../../shared/components/ncIconPicker/mixins/svgHelper.js'; export default { @@ -226,6 +265,21 @@ export default { } }, }, + // The menu of the uploaded scheme; null when the file has none, which keeps the current menu + menuItems: { + type: Array, + default: null, + }, + // The pages of the uploaded scheme; null when the file has none, which keeps the current pages + pages: { + type: Array, + default: null, + }, + // The menu the application has now + currentMenuItems: { + type: Array, + default: () => [], + }, }, emits: ['update'], @@ -236,6 +290,7 @@ export default { importIcon: true, importDescription: true, importNodes: true, + importMenu: true, addTablesConfig: [], removeColumnsConfig: [], modifyTablesConfig: [], @@ -268,6 +323,11 @@ export default { this.emitPayload() } }, + importMenu: { + handler() { + this.emitPayload() + }, + }, importNodes: { handler() { this.emitPayload() @@ -288,6 +348,7 @@ export default { }, methods: { + pageTitle, t, initializeSelection() { @@ -333,6 +394,14 @@ export default { }, nodes: this.importNodes ? this.previewData.nodes.to : this.previewData.nodes.from, } + // Left out, the import keeps the current menu and pages. Data widgets name their node by its + // id in the nodes of the file, so pages only come along with those nodes. + if (this.importMenu && this.menuItems) { + payload.menuItems = this.menuItems + } + if (this.importMenu && this.pages && this.importNodes) { + payload.pages = this.pages + } this.$emit('update', payload) }, diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 2bc046f036..d720c439af 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -7683,12 +7683,34 @@ export interface operations { readonly modifyTables: readonly components["schemas"]["Table"][]; }; /** - * @description Pages of the scheme; each updates the page with the same technical name or is added, with its widgets + * @description Pages of the scheme; each updates the page with the same technical name or is added, with its widgets; leave it out to keep the current pages * @default null */ readonly pages?: readonly { readonly [key: string]: Record; }[] | null; + /** + * @description Menu of the context: tables and views by uuid, pages by technical name, parents by technical name; leave it out to keep the current menu + * @default null + */ + readonly menuItems?: readonly { + readonly label: string; + readonly icon?: string; + /** @enum {string} */ + readonly targetType: "view" | "table" | "url" | "page"; + readonly targetUuid?: string; + readonly targetPageTechnicalName?: string; + readonly url?: string; + readonly technicalName?: string; + readonly parentTechnicalName?: string; + }[] | null; + /** + * @description Views without a table from a scheme of an older version; each becomes a page + * @default [] + */ + readonly gridViews?: readonly { + readonly [key: string]: Record; + }[]; }; }; }; diff --git a/tests/unit/Controller/ContextControllerTest.php b/tests/unit/Controller/ContextControllerTest.php index 2bd2f5cd65..af1d817a7d 100644 --- a/tests/unit/Controller/ContextControllerTest.php +++ b/tests/unit/Controller/ContextControllerTest.php @@ -11,6 +11,10 @@ use OCA\Tables\Controller\ContextController; use OCA\Tables\Db\Context; +use OCA\Tables\Errors\BadRequestError; +use OCA\Tables\Errors\InternalError; +use OCA\Tables\Errors\NotFoundError; +use OCA\Tables\Model\ContextScheme; use OCA\Tables\Model\MenuItemInput; use OCA\Tables\Service\ColumnService; use OCA\Tables\Service\ContextService; @@ -27,10 +31,14 @@ final class ContextControllerTest extends TestCase { private ContextService&MockObject $contextService; private ContextController $controller; + private IDBConnection&MockObject $db; + + private const TABLES = ['addTables' => [], 'modifyTables' => []]; protected function setUp(): void { parent::setUp(); $this->contextService = $this->createMock(ContextService::class); + $this->db = $this->createMock(IDBConnection::class); $this->controller = new ContextController( $this->createMock(IRequest::class), $this->createMock(LoggerInterface::class), @@ -38,7 +46,7 @@ protected function setUp(): void { 'user-1', $this->contextService, $this->createMock(TableService::class), - $this->createMock(IDBConnection::class), + $this->db, $this->createMock(ColumnService::class), $this->createMock(ViewService::class), ); @@ -69,4 +77,59 @@ public function testUpdateRefusesAPageItemWithoutAnId(): void { $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); $this->assertStringContainsString('needs its id', $response->getData()['message']); } + + public function testImportOfAMalformedSchemeIsABadRequestThatNeverReachesTheService(): void { + $this->contextService->expects($this->never())->method('importScheme'); + + $response = $this->controller->importScheme(7, 'Intake', 'star', '', [], ['addTables' => []]); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + + public function testImportThatTheServiceRefusesAsABadRequestRollsBackWithA400(): void { + $this->contextService->method('importScheme')->willThrowException(new BadRequestError('Unknown widget type')); + $this->db->expects($this->once())->method('rollBack'); + $this->db->expects($this->never())->method('commit'); + + $response = $this->controller->importScheme(7, 'Intake', 'star', '', [], self::TABLES); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + + public function testImportThatFailsInternallyRollsBackWithA500(): void { + $this->contextService->method('importScheme')->willThrowException(new InternalError('insert failed')); + $this->db->expects($this->once())->method('rollBack'); + + $response = $this->controller->importScheme(7, 'Intake', 'star', '', [], self::TABLES); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + } + + public function testImportOfAPageThatIsGoneRollsBackWithA404(): void { + $this->contextService->method('importScheme')->willThrowException(new NotFoundError('page not found')); + $this->db->expects($this->once())->method('rollBack'); + + $response = $this->controller->importScheme(7, 'Intake', 'star', '', [], self::TABLES); + + $this->assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + } + + public function testImportPassesThePagesMenuAndGridViewsOnAndLeavesAbsentOnesNull(): void { + $schemes = []; + $this->contextService->expects($this->exactly(2))->method('importScheme') + ->willReturnCallback(static function (int $contextId, ContextScheme $scheme) use (&$schemes): Context { + $schemes[] = $scheme; + return new Context(); + }); + + $this->controller->importScheme(7, 'Intake', 'star', '', [], self::TABLES); + $this->controller->importScheme(7, 'Intake', 'star', '', [], self::TABLES, [['id' => 3, 'page_type' => 'grid', 'title' => 'Reports']], [], [['uuid' => 'grid-1', 'title' => 'Old page']]); + + $this->assertNull($schemes[0]->getMenuItems()); + $this->assertNull($schemes[0]->getPages()); + $this->assertSame([], $schemes[0]->getGridViews()); + $this->assertSame([], $schemes[1]->getMenuItems()); + $this->assertSame([['id' => 3, 'page_type' => 'grid', 'title' => 'Reports']], $schemes[1]->getPages()); + $this->assertSame('grid-1', $schemes[1]->getGridViews()[0]['uuid']); + } } diff --git a/tests/unit/Model/ContextSchemeTest.php b/tests/unit/Model/ContextSchemeTest.php new file mode 100644 index 0000000000..5a1b6d5484 --- /dev/null +++ b/tests/unit/Model/ContextSchemeTest.php @@ -0,0 +1,125 @@ + [], 'modifyTables' => []]; + + public function testCreateFromInputCarriesEveryPart(): void { + $scheme = ContextScheme::createFromInputArray([ + 'name' => 'Intake', 'icon' => 'inbox', 'description' => 'desc', + 'nodes' => [['node_type' => 0, 'node_uuid' => 'u1', 'permissions' => 1]], + 'tables' => self::TABLES, + 'menuItems' => [['label' => 'Home', 'targetType' => 'page', 'targetPageTechnicalName' => 'home']], + 'pages' => [['id' => 3, 'page_type' => 'grid', 'title' => 'Home', 'technical_name' => 'home', 'widgets' => []]], + 'gridViews' => [['uuid' => 'v1', 'title' => 'Old home']], + ]); + + $this->assertSame('Intake', $scheme->getName()); + $this->assertSame('inbox', $scheme->getIcon()); + $this->assertCount(1, $scheme->getNodes()); + $this->assertCount(1, $scheme->getMenuItems()); + $this->assertCount(1, $scheme->getPages()); + $this->assertCount(1, $scheme->getGridViews()); + $this->assertSame(self::TABLES, $scheme->getTables()); + } + + public function testCreateFromInputKeepsAnAbsentMenuOrPagesApartFromEmptyOnes(): void { + $without = ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES]); + $empty = ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'menuItems' => [], 'pages' => []]); + + $this->assertNull($without->getMenuItems()); + $this->assertNull($without->getPages()); + $this->assertSame([], $empty->getMenuItems()); + $this->assertSame([], $empty->getPages()); + $this->assertSame([], $without->getGridViews()); + } + + public function testCreateFromInputRejectsAPageThatIsNotAnObject(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Every page'); + ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'pages' => ['home']]); + } + + public function testCreateFromInputRejectsPageWidgetsThatAreNotAList(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Every page'); + ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'pages' => [['page_type' => 'grid', 'widgets' => 'header']]]); + } + + public function testCreateFromInputListsPagesKeyedById(): void { + $scheme = ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'pages' => ['3' => ['id' => 3, 'page_type' => 'startpage']]]); + + $this->assertSame([['id' => 3, 'page_type' => 'startpage']], $scheme->getPages()); + } + + public function testTheMenuIsExported(): void { + $scheme = new ContextScheme('Intake', 'star', '', [], [], self::TABLES, [['label' => 'Home']]); + + $this->assertSame([['label' => 'Home']], $scheme->jsonSerialize()['menuItems']); + } + + /** + * @dataProvider provideInvalidGridViews + */ + public function testCreateFromInputRejectsAnInvalidGridView(array $gridView, string $message): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'gridViews' => [$gridView]]); + } + + /** + * @return array, string}> + */ + public static function provideInvalidGridViews(): array { + return [ + 'no uuid' => [['title' => 'Home'], '"uuid"'], + 'a blank title' => [['uuid' => 'v1', 'title' => ' '], '"title"'], + 'a grid that is not an object' => [['uuid' => 'v1', 'title' => 'Home', 'grid' => 'widgets'], '"grid" must be an object'], + ]; + } + + public function testCreateFromInputAcceptsAGridViewAsAnOlderSchemeExportedIt(): void { + $scheme = ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'gridViews' => [[ + 'uuid' => 'v1', 'title' => 'Home', 'emoji' => '🏠', 'description' => null, 'type' => 'grid', + 'createdAt' => '2026-10-14 12:00:00', 'lastEditAt' => '2026-10-14 12:00:00', 'grid' => ['widgets' => [], 'layout' => []], + ]]]); + + $this->assertCount(1, $scheme->getGridViews()); + } + + public function testCreateFromInputRejectsTablesWithoutBothLists(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('addTables'); + ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => ['addTables' => []]]); + } + + public function testCreateFromInputRejectsAMissingName(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('name'); + ContextScheme::createFromInputArray(['name' => ' ', 'tables' => self::TABLES]); + } + + public function testCreateFromInputRejectsANonListMenu(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('menuItems'); + ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'menuItems' => 'home']); + } + + public function testCreateFromInputRejectsAnIconThatIsNotAString(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"icon" must be a string'); + ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'icon' => 7]); + } +} diff --git a/tests/unit/Service/ContextPagesRoundTripTest.php b/tests/unit/Service/ContextPagesRoundTripTest.php index e4485513a9..9d2e16dc8f 100644 --- a/tests/unit/Service/ContextPagesRoundTripTest.php +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -26,6 +26,7 @@ use OCA\Tables\Db\WidgetSourceMapper; use OCA\Tables\Helper\GroupHelper; use OCA\Tables\Helper\UserHelper; +use OCA\Tables\Service\ContextSchemeService; use OCA\Tables\Service\ContextService; use OCA\Tables\Service\GridWidgetTypes; use OCA\Tables\Service\PermissionsService; @@ -89,6 +90,7 @@ protected function setUp(): void { new GridWidgetTypes($l10n), new MenuItemMapper($this->connectionAdapter), $l10n, + $this->createMock(ContextSchemeService::class), ); } diff --git a/tests/unit/Service/ContextSchemeServiceTest.php b/tests/unit/Service/ContextSchemeServiceTest.php new file mode 100644 index 0000000000..956350dd0a --- /dev/null +++ b/tests/unit/Service/ContextSchemeServiceTest.php @@ -0,0 +1,187 @@ +createMock(TableMapper::class); + $viewMapper = $this->createMock(ViewMapper::class); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->service = new ContextSchemeService( + $tableMapper, + $viewMapper, + $this->permissionsService, + $this->createMock(LoggerInterface::class), + ); + + // Tables and views exist here under the uuids "table-" and "view-" + $tableMapper->method('findByUuid')->willReturnCallback(static function (string $uuid): Table { + if (!str_starts_with($uuid, 'table-')) { + throw new DoesNotExistException('no table ' . $uuid); + } + $table = new Table(); + $table->setId((int)substr($uuid, 6)); + return $table; + }); + $viewMapper->method('findByUuid')->willReturnCallback(static function (string $uuid): ?View { + if (!str_starts_with($uuid, 'view-')) { + return null; + } + $view = new View(); + $view->setId((int)substr($uuid, 5)); + return $view; + }); + $tableMapper->method('find')->willReturnCallback(static function (int $id): Table { + if ($id === 404) { + throw new DoesNotExistException('gone'); + } + $table = new Table(); + $table->setId($id); + $table->setUuid('table-' . $id); + return $table; + }); + $viewMapper->method('find')->willReturnCallback(static function (int $id): View { + $view = new View(); + $view->setId($id); + $view->setUuid('view-' . $id); + return $view; + }); + } + + private function context(array $menuItems, array $pages): Context { + $context = new Context(); + $context->id = 7; + $context->setNodes([]); + $context->setPages($pages); + $context->setMenuItems($menuItems); + return $context; + } + + private function menuItem(int $id, string $technicalName, string $targetType, ?int $targetId, ?int $parentId = null, bool $broken = false): array { + return [ + 'id' => $id, 'contextId' => 7, 'label' => ucfirst($technicalName), 'icon' => null, 'targetType' => $targetType, 'targetId' => $targetId, + 'url' => $targetType === 'url' ? 'https://example.org' : null, 'technicalName' => $technicalName, 'order' => $id * 10, + 'parentId' => $parentId, 'broken' => $broken, + ]; + } + + public function testExportNamesTablesAndViewsByUuidAndPagesByTechnicalNameAndSkipsBrokenItems(): void { + $context = $this->context([ + $this->menuItem(1, 'dashboard', 'page', 3), + $this->menuItem(2, 'cases', 'table', 5, 1), + $this->menuItem(3, 'open', 'view', 6), + $this->menuItem(4, 'gone', 'page', 9, null, true), + $this->menuItem(5, 'lost', 'table', 404), + $this->menuItem(6, 'docs', 'url', null), + $this->menuItem(7, 'elsewhere', 'page', 12), + ], [ + 3 => ['id' => 3, 'page_type' => 'startpage', 'technical_name' => 'dashboard', 'content' => []], + ]); + + $exported = $this->service->exportMenuItems($context); + + $this->assertSame(['dashboard', 'cases', 'open', 'docs'], array_column($exported, 'technicalName')); + $this->assertSame(['dashboard', null, null, null], array_column($exported, 'targetPageTechnicalName')); + $this->assertSame([null, 'table-5', 'view-6', null], array_column($exported, 'targetUuid')); + $this->assertSame([null, 'dashboard', null, null], array_column($exported, 'parentTechnicalName')); + } + + public function testImportMapsPageTargetsByTechnicalNameAndTurnsAGridViewTargetIntoItsPage(): void { + $this->permissionsService->method('canManageNodeById')->willReturnCallback(static fn (int $type, int $id): bool => $id !== 8); + + $items = $this->service->resolveMenuItems([ + ['label' => 'Home', 'targetType' => 'page', 'targetPageTechnicalName' => 'dashboard', 'technicalName' => 'home'], + ['label' => 'Old page', 'targetType' => 'view', 'targetUuid' => 'grid-1'], + ['label' => 'Cases', 'targetType' => 'table', 'targetUuid' => 'table-5', 'parentTechnicalName' => 'home'], + ['label' => 'Not mine', 'targetType' => 'table', 'targetUuid' => 'table-8'], + ['label' => 'Not here', 'targetType' => 'view', 'targetUuid' => 'missing'], + ['label' => 'Gone page', 'targetType' => 'page', 'targetPageTechnicalName' => 'archive'], + ['label' => 'Docs', 'targetType' => 'url', 'url' => 'https://example.org', 'parentTechnicalName' => 'skipped'], + ], self::USER, ['dashboard' => 30, 'old_page' => 31], ['grid-1' => 'old_page']); + + $this->assertSame(['Home', 'Old page', 'Cases', 'Docs'], array_map(static fn (MenuItemInput $item) => $item->getLabel(), $items)); + $this->assertSame(['page', 'page', 'table', 'url'], array_map(static fn (MenuItemInput $item) => $item->getTargetType(), $items)); + $this->assertSame([30, 31, 5, null], array_map(static fn (MenuItemInput $item) => $item->getTargetId(), $items)); + $this->assertSame([null, null, 0, null], array_map(static fn (MenuItemInput $item) => $item->getParentIndex(), $items)); + } + + public function testImportGivesALaterItemWithATakenTechnicalNameADerivedOne(): void { + $items = $this->service->resolveMenuItems([ + ['label' => 'One', 'targetType' => 'url', 'url' => 'https://example.org/1', 'technicalName' => 'docs'], + ['label' => 'Two', 'targetType' => 'url', 'url' => 'https://example.org/2', 'technicalName' => 'docs'], + ], self::USER, [], []); + + $this->assertSame(['docs', null], array_map(static fn (MenuItemInput $item) => $item->getTechnicalName(), $items)); + } + + public function testAGridViewBecomesAPageWithItsWidgetsInPlace(): void { + $this->permissionsService->method('canManageNodeById')->willReturnCallback(static fn (int $type, int $id): bool => $id !== 8); + $nodes = [['id' => 70, 'node_type' => Application::NODE_TYPE_TABLE, 'node_uuid' => 'table-5', 'permissions' => 1]]; + + $converted = $this->service->pagesFromGridViews([[ + 'uuid' => 'grid-1', + 'title' => 'Intake home', + 'emoji' => '🏠', + 'grid' => [ + 'widgets' => [ + ['id' => 'w1', 'type' => 'header', 'configuration' => ['title' => '', 'showTitle' => false], 'content' => ['title' => 'Welcome', 'subtitle' => 'Start here']], + ['id' => 'w2', 'type' => 'data', 'configuration' => ['title' => 'Cases', 'showTitle' => true], 'content' => ['target' => ['type' => 'table', 'id' => 1, 'uuid' => 'table-5']]], + ['id' => 'w3', 'type' => 'data', 'configuration' => [], 'content' => ['target' => ['type' => 'view', 'uuid' => 'view-6']]], + ['id' => 'w4', 'type' => 'data', 'configuration' => [], 'content' => ['target' => ['type' => 'table', 'uuid' => 'table-8']]], + ['id' => 'w5', 'type' => 'text', 'configuration' => ['title' => 'About'], 'content' => ['text' => 'Hello']], + ['id' => 'unplaced', 'type' => 'text', 'content' => ['text' => 'Not in the layout']], + ], + 'layout' => [ + ['widgetId' => 'w1', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2], + ['widgetId' => 'w2', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5], + ['widgetId' => 'w3', 'gridX' => 6, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5], + ['widgetId' => 'w4', 'gridX' => 0, 'gridY' => 7, 'gridWidth' => 12, 'gridHeight' => 5], + ['widgetId' => 'w5', 'gridX' => 0, 'gridY' => 12, 'gridWidth' => 6, 'gridHeight' => 2], + ], + ], + ]], $nodes, ['intake_home' => true], self::USER); + + $page = $converted['pages'][0]; + // The scheme already has a page named after the title, so the grid view's page gets the next name + $this->assertSame('intake_home_2', $page['technical_name']); + $this->assertSame(['grid-1' => 'intake_home_2'], $converted['technicalNames']); + $this->assertSame(['grid', 'Intake home', '🏠'], [$page['page_type'], $page['title'], $page['emoji']]); + $this->assertSame(['header', 'data', 'data', 'data', 'text'], array_column($page['widgets'], 'type')); + $this->assertSame(['heading' => 'Welcome', 'subheading' => 'Start here'], $page['widgets'][0]['settings']); + $this->assertSame([null, 'Cases', null, null, 'About'], array_column($page['widgets'], 'title')); + $this->assertSame([6, 2, 6, 5], [$page['widgets'][2]['gridX'], $page['widgets'][2]['gridY'], $page['widgets'][2]['gridWidth'], $page['widgets'][2]['gridHeight']]); + // A scheme node shows the table; the view is added as a node; the table the importer cannot manage gets no source + $this->assertSame(['nodeRelId' => 70], $page['widgets'][1]['source']); + $this->assertSame(['nodeRelId' => -1], $page['widgets'][2]['source']); + $this->assertNull($page['widgets'][3]['source']); + $this->assertArrayNotHasKey('source', $page['widgets'][4]); + $this->assertSame([['id' => -1, 'node_type' => Application::NODE_TYPE_VIEW, 'node_uuid' => 'view-6', 'permissions' => Application::PERMISSION_READ]], $converted['nodes']); + } +} diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index 106d79682b..61b67c15f5 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -27,6 +27,7 @@ use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Model\ContextScheme; use OCA\Tables\Model\MenuItemInput; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; @@ -50,6 +51,8 @@ final class ContextServiceTest extends TestCase { private MockObject $tableMapper; private MockObject $viewMapper; private MockObject $permissionsService; + private ContextSchemeService&MockObject $contextSchemeService; + private array $constructorArgs; protected function setUp(): void { parent::setUp(); @@ -75,7 +78,8 @@ protected function setUp(): void { $l10n = $this->createMock(IL10N::class); $l10n->method('t')->willReturnCallback(static fn (string $text): string => 'translated ' . $text); - $this->service = new ContextService( + $this->contextSchemeService = $this->createMock(ContextSchemeService::class); + $this->constructorArgs = [ $this->contextMapper, $this->contextNodeRelMapper, $this->pageMapper, @@ -95,7 +99,115 @@ protected function setUp(): void { new GridWidgetTypes($gridL10n), $this->menuItemMapper, $l10n, - ); + $this->contextSchemeService, + ]; + $this->service = new ContextService(...$this->constructorArgs); + } + + /** + * A service whose updates, page import and table services are mocked, to follow an import. + * + * @param list $calls collects the steps of the import in order + */ + private function importingService(array &$calls, array $pagesAfterImport = []): ContextService&MockObject { + $service = $this->getMockBuilder(ContextService::class) + ->setConstructorArgs($this->constructorArgs) + ->onlyMethods(['update', 'importSchemePages', 'tableService', 'viewService', 'columnService']) + ->getMock(); + $service->method('tableService')->willReturn($this->createMock(TableService::class)); + $service->method('viewService')->willReturn($this->createMock(ViewService::class)); + $service->method('columnService')->willReturn($this->createMock(ColumnService::class)); + $service->method('update')->willReturnCallback(function (int $contextId, string $userId, ?string $name, ?string $icon, ?string $description, ?array $nodes, ?array $menuItems = null) use (&$calls): Context { + $calls[] = $nodes !== null ? 'nodes:' . json_encode(array_column($nodes, 'id')) : 'menu:' . count($menuItems ?? []); + return $this->contextWithMenu($contextId, []); + }); + $service->method('importSchemePages')->willReturnCallback(static function (int $contextId, array $nodes, array $pages) use (&$calls): void { + $calls[] = 'pages:' . json_encode(array_column($pages, 'technical_name')) . ' nodes:' . json_encode(array_column($nodes, 'id')); + }); + $this->contextMapper->method('findById')->willReturnCallback(function (int $id) use ($pagesAfterImport): Context { + $context = $this->contextWithMenu($id, []); + $context->setPages($pagesAfterImport); + return $context; + }); + return $service; + } + + private function scheme(array $nodes, ?array $pages, ?array $menuItems, array $gridViews = []): ContextScheme { + return new ContextScheme('Intake', 'star', '', $nodes, $pages, ['addTables' => [], 'modifyTables' => []], $menuItems, $gridViews); + } + + public function testImportSetsTheNodesThenThePagesThenTheMenu(): void { + $calls = []; + $this->contextSchemeService->expects($this->once())->method('pagesFromGridViews') + ->with([['uuid' => 'grid-1', 'title' => 'Old page']], $this->anything(), ['dashboard' => true], 'user-1') + ->willReturn([ + 'pages' => [['page_type' => 'grid', 'technical_name' => 'old_page', 'widgets' => []]], + 'nodes' => [['id' => -1, 'node_type' => Application::NODE_TYPE_VIEW, 'node_uuid' => 'view-6', 'permissions' => 1]], + 'technicalNames' => ['grid-1' => 'old_page'], + ]); + $this->contextSchemeService->expects($this->once())->method('resolveMenuItems') + ->with([['label' => 'Home']], 'user-1', ['dashboard' => 30, 'old_page' => 31], ['grid-1' => 'old_page']) + ->willReturn([MenuItemInput::createFromInputArray(['label' => 'Home', 'targetType' => 'page', 'targetId' => 30])]); + $table = new Table(); + $table->setId(5); + $view = new View(); + $view->setId(6); + $this->tableMapper->method('findByUuid')->with('table-uuid')->willReturn($table); + $this->viewMapper->method('findByUuid')->with('view-6')->willReturn($view); + $service = $this->importingService($calls, [ + 30 => ['id' => 30, 'page_type' => 'startpage', 'technical_name' => 'dashboard'], + 31 => ['id' => 31, 'page_type' => 'grid', 'technical_name' => 'old_page'], + ]); + + $service->importScheme(7, $this->scheme( + [ + ['id' => 70, 'node_type' => Application::NODE_TYPE_TABLE, 'node_uuid' => 'table-uuid', 'permissions' => 1], + ['id' => 71, 'node_type' => Application::NODE_TYPE_VIEW, 'node_uuid' => 'grid-1', 'permissions' => 1], + ], + [['id' => 3, 'page_type' => 'startpage', 'technical_name' => 'dashboard', 'widgets' => []]], + [['label' => 'Home']], + [['uuid' => 'grid-1', 'title' => 'Old page']], + ), 'user-1'); + + // The grid view node is no node any more: it became a page, and the view its widget shows was added + $this->assertSame(['nodes:[5,6]', 'pages:["dashboard","old_page"] nodes:[70,-1]', 'menu:1'], $calls); + } + + public function testImportOfASchemeWithoutPagesOrMenuLeavesBothAlone(): void { + $calls = []; + $this->contextSchemeService->expects($this->never())->method('pagesFromGridViews'); + $this->contextSchemeService->expects($this->never())->method('resolveMenuItems'); + $service = $this->importingService($calls); + + $service->importScheme(7, $this->scheme([], null, null), 'user-1'); + + $this->assertSame(['nodes:[]'], $calls); + } + + public function testImportOfAnEmptyMenuClearsTheMenu(): void { + $calls = []; + $this->contextSchemeService->method('resolveMenuItems')->with([])->willReturn([]); + $service = $this->importingService($calls); + + $service->importScheme(7, $this->scheme([], null, []), 'user-1'); + + $this->assertSame(['nodes:[]', 'menu:0'], $calls); + } + + public function testExportCarriesTheMenu(): void { + $context = $this->contextWithMenu(7, []); + $this->contextMapper->method('findById')->willReturn($context); + $this->contextSchemeService->method('exportMenuItems')->with($context)->willReturn([['label' => 'Home', 'targetType' => 'page', 'targetPageTechnicalName' => 'dashboard']]); + $service = $this->getMockBuilder(ContextService::class) + ->setConstructorArgs($this->constructorArgs) + ->onlyMethods(['tableService', 'viewService']) + ->getMock(); + $service->method('tableService')->willReturn($this->createMock(TableService::class)); + $service->method('viewService')->willReturn($this->createMock(ViewService::class)); + + $scheme = $service->getScheme(7, 'user-1')->jsonSerialize(); + + $this->assertSame([['label' => 'Home', 'targetType' => 'page', 'targetPageTechnicalName' => 'dashboard']], $scheme['menuItems']); } private function contextWithMenu(int $id, array $menuItems): Context {