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
24 changes: 24 additions & 0 deletions cypress/component/menuItems.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
isFixedItem,
isValidMenuLink,
menuItemErrors,
menuItemRoute,
menuTree,
moveItem,
newDashboardItem,
outdentItem,
Expand Down Expand Up @@ -172,4 +174,26 @@ describe('menu items', () => {
// without an id, only the fixed dashboard item is valid
expect(menuItemErrors({ ...page, targetId: null }).target).to.equal(true)
})

it('routes a page, view or table entry inside the app and leaves a link outside it', () => {
expect(menuItemRoute({ targetType: 'page', targetId: 12, technicalName: 'dashboard' }, 7)).to.equal('/application/7/menu/dashboard')
expect(menuItemRoute({ targetType: 'view', technicalName: 'home' }, 7)).to.equal('/application/7/menu/home')
expect(menuItemRoute({ targetType: 'table', technicalName: 'cases' }, 7)).to.equal('/application/7/menu/cases')
expect(menuItemRoute({ targetType: 'url', technicalName: 'docs' }, 7)).to.equal(null)
})

it('builds the stored menu into a tree in menu order', () => {
const stored = [
{ id: 3, order: 30, parentId: null, label: 'Docs' },
{ id: 2, order: 20, parentId: 1, label: 'Open cases' },
{ id: 1, order: 10, parentId: null, label: 'Cases' },
]

const tree = menuTree(stored)

expect(tree.map(branch => branch.item.label)).to.deep.equal(['Cases', 'Docs'])
expect(tree[0].children.map(child => child.label)).to.deep.equal(['Open cases'])
expect(tree[1].children).to.deep.equal([])
expect(menuTree(undefined)).to.deep.equal([])
})
})
125 changes: 125 additions & 0 deletions playwright/e2e/application.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* 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'

/**
* Creates an application with a table on its start page, a grid page with a header, and a menu
* that opens the start page, the grid page, the table and an external link, through the API.
*
* @param request the request context of the test user
* @param user the test user
* @param name name of the application
*/
async function createApplicationWithMenu(request: APIRequestContext, user: TestUser, name: string) {
const tableResponse = await ocsRequest(request, user, { method: 'POST', url: '/index.php/apps/tables/api/1/tables', data: { title: name + ' cases' } })
const tableId = (await tableResponse.json()).id
const contextResponse = await ocsRequest(request, user, {
method: 'POST',
url: `${API}/contexts?format=json`,
data: { name, iconName: 'star', nodes: [{ id: tableId, type: 0, permissions: 1 }] },
})
const context = (await contextResponse.json()).ocs.data
// A new application's dashboard item opens its start page
const startPageId = context.menuItems.find((item: { technicalName: string }) => item.technicalName === 'dashboard').targetId
const pageResponse = await ocsRequest(request, user, { method: 'POST', url: `${API}/contexts/${context.id}/pages?format=json`, data: { title: 'Front page' } })
const frontPageId = (await pageResponse.json()).ocs.data.id
await ocsRequest(request, user, {
method: 'PUT',
url: `${API}/contexts/${context.id}/pages/${frontPageId}/widgets?format=json`,
data: { widgets: [{ type: 'header', configuration: {}, content: { title: 'Welcome on the front page' }, gridX: 0, gridY: 0, gridWidth: 12, gridHeight: 2 }] },
})
await ocsRequest(request, user, {
method: 'PUT',
url: `${API}/contexts/${context.id}?format=json`,
data: {
menuItems: [
{ label: 'Dashboard', targetType: 'page', targetId: startPageId, technicalName: 'dashboard' },
{ label: 'Front page', targetType: 'page', targetId: frontPageId },
{ label: 'Cases', targetType: 'table', targetId: tableId },
{ label: 'Handbook', targetType: 'url', url: 'https://docs.nextcloud.com' },
],
},
})
return { contextId: context.id, frontPageId, tableTitle: name + ' cases' }
}

