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
26 changes: 26 additions & 0 deletions cypress/component/CollapsibleDescription.cy.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
87 changes: 87 additions & 0 deletions cypress/component/TabsWidget.cy.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
2 changes: 1 addition & 1 deletion cypress/component/gridLayout.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
99 changes: 99 additions & 0 deletions playwright/e2e/context-tabs-widget.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
69 changes: 57 additions & 12 deletions src/modules/grid/GridPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
</template>
</NcEmptyContent>

<GridCanvas :layout="activeGrid.layout" :editable="isEditing" @layout-change="onLayoutChange">
<GridCanvas :layout="pageLayout" :editable="isEditing" @layout-change="onLayoutChange">
<template #widget="{ item, narrow }">
<GridWidget v-if="widgetsById[item.widgetId]"
:widget="widgetsById[item.widgetId]"
Expand All @@ -91,6 +91,7 @@
<AddWidget :show-modal="addWidgetOpen"
:editing-widget="widgetBeingConfigured"
:nodes="context.nodes"
:allow-tabs="targetTab === null"
@close="closeAddWidget"
@submit="onWidgetSubmit" />
</div>
Expand All @@ -113,7 +114,7 @@ import GridWidget from './GridWidget.vue'
import AddWidget from '../modals/AddWidget.vue'
import NamedIcon from '../../shared/components/ncIconPicker/NamedIcon.vue'
import { useTablesStore } from '../../store/store.js'
import { GRID_COLUMNS, GRID_MIN_WIDTH, cloneGrid, findWidgetType, gridToWidgets, isStartPage, nextFreeRow, pageTitle, pageToGrid } from './gridLayout.js'
import { GRID_COLUMNS, GRID_MIN_WIDTH, cloneGrid, findWidgetType, gridToWidgets, isStartPage, levelLayout, mergeLayout, nextFreeRow, pageTitle, pageToGrid, tabTitles } from './gridLayout.js'

