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 {