test.describe('Applications', () => {
test('An application opened on its own shows its menu and opens what each item points at', async ({ userPage: { page, user } }) => {
const { contextId, tableTitle } = await createApplicationWithMenu(page.request, user, 'standalone application')

await page.goto(`/index.php/apps/tables/app/${contextId}`)

await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('standalone application')
const items = page.locator('[data-cy="application-nav-item"]')
await expect(items).toHaveCount(4)
// The first menu item is the dashboard: the start page with the application's table
await expect(page).toHaveURL(/\/menu\/dashboard$/)
await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 30000 })
await expect(page.locator('[data-cy="navigationCreateTableIcon"]')).toHaveCount(0)
await expect(page.locator('[data-cy="application-nav-overview"]')).toHaveCount(0)
// Nothing focused the content area before the person touched the page, so no focus ring is drawn around it
expect(await page.evaluate(() => document.activeElement?.id ?? '')).not.toBe('app-content-vue')

await items.filter({ hasText: 'Front page' }).getByRole('link').click()
await expect(page).toHaveURL(/\/menu\/front_page$/)
await expect(page.getByText('Welcome on the front page')).toBeVisible()

await items.filter({ hasText: 'Cases' }).getByRole('link').click()
await expect(page).toHaveURL(/\/menu\/cases$/)
await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible()

await expect(items.filter({ hasText: 'Handbook' }).getByRole('link')).toHaveAttribute('href', 'https://docs.nextcloud.com')
})

test('Inside Tables a menu item opens below a breadcrumb back to the application', async ({ userPage: { page, user } }) => {
const { contextId } = await createApplicationWithMenu(page.request, user, 'breadcrumb application')

await page.goto(`/index.php/apps/tables/#/application/${contextId}/menu/front_page`)

const breadcrumb = page.locator('[data-cy="context-breadcrumb"]')
await expect(breadcrumb).toContainText('breadcrumb application')
await expect(breadcrumb).toContainText('Front page')
await expect(page.getByText('Welcome on the front page')).toBeVisible()
await expect(page.locator('[data-cy="context-open-application"]')).toHaveAttribute('href', new RegExp(`/apps/tables/app/${contextId}$`))

await page.locator('[data-cy="context-menu-overview"]').click()
await expect(page).toHaveURL(new RegExp(`/application/${contextId}$`))
await expect(page.locator('[data-cy="contextPageLink"]', { hasText: 'Front page' })).toBeVisible()
})

test('A menu item whose page is gone shows as broken', async ({ userPage: { page, user } }) => {
const { contextId, frontPageId } = await createApplicationWithMenu(page.request, user, 'broken item application')
await ocsRequest(page.request, user, { method: 'DELETE', url: `${API}/contexts/${contextId}/pages/${frontPageId}?format=json` })

await page.goto(`/index.php/apps/tables/app/${contextId}`)
await expect(page).toHaveURL(/\/menu\/dashboard$/)
await page.locator('[data-cy="application-nav-item"]').filter({ hasText: 'Front page' }).getByRole('link').click()

await expect(page.locator('[data-cy="context-menu-target-missing"]')).toBeVisible()
await expect(page.locator('[data-cy="application-nav-item"]').filter({ hasText: 'Front page' }).locator('.alert-circle-outline-icon')).toHaveCount(1)
})

test('An application whose menu has no dashboard item keeps its start page in the navigation', async ({ userPage: { page, user } }) => {
const { contextId, tableTitle } = await createApplicationWithMenu(page.request, user, 'no dashboard application')
await ocsRequest(page.request, user, {
method: 'PUT',
url: `${API}/contexts/${contextId}?format=json`,
data: { menuItems: [{ label: 'Handbook', targetType: 'url', url: 'https://docs.nextcloud.com' }] },
})

await page.goto(`/index.php/apps/tables/app/${contextId}`)

const startPage = page.locator('[data-cy="application-nav-overview"]')
await expect(startPage).toContainText('Dashboard')
await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 30000 })
})
})
70 changes: 50 additions & 20 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
-->
<template>
<NcContent app-name="tables">
<Navigation v-if="!isPublicShare" />
<ApplicationNavigation v-if="!isPublicShare && isStandaloneApplication" />
<Navigation v-else-if="!isPublicShare" />
<NcAppContent>
<div v-if="isLoadingSomething" class="icon-loading" />

Expand All @@ -18,6 +19,7 @@
<script>
import { NcContent, NcAppContent } from '@nextcloud/vue'
import Navigation from './modules/navigation/sections/Navigation.vue'
import ApplicationNavigation from './modules/navigation/sections/ApplicationNavigation.vue'
import PublicTableView from './pages/PublicTableView.vue'
import { mapState, mapActions } from 'pinia'
import Sidebar from './modules/sidebar/sections/Sidebar.vue'
Expand All @@ -26,6 +28,10 @@ import { loadState } from '@nextcloud/initial-state'
import { useTablesStore } from './store/store.js'
import { generateUrl } from '@nextcloud/router'

