From d983b6367de12397c148437f0f25d80f2a49148e Mon Sep 17 00:00:00 2001 From: Remko Date: Mon, 12 Oct 2026 13:37:57 +0200 Subject: [PATCH 1/4] feat(contexts): edit the menu when creating an application The create dialog gets a menu editor. Every entry opens a view, a table or an external link, can carry an icon and can sit under the top-level entry above it, one level deep. - entries are reordered by dragging or with move buttons, and moved in and out of a parent with indent buttons, so the menu can be arranged with the keyboard - a parent moves together with its children; removing a parent moves its children to the top level Co-authored-by: Ruben van der Linde Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- src/modules/modals/CreateContext.vue | 12 + src/shared/components/ApplicationIcon.vue | 49 ++ .../ncContextResource/MenuItemsEditor.vue | 442 ++++++++++++++++++ src/shared/utils/menuItems.js | 24 + 4 files changed, 527 insertions(+) create mode 100644 src/shared/components/ApplicationIcon.vue create mode 100644 src/shared/components/ncContextResource/MenuItemsEditor.vue create mode 100644 src/shared/utils/menuItems.js diff --git a/src/modules/modals/CreateContext.vue b/src/modules/modals/CreateContext.vue index 9b56275a70..e09891003d 100644 --- a/src/modules/modals/CreateContext.vue +++ b/src/modules/modals/CreateContext.vue @@ -42,6 +42,12 @@ +
+
+ {{ t('tables', 'Menu') }} +
+ +
@@ -68,6 +74,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 { 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' @@ -84,6 +92,7 @@ export default { NcButton, NcIconSvgWrapper, NcContextResource, + MenuItemsEditor, NcActionCheckbox, }, mixins: [svgHelper, permissionBitmask], @@ -106,6 +115,7 @@ export default { customTitleChosen: false, errorTitle: false, description: '', + menuItems: [], resources: [], receivers: [], showInNavigationDefault: false, @@ -169,6 +179,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( @@ -192,6 +203,7 @@ export default { }, reset() { this.title = '' + this.menuItems = [] this.errorTitle = false this.setIcon(this.randomIcon()) this.customTitleChosen = false diff --git a/src/shared/components/ApplicationIcon.vue b/src/shared/components/ApplicationIcon.vue new file mode 100644 index 0000000000..e38c28e2b4 --- /dev/null +++ b/src/shared/components/ApplicationIcon.vue @@ -0,0 +1,49 @@ + + + + diff --git a/src/shared/components/ncContextResource/MenuItemsEditor.vue b/src/shared/components/ncContextResource/MenuItemsEditor.vue new file mode 100644 index 0000000000..821d720b2e --- /dev/null +++ b/src/shared/components/ncContextResource/MenuItemsEditor.vue @@ -0,0 +1,442 @@ + + + + + + diff --git a/src/shared/utils/menuItems.js b/src/shared/utils/menuItems.js new file mode 100644 index 0000000000..08fa0774f3 --- /dev/null +++ b/src/shared/utils/menuItems.js @@ -0,0 +1,24 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * The payload the API expects for one menu item. + * + * @param {object} item an item from the editor + * @param {Array} items the whole editable list, to turn the parent key into the parent's position + * @return {object} + */ +export function toMenuItemPayload(item, items) { + const parentIndex = item.parentKey ? items.findIndex(candidate => candidate.key === item.parentKey) : -1 + return { + label: item.label, + icon: item.icon || null, + targetType: item.targetType, + targetId: item.targetType === 'url' ? null : item.targetId, + url: item.targetType === 'url' ? item.url : null, + technicalName: item.technicalName || null, + parentIndex: parentIndex >= 0 ? parentIndex : null, + } +} From cd3438d4136209d9ed2a23cb045423a76fa855e1 Mon Sep 17 00:00:00 2001 From: Remko Date: Tue, 13 Oct 2026 15:04:04 +0200 Subject: [PATCH 2/4] fix(applications): validate the menu editor and test its list operations - the menu only offers tables and views the user can manage, like the resource picker - rows with a missing label, target or a bad link are marked, and creating waits until all are valid - the list operations are pure functions in utils/menuItems.js, covered by a component spec - option labels are translated as one string, and the label is taken from the option's title - only the handle starts a drag, so text in the fields stays selectable - buttons and rows name their item for screen readers - a slower icon load no longer replaces a newer one - no CSS fallbacks, capitalised comments and JSDoc on every method Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Remko --- cypress/component/menuItems.cy.js | 118 ++++++ src/modules/modals/CreateContext.vue | 18 +- src/shared/components/ApplicationIcon.vue | 8 +- .../ncContextResource/MenuItemsEditor.vue | 369 ++++++++---------- src/shared/utils/menuItems.js | 278 ++++++++++++- 5 files changed, 588 insertions(+), 203 deletions(-) create mode 100644 cypress/component/menuItems.cy.js diff --git a/cypress/component/menuItems.cy.js b/cypress/component/menuItems.cy.js new file mode 100644 index 0000000000..ad68aa1952 --- /dev/null +++ b/cypress/component/menuItems.cy.js @@ -0,0 +1,118 @@ +/** + * 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, + 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} items the list + * @return {Array} 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) + }) +}) diff --git a/src/modules/modals/CreateContext.vue b/src/modules/modals/CreateContext.vue index e09891003d..0316b65f9a 100644 --- a/src/modules/modals/CreateContext.vue +++ b/src/modules/modals/CreateContext.vue @@ -60,7 +60,11 @@
- + {{ t('tables', 'Create application') }}
@@ -75,7 +79,7 @@ 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 { toMenuItemPayload } from '../../shared/utils/menuItems.js' +import { areMenuItemsValid, 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' @@ -121,6 +125,16 @@ export default { 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) { diff --git a/src/shared/components/ApplicationIcon.vue b/src/shared/components/ApplicationIcon.vue index e38c28e2b4..e01608b791 100644 --- a/src/shared/components/ApplicationIcon.vue +++ b/src/shared/components/ApplicationIcon.vue @@ -41,7 +41,13 @@ export default { iconName: { immediate: true, async handler(iconName) { - this.svg = iconName ? await this.getContextIcon(iconName) : null + // Counts the loads, so an icon that a newer choice overtook does not replace it + this.iconRequest = (this.iconRequest ?? 0) + 1 + const request = this.iconRequest + const svg = iconName ? await this.getContextIcon(iconName) : null + if (request === this.iconRequest) { + this.svg = svg + } }, }, }, diff --git a/src/shared/components/ncContextResource/MenuItemsEditor.vue b/src/shared/components/ncContextResource/MenuItemsEditor.vue index 821d720b2e..23c83cf89c 100644 --- a/src/shared/components/ncContextResource/MenuItemsEditor.vue +++ b/src/shared/components/ncContextResource/MenuItemsEditor.vue @@ -12,16 +12,24 @@ :key="item.key" class="menu-items-editor__item" :class="{ 'menu-items-editor__item--child': item.parentKey !== null, 'menu-items-editor__item--dragging': draggedIndex === index }" - draggable="true" + :aria-label="itemName(item)" + :draggable="handleIndex === index" data-cy="menuItemRow" @dragstart="dragStart(index, $event)" @dragover.prevent="dragOver(index)" @dragend="dragEnd"> -