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
205 changes: 205 additions & 0 deletions playwright/e2e/context-grid-pages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { type APIRequestContext, type Page } from '@playwright/test'
import { test, expect } from '../support/fixtures'
import { createRandomUser, ocsRequest, type TestUser } from '../support/api'
import { login } from '../support/login'

// The app navigation takes width from the canvas; the grid only arranges at 12 columns
test.use({ viewport: { width: 1600, height: 1000 } })

const API = '/ocs/v2.php/apps/tables/api/2'

async function createTableViaApi(request: APIRequestContext, user: TestUser, title: string): Promise<number> {
const res = await ocsRequest(request, user, { method: 'POST', url: '/index.php/apps/tables/api/1/tables', data: { title } })
return (await res.json()).id
}

async function createApplicationViaApi(request: APIRequestContext, user: TestUser, name: string, tableIds: number[]): Promise<number> {
const res = await ocsRequest(request, user, {
method: 'POST',
url: `${API}/contexts?format=json`,
data: { name, iconName: 'equalizer', description: '', nodes: tableIds.map(id => ({ id, type: 0, permissions: 1 })) },
})
return (await res.json()).ocs.data.id
}

async function getApplication(request: APIRequestContext, user: TestUser, contextId: number) {
const res = await ocsRequest(request, user, { method: 'GET', url: `${API}/contexts/${contextId}?format=json` })
return (await res.json()).ocs.data
}

async function createPageViaApi(request: APIRequestContext, user: TestUser, contextId: number, title: string): Promise<number> {
const res = await ocsRequest(request, user, { method: 'POST', url: `${API}/contexts/${contextId}/pages?format=json`, data: { title } })
return (await res.json()).ocs.data.id
}

async function openApplication(page: Page, contextId: number, path = '') {
await page.goto(`/index.php/apps/tables/#/application/${contextId}${path}`)
await expect(page.locator('[data-cy="contextPageBar"]')).toBeVisible({ timeout: 30000 })
}

async function pickOption(page: Page, selectCy: string, option: string) {
await page.locator(`[data-cy="${selectCy}"] input`).click()
await page.locator('.vs__dropdown-menu li').filter({ hasText: option }).click()
}

