Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions lib/Controller/ContextPageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions lib/Service/PageService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
37 changes: 37 additions & 0 deletions playwright/e2e/context-grid-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@

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)

Check failure on line 102 in playwright/e2e/context-grid-pages.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright (stable35)

[chromium] › playwright/e2e/context-grid-pages.spec.ts:68:2 › Grid pages of an application › A new page holds a header and a table of the application after a reload

1) [chromium] › playwright/e2e/context-grid-pages.spec.ts:68:2 › Grid pages of an application › A new page holds a header and a table of the application after a reload Error: expect(received).toBe(expected) // Object.is equality Expected: true Received: false 100 | const saved = page.waitForResponse(r => /\/pages\/\d+/.test(r.url()) && r.request().method() === 'PATCH') 101 | await page.locator('[data-cy="gridPageSave"]').click() > 102 | expect((await saved).ok()).toBe(true) | ^ 103 | await expect(page.locator('[data-cy="gridPageEdit"]')).toBeVisible() 104 | 105 | await page.reload() at /home/runner/work/tables/tables/playwright/e2e/context-grid-pages.spec.ts:102:30
await expect(page.locator('[data-cy="gridPageEdit"]')).toBeVisible()

await page.reload()
Expand Down Expand Up @@ -204,6 +204,43 @@
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])
Expand Down
27 changes: 21 additions & 6 deletions src/modules/grid/GridPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="grid-page" data-cy="gridPage">
<div class="grid-page__header">
<div class="grid-page__heading">
<div class="grid-page"
:class="{ 'grid-page--start': isStartPage }"
:data-cy="isStartPage ? 'startPageGrid' : 'gridPage'">
<div v-if="!isStartPage || canEdit" class="grid-page__header">
<div v-if="!isStartPage" class="grid-page__heading">
<h2 class="grid-page__title" data-cy="gridPageTitle">
<span v-if="page.emoji" aria-hidden="true">{{ page.emoji }}</span>
{{ page.title }}
Expand Down Expand Up @@ -39,7 +41,7 @@
</template>
{{ t('tables', 'Edit page') }}
</NcButton>
<NcActions :aria-label="t('tables', 'Page actions')" data-cy="gridPageActions">
<NcActions v-if="!isStartPage" :aria-label="t('tables', 'Page actions')" data-cy="gridPageActions">
<NcActionButton :close-after-click="true" data-cy="gridPageEditDetails" @click="$emit('edit-details', page)">
<template #icon>
<TextBoxEditOutline :size="20" />
Expand All @@ -57,7 +59,7 @@
</div>
</div>

<NcEmptyContent v-if="activeGrid.layout.length === 0"
<NcEmptyContent v-if="activeGrid.layout.length === 0 && (isEditing || !isStartPage)"
class="grid-page__empty"
:name="t('tables', 'This page is empty')"
:description="emptyDescription">
Expand All @@ -71,7 +73,10 @@
</template>
</NcEmptyContent>

<GridCanvas :layout="activeGrid.layout" :editable="isEditing" @layout-change="onLayoutChange">
<GridCanvas v-if="activeGrid.layout.length > 0 || isEditing || !isStartPage"
:layout="activeGrid.layout"
:editable="isEditing"
@layout-change="onLayoutChange">
<template #widget="{ item, narrow }">
<GridWidget v-if="widgetsById[item.widgetId]"
:widget="widgetsById[item.widgetId]"
Expand Down Expand Up @@ -147,6 +152,12 @@ export default {
type: Boolean,
default: false,
},
// The start page shows its widgets above its tables and views, under the
// application's own title; without widgets it shows only the edit button
isStartPage: {
type: Boolean,
default: false,
},
},
emits: ['saved', 'edit-details', 'delete'],
setup(props) {
Expand Down Expand Up @@ -375,6 +386,10 @@ export default {
width: 100%;
padding: calc(4 * var(--default-grid-baseline));

&--start {
padding-bottom: 0;
}

&__header {
display: flex;
align-items: flex-start;
Expand Down
27 changes: 26 additions & 1 deletion src/pages/Context.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,15 @@
<ErrorMessage v-else :message="t('tables', 'This page could not be found')" />
</template>

<div v-else class="resources">
<GridPage v-else-if="startPage"
:key="'start-' + startPage.id"
:context="activeContext"
:page="startPage"
:can-edit="canManage"
is-start-page
@saved="page => startPage = page" />

<div v-if="activePageId === null" class="resources">
<div v-for="resource in contextResources" :key="resource.key">
<div v-if="!resource.isView" class="resource">
<TableWrapper v-model:view-setting="resourceViewSettings[resource.key]" :table="resource" :columns="columns[resource.key]"
Expand Down Expand Up @@ -115,6 +123,7 @@ export default {
loadedSignature: null,
isReloading: false,
activePage: null,
startPage: null,
pageLoading: false,
pageFormOpen: false,
pageBeingEdited: null,
Expand Down Expand Up @@ -241,6 +250,7 @@ export default {
if (this.activePageId !== null) {
await this.loadActivePage()
} else if (this.context && this.context.pages) {
await this.loadStartPage()
const pages = Object.values(this.context.pages)
const startPage = pages.find(p => p.page_type === 'startpage')

Expand Down Expand Up @@ -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.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/types/openapi/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
12 changes: 9 additions & 3 deletions tests/unit/Service/PageServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading