diff --git a/appinfo/info.xml b/appinfo/info.xml index 4edcef88db..506468495d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 2.5.0-dev.0 + 2.5.0-dev.1 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/lib/Controller/ContextPageController.php b/lib/Controller/ContextPageController.php index ce30cad2bf..ee299a723b 100644 --- a/lib/Controller/ContextPageController.php +++ b/lib/Controller/ContextPageController.php @@ -105,8 +105,9 @@ public function create(int $contextId, string $title, ?string $technicalName = n * [api v2] Change a page or its widgets * * Fields left out keep their value; an empty icon or description removes it. A given - * `widgets` list replaces the widgets of the grid page: listed widgets with an `id` - * are updated, others are added, and widgets not listed are deleted. + * `widgets` list replaces the widgets of the page, a grid page or the start page: + * listed widgets with an `id` are updated, others are added, and widgets not listed + * are deleted. * * A widget has a `type`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of * 12 columns, `settings` following the schema of its type, and optionally an `id`, diff --git a/lib/Migration/Version2500Date20261030000000.php b/lib/Migration/Version2500Date20261030000000.php new file mode 100644 index 0000000000..41cc6fdf67 --- /dev/null +++ b/lib/Migration/Version2500Date20261030000000.php @@ -0,0 +1,119 @@ +emptyStartPages() as $startPage) { + $widgets = GridWidgetTypes::startPageWidgets( + (string)$startPage['name'], + (string)($startPage['description'] ?? ''), + $this->nodeRelIdsInPageOrder((int)$startPage['page_id']), + ); + $this->db->beginTransaction(); + try { + foreach ($widgets as $widget) { + $this->insertWidget((int)$startPage['page_id'], $widget); + } + $this->db->commit(); + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + } + + /** + * @return list> start pages without widgets, with the name and description of their application + */ + private function emptyStartPages(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('p.id as page_id', 'c.name', 'c.description') + ->from(self::PREFIX . 'page', 'p') + ->innerJoin('p', self::PREFIX . 'context', 'c', $qb->expr()->eq('c.id', 'p.context_id')) + ->leftJoin('p', self::PREFIX . 'widget', 'w', $qb->expr()->eq('w.page_id', 'p.id')) + ->where($qb->expr()->eq('p.page_type', $qb->createNamedParameter(Page::TYPE_STARTPAGE))) + ->andWhere($qb->expr()->isNull('w.id')) + ->orderBy('p.id'); + $result = $qb->executeQuery(); + $rows = $result->fetchAllAssociative(); + $result->closeCursor(); + return $rows; + } + + /** + * @return list the tables and views a start page lists, as node relations of its application, in page order + */ + private function nodeRelIdsInPageOrder(int $pageId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('pc.node_rel_id') + ->from(self::PREFIX . 'page_content', 'pc') + ->innerJoin('pc', self::PREFIX . 'rel_context_node', 'r', $qb->expr()->eq('r.id', 'pc.node_rel_id')) + ->where($qb->expr()->eq('pc.page_id', $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT))) + ->orderBy('pc.order') + ->addOrderBy('pc.id'); + $result = $qb->executeQuery(); + $nodeRelIds = array_map(intval(...), $result->fetchFirstColumn()); + $result->closeCursor(); + return array_values(array_unique($nodeRelIds)); + } + + /** + * @param array{type: string, position: array{gridX: int, gridY: int, gridWidth: int, gridHeight: int}, settings: array, nodeRelId: ?int} $widget + */ + private function insertWidget(int $pageId, array $widget): void { + $qb = $this->db->getQueryBuilder(); + $qb->insert(self::PREFIX . 'widget') + ->values([ + 'page_id' => $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT), + 'type' => $qb->createNamedParameter($widget['type']), + 'grid_x' => $qb->createNamedParameter($widget['position']['gridX'], IQueryBuilder::PARAM_INT), + 'grid_y' => $qb->createNamedParameter($widget['position']['gridY'], IQueryBuilder::PARAM_INT), + 'grid_width' => $qb->createNamedParameter($widget['position']['gridWidth'], IQueryBuilder::PARAM_INT), + 'grid_height' => $qb->createNamedParameter($widget['position']['gridHeight'], IQueryBuilder::PARAM_INT), + 'settings' => $qb->createNamedParameter($widget['settings'], IQueryBuilder::PARAM_JSON), + ]) + ->executeStatement(); + if ($widget['nodeRelId'] === null) { + return; + } + $widgetId = $qb->getLastInsertId(); + $source = $this->db->getQueryBuilder(); + $source->insert(self::PREFIX . 'widget_source') + ->values([ + 'widget_id' => $source->createNamedParameter($widgetId, IQueryBuilder::PARAM_INT), + 'node_rel_id' => $source->createNamedParameter($widget['nodeRelId'], IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + } +} diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index ed01048c24..970036e911 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -562,6 +562,7 @@ protected function insertPage(Context $context): void { $addedPage = $page->jsonSerialize(); $i = 1; + $nodeRelIds = []; $contextNodes = $context->getNodes(); if ($contextNodes) { foreach ($contextNodes as $node) { @@ -575,9 +576,14 @@ protected function insertPage(Context $context): void { $addedPage['content'][$pageContent->getId()] = $pageContent->jsonSerialize(); // the content is already embedded in the page unset($addedPage['content'][$pageContent->getId()]['pageId']); + $nodeRelIds[] = (int)$node['id']; } } + foreach (GridWidgetTypes::startPageWidgets($context->getName(), $context->getDescription(), $nodeRelIds) as $widget) { + $this->widgetService->insert($page->getId(), $widget['type'], null, $widget['position'], $widget['settings'], $widget['nodeRelId']); + } + $context->setPages([$addedPage['id'] => $addedPage]); } @@ -718,6 +724,7 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C $pageMap[$pageArr['id'] ?? null] = $page; // insert page conten + $contentRelIds = []; if (!empty($pageArr['content'])) { foreach ($pageArr['content'] as $contentArr) { $oldNodeRelId = self::importedKey($contentArr['node_rel_id'] ?? $contentArr['nodeRelId'] ?? null); @@ -730,6 +737,14 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C $pageContent->setNodeRelId($newNodeRel->getId()); $pageContent->setOrder($contentArr['order'] ?? 100); $this->pageContentMapper->insert($pageContent); + $contentRelIds[] = $newNodeRel->getId(); + } + } + + // A start page exported before pages held widgets starts filled, like a new one + if ($page->getPageType() === Page::TYPE_STARTPAGE && !array_key_exists('widgets', $pageArr)) { + foreach (GridWidgetTypes::startPageWidgets($newContext->getName(), $newContext->getDescription(), array_values(array_unique($contentRelIds))) as $widget) { + $this->widgetService->insert($page->getId(), $widget['type'], null, $widget['position'], $widget['settings'], $widget['nodeRelId']); } } diff --git a/lib/Service/GridWidgetTypes.php b/lib/Service/GridWidgetTypes.php index 152b172272..e5aa8509e2 100644 --- a/lib/Service/GridWidgetTypes.php +++ b/lib/Service/GridWidgetTypes.php @@ -28,6 +28,8 @@ class GridWidgetTypes { public const TYPE_DATA = 'data'; public const GRID_COLUMNS = 12; + public const HEADER_HEIGHT = 2; + public const DATA_HEIGHT = 5; public const GRID_MAX_HEIGHT = 100; public const GRID_MAX_Y = 10000; public const TITLE_MAX_LENGTH = 200; @@ -51,8 +53,8 @@ public function all(): array { self::TYPE_HEADER => [ 'type' => self::TYPE_HEADER, 'title' => $this->l10n->t('Header'), - 'defaultWidth' => 12, - 'defaultHeight' => 2, + 'defaultWidth' => self::GRID_COLUMNS, + 'defaultHeight' => self::HEADER_HEIGHT, 'hasSource' => false, 'settings' => [ 'heading' => ['type' => 'string', 'title' => $this->l10n->t('Heading'), 'default' => '', 'required' => true], @@ -79,14 +81,46 @@ public function all(): array { self::TYPE_DATA => [ 'type' => self::TYPE_DATA, 'title' => $this->l10n->t('Table or view'), - 'defaultWidth' => 12, - 'defaultHeight' => 5, + 'defaultWidth' => self::GRID_COLUMNS, + 'defaultHeight' => self::DATA_HEIGHT, 'hasSource' => true, 'settings' => [], ], ]; } + /** + * The widgets a start page starts with: a header with the name and description of its + * application, then one full width table or view widget per node, in the given order. + * Without a name there is no header. + * + * @param list $nodeRelIds node relations of the application, in page order + * @return list, nodeRelId: ?int}> + */ + public static function startPageWidgets(string $name, string $description, array $nodeRelIds): array { + $widgets = []; + $gridY = 0; + if (trim($name) !== '') { + $widgets[] = [ + 'type' => self::TYPE_HEADER, + 'position' => ['gridX' => 0, 'gridY' => $gridY, 'gridWidth' => self::GRID_COLUMNS, 'gridHeight' => self::HEADER_HEIGHT], + 'settings' => ['heading' => $name, 'subheading' => $description, 'textAlign' => 'start', 'backgroundColor' => '', 'textColor' => ''], + 'nodeRelId' => null, + ]; + $gridY += self::HEADER_HEIGHT; + } + foreach (array_slice($nodeRelIds, 0, self::MAX_WIDGETS_PER_PAGE - count($widgets)) as $nodeRelId) { + $widgets[] = [ + 'type' => self::TYPE_DATA, + 'position' => ['gridX' => 0, 'gridY' => $gridY, 'gridWidth' => self::GRID_COLUMNS, 'gridHeight' => self::DATA_HEIGHT], + 'settings' => [], + 'nodeRelId' => $nodeRelId, + ]; + $gridY += self::DATA_HEIGHT; + } + return $widgets; + } + /** * @return TablesWidgetType * @throws BadRequestError diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php index 5005b5efed..83de040ad3 100644 --- a/lib/Service/PageService.php +++ b/lib/Service/PageService.php @@ -120,7 +120,7 @@ public function create(int $contextId, string $title, ?string $technicalName, ?s */ public function update(int $contextId, int $pageId, ?string $title, ?string $technicalName, ?string $icon, ?string $description, ?array $widgets, string $userId): array { $context = $this->getManageableContext($contextId, $userId); - $pageArray = $this->getPageArray($context, $pageId); + $this->getPageArray($context, $pageId); $page = $this->pageMapper->findById($pageId); if ($title !== null) { @@ -138,9 +138,6 @@ public function update(int $contextId, int $pageId, ?string $title, ?string $tec $rows = null; if ($widgets !== null) { - if ($pageArray['page_type'] !== Page::TYPE_GRID) { - throw new BadRequestError('Only grid pages hold widgets.'); - } $rows = $this->readWidgets($widgets, $context, $pageId); } diff --git a/openapi.json b/openapi.json index b0e4a2c6cd..a61ef548be 100644 --- a/openapi.json +++ b/openapi.json @@ -14979,7 +14979,7 @@ "patch": { "operationId": "context_page-update", "summary": "[api v2] Change a page or its widgets", - "description": "Fields left out keep their value; an empty icon or description removes it. A given `widgets` list replaces the widgets of the grid page: listed widgets with an `id` are updated, others are added, and widgets not listed are deleted.\nA widget has a `type`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns, `settings` following the schema of its type, and optionally an `id`, `title`, `icon` and `description`. A data widget's `source` is `{nodeRelId}` of a table or view of this application, or null.", + "description": "Fields left out keep their value; an empty icon or description removes it. A given `widgets` list replaces the widgets of the page, a grid page or the start page: listed widgets with an `id` are updated, others are added, and widgets not listed are deleted.\nA widget has a `type`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns, `settings` following the schema of its type, and optionally an `id`, `title`, `icon` and `description`. A data widget's `source` is `{nodeRelId}` of a table or view of this application, or null.", "tags": [ "context_page" ], diff --git a/package-lock.json b/package-lock.json index 3a5f9d5939..143b8938dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tables", - "version": "2.5.0-dev.0", + "version": "2.5.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tables", - "version": "2.5.0-dev.0", + "version": "2.5.0-dev.1", "license": "AGPL-3.0-or-later", "dependencies": { "@material-symbols/svg-400": "^0.48.1", diff --git a/package.json b/package.json index d3d93cceba..c1c3181d00 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tables", "description": "Manage data within tables.", - "version": "2.5.0-dev.0", + "version": "2.5.0-dev.1", "author": "Nextcloud GmbH and Nextcloud contributors", "bugs": { "url": "https://github.com/nextcloud/tables/issues" diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 301ff9a878..c3b06eab16 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -954,7 +954,7 @@ export type paths = { readonly head?: never; /** * [api v2] Change a page or its widgets - * @description Fields left out keep their value; an empty icon or description removes it. A given `widgets` list replaces the widgets of the grid page: listed widgets with an `id` are updated, others are added, and widgets not listed are deleted. + * @description Fields left out keep their value; an empty icon or description removes it. A given `widgets` list replaces the widgets of the page, a grid page or the start page: listed widgets with an `id` are updated, others are added, and widgets not listed are deleted. * A widget has a `type`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns, `settings` following the schema of its type, and optionally an `id`, `title`, `icon` and `description`. A data widget's `source` is `{nodeRelId}` of a table or view of this application, or null. */ readonly patch: operations["context_page-update"]; diff --git a/tests/unit/Migration/Version2500Date20261030000000Test.php b/tests/unit/Migration/Version2500Date20261030000000Test.php new file mode 100644 index 0000000000..0b02f229c9 --- /dev/null +++ b/tests/unit/Migration/Version2500Date20261030000000Test.php @@ -0,0 +1,119 @@ +cleanupTablesData(); + $this->migration = new Version2500Date20261030000000($this->connectionAdapter); + } + + protected function tearDown(): void { + $this->cleanupTablesData(); + parent::tearDown(); + } + + public function testAnEmptyStartPageGetsAHeaderAndItsTablesInPageOrderAndASecondRunChangesNothing(): void { + $contextId = $this->insertContext('Intake portal', 'Requests from residents'); + $startPage = $this->insert('page', ['context_id' => $contextId, 'page_type' => 'startpage']); + $gridPage = $this->insert('page', ['context_id' => $contextId, 'page_type' => 'grid']); + $tasks = $this->insert('rel_context_node', ['context_id' => $contextId, 'node_type' => 0, 'node_id' => 11, 'permissions' => 1]); + $openTasks = $this->insert('rel_context_node', ['context_id' => $contextId, 'node_type' => 1, 'node_id' => 12, 'permissions' => 1]); + $this->insert('page_content', ['page_id' => $startPage, 'node_rel_id' => $tasks, 'order' => 20]); + $this->insert('page_content', ['page_id' => $startPage, 'node_rel_id' => $openTasks, 'order' => 10]); + $this->insert('page_content', ['page_id' => $startPage, 'node_rel_id' => 999999, 'order' => 30]); + + $this->migrate(); + + $expected = [ + ['header', 0, 0, 12, 2, ['backgroundColor' => '', 'heading' => 'Intake portal', 'subheading' => 'Requests from residents', 'textAlign' => 'start', 'textColor' => ''], null], + ['data', 0, 2, 12, 5, [], $openTasks], + ['data', 0, 7, 12, 5, [], $tasks], + ]; + $this->assertSame($expected, $this->widgets($startPage)); + $this->assertSame([], $this->widgets($gridPage)); + + $this->migrate(); + $this->assertSame($expected, $this->widgets($startPage)); + } + + public function testAStartPageWithWidgetsIsLeftAlone(): void { + $contextId = $this->insertContext('Arranged', ''); + $startPage = $this->insert('page', ['context_id' => $contextId, 'page_type' => 'startpage']); + $node = $this->insert('rel_context_node', ['context_id' => $contextId, 'node_type' => 0, 'node_id' => 11, 'permissions' => 1]); + $this->insert('page_content', ['page_id' => $startPage, 'node_rel_id' => $node, 'order' => 10]); + $this->insert('widget', ['page_id' => $startPage, 'type' => 'text', 'grid_width' => 6, 'grid_height' => 2, 'settings' => ['text' => 'Mine']]); + + $this->migrate(); + + $this->assertSame([['text', 0, 0, 6, 2, ['text' => 'Mine'], null]], $this->widgets($startPage)); + } + + private function migrate(): void { + $this->migration->postSchemaChange(new NullOutput(), fn () => new SchemaWrapper($this->connection), []); + } + + private function insertContext(string $name, string $description): int { + return $this->insert('context', ['name' => $name, 'icon' => 'apps', 'description' => $description, 'owner_id' => 'alice', 'owner_type' => 0]); + } + + /** + * @param array> $values + */ + private function insert(string $table, array $values): int { + $qb = $this->connectionAdapter->getQueryBuilder(); + $qb->insert('tables_contexts_' . $table) + ->values(array_map(static fn (int|string|array $value) => $qb->createNamedParameter($value, match (true) { + is_int($value) => IQueryBuilder::PARAM_INT, + is_array($value) => IQueryBuilder::PARAM_JSON, + default => IQueryBuilder::PARAM_STR, + }), $values)) + ->executeStatement(); + return $qb->getLastInsertId(); + } + + /** + * @return list, ?int}> type, place, settings and source of each widget + */ + private function widgets(int $pageId): array { + $qb = $this->connectionAdapter->getQueryBuilder(); + $qb->select('w.type', 'w.grid_x', 'w.grid_y', 'w.grid_width', 'w.grid_height', 'w.settings', 's.node_rel_id') + ->from('tables_contexts_widget', 'w') + ->leftJoin('w', 'tables_contexts_widget_source', 's', $qb->expr()->eq('s.widget_id', 'w.id')) + ->where($qb->expr()->eq('w.page_id', $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT))) + ->orderBy('w.grid_y') + ->addOrderBy('w.id'); + $result = $qb->executeQuery(); + $widgets = []; + while ($row = $result->fetchAssociative()) { + // MySQL stores JSON objects with their keys sorted + $settings = json_decode((string)$row['settings'], true); + ksort($settings); + $widgets[] = [ + $row['type'], (int)$row['grid_x'], (int)$row['grid_y'], (int)$row['grid_width'], (int)$row['grid_height'], + $settings, $row['node_rel_id'] === null ? null : (int)$row['node_rel_id'], + ]; + } + $result->closeCursor(); + return $widgets; + } +} diff --git a/tests/unit/Service/ContextPagesRoundTripTest.php b/tests/unit/Service/ContextPagesRoundTripTest.php index b2951a5578..99c6c80126 100644 --- a/tests/unit/Service/ContextPagesRoundTripTest.php +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -133,6 +133,39 @@ public function testExportedWidgetsAreImportedWithTheirNode(): void { $this->assertSame(json_encode($expected), json_encode($withoutIds($gridPages[0]['widgets']))); } + public function testAStartPageExportedWithoutWidgetsIsFilledOnImportAndOneWithAnEmptyListIsNot(): void { + [$context, $relation] = $this->createContextWithGridPage(); + $exported = json_decode(json_encode(array_merge( + $this->contextMapper->findById($context->getId(), self::OWNER)->jsonSerialize(), + ['pages' => $this->service->getPagesWithWidgets($this->contextMapper->findById($context->getId(), self::OWNER))], + )), true); + $exported['nodes'][$relation->getId()]['node_id'] = 4244; + $startPageKey = array_key_first(array_filter($exported['pages'], static fn (array $page): bool => $page['page_type'] === Page::TYPE_STARTPAGE)); + + $older = $exported; + unset($older['pages'][$startPageKey]['widgets']); + $withoutWidgets = $exported; + $withoutWidgets['pages'][$startPageKey]['widgets'] = []; + + $startPageWidgets = function (array $export, int $tableId): array { + $table = new Table(); + $table->id = $tableId; + $table->setOwnership(self::OWNER); + $imported = $this->service->importContext($table, $export, 4244); + $this->assertNotNull($imported); + $this->contextIds[] = $imported->getId(); + $importedContext = $this->contextMapper->findById($imported->getId(), self::OWNER); + $pages = array_values(array_filter($this->service->getPagesWithWidgets($importedContext), static fn (array $page): bool => $page['page_type'] === Page::TYPE_STARTPAGE)); + return array_map(static fn (array $widget): array => [$widget['type'], $widget['source']['nodeRelId'] ?? null], $pages[0]['widgets']); + }; + + $filled = $startPageWidgets($older, 4244); + $this->assertSame('header', $filled[0][0]); + $this->assertSame(['data'], array_unique(array_column(array_slice($filled, 1), 0))); + $this->assertNotEmpty(array_slice($filled, 1)); + $this->assertSame([], $startPageWidgets($withoutWidgets, 4245)); + } + public function testAnUnknownIconIsDroppedOnImport(): void { [$context, $relation, $gridPageId] = $this->createContextWithGridPage(); $table = new Table(); diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index 9dd2b6f031..b5ee859939 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -22,6 +22,7 @@ use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\ViewMapper; +use OCA\Tables\Db\Widget; use OCA\Tables\Service\Support\AuditLogServiceInterface; use OCP\IDBConnection; use OCP\IL10N; @@ -41,6 +42,7 @@ final class ContextServiceTest extends TestCase { private MockObject $userManager; private MockObject $auditLogService; private MockObject $widgetService; + private MockObject $permissionsService; private MockObject $dbConnection; protected function setUp(): void { @@ -51,7 +53,7 @@ protected function setUp(): void { $this->pageMapper = $this->createMock(PageMapper::class); $this->pageContentMapper = $this->createMock(PageContentMapper::class); $logger = $this->createMock(LoggerInterface::class); - $permissionsService = $this->createMock(PermissionsService::class); + $this->permissionsService = $this->createMock(PermissionsService::class); $this->userManager = $this->createMock(IUserManager::class); $this->auditLogService = $this->createMock(AuditLogServiceInterface::class); $this->dbConnection = $this->createMock(IDBConnection::class); @@ -68,7 +70,7 @@ protected function setUp(): void { $this->pageMapper, $this->pageContentMapper, $logger, - $permissionsService, + $this->permissionsService, $this->userManager, $this->auditLogService, $this->dbConnection, @@ -407,6 +409,50 @@ public function testANewApplicationGetsAStartPageNamedDashboard(): void { self::assertSame('dashboard', $context->getPages()[70]['technicalName']); } + public static function startPagesOfNewApplications(): array { + return [ + 'a header, then a table or view per node' => [ + [['id' => 5, 'type' => 0], ['id' => 9, 'type' => 1]], + [['header', 0, 'Intake portal', 'Requests', null], ['data', 2, null, null, 41], ['data', 7, null, null, 42]], + ], + 'only a header without tables' => [[], [['header', 0, 'Intake portal', 'Requests', null]]], + ]; + } + + /** + * @dataProvider startPagesOfNewApplications + * @param list $expected type, row, heading, subheading and source of each widget + */ + public function testANewApplicationStartsWithItsWidgets(array $nodes, array $expected): void { + $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context { + $context->id = 8; + return $context; + }); + $this->pageMapper->method('insert')->willReturnCallback(static function (Page $page): Page { + $page->id = 3; + return $page; + }); + $this->permissionsService->method('canManageNodeById')->willReturn(true); + $relId = 40; + $this->contextNodeRelMapper->method('insert')->willReturnCallback(static function (ContextNodeRelation $relation) use (&$relId): ContextNodeRelation { + $relation->id = ++$relId; + return $relation; + }); + $this->pageContentMapper->method('insert')->willReturnCallback(static function (PageContent $content) use (&$relId): PageContent { + $content->id = 100 + $relId; + return $content; + }); + $widgets = []; + $this->widgetService->method('insert')->willReturnCallback(static function (int $pageId, string $type, ?string $title, array $position, array $settings, ?int $nodeRelId) use (&$widgets): Widget { + $widgets[] = [$type, $position['gridY'], $settings['heading'] ?? null, $settings['subheading'] ?? null, $nodeRelId]; + return new Widget(); + }); + + $this->service->create('Intake portal', 'heart', 'Requests', $nodes, 'user-1', 0); + + self::assertSame($expected, $widgets); + } + public static function importedPages(): array { $start = Page::TYPE_STARTPAGE; $grid = Page::TYPE_GRID; diff --git a/tests/unit/Service/PageServiceTest.php b/tests/unit/Service/PageServiceTest.php index 00774bcaf0..987ded666f 100644 --- a/tests/unit/Service/PageServiceTest.php +++ b/tests/unit/Service/PageServiceTest.php @@ -338,11 +338,13 @@ public function testUpdateRejectsInvalidWidgets(array $widgets, string $message) $this->service->update(7, self::GRID_PAGE, null, null, null, null, $widgets, 'alice'); } - public function testTheStartPageHoldsNoWidgets(): void { + public function testTheStartPageHoldsWidgets(): void { $this->permissionsService->method('canManageContext')->willReturn(true); + $this->widgetService->expects($this->once())->method('syncPage')->with(self::START_PAGE, $this->callback(static fn (array $rows): bool => array_column($rows, 'type') === ['text'])); - $this->expectException(BadRequestError::class); - $this->service->update(7, self::START_PAGE, null, null, null, null, [], 'alice'); + $this->service->update(7, self::START_PAGE, null, null, null, null, [ + ['type' => 'text', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => ['text' => 'Hello']], + ], 'alice'); } public function testDeleteRemovesAGridPageWithItsWidgets(): void {