Skip to content
Closed
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
48 changes: 48 additions & 0 deletions cypress/component/contextLayout.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { applyPersonalWidths, loadContextLayout, saveContextLayout } from '../../src/shared/utils/contextLayout.js'

describe('application layout', () => {
beforeEach(() => {
window.localStorage.clear()
})

it('keeps a saved layout per application and page', () => {
saveContextLayout(7, 3, { 'content-4': 6, 'content-9': 4 })

expect(loadContextLayout(7, 3)).to.deep.equal({ 'content-4': 6, 'content-9': 4 })
expect(loadContextLayout(7, 5)).to.deep.equal({})
expect(loadContextLayout(8, 3)).to.deep.equal({})
})

it('resets to the page as arranged when an empty layout is saved', () => {
saveContextLayout(7, 3, { 'content-4': 6 })
saveContextLayout(7, 3, {})

expect(loadContextLayout(7, 3)).to.deep.equal({})
})

it('drops widths it does not know and survives a broken entry', () => {
saveContextLayout(7, 3, { a: 6, b: 5, c: '12' })
expect(loadContextLayout(7, 3)).to.deep.equal({ a: 6 })

const key = Object.keys(window.localStorage).find(name => name.endsWith(':7:3'))
window.localStorage.setItem(key, '{not json')
expect(loadContextLayout(7, 3)).to.deep.equal({})
})

it('applies the widths to the layout and keeps a widened widget on the grid', () => {
const layout = [
{ widgetId: 'a', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 5 },
{ widgetId: 'b', gridX: 8, gridY: 5, gridWidth: 4, gridHeight: 5 },
]

const shown = applyPersonalWidths(layout, { a: 6, b: 8 })

expect(shown[0]).to.deep.include({ gridX: 0, gridWidth: 6 })
expect(shown[1]).to.deep.include({ gridX: 4, gridWidth: 8 })
expect(applyPersonalWidths(layout, {})).to.deep.equal(layout)
})
})
29 changes: 29 additions & 0 deletions playwright/e2e/application.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
await page.getByRole('menuitem', { name: 'Edit name, icon and members' }).click()
await page.locator('[data-cy="editContextTechnicalName"]').fill(technicalName)
await page.locator('[data-cy="editContextSubmitBtn"]').click()
await expect(page.locator('[data-cy="context-address"]')).toContainText('/apps/tables/app/' + technicalName)

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:169:2 › Applications › An application with a technical name is opened at its readable address

1) [chromium] › playwright/e2e/application.spec.ts:169:2 › Applications › An application with a technical name is opened at its readable address Error: expect(locator).toContainText(expected) failed Locator: locator('[data-cy="context-address"]') Expected substring: "/apps/tables/app/intake_1792090023286" Received string: "http://localhost:8089/index.php/apps/tables/app/7" Timeout: 30000ms Call log: - Expect "toContainText" locator('[data-cy="context-address"]') with timeout 30000ms - waiting for locator('[data-cy="context-address"]') 63 × locator resolved to <a rel="noopener" target="_blank" data-v-364abf28="" class="overview__address" data-cy="context-address" href="http://localhost:8089/index.php/apps/tables/app/7">http://localhost:8089/index.php/apps/tables/app/7</a> - unexpected value "http://localhost:8089/index.php/apps/tables/app/7" 178 | await page.locator('[data-cy="editContextTechnicalName"]').fill(technicalName) 179 | await page.locator('[data-cy="editContextSubmitBtn"]').click() > 180 | await expect(page.locator('[data-cy="context-address"]')).toContainText('/apps/tables/app/' + technicalName) | ^ 181 | 182 | await page.goto('/index.php/apps/tables/app/' + technicalName) 183 | await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('named application') at /home/runner/work/tables/tables/playwright/e2e/application.spec.ts:180:61

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

View workflow job for this annotation

GitHub Actions / Playwright (stable34)

[chromium] › playwright/e2e/application.spec.ts:169:2 › Applications › An application with a technical name is opened at its readable address

1) [chromium] › playwright/e2e/application.spec.ts:169:2 › Applications › An application with a technical name is opened at its readable address Error: expect(locator).toContainText(expected) failed Locator: locator('[data-cy="context-address"]') Expected substring: "/apps/tables/app/intake_1792090130255" Received string: "http://localhost:8089/index.php/apps/tables/app/7" Timeout: 30000ms Call log: - Expect "toContainText" locator('[data-cy="context-address"]') with timeout 30000ms - waiting for locator('[data-cy="context-address"]') 63 × locator resolved to <a rel="noopener" target="_blank" data-v-364abf28="" class="overview__address" data-cy="context-address" href="http://localhost:8089/index.php/apps/tables/app/7">http://localhost:8089/index.php/apps/tables/app/7</a> - unexpected value "http://localhost:8089/index.php/apps/tables/app/7" 178 | await page.locator('[data-cy="editContextTechnicalName"]').fill(technicalName) 179 | await page.locator('[data-cy="editContextSubmitBtn"]').click() > 180 | await expect(page.locator('[data-cy="context-address"]')).toContainText('/apps/tables/app/' + technicalName) | ^ 181 | 182 | await page.goto('/index.php/apps/tables/app/' + technicalName) 183 | await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('named application') at /home/runner/work/tables/tables/playwright/e2e/application.spec.ts:180:61

