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
39 changes: 39 additions & 0 deletions cypress/component/contextLayout.cy.js
Original file line number Diff line number Diff line change
@@ -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 })
})
})
29 changes: 29 additions & 0 deletions playwright/e2e/application.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,35 @@
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()
// The width menu is the only menu in the card's toolbar while the layout is edited
await item.locator('.context__grid-item-toolbar').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('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')
Expand Down Expand Up @@ -148,7 +177,7 @@

const saveResponse = page.waitForResponse(response => response.url().includes('/apps/tables/api/2/contexts/') && response.request().method() === 'PUT')
await page.locator('[data-cy="context-menu-save"]').click()
expect((await saveResponse).status()).toBe(200)

Check failure on line 180 in playwright/e2e/application.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright (stable35)

[chromium] β€Ί playwright/e2e/application.spec.ts:159:2 β€Ί Applications β€Ί A menu entry is reordered with its buttons

1) [chromium] β€Ί playwright/e2e/application.spec.ts:159:2 β€Ί Applications β€Ί A menu entry is reordered with its buttons, sits under another one and keeps its place after a reload Error: expect(received).toBe(expected) // Object.is equality Expected: 200 Received: 500 178 | const saveResponse = page.waitForResponse(response => response.url().includes('/apps/tables/api/2/contexts/') && response.request().method() === 'PUT') 179 | await page.locator('[data-cy="context-menu-save"]').click() > 180 | expect((await saveResponse).status()).toBe(200) | ^ 181 | 182 | await page.reload() 183 | const pages = page.locator('[data-cy="page-card"]') at /home/runner/work/tables/tables/playwright/e2e/application.spec.ts:180:41

await page.reload()
const pages = page.locator('[data-cy="page-card"]')
Expand Down
1 change: 1 addition & 0 deletions playwright/e2e/context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
const updateContextReq = page.waitForResponse(r => r.url().includes('/apps/tables/') && r.request().method() === 'PUT')
await page.locator('[data-cy="editContextSubmitBtn"]').click()
await updateContextReq
await expect(page.getByRole('dialog', { name: 'Edit application' })).toBeHidden({ timeout: 10000 })

Check failure on line 106 in playwright/e2e/context.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright (stable34)

[chromium] β€Ί playwright/e2e/context.spec.ts:76:2 β€Ί Manage a context β€Ί Update and add resources

2) [chromium] β€Ί playwright/e2e/context.spec.ts:76:2 β€Ί Manage a context β€Ί Update and add resources Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeHidden() failed Locator: getByRole('dialog', { name: 'Edit application' }) Expected: hidden Received: visible Timeout: 10000ms Call log: - Expect "toBeHidden" getByRole('dialog', { name: 'Edit application' }) with timeout 10000ms - waiting for getByRole('dialog', { name: 'Edit application' }) 24 Γ— locator resolved to <div role="dialog" tabindex="-1" aria-modal="true" data-v-c6039372="" data-v-adda232f="" data-v-8bc7bdfa="" data-v-owner="803" data-cy="editContextModal" aria-labelledby="nc-vue-127" class="dialog__modal modal-mask" aria-describedby="modal-description-nc-vue-128">…</div> - unexpected value "visible" 104 | await page.locator('[data-cy="editContextSubmitBtn"]').click() 105 | await updateContextReq > 106 | await expect(page.getByRole('dialog', { name: 'Edit application' })).toBeHidden({ timeout: 10000 }) | ^ 107 | 108 | await expect(page.locator('[data-cy="navigationContextItem"]').filter({ hasText: `updated ${contextTitle}` })).toBeVisible() 109 | await openApplication(page, `updated ${contextTitle}`) at /home/runner/work/tables/tables/playwright/e2e/context.spec.ts:106:72

Check failure on line 106 in playwright/e2e/context.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright (stable34)

[chromium] β€Ί playwright/e2e/context.spec.ts:76:2 β€Ί Manage a context β€Ί Update and add resources

1) [chromium] β€Ί playwright/e2e/context.spec.ts:76:2 β€Ί Manage a context β€Ί Update and add resources Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeHidden() failed Locator: getByRole('dialog', { name: 'Edit application' }) Expected: hidden Received: visible Timeout: 10000ms Call log: - Expect "toBeHidden" getByRole('dialog', { name: 'Edit application' }) with timeout 10000ms - waiting for getByRole('dialog', { name: 'Edit application' }) 24 Γ— locator resolved to <div role="dialog" tabindex="-1" aria-modal="true" data-v-c6039372="" data-v-adda232f="" data-v-8bc7bdfa="" data-v-owner="803" data-cy="editContextModal" aria-labelledby="nc-vue-127" class="dialog__modal modal-mask" aria-describedby="modal-description-nc-vue-128">…</div> - unexpected value "visible" 104 | await page.locator('[data-cy="editContextSubmitBtn"]').click() 105 | await updateContextReq > 106 | await expect(page.getByRole('dialog', { name: 'Edit application' })).toBeHidden({ timeout: 10000 }) | ^ 107 | 108 | await expect(page.locator('[data-cy="navigationContextItem"]').filter({ hasText: `updated ${contextTitle}` })).toBeVisible() 109 | await openApplication(page, `updated ${contextTitle}`) at /home/runner/work/tables/tables/playwright/e2e/context.spec.ts:106:72

