From 4460f22cc96310bcd3294587470559bd8a618a70 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 15 Oct 2026 20:15:06 +0200 Subject: [PATCH] feat(applications): personal layout for the standalone application Opened on its own, an application lets each person choose how wide each widget of a page is, without changing the page for anyone else. - Widths: "Edit layout" lets a person give each widget full width, two thirds, half or one third; "Reset layout" goes back to the page as arranged, and the layout is saved or cancelled as a whole. A widened widget that would no longer fit moves left; the grid moves widgets down where they would overlap. - Storage: the layout is personal and kept in the browser (local storage), per user, application and page. It does not follow the user to another browser or device. When the browser refuses to store it, the layout applies to the current visit only and the user is told so. - Managers editing the page see and arrange the page as stored. Assisted-by: ClaudeCode:claude-opus-5-5 --- cypress/component/contextLayout.cy.js | 48 ++++++++++ playwright/e2e/application.spec.ts | 29 ++++++ src/modules/grid/GridPage.vue | 126 ++++++++++++++++++++++++-- src/modules/grid/GridWidget.vue | 39 +++++++- src/pages/Context.vue | 2 + src/shared/utils/contextLayout.js | 74 +++++++++++++++ 6 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 cypress/component/contextLayout.cy.js create mode 100644 src/shared/utils/contextLayout.js diff --git a/cypress/component/contextLayout.cy.js b/cypress/component/contextLayout.cy.js new file mode 100644 index 0000000000..931046c5ad --- /dev/null +++ b/cypress/component/contextLayout.cy.js @@ -0,0 +1,48 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { applyPersonalWidths, loadContextLayout, saveContextLayout } from '../../src/shared/utils/contextLayout.js' + +describe('application layout', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('keeps a saved layout per application and page', () => { + saveContextLayout(7, 3, { 'content-4': 6, 'content-9': 4 }) + + expect(loadContextLayout(7, 3)).to.deep.equal({ 'content-4': 6, 'content-9': 4 }) + expect(loadContextLayout(7, 5)).to.deep.equal({}) + expect(loadContextLayout(8, 3)).to.deep.equal({}) + }) + + it('resets to the page as arranged when an empty layout is saved', () => { + saveContextLayout(7, 3, { 'content-4': 6 }) + saveContextLayout(7, 3, {}) + + expect(loadContextLayout(7, 3)).to.deep.equal({}) + }) + + it('drops widths it does not know and survives a broken entry', () => { + saveContextLayout(7, 3, { a: 6, b: 5, c: '12' }) + expect(loadContextLayout(7, 3)).to.deep.equal({ a: 6 }) + + const key = Object.keys(window.localStorage).find(name => name.endsWith(':7:3')) + window.localStorage.setItem(key, '{not json') + expect(loadContextLayout(7, 3)).to.deep.equal({}) + }) + + it('applies the widths to the layout and keeps a widened widget on the grid', () => { + const layout = [ + { widgetId: 'a', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 5 }, + { widgetId: 'b', gridX: 8, gridY: 5, gridWidth: 4, gridHeight: 5 }, + ] + + const shown = applyPersonalWidths(layout, { a: 6, b: 8 }) + + expect(shown[0]).to.deep.include({ gridX: 0, gridWidth: 6 }) + expect(shown[1]).to.deep.include({ gridX: 4, gridWidth: 8 }) + expect(applyPersonalWidths(layout, {})).to.deep.equal(layout) + }) +}) diff --git a/playwright/e2e/application.spec.ts b/playwright/e2e/application.spec.ts index 6c1ec0b047..f62875149f 100644 --- a/playwright/e2e/application.spec.ts +++ b/playwright/e2e/application.spec.ts @@ -197,6 +197,35 @@ test.describe('Applications', () => { await expect(page.locator('[data-cy="context-title"]')).toContainText('listed application') }) + test('A width chosen for a table in an application opened on its own is kept after a reload', async ({ userPage: { page, user } }) => { + const tableResponse = await ocsRequest(page.request, user, { method: 'POST', url: '/index.php/apps/tables/api/1/tables', data: { title: 'Layout table' } }) + const table = await tableResponse.json() + const contextResponse = await ocsRequest(page.request, user, { + method: 'POST', + url: `${API}/contexts?format=json`, + data: { name: 'layout application', iconName: 'star', nodes: [{ id: table.id, type: 0, permissions: 1 }] }, + }) + const context = (await contextResponse.json()).ocs.data + + await page.goto(`/index.php/apps/tables/app/${context.id}`) + const item = page.locator('[data-cy="gridItem"]') + await expect(item).toHaveCount(1, { timeout: 30000 }) + await expect(item).toHaveAttribute('gs-w', '12') + + await page.locator('[data-cy="context-layout-edit"]').click() + await item.locator('[data-cy="gridWidgetWidth"]').getByRole('button').first().click() + await page.getByRole('menuitemradio', { name: 'Half' }).or(page.getByRole('radio', { name: 'Half' })).first().click() + await page.locator('[data-cy="context-layout-save"]').click() + await expect(item).toHaveAttribute('gs-w', '6') + + await page.reload() + await expect(page.locator('[data-cy="gridItem"]')).toHaveAttribute('gs-w', '6', { timeout: 30000 }) + // The page as arranged for everyone keeps its width + const stored = await getApplication(page.request, user, context.id) + const startPage = Object.values(stored.pages as Record }>).find(entry => entry.page_type === 'startpage') + expect(Object.values(startPage!.content).map(entry => entry.grid_width)).toEqual([12]) + }) + test('A page with widgets added on the overview gets a menu entry and opens in edit mode', async ({ userPage: { page } }) => { await page.goto('/index.php/apps/tables') await createContext(page, 'overview application') diff --git a/src/modules/grid/GridPage.vue b/src/modules/grid/GridPage.vue index 8f92cd3a20..13ec8e8ed2 100644 --- a/src/modules/grid/GridPage.vue +++ b/src/modules/grid/GridPage.vue @@ -4,9 +4,26 @@ -->