/**
* A grid page of an application: its widgets on a grid, with an edit mode that saves the
Expand Down Expand Up @@ -141,6 +142,16 @@ export default {
return {
// Widget titles sit below the title of the page, or of the application on a start page without one
gridHeadingLevel: computed(() => this.showHeading ? 3 : 2),
// What a tabs widget needs to show and change the widgets in its tabs
gridActions: {
grid: () => this.activeGrid,
nodes: () => this.context.nodes,
changeLayout: this.onLayoutChange,
configure: this.configureWidget,
remove: this.removeWidget,
move: this.moveWidget,
addToTab: this.openAddWidgetInTab,
},
}
},
props: {
Expand Down Expand Up @@ -199,6 +210,8 @@ export default {
saving: false,
addWidgetOpen: false,
widgetBeingConfigured: null,
// The tab a new widget goes into, as {parentKey, tab}; null for the page itself
targetTab: null,
}
},
computed: {
Expand All @@ -225,6 +238,10 @@ export default {
}
return this.canEdit ? t('tables', 'Edit the page to add widgets.') : ''
},
// The widgets on the page itself; those in tabs are shown by their tabs widget
pageLayout() {
return levelLayout(this.activeGrid)
},
widgetsById() {
return Object.fromEntries(this.activeGrid.widgets.map(widget => [widget.key, widget]))
},
Expand Down Expand Up @@ -273,13 +290,26 @@ export default {
if (!this.isEditing) {
return
}
this.draftGrid = { ...this.draftGrid, layout }
// A canvas shows one level of the grid, the page itself or one tab
this.draftGrid = { ...this.draftGrid, layout: mergeLayout(this.draftGrid.layout, layout) }
},
/**
* Opens the dialog for a new widget.
*/
openAddWidget() {
this.widgetBeingConfigured = null
this.targetTab = null
this.addWidgetOpen = true
},
/**
* Opens the dialog for a new widget in a tab of a tabs widget.
*
* @param {object} tabsWidget the tabs widget
* @param {number} tab the position of the tab
*/
openAddWidgetInTab(tabsWidget, tab) {
this.widgetBeingConfigured = null
this.targetTab = { parentKey: tabsWidget.key, tab }
this.addWidgetOpen = true
},
/**
Expand All @@ -288,6 +318,7 @@ export default {
closeAddWidget() {
this.addWidgetOpen = false
this.widgetBeingConfigured = null
this.targetTab = null
},
/**
* Opens the dialog for a widget on the grid.
Expand All @@ -296,33 +327,44 @@ export default {
*/
configureWidget(widget) {
this.widgetBeingConfigured = widget
this.targetTab = widget.parentKey ? { parentKey: widget.parentKey, tab: widget.parentTab } : null
this.addWidgetOpen = true
},
/**
* Updates the configured widget, or places a new one below the others.
* Updates the configured widget, or places a new one below the others on its level. A tabs
* widget that loses tabs moves the widgets of those tabs into its last one.
*
* @param {{type: string, title: string, icon: string|null, description: string, settings: object, nodeRelId: number|null}} payload the widget from the dialog
*/
onWidgetSubmit(payload) {
if (this.widgetBeingConfigured) {
const configuredKey = this.widgetBeingConfigured.key
const lastTab = Math.max(0, tabTitles(payload.settings).length - 1)
this.draftGrid = {
...this.draftGrid,
widgets: this.draftGrid.widgets.map(widget => widget.key === this.widgetBeingConfigured.key
? { ...widget, title: payload.title, icon: payload.icon, description: payload.description, settings: payload.settings, nodeRelId: payload.nodeRelId }
: widget),
widgets: this.draftGrid.widgets.map(widget => {
if (widget.key === configuredKey) {
return { ...widget, title: payload.title, icon: payload.icon, description: payload.description, settings: payload.settings, nodeRelId: payload.nodeRelId }
}
if (widget.parentKey === configuredKey && widget.parentTab > lastTab) {
return { ...widget, parentTab: lastTab }
}
return widget
}),
}
this.closeAddWidget()
return
}
const key = 'new-' + payload.type + '-' + Date.now()
const widgetType = findWidgetType(this.widgetTypes, payload.type)
const size = { gridWidth: widgetType?.defaultWidth ?? 6, gridHeight: widgetType?.defaultHeight ?? 3 }
const parent = this.targetTab ? { parentKey: this.targetTab.parentKey, parentTab: this.targetTab.tab } : { parentKey: null, parentTab: null }
this.draftGrid = {
widgets: [...this.draftGrid.widgets, { key, id: null, ...payload }],
widgets: [...this.draftGrid.widgets, { key, id: null, ...payload, ...parent }],
layout: [...this.draftGrid.layout, {
widgetId: key,
gridX: 0,
gridY: nextFreeRow(this.draftGrid.layout),
gridY: nextFreeRow(levelLayout(this.draftGrid, parent.parentKey, parent.parentTab)),
gridWidth: size.gridWidth,
gridHeight: size.gridHeight,
}],
Expand Down Expand Up @@ -354,14 +396,17 @@ export default {
}
},
/**
* Removes a widget and its place on the grid.
* Removes a widget and its place on the grid; a tabs widget takes the widgets in its tabs along.
*
* @param {object} widget the widget to remove
*/
removeWidget(widget) {
const removed = new Set(this.draftGrid.widgets
.filter(item => item.key === widget.key || item.parentKey === widget.key)
.map(item => item.key))
this.draftGrid = {
widgets: this.draftGrid.widgets.filter(item => item.key !== widget.key),
layout: this.draftGrid.layout.filter(item => item.widgetId !== widget.key),
widgets: this.draftGrid.widgets.filter(item => !removed.has(item.key)),
layout: this.draftGrid.layout.filter(item => !removed.has(item.widgetId)),
}
},
/**
Expand Down
Loading
Loading