await expect(page.locator('[data-cy="navigationContextItem"]').filter({ hasText: `updated ${contextTitle}` })).toBeVisible()
await openApplication(page, `updated ${contextTitle}`)
Expand All @@ -130,7 +130,7 @@
const updateResponse = page.waitForResponse(r => r.url().includes('/apps/tables/') && r.request().method() === 'PUT')
await page.locator('[data-cy="editContextSubmitBtn"]').click()
await updateResponse
await expect(page.locator('[class*="toast_success"]').first()).toBeVisible({ timeout: 10000 })

Check failure on line 133 in playwright/e2e/context.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright (stable34)

[chromium] β€Ί playwright/e2e/context.spec.ts:115:2 β€Ί Manage a context β€Ί Share context with resources

3) [chromium] β€Ί playwright/e2e/context.spec.ts:115:2 β€Ί Manage a context β€Ί Share context with resources Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeVisible() failed Locator: locator('[class*="toast_success"]').first() Expected: visible Timeout: 10000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('[class*="toast_success"]').first() with timeout 10000ms - waiting for locator('[class*="toast_success"]').first() 131 | await page.locator('[data-cy="editContextSubmitBtn"]').click() 132 | await updateResponse > 133 | await expect(page.locator('[class*="toast_success"]').first()).toBeVisible({ timeout: 10000 }) | ^ 134 | 135 | await openApplication(page, contextTitle) 136 | await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) at /home/runner/work/tables/tables/playwright/e2e/context.spec.ts:133:66

await openApplication(page, contextTitle)
await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 })
Expand Down Expand Up @@ -233,7 +233,7 @@

await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible()
await openApplication(page, contextTitle)
await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 })

Check failure on line 236 in playwright/e2e/context.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright (stable34)

[chromium] β€Ί playwright/e2e/context.spec.ts:219:2 β€Ί Manage a context β€Ί Remove context resource

4) [chromium] β€Ί playwright/e2e/context.spec.ts:219:2 β€Ί Manage a context β€Ί Remove context resource Error: expect(locator).toBeVisible() failed Locator: locator('h1').filter({ hasText: 'test table resource' }) Expected: visible Timeout: 10000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('h1').filter({ hasText: 'test table resource' }) with timeout 10000ms - waiting for locator('h1').filter({ hasText: 'test table resource' }) 234 | await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() 235 | await openApplication(page, contextTitle) > 236 | await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) | ^ 237 | 238 | await page.goto('/index.php/apps/tables') 239 | await loadContext(page, contextTitle) at /home/runner/work/tables/tables/playwright/e2e/context.spec.ts:236:61

await page.goto('/index.php/apps/tables')
await loadContext(page, contextTitle)
Expand All @@ -247,6 +247,7 @@
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()
})

Expand Down
191 changes: 187 additions & 4 deletions src/pages/Context.vue
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,52 @@
</section>
</div>

<div v-if="!selectedMenuItem && isStandalone" class="resources">
<div v-for="resource in contextResources" :key="resource.key">
<div v-if="!selectedMenuItem && isStandalone" class="context__layout-toolbar" data-cy="context-layout-toolbar">

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟒 Minor β€” "Edit layout" shows when there is nothing to lay out

The toolbar also renders when contextResources is empty, for example an application with only grid pages.

Impact: an edit button over an empty page.

Suggested fix: add && contextResources.length > 0.