test.describe('Grid pages of an application', () => {
test('The start page lists the tables as before, with the page bar under the description', async ({ userPage: { page, user } }) => {
const first = await createTableViaApi(page.request, user, 'grid first table')
const second = await createTableViaApi(page.request, user, 'grid second table')
const contextId = await createApplicationViaApi(page.request, user, 'grid existing app', [first, second])

await openApplication(page, contextId)

const firstHeading = page.locator('h1', { hasText: 'grid first table' })
const secondHeading = page.locator('h1', { hasText: 'grid second table' })
await expect(firstHeading).toBeVisible()
await expect(secondHeading).toBeVisible()
expect((await firstHeading.boundingBox())!.y).toBeLessThan((await secondHeading.boundingBox())!.y)
await expect(page.locator('[data-cy="contextStartPageLink"]')).toHaveAttribute('aria-current', 'page')
await expect(page.locator('[data-cy="contextPageAdd"]')).toBeVisible()
await expect(page.locator('[data-cy="gridPage"]')).toHaveCount(0)
})

test('A new page holds a header and a table of the application after a reload', async ({ userPage: { page, user } }) => {
const inApp = await createTableViaApi(page.request, user, 'grid table in app')
await createTableViaApi(page.request, user, 'grid table outside app')
const contextId = await createApplicationViaApi(page.request, user, 'grid new page app', [inApp])
await openApplication(page, contextId)

await page.locator('[data-cy="contextPageAdd"]').click()
const pageForm = page.locator('[data-cy="contextPageForm"]')
await pageForm.getByRole('textbox', { name: 'Title' }).fill('Overview')
await pageForm.getByRole('textbox', { name: 'Description' }).fill('Open work at a glance')
await page.locator('[data-cy="contextPageSubmit"]').click()
await expect(page).toHaveURL(/\/application\/\d+\/page\/\d+/)
await expect(page.locator('[data-cy="contextPageLink"]', { hasText: 'Overview' })).toHaveAttribute('aria-current', 'page')
await expect(page.locator('[data-cy="gridPageTitle"]')).toHaveText('Overview')
await expect(page.getByText('Open work at a glance')).toBeVisible()

await page.locator('[data-cy="gridPageEdit"]').click()
await page.locator('[data-cy="gridPageAddWidget"]').click()
const dialog = page.locator('[data-cy="addWidgetModal"]')
await dialog.locator('[data-cy="widgetFieldHeading"] input').fill('Welcome to the overview')
await page.locator('[data-cy="addWidgetSubmit"]').click()
await expect(dialog).toBeHidden()

await page.locator('[data-cy="gridPageAddWidget"]').click()
await pickOption(page, 'addWidgetType', 'Table or view')
await dialog.locator('[data-cy="addWidgetNode"] input').click()
await expect(page.locator('.vs__dropdown-menu li').filter({ hasText: 'grid table outside app' })).toHaveCount(0)
await page.locator('.vs__dropdown-menu li').filter({ hasText: 'grid table in app' }).click()
await dialog.getByRole('textbox', { name: 'Title' }).fill('Tasks')
await page.locator('[data-cy="addWidgetSubmit"]').click()
await expect(dialog).toBeHidden()

const saved = page.waitForResponse(r => /\/pages\/\d+/.test(r.url()) && r.request().method() === 'PATCH')
await page.locator('[data-cy="gridPageSave"]').click()
expect((await saved).ok()).toBe(true)
await expect(page.locator('[data-cy="gridPageEdit"]')).toBeVisible()

await page.reload()
await expect(page.locator('[data-cy="gridPage"]')).toBeVisible({ timeout: 30000 })
await expect(page.getByText('Welcome to the overview')).toBeVisible()
await expect(page.locator('[data-cy="gridWidget"] h2', { hasText: 'Tasks' })).toBeVisible()
await expect(page.locator('h1', { hasText: 'grid table in app' })).toBeVisible({ timeout: 30000 })

// The start page still lists its table and nothing else
const application = await getApplication(page.request, user, contextId)
const startPage = Object.values(application.pages as Record<string, { page_type: string, content: object }>).find(entry => entry.page_type === 'startpage')
expect(Object.keys(startPage!.content)).toHaveLength(1)
await page.locator('[data-cy="contextStartPageLink"]').click()
await expect(page.locator('h1', { hasText: 'grid table in app' })).toBeVisible({ timeout: 30000 })
await expect(page.locator('[data-cy="gridPage"]')).toHaveCount(0)
})

test('Cancel drops every change made in edit mode', async ({ userPage: { page, user } }) => {
const table = await createTableViaApi(page.request, user, 'grid cancel table')
const contextId = await createApplicationViaApi(page.request, user, 'grid cancel app', [table])
const pageId = await createPageViaApi(page.request, user, contextId, 'Drafts')
await openApplication(page, contextId, `/page/${pageId}`)

await page.locator('[data-cy="gridPageEdit"]').click()
await page.locator('[data-cy="gridPageAddWidget"]').click()
await page.locator('[data-cy="widgetFieldHeading"] input').fill('Never saved')
await page.locator('[data-cy="addWidgetSubmit"]').click()
await expect(page.locator('[data-cy="gridItem"]')).toHaveCount(1)

let patched = false
page.on('request', request => {
if (request.method() === 'PATCH') {
patched = true
}
})
await page.locator('[data-cy="gridPageCancel"]').click()
await expect(page.locator('[data-cy="gridItem"]')).toHaveCount(0)
expect(patched).toBe(false)
})

test('A sharee who cannot manage the application sees its pages read-only with live data', async ({ userPage: { page, user }, browser, baseURL, request }) => {
const sharee = await createRandomUser(request)
const table = await createTableViaApi(page.request, user, 'grid shared table')
const contextId = await createApplicationViaApi(page.request, user, 'grid shared app', [table])
const pageId = await createPageViaApi(page.request, user, contextId, 'Shared overview')
const nodeRelId = Number(Object.keys((await getApplication(page.request, user, contextId)).nodes)[0])
await ocsRequest(page.request, user, {
method: 'PATCH',
url: `${API}/contexts/${contextId}/pages/${pageId}?format=json`,
data: { widgets: [{ type: 'data', title: 'Shared tasks', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 5, source: { nodeRelId } }] },
})
await ocsRequest(page.request, user, {
method: 'POST',
url: '/index.php/apps/tables/api/1/shares',
data: { nodeId: contextId, nodeType: 'context', receiver: sharee.userId, receiverType: 'user', permissionRead: true, displayMode: 2 },
})

const shareeContext = await browser.newContext({ baseURL, viewport: { width: 1600, height: 1000 } })
const shareePage = await shareeContext.newPage()
await login(shareePage, sharee)
await openApplication(shareePage, contextId)
await expect(shareePage.locator('[data-cy="contextPageAdd"]')).toHaveCount(0)
await shareePage.locator('[data-cy="contextPageLink"]', { hasText: 'Shared overview' }).click()

await expect(shareePage.locator('[data-cy="gridWidget"] h2', { hasText: 'Shared tasks' })).toBeVisible({ timeout: 30000 })
await expect(shareePage.locator('h1', { hasText: 'grid shared table' })).toBeVisible({ timeout: 30000 })
await expect(shareePage.locator('[data-cy="gridPageEdit"]')).toHaveCount(0)
await expect(shareePage.locator('[data-cy="gridPageActions"]')).toHaveCount(0)

const forbidden = await shareePage.request.fetch(`${API}/contexts/${contextId}/pages/${pageId}?format=json`, {
method: 'PATCH',
headers: { 'OCS-APIRequest': 'true', 'Content-Type': 'application/json', Authorization: 'Basic ' + Buffer.from(`${sharee.userId}:${sharee.password}`).toString('base64') },
data: { widgets: [] },
})
expect(forbidden.status()).toBe(403)

await shareeContext.close()
})

test('A table removed from the application leaves its widget asking for a new one', async ({ userPage: { page, user } }) => {
const table = await createTableViaApi(page.request, user, 'grid removed table')
const contextId = await createApplicationViaApi(page.request, user, 'grid removal app', [table])
const pageId = await createPageViaApi(page.request, user, contextId, 'Board')
const nodeRelId = Number(Object.keys((await getApplication(page.request, user, contextId)).nodes)[0])
await ocsRequest(page.request, user, {
method: 'PATCH',
url: `${API}/contexts/${contextId}/pages/${pageId}?format=json`,
data: { widgets: [{ type: 'data', title: 'Gone soon', gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 5, source: { nodeRelId } }] },
})
await ocsRequest(page.request, user, {
method: 'PUT',
url: `${API}/contexts/${contextId}?format=json`,
data: { nodes: [] },
})

await openApplication(page, contextId, `/page/${pageId}`)
await expect(page.locator('[data-cy="gridWidget"] h2', { hasText: 'Gone soon' })).toBeVisible({ timeout: 30000 })
await expect(page.locator('[data-cy="dataWidgetNoSource"]')).toBeVisible()

await page.locator('[data-cy="gridPageEdit"]').click()
await expect(page.locator('[data-cy="dataWidgetPickSource"]')).toBeVisible()
})
})
113 changes: 113 additions & 0 deletions src/modules/grid/ContextPageBar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<nav class="context-page-bar" :aria-label="t('tables', 'Pages of this application')" data-cy="contextPageBar">
<ul class="context-page-bar__list">
<li>
<RouterLink class="context-page-bar__link"
:class="{ 'context-page-bar__link--active': activePageId === null }"
:aria-current="activePageId === null ? 'page' : undefined"
:to="{ name: 'context', params: { contextId: context.id } }"
data-cy="contextStartPageLink">
{{ t('tables', 'Start page') }}
</RouterLink>
</li>
<li v-for="page in gridPages" :key="page.id">
<RouterLink class="context-page-bar__link"
:class="{ 'context-page-bar__link--active': activePageId === page.id }"
:aria-current="activePageId === page.id ? 'page' : undefined"
:to="{ name: 'contextPage', params: { contextId: context.id, pageId: page.id } }"
data-cy="contextPageLink">
<span v-if="page.emoji" aria-hidden="true">{{ page.emoji }}</span>
{{ page.title }}
</RouterLink>
</li>
<li v-if="canManage">
<NcButton variant="tertiary" data-cy="contextPageAdd" @click="$emit('add-page')">
<template #icon>
<Plus :size="20" />
</template>
{{ t('tables', 'Add page') }}
</NcButton>
</li>
</ul>
</nav>
</template>

