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
118 changes: 118 additions & 0 deletions cypress/component/menuItems.cy.js
Original file line number Diff line number Diff line change
@@ -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<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)
})
})
6 changes: 4 additions & 2 deletions lib/Service/ContextService.php
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,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 @@ -576,7 +579,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
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, 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: [],
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 = []
this.errorTitle = false
this.setIcon(this.randomIcon())
this.customTitleChosen = false
Expand Down
55 changes: 55 additions & 0 deletions src/shared/components/ApplicationIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcIconSvgWrapper v-if="svg" :svg="svg" :size="size" />
<ViewGridOutline v-else :size="size" />
</template>

<script>
import { NcIconSvgWrapper } from '@nextcloud/vue'
import ViewGridOutline from 'vue-material-design-icons/ViewGridOutline.vue'
import svgHelper from './ncIconPicker/mixins/svgHelper.js'

/**
* The material icon of an application, loaded by name.
*/
export default {
name: 'ApplicationIcon',
components: {
NcIconSvgWrapper,
ViewGridOutline,
},
mixins: [svgHelper],
props: {
iconName: {
type: String,
default: null,
},
size: {
type: Number,
default: 20,
},
},
data() {
return {
svg: null,
}
},
watch: {
iconName: {
immediate: true,
async handler(iconName) {
// 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
}
},
},
},
}
</script>
Loading
Loading