diff --git a/cypress/component/ImportContextSchemePreview.cy.js b/cypress/component/ImportContextSchemePreview.cy.js index d834d00442..9e3409abc8 100644 --- a/cypress/component/ImportContextSchemePreview.cy.js +++ b/cypress/component/ImportContextSchemePreview.cy.js @@ -6,6 +6,8 @@ import ImportContextSchemePreview from '../../src/modules/modals/ImportContextSc const MENU_SWITCH = '[data-cy="importSchemeMenu"]' const SKIPPED = '[data-cy="importSchemeMenuSkipped"]' +const PAGES_SWITCH = '[data-cy="importSchemePages"]' +const PAGES_SKIPPED = '[data-cy="importSchemePagesSkipped"]' const schemeMenu = [ { parent: null, title: 'Reports', displayTitle: 'Reports', icon: null, description: null, target: null }, @@ -40,22 +42,64 @@ describe('Import application scheme preview', () => { cy.get(SKIPPED).should('contain', 'Missing table') }) + it('sends the pages of the scheme with the import', () => { + mountPreview(undefined, { from: [{ technicalName: 'dashboard', title: 'Old start' }], to: schemePages, skipped: [] }) + .then(({ wrapper }) => { + cy.get(PAGES_SWITCH).should('exist') + cy.contains('Old start').should('exist') + cy.contains('Overview').should('exist') + cy.wrap(null).then(() => { + expect(lastPayload(wrapper).pages).to.deep.equal(schemePages) + }) + }) + }) + + it('leaves the pages out when the switch is turned off', () => { + mountPreview(undefined, { from: [], to: schemePages, skipped: [] }) + .then(({ wrapper }) => { + cy.get(PAGES_SWITCH).click({ force: true }) + cy.wrap(null).then(() => { + expect(lastPayload(wrapper)).to.not.have.property('pages') + }) + }) + }) + + it('lists the pages and widgets the import would skip', () => { + mountPreview(undefined, { + from: [], + to: [], + skipped: [ + { pageIndex: 0, page: 'Overview', widgetIndex: 2, widget: 'Elsewhere', reason: 'its table is not here or cannot be managed by the user' }, + { pageIndex: 1, page: 'Second start', widgetIndex: null, widget: null, reason: 'an application has one start page' }, + ], + }) + + cy.get(PAGES_SKIPPED).should('contain', 'Overview: Elsewhere').and('contain', 'Second start') + }) + it('has no menu section for a preview without a menu', () => { mountPreview(undefined) .then(({ wrapper }) => { cy.get(MENU_SWITCH).should('not.exist') + cy.get(PAGES_SWITCH).should('not.exist') cy.wrap(null).then(() => { expect(lastPayload(wrapper)).to.not.have.property('menu') + expect(lastPayload(wrapper)).to.not.have.property('pages') }) }) }) }) +const schemePages = [ + { page_type: 'grid', technical_name: 'overview', title: 'Overview', widgets: [{ type: 'data', title: 'Tasks', source: { targetType: 'table', targetUuid: 'table-uuid' } }] }, +] + /** * @param {object|undefined} menu the menu part of the preview response + * @param {object|undefined} pages the pages part of the preview response * @return {Cypress.Chainable} the mount result */ -function mountPreview(menu) { +function mountPreview(menu, pages = undefined) { return cy.mount(ImportContextSchemePreview, { props: { previewData: { @@ -66,6 +110,7 @@ function mountPreview(menu) { addTables: {}, modifyTables: {}, ...(menu === undefined ? {} : { menu }), + ...(pages === undefined ? {} : { pages }), }, }, }) diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index e7955ce01e..d1c6f505ba 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -360,6 +360,7 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR * @param list $nodes Ordered meta data of the related nodes * @psalm-param array{addTables: list, modifyTables: list} $tables Tables to be added or modified * @param list>|null $menu Menu of the scheme, replacing the menu of the context: pages by technical name, tables and views by uuid, the group of an item by its position in the list; items that cannot be used here are skipped. Left out, the menu stays as it is + * @param list>|null $pages Pages of the scheme: the start page updates the start page, a grid page the page with its technical name or it is added, and its widgets replace the widgets of that page; a widget names its table or view by `targetType` and `targetUuid` in its `source`. Pages and widgets that cannot be used here are skipped. Left out, the pages stay as they are * * @return DataResponse, array{}>|DataResponse * @@ -370,10 +371,10 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] - public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $menu = null): DataResponse { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $menu = null, ?array $pages = null): DataResponse { try { $this->db->beginTransaction(); - $context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId, $menu); + $context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId, $menu, $pages); $this->db->commit(); return new DataResponse($context->jsonSerialize()); } catch (\InvalidArgumentException $e) { diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index 12ffa5e96a..56b3d771d1 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -23,6 +23,7 @@ use OCA\Tables\Db\PageMapper; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; +use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; @@ -640,21 +641,34 @@ private function importablePages(array $pages): array { $importable = []; $hasStartPage = false; foreach ($pages as $key => $pageArr) { - $type = is_array($pageArr) ? ($pageArr['page_type'] ?? $pageArr['pageType'] ?? null) : null; - if ($type !== Page::TYPE_STARTPAGE && $type !== Page::TYPE_GRID) { - $this->logger->warning('Skipping an imported page: its type is not ' . Page::TYPE_STARTPAGE . ' or ' . Page::TYPE_GRID . '.'); + $reason = self::pageSkipReason($pageArr, $hasStartPage); + if ($reason !== null) { + $this->logger->warning('Skipping an imported page: ' . $reason . '.'); continue; } - if ($type === Page::TYPE_STARTPAGE && $hasStartPage) { - $this->logger->warning('Skipping an imported page: an application has one start page.'); - continue; - } - $hasStartPage = $hasStartPage || $type === Page::TYPE_STARTPAGE; $importable[$key] = $pageArr; } return $importable; } + /** + * Why an exported page cannot be imported, or null when it can: it must be a start + * page or a grid page, and an application has one start page. + * + * @param bool $hasStartPage whether an earlier page of the import is the start page; set when this one is + */ + private static function pageSkipReason(mixed $pageArr, bool &$hasStartPage): ?string { + $type = is_array($pageArr) ? ($pageArr['page_type'] ?? $pageArr['pageType'] ?? null) : null; + if ($type !== Page::TYPE_STARTPAGE && $type !== Page::TYPE_GRID) { + return 'its type is not ' . Page::TYPE_STARTPAGE . ' or ' . Page::TYPE_GRID; + } + if ($type === Page::TYPE_STARTPAGE && $hasStartPage) { + return 'an application has one start page'; + } + $hasStartPage = $hasStartPage || $type === Page::TYPE_STARTPAGE; + return null; + } + /** * Imports an application of a user's data that holds the given table. Its menu keeps * the links, the items for its pages and the items for the table, found by the uuid @@ -786,14 +800,23 @@ private function logSkippedMenuItems(array $skipped): void { } /** - * The node relation of an application for an exported table or view, found by uuid. + * The table or view (by kind, `table` or `view`) with a uuid, or null when it is not here. + * + * @throws Exception */ - private function nodeRelIdByUuid(Context $context, string $kind, string $uuid): ?int { + private function findNodeByUuid(string $kind, string $uuid): Table|View|null { try { - $node = $kind === 'table' ? $this->tableMapper->findByUuid($uuid) : $this->viewMapper->findByUuid($uuid); + return $kind === 'table' ? $this->tableMapper->findByUuid($uuid) : $this->viewMapper->findByUuid($uuid); } catch (DoesNotExistException) { return null; } + } + + /** + * The node relation of an application for an exported table or view, found by uuid. + */ + private function nodeRelIdByUuid(Context $context, string $kind, string $uuid): ?int { + $node = $this->findNodeByUuid($kind, $uuid); if ($node === null) { return null; } @@ -860,16 +883,9 @@ public function getPagesWithWidgets(Context $context): array { * @throws Exception */ private function importWidget(int $pageId, int $index, mixed $widgetArr, array $nodeRelMap): void { - if (!is_array($widgetArr) || !is_string($widgetArr['type'] ?? null)) { - return; - } try { - $hasSource = $this->gridWidgetTypes->get($widgetArr['type'])['hasSource']; - $settings = $this->gridWidgetTypes->sanitizeSettings($widgetArr['type'], $widgetArr['settings'] ?? null); - $title = $this->gridWidgetTypes->validateTitle($index, $widgetArr['title'] ?? null); - $description = $this->gridWidgetTypes->validateDescription($index, $widgetArr['description'] ?? null); - $position = $this->gridWidgetTypes->validatePosition($index, $widgetArr); - $source = $widgetArr['source'] ?? null; + $widget = $this->readImportedWidget($index, $widgetArr); + $source = $widget['source']; $oldNodeRelId = is_array($source) ? ($source['nodeRelId'] ?? null) : null; if (($source !== null && !is_array($source)) || ($oldNodeRelId !== null && !is_int($oldNodeRelId))) { throw new BadRequestError('The source of widget ' . $index . ' must be an object with a numeric nodeRelId.'); @@ -878,19 +894,41 @@ private function importWidget(int $pageId, int $index, mixed $widgetArr, array $ $this->logger->warning('Skipping an imported widget: ' . $e->getMessage(), ['exception' => $e]); return; } - $newNodeRel = $hasSource && $oldNodeRelId !== null ? ($nodeRelMap[$oldNodeRelId] ?? null) : null; + $newNodeRel = $widget['hasSource'] && $oldNodeRelId !== null ? ($nodeRelMap[$oldNodeRelId] ?? null) : null; $this->widgetService->insert( $pageId, - $widgetArr['type'], - $title, - $position, - $settings, + $widget['type'], + $widget['title'], + $widget['position'], + $widget['settings'], $newNodeRel?->getId(), - $this->importedIcon($widgetArr['icon'] ?? null), - $description, + $widget['icon'], + $widget['description'], ); } + /** + * The fields of an exported widget as the API would save them, and its source as exported. + * + * @return array{type: string, hasSource: bool, title: ?string, icon: ?string, description: ?string, position: array{gridX: int, gridY: int, gridWidth: int, gridHeight: int}, settings: array, source: mixed} + * @throws BadRequestError when the widget would not be saved through the API + */ + private function readImportedWidget(int $index, mixed $widgetArr): array { + if (!is_array($widgetArr) || !is_string($widgetArr['type'] ?? null)) { + throw new BadRequestError('Widget ' . $index . ' must be an object with a type.'); + } + return [ + 'type' => $widgetArr['type'], + 'hasSource' => $this->gridWidgetTypes->get($widgetArr['type'])['hasSource'], + 'title' => $this->gridWidgetTypes->validateTitle($index, $widgetArr['title'] ?? null), + 'icon' => $this->importedIcon($widgetArr['icon'] ?? null), + 'description' => $this->gridWidgetTypes->validateDescription($index, $widgetArr['description'] ?? null), + 'position' => $this->gridWidgetTypes->validatePosition($index, $widgetArr), + 'settings' => $this->gridWidgetTypes->sanitizeSettings($widgetArr['type'], $widgetArr['settings'] ?? null), + 'source' => $widgetArr['source'] ?? null, + ]; + } + /** * An exported id usable as an array key, or null for a value of another type. */ @@ -969,12 +1007,40 @@ public function getScheme(int $contextId, string $userId): ContextScheme { $context->getIcon(), $context->getDescription(), array_values($nodes), - $this->getPagesWithWidgets($context), + self::exportPages($this->getPagesWithWidgets($context), $nodes), array_values($tables), $this->menuService->export($context), ); } + /** + * The pages of an application as a scheme carries them: the source of a widget also + * names its table or view by `targetType` and `targetUuid`, which an import resolves. + * + * @param array> $pages the pages with their widgets + * @param array> $nodes the nodes of the scheme, with their uuid + * @return array> + */ + private static function exportPages(array $pages, array $nodes): array { + $targets = []; + foreach ($nodes as $node) { + if (isset($node['node_uuid'])) { + $targets[$node['id']] = [ + 'targetType' => $node['node_type'] === Application::NODE_TYPE_TABLE ? 'table' : 'view', + 'targetUuid' => $node['node_uuid'], + ]; + } + } + foreach ($pages as $pageId => $page) { + foreach ($page['widgets'] ?? [] as $index => $widget) { + if (is_array($widget['source'] ?? null)) { + $pages[$pageId]['widgets'][$index]['source'] += $targets[$widget['source']['nodeRelId']] ?? []; + } + } + } + return $pages; + } + /** * @param int $contextId * @param array $updateScheme @@ -1041,6 +1107,23 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string } } + $schemePageIds = []; + if (is_array($updateScheme['pages'] ?? null)) { + $addedUuids = $this->addedSchemeUuids($updateSchemeTables, array_keys($tablesMap)); + $schemeNodes = $this->previewSchemeNodes($context, is_array($updateScheme['nodes'] ?? null) ? $updateScheme['nodes'] : [], $userId, $addedUuids); + ['pages' => $pages, 'skipped' => $skipped] = $this->resolveSchemePages($context, array_values($updateScheme['pages']), $schemeNodes); + $changes['pages'] = [ + 'from' => array_values(array_map(static fn (array $page): array => ['technicalName' => $page['technical_name'] ?? null, 'title' => $page['title'] ?? null], $context->getPages() ?? [])), + 'to' => array_column($pages, 'entry'), + 'skipped' => $skipped, + ]; + foreach ($pages as $page) { + if ($page['name'] !== null) { + // a page the import adds is not here yet; any id passes the check + $schemePageIds[$page['name']] = $page['pageId'] ?? 0; + } + } + } if (is_array($updateScheme['menu'] ?? null)) { $schemeUuids = []; foreach ($changes['nodes']['to'] ?? [] as $node) { @@ -1051,6 +1134,7 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string $context, $updateScheme['menu'], static fn (string $kind, string $uuid): ?int => isset($schemeUuids[$kind][$uuid]) ? 0 : null, + $schemePageIds, ); $changes['menu'] = [ 'from' => $currentScheme->getMenu(), @@ -1062,6 +1146,240 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string return $changes; } + /** + * The nodes of a scheme that an import makes nodes of the application, by node type and + * uuid as {@see self::resolveSchemePages()} takes them: a node the application has, one + * the import adds, and one that is here and manageable by the user. + * + * @param array $schemeNodes + * @param array $addedUuids uuids of the tables and views the import adds + * @return array + * @throws Exception + */ + private function previewSchemeNodes(Context $context, array $schemeNodes, string $userId, array $addedUuids): array { + $current = []; + foreach ($context->getNodes() ?? [] as $node) { + $current[sprintf('t%di%d', $node['node_type'], $node['node_id'])] = true; + } + $nodes = []; + foreach ($schemeNodes as $node) { + $nodeType = is_array($node) ? ($node['node_type'] ?? null) : null; + $uuid = is_array($node) ? ($node['node_uuid'] ?? null) : null; + if (($nodeType !== Application::NODE_TYPE_TABLE && $nodeType !== Application::NODE_TYPE_VIEW) || !is_string($uuid)) { + continue; + } + $found = $this->findNodeByUuid($nodeType === Application::NODE_TYPE_TABLE ? 'table' : 'view', $uuid); + if (isset($addedUuids[$uuid]) + || ($found !== null && (isset($current[sprintf('t%di%d', $nodeType, $found->getId())]) || $this->permissionsService->canManageNodeById($nodeType, $found->getId(), $userId)))) { + $nodes[$nodeType . ':' . $uuid] = $found?->getId(); + } + } + return $nodes; + } + + /** + * The uuids of the tables and views of a scheme that are not here, which an import adds. + * + * @param array $schemeTables + * @param list $localTableUuids + * @return array + * @throws Exception + */ + private function addedSchemeUuids(array $schemeTables, array $localTableUuids): array { + $localTableUuids = array_flip($localTableUuids); + $addedUuids = []; + foreach ($schemeTables as $table) { + if (!is_array($table)) { + continue; + } + if (is_string($table['uuid'] ?? null) && !isset($localTableUuids[$table['uuid']])) { + $addedUuids[$table['uuid']] = true; + } + foreach (is_array($table['views'] ?? null) ? $table['views'] : [] as $view) { + if (is_array($view) && is_string($view['uuid'] ?? null) && $this->findNodeByUuid('view', $view['uuid']) === null) { + $addedUuids[$view['uuid']] = true; + } + } + } + return $addedUuids; + } + + /** + * Checks the pages of a scheme against an application of this instance. Pages follow + * {@see self::pageSkipReason()}: the start page of the scheme is the start page of the + * application, and a grid page is the page with its technical name, or a new one. A + * widget is checked as the API would save it, and the table or view it shows, named by + * uuid, must be a node of the application once the nodes of the scheme are imported; a + * widget never adds a node. A widget that does not pass, that sits on the start page or + * that is past {@see GridWidgetTypes::MAX_WIDGETS_PER_PAGE} is skipped with the reason, + * and so is a page that cannot be imported. + * + * Each page comes with the name the scheme gives it, the id of the page it updates or + * the technical name a new page gets, and its entry with the kept widgets only. + * + * @param list $schemePages + * @param array $nodes the nodes of the application after the import, by node type and uuid (`:`), with their id when it is known + * @return array{pages: list, name: ?string, pageId: ?int, technicalName: ?string, isGrid: bool, title: ?string, icon: ?string, description: ?string, widgets: list, source: mixed}, source: ?array{0: int, 1: ?int}}>}>, skipped: list} + */ + private function resolveSchemePages(Context $context, array $schemePages, array $nodes): array { + $startPageId = null; + $pageIdsByName = []; + foreach ($context->getPages() ?? [] as $pageId => $page) { + if (($page['page_type'] ?? null) === Page::TYPE_STARTPAGE) { + $startPageId ??= (int)$pageId; + } + if (is_string($page['technical_name'] ?? null)) { + $pageIdsByName[$page['technical_name']] = (int)$pageId; + } + } + + $pages = []; + $skipped = []; + $usedNames = []; + $hasStartPage = false; + foreach ($schemePages as $pageIndex => $pageArr) { + $skip = static function (string $reason, ?int $widgetIndex = null, mixed $widget = null) use (&$skipped, $pageIndex, $pageArr): void { + $skipped[] = [ + 'pageIndex' => $pageIndex, + 'page' => is_array($pageArr) ? ($pageArr['title'] ?? $pageArr['technical_name'] ?? $pageArr['technicalName'] ?? null) : null, + 'widgetIndex' => $widgetIndex, + 'widget' => is_array($widget) ? ($widget['title'] ?? null) : null, + 'reason' => $reason, + ]; + }; + $reason = self::pageSkipReason($pageArr, $hasStartPage); + if ($reason !== null || !is_array($pageArr)) { + $skip($reason ?? 'it is not an object'); + continue; + } + $name = $pageArr['technical_name'] ?? $pageArr['technicalName'] ?? null; + $name = is_string($name) && TechnicalNameValidator::isValid($name) && !isset($usedNames[$name]) ? $name : null; + $pageId = null; + $technicalName = null; + if (($pageArr['page_type'] ?? $pageArr['pageType'] ?? null) === Page::TYPE_STARTPAGE) { + if ($startPageId === null) { + $skip('the application has no start page'); + continue; + } + $pageId = $startPageId; + } elseif ($name !== null && isset($pageIdsByName[$name])) { + $pageId = $pageIdsByName[$name]; + } elseif ($name !== null && !Page::isDefaultName($name)) { + $technicalName = $name; + } + if ($name !== null) { + $usedNames[$name] = true; + } + $isGrid = $pageId === null || $pageId !== $startPageId; + + $widgets = []; + $keptEntries = []; + foreach (is_array($pageArr['widgets'] ?? null) ? array_values($pageArr['widgets']) : [] as $widgetIndex => $widgetArr) { + if (!$isGrid) { + $skip('only grid pages hold widgets', $widgetIndex, $widgetArr); + continue; + } + if ($widgetIndex >= GridWidgetTypes::MAX_WIDGETS_PER_PAGE) { + $skip('a page holds at most ' . GridWidgetTypes::MAX_WIDGETS_PER_PAGE . ' widgets', $widgetIndex, $widgetArr); + continue; + } + try { + $widget = $this->readImportedWidget($widgetIndex, $widgetArr); + } catch (BadRequestError $e) { + $skip($e->getMessage(), $widgetIndex, $widgetArr); + continue; + } + $source = $widget['hasSource'] ? $widget['source'] : null; + $node = null; + if ($source !== null) { + $targetType = is_array($source) ? ($source['targetType'] ?? null) : null; + $targetUuid = is_array($source) ? ($source['targetUuid'] ?? null) : null; + if (($targetType !== 'table' && $targetType !== 'view') || !is_string($targetUuid)) { + $skip('its source names no table or view by uuid', $widgetIndex, $widgetArr); + continue; + } + $nodeType = $targetType === 'table' ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW; + if (!array_key_exists($nodeType . ':' . $targetUuid, $nodes)) { + $skip('its ' . $targetType . ' is not one of the resources of the application', $widgetIndex, $widgetArr); + continue; + } + $node = [$nodeType, $nodes[$nodeType . ':' . $targetUuid]]; + } + $widgets[] = ['widget' => $widget, 'source' => $node]; + $keptEntries[] = $widgetArr; + } + + $pages[] = [ + 'entry' => ['widgets' => $keptEntries] + $pageArr, + 'name' => $name, + 'pageId' => $pageId, + 'technicalName' => $technicalName, + 'isGrid' => $isGrid, + 'title' => $this->importedText($pageArr['title'] ?? null, Page::TITLE_MAX_LENGTH, 'page title'), + 'icon' => $this->importedIcon($pageArr['icon'] ?? null), + 'description' => $this->importedText($pageArr['description'] ?? null, Page::DESCRIPTION_MAX_LENGTH, 'page description'), + 'widgets' => $widgets, + ]; + } + return ['pages' => $pages, 'skipped' => $skipped]; + } + + /** + * Saves the pages of a scheme as {@see self::resolveSchemePages()} resolved them. A page + * takes the title, icon and description of the scheme, and the widgets of a grid page + * replace its widgets. A new page without a free technical name gets the default name. + * + * @param list, source: mixed}, source: ?array{0: int, 1: ?int}}>, ...}> $pages + * @return array ids of the pages by the technical name the scheme gives them + * @throws DoesNotExistException + * @throws Exception + * @throws MultipleObjectsReturnedException + * @throws NotFoundError + */ + private function importSchemePages(int $contextId, array $pages, string $userId): array { + $relIds = []; + foreach ($this->contextMapper->findById($contextId, $userId)->getNodes() ?? [] as $relId => $node) { + $relIds[sprintf('t%di%d', $node['node_type'], $node['node_id'])] = (int)$relId; + } + $pageIds = []; + foreach ($pages as $resolved) { + $page = $resolved['pageId'] !== null ? $this->pageMapper->findById($resolved['pageId']) : new Page(); + $page->setTitle($resolved['title']); + $page->setIcon($resolved['icon']); + $page->setDescription($resolved['description']); + if ($resolved['pageId'] !== null) { + if ($page->getUpdatedFields() !== []) { + $this->pageMapper->update($page); + } + } else { + $page->setContextId($contextId); + $page->setPageType(Page::TYPE_GRID); + if ($resolved['technicalName'] === null) { + $page = $this->pageMapper->insertWithDefaultName($page); + } else { + $page->setTechnicalName($resolved['technicalName']); + $page = $this->pageMapper->insert($page); + } + } + if ($resolved['name'] !== null) { + $pageIds[$resolved['name']] = $page->getId(); + } + if ($resolved['isGrid']) { + $this->widgetService->syncPage($page->getId(), array_map(static fn (array $kept): array => [ + 'id' => null, + 'type' => $kept['widget']['type'], + 'title' => $kept['widget']['title'], + 'icon' => $kept['widget']['icon'], + 'description' => $kept['widget']['description'], + 'position' => $kept['widget']['position'], + 'settings' => $kept['widget']['settings'], + 'nodeRelId' => $kept['source'] !== null ? ($relIds[sprintf('t%di%d', $kept['source'][0], $kept['source'][1])] ?? null) : null, + ], $resolved['widgets'])); + } + } + return $pageIds; + } + /** * @param int $contextId * @param string $name @@ -1070,6 +1388,7 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string * @param array $nodes * @param array $tables * @param string $userId + * @param list|null $pages the pages of the scheme, see {@see self::resolveSchemePages()}; null leaves the pages alone * * @return Context * @@ -1083,7 +1402,7 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string * @throws NotFoundExceptionInterface * @throws PermissionError */ - public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, string $userId, ?array $menu = null): Context { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, string $userId, ?array $menu = null, ?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'])) { @@ -1125,6 +1444,7 @@ public function importScheme(int $contextId, string $name, string $iconName, str // Resolve node ids for the new tables and views $resolvedNodes = []; + $nodeIds = []; foreach ($nodes as $node) { if ($node['node_type'] === Application::NODE_TYPE_TABLE) { $table = $this->tableMapper->findByUuid($node['node_uuid']); @@ -1133,6 +1453,7 @@ public function importScheme(int $contextId, string $name, string $iconName, str 'type' => $node['node_type'], 'permissions' => $node['permissions'], ]; + $nodeIds[$node['node_type'] . ':' . $node['node_uuid']] = $table->getId(); } elseif ($node['node_type'] === Application::NODE_TYPE_VIEW) { $view = $this->viewMapper->findByUuid($node['node_uuid']); $resolvedNodes[] = [ @@ -1140,19 +1461,29 @@ public function importScheme(int $contextId, string $name, string $iconName, str 'type' => $node['node_type'], 'permissions' => $node['permissions'], ]; + $nodeIds[$node['node_type'] . ':' . $node['node_uuid']] = $view->getId(); } } + $schemePages = null; + if ($pages !== null) { + ['pages' => $schemePages, 'skipped' => $skipped] = $this->resolveSchemePages($this->contextMapper->findById($contextId, $userId), array_values($pages), $nodeIds); + foreach ($skipped as $skip) { + $this->logger->warning('Skipping ' . ($skip['widgetIndex'] === null ? '' : 'widget ' . $skip['widgetIndex'] . ' of ') . 'page ' . $skip['pageIndex'] . ' of an imported scheme: ' . $skip['reason']); + } + } $context = $this->update($contextId, $userId, $name, $iconName, $description, $resolvedNodes); + $schemePageIds = $schemePages !== null ? $this->importSchemePages($contextId, $schemePages, $userId) : []; if ($menu !== null) { $withNodes = $this->contextMapper->findById($contextId, $userId); $this->logSkippedMenuItems($this->menuService->import( $withNodes, $menu, fn (string $kind, string $uuid): ?int => $this->nodeRelIdByUuid($withNodes, $kind, $uuid), + $schemePageIds, )); } - return $context; + return $schemePages !== null ? $this->contextMapper->findById($contextId, $userId) : $context; } } diff --git a/lib/Service/MenuService.php b/lib/Service/MenuService.php index d3561dd9a2..579933b389 100644 --- a/lib/Service/MenuService.php +++ b/lib/Service/MenuService.php @@ -284,11 +284,12 @@ public function export(Context $context): array { * * @param list $exported * @param \Closure(string, string): ?int $nodeRelIdOf + * @param array $schemePageIds see {@see self::readExported()} * @return list * @throws Exception */ - public function import(Context $context, array $exported, \Closure $nodeRelIdOf): array { - ['items' => $items, 'skipped' => $skipped] = $this->readExported($context, $exported, $nodeRelIdOf); + public function import(Context $context, array $exported, \Closure $nodeRelIdOf, array $schemePageIds = []): array { + ['items' => $items, 'skipped' => $skipped] = $this->readExported($context, $exported, $nodeRelIdOf, $schemePageIds); $this->atomic(function () use ($context, $items): void { $this->menuItemMapper->deleteByContextId($context->getId()); @@ -314,12 +315,13 @@ public function import(Context $context, array $exported, \Closure $nodeRelIdOf) * * @param list $exported * @param \Closure(string, string): ?int $nodeRelIdOf + * @param array $schemePageIds ids of the pages of a scheme by the technical name it gives them, ahead of the pages of the application * @return array{items: list, index: int, parent: ?int}>, skipped: list} */ - public function readExported(Context $context, array $exported, \Closure $nodeRelIdOf): array { - $pageIds = []; + public function readExported(Context $context, array $exported, \Closure $nodeRelIdOf, array $schemePageIds = []): array { + $pageIds = $schemePageIds; foreach ($context->getPages() ?? [] as $pageId => $page) { - $pageIds[(string)$page['technical_name']] = (int)$pageId; + $pageIds[(string)$page['technical_name']] ??= (int)$pageId; } $items = []; diff --git a/openapi.json b/openapi.json index 3c043c7fea..751fd7d7ac 100644 --- a/openapi.json +++ b/openapi.json @@ -14072,6 +14072,18 @@ "type": "object" } } + }, + "pages": { + "type": "array", + "nullable": true, + "default": null, + "description": "Pages of the scheme: the start page updates the start page, a grid page the page with its technical name or it is added, and its widgets replace the widgets of that page; a widget names its table or view by `targetType` and `targetUuid` in its `source`. Pages and widgets that cannot be used here are skipped. Left out, the pages stay as they are", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } } } } diff --git a/src/modules/modals/ImportContextSchemePreview.vue b/src/modules/modals/ImportContextSchemePreview.vue index 1706971114..fedb67e8e7 100644 --- a/src/modules/modals/ImportContextSchemePreview.vue +++ b/src/modules/modals/ImportContextSchemePreview.vue @@ -214,6 +214,41 @@ + + +
+
+ + {{ t('tables', 'Pages') }} + +
+
    +
  • + {{ t('tables', 'From') }}: +
  • +
  • + {{ page.title || page.technicalName }} +
  • +
+
    +
  • + {{ t('tables', 'To') }}: +
  • +
  • + {{ page.title || page.technical_name || page.technicalName }} +
  • +
+
+
    +
  • + {{ t('tables', 'Skipped, not available here') }}: +
  • +
  • + {{ skippedPageLabel(item) }} +
  • +
+
+
@@ -272,6 +307,7 @@ export default { importDescription: true, importNodes: true, importMenu: true, + importPages: true, addTablesConfig: [], removeColumnsConfig: [], modifyTablesConfig: [], @@ -314,6 +350,11 @@ export default { this.emitPayload() }, }, + importPages: { + handler() { + this.emitPayload() + }, + }, addTablesConfig: { handler() { this.emitPayload() @@ -351,6 +392,18 @@ export default { })) }, + /** + * @param {object} item a skipped page, or a skipped widget of a page + * @return {string} the page, and the widget when it is one + */ + skippedPageLabel(item) { + const page = item.page || t('tables', 'Untitled page') + if (item.widgetIndex === null) { + return page + } + return t('tables', '{page}: {widget}', { page, widget: item.widget || t('tables', 'Untitled widget') }, undefined, { escape: false }) + }, + getShortDescription(description) { if (!description) { return '' @@ -378,6 +431,10 @@ export default { if (this.importMenu && this.previewData.menu) { payload.menu = this.previewData.menu.to } + // without pages the import leaves the pages of the application as they are + if (this.importPages && this.previewData.pages) { + payload.pages = this.previewData.pages.to + } this.$emit('update', payload) }, diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index dc24155625..06d10425a7 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -7931,6 +7931,13 @@ export interface operations { readonly menu?: readonly { readonly [key: string]: Record; }[] | null; + /** + * @description Pages of the scheme: the start page updates the start page, a grid page the page with its technical name or it is added, and its widgets replace the widgets of that page; a widget names its table or view by `targetType` and `targetUuid` in its `source`. Pages and widgets that cannot be used here are skipped. Left out, the pages stay as they are + * @default null + */ + readonly pages?: readonly { + readonly [key: string]: Record; + }[] | null; }; }; }; diff --git a/tests/unit/Service/ContextPagesRoundTripTest.php b/tests/unit/Service/ContextPagesRoundTripTest.php index 6548611618..3e6bafc6dc 100644 --- a/tests/unit/Service/ContextPagesRoundTripTest.php +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -20,23 +20,29 @@ use OCA\Tables\Db\PageMapper; use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; +use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; use OCA\Tables\Db\WidgetMapper; use OCA\Tables\Db\WidgetSourceMapper; use OCA\Tables\Helper\GroupHelper; use OCA\Tables\Helper\UserHelper; +use OCA\Tables\Model\TableScheme; use OCA\Tables\Service\ContextService; use OCA\Tables\Service\GridWidgetTypes; use OCA\Tables\Service\MenuService; use OCA\Tables\Service\PermissionsService; use OCA\Tables\Service\ShareService; use OCA\Tables\Service\Support\AuditLogServiceInterface; +use OCA\Tables\Service\TableService; +use OCA\Tables\Service\ViewService; use OCA\Tables\Service\WidgetService; use OCA\Tables\Tests\Unit\Database\DatabaseTestCase; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\IL10N; use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; /** @@ -51,6 +57,10 @@ class ContextPagesRoundTripTest extends DatabaseTestCase { private ContextNodeRelationMapper $nodeRelMapper; private PageMapper $pageMapper; private WidgetService $widgetService; + private MenuService $menuService; + private PermissionsService&MockObject $permissionsService; + private TableMapper&MockObject $tableMapper; + private ViewMapper&MockObject $viewMapper; /** @var int[] */ private array $contextIds = []; @@ -68,13 +78,18 @@ protected function setUp(): void { new WidgetSourceMapper($this->connectionAdapter), $this->connectionAdapter, ); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->tableMapper = $this->createMock(TableMapper::class); + $this->viewMapper = $this->createMock(ViewMapper::class); + $menuItemMapper = new MenuItemMapper($this->connectionAdapter); + $this->menuService = new MenuService($this->contextMapper, $menuItemMapper, $this->permissionsService, $this->tableMapper, $this->viewMapper, $this->connectionAdapter); $this->service = new ContextService( $this->contextMapper, $this->nodeRelMapper, $this->pageMapper, new PageContentMapper($this->connectionAdapter), $this->createMock(LoggerInterface::class), - $this->createMock(PermissionsService::class), + $this->permissionsService, $this->createMock(IUserManager::class), $this->createMock(AuditLogServiceInterface::class), $this->connectionAdapter, @@ -82,12 +97,12 @@ protected function setUp(): void { false, $this->createMock(INavigationManager::class), $this->createMock(IURLGenerator::class), - $this->createMock(TableMapper::class), - $this->createMock(ViewMapper::class), + $this->tableMapper, + $this->viewMapper, $this->widgetService, new GridWidgetTypes($l10n), - $this->createMock(MenuItemMapper::class), - $this->createMock(MenuService::class), + $menuItemMapper, + $this->menuService, ); } @@ -200,4 +215,146 @@ private function createContextWithGridPage(): array { return [$context, $relation, $page->getId()]; } + + public function testASchemeCarriesPagesWithWidgetsOnTheirTablesAndViewsByUuid(): void { + $this->permissionsService->method('canManageNodeById')->willReturnCallback(static fn (int $type, int $id): bool => $id !== 5199); + $this->permissionsService->method('canManageContext')->willReturn(true); + $this->tableMapper->method('findMany')->willReturnCallback(static fn (array $ids): array => array_combine($ids, array_map(static fn (): Table => new Table(), $ids))); + $node = static fn (int $id, int $type): array => ['id' => $id, 'type' => $type, 'permissions' => Application::PERMISSION_MANAGE]; + $source = $this->service->create('Source', 'tables', '', [$node(4181, Application::NODE_TYPE_TABLE), $node(4182, Application::NODE_TYPE_VIEW)], self::OWNER, Application::OWNER_TYPE_USER); + $this->contextIds[] = $source->getId(); + $relationIds = array_column($this->contextMapper->findById($source->getId(), self::OWNER)->getNodes(), 'id', 'node_id'); + $overview = $this->insertGridPage($source->getId(), 'overview', 'Overview'); + $this->widgetService->insert($overview->getId(), 'header', null, ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2], ['heading' => 'Source'], null); + $this->widgetService->insert($overview->getId(), 'data', 'Tasks', ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], [], $relationIds[4181]); + $this->widgetService->insert($overview->getId(), 'data', 'Open tasks', ['gridX' => 0, 'gridY' => 7, 'gridWidth' => 12, 'gridHeight' => 5], [], $relationIds[4182]); + $notes = $this->insertGridPage($source->getId(), null, 'Notes'); + $this->menuService->create($source->getId(), 'Notes', null, null, ['pageId' => $notes->getId()], self::OWNER); + + $tableService = $this->createMock(TableService::class); + $tableService->method('find')->willReturnCallback(static fn (int $id): Table => self::withUuid(new Table(), $id, 'table-' . $id)); + $tableService->method('getScheme')->willReturn($this->createMock(TableScheme::class)); + $tableService->method('findAll')->willReturn([]); + $viewService = $this->createMock(ViewService::class); + $viewService->method('find')->willReturnCallback(static function (int $id): View { + $view = self::withUuid(new View(), $id, 'view-' . $id); + $view->setTableId(4181); + return $view; + }); + $scheme = $this->withServices([TableService::class => $tableService, ViewService::class => $viewService], fn (): array => json_decode(json_encode($this->service->getScheme($source->getId(), self::OWNER)), true)); + $this->assertSame(['targetType' => 'view', 'targetUuid' => 'view-4182'], array_diff_key(array_values(array_filter($scheme['pages'], static fn (array $page): bool => $page['technical_name'] === 'overview'))[0]['widgets'][2]['source'], ['nodeRelId' => true])); + // the tables of the scheme are not under test here + $scheme['tables'] = []; + $overviewWidgets = &$scheme['pages'][array_key_first(array_filter($scheme['pages'], static fn (array $page): bool => $page['technical_name'] === 'overview'))]['widgets']; + $overviewWidgets[] = ['type' => 'data', 'title' => 'Elsewhere', 'gridX' => 0, 'gridY' => 12, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => ['targetType' => 'table', 'targetUuid' => 'table-4199']]; + // here and manageable, but not a node of the scheme: a widget does not add it + $overviewWidgets[] = ['type' => 'data', 'title' => 'Not a resource', 'gridX' => 0, 'gridY' => 17, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => ['targetType' => 'table', 'targetUuid' => 'table-4198']]; + unset($overviewWidgets); + $target = $this->service->create('Target', 'tables', '', [], self::OWNER, Application::OWNER_TYPE_USER); + $this->contextIds[] = $targetId = $target->getId(); + + $this->tableMapper->method('findByUuid')->willReturnCallback(static fn (string $uuid): Table => match ($uuid) { + 'table-4181' => self::withUuid(new Table(), 5181, $uuid), + 'table-4198' => self::withUuid(new Table(), 5198, $uuid), + 'table-4199' => self::withUuid(new Table(), 5199, $uuid), + default => throw new DoesNotExistException('No table'), + }); + $this->viewMapper->method('findByUuid')->willReturnCallback(static fn (string $uuid): View => $uuid === 'view-4182' ? self::withUuid(new View(), 5182, $uuid) : throw new DoesNotExistException('No view')); + $preview = $this->withServices([TableService::class => $tableService], fn (): array => $this->service->compareSchemeChanges($targetId, $scheme, self::OWNER)); + + $this->assertSame([ + ['Elsewhere', 'its table is not one of the resources of the application'], + ['Not a resource', 'its table is not one of the resources of the application'], + ], array_map(static fn (array $skip): array => [$skip['widget'], $skip['reason']], $preview['pages']['skipped'])); + $this->assertSame([], $preview['menu']['skipped']); + $this->service->addNodeToContext($this->contextMapper->findById($targetId, self::OWNER), 5181, Application::NODE_TYPE_TABLE, Application::PERMISSION_MANAGE); + $this->service->addNodeToContext($this->contextMapper->findById($targetId, self::OWNER), 5182, Application::NODE_TYPE_VIEW, Application::PERMISSION_MANAGE); + $this->service->importScheme($targetId, 'Target', 'tables', '', $scheme['nodes'], ['addTables' => [], 'modifyTables' => []], self::OWNER, $preview['menu']['to'], $scheme['pages']); + + $target = $this->contextMapper->findById($targetId, self::OWNER); + $targetRelationIds = array_column($target->getNodes(), 'id', 'node_id'); + $this->assertEqualsCanonicalizing([5181, 5182], array_keys($targetRelationIds)); + $pages = $this->service->getPagesWithWidgets($target); + $byTitle = array_column(array_filter($pages, static fn (array $page): bool => $page['title'] !== null), null, 'title'); + $this->assertSame('overview', $byTitle['Overview']['technical_name']); + $this->assertSame( + [['header', null], ['data', $targetRelationIds[5181]], ['data', $targetRelationIds[5182]]], + array_map(static fn (array $widget): array => [$widget['type'], $widget['source']['nodeRelId'] ?? null], $byTitle['Overview']['widgets']), + ); + $notesId = $byTitle['Notes']['id']; + $this->assertSame('page_' . $notesId, $byTitle['Notes']['technical_name']); + $this->assertSame([['pageId' => $notesId]], array_column(array_values(array_filter($this->menuService->findAll($targetId, self::OWNER), static fn (array $item): bool => $item['title'] === 'Notes')), 'target')); + } + + public function testAPreviewCountsTheTablesAndViewsTheImportAddsAsResources(): void { + $this->tableMapper->method('findByUuid')->willThrowException(new DoesNotExistException('No table')); + $this->viewMapper->method('findByUuid')->willThrowException(new DoesNotExistException('No view')); + $tableService = $this->createMock(TableService::class); + $tableService->method('findAll')->willReturn([]); + $target = $this->service->create('Target', 'tables', '', [], self::OWNER, Application::OWNER_TYPE_USER); + $this->contextIds[] = $target->getId(); + $widget = static fn (string $title, string $type, string $uuid): array => ['type' => 'data', 'title' => $title, 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => ['targetType' => $type, 'targetUuid' => $uuid]]; + + $preview = $this->withServices([TableService::class => $tableService], fn (): array => $this->service->compareSchemeChanges($target->getId(), [ + 'nodes' => [ + ['node_type' => Application::NODE_TYPE_TABLE, 'node_uuid' => 'new-table', 'permissions' => 1], + ['node_type' => Application::NODE_TYPE_VIEW, 'node_uuid' => 'new-view', 'permissions' => 1], + ['node_type' => Application::NODE_TYPE_VIEW, 'node_uuid' => 'gone-view', 'permissions' => 1], + ], + 'tables' => [['uuid' => 'new-table', 'views' => [['uuid' => 'new-view']]]], + 'pages' => [['page_type' => Page::TYPE_GRID, 'technical_name' => 'overview', 'widgets' => [ + $widget('Added table', 'table', 'new-table'), + $widget('Added view', 'view', 'new-view'), + $widget('Gone', 'view', 'gone-view'), + ]]], + ], self::OWNER)); + + $this->assertSame(['Added table', 'Added view'], array_column($preview['pages']['to'][0]['widgets'], 'title')); + $this->assertSame(['Gone'], array_column($preview['pages']['skipped'], 'widget')); + } + + private function insertGridPage(int $contextId, ?string $technicalName, string $title): Page { + $page = new Page(); + $page->setContextId($contextId); + $page->setPageType(Page::TYPE_GRID); + $page->setTitle($title); + if ($technicalName === null) { + return $this->pageMapper->insertWithDefaultName($page); + } + $page->setTechnicalName($technicalName); + return $this->pageMapper->insert($page); + } + + /** + * @template T of Table|View + * @param T $node + * @return T + */ + private static function withUuid(Table|View $node, int $id, string $uuid): Table|View { + $node->id = $id; + $node->setUuid($uuid); + return $node; + } + + /** + * Runs a callback with services of the server container replaced, as the scheme + * export and preview get them from there. + * + * @param array $services + */ + private function withServices(array $services, callable $callback): mixed { + $container = \OC::$server->getAppContainerForService(TableService::class) ?? \OC::$server; + $real = []; + foreach ($services as $class => $service) { + $real[$class] = \OCP\Server::get($class); + $container->registerService($class, static fn () => $service); + } + try { + return $callback(); + } finally { + foreach ($real as $class => $service) { + $container->registerService($class, static fn () => $service); + } + } + } } diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index ba5ae6d882..d7cedda290 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -43,6 +43,7 @@ final class ContextServiceTest extends TestCase { private MockObject $auditLogService; private MockObject $widgetService; private MockObject $dbConnection; + private MockObject $logger; protected function setUp(): void { parent::setUp(); @@ -51,7 +52,7 @@ protected function setUp(): void { $this->contextNodeRelMapper = $this->createMock(ContextNodeRelationMapper::class); $this->pageMapper = $this->createMock(PageMapper::class); $this->pageContentMapper = $this->createMock(PageContentMapper::class); - $logger = $this->createMock(LoggerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); $permissionsService = $this->createMock(PermissionsService::class); $this->userManager = $this->createMock(IUserManager::class); $this->auditLogService = $this->createMock(AuditLogServiceInterface::class); @@ -68,7 +69,7 @@ protected function setUp(): void { $this->contextNodeRelMapper, $this->pageMapper, $this->pageContentMapper, - $logger, + $this->logger, $permissionsService, $this->userManager, $this->auditLogService, @@ -600,4 +601,79 @@ public function testAnImportedPageKeepsAtMostTheMaximumNumberOfWidgets(): void { self::assertCount(GridWidgetTypes::MAX_WIDGETS_PER_PAGE, $record['widgets']); } + + public function testImportingSchemePagesKeepsWhatCanBeUsedAndSkipsTheRestWithAReason(): void { + $context = new Context(); + $context->id = 7; + $context->setName('Intake portal'); + $context->setOwnerId('user-1'); + $context->setOwnerType(Application::OWNER_TYPE_USER); + $context->setNodes([]); + $context->setPages([ + 301 => ['id' => 301, 'page_type' => Page::TYPE_STARTPAGE, 'technical_name' => 'dashboard', 'content' => []], + 302 => ['id' => 302, 'page_type' => Page::TYPE_GRID, 'technical_name' => 'overview', 'content' => []], + ]); + $this->contextMapper->method('findById')->willReturn($context); + $this->contextMapper->method('update')->willReturnArgument(0); + $this->pageMapper->method('findById')->willReturnCallback(static function (int $id): Page { + $page = new Page(); + $page->id = $id; + $page->resetUpdatedFields(); + return $page; + }); + $updated = []; + $this->pageMapper->method('update')->willReturnCallback(static function (Page $page) use (&$updated): Page { + $updated[] = [$page->getId(), $page->getTitle(), $page->getIcon()]; + return $page; + }); + $inserted = []; + $this->pageMapper->method('insert')->willReturnCallback(static function (Page $page) use (&$inserted): Page { + $page->id = 400 + count($inserted); + $inserted[] = [$page->getTechnicalName(), $page->getTitle(), $page->getPageType()]; + return $page; + }); + $this->pageMapper->method('insertWithDefaultName')->willReturnCallback(static function (Page $page) use (&$inserted): Page { + $page->id = 400 + count($inserted); + $inserted[] = ['page_' . $page->getId(), $page->getTitle(), $page->getPageType()]; + return $page; + }); + $synced = []; + $this->widgetService->method('syncPage')->willReturnCallback(static function (int $pageId, array $widgets) use (&$synced): void { + $synced[$pageId] = array_map(static fn (array $widget): array => [$widget['type'], $widget['title'], $widget['nodeRelId']], $widgets); + }); + $warnings = []; + $this->logger->method('warning')->willReturnCallback(static function (string $message) use (&$warnings): void { + $warnings[] = $message; + }); + $text = static fn (?string $title): array => ['type' => 'text', 'title' => $title, 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => ['text' => 'Hi']]; + + $this->service->importScheme(7, 'Intake', 'star', '', [], ['addTables' => [], 'modifyTables' => []], 'user-1', null, [ + ['page_type' => Page::TYPE_STARTPAGE, 'technical_name' => 'dashboard', 'title' => 'Home', 'icon' => 'star', 'widgets' => [$text('On the start page')]], + ['page_type' => Page::TYPE_STARTPAGE, 'title' => 'Second start'], + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'overview', 'title' => 'Overview', 'widgets' => [ + $text('Kept'), + ['type' => 'data', 'title' => 'Without source', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => null], + ['type' => 'data', 'title' => 'Old source', 'gridX' => 0, 'gridY' => 7, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => ['nodeRelId' => 5]], + ['type' => 'chart', 'title' => 'Unknown type', 'gridX' => 0, 'gridY' => 12, 'gridWidth' => 12, 'gridHeight' => 5], + ]], + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'overview', 'title' => 'Same name'], + 'not a page', + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'reports', 'title' => 'Reports', 'widgets' => array_fill(0, GridWidgetTypes::MAX_WIDGETS_PER_PAGE + 1, $text(null))], + ]); + + $this->assertSame([[301, 'Home', 'star'], [302, 'Overview', null]], $updated); + $this->assertSame([['page_400', 'Same name', Page::TYPE_GRID], ['reports', 'Reports', Page::TYPE_GRID]], $inserted); + $this->assertSame([['text', 'Kept', null], ['data', 'Without source', null]], $synced[302]); + $this->assertSame([], $synced[400]); + $this->assertCount(GridWidgetTypes::MAX_WIDGETS_PER_PAGE, $synced[401]); + $this->assertArrayNotHasKey(301, $synced); + $this->assertSame([ + 'Skipping widget 0 of page 0 of an imported scheme: only grid pages hold widgets', + 'Skipping page 1 of an imported scheme: an application has one start page', + 'Skipping widget 2 of page 2 of an imported scheme: its source names no table or view by uuid', + 'Skipping widget 3 of page 2 of an imported scheme: Unknown widget type "chart", expected one of: header, text, data', + 'Skipping page 4 of an imported scheme: its type is not startpage or grid', + 'Skipping widget 100 of page 5 of an imported scheme: a page holds at most 100 widgets', + ], $warnings); + } }