From 1f13314676e9f1cb7001acfab1a9d8931be61f6c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Mon, 2 Nov 2026 11:53:13 +0100 Subject: [PATCH 1/2] feat(pages): add a tabs widget whose tabs hold their own widgets A tabs widget puts several grids in the room of one: each tab holds its own widgets, tables and views included. The tab strip follows the tabs pattern for the keyboard, and in edit mode each tab can get widgets of its own, saved with parentIndex and parentTab. A table or view widget shows the description of its table or view behind a button. Co-authored-by: taiebot <13300652+taiebot@users.noreply.github.com> Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- .../component/CollapsibleDescription.cy.js | 26 ++ cypress/component/TabsWidget.cy.js | 87 +++++++ cypress/component/gridLayout.cy.js | 2 +- playwright/e2e/context-tabs-widget.spec.ts | 99 +++++++ src/modules/grid/GridPage.vue | 69 ++++- src/modules/grid/GridWidget.vue | 12 +- src/modules/grid/gridLayout.js | 47 +++- src/modules/grid/widgetRegistry.js | 3 + src/modules/grid/widgets/DataWidget.vue | 2 + src/modules/grid/widgets/TabsWidget.vue | 241 ++++++++++++++++++ .../main/sections/CollapsibleDescription.vue | 64 +++++ src/modules/main/sections/TableWrapper.vue | 10 +- src/modules/main/sections/View.vue | 10 +- src/modules/modals/AddWidget.vue | 8 +- 14 files changed, 659 insertions(+), 21 deletions(-) create mode 100644 cypress/component/CollapsibleDescription.cy.js create mode 100644 cypress/component/TabsWidget.cy.js create mode 100644 playwright/e2e/context-tabs-widget.spec.ts create mode 100644 src/modules/grid/widgets/TabsWidget.vue create mode 100644 src/modules/main/sections/CollapsibleDescription.vue diff --git a/cypress/component/CollapsibleDescription.cy.js b/cypress/component/CollapsibleDescription.cy.js new file mode 100644 index 0000000000..36ff896809 --- /dev/null +++ b/cypress/component/CollapsibleDescription.cy.js @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import CollapsibleDescription from '../../src/modules/main/sections/CollapsibleDescription.vue' + +describe('CollapsibleDescription', () => { + beforeEach(() => { + // The description is rendered by the editor of the Text app + window.OCA = { ...window.OCA, Text: { createEditor: () => Promise.resolve({ destroy() {}, setReadOnly() {} }) } } + }) + + it('shows the description behind a button that says whether it is open', () => { + cy.mount(CollapsibleDescription, { props: { description: 'What this table is for' } }) + + cy.get('[data-cy="collapsibleDescriptionToggle"]').should('have.attr', 'aria-expanded', 'false').and('contain.text', 'Show description') + cy.get('.element-description').should('not.exist') + cy.get('[data-cy="collapsibleDescriptionToggle"]').click() + cy.get('[data-cy="collapsibleDescriptionToggle"]').should('have.attr', 'aria-expanded', 'true').and('contain.text', 'Hide description') + cy.get('[data-cy="collapsibleDescriptionToggle"]').invoke('attr', 'aria-controls').then(id => cy.get('#' + id).find('.element-description').should('exist')) + + // A blank description shows nothing + cy.mount(CollapsibleDescription, { props: { description: ' ' } }) + cy.get('[data-cy="collapsibleDescription"]').should('not.exist') + }) +}) diff --git a/cypress/component/TabsWidget.cy.js b/cypress/component/TabsWidget.cy.js new file mode 100644 index 0000000000..f54f513360 --- /dev/null +++ b/cypress/component/TabsWidget.cy.js @@ -0,0 +1,87 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { createPinia } from 'pinia' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import TabsWidget from '../../src/modules/grid/widgets/TabsWidget.vue' +import { gridToWidgets, levelLayout, mergeLayout, pageToGrid, tabTitles } from '../../src/modules/grid/gridLayout.js' + +const page = { + widgets: [ + { id: 1, type: 'tabs', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 8, settings: { tabs: 'Open\n\nClosed' }, source: null, parentId: null, parentTab: null }, + { id: 2, type: 'text', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2, settings: { text: 'Open ones' }, source: null, parentId: 1, parentTab: 0 }, + { id: 3, type: 'text', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2, settings: { text: 'Closed ones' }, source: null, parentId: 1, parentTab: 1 }, + { id: 4, type: 'header', gridX: 0, gridY: 8, gridWidth: 12, gridHeight: 2, settings: { heading: 'Below' }, source: null, parentId: null, parentTab: null }, + ], +} + +const widgetTypes = [{ type: 'tabs', hasSource: false }, { type: 'text', hasSource: false }, { type: 'header', hasSource: false }] + +/** + * @param {boolean} editable whether the page is edited + * @return {object} the actions the widget calls + */ +function mountTabs(editable) { + const grid = pageToGrid(page) + const actions = { + grid: () => grid, + nodes: () => ({}), + changeLayout: cy.stub().as('changeLayout'), + configure: cy.stub(), + remove: cy.stub(), + move: cy.stub(), + addToTab: cy.stub().as('addToTab'), + } + cy.intercept('GET', '**/apps/tables/api/2/widget-types', { ocs: { data: [] } }) + cy.mount(TabsWidget, { + props: { widget: grid.widgets[0], settings: grid.widgets[0].settings, editable }, + global: { plugins: [createPinia()], mixins: [{ methods: { t, n } }], provide: { gridActions: actions } }, + }) + return actions +} + +describe('TabsWidget', () => { + it('shows the widgets of the open tab only', () => { + cy.viewport(1400, 800) + mountTabs(false) + + cy.get('[role="tab"]').should('have.length', 2) + cy.get('[role="tab"]').eq(0).should('have.attr', 'aria-selected', 'true') + // The widgets in a tab load their component on first use + cy.contains('Open ones', { timeout: 20000 }).should('be.visible') + cy.contains('Closed ones').should('not.exist') + cy.contains('Below').should('not.exist') + cy.get('[role="tab"]').eq(1).click() + cy.contains('Closed ones').should('be.visible') + cy.get('[role="tabpanel"]').should('have.attr', 'aria-labelledby').then(id => cy.get('[role="tab"]').eq(1).should('have.id', id)) + }) + + it('moves between tabs with the keyboard with one tab stop', () => { + mountTabs(false) + + cy.get('[role="tab"][tabindex="0"]').should('have.length', 1).focus() + cy.focused().trigger('keydown', { key: 'ArrowRight' }) + cy.focused().should('contain.text', 'Closed') + cy.focused().trigger('keydown', { key: 'Home' }) + cy.focused().should('contain.text', 'Open') + }) +}) + +describe('grid levels', () => { + it('splits the grid by level and saves widgets in tabs with the position of their tabs widget', () => { + const grid = pageToGrid(page) + + expect(tabTitles(grid.widgets[0].settings)).to.deep.equal(['Open', 'Closed']) + expect(levelLayout(grid).map(item => item.widgetId)).to.deep.equal(['w1', 'w4']) + expect(levelLayout(grid, 'w1', 1).map(item => item.widgetId)).to.deep.equal(['w3']) + const merged = mergeLayout(grid.layout, [{ widgetId: 'w3', gridX: 6, gridY: 1, gridWidth: 6, gridHeight: 2 }]) + expect(merged[2].gridX).to.equal(6) + expect(merged[0]).to.equal(grid.layout[0]) + + const saved = gridToWidgets(grid, widgetTypes) + expect(saved[1]).to.deep.include({ parentIndex: 0, parentTab: 0 }) + expect(saved[2]).to.deep.include({ parentIndex: 0, parentTab: 1 }) + expect(saved[0]).not.to.have.property('parentIndex') + }) +}) diff --git a/cypress/component/gridLayout.cy.js b/cypress/component/gridLayout.cy.js index f3e5d9bbc6..c37c367fe1 100644 --- a/cypress/component/gridLayout.cy.js +++ b/cypress/component/gridLayout.cy.js @@ -57,7 +57,7 @@ describe('grid layout', () => { const grid = pageToGrid(page) expect(grid.widgets.map(widget => widget.key)).to.deep.equal(['w11', 'w12', 'w13', 'w14']) - expect(grid.widgets[0]).to.deep.equal({ key: 'w11', id: 11, type: 'header', title: '', icon: null, description: '', settings: { heading: 'Hi', textAlign: 'center' }, nodeRelId: null }) + expect(grid.widgets[0]).to.deep.equal({ key: 'w11', id: 11, type: 'header', title: '', icon: null, description: '', settings: { heading: 'Hi', textAlign: 'center' }, nodeRelId: null, parentKey: null, parentTab: null }) expect(grid.widgets[1]).to.deep.include({ title: 'Tasks', icon: 'check', description: 'Open work', settings: {}, nodeRelId: 4 }) expect(grid.layout[2]).to.deep.equal({ widgetId: 'w13', gridX: 6, gridY: 2, gridWidth: 6, gridHeight: 5 }) expect(widgetTarget(grid.widgets[1], nodes)).to.deep.equal({ type: 'table', id: 40 }) diff --git a/playwright/e2e/context-tabs-widget.spec.ts b/playwright/e2e/context-tabs-widget.spec.ts new file mode 100644 index 0000000000..121371ec31 --- /dev/null +++ b/playwright/e2e/context-tabs-widget.spec.ts @@ -0,0 +1,99 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { type APIRequestContext } from '@playwright/test' +import { test, expect } from '../support/fixtures' +import { ocsRequest, type TestUser } from '../support/api' + +test.use({ viewport: { width: 1600, height: 1000 } }) + +const API = '/ocs/v2.php/apps/tables/api/2' + +async function createApplicationWithPage(request: APIRequestContext, user: TestUser, name: string) { + const table = await ocsRequest(request, user, { method: 'POST', url: '/index.php/apps/tables/api/1/tables', data: { title: name + ' table' } }) + const tableId = (await table.json()).id + await ocsRequest(request, user, { method: 'PUT', url: `/ocs/v2.php/apps/tables/api/2/tables/${tableId}?format=json`, data: { description: 'What this table is for' } }) + const context = await ocsRequest(request, user, { + method: 'POST', + url: `${API}/contexts?format=json`, + data: { name, iconName: 'star', description: '', nodes: [{ id: tableId, type: 0, permissions: 1 }] }, + }) + const contextId = (await context.json()).ocs.data.id + const contextData = (await (await ocsRequest(request, user, { method: 'GET', url: `${API}/contexts/${contextId}?format=json` })).json()).ocs.data + const page = await ocsRequest(request, user, { method: 'POST', url: `${API}/contexts/${contextData.id}/pages?format=json`, data: { title: 'Tabs' } }) + return { contextId: contextData.id, pageId: (await page.json()).ocs.data.id, nodeRelId: Number(Object.keys(contextData.nodes)[0]), tableTitle: name + ' table' } +} + +test.describe('Tabs widget', () => { + test('Each tab holds its own widgets, a table included', async ({ userPage: { page, user } }) => { + const { contextId, pageId, nodeRelId, tableTitle } = await createApplicationWithPage(page.request, user, 'tabs view') + const saved = await ocsRequest(page.request, user, { + method: 'PATCH', + url: `${API}/contexts/${contextId}/pages/${pageId}?format=json`, + data: { + widgets: [ + { type: 'tabs', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 10, settings: { tabs: 'Notes\nData' } }, + { type: 'text', gridX: 0, gridY: 0, gridWidth: 6, gridHeight: 2, settings: { text: 'In the first tab' }, parentIndex: 0, parentTab: 0 }, + { type: 'data', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 6, source: { nodeRelId }, parentIndex: 0, parentTab: 1 }, + ], + }, + }) + expect(saved.ok()).toBe(true) + + await page.goto(`/index.php/apps/tables/#/application/${contextId}/page/${pageId}`) + const tabs = page.locator('[data-cy="tabsWidget"]').getByRole('tab') + await expect(tabs).toHaveCount(2, { timeout: 30000 }) + await expect(page.getByText('In the first tab')).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toHaveCount(0) + + // The keyboard moves between tabs, Enter opens one + await tabs.nth(0).focus() + await page.keyboard.press('ArrowRight') + await expect(tabs.nth(1)).toBeFocused() + await page.keyboard.press('Enter') + await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true') + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 30000 }) + await expect(page.getByText('In the first tab')).toHaveCount(0) + + // A table with a description shows it behind a button + const toggle = page.locator('[data-cy="collapsibleDescriptionToggle"]') + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await toggle.click() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + }) + + test('A widget is added to a tab in edit mode and stays there after a reload', async ({ userPage: { page, user } }) => { + const { contextId, pageId } = await createApplicationWithPage(page.request, user, 'tabs edit') + await page.goto(`/index.php/apps/tables/#/application/${contextId}/page/${pageId}`) + + await page.locator('[data-cy="gridPageEdit"]').click({ timeout: 30000 }) + await page.locator('[data-cy="gridPageAddWidget"]').click() + await page.locator('[data-cy="addWidgetType"] input').click() + await page.locator('.vs__dropdown-menu li').filter({ hasText: 'Tabs' }).click() + await page.locator('[data-cy="widgetFieldTabs"] textarea').fill('First\nSecond') + await page.locator('[data-cy="addWidgetSubmit"]').click() + + await page.locator('[data-cy="tabsWidget"]').getByRole('tab', { name: 'Second' }).click() + await page.locator('[data-cy="tabsWidgetAdd"]').click() + await page.locator('[data-cy="addWidgetType"] input').click() + // Tabs go one level deep + await expect(page.locator('.vs__dropdown-menu li').filter({ hasText: 'Tabs' })).toHaveCount(0) + await page.locator('.vs__dropdown-menu li').filter({ hasText: 'Description' }).click() + await page.locator('[data-cy="widgetFieldText"] textarea').fill('Only in the second tab') + await page.locator('[data-cy="addWidgetSubmit"]').click() + + const response = page.waitForResponse(r => /\/pages\/\d+/.test(r.url()) && r.request().method() === 'PATCH') + await page.locator('[data-cy="gridPageSave"]').click() + const widgets = (await (await response).json()).ocs.data.widgets + const tabsWidget = widgets.find((widget: { type: string }) => widget.type === 'tabs') + expect(widgets.find((widget: { type: string }) => widget.type === 'text')).toMatchObject({ parentId: tabsWidget.id, parentTab: 1 }) + + await page.reload() + await expect(page.locator('[data-cy="tabsWidget"]').getByRole('tab')).toHaveCount(2, { timeout: 30000 }) + await expect(page.getByText('Only in the second tab')).toHaveCount(0) + await page.locator('[data-cy="tabsWidget"]').getByRole('tab', { name: 'Second' }).click() + await expect(page.getByText('Only in the second tab')).toBeVisible() + }) +}) diff --git a/src/modules/grid/GridPage.vue b/src/modules/grid/GridPage.vue index 3e9b830b32..21a43b1563 100644 --- a/src/modules/grid/GridPage.vue +++ b/src/modules/grid/GridPage.vue @@ -74,7 +74,7 @@ - +