From ef4c253abf8bd3118e3d0c4d3d4cd870b6049f33 Mon Sep 17 00:00:00 2001 From: Remko Date: Wed, 14 Oct 2026 11:08:19 +0200 Subject: [PATCH 1/2] feat(applications): personal layout for the standalone overview - opened on its own, an application shows its tables and views as cards on a 12-column grid that narrows on smaller screens - each person can give a card full, two thirds, half or one third of the width, and reset it to the default - the layout is kept per user in this browser; when the browser refuses to store it, it applies to the current visit only and says so Signed-off-by: Remko Assisted-by: ClaudeCode:claude-opus-5-5 --- cypress/component/contextLayout.cy.js | 39 ++++++ playwright/e2e/application.spec.ts | 28 ++++ playwright/e2e/context.spec.ts | 1 + src/pages/Context.vue | 191 +++++++++++++++++++++++++- src/shared/utils/contextLayout.js | 68 +++++++++ 5 files changed, 323 insertions(+), 4 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..40d5d07d0a --- /dev/null +++ b/cypress/component/contextLayout.cy.js @@ -0,0 +1,39 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getGridCellStyle, loadContextLayout, saveContextLayout } from '../../src/shared/utils/contextLayout.js' + +describe('application layout', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('keeps a saved layout per application', () => { + saveContextLayout(7, { 4: 6, 'view-9': 4 }) + + expect(loadContextLayout(7)).to.deep.equal({ 4: 6, 'view-9': 4 }) + expect(loadContextLayout(8)).to.deep.equal({}) + }) + + it('resets to the default when an empty layout is saved', () => { + saveContextLayout(7, { 4: 6 }) + saveContextLayout(7, {}) + + expect(loadContextLayout(7)).to.deep.equal({}) + }) + + it('drops widths it does not know and survives a broken entry', () => { + saveContextLayout(7, { 4: 6, 5: 5, 6: '12' }) + expect(loadContextLayout(7)).to.deep.equal({ 4: 6 }) + + const key = Object.keys(window.localStorage).find(name => name.endsWith(':7')) + window.localStorage.setItem(key, '{not json') + expect(loadContextLayout(7)).to.deep.equal({}) + }) + + it('narrows a wide item to the medium grid', () => { + expect(getGridCellStyle(8)).to.deep.equal({ '--context-grid-span': 8, '--context-grid-span-medium': 6 }) + expect(getGridCellStyle(4)).to.deep.equal({ '--context-grid-span': 4, '--context-grid-span-medium': 4 }) + }) +}) diff --git a/playwright/e2e/application.spec.ts b/playwright/e2e/application.spec.ts index 4b316ee581..76d5d914d9 100644 --- a/playwright/e2e/application.spec.ts +++ b/playwright/e2e/application.spec.ts @@ -108,6 +108,34 @@ 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: '/ocs/v2.php/apps/tables/api/2/tables?format=json', + data: { title: 'Layout table', template: 'custom' }, + }) + const table = (await tableResponse.json()).ocs.data + const contextResponse = await ocsRequest(page.request, user, { + method: 'POST', + url: '/ocs/v2.php/apps/tables/api/2/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="context-grid-item"]') + await expect(item).toHaveCount(1, { timeout: 10000 }) + + await page.locator('[data-cy="context-layout-edit"]').click() + await item.getByRole('button', { name: 'Width of Layout table' }).click() + await page.getByRole('menuitemradio', { name: 'Half' }).click() + await page.locator('[data-cy="context-layout-save"]').click() + await expect(item).toHaveAttribute('style', /--context-grid-span: 6/) + + await page.reload() + await expect(page.locator('[data-cy="context-grid-item"]')).toHaveAttribute('style', /--context-grid-span: 6/, { timeout: 10000 }) + }) + test('A page with widgets added on the overview gets a menu entry and opens', async ({ userPage: { page } }) => { await page.goto('/index.php/apps/tables') await createContext(page, 'overview application') diff --git a/playwright/e2e/context.spec.ts b/playwright/e2e/context.spec.ts index 8afce5a67d..dccece8da4 100644 --- a/playwright/e2e/context.spec.ts +++ b/playwright/e2e/context.spec.ts @@ -247,6 +247,7 @@ test.describe('Manage a context', () => { await openApplication(page, contextTitle) // The application has loaded once its loading icon is gone; only then does a missing table mean anything await expect(page.locator('.main-context-view > .icon-loading')).toHaveCount(0, { timeout: 10000 }) + await expect(page.locator('[data-cy="context-grid-item"]')).toHaveCount(0) await expect(page.locator('h1', { hasText: tableTitle })).toBeHidden() }) diff --git a/src/pages/Context.vue b/src/pages/Context.vue index a8b416e016..36531019ed 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -366,8 +366,52 @@ -
-
+
+ + + + {{ t('tables', 'Edit layout') }} + +
+
+
+
+ + + + {{ option.label }} + + +
-
+
@@ -394,7 +438,7 @@