diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index 72fbad908b..02bfacaec3 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,6 +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 + * @psalm-param ?list $menuItems Menu of the context: tables and views by uuid, pages by their id in the scheme, parents by technical name; leave it out to keep the current menu + * @psalm-param ?list> $pages Pages of the context with their widgets (a scheme of an older version keys them by id); data widgets name their node by its id in the nodes; leave it out to keep the current pages + * @psalm-param list> $gridViews Views without a table from a scheme of an older version; each becomes a page * * @return DataResponse, array{}>|DataResponse * @@ -371,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): DataResponse { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $menuItems = null, ?array $pages = 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); + $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) { @@ -389,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..d37107acbe 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['content']) && !is_array($page['content']))) { + throw new InvalidArgumentException('Every page of a scheme must be an object, with its content 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']) ? $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; data widgets name their 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 their id in the scheme, 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..dfab488542 --- /dev/null +++ b/lib/Service/ContextSchemeService.php @@ -0,0 +1,492 @@ +> + * @throws NotFoundError|Exception + */ + public function exportPages(int $contextId, string $userId): array { + return $this->pageService->findAll($contextId, $userId); + } + + /** + * Menu items with their targets as uuids, or as the id of a page of the scheme. An item whose + * target is gone is left out. + * + * @return list> + */ + public function exportMenuItems(Context $context): array { + $menuItems = []; + $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, + 'targetPageId' => 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['targetPageId'] = $item['targetId'], + 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; + } + + /** + * Brings the pages of a scheme into an application whose nodes the import already set. The + * start page of the scheme fills the start page here, but only when it was arranged on the + * grid; otherwise the start page keeps the content the nodes gave it. Every grid page, and every + * grid view of an older scheme, gets a technical name: its own, else one made from its title, + * unique within the scheme and apart from the start page here. It replaces the page with that + * technical name, or is added. A data widget whose table or view does not exist here, or that + * the importer cannot add to the application, is left out. + * + * @return array{pages: array, gridViews: array} the local page ids by page id in the scheme, and by grid view uuid + * @throws BadRequestError|NotFoundError|PermissionError|Exception + */ + public function importPages(Context $context, ContextScheme $scheme, string $userId): array { + $contextId = $context->getId(); + $nodeKeys = []; + foreach ($context->getNodes() ?? [] as $node) { + $nodeKeys[$this->nodeKey((int)$node['node_type'], (int)$node['node_id'])] = true; + } + $schemeNodes = $this->schemeNodes($scheme->getNodes() ?? []); + + $localPages = $this->pageService->findAll($contextId, $userId); + $startPageId = null; + $reserved = []; + $pagesByTechnicalName = []; + foreach ($localPages as $page) { + if ($page['page_type'] === Page::TYPE_STARTPAGE) { + $startPageId = $page['id']; + $reserved[$page['technical_name']] = true; + } else { + $pagesByTechnicalName[$page['technical_name']] = $page['id']; + } + } + $technicalNames = $this->schemeTechnicalNames($scheme, $reserved); + + $pageIds = []; + foreach ($scheme->getPages() ?? [] as $key => $page) { + if (!is_array($page)) { + continue; + } + $sourceId = $page['id'] ?? $key; + $content = PageContentHelper::readContent($this->storedContent($page['content'] ?? [])); + if (($page['page_type'] ?? $page['pageType'] ?? null) === Page::TYPE_STARTPAGE) { + if ($startPageId === null) { + continue; + } + $pageIds[$sourceId] = $startPageId; + if ($this->isArrangedOnTheGrid($page['content'] ?? [])) { + $widgets = $this->widgetsFromContent($content, $schemeNodes, $nodeKeys, $userId); + $this->pageService->saveWidgets($contextId, $startPageId, $widgets, $userId); + } + continue; + } + + $pageId = $this->importPage($contextId, $page['title'] ?? null, $technicalNames['page:' . $key], $pagesByTechnicalName, $userId); + $pageIds[$sourceId] = $pageId; + $widgets = $this->widgetsFromContent($content, $schemeNodes, $nodeKeys, $userId); + $this->pageService->saveWidgets($contextId, $pageId, $widgets, $userId); + } + + $gridViewPageIds = []; + foreach ($scheme->getGridViews() as $key => $gridView) { + $pageId = $this->importPage($contextId, $gridView['title'], $technicalNames['gridView:' . $key], $pagesByTechnicalName, $userId); + $widgets = $this->widgetsFromGridView($gridView['grid'] ?? null, $nodeKeys, $userId); + $this->pageService->saveWidgets($contextId, $pageId, $widgets, $userId); + $gridViewPageIds[$gridView['uuid']] = $pageId; + } + + return ['pages' => $pageIds, 'gridViews' => $gridViewPageIds]; + } + + /** + * 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 page id in the scheme + * @param array $gridViewPageIds local page ids by grid view uuid + * @return list + */ + public function resolveMenuItems(array $menuItems, string $userId, array $pageIds, array $gridViewPageIds): 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($gridViewPageIds[$targetUuid])) { + $item['targetType'] = MenuItem::TARGET_PAGE; + $item['targetId'] = $gridViewPageIds[$targetUuid]; + } elseif ($targetType === MenuItem::TARGET_PAGE) { + $targetPageId = $item['targetPageId'] ?? null; + $item['targetId'] = (is_int($targetPageId) || is_string($targetPageId)) ? ($pageIds[$targetPageId] ?? 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['targetPageId'], $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 local table or view of every node of the scheme, by its relation id in the scheme. + * + * @param array $nodes + * @return array + */ + private function schemeNodes(array $nodes): array { + $resolved = []; + foreach ($nodes as $node) { + if (!is_array($node) || !isset($node['id'], $node['node_type']) || !is_string($node['node_uuid'] ?? null)) { + continue; + } + $nodeType = (int)$node['node_type']; + $nodeId = $this->localId($nodeType === Application::NODE_TYPE_TABLE ? MenuItem::TARGET_TABLE : MenuItem::TARGET_VIEW, $node['node_uuid']); + if ($nodeId !== null) { + $resolved[(int)$node['id']] = ['nodeType' => $nodeType, 'nodeId' => $nodeId]; + } + } + return $resolved; + } + + /** + * Page content from a scheme in the form it is stored in: an exported page has its + * configuration and content apart, the database keeps them together. + * + * @param mixed $content + * @return array> + */ + private function storedContent(mixed $content): array { + if (!is_array($content)) { + return []; + } + $stored = []; + foreach ($content as $key => $entry) { + if (!is_array($entry)) { + continue; + } + if (is_array($entry['configuration'] ?? null) || is_array($entry['content'] ?? null)) { + $entry['configuration'] = [ + 'configuration' => is_array($entry['configuration'] ?? null) ? $entry['configuration'] : [], + 'content' => is_array($entry['content'] ?? null) ? $entry['content'] : [], + ]; + unset($entry['content']); + } + $stored[$key] = $entry; + } + return $stored; + } + + /** + * Whether page content was arranged on the grid: it has a widget that is not a table or view, + * or a position. Content of an older scheme only lists its tables and views in order. + */ + private function isArrangedOnTheGrid(mixed $content): bool { + if (!is_array($content)) { + return false; + } + foreach ($content as $entry) { + if (!is_array($entry)) { + continue; + } + $type = $entry['type'] ?? null; + if ((is_string($type) && $type !== '' && $type !== GridWidgetTypes::TYPE_DATA) + || ($entry['grid_y'] ?? $entry['gridY'] ?? null) !== null) { + return true; + } + } + return false; + } + + /** + * The widgets to save for page content read from a scheme. + * + * @param array> $content read by PageContentHelper + * @param array $schemeNodes + * @param array $nodeKeys the nodes of the application + * @return list> + */ + private function widgetsFromContent(array $content, array $schemeNodes, array &$nodeKeys, string $userId): array { + $widgets = []; + foreach ($content as $entry) { + $widget = [ + 'type' => $entry['type'], + 'configuration' => $entry['configuration'], + 'content' => $entry['content'], + 'gridX' => $entry['grid_x'], + 'gridY' => $entry['grid_y'], + 'gridWidth' => $entry['grid_width'], + 'gridHeight' => $entry['grid_height'], + ]; + if ($entry['type'] === GridWidgetTypes::TYPE_DATA) { + $node = $entry['node_rel_id'] !== null ? ($schemeNodes[$entry['node_rel_id']] ?? null) : null; + if ($node === null || !$this->canPlace($node, $nodeKeys, $userId)) { + $this->logger->info('Data widget skipped on import, its table or view does not exist here'); + continue; + } + $widget['node'] = $node; + } + $widgets[] = $widget; + } + return $widgets; + } + + /** + * The widgets of a grid view of an older scheme, placed as its layout placed them. A data + * widget named its table or view in its content; on a page that is the widget's node. + * + * @param array $nodeKeys the nodes of the application + * @return list> + */ + private function widgetsFromGridView(mixed $grid, array &$nodeKeys, string $userId): 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; + } + $content = is_array($gridWidget['content'] ?? null) ? $gridWidget['content'] : []; + $widget = [ + 'type' => $gridWidget['type'], + 'configuration' => is_array($gridWidget['configuration'] ?? null) ? $gridWidget['configuration'] : [], + 'content' => $content, + 'gridX' => $position['gridX'] ?? null, + 'gridY' => $position['gridY'] ?? null, + 'gridWidth' => $position['gridWidth'] ?? null, + 'gridHeight' => $position['gridHeight'] ?? null, + ]; + if ($gridWidget['type'] === GridWidgetTypes::TYPE_DATA) { + $target = is_array($content['target'] ?? null) ? $content['target'] : []; + unset($widget['content']['target']); + $targetType = $target['type'] ?? null; + $nodeId = is_string($targetType) && is_string($target['uuid'] ?? null) ? $this->localId($targetType, $target['uuid']) : null; + $node = $nodeId === null ? null : [ + 'nodeType' => $targetType === MenuItem::TARGET_TABLE ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW, + 'nodeId' => $nodeId, + ]; + if ($node === null || !$this->canPlace($node, $nodeKeys, $userId)) { + $this->logger->info('Grid widget skipped on import, its table or view does not exist here', ['widget' => $gridWidget['id'] ?? null]); + continue; + } + $widget['node'] = $node; + } + $widgets[] = $widget; + } + return $widgets; + } + + /** + * Whether a data widget can show a node: it is part of the application, or the importer can + * manage it, so saving the page adds it. + * + * @param array{nodeType: int, nodeId: int} $node + * @param array $nodeKeys the nodes of the application, extended by the nodes that saving adds + */ + private function canPlace(array $node, array &$nodeKeys, string $userId): bool { + $key = $this->nodeKey($node['nodeType'], $node['nodeId']); + if (isset($nodeKeys[$key])) { + return true; + } + if (!$this->permissionsService->canManageNodeById($node['nodeType'], $node['nodeId'], $userId)) { + return false; + } + $nodeKeys[$key] = true; + return true; + } + + /** + * The local id of a table or view a menu item names 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; + } + } + + /** + * A page title from a scheme, falling back to the technical name; null when there is neither. + */ + private function pageTitle(mixed $title, string $technicalName): string { + $title = is_string($title) ? trim($title) : ''; + if ($title === '') { + return $technicalName; + } + return mb_substr($title, 0, self::PAGE_TITLE_MAX_LENGTH); + } + + /** + * The local page with the technical name, renamed to the title of the scheme, or a new page. + * + * @param array $pagesByTechnicalName local page ids by technical name; a new page is added + * @throws BadRequestError|NotFoundError|PermissionError|Exception + */ + private function importPage(int $contextId, mixed $title, string $technicalName, array &$pagesByTechnicalName, string $userId): int { + $title = $this->pageTitle($title, $technicalName); + if (isset($pagesByTechnicalName[$technicalName])) { + $this->pageService->update($contextId, $pagesByTechnicalName[$technicalName], $title, null, $userId); + return $pagesByTechnicalName[$technicalName]; + } + $pageId = $this->pageService->create($contextId, $title, $technicalName, $userId)['id']; + $pagesByTechnicalName[$technicalName] = $pageId; + return $pageId; + } + + /** + * The technical name of every grid page and grid view of a scheme, keyed `page:` and + * `gridView:`. + * + * @param array $reserved names the pages of the scheme cannot get + * @return array + */ + private function schemeTechnicalNames(ContextScheme $scheme, array $reserved): array { + $pages = []; + foreach ($scheme->getPages() ?? [] as $key => $page) { + if (!is_array($page) || ($page['page_type'] ?? $page['pageType'] ?? null) === Page::TYPE_STARTPAGE) { + continue; + } + $technicalName = $page['technical_name'] ?? $page['technicalName'] ?? null; + $pages['page:' . $key] = [ + 'name' => is_string($technicalName) ? $technicalName : null, + 'fallback' => TechnicalNameHelper::pageFallback(false, is_string($page['title'] ?? null) ? $page['title'] : null), + ]; + } + foreach ($scheme->getGridViews() as $key => $gridView) { + $technicalName = $gridView['technicalName'] ?? $gridView['technical_name'] ?? null; + $pages['gridView:' . $key] = [ + 'name' => is_string($technicalName) ? $technicalName : null, + 'fallback' => TechnicalNameHelper::pageFallback(false, $gridView['title']), + ]; + } + return TechnicalNameHelper::assign($pages, $reserved); + } + + private function nodeKey(int $nodeType, int $nodeId): string { + return sprintf('t%di%d', $nodeType, $nodeId); + } +} diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index a2f99165aa..5027713f63 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -1060,8 +1060,8 @@ protected function importPageContent(Page $page, array $contents, array $nodeRel */ 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 = []; @@ -1092,13 +1092,15 @@ public function getScheme(int $contextId, string $userId): ContextScheme { } } + $schemeService = $this->contextSchemeService(); return new ContextScheme( $context->getName(), $context->getIcon(), $context->getDescription(), array_values($nodes), - $context->getPages(), + $schemeService->exportPages($contextId, $userId), array_values($tables), + $schemeService->exportMenuItems($context), ); } @@ -1192,16 +1194,15 @@ 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 { - // 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() ?? []; + // Grid views of an older scheme become pages, not nodes + $gridViewUuids = array_column($scheme->getGridViews(), 'uuid'); - $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); @@ -1243,7 +1244,13 @@ public function importScheme(int $contextId, string $name, string $iconName, str 'permissions' => $node['permissions'], ]; } elseif ($node['node_type'] === Application::NODE_TYPE_VIEW) { + if (in_array($node['node_uuid'], $gridViewUuids, true)) { + continue; + } $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'], @@ -1252,8 +1259,39 @@ public function importScheme(int $contextId, string $name, string $iconName, str } } - $context = $this->update($contextId, $userId, $name, $iconName, $description, $resolvedNodes); + $context = $this->update($contextId, $userId, $scheme->getName(), $scheme->getIcon() ?? '', $scheme->getDescription() ?? '', $resolvedNodes); - return $context; + // Pages come after the nodes their data widgets show, the menu after the pages it opens + $schemeService = $this->contextSchemeService(); + $pageIds = ['pages' => [], 'gridViews' => []]; + if ($scheme->getPages() !== null || $scheme->getGridViews() !== []) { + $pageIds = $schemeService->importPages($this->contextMapper->findById($contextId, $userId), $scheme, $userId); + } + if ($scheme->getMenuItems() !== null) { + $menuItems = $schemeService->resolveMenuItems($scheme->getMenuItems(), $userId, $pageIds['pages'], $pageIds['gridViews']); + $this->update($contextId, $userId, null, null, null, null, $menuItems); + } + + return $this->contextMapper->findById($contextId, $userId); + } + + /** + * The services the scheme export and import use. 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); + } + + protected function contextSchemeService(): ContextSchemeService { + return \OCP\Server::get(ContextSchemeService::class); } } diff --git a/openapi.json b/openapi.json index 40c4031158..6e667e4358 100644 --- a/openapi.json +++ b/openapi.json @@ -13831,6 +13831,75 @@ } } } + }, + "menuItems": { + "type": "array", + "nullable": true, + "default": null, + "description": "Menu of the context: tables and views by uuid, pages by their id in the scheme, 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" + }, + "targetPageId": { + "type": "integer", + "format": "int64" + }, + "url": { + "type": "string" + }, + "technicalName": { + "type": "string" + }, + "parentTechnicalName": { + "type": "string" + } + } + } + }, + "pages": { + "type": "array", + "nullable": true, + "default": null, + "description": "Pages of the context with their widgets (a scheme of an older version keys them by id); data widgets name their node by its id in the nodes; leave it out to keep the current pages", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + }, + "gridViews": { + "type": "array", + "default": [], + "description": "Views without a table from a scheme of an older version; each becomes a page", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } } } } diff --git a/playwright/e2e/application.spec.ts b/playwright/e2e/application.spec.ts index ea5753c9f0..ccc4bed234 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' import { createContext, loadContext } from '../support/commands' @@ -204,4 +204,157 @@ 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}`) + 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 as unknown as { technical_name: string }).technical_name).toBe('intake_home') + expect(Object.values(intake!.content).map(entry => entry.type).sort()).toEqual(['data', 'header']) + 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}`) + 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..fc7328183a 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') }}: +
  • +
  • + {{ page.title || (page.page_type === 'startpage' ? t('tables', 'Dashboard') : '') }} +
  • +
+

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

+
+
+
@@ -226,6 +264,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 +289,7 @@ export default { importIcon: true, importDescription: true, importNodes: true, + importMenu: true, addTablesConfig: [], removeColumnsConfig: [], modifyTablesConfig: [], @@ -268,6 +322,11 @@ export default { this.emitPayload() } }, + importMenu: { + handler() { + this.emitPayload() + }, + }, importNodes: { handler() { this.emitPayload() @@ -333,6 +392,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 48c32d2ec6..df64797ef4 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -7703,6 +7703,36 @@ export interface operations { readonly addTables: readonly components["schemas"]["Table"][]; readonly modifyTables: readonly components["schemas"]["Table"][]; }; + /** + * @description Menu of the context: tables and views by uuid, pages by their id in the scheme, 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; + /** Format: int64 */ + readonly targetPageId?: number; + readonly url?: string; + readonly technicalName?: string; + readonly parentTechnicalName?: string; + }[] | null; + /** + * @description Pages of the context with their widgets (a scheme of an older version keys them by id); data widgets name their node by its id in the nodes; leave it out to keep the current pages + * @default null + */ + readonly pages?: readonly { + readonly [key: string]: Record; + }[] | 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..0e98753a5b 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), ); @@ -61,6 +69,63 @@ public function testCreatePassesAPageItemWithoutAnIdOnAsTheNewStartPage(): void $this->assertSame(Http::STATUS_OK, $response->getStatus()); } + 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 testImportPassesTheMenuPagesAndGridViewsOnAndLeavesAbsentOnesNull(): void { + $this->contextService->expects($this->exactly(2))->method('importScheme') + ->willReturnCallback(static function (int $contextId, ContextScheme $scheme): Context { + static $call = 0; + $call++; + if ($call === 1) { + TestCase::assertNull($scheme->getMenuItems()); + TestCase::assertNull($scheme->getPages()); + TestCase::assertSame([], $scheme->getGridViews()); + } else { + TestCase::assertSame([], $scheme->getMenuItems()); + TestCase::assertSame([['id' => 3, 'page_type' => 'grid', 'title' => 'Reports']], $scheme->getPages()); + TestCase::assertSame('grid-1', $scheme->getGridViews()[0]['uuid']); + } + 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']]); + } + public function testUpdateRefusesAPageItemWithoutAnId(): void { $this->contextService->expects($this->never())->method('update'); diff --git a/tests/unit/Model/ContextSchemeTest.php b/tests/unit/Model/ContextSchemeTest.php new file mode 100644 index 0000000000..a47ad203a1 --- /dev/null +++ b/tests/unit/Model/ContextSchemeTest.php @@ -0,0 +1,113 @@ + [], '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', 'targetPageId' => 3]], + 'pages' => [['id' => 3, 'page_type' => 'grid', 'title' => 'Home', 'content' => []]], + '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 testCreateFromInputRejectsPageContentThatIsNotAList(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Every page'); + ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'pages' => [['page_type' => 'grid', 'content' => 'widgets']]]); + } + + /** + * @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/ContextSchemeServiceTest.php b/tests/unit/Service/ContextSchemeServiceTest.php new file mode 100644 index 0000000000..2e9a76f092 --- /dev/null +++ b/tests/unit/Service/ContextSchemeServiceTest.php @@ -0,0 +1,297 @@ +pageService = $this->createMock(PageService::class); + $this->tableMapper = $this->createMock(TableMapper::class); + $this->viewMapper = $this->createMock(ViewMapper::class); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->service = new ContextSchemeService( + $this->pageService, + $this->tableMapper, + $this->viewMapper, + $this->permissionsService, + $this->createMock(LoggerInterface::class), + ); + + // Tables and views exist here under the uuids "table-" and "view-" + $this->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; + }); + $this->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; + }); + $this->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; + }); + $this->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 $nodes = [], array $menuItems = []): Context { + $context = new Context(); + $context->id = 7; + $context->setNodes($nodes); + $context->setPages([]); + $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, + ]; + } + + /** + * @param list> $pages the pages the application has here + * @param array>> $saved collects the widgets saved per page + * @param list $created collects the pages created + */ + private function expectPages(array $pages, array &$saved, array &$created = []): void { + $this->pageService->method('findAll')->willReturn($pages); + $this->pageService->method('saveWidgets')->willReturnCallback(static function (int $contextId, int $pageId, array $widgets) use (&$saved): array { + $saved[$pageId] = $widgets; + return []; + }); + $this->pageService->method('create')->willReturnCallback(static function (int $contextId, string $title, ?string $technicalName) use (&$created): array { + $created[] = [$title, $technicalName]; + return ['id' => 100 + count($created)]; + }); + } + + private function startPage(int $id = 1): array { + return ['id' => $id, 'page_type' => 'startpage', 'title' => null, 'technical_name' => 'dashboard', 'content' => []]; + } + + public function testExportNamesTablesAndViewsByUuidAndPagesByIdAndSkipsBrokenItems(): void { + $context = $this->context(menuItems: [ + $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), + ]); + + $exported = $this->service->exportMenuItems($context); + + $this->assertSame(['dashboard', 'cases', 'open', 'docs'], array_column($exported, 'technicalName')); + $this->assertSame([3, null, null, null], array_column($exported, 'targetPageId')); + $this->assertSame([null, 'table-5', 'view-6', null], array_column($exported, 'targetUuid')); + $this->assertSame([null, 'dashboard', null, null], array_column($exported, 'parentTechnicalName')); + } + + public function testImportMapsPageTargetsAndTurnsAGridViewTargetIntoItsPage(): void { + $this->permissionsService->method('canManageNodeById')->willReturnCallback(static fn (int $type, int $id): bool => $id !== 8); + + $items = $this->service->resolveMenuItems([ + ['label' => 'Home', 'targetType' => 'page', 'targetPageId' => 3, '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', 'targetPageId' => 4], + ['label' => 'Docs', 'targetType' => 'url', 'url' => 'https://example.org', 'parentTechnicalName' => 'skipped'], + ], self::USER, [3 => 30], ['grid-1' => 31]); + + $this->assertSame(['Home', 'Old page', 'Cases', 'Docs'], array_map(static fn ($item) => $item->getLabel(), $items)); + $this->assertSame(['page', 'page', 'table', 'url'], array_map(static fn ($item) => $item->getTargetType(), $items)); + $this->assertSame([30, 31, 5, null], array_map(static fn ($item) => $item->getTargetId(), $items)); + $this->assertSame([null, null, 0, null], array_map(static fn ($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 ($item) => $item->getTechnicalName(), $items)); + } + + public function testImportFillsTheStartPageOnlyWhenItWasArrangedOnTheGrid(): void { + $saved = []; + $this->expectPages([$this->startPage()], $saved); + $context = $this->context([10 => ['id' => 10, 'node_id' => 5, 'node_type' => Application::NODE_TYPE_TABLE, 'permissions' => 1]]); + $nodes = [['id' => 70, 'node_type' => Application::NODE_TYPE_TABLE, 'node_uuid' => 'table-5', 'permissions' => 1]]; + + $listed = $this->service->importPages($context, new ContextScheme('App', '', '', $nodes, [ + '2' => ['id' => 2, 'page_type' => 'startpage', 'content' => ['11' => ['id' => 11, 'order' => 10, 'node_rel_id' => 70]]], + ]), self::USER); + $this->assertSame([], $saved, 'a start page that only lists its tables keeps what the nodes gave it'); + $this->assertSame([2 => 1], $listed['pages']); + + $this->service->importPages($context, new ContextScheme('App', '', '', $nodes, [[ + 'id' => 2, 'page_type' => 'startpage', 'content' => [ + ['type' => 'header', 'configuration' => ['title' => '', 'showTitle' => false], 'content' => ['title' => 'Welcome'], 'grid_x' => 0, 'grid_y' => 0, 'grid_width' => 12, 'grid_height' => 2, 'node_rel_id' => null], + ['type' => 'data', 'configuration' => [], 'content' => [], 'grid_x' => 0, 'grid_y' => 2, 'grid_width' => 6, 'grid_height' => 5, 'node_rel_id' => 70], + ], + ]]), self::USER); + $this->assertSame(['header', 'data'], array_column($saved[1], 'type')); + $this->assertSame('Welcome', $saved[1][0]['content']['title']); + $this->assertSame([0, 2], array_column($saved[1], 'gridY')); + $this->assertSame(['nodeType' => Application::NODE_TYPE_TABLE, 'nodeId' => 5], $saved[1][1]['node']); + } + + public function testImportReplacesAPageWithTheSameTechnicalNameAndAddsTheOthers(): void { + $saved = []; + $created = []; + $this->expectPages([$this->startPage(), ['id' => 4, 'page_type' => 'grid', 'title' => 'Old', 'technical_name' => 'reports', 'content' => []]], $saved, $created); + $this->pageService->expects($this->once())->method('update')->with(7, 4, 'Reports', null, self::USER); + + $result = $this->service->importPages($this->context(), new ContextScheme('App', '', '', [], [ + ['id' => 20, 'page_type' => 'grid', 'title' => 'Reports', 'technical_name' => 'reports', 'content' => []], + ['id' => 21, 'page_type' => 'grid', 'title' => 'Intake', 'technical_name' => 'intake', 'content' => [ + ['type' => 'text', 'configuration' => [], 'content' => ['text' => 'Hello'], 'grid_x' => 0, 'grid_y' => 0, 'grid_width' => 6, 'grid_height' => 2], + ]], + ['id' => 22, 'page_type' => 'grid', 'title' => '', 'technical_name' => 'Not valid', 'content' => []], + ]), self::USER); + + $this->assertSame([20 => 4, 21 => 101, 22 => 102], $result['pages']); + $this->assertSame([['Intake', 'intake'], ['not_valid', 'not_valid']], $created); + $this->assertSame([], $saved[4]); + $this->assertSame('Hello', $saved[101][0]['content']['text']); + } + + public function testImportNamesPagesWithoutTechnicalNameAndMatchesThemByThatName(): void { + $saved = []; + $created = []; + $this->expectPages([$this->startPage(), ['id' => 4, 'page_type' => 'grid', 'title' => 'Old', 'technical_name' => 'open_cases', 'content' => []]], $saved, $created); + $this->pageService->expects($this->once())->method('update')->with(7, 4, 'Open cases', null, self::USER); + + $result = $this->service->importPages($this->context(), new ContextScheme('App', '', '', [], [ + ['id' => 20, 'page_type' => 'grid', 'title' => 'Open cases', 'content' => []], + ['id' => 21, 'page_type' => 'grid', 'title' => 'Open cases', 'content' => []], + ['id' => 22, 'page_type' => 'grid', 'title' => 'Board', 'technical_name' => 'dashboard', 'content' => []], + ['id' => 23, 'page_type' => 'grid', 'title' => 'Reports', 'technical_name' => 'reports', 'content' => []], + ['id' => 24, 'page_type' => 'grid', 'title' => 'Reports again', 'technical_name' => 'reports', 'content' => []], + ]), self::USER); + + $this->assertSame([20 => 4, 21 => 101, 22 => 102, 23 => 103, 24 => 104], $result['pages']); + $this->assertSame([ + ['Open cases', 'open_cases_2'], + ['Board', 'dashboard_2'], + ['Reports', 'reports'], + ['Reports again', 'reports_2'], + ], $created); + } + + public function testImportMatchesAGridViewOfAnOlderSchemeByItsTechnicalName(): void { + $saved = []; + $created = []; + $this->expectPages([$this->startPage(), ['id' => 4, 'page_type' => 'grid', 'title' => 'Intake', 'technical_name' => 'intake', 'content' => []]], $saved, $created); + $this->pageService->expects($this->once())->method('update')->with(7, 4, 'Intake home', null, self::USER); + + $result = $this->service->importPages($this->context(), new ContextScheme('App', '', '', [], null, [], null, [ + ['uuid' => 'grid-1', 'title' => 'Intake home', 'technicalName' => 'intake'], + ['uuid' => 'grid-2', 'title' => 'Archive'], + ]), self::USER); + + $this->assertSame(['grid-1' => 4, 'grid-2' => 101], $result['gridViews']); + $this->assertSame([['Archive', 'archive']], $created); + } + + public function testImportLeavesOutDataWidgetsWhoseNodeIsNotHereOrCannotBeAdded(): void { + $saved = []; + $this->expectPages([$this->startPage()], $saved); + $this->permissionsService->method('canManageNodeById')->willReturnCallback(static fn (int $type, int $id): bool => $id === 6); + $nodes = [ + ['id' => 70, 'node_type' => Application::NODE_TYPE_TABLE, 'node_uuid' => 'table-5', 'permissions' => 1], + ['id' => 71, 'node_type' => Application::NODE_TYPE_VIEW, 'node_uuid' => 'view-6', 'permissions' => 1], + ['id' => 72, 'node_type' => Application::NODE_TYPE_TABLE, 'node_uuid' => 'elsewhere', 'permissions' => 1], + ]; + $data = static fn (int $relId, int $y): array => ['type' => 'data', 'configuration' => [], 'content' => [], 'grid_x' => 0, 'grid_y' => $y, 'grid_width' => 12, 'grid_height' => 5, 'node_rel_id' => $relId]; + + $this->service->importPages($this->context(), new ContextScheme('App', '', '', $nodes, [ + ['id' => 20, 'page_type' => 'grid', 'title' => 'Data', 'content' => [$data(70, 0), $data(71, 5), $data(72, 10), $data(99, 15)]], + ]), self::USER); + + $this->assertSame([['nodeType' => Application::NODE_TYPE_VIEW, 'nodeId' => 6]], array_column($saved[101], 'node')); + } + + public function testImportTurnsAGridViewOfAnOlderSchemeIntoAPage(): void { + $saved = []; + $created = []; + $this->expectPages([$this->startPage()], $saved, $created); + $this->permissionsService->method('canManageNodeById')->willReturn(true); + $context = $this->context([10 => ['id' => 10, 'node_id' => 5, 'node_type' => Application::NODE_TYPE_TABLE, 'permissions' => 1]]); + + $result = $this->service->importPages($context, new ContextScheme('App', '', '', [], null, [], null, [[ + 'uuid' => 'grid-1', + 'title' => 'Intake home', + 'grid' => [ + 'widgets' => [ + ['id' => 'w1', 'type' => 'header', 'configuration' => ['showTitle' => false], 'content' => ['title' => 'Welcome']], + ['id' => 'w2', 'type' => 'data', 'configuration' => [], 'content' => ['target' => ['type' => 'table', 'id' => 99, 'uuid' => 'table-5']]], + ['id' => 'w3', 'type' => 'data', 'configuration' => [], 'content' => ['target' => ['type' => 'view', 'id' => 98, 'uuid' => 'not-here']]], + ['id' => 'w4', 'type' => 'text', 'configuration' => [], 'content' => ['text' => 'Not placed']], + ], + 'layout' => [ + ['widgetId' => 'w1', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2], + ['widgetId' => 'w2', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 8, 'gridHeight' => 5], + ['widgetId' => 'w3', 'gridX' => 8, 'gridY' => 2, 'gridWidth' => 4, 'gridHeight' => 5], + ], + ], + ]]), self::USER); + + $this->assertSame(['grid-1' => 101], $result['gridViews']); + $this->assertSame([['Intake home', 'intake_home']], $created); + $this->assertSame(['header', 'data'], array_column($saved[101], 'type')); + $this->assertSame(['nodeType' => Application::NODE_TYPE_TABLE, 'nodeId' => 5], $saved[101][1]['node']); + $this->assertSame([], $saved[101][1]['content']); + $this->assertSame([8, 5], [$saved[101][1]['gridWidth'], $saved[101][1]['gridHeight']]); + } +} diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index 649f5a84e1..5a0a601427 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; @@ -48,6 +49,7 @@ final class ContextServiceTest extends TestCase { private MockObject $tableMapper; private MockObject $viewMapper; private MockObject $permissionsService; + private array $constructorArgs; protected function setUp(): void { parent::setUp(); @@ -70,7 +72,7 @@ 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->constructorArgs = [ $this->contextMapper, $this->contextNodeRelMapper, $this->pageMapper, @@ -88,7 +90,106 @@ protected function setUp(): void { $this->viewMapper, $this->menuItemMapper, $l10n, - ); + ]; + $this->service = new ContextService(...$this->constructorArgs); + } + + /** + * A service whose updates, scheme service and table services are mocked, to follow an import. + * + * @param list $calls collects the steps of the import in order + */ + private function importingService(ContextSchemeService&MockObject $schemeService, array &$calls): ContextService&MockObject { + $service = $this->getMockBuilder(ContextService::class) + ->setConstructorArgs($this->constructorArgs) + ->onlyMethods(['update', 'contextSchemeService', 'tableService', 'viewService', 'columnService']) + ->getMock(); + $service->method('contextSchemeService')->willReturn($schemeService); + $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, []); + }); + $this->contextMapper->method('findById')->willReturnCallback(fn (int $id): Context => $this->contextWithMenu($id, [])); + 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 = []; + $schemeService = $this->createMock(ContextSchemeService::class); + $schemeService->expects($this->once())->method('importPages')->willReturnCallback(static function () use (&$calls): array { + $calls[] = 'pages'; + return ['pages' => [3 => 30], 'gridViews' => ['grid-1' => 31]]; + }); + $schemeService->expects($this->once())->method('resolveMenuItems') + ->with([['label' => 'Home']], 'user-1', [3 => 30], ['grid-1' => 31]) + ->willReturn([MenuItemInput::createFromInputArray(['label' => 'Home', 'targetType' => 'page', 'targetId' => 30])]); + $table = new Table(); + $table->setId(5); + $this->tableMapper->method('findByUuid')->with('table-uuid')->willReturn($table); + $service = $this->importingService($schemeService, $calls); + + $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', 'content' => []]], + [['label' => 'Home']], + [['uuid' => 'grid-1', 'title' => 'Old page']], + ), 'user-1'); + + // The grid view node is no node any more: it became a page + $this->assertSame(['nodes:[5]', 'pages', 'menu:1'], $calls); + } + + public function testImportOfASchemeWithoutPagesOrMenuLeavesBothAlone(): void { + $calls = []; + $schemeService = $this->createMock(ContextSchemeService::class); + $schemeService->expects($this->never())->method('importPages'); + $schemeService->expects($this->never())->method('resolveMenuItems'); + $service = $this->importingService($schemeService, $calls); + + $service->importScheme(7, $this->scheme([], null, null), 'user-1'); + + $this->assertSame(['nodes:[]'], $calls); + } + + public function testImportOfAnEmptyMenuClearsTheMenu(): void { + $calls = []; + $schemeService = $this->createMock(ContextSchemeService::class); + $schemeService->method('resolveMenuItems')->with([])->willReturn([]); + $service = $this->importingService($schemeService, $calls); + + $service->importScheme(7, $this->scheme([], null, []), 'user-1'); + + $this->assertSame(['nodes:[]', 'menu:0'], $calls); + } + + public function testExportCarriesThePagesAndTheMenu(): void { + $context = $this->contextWithMenu(7, []); + $this->contextMapper->method('findById')->willReturn($context); + $schemeService = $this->createMock(ContextSchemeService::class); + $schemeService->method('exportPages')->with(7, 'user-1')->willReturn([['id' => 3, 'page_type' => 'startpage', 'content' => []]]); + $schemeService->method('exportMenuItems')->with($context)->willReturn([['label' => 'Home', 'targetType' => 'page', 'targetPageId' => 3]]); + $service = $this->getMockBuilder(ContextService::class) + ->setConstructorArgs($this->constructorArgs) + ->onlyMethods(['contextSchemeService', 'tableService', 'viewService']) + ->getMock(); + $service->method('contextSchemeService')->willReturn($schemeService); + $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([['id' => 3, 'page_type' => 'startpage', 'content' => []]], $scheme['pages']); + $this->assertSame([['label' => 'Home', 'targetType' => 'page', 'targetPageId' => 3]], $scheme['menuItems']); } private function contextWithMenu(int $id, array $menuItems): Context {