<template v-if="isEditingLayout">
<NcButton v-if="hasCustomLayout" variant="tertiary" data-cy="context-layout-reset" @click="draftLayout = {}">
<template #icon>
<Restore :size="20" />
</template>
{{ t('tables', 'Reset layout') }}
</NcButton>
<NcButton variant="tertiary" data-cy="context-layout-cancel" @click="draftLayout = null">
{{ t('tables', 'Cancel') }}
</NcButton>
<NcButton variant="primary" data-cy="context-layout-save" @click="saveLayout">
<template #icon>
<ContentSaveOutline :size="20" />
</template>
{{ t('tables', 'Save') }}
</NcButton>
</template>
<NcButton v-else variant="secondary" data-cy="context-layout-edit" @click="draftLayout = { ...layout }">
<template #icon>
<PencilOutline :size="20" />
</template>
{{ t('tables', 'Edit layout') }}
</NcButton>
</div>
<div v-if="!selectedMenuItem && isStandalone" class="context__grid" :class="{ 'context__grid--editing': isEditingLayout }">
<section v-for="resource in contextResources"
:key="resource.key"
class="context__grid-item"
:style="getGridCellStyle(getResourceSpan(resource))"
data-cy="context-grid-item">
<div v-if="isEditingLayout" class="context__grid-item-toolbar">
<NcActions :menu-name="t('tables', 'Width')" :aria-label="t('tables', 'Width of {title}', { title: resource.title }, undefined, { escape: false })">
<template #icon>
<TableColumnWidth :size="20" />
</template>
<NcActionRadio v-for="option in widthOptions"
:key="option.span"
:name="'context-width-' + resource.key"
:value="option.span"
:model-value="getResourceSpan(resource)"
@update:model-value="span => setResourceSpan(resource, span)">
{{ option.label }}
</NcActionRadio>
</NcActions>
</div>
<div v-if="!resource.isView" class="resource">
<TableWrapper v-model:view-setting="resourceViewSettings[resource.key]" :table="resource" :columns="columns[resource.key]"
:rows="rows[resource.key]" @create-column="createColumn(false, resource)"
Expand All @@ -384,7 +428,7 @@
@import="openImportModal(resource, true)" @download-csv="downloadCSV(resource, true)"
@download-filtered-csv="rows => downloadFilteredCSV(rows, resource, true)" />
</div>
</div>
</section>
</div>
</div>