await page.goto('/index.php/apps/tables/app/' + technicalName)
await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('named application')
Expand All @@ -197,6 +197,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: '/index.php/apps/tables/api/1/tables', data: { title: 'Layout table' } })
const table = await tableResponse.json()
const contextResponse = await ocsRequest(page.request, user, {
method: 'POST',
url: `${API}/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="gridItem"]')
await expect(item).toHaveCount(1, { timeout: 30000 })
await expect(item).toHaveAttribute('gs-w', '12')

await page.locator('[data-cy="context-layout-edit"]').click()
await item.locator('[data-cy="gridWidgetWidth"]').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('gs-w', '6')

await page.reload()
await expect(page.locator('[data-cy="gridItem"]')).toHaveAttribute('gs-w', '6', { timeout: 30000 })
// The page as arranged for everyone keeps its width
const stored = await getApplication(page.request, user, context.id)
const startPage = Object.values(stored.pages as Record<string, { page_type: string, content: Record<string, { grid_width: number }> }>).find(entry => entry.page_type === 'startpage')
expect(Object.values(startPage!.content).map(entry => entry.grid_width)).toEqual([12])
})

test('A page with widgets added on the overview gets a menu entry and opens in edit mode', async ({ userPage: { page } }) => {
await page.goto('/index.php/apps/tables')
await createContext(page, 'overview application')
Expand All @@ -219,7 +248,7 @@
await page.locator('[data-cy="context-menu-overview"]').click()
const intake = page.locator('[data-cy="page-card"]').filter({ hasText: 'Intake home' })
await expect(intake).toContainText('Page with widgets')
await expect(page.locator('[data-cy="context-menu-preview-item"]').filter({ hasText: 'Intake home' })).toBeVisible()

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

View workflow job for this annotation

GitHub Actions / Playwright (stable33)

[chromium] › playwright/e2e/application.spec.ts:229:2 › Applications › A page with widgets added on the overview gets a menu entry and opens in edit mode

1) [chromium] › playwright/e2e/application.spec.ts:229:2 › Applications › A page with widgets added on the overview gets a menu entry and opens in edit mode Error: expect(locator).toBeVisible() failed Locator: locator('[data-cy="context-menu-preview-item"]').filter({ hasText: 'Intake home' }) Expected: visible Timeout: 30000ms Error: element(s) not found Call log: - Expect "toBeVisible" locator('[data-cy="context-menu-preview-item"]').filter({ hasText: 'Intake home' }) with timeout 30000ms - waiting for locator('[data-cy="context-menu-preview-item"]').filter({ hasText: 'Intake home' }) 249 | const intake = page.locator('[data-cy="page-card"]').filter({ hasText: 'Intake home' }) 250 | await expect(intake).toContainText('Page with widgets') > 251 | await expect(page.locator('[data-cy="context-menu-preview-item"]').filter({ hasText: 'Intake home' })).toBeVisible() | ^ 252 | }) 253 | 254 | test('A menu entry is reordered with its buttons, sits under another one and keeps its place after a reload', async ({ userPage: { page } }) => { at /home/runner/work/tables/tables/playwright/e2e/application.spec.ts:251:106
})

test('A menu entry is reordered with its buttons, sits under another one and keeps its place after a reload', async ({ userPage: { page } }) => {
Expand Down
126 changes: 116 additions & 10 deletions src/modules/grid/GridPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,26 @@
-->
<template>
<div class="grid-page" data-cy="gridPage">
<div v-if="canEdit" class="grid-page__header">
<div v-if="canEdit || personalLayout" class="grid-page__header">
<div class="grid-page__actions">
<template v-if="isEditing">
<template v-if="isEditingWidths">
<NcButton v-if="Object.keys(widthDraft).length > 0" variant="tertiary" data-cy="context-layout-reset" @click="widthDraft = {}">
<template #icon>
<Restore :size="20" />
</template>
{{ t('tables', 'Reset layout') }}
</NcButton>
<NcButton variant="tertiary" data-cy="context-layout-cancel" @click="widthDraft = null">
{{ t('tables', 'Cancel') }}
</NcButton>
<NcButton variant="primary" data-cy="context-layout-save" @click="saveWidths">
<template #icon>
<ContentSaveOutline :size="20" />
</template>
{{ t('tables', 'Save') }}
</NcButton>
</template>
<template v-else-if="isEditing">
<NcButton variant="secondary" data-cy="gridPageAddWidget" @click="openAddWidget">
<template #icon>
<Plus :size="20" />
Expand All @@ -23,12 +40,23 @@
{{ t('tables', 'Save') }}
</NcButton>
</template>
<NcButton v-else variant="secondary" data-cy="gridPageEdit" @click="startEditing">
<template #icon>
<PencilOutline :size="20" />
</template>
{{ t('tables', 'Edit page') }}
</NcButton>
<template v-else>
<NcButton v-if="personalLayout && shownLayout.length > 0"
variant="tertiary"
data-cy="context-layout-edit"
@click="widthDraft = { ...widths }">
<template #icon>
<TableColumnWidth :size="20" />
</template>
{{ t('tables', 'Edit layout') }}
</NcButton>
<NcButton v-if="canEdit" variant="secondary" data-cy="gridPageEdit" @click="startEditing">
<template #icon>
<PencilOutline :size="20" />
</template>
{{ t('tables', 'Edit page') }}
</NcButton>
</template>
</div>
</div>

Expand All @@ -46,13 +74,16 @@
</template>
</NcEmptyContent>

<GridCanvas :layout="activeGrid.layout" :editable="isEditing" @layout-change="onLayoutChange">
<GridCanvas :layout="shownLayout" :editable="isEditing" @layout-change="onLayoutChange">
<template #widget="{ item, narrow }">
<GridWidget v-if="widgetsById[item.widgetId]"
:widget="widgetsById[item.widgetId]"
:layout-item="item"
:editable="isEditing"
:arrangeable="!narrow"
:width-options="isEditingWidths ? widthOptions : null"
:width="item.gridWidth"
@set-width="setWidth"
@configure="configureWidget"
@remove="removeWidget"
@move="moveWidget" />
Expand All @@ -68,10 +99,12 @@

<script>
import { NcButton, NcEmptyContent } from '@nextcloud/vue'
import { showConfirmation, showSuccess } from '@nextcloud/dialogs'
import { showConfirmation, showError, showSuccess } from '@nextcloud/dialogs'
import ContentSaveOutline from 'vue-material-design-icons/ContentSaveOutline.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 TableColumnWidth from 'vue-material-design-icons/TableColumnWidth.vue'
import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue'
import { computed, ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
Expand All @@ -80,6 +113,7 @@ import GridCanvas from './GridCanvas.vue'
import GridWidget from './GridWidget.vue'
import AddWidget from '../modals/AddWidget.vue'
import { useTablesStore } from '../../store/store.js'
import { applyPersonalWidths, loadContextLayout, saveContextLayout } from '../../shared/utils/contextLayout.js'
import { GRID_COLUMNS, GRID_MIN_WIDTH, cloneGrid, findWidgetType, gridToWidgets, nextFreeRow, pageToGrid } from './gridLayout.js'

/**
Expand All @@ -94,6 +128,8 @@ export default {
ContentSaveOutline,
PencilOutline,
Plus,
Restore,
TableColumnWidth,
ViewDashboardOutline,
GridCanvas,
GridWidget,
Expand All @@ -118,6 +154,11 @@ export default {
type: Boolean,
default: false,
},
// Lets the user choose their own widths of the widgets, kept in this browser
personalLayout: {
type: Boolean,
default: false,
},
},
emits: ['saved'],
setup(props) {
Expand Down Expand Up @@ -159,6 +200,9 @@ export default {
saving: false,
addWidgetOpen: false,
widgetBeingConfigured: null,
// The user's own widths of the widgets, and the ones being edited
widths: {},
widthDraft: null,
}
},
computed: {
Expand All @@ -169,13 +213,37 @@ export default {
isEditing() {
return this.draftGrid !== null
},
isEditingWidths() {
return this.widthDraft !== null
},
/**
* The layout on screen: the draft while the page is edited, else the page as arranged with
* the user's own widths.
*
* @return {Array}
*/
shownLayout() {
if (this.isEditing || !this.personalLayout) {
return this.activeGrid.layout
}
return applyPersonalWidths(this.storedGrid.layout, this.widthDraft ?? this.widths)
},
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') },
]
},
widgetsById() {
return Object.fromEntries(this.activeGrid.widgets.map(widget => [widget.id, widget]))
},
},
watch: {
'page.id'() {
this.draftGrid = null
this.loadWidths()
},
startInEditMode(startInEditMode) {
if (startInEditMode && this.canEdit && !this.isEditing) {
Expand All @@ -184,6 +252,7 @@ export default {
},
},
mounted() {
this.loadWidths()
window.addEventListener('beforeunload', this.warnBeforeUnload)
if (this.startInEditMode && this.canEdit) {
this.startEditing()
Expand All @@ -204,6 +273,43 @@ export default {
event.preventDefault()
}
},
loadWidths() {
this.widths = this.personalLayout ? loadContextLayout(this.context.id, this.page.id) : {}
this.widthDraft = null
},
/**
* Sets the user's own width of one widget; the full width of the page as arranged is no choice
* of its own, so it is not kept.
*
* @param {string} widgetId the widget
* @param {number} span the number of columns
*/
setWidth(widgetId, span) {
const draft = { ...this.widthDraft }
const arranged = this.storedGrid.layout.find(item => item.widgetId === widgetId)
if (arranged && arranged.gridWidth === span) {
delete draft[widgetId]
} else {
draft[widgetId] = span
}
this.widthDraft = draft
},
/**
* Keeps the widths being edited for this user in this browser.
*/
saveWidths() {
if (this.widthDraft === null) {
return
}
this.widths = this.widthDraft
this.widthDraft = null
try {
saveContextLayout(this.context.id, this.page.id, this.widths)
} 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.'))
}
},
/**
* Starts editing on a copy of the stored grid.
*/
Expand Down
39 changes: 37 additions & 2 deletions src/modules/grid/GridWidget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@
</NcActionButton>
</NcActions>
</header>
<div v-if="widthOptions" class="grid-widget__width" data-cy="gridWidgetWidth">
<NcActions :menu-name="t('tables', 'Width')" :aria-label="t('tables', 'Width of {title}', { title: label }, undefined, { escape: false })">
<template #icon>
<TableColumnWidth :size="20" />
</template>
<NcActionRadio v-for="option in widthOptions"
:key="option.span"
:name="'grid-widget-width-' + widget.id"
:value="option.span"
:model-value="width"
@update:model-value="span => $emit('set-width', widget.id, span)">
{{ option.label }}
</NcActionRadio>
</NcActions>
</div>
<div class="grid-widget__body">
<component :is="widgetComponent" v-if="widgetComponent" v-bind="componentProps" />
<p v-else class="grid-widget__unknown">
Expand All @@ -48,7 +63,7 @@

<script>
import { computed } from 'vue'
import { NcActionButton, NcActions, NcActionSeparator } from '@nextcloud/vue'
import { NcActionButton, NcActionRadio, NcActions, NcActionSeparator } from '@nextcloud/vue'
import ArrowCollapseHorizontal from 'vue-material-design-icons/ArrowCollapseHorizontal.vue'
import ArrowCollapseVertical from 'vue-material-design-icons/ArrowCollapseVertical.vue'
import ArrowDown from 'vue-material-design-icons/ArrowDown.vue'
Expand All @@ -58,6 +73,7 @@ import ArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import ArrowUp from 'vue-material-design-icons/ArrowUp.vue'
import CogOutline from 'vue-material-design-icons/CogOutline.vue'
import TableColumnWidth from 'vue-material-design-icons/TableColumnWidth.vue'
import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import { mapActions, mapState } from 'pinia'
import { useTablesStore } from '../../store/store.js'
Expand All @@ -72,6 +88,7 @@ export default {
name: 'GridWidget',
components: {
NcActionButton,
NcActionRadio,
NcActions,
NcActionSeparator,
ArrowCollapseHorizontal,
Expand All @@ -83,6 +100,7 @@ export default {
ArrowRight,
ArrowUp,
CogOutline,
TableColumnWidth,
TrashCanOutline,
},
provide() {
Expand Down Expand Up @@ -110,8 +128,18 @@ export default {
type: Boolean,
default: true,
},
// The widths the user can choose for the widget in their own layout; null when not choosing
widthOptions: {
type: Array,
default: null,
},
// The columns the widget spans now
width: {
type: Number,
default: null,
},
},
emits: ['configure', 'remove', 'move'],
emits: ['configure', 'remove', 'move', 'set-width'],
computed: {
...mapState(useTablesStore, ['widgetTypes']),
hasHeading() {
Expand Down Expand Up @@ -188,6 +216,13 @@ export default {
white-space: nowrap;
}

&__width {
display: flex;
justify-content: flex-end;
padding: var(--default-grid-baseline);
border-bottom: 1px solid var(--color-border);
}

&__body {
flex: 1;
min-height: 0;
Expand Down
Loading
Loading