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
23 changes: 23 additions & 0 deletions cypress/component/menuItems.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
indentItem,
isValidMenuLink,
menuItemErrors,
menuItemRoute,
menuTree,
moveItem,
outdentItem,
parentOfPosition,
Expand Down Expand Up @@ -115,4 +117,25 @@ describe('menu items', () => {
expect(dragItemOver(items, 0, 1)).to.equal(null, 'a block cannot be dropped on its own child')
expect(dragItemOver(items, 2, 2)).to.equal(null)
})

it('routes a view or table entry inside the app and leaves a link outside it', () => {
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([])
})
})
51 changes: 51 additions & 0 deletions playwright/e2e/application.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

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

/**
* Creates a grid view and an application whose menu opens it, through the API.
*
* @param page the page of the test user
* @param user the test user
* @param name name of the application
* @param pageTitle title of the grid view, and label of its menu item
*/
async function createApplicationWithGridPage(page: Page, user: TestUser, name: string, pageTitle: string) {
const viewResponse = await ocsRequest(page.request, user, {
method: 'POST',
url: '/ocs/v2.php/apps/tables/api/2/views?format=json',
data: { title: pageTitle },
})
const view = (await viewResponse.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,
iconName: 'star',
menuItems: [{ label: pageTitle, targetType: 'view', targetId: view.id }],
},
})
return (await contextResponse.json()).ocs.data
}

test.describe('Applications', () => {
test('An application opened on its own shows its menu and its first page', async ({ userPage: { page, user } }) => {
const context = await createApplicationWithGridPage(page, user, 'standalone application', 'Front page')

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

await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('standalone application')
await expect(page.locator('[data-cy="application-nav-item"]').filter({ hasText: 'Front page' })).toBeVisible()
await expect(page).toHaveURL(/\/menu\/front_page$/)
await expect(page.locator('[data-cy="gridViewHeading"]')).toContainText('Front page')
await expect(page.locator('[data-cy="navigationCreateTableIcon"]')).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')
})
})
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
157 changes: 157 additions & 0 deletions src/modules/navigation/sections/ApplicationNavigation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcAppNavigation :aria-label="context ? context.name : t('tables', 'Application')">
<template #list>
<NcLoadingIcon v-if="!context" :size="32" />
<template v-else>
<div class="application-navigation__header" data-cy="application-nav-header">
<ApplicationIcon :icon-name="context.iconName" :size="32" />
<div class="application-navigation__title">
<strong>{{ context.name }}</strong>
<span v-if="context.description" class="application-navigation__description">{{ context.description }}</span>
</div>
</div>
<NcAppNavigationItem v-for="branch in menuTree"
:key="branch.item.id"
:name="branch.item.label"
:to="branch.item.targetType === 'url' ? undefined : menuItemRoute(branch.item, context.id)"
:href="branch.item.targetType === 'url' ? branch.item.url : undefined"
:class="{ active: isActive(branch.item) }"
:allow-collapse="branch.children.length > 0"
:open="branch.children.some(child => isActive(child))"
data-cy="application-nav-item">
<template #icon>
<MenuItemIcon :item="branch.item" />
</template>
<template v-if="branch.children.length > 0">
<NcAppNavigationItem v-for="child in branch.children"
:key="child.id"
:name="child.label"
:to="child.targetType === 'url' ? undefined : menuItemRoute(child, context.id)"
:href="child.targetType === 'url' ? child.url : undefined"
:class="{ active: isActive(child) }"
data-cy="application-nav-item">
<template #icon>
<MenuItemIcon :item="child" />
</template>
</NcAppNavigationItem>
</template>
</NcAppNavigationItem>
<NcAppNavigationItem v-if="hasResources"
:name="t('tables', 'Overview')"
:to="'/application/' + context.id"
:class="{ active: !$route.params.itemName }"
data-cy="application-nav-overview">
<template #icon>
<ViewGridOutline :size="20" />
</template>
</NcAppNavigationItem>
</template>
</template>
<template #footer>
<div v-if="context && ownsContext(context)" class="application-navigation__footer">
<NcButton variant="tertiary" :href="configurationUrl" data-cy="application-nav-configure">
<template #icon>
<CogOutline :size="20" />
</template>
{{ t('tables', 'Configure in Tables') }}
</NcButton>
</div>
</template>
</NcAppNavigation>
</template>

<script>
import { NcAppNavigation, NcAppNavigationItem, NcButton, NcLoadingIcon } from '@nextcloud/vue'
import CogOutline from 'vue-material-design-icons/CogOutline.vue'
import ViewGridOutline from 'vue-material-design-icons/ViewGridOutline.vue'
import { generateUrl } from '@nextcloud/router'
import { mapState } from 'pinia'
import ApplicationIcon from '../../../shared/components/ApplicationIcon.vue'
import MenuItemIcon from '../../../shared/components/MenuItemIcon.vue'
import permissionsMixin from '../../../shared/components/ncTable/mixins/permissionsMixin.js'
import { menuItemRoute, menuTree } from '../../../shared/utils/menuItems.js'
import { NODE_TYPE_TABLE } from '../../../shared/constants.ts'
import { useTablesStore } from '../../../store/store.js'

/**
* The navigation of an application opened on its own: its menu items are the entries.
*/
export default {
name: 'ApplicationNavigation',
components: {
NcAppNavigation,
NcAppNavigationItem,
NcButton,
NcLoadingIcon,
CogOutline,
ViewGridOutline,
ApplicationIcon,
MenuItemIcon,
},
mixins: [permissionsMixin],
computed: {
...mapState(useTablesStore, ['activeContext', 'views']),
context() {
return this.activeContext
},
menuItems() {
return [...(this.context?.menuItems ?? [])].sort((a, b) => a.order - b.order)
},
menuTree() {
return menuTree(this.menuItems)
},
/**
* Whether the overview has a table or view to show. A grid view is a page of its own, reached
* through the menu, so it does not count.
*
* @return {boolean}
*/
hasResources() {
Comment thread
remko48 marked this conversation as resolved.
return Object.values(this.context?.nodes ?? {}).some(node => parseInt(node.node_type) === NODE_TYPE_TABLE
|| this.views.find(view => view.id === node.node_id)?.type !== 'grid')
},
configurationUrl() {
return generateUrl('/apps/tables/') + '#/application/' + this.context.id
},
},
methods: {
menuItemRoute,
isActive(item) {
return item.targetType !== 'url' && this.$route.params.itemName === item.technicalName
},
},
}
</script>

<style lang="scss" scoped>
.application-navigation {
&__header {
display: flex;
align-items: center;
gap: calc(3 * var(--default-grid-baseline));
padding: calc(3 * var(--default-grid-baseline)) calc(4 * var(--default-grid-baseline));
}

&__title {
display: flex;
flex-direction: column;
min-width: 0;
}

&__description {
color: var(--color-text-maxcontrast);
font-size: 0.9em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

&__footer {
padding: calc(2 * var(--default-grid-baseline));
}
}
</style>
Loading
Loading