// A page that focuses its content before the person has touched it shows the browser's focus
// ring around the whole content area. Focus moves only after a first pointer or keyboard event.
const FIRST_INTERACTION_EVENTS = ['pointerdown', 'keydown']

export default {
name: 'App',
components: {
Expand All @@ -34,6 +40,7 @@ export default {
NcContent,
NcAppContent,
Navigation,
ApplicationNavigation,
},
props: {
tableId: {
Expand All @@ -46,20 +53,28 @@ export default {
loading: false,
defaultPageTitle: false,
shareToken: loadState('tables', 'shareToken', false),
standaloneContextId: loadState('tables', 'contextId', null),
userHasInteracted: false,
}
},
computed: {
...mapState(useTablesStore, ['isLoadingSomething', 'activeView', 'activeTable', 'activeContext']),
isPublicShare() {
return !!this.shareToken
},
isStandaloneApplication() {
return this.standaloneContextId !== null
},
},
watch: {
'$route'(to) {
this.routing(to)
},
},
async created() {
for (const type of FIRST_INTERACTION_EVENTS) {
document.addEventListener(type, this.onFirstInteraction, { capture: true })
}
const store = useTablesStore()
if (!this.isPublicShare) {
await Promise.all([
Expand All @@ -73,27 +88,40 @@ export default {
store.setLoading({ key: 'viewsShared', value: false })
store.setLoading({ key: 'contexts', value: false })
}
this.routing(this.$route)
this.routing(this.$route, true)
},
mounted() {
this.$nextTick(() => this.observeAppContent())
},
beforeUnmount() {
this.removeFirstInteractionListeners()
},
methods: {
onFirstInteraction() {
this.userHasInteracted = true
this.removeFirstInteractionListeners()
},
removeFirstInteractionListeners() {
for (const type of FIRST_INTERACTION_EVENTS) {
document.removeEventListener(type, this.onFirstInteraction, { capture: true })
}
},
...mapActions(useTablesStore, ['loadTablesFromBE', 'getAllContexts', 'loadViewsSharedWithMeFromBE', 'loadTemplatesFromBE', 'setActiveRowId', 'setActiveTableId', 'setActiveViewId', 'setActiveContextId']),
routing(currentRoute) {
/**
* @param {object} currentRoute the route to apply
* @param {boolean} initial true for the first route on page load. Routes that follow without the
* user having touched the page (the redirects of the standalone shell) do not take focus either:
* a programmatic focus before any interaction draws the focus ring around the content area
*/
routing(currentRoute, initial = false) {
const url = generateUrl('/apps/tables/')

try {
if (loadState('tables', 'contextId', undefined)) {
// prepare route, when Context is opened from navigation bar
const contextId = loadState('tables', 'contextId', undefined)
const originalUrl = window.location.href
this.$router.replace('/application/' + contextId).catch(() => { })
// reverts turning /apps/tables/app/28 into /apps/tables/app/28#/application/28
history.replaceState({}, undefined, originalUrl)
}
} catch (e) {
// contextId is not always set, it is fine.
// An application opened on its own starts at its root; later route changes inside it must stay
if (this.standaloneContextId !== null && currentRoute.path === '/') {
const originalUrl = window.location.href
this.$router.replace('/application/' + this.standaloneContextId).catch(() => { })
// Reverts turning /apps/tables/app/28 into /apps/tables/app/28#/application/28
history.replaceState({}, undefined, originalUrl)
}

if (currentRoute.name === 'tableRow' || currentRoute.name === 'viewRow') {
Expand Down Expand Up @@ -133,13 +161,15 @@ export default {
}

// move the focus away from nav bar (import for app-internal switch)
const appContent = document.getElementById('app-content-vue')
const oldTabIndex = appContent.tabIndex
if (oldTabIndex === -1) {
appContent.tabIndex = 0
if (!initial && this.userHasInteracted) {
const appContent = document.getElementById('app-content-vue')
const oldTabIndex = appContent.tabIndex
if (oldTabIndex === -1) {
appContent.tabIndex = 0
}
appContent.focus()
appContent.tabIndex = oldTabIndex
}
appContent.focus()
appContent.tabIndex = oldTabIndex
}
},
switchActiveMenuEntry(targetElement) {
Expand Down
Loading
Loading