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 {