From a3dc77ae6fef4dc84584faa9edeca991c0366db0 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 2 Nov 2026 11:45:53 +0100 Subject: [PATCH 1/2] feat(pages): fill the start page of an application with widgets The start page of an application is a grid page. A new application starts with a header showing its name and description and one full width table or view widget per table or view. A migration fills existing start pages without widgets the same way, in the order they listed their tables, and an import of data exported before pages held widgets does too. The page endpoint accepts widgets on the start page, which still cannot be deleted. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- appinfo/info.xml | 2 +- lib/Controller/ContextPageController.php | 5 +- .../Version2500Date20261030000000.php | 119 ++++++++++++++++++ lib/Service/ContextService.php | 15 +++ lib/Service/GridWidgetTypes.php | 42 ++++++- lib/Service/PageService.php | 5 +- package-lock.json | 4 +- package.json | 2 +- .../Version2500Date20261030000000Test.php | 119 ++++++++++++++++++ .../Service/ContextPagesRoundTripTest.php | 33 +++++ tests/unit/Service/ContextServiceTest.php | 50 +++++++- tests/unit/Service/PageServiceTest.php | 8 +- 12 files changed, 385 insertions(+), 19 deletions(-) create mode 100644 lib/Migration/Version2500Date20261030000000.php create mode 100644 tests/unit/Migration/Version2500Date20261030000000Test.php 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/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/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 { From 05ea37eafc547f1299307ccc0ed2efbfc34988e1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 2 Nov 2026 13:42:43 +0100 Subject: [PATCH 2/2] chore(api): regenerate the OpenAPI spec and TypeScript types Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- openapi.json | 2 +- src/types/openapi/openapi.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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"];