diff --git a/lib/Controller/ContextPageController.php b/lib/Controller/ContextPageController.php index f860d62933..109d519a50 100644 --- a/lib/Controller/ContextPageController.php +++ b/lib/Controller/ContextPageController.php @@ -102,9 +102,10 @@ public function create(int $contextId, string $title, ?string $emoji = null, ?st * [api v2] Change a page or its widgets * * Fields left out keep their value; an empty emoji or description removes it. When - * `widgets` is given, the widgets of the grid page are made to match it in one + * `widgets` is given, the widgets of the page are made to match it in one * transaction: listed widgets with an `id` are updated, listed widgets without one - * are added and widgets that are not listed are deleted. + * are added and widgets that are not listed are deleted. On the start page the + * widgets are shown above its tables and views. * * A widget has a `type`, an optional `title`, `gridX`, `gridY`, `gridWidth` and * `gridHeight` on a grid of 12 columns and `settings` following the schema of its diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php index b08dbdfc68..afbc3ac78a 100644 --- a/lib/Service/PageService.php +++ b/lib/Service/PageService.php @@ -131,7 +131,7 @@ public function create(int $contextId, string $title, ?string $emoji, ?string $d */ public function update(int $contextId, int $pageId, ?string $title, ?string $emoji, ?string $description, ?array $widgets, string $userId, ?string $technicalName = null): array { $context = $this->getManageableContext($contextId, $userId); - $pageArray = $this->getPageArray($context, $pageId); + $this->getPageArray($context, $pageId); $page = $this->pageMapper->findById($pageId); if ($title !== null) { @@ -150,9 +150,6 @@ public function update(int $contextId, int $pageId, ?string $title, ?string $emo $rows = null; $nodesToAdd = []; if ($widgets !== null) { - if ($pageArray['page_type'] !== Page::TYPE_GRID) { - throw new BadRequestError('Only grid pages hold widgets.'); - } [$rows, $nodesToAdd] = $this->readWidgets($widgets, $context, $pageId, $userId); } diff --git a/openapi.json b/openapi.json index ac99d86057..d3f9274617 100644 --- a/openapi.json +++ b/openapi.json @@ -14697,7 +14697,7 @@ "patch": { "operationId": "context_page-update", "summary": "[api v2] Change a page or its widgets", - "description": "Fields left out keep their value; an empty emoji or description removes it. When `widgets` is given, the widgets of the grid page are made to match it in one transaction: listed widgets with an `id` are updated, listed widgets without one are added and widgets that are not listed are deleted.\nA widget has a `type`, an optional `title`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns and `settings` following the schema of its type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or view of this application, `{nodeType, nodeId}` of a table or view the user manages, which is added to the application, or null.", + "description": "Fields left out keep their value; an empty emoji or description removes it. When `widgets` is given, the widgets of the page are made to match it in one transaction: listed widgets with an `id` are updated, listed widgets without one are added and widgets that are not listed are deleted. On the start page the widgets are shown above its tables and views.\nA widget has a `type`, an optional `title`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns and `settings` following the schema of its type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or view of this application, `{nodeType, nodeId}` of a table or view the user manages, which is added to the application, or null.", "tags": [ "context_page" ], diff --git a/playwright/e2e/context-grid-pages.spec.ts b/playwright/e2e/context-grid-pages.spec.ts index 684eddbad8..92d973197d 100644 --- a/playwright/e2e/context-grid-pages.spec.ts +++ b/playwright/e2e/context-grid-pages.spec.ts @@ -204,6 +204,43 @@ test.describe('Grid pages of an application', () => { expect((await renamed).status()).toBe(400) }) + test('Widgets on the start page show above its tables', async ({ userPage: { page, user }, browser, baseURL, request }) => { + const sharee = await createRandomUser(request) + const table = await createTableViaApi(page.request, user, 'grid start table') + const contextId = await createApplicationViaApi(page.request, user, 'grid start app', [table]) + await ocsRequest(page.request, user, { + method: 'POST', + url: '/index.php/apps/tables/api/1/shares', + data: { nodeId: contextId, nodeType: 'context', receiver: sharee.userId, receiverType: 'user', permissionRead: true, displayMode: 2 }, + }) + await openApplication(page, contextId) + await expect(page.locator('h1', { hasText: 'grid start table' })).toBeVisible({ timeout: 30000 }) + await expect(page.locator('[data-cy="startPageGrid"] [data-cy="gridItem"]')).toHaveCount(0) + + await page.locator('[data-cy="startPageGrid"] [data-cy="gridPageEdit"]').click() + await page.locator('[data-cy="gridPageAddWidget"]').click() + await page.locator('[data-cy="widgetFieldHeading"] input').fill('Team dashboard') + await page.locator('[data-cy="addWidgetSubmit"]').click() + const saved = page.waitForResponse(r => /\/pages\/\d+/.test(r.url()) && r.request().method() === 'PATCH') + await page.locator('[data-cy="gridPageSave"]').click() + expect((await saved).ok()).toBe(true) + + await page.reload() + const header = page.getByText('Team dashboard') + const tableHeading = page.locator('h1', { hasText: 'grid start table' }) + await expect(header).toBeVisible({ timeout: 30000 }) + await expect(tableHeading).toBeVisible({ timeout: 30000 }) + expect((await header.boundingBox())!.y).toBeLessThan((await tableHeading.boundingBox())!.y) + + const shareeContext = await browser.newContext({ baseURL, viewport: { width: 1600, height: 1000 } }) + const shareePage = await shareeContext.newPage() + await login(shareePage, sharee) + await openApplication(shareePage, contextId) + await expect(shareePage.getByText('Team dashboard')).toBeVisible({ timeout: 30000 }) + await expect(shareePage.locator('[data-cy="gridPageEdit"]')).toHaveCount(0) + await shareeContext.close() + }) + test('A table removed from the application leaves its widget asking for a new one', async ({ userPage: { page, user } }) => { const table = await createTableViaApi(page.request, user, 'grid removed table') const contextId = await createApplicationViaApi(page.request, user, 'grid removal app', [table]) diff --git a/src/modules/grid/GridPage.vue b/src/modules/grid/GridPage.vue index 4832a472ba..26566ebe00 100644 --- a/src/modules/grid/GridPage.vue +++ b/src/modules/grid/GridPage.vue @@ -3,9 +3,11 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> {{ t('tables', 'Edit page') }} - + - + -
+ + +
p.page_type === 'startpage') @@ -340,6 +350,21 @@ export default { } await this.loadApplicationNodes() }, + /** + * Loads the widgets of the start page, shown above its tables and views. + */ + async loadStartPage() { + this.startPage = null + const startPage = Object.values(this.context?.pages ?? {}).find(page => page.page_type === 'startpage') + if (!startPage) { + return + } + try { + this.startPage = await this.loadContextPage({ contextId: this.activeContextId, pageId: startPage.id }) + } catch (e) { + displayError(e, t('tables', 'Could not load the page.')) + } + }, /** * Puts the tables and views of the application in the store, without their rows. */ diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 675e772bb5..a08f4c293a 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -914,7 +914,7 @@ export type paths = { readonly head?: never; /** * [api v2] Change a page or its widgets - * @description Fields left out keep their value; an empty emoji or description removes it. When `widgets` is given, the widgets of the grid page are made to match it in one transaction: listed widgets with an `id` are updated, listed widgets without one are added and widgets that are not listed are deleted. + * @description Fields left out keep their value; an empty emoji or description removes it. When `widgets` is given, the widgets of the page are made to match it in one transaction: listed widgets with an `id` are updated, listed widgets without one are added and widgets that are not listed are deleted. On the start page the widgets are shown above its tables and views. * A widget has a `type`, an optional `title`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns and `settings` following the schema of its type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or view of this application, `{nodeType, nodeId}` of a table or view the user manages, which is added to the application, or null. */ readonly patch: operations["context_page-update"]; diff --git a/tests/unit/Service/PageServiceTest.php b/tests/unit/Service/PageServiceTest.php index 4533f5d7d6..ad37209ce4 100644 --- a/tests/unit/Service/PageServiceTest.php +++ b/tests/unit/Service/PageServiceTest.php @@ -306,11 +306,17 @@ public function testUpdateRejectsInvalidWidgets(array $widgets, string $message) $this->service->update(7, self::GRID_PAGE, null, null, null, $widgets, 'alice'); } - public function testTheStartPageHoldsNoWidgets(): void { + public function testTheStartPageHoldsWidgetsAboveItsTables(): void { $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->widgetService->expects($this->once())->method('syncPage')->willReturnCallback(function (int $pageId, array $rows): void { + $this->assertSame(self::START_PAGE, $pageId); + $this->assertSame(['header'], array_column($rows, 'type')); + }); + $this->pageContentMapper->expects($this->never())->method($this->anything()); - $this->expectException(BadRequestError::class); - $this->service->update(7, self::START_PAGE, null, null, null, [], 'alice'); + $this->service->update(7, self::START_PAGE, null, null, null, [ + ['type' => 'header', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2, 'settings' => ['heading' => 'Welcome']], + ], 'alice'); } public function testDeleteRemovesAGridPageWithItsWidgets(): void {