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..3f3d58780b --- /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('[data-cy="tabsWidget"] [data-cy="gridWidget"]').getByRole('heading', { name: 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('[data-cy="tabsWidget"] [data-cy="gridWidget"]').getByRole('heading', { name: 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 @@ - +