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
185 changes: 185 additions & 0 deletions cypress/component/menuItems.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import {
areMenuItemsValid,
canIndent,
canMove,
dragItemOver,
indentItem,
isValidMenuLink,
menuItemErrors,
moveItem,
newDashboardItem,
opensNewStartPage,
outdentItem,
parentOfPosition,
removeItem,
toMenuItemPayload,
} from '../../src/shared/utils/menuItems.js'

/**
* An item of the editor's list.
*
* @param {string} key the item's key, also used as its label
* @param {string|null} parentKey the key of its parent, or null at the top level
* @return {object} the item
*/
function item(key, parentKey = null) {
return { key, label: key, icon: null, targetType: 'url', targetId: null, url: 'https://example.org/' + key, technicalName: null, parentKey }
}

/**
* The list as "key" for a top-level item and "parent>key" for a child, to read the shape at a glance.
*
* @param {Array<object>} items the list
* @return {Array<string>} the shape of the list
*/
function shape(items) {
return items.map(entry => entry.parentKey === null ? entry.key : entry.parentKey + '>' + entry.key)
}

// A with its child a1, then B and C at the top level
const menu = () => [item('A'), item('a1', 'A'), item('B'), item('C')]

describe('menu items', () => {
it('sends a child with the position of its parent', () => {
const items = menu()

expect(toMenuItemPayload(items[1], items).parentIndex).to.equal(0)
expect(toMenuItemPayload(items[2], items).parentIndex).to.equal(null)
expect(toMenuItemPayload(items[2], items).targetId).to.equal(null)
})

it('accepts only the links the server accepts', () => {
expect(isValidMenuLink('https://example.org/docs')).to.equal(true)
expect(isValidMenuLink('http://example.org')).to.equal(true)
expect(isValidMenuLink('www.example.org')).to.equal(false)
expect(isValidMenuLink('https://')).to.equal(false)
expect(isValidMenuLink('javascript://example.com/%0Aalert(1)')).to.equal(false)
expect(isValidMenuLink('https://example.org/' + 'x'.repeat(2000))).to.equal(false)
})

it('marks an item without a label, without a target or with a bad link', () => {
expect(menuItemErrors({ ...item('A'), label: ' ' }).label).to.equal(true)
expect(menuItemErrors({ ...item('A'), targetType: 'view', targetId: null }).target).to.equal(true)
expect(menuItemErrors({ ...item('A'), url: 'www.example.org' }).url).to.equal(true)
expect(menuItemErrors(item('A'))).to.deep.equal({ label: false, target: false, url: false })
expect(areMenuItemsValid(menu())).to.equal(true)
expect(areMenuItemsValid([...menu(), { ...item('D'), url: '' }])).to.equal(false)
})

it('moves a top-level item together with its children', () => {
expect(shape(moveItem(menu(), 2, -1))).to.deep.equal(['B', 'A', 'A>a1', 'C'])
expect(shape(moveItem(menu(), 0, 1))).to.deep.equal(['B', 'A', 'A>a1', 'C'])
expect(canMove(menu(), 0, -1)).to.equal(false)
expect(canMove(menu(), 1, 1)).to.equal(false, 'a1 is the only child of A')
})

it('nests one level deep and only under the top-level item above', () => {
expect(canIndent(menu(), 0)).to.equal(false, 'nothing above A')
expect(canIndent(menu(), 2)).to.equal(true)
expect(shape(indentItem(menu(), 2))).to.deep.equal(['A', 'A>a1', 'A>B', 'C'])
expect(canIndent([item('A'), item('B'), item('b1', 'B')], 1)).to.equal(false, 'B has a child itself')
})

it('puts an outdented child after the last child of its parent', () => {
const items = [item('A'), item('a1', 'A'), item('a2', 'A'), item('B')]

expect(shape(outdentItem(items, 1))).to.deep.equal(['A', 'A>a2', 'a1', 'B'])
})

it('moves the children of a removed item to the top level', () => {
expect(shape(removeItem(menu(), 0))).to.deep.equal(['a1', 'B', 'C'])
expect(shape(removeItem(menu(), 1))).to.deep.equal(['A', 'B', 'C'])
})

it('knows which parent a position belongs to', () => {
const rest = [item('A'), item('a1', 'A'), item('B')]

expect(parentOfPosition(rest, 0)).to.equal(null)
expect(parentOfPosition(rest, 1)?.key).to.equal('A')
expect(parentOfPosition(rest, 2)?.key).to.equal('A')
expect(parentOfPosition(rest, 3)).to.equal(null)
})

it('drags a child into another parent and a top-level block past children', () => {
const items = [item('A'), item('a1', 'A'), item('B'), item('b1', 'B')]

const childMoved = dragItemOver(items, 1, 3)
expect(shape(childMoved.items)).to.deep.equal(['A', 'B', 'B>b1', 'B>a1'])
expect(childMoved.draggedIndex).to.equal(3)

const blockMoved = dragItemOver(items, 2, 1)
expect(shape(blockMoved.items)).to.deep.equal(['A', 'A>a1', 'B', 'B>b1'], 'B cannot land between A and its child')

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('starts the menu with a dashboard item that opens the new start page', () => {
const dashboard = newDashboardItem('Dashboard')
const items = [dashboard, item('A'), item('B')]

expect(opensNewStartPage(dashboard)).to.equal(true)
expect(menuItemErrors(dashboard)).to.deep.equal({ label: false, target: false, url: false })
expect(menuItemErrors({ ...dashboard, label: ' ' }).label).to.equal(true)
expect(areMenuItemsValid(items)).to.equal(true)

const payload = toMenuItemPayload({ ...dashboard, label: 'Overview' }, items)
expect(payload.targetType).to.equal('page')
expect(payload.targetId).to.equal(null)
expect(payload.label).to.equal('Overview')
expect(payload.technicalName).to.equal('dashboard')
})

it('moves and removes the dashboard item like any other item', () => {
const items = [newDashboardItem('Dashboard'), item('A'), item('B')]

expect(shape(removeItem(items, 0))).to.deep.equal(['A', 'B'])
expect(canMove(items, 0, 1)).to.equal(true)
expect(shape(moveItem(items, 0, 1))).to.deep.equal(['A', 'dashboard', 'B'])
expect(canMove(items, 1, -1)).to.equal(true)
expect(shape(moveItem(items, 1, -1))).to.deep.equal(['A', 'dashboard', 'B'])
expect(shape(dragItemOver(items, 0, 2).items)).to.deep.equal(['A', 'B', 'dashboard'])
expect(shape(dragItemOver(items, 2, 0).items)).to.deep.equal(['B', 'dashboard', 'A'])

const moved = moveItem(items, 0, 1)
const nested = indentItem(moved, 1)
expect(canIndent(moved, 1)).to.equal(true)
expect(shape(nested)).to.deep.equal(['A', 'A>dashboard', 'B'])
const payload = toMenuItemPayload(nested[1], nested)
expect([payload.targetType, payload.targetId, payload.parentIndex]).to.deep.equal(['page', null, 0])
})

it('nests items under the dashboard item like under any other', () => {
const items = [newDashboardItem('Dashboard'), item('A')]

const nested = indentItem(items, 1)
expect(shape(nested)).to.deep.equal(['dashboard', 'dashboard>A'])
expect(toMenuItemPayload(nested[1], nested).parentIndex).to.equal(0)
expect(shape(outdentItem(nested, 1))).to.deep.equal(['dashboard', 'A'])
})

it('passes a page item from the server through as it is', () => {
const page = { key: 'p', label: 'Overview', icon: null, targetType: 'page', targetId: 12, url: null, technicalName: 'overview', parentKey: null }
const items = [page, item('A')]

expect(menuItemErrors(page)).to.deep.equal({ label: false, target: false, url: false })
expect(areMenuItemsValid(items)).to.equal(true)
expect(toMenuItemPayload(page, items)).to.deep.equal({
label: 'Overview',
icon: null,
targetType: 'page',
targetId: 12,
url: null,
technicalName: 'overview',
parentIndex: null,
})
expect(opensNewStartPage(page)).to.equal(false)
expect(shape(moveItem(items, 0, 1))).to.deep.equal(['A', 'p'])
// without an id, only the dashboard item of a new application is valid
expect(menuItemErrors({ ...page, targetId: null }).target).to.equal(true)
})
})
6 changes: 4 additions & 2 deletions lib/Service/ContextService.php
Original file line number Diff line number Diff line change
Expand Up @@ -628,7 +628,10 @@ private function applyUpdate(int $contextId, string $userId, ?string $name, ?str
public function delete(int $contextId, string $userId): Context {
$context = $this->contextMapper->findById($contextId, $userId);

$this->atomic(function () use ($context): void {
// The application goes first, so the transaction holds the write lock before it reads: on
// SQLite a transaction that reads first fails at once when another request writes meanwhile
$this->atomicRetry(function () use ($context): void {
$this->contextMapper->delete($context);
$this->shareService->deleteAllForContext($context);
$this->menuItemMapper->deleteAllByContextId($context->getId());
$this->contextNodeRelMapper->deleteAllByContextId($context->getId());
Expand All @@ -637,7 +640,6 @@ public function delete(int $contextId, string $userId): Context {
$this->pageContentMapper->deleteByPageId($pageId);
$this->pageMapper->deleteByPageId($pageId);
}
$this->contextMapper->delete($context);
}, $this->dbc);
return $context;
}
Expand Down
96 changes: 96 additions & 0 deletions playwright/e2e/context-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Locator, Page } from '@playwright/test'
import { test, expect } from '../support/fixtures'
import { ensureNavigationOpen } from '../support/commands'

type MenuItem = { label: string, targetType: string, targetId: number | null, technicalName: string }

/**
* Opens the create application dialog.
*
* @param page the page of the signed in user
* @return the dialog
*/
async function openCreateDialog(page: Page): Promise<Locator> {
await page.goto('/index.php/apps/tables')
await ensureNavigationOpen(page)
await page.getByRole('button', { name: /^Create application$/ }).first().click()
const modal = page.locator('[data-cy="createContextModal"]')
await expect(modal).toBeVisible()
return modal
}

/**
* Submits the create application dialog.
*
* @param page the page of the signed in user
* @param title the title of the new application
* @return the created application as the API returns it
*/
async function createApplication(page: Page, title: string) {
await page.locator('[data-cy="createContextTitle"]').fill(title)
const created = page.waitForResponse(response => response.url().includes('/api/2/contexts') && response.request().method() === 'POST')
await page.locator('[data-cy="createContextSubmitBtn"]').click()
const context = (await (await created).json()).ocs.data
const startPageId = Number(Object.keys(context.pages).find(id => context.pages[id].pageType === 'startpage'))
const menu = context.menuItems.map((item: MenuItem) => [item.label, item.targetType, item.targetId, item.technicalName])
return { context, startPageId, menu }
}

test.describe('Application menu', () => {
test('A new application gets a dashboard item that opens its start page', async ({
userPage: { page },
}) => {
const contextTitle = 'test application dashboard'
const modal = await openCreateDialog(page)

// The dashboard item comes first; it opens the start page and can be renamed and moved
const rows = modal.locator('[data-cy="menuItemRow"]')
const firstRow = rows.first()
await expect(firstRow.getByRole('textbox', { name: 'Label' })).toHaveValue('Dashboard')
await expect(firstRow.getByRole('combobox', { name: 'Opens' })).toBeDisabled()
await expect(firstRow.locator('[data-cy="menuItemRemove"]')).toBeVisible()
await firstRow.getByRole('textbox', { name: 'Label' }).fill('Overview')

await modal.locator('[data-cy="menuItemAdd"]').click()
const secondRow = rows.nth(1)
await secondRow.getByRole('textbox', { name: 'Label' }).fill('Docs')
await secondRow.getByRole('textbox', { name: 'Link' }).fill('https://example.org/docs')
await secondRow.locator('[data-cy="menuItemMoveUp"]').click()
await expect(rows.first().getByRole('textbox', { name: 'Label' })).toHaveValue('Docs')

const { context, startPageId, menu } = await createApplication(page, contextTitle)
expect(menu).toEqual([
['Docs', 'url', null, 'docs'],
['Overview', 'page', startPageId, 'dashboard'],
])
expect(context.menuItems[1].broken).toBe(false)

// The dashboard item opens the start page of the application
await page.goto('/index.php/apps/tables/#/application/' + context.id)
await expect(page.locator('h1').filter({ hasText: contextTitle }).first()).toBeVisible()
})

test('The dashboard item can be removed in the dialog', async ({
userPage: { page },
}) => {
const modal = await openCreateDialog(page)
const rows = modal.locator('[data-cy="menuItemRow"]')
await expect(rows).toHaveCount(1)
await rows.first().locator('[data-cy="menuItemRemove"]').click()
await expect(rows).toHaveCount(0)

// The menu is stored as submitted, and the application still has its start page
const contextTitle = 'test application without dashboard'
const { context, startPageId, menu } = await createApplication(page, contextTitle)
expect(menu).toEqual([])
expect(startPageId).toBeGreaterThan(0)

await page.goto('/index.php/apps/tables/#/application/' + context.id)
await expect(page.locator('h1').filter({ hasText: contextTitle }).first()).toBeVisible()
})
})
32 changes: 31 additions & 1 deletion src/modules/modals/CreateContext.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@
</div>
<NcContextResource v-model:resources="resources" v-model:receivers="receivers" />
</div>
<div class="col-4 row space-T">
<div class="col-4">
{{ t('tables', 'Menu') }}
</div>
<MenuItemsEditor ref="menuItemsEditor" v-model:items="menuItems" />
</div>
<div class="row space-T">
<div data-cy="createContextShowInNavSwitch">
<NcActionCheckbox :model-value="showInNavigationDefault" @change="changeDisplayMode">
Expand All @@ -54,7 +60,10 @@
</div>
<div class="row space-R row space-T">
<div class="fix-col-4 end">
<NcButton type="primary" :aria-label="t('tables', 'Create application')" data-cy="createContextSubmitBtn" @click="submit">
<NcButton type="primary"
:aria-label="t('tables', 'Create application')"
data-cy="createContextSubmitBtn"
@click="submit">
{{ t('tables', 'Create application') }}
</NcButton>
</div>
Expand All @@ -68,6 +77,8 @@ import { NcDialog, NcButton, NcIconSvgWrapper, NcActionCheckbox } from '@nextclo
import { showError } from '@nextcloud/dialogs'
import '@nextcloud/dialogs/style.css'
import NcContextResource from '../../shared/components/ncContextResource/NcContextResource.vue'
import MenuItemsEditor from '../../shared/components/ncContextResource/MenuItemsEditor.vue'
import { areMenuItemsValid, newDashboardItem, toMenuItemPayload } from '../../shared/utils/menuItems.js'
import NcIconPicker from '../../shared/components/ncIconPicker/NcIconPicker.vue'
import svgHelper from '../../shared/components/ncIconPicker/mixins/svgHelper.js'
import permissionBitmask from '../../shared/components/ncContextResource/mixins/permissionBitmask.js'
Expand All @@ -84,6 +95,7 @@ export default {
NcButton,
NcIconSvgWrapper,
NcContextResource,
MenuItemsEditor,
NcActionCheckbox,
},
mixins: [svgHelper, permissionBitmask],
Expand All @@ -106,11 +118,22 @@ export default {
customTitleChosen: false,
errorTitle: false,
description: '',
menuItems: [newDashboardItem(t('tables', 'Dashboard'))],
resources: [],
receivers: [],
showInNavigationDefault: false,
}
},
computed: {
/**
* Whether every menu item can be saved, so a half-filled item does not fail the whole application.
*
* @return {boolean} true when no menu item has an error
*/
menuItemsValid() {
return areMenuItemsValid(this.menuItems)
},
},
watch: {
title() {
if (this.title.length >= 200) {
Expand Down Expand Up @@ -141,6 +164,11 @@ export default {
this.$emit('close')
},
async submit() {
// Show every menu error now, and never send a menu the server would refuse
this.$refs.menuItemsEditor?.touchAll()
if (!this.menuItemsValid) {
return
}
if (this.title === '') {
showError(t('tables', 'Cannot create new application. Title is missing.'))
this.errorTitle = true
Expand Down Expand Up @@ -169,6 +197,7 @@ export default {
iconName: this.icon.name,
description: this.description,
nodes: dataResources,
menuItems: this.menuItems.map(item => toMenuItemPayload(item, this.menuItems)),
}
// adding share to oneself to have navigation display control
this.receivers.push(
Expand All @@ -192,6 +221,7 @@ export default {
},
reset() {
this.title = ''
this.menuItems = [newDashboardItem(t('tables', 'Dashboard'))]
this.errorTitle = false
this.setIcon(this.randomIcon())
this.customTitleChosen = false
Expand Down
Loading
Loading