From a5210fff5c5aaf3d9164368c4b58e434e3ba9b9e Mon Sep 17 00:00:00 2001 From: Remko Date: Wed, 14 Oct 2026 10:35:26 +0200 Subject: [PATCH 1/4] feat(applications): overview page to manage pages and menu - inside Tables, an application opens on an overview: its name, address, settings and a table of its pages, instead of its tables and views - the tables and views of an application show where it is used: opened on its own - the menu is edited in place on the overview, with the same checks as in the create dialog - a page with widgets added from the overview gets a menu entry right away and opens Signed-off-by: Remko Assisted-by: ClaudeCode:claude-opus-5-5 --- cypress/component/menuItems.cy.js | 13 + playwright/e2e/application.spec.ts | 50 ++ playwright/e2e/context.spec.ts | 46 +- src/modules/modals/GridViewSettings.vue | 15 +- src/modules/modals/Modals.vue | 13 +- src/pages/Context.vue | 617 ++++++++++++++++++++++-- src/shared/utils/menuItems.js | 19 + src/store/store.js | 25 + 8 files changed, 748 insertions(+), 50 deletions(-) diff --git a/cypress/component/menuItems.cy.js b/cypress/component/menuItems.cy.js index 7c3ba5f8b7..8b3647cf76 100644 --- a/cypress/component/menuItems.cy.js +++ b/cypress/component/menuItems.cy.js @@ -16,6 +16,7 @@ import { outdentItem, parentOfPosition, removeItem, + toEditableMenuItems, toMenuItemPayload, } from '../../src/shared/utils/menuItems.js' @@ -118,6 +119,18 @@ describe('menu items', () => { expect(dragItemOver(items, 2, 2)).to.equal(null) }) + it('turns the stored menu into editor items that keep their parent', () => { + const editable = toEditableMenuItems([ + { id: 1, label: 'Cases', icon: 'star', targetType: 'table', targetId: 4, url: null, technicalName: 'cases', parentId: null }, + { id: 2, label: 'Docs', targetType: 'url', url: 'https://example.org', technicalName: 'docs', parentId: 1 }, + ]) + + expect(editable.map(entry => entry.key)).to.deep.equal(['item-1', 'item-2']) + expect(editable[0]).to.deep.include({ label: 'Cases', icon: 'star', targetType: 'table', targetId: 4, url: '', parentKey: null }) + expect(editable[1]).to.deep.include({ icon: null, targetId: null, url: 'https://example.org', parentKey: 'item-1' }) + expect(toEditableMenuItems(null)).to.deep.equal([]) + }) + 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') diff --git a/playwright/e2e/application.spec.ts b/playwright/e2e/application.spec.ts index 61e565ff88..01441819d6 100644 --- a/playwright/e2e/application.spec.ts +++ b/playwright/e2e/application.spec.ts @@ -6,6 +6,7 @@ import type { Page } from '@playwright/test' import { test, expect } from '../support/fixtures' import { ocsRequest, type TestUser } from '../support/api' +import { createContext, loadContext } from '../support/commands' /** * Creates a grid view and an application whose menu opens it, through the API. @@ -48,4 +49,53 @@ test.describe('Applications', () => { // 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') }) + + test('A page with widgets added on the overview gets a menu entry and opens', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createContext(page, 'overview application') + await loadContext(page, 'overview application') + + await page.locator('[data-cy="context-add-page"]').getByRole('button').first().click() + await page.getByRole('menuitem', { name: 'Page with widgets' }).click() + const dialog = page.locator('[data-cy="gridViewSettingsModal"]') + await dialog.getByRole('textbox', { name: 'Title', exact: true }).fill('Intake home') + await dialog.locator('[data-cy="gridViewSubmit"]').click() + + await expect(page).toHaveURL(/\/menu\/intake_home$/) + await expect(page.locator('[data-cy="context-breadcrumb"]')).toContainText('Intake home') + await expect(page.locator('[data-cy="gridViewHeading"]')).toContainText('Intake home') + + await page.locator('[data-cy="context-menu-overview"]').click() + await expect(page.locator('[data-cy="page-card"]').filter({ hasText: 'Intake home' })).toContainText('Page with widgets') + }) + + test('A menu entry is reordered with its buttons, sits under another one and keeps its place after a reload', async ({ userPage: { page } }) => { + await page.goto('/index.php/apps/tables') + await createContext(page, 'nested menu application') + await loadContext(page, 'nested menu application') + + await page.locator('[data-cy="context-menu-edit"]').click() + for (const [label, url] of [['Handbook', 'https://docs.nextcloud.com'], ['Forum', 'https://help.nextcloud.com']]) { + await page.locator('[data-cy="menuItemAdd"]').click() + await page.locator('[data-cy="menuItemLabel"] input').last().fill(label) + await page.locator('[data-cy="menuItemLink"] input').last().fill(url) + } + + // Without a pointer: move Forum to the top, then put Handbook under it + await page.locator('[data-cy="menuItemRow"]').nth(1).locator('[data-cy="menuItemMoveUp"]').click() + await expect(page.locator('[data-cy="menuItemLabel"] input').first()).toHaveValue('Forum') + await page.locator('[data-cy="menuItemRow"]').nth(1).locator('[data-cy="menuItemIndent"]').click() + await expect(page.locator('[data-cy="menuItemRow"]').nth(1).locator('[data-cy="menuItemOutdent"]')).toBeVisible() + + const saveResponse = page.waitForResponse(response => response.url().includes('/apps/tables/api/2/contexts/') && response.request().method() === 'PUT') + await page.locator('[data-cy="context-menu-save"]').click() + expect((await saveResponse).status()).toBe(200) + + await page.reload() + const pages = page.locator('[data-cy="page-card"]') + await expect(pages).toHaveCount(2) + await expect(pages.nth(0)).toContainText('Forum') + await expect(pages.nth(1)).toContainText('1.1') + await expect(pages.nth(1)).toContainText('Handbook') + }) }) diff --git a/playwright/e2e/context.spec.ts b/playwright/e2e/context.spec.ts index 76fae80ef5..8afce5a67d 100644 --- a/playwright/e2e/context.spec.ts +++ b/playwright/e2e/context.spec.ts @@ -27,6 +27,20 @@ async function setupContextAndNavigate(page: Page, contextTitle: string) { await loadContext(page, contextTitle) } +/** + * Inside Tables the application page is where an application is configured; its tables and + * views render in the application itself. + */ +async function openApplication(page: Page, contextTitle: string) { + await loadContext(page, contextTitle) + const contextId = page.url().match(/\/application\/(\d+)/)?.[1] + if (!contextId) { + throw new Error(`Could not find the id of application ${contextTitle}`) + } + await page.goto(`/index.php/apps/tables/app/${contextId}`) + await expect(page.locator('[data-cy="application-nav-header"]')).toContainText(contextTitle, { timeout: 10000 }) +} + async function selectFromVueDropdown(page: Page, inputSelector: string, value: string) { const input = page.locator(inputSelector).first() await input.click() @@ -92,11 +106,10 @@ test.describe('Manage a context', () => { await expect(page.getByRole('dialog', { name: 'Edit application' })).toBeHidden({ timeout: 10000 }) await expect(page.locator('[data-cy="navigationContextItem"]').filter({ hasText: `updated ${contextTitle}` })).toBeVisible() - await loadContext(page, `updated ${contextTitle}`) + await openApplication(page, `updated ${contextTitle}`) - await expect(page.locator('h1', { hasText: `updated ${contextTitle}` })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: viewTitle })).toBeVisible() + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) + await expect(page.locator('h1', { hasText: viewTitle })).toBeVisible({ timeout: 10000 }) }) test('Share context with resources', async ({ userPage: { page }, request }) => { @@ -119,8 +132,7 @@ test.describe('Manage a context', () => { await updateResponse await expect(page.locator('[class*="toast_success"]').first()).toBeVisible({ timeout: 10000 }) - await loadContext(page, contextTitle) - await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) + await openApplication(page, contextTitle) await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) // verify context was shared properly @@ -128,9 +140,8 @@ test.describe('Manage a context', () => { await login(page, nonLocalUser) await page.goto('/index.php/apps/tables') - await loadContext(page, contextTitle) - await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + await openApplication(page, contextTitle) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) }) test('Transfer context', async ({ userPage: { page }, request }) => { @@ -221,8 +232,11 @@ test.describe('Manage a context', () => { await page.locator('[data-cy="editContextSubmitBtn"]').click() await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + await openApplication(page, contextTitle) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) + await page.goto('/index.php/apps/tables') + await loadContext(page, contextTitle) await openContextEditModal(page, contextTitle) await page.locator('[data-cy="contextResourceList"] button').filter({ hasText: 'Delete' }).click({ force: true }) @@ -230,6 +244,9 @@ test.describe('Manage a context', () => { await page.locator('[data-cy="editContextSubmitBtn"]').click() await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() + await openApplication(page, contextTitle) + // The application has loaded once its loading icon is gone; only then does a missing table mean anything + await expect(page.locator('.main-context-view > .icon-loading')).toHaveCount(0, { timeout: 10000 }) await expect(page.locator('h1', { hasText: tableTitle })).toBeHidden() }) @@ -248,7 +265,10 @@ test.describe('Manage a context', () => { await expect(page.locator('[data-cy="contextResourceList"]')).toContainText(tableTitle) await expect(page.locator('[data-cy="contextResourcePerms"]')).toContainText(tableTitle) await page.locator('[data-cy="editContextSubmitBtn"]').click() + await expect(page.getByRole('dialog', { name: 'Edit application' })).toBeHidden({ timeout: 10000 }) + await openApplication(page, contextTitle) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) await createTextLineColumn(page, 'title', '', '', true) await page.locator('button').filter({ hasText: 'Create row' }).first().click() await fillInValueTextLine(page, 'title', 'first row') @@ -293,6 +313,7 @@ test.describe('Manage a context', () => { await updatePermsResponse await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible({ timeout: 10000 }) + await openApplication(page, contextTitle) await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) // verify that shared user can modify and delete data in the resource @@ -300,9 +321,8 @@ test.describe('Manage a context', () => { await login(page, nonLocalUser) await page.goto('/index.php/apps/tables') - await loadContext(page, contextTitle) - await expect(page.locator('h1', { hasText: contextTitle })).toBeVisible() - await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible() + await openApplication(page, contextTitle) + await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 10000 }) await page.locator('button').filter({ hasText: 'Create row' }).first().click() await fillInValueTextLine(page, 'title', 'first row') diff --git a/src/modules/modals/GridViewSettings.vue b/src/modules/modals/GridViewSettings.vue index 9b83bb5f25..2115d34f7c 100644 --- a/src/modules/modals/GridViewSettings.vue +++ b/src/modules/modals/GridViewSettings.vue @@ -66,8 +66,13 @@ export default { type: Object, default: null, }, + // Passed on with the created event, so an application can add the new view to its menu + contextId: { + type: Number, + default: null, + }, }, - emits: ['close'], + emits: ['close', 'created'], data() { return { title: '', @@ -97,7 +102,8 @@ export default { this.saving = false }, /** - * Saves the view, or creates it and opens it. + * Saves the view, or creates it and opens it. A view created for an application is opened + * by the application, through its menu. */ async submit() { this.errorTitle = this.title.trim() === '' @@ -117,8 +123,11 @@ export default { const created = await this.insertStandaloneView({ data }) this.saving = false if (created) { + this.$emit('created', { view: created, contextId: this.contextId }) this.$emit('close') - await this.$router.push('/view/' + created.id).catch(err => err) + if (this.contextId === null) { + await this.$router.push('/view/' + created.id).catch(err => err) + } } }, }, diff --git a/src/modules/modals/Modals.vue b/src/modules/modals/Modals.vue index 8edae596c7..9b5de53c08 100644 --- a/src/modules/modals/Modals.vue +++ b/src/modules/modals/Modals.vue @@ -38,6 +38,8 @@ +import { emit } from '@nextcloud/event-bus' import { useEventBusSubscriptions } from '../../shared/composables/useEventBusSubscriptions.js' import CreateRow from './CreateRow.vue' import ImportScheme from './ImportScheme.vue' @@ -159,7 +162,7 @@ export default { } this.viewToEdit = { ...view, createView: false } }) - this.subscribeToEventBus('tables:view:create-grid', () => { this.gridView = { view: null } }) + this.subscribeToEventBus('tables:view:create-grid', payload => { this.gridView = { view: null, contextId: payload?.contextId ?? null } }) this.subscribeToEventBus('tables:view:create', tableInfos => { this.viewToEdit = { view: { tableId: tableInfos.tableId, sort: [], filter: [] }, @@ -196,6 +199,14 @@ export default { }, methods: { + /** + * Tells the application a grid view was created from, so it can add the view to its menu. + * + * @param {{view: object, contextId: number|null}} payload the created view and its application + */ + emitGridViewCreated(payload) { + emit('tables:view:grid-created', payload) + }, onSaveNewColumn(event) { if (this.createColumnInfo?.onSave) { this.createColumnInfo.onSave(event) diff --git a/src/pages/Context.vue b/src/pages/Context.vue index 34c615c0d9..2a732acd8a 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -52,19 +52,225 @@ -
-
-

-   {{ - activeContext.name }} -

-
-
- {{ activeContext.description }} -
+
+
+
+ +
+

+ {{ activeContext.name }} +

+

+ {{ activeContext.description }} +

+ +
+
+
+ + + {{ t('tables', 'Open application') }} + + + + + {{ t('tables', 'Edit name, icon and members') }} + + + + {{ t('tables', 'Import a scheme') }} + + + + {{ t('tables', 'Transfer ownership') }} + + + + + {{ t('tables', 'Delete application') }} + + +
+
+ +
+
+

{{ t('tables', 'Pages') }}

+
+ + + + + {{ t('tables', 'Page with widgets') }} + + + + {{ t('tables', 'Table or view') }} + + + + {{ t('tables', 'Link') }} + + +
+
+

+ {{ t('tables', 'No pages yet. Every page gets an entry in the menu of the application.') }} +

+
+ + + + + + + + + + + + + + + + + + + +
+ + {{ t('tables', 'Position') }} + + {{ t('tables', 'Page') }} + + {{ t('tables', 'Type') }} + + {{ t('tables', 'Shows') }} + + {{ t('tables', 'Actions') }} +
+ {{ page.position }} + + + + {{ page.item.label }} + + {{ page.item.label }} + {{ page.typeLabel }} + {{ page.description }} + + + + {{ t('tables', 'Design') }} + + + {{ t('tables', 'Open') }} + + + {{ t('tables', 'Open') }} + +
+
+
+ +
+
+

{{ t('tables', 'Menu') }}

+
+ + {{ t('tables', 'Cancel') }} + + + + {{ t('tables', 'Save menu') }} + + + + {{ t('tables', 'Edit menu') }} + +
+
+

+ {{ t('tables', 'The menu is what people see on the left when they open the application. Order it the way they work.') }} +

+ +
    +
  1. + + {{ page.item.label }} + {{ page.description }} +
  2. +
+

+ {{ t('tables', 'The menu is empty. Add a page, or edit the menu to link tables and external links.') }} +

+
-
+
import MainModals from '../modules/modals/Modals.vue' import { mapState, mapActions, storeToRefs } from 'pinia' -import { NcButton, NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon } from '@nextcloud/vue' +import { NcActionButton, NcActionSeparator, NcActions, NcButton, NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon } from '@nextcloud/vue' import { loadState } from '@nextcloud/initial-state' import { generateUrl } from '@nextcloud/router' import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue' +import ContentCopy from 'vue-material-design-icons/ContentCopy.vue' +import ContentSaveOutline from 'vue-material-design-icons/ContentSaveOutline.vue' +import DeleteOutline from 'vue-material-design-icons/DeleteOutline.vue' +import LinkVariant from 'vue-material-design-icons/LinkVariant.vue' import OpenInNew from 'vue-material-design-icons/OpenInNew.vue' +import PencilOutline from 'vue-material-design-icons/PencilOutline.vue' +import Plus from 'vue-material-design-icons/Plus.vue' +import SwapHorizontal from 'vue-material-design-icons/SwapHorizontal.vue' +import TableIcon from 'vue-material-design-icons/Table.vue' +import TrayArrowDown from 'vue-material-design-icons/TrayArrowDown.vue' +import ViewDashboardOutline from 'vue-material-design-icons/ViewDashboardOutline.vue' import GridView from '../modules/grid/GridView.vue' +import MenuItemIcon from '../shared/components/MenuItemIcon.vue' +import MenuItemsEditor from '../shared/components/ncContextResource/MenuItemsEditor.vue' import permissionsMixin from '../shared/components/ncTable/mixins/permissionsMixin.js' -import { menuItemRoute } from '../shared/utils/menuItems.js' +import { useEventBusSubscriptions } from '../shared/composables/useEventBusSubscriptions.js' +import { areMenuItemsValid, menuItemRoute, menuTree, toEditableMenuItems, toMenuItemPayload } from '../shared/utils/menuItems.js' import TableWrapper from '../modules/main/sections/TableWrapper.vue' import CustomView from '../modules/main/sections/View.vue' import { emit } from '@nextcloud/event-bus' @@ -110,18 +329,33 @@ import { useTablesStore } from '../store/store.js' import { useDataStore } from '../store/data.js' import ErrorMessage from '../modules/main/partials/ErrorMessage.vue' import displayError, { getNotFoundError, getGenericLoadError } from '../shared/utils/displayError.js' -import { showError } from '@nextcloud/dialogs' +import { showError, showSuccess } from '@nextcloud/dialogs' export default { components: { MainModals, + NcActionButton, + NcActionSeparator, + NcActions, NcButton, NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon, ChevronLeft, + ContentCopy, + ContentSaveOutline, + DeleteOutline, + LinkVariant, OpenInNew, + PencilOutline, + Plus, + SwapHorizontal, + TableIcon, + TrayArrowDown, + ViewDashboardOutline, GridView, + MenuItemIcon, + MenuItemsEditor, ErrorMessage, TableWrapper, CustomView, @@ -131,7 +365,7 @@ export default { setup() { const store = useDataStore() const { getColumns, getRows } = storeToRefs(store) - return { getColumns, getRows } + return { getColumns, getRows, ...useEventBusSubscriptions() } }, data() { @@ -147,6 +381,8 @@ export default { menuResource: null, menuViewSetting: {}, menuTargetLoading: false, + menuDraft: null, + menuSaving: false, standaloneContextId: loadState('tables', 'contextId', null), openedFirstPage: false, } @@ -188,6 +424,40 @@ export default { const stored = this.views.find(view => view.id === this.menuResource.id) return stored ? { ...this.menuResource, ...stored, isView: true } : this.menuResource }, + /** + * The menu items in menu order, each with what it opens, for the pages table and the menu preview. + * + * @return {Array} + */ + pages() { + const ordered = [] + menuTree(this.menuItems).forEach((branch, rootIndex) => { + ordered.push({ item: branch.item, position: String(rootIndex + 1), level: 0 }) + branch.children.forEach((child, childIndex) => ordered.push({ item: child, position: (rootIndex + 1) + '.' + (childIndex + 1), level: 1 })) + }) + return ordered.map(({ item, position, level }) => { + const page = { item, position, level, route: menuItemRoute(item, this.activeContext.id), view: null } + if (item.targetType === 'url') { + return { ...page, kind: 'url', typeLabel: t('tables', 'Link'), description: item.url } + } + if (item.broken) { + return { ...page, kind: 'broken', typeLabel: t('tables', 'Missing'), description: t('tables', 'The view or table this entry opened no longer exists') } + } + if (item.targetType === 'table') { + const table = this.tables.find(table => table.id === item.targetId) + return { ...page, kind: 'table', typeLabel: t('tables', 'Table'), description: table ? n('tables', '%n row', '%n rows', table.rowsCount ?? 0) : t('tables', 'Not accessible to you') } + } + const view = this.views.find(view => view.id === item.targetId) + if (view?.type === 'grid') { + return { ...page, kind: 'grid', typeLabel: t('tables', 'Page with widgets'), description: n('tables', '%n widget', '%n widgets', view.grid?.widgets?.length ?? 0), view } + } + const table = view ? this.tables.find(table => table.id === view.tableId) : null + const description = view + ? (table ? t('tables', 'View of {table}', { table: table.title }, undefined, { escape: false }) : view.title) + : t('tables', 'Not accessible to you') + return { ...page, kind: 'view', typeLabel: t('tables', 'View'), description, view } + }) + }, rows() { const rows = {} if (this.context && this.context.nodes) { @@ -257,18 +527,90 @@ export default { }, async mounted() { - // An application opened on its own shows its menu in the navigation, which must stay open - if (!this.isStandalone) { - emit('toggle-navigation', { - open: false, - }) - } + this.subscribeToEventBus('tables:view:grid-created', this.onGridViewCreated) await this.reload() }, methods: { - ...mapActions(useTablesStore, ['loadContext', 'validateExportAccess', 'loadContextTable', 'loadContextView']), + ...mapActions(useTablesStore, ['loadContext', 'validateExportAccess', 'loadContextTable', 'loadContextView', 'updateContextMenuItems']), ...mapActions(useDataStore, ['loadColumnsFromBE', 'loadRowsFromBE', 'loadRelationsFromBE']), + async copyAddress() { + try { + await navigator.clipboard.writeText(this.applicationUrl) + showSuccess(t('tables', 'Address copied')) + } catch (e) { + showError(t('tables', 'The address could not be copied. Select it and copy it yourself.')) + } + }, + editApplication() { + emit('tables:context:edit', this.activeContext.id) + }, + importScheme() { + emit('tables:context:import-scheme', this.activeContext.id) + }, + transferApplication() { + emit('tables:context:transfer', this.activeContext) + }, + deleteApplication() { + emit('tables:context:delete', this.activeContext) + }, + /** + * Opens the menu editor, with a fresh item of the given kind appended when asked for. + * + * @param {string|null} appendKind 'table' or 'url' to start a new entry of that kind + */ + editMenu(appendKind = null) { + const items = toEditableMenuItems(this.menuItems) + if (appendKind !== null) { + items.push({ key: 'new-' + Date.now(), label: '', icon: null, targetType: appendKind, targetId: null, url: '', technicalName: null, parentKey: null }) + } + this.menuDraft = items + this.$nextTick(() => document.querySelector('[data-cy="context-menu-editor"]')?.scrollIntoView({ behavior: 'smooth', block: 'start' })) + }, + async saveMenu() { + // Show every error now, and never send a menu the server would refuse + this.$refs.menuItemsEditor?.touchAll() + if (this.menuDraft === null || !areMenuItemsValid(this.menuDraft)) { + return + } + this.menuSaving = true + const context = await this.updateContextMenuItems({ id: this.activeContext.id, menuItems: this.menuDraft.map(item => toMenuItemPayload(item, this.menuDraft)) }) + this.menuSaving = false + if (context) { + this.menuDraft = null + showSuccess(t('tables', 'Menu saved')) + } + }, + addGridView() { + emit('tables:view:create-grid', { contextId: this.activeContext.id }) + }, + /** + * A grid view created from this application gets a menu item right away, and opens. + * + * @param {{view: object, contextId: number|null}} payload the created view and the application it was created from + */ + async onGridViewCreated({ view, contextId }) { + if (!view || contextId !== this.activeContext?.id) { + return + } + const menuItems = [ + ...this.menuItems.map(item => ({ + label: item.label, + icon: item.icon, + targetType: item.targetType, + targetId: item.targetId, + url: item.url, + technicalName: item.technicalName, + parentIndex: item.parentId != null ? this.menuItems.findIndex(candidate => candidate.id === item.parentId) : null, + })), + { label: view.title, icon: null, targetType: 'view', targetId: view.id, url: null, technicalName: null, parentIndex: null }, + ] + const context = await this.updateContextMenuItems({ id: this.activeContext.id, menuItems }) + const added = context?.menuItems?.find(item => item.targetType === 'view' && item.targetId === view.id) + if (added) { + await this.$router.push(menuItemRoute(added, this.activeContext.id)).catch(err => err) + } + }, /** * An application opened on its own starts on its first page, not on the resource overview. * Only once: the overview stays reachable from the menu afterwards. @@ -495,17 +837,181 @@ export default {