<script>
import { NcButton } from '@nextcloud/vue'
import Plus from 'vue-material-design-icons/Plus.vue'
import { PAGE_TYPE_GRID } from './gridLayout.js'

/**
* The pages of an application under its description: the start page, one tab per grid
* page, and adding a page for those who manage the application.
*/
export default {
name: 'ContextPageBar',
components: {
NcButton,
Plus,
},
props: {
context: {
type: Object,
required: true,
},
// The grid page that is shown; null on the start page
activePageId: {
type: Number,
default: null,
},
canManage: {
type: Boolean,
default: false,
},
},
emits: ['add-page'],
computed: {
gridPages() {
return Object.values(this.context.pages ?? {})
.filter(page => page.page_type === PAGE_TYPE_GRID)
.sort((a, b) => a.id - b.id)
},
},
}
</script>

<style lang="scss" scoped>
.context-page-bar {
margin-inline-start: 32px;
border-bottom: 1px solid var(--color-border);

&__list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--default-grid-baseline);
}

&__link {
display: inline-flex;
align-items: center;
gap: var(--default-grid-baseline);
min-height: var(--default-clickable-area);
padding: 0 calc(3 * var(--default-grid-baseline));
border-bottom: 2px solid transparent;
color: var(--color-text-maxcontrast);

&:hover,
&:focus-visible {
color: var(--color-main-text);
}

&--active {
border-bottom-color: var(--color-primary-element);
color: var(--color-main-text);
font-weight: bold;
}
}
}
</style>
Loading
Loading