Expand All @@ -397,7 +441,7 @@
<script>
import MainModals from '../modules/modals/Modals.vue'
import { mapState, mapActions, storeToRefs } from 'pinia'
import { NcActionButton, NcActionSeparator, NcActions, NcAvatar, NcButton, NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon, NcTextField } from '@nextcloud/vue'
import { NcActionButton, NcActionRadio, NcActionSeparator, NcActions, NcAvatar, NcButton, NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon, NcTextField } from '@nextcloud/vue'
import { getCurrentUser } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { generateUrl } from '@nextcloud/router'
Expand All @@ -410,15 +454,18 @@ import LinkVariant from 'vue-material-design-icons/LinkVariant.vue'
import OpenInNew from 'vue-material-design-icons/OpenInNew.vue'
import PencilOutline from 'vue-material-design-icons/PencilOutline.vue'
import Plus from 'vue-material-design-icons/Plus.vue'
import Restore from 'vue-material-design-icons/Restore.vue'
import SwapHorizontal from 'vue-material-design-icons/SwapHorizontal.vue'
import TableIcon from 'vue-material-design-icons/Table.vue'
import TableColumnWidth from 'vue-material-design-icons/TableColumnWidth.vue'
import TrayArrowDown from 'vue-material-design-icons/TrayArrowDown.vue'
import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
import GridView from '../modules/grid/GridView.vue'
import MenuItemIcon from '../shared/components/MenuItemIcon.vue'
import MenuItemsEditor from '../shared/components/ncContextResource/MenuItemsEditor.vue'
import permissionsMixin from '../shared/components/ncTable/mixins/permissionsMixin.js'
import { useEventBusSubscriptions } from '../shared/composables/useEventBusSubscriptions.js'
import { GRID_COLUMNS, getGridCellStyle, loadContextLayout, saveContextLayout } from '../shared/utils/contextLayout.js'
import { areMenuItemsValid, menuItemRoute, menuTree, toEditableMenuItems, toMenuItemPayload } from '../shared/utils/menuItems.js'
import TableWrapper from '../modules/main/sections/TableWrapper.vue'
import CustomView from '../modules/main/sections/View.vue'
Expand All @@ -436,6 +483,7 @@ export default {
components: {
MainModals,
NcActionButton,
NcActionRadio,
NcActionSeparator,
NcActions,
NcAvatar,
Expand All @@ -453,7 +501,9 @@ export default {
OpenInNew,
PencilOutline,
Plus,
Restore,
SwapHorizontal,
TableColumnWidth,
TableIcon,
TrayArrowDown,
ViewDashboardOutline,
Expand Down Expand Up @@ -488,6 +538,9 @@ export default {
menuDraft: null,
menuSaving: false,
pageFilter: '',
// The user's own widths of the tables and views, and the ones being edited
layout: {},
draftLayout: null,
standaloneContextId: loadState('tables', 'contextId', null),
openedFirstPage: false,
}
Expand Down Expand Up @@ -565,6 +618,23 @@ export default {
return { ...page, kind: 'view', typeLabel: t('tables', 'View'), description, view }
})
},
isEditingLayout() {
return this.draftLayout !== null
},
activeLayout() {
return this.draftLayout ?? this.layout
},
hasCustomLayout() {
return Object.keys(this.activeLayout).length > 0
},
widthOptions() {
return [
{ span: 12, label: t('tables', 'Full width') },
{ span: 8, label: t('tables', 'Two thirds') },
{ span: 6, label: t('tables', 'Half') },
{ span: 4, label: t('tables', 'One third') },
]
},
filteredPages() {
const query = this.pageFilter.trim().toLowerCase()
if (query === '') {
Expand Down Expand Up @@ -735,6 +805,33 @@ export default {
methods: {
...mapActions(useTablesStore, ['loadContext', 'validateExportAccess', 'loadContextTable', 'loadContextView', 'updateContextMenuItems']),
...mapActions(useDataStore, ['loadColumnsFromBE', 'loadRowsFromBE', 'loadRelationsFromBE']),
getGridCellStyle,
getResourceSpan(resource) {
return this.activeLayout[resource.key] ?? GRID_COLUMNS
},
setResourceSpan(resource, span) {
const draftLayout = { ...this.activeLayout }
if (span === GRID_COLUMNS) {
delete draftLayout[resource.key]
} else {
draftLayout[resource.key] = span
}
this.draftLayout = draftLayout
},
saveLayout() {
if (this.draftLayout === null) {
return
}
// The layout is the user's own arrangement and saves once, when editing ends
this.layout = this.draftLayout
this.draftLayout = null
try {
saveContextLayout(this.activeContextId, this.layout)
} catch (error) {
console.error('The application layout could not be stored', error)
showError(t('tables', 'Your layout applies to this visit only, as this browser does not allow storing it.'))
}
},
async copyAddress() {
try {
await navigator.clipboard.writeText(this.applicationUrl)
Expand Down Expand Up @@ -892,6 +989,8 @@ export default {
this.loading = true
this.contextResources = []
this.resourceViewSettings = {}
this.layout = loadContextLayout(this.activeContextId)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟒 Minor β€” any reload throws away a layout edit in progress

reload() sets draftLayout = null on the next line, and it runs on every context-signature change (for example a resource added in another tab).

Impact: unsaved widths are lost without notice.

Suggested fix: only reset the draft when the application id changes.

this.draftLayout = null

try {
await this.loadContext({ id: this.activeContextId })
Expand Down Expand Up @@ -1395,6 +1494,90 @@ export default {
}
}

.context__layout-toolbar {
display: flex;
justify-content: flex-end;
gap: calc(2 * var(--default-grid-baseline));
width: var(--app-content-width, 100%);
padding-block: calc(3 * var(--default-grid-baseline)) 0;
padding-inline: calc(var(--default-clickable-area) + 4 * var(--default-grid-baseline)) calc(4 * var(--default-grid-baseline));
}

// A 12-column grid of the tables and views, collapsing to 6 columns and then to one on smaller screens
.context__grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: calc(4 * var(--default-grid-baseline));
width: var(--app-content-width, 100%);
padding: calc(4 * var(--default-grid-baseline));
}

.context__grid-item {
overflow-x: auto;
grid-column: span var(--context-grid-span, 12);
min-width: 0;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
background-color: var(--color-main-background);
box-shadow: 0 2px 4px var(--color-box-shadow);

// A table or view fits its card instead of the whole page
.resource {
width: auto;
min-width: 0;
margin: 0;

&:deep(.row.first-row),
&:deep(.row.space-T),
&:deep(.options.row),
&:deep(.options .sticky) {
position: static;
width: auto;
}

&:deep(.row.first-row) {
padding-inline-start: calc(4 * var(--default-grid-baseline));
}

// The sticky table header sticks to the top of its card, not below the page header
&:deep(table thead) {
top: 0;
}
}
}

.context__grid--editing .context__grid-item {
outline: 2px dashed var(--color-border-dark);
outline-offset: 2px;
}

.context__grid-item-toolbar {
display: flex;
justify-content: flex-end;
padding: calc(2 * var(--default-grid-baseline));
border-bottom: 1px solid var(--color-border);
}

@media (max-width: 900px) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟒 Minor β€” the breakpoints follow the viewport, not the space the grid actually has

Opened on its own, the application keeps its navigation (about 300 px) open. At a 1100 px viewport the grid has about 800 px and still gets 12 columns, so "One third" cards hold full tables in about 250 px.

Impact: cramped cards while the screen counts as wide.

Suggested fix: container-type: inline-size on .context__grid, with @container rules instead of @media.

.context__grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}

.context__grid-item {
grid-column: span var(--context-grid-span-medium, 6);
}
}

@media (max-width: 600px) {
.context__grid {
grid-template-columns: minmax(0, 1fr);
}

.context__grid-item {
grid-column: 1 / -1;
}
}

.context--standalone {
width: 100%;

Expand Down
Loading
Loading