diff --git a/cypress/component/menuItems.cy.js b/cypress/component/menuItems.cy.js index d04be9db92..9f113fd4da 100644 --- a/cypress/component/menuItems.cy.js +++ b/cypress/component/menuItems.cy.js @@ -16,8 +16,10 @@ import { newDashboardItem, opensNewStartPage, outdentItem, + pageTitle, parentOfPosition, removeItem, + toEditableMenuItems, toMenuItemPayload, } from '../../src/shared/utils/menuItems.js' @@ -185,6 +187,26 @@ describe('menu items', () => { expect(menuItemErrors({ ...page, targetId: null }).target).to.equal(true) }) + it('names a page by its title, an untitled start page as the dashboard', () => { + expect(pageTitle({ title: 'Intake', page_type: 'grid' })).to.equal('Intake') + expect(pageTitle({ title: null, page_type: 'startpage' })).to.equal('Dashboard') + expect(pageTitle({ title: null, pageType: 'startpage' })).to.equal('Dashboard') + expect(pageTitle({ title: null, pageType: 'grid' })).to.equal('Untitled page') + expect(pageTitle({ title: 'Home', page_type: 'startpage' })).to.equal('Home') + }) + + 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 page, view or table entry inside the app and leaves a link outside it', () => { expect(menuItemRoute({ targetType: 'page', targetId: 12, technicalName: 'dashboard' }, 7)).to.equal('/application/7/menu/dashboard') expect(menuItemRoute({ targetType: 'view', technicalName: 'home' }, 7)).to.equal('/application/7/menu/home') diff --git a/playwright/e2e/application.spec.ts b/playwright/e2e/application.spec.ts index 9c7d814efa..3f95958fda 100644 --- a/playwright/e2e/application.spec.ts +++ b/playwright/e2e/application.spec.ts @@ -131,4 +131,95 @@ test.describe('Applications', () => { await expect(startPage).toContainText('Dashboard') await expect(page.locator('h1', { hasText: tableTitle })).toBeVisible({ timeout: 30000 }) }) + + test('The overview lists the pages; a page added there gets a menu entry and opens in edit mode', async ({ userPage: { page, user } }) => { + const contextResponse = await ocsRequest(page.request, user, { method: 'POST', url: `${API}/contexts?format=json`, data: { name: 'overview application', iconName: 'star' } }) + const contextId = (await contextResponse.json()).ocs.data.id + + await page.goto(`/index.php/apps/tables/#/application/${contextId}`) + await page.locator('[data-cy="context-overview-link"]').click() + await expect(page).toHaveURL(new RegExp(`/application/${contextId}/overview$`)) + + const cards = page.locator('[data-cy="page-card"]') + await expect(cards).toHaveCount(1) + await expect(cards.first()).toContainText('Dashboard') + await expect(cards.first()).toContainText('Start page') + await expect(cards.first()).toContainText('0 widgets') + + await page.locator('[data-cy="context-add-page"]').getByRole('button').first().click() + await page.getByRole('menuitem', { name: 'Page with widgets' }).click() + const form = page.getByRole('dialog', { name: 'Add page' }) + await form.getByRole('textbox', { name: 'Title' }).fill('Intake home') + await form.getByRole('textbox', { name: 'Description' }).fill('Where new requests come in') + await page.locator('[data-cy="contextPageSubmit"]').click() + + await expect(page).toHaveURL(/\/application\/\d+\/page\/\d+\?edit=1$/) + await expect(page.locator('[data-cy="gridPageTitle"]')).toContainText('Intake home') + await expect(page.locator('[data-cy="gridPageSave"]')).toBeVisible() + + await page.locator('[data-cy="gridPageCancel"]').click() + await page.locator('[data-cy="context-overview-link"]').click() + const intake = cards.filter({ hasText: 'Intake home' }) + await expect(intake).toContainText('Page with widgets') + await expect(page.locator('[data-cy="context-menu-preview-item"]').filter({ hasText: 'Intake home' })).toBeVisible() + + const menu = (await (await ocsRequest(page.request, user, { method: 'GET', url: `${API}/contexts/${contextId}?format=json` })).json()).ocs.data.menuItems + expect(menu.map((item: { label: string, technicalName: string }) => [item.label, item.technicalName])).toEqual([['Dashboard', 'dashboard'], ['Intake home', 'intake_home']]) + }) + + test('A menu entry is reordered with its buttons, sits under another one and keeps its place after a reload', async ({ userPage: { page, user } }) => { + const contextResponse = await ocsRequest(page.request, user, { method: 'POST', url: `${API}/contexts?format=json`, data: { name: 'nested menu application', iconName: 'star' } }) + const contextId = (await contextResponse.json()).ocs.data.id + await page.goto(`/index.php/apps/tables/#/application/${contextId}/overview`) + + 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() + const row = page.locator('[data-cy="menuItemRow"]').last() + await row.getByRole('textbox', { name: 'Label' }).fill(label) + await row.getByRole('textbox', { name: 'Link' }).fill(url) + } + + // Without a pointer: move Forum above Handbook, then put Handbook under it + const rows = page.locator('[data-cy="menuItemRow"]') + await rows.nth(2).locator('[data-cy="menuItemMoveUp"]').click() + await expect(rows.nth(1).getByRole('textbox', { name: 'Label' })).toHaveValue('Forum') + await rows.nth(2).locator('[data-cy="menuItemIndent"]').click() + await expect(rows.nth(2).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 items = page.locator('[data-cy="context-menu-preview-item"]') + await expect(items).toHaveCount(3) + await expect(items.nth(0)).toContainText('Dashboard') + await expect(items.nth(1)).toContainText('Forum') + await expect(items.nth(2)).toContainText('2.1') + await expect(items.nth(2)).toContainText('Handbook') + }) + + test('A menu entry can be pointed at a page of the application', async ({ userPage: { page, user } }) => { + const { contextId } = await createApplicationWithMenu(page.request, user, 'page target application') + await ocsRequest(page.request, user, { method: 'POST', url: `${API}/contexts/${contextId}/pages?format=json`, data: { title: 'Reports' } }) + + await page.goto(`/index.php/apps/tables/#/application/${contextId}/overview`) + await page.locator('[data-cy="context-menu-edit"]').click() + await page.locator('[data-cy="menuItemAdd"]').click() + const row = page.locator('[data-cy="menuItemRow"]').last() + await row.locator('[data-cy="menuItemTarget"] input').click() + await page.locator('.vs__dropdown-menu li').filter({ hasText: 'Reports (page)' }).click() + await expect(row.getByRole('textbox', { name: 'Label' })).toHaveValue('Reports') + + 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 expect(page.locator('[data-cy="context-menu-preview-item"]').filter({ hasText: 'Reports' }).last()).toBeVisible() + + await page.goto(`/index.php/apps/tables/app/${contextId}`) + await page.locator('[data-cy="application-nav-item"]').filter({ hasText: 'Reports' }).getByRole('link').click() + await expect(page).toHaveURL(/\/menu\/reports$/) + await expect(page.locator('[data-cy="gridPage"]')).toBeVisible() + }) }) diff --git a/src/modules/grid/GridPage.vue b/src/modules/grid/GridPage.vue index 26566ebe00..ac115c806f 100644 --- a/src/modules/grid/GridPage.vue +++ b/src/modules/grid/GridPage.vue @@ -158,6 +158,11 @@ export default { type: Boolean, default: false, }, + // Opens the page in edit mode, for a "Design" link + startInEditMode: { + type: Boolean, + default: false, + }, }, emits: ['saved', 'edit-details', 'delete'], setup(props) { @@ -226,6 +231,9 @@ export default { }, mounted() { window.addEventListener('beforeunload', this.warnBeforeUnload) + if (this.startInEditMode && this.canEdit) { + this.startEditing() + } }, beforeUnmount() { window.removeEventListener('beforeunload', this.warnBeforeUnload) diff --git a/src/modules/main/sections/ApplicationOverview.vue b/src/modules/main/sections/ApplicationOverview.vue new file mode 100644 index 0000000000..38a56aaa09 --- /dev/null +++ b/src/modules/main/sections/ApplicationOverview.vue @@ -0,0 +1,730 @@ + + + + + + diff --git a/src/pages/Context.vue b/src/pages/Context.vue index 345e482ce6..16554eca24 100644 --- a/src/pages/Context.vue +++ b/src/pages/Context.vue @@ -29,12 +29,26 @@ -
+ + +

  {{ activeContext.name }}

+ + + {{ t('tables', 'Overview') }} +
{{ activeContext.description }} @@ -60,6 +74,7 @@ :context="activeContext" :page="activePage" :can-edit="canManage" + :start-in-edit-mode="$route.query.edit === '1'" @saved="page => activePage = page" @edit-details="openPageForm" @delete="deletePage" /> @@ -72,6 +87,7 @@ :context="activeContext" :page="startPage" :can-edit="canManage" + :start-in-edit-mode="$route.query.edit === '1'" is-start-page @saved="page => startPage = page" /> @@ -111,6 +127,7 @@ import { NcButton, NcEmptyContent, NcIconSvgWrapper } from '@nextcloud/vue' import { loadState } from '@nextcloud/initial-state' import { generateUrl } from '@nextcloud/router' import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue' +import InformationOutline from 'vue-material-design-icons/InformationOutline.vue' import OpenInNew from 'vue-material-design-icons/OpenInNew.vue' import TableWrapper from '../modules/main/sections/TableWrapper.vue' import CustomView from '../modules/main/sections/View.vue' @@ -124,6 +141,7 @@ import ErrorMessage from '../modules/main/partials/ErrorMessage.vue' import ContextPageBar from '../modules/grid/ContextPageBar.vue' import GridPage from '../modules/grid/GridPage.vue' import DataWidget from '../modules/grid/widgets/DataWidget.vue' +import ApplicationOverview from '../modules/main/sections/ApplicationOverview.vue' import ContextPageForm from '../modules/modals/ContextPageForm.vue' import permissionsMixin from '../shared/components/ncTable/mixins/permissionsMixin.js' import displayError, { getNotFoundError, getGenericLoadError } from '../shared/utils/displayError.js' @@ -143,8 +161,10 @@ export default { NcEmptyContent, NcIconSvgWrapper, ChevronLeft, + InformationOutline, OpenInNew, DataWidget, + ApplicationOverview, ErrorMessage, TableWrapper, CustomView, @@ -200,7 +220,7 @@ export default { * @return {boolean} */ showsStartPage() { - if (this.activePageId !== null) { + if (this.activePageId !== null || this.isOverview) { return false } return !this.isMenuRoute || this.menuTarget?.startPage === true @@ -222,6 +242,14 @@ export default { isMenuRoute() { return !!this.$route.params.itemName }, + /** + * Whether the overview is shown, where the application is managed inside Tables. + * + * @return {boolean} + */ + isOverview() { + return this.$route.name === 'contextOverview' && !this.isStandalone + }, selectedMenuItem() { const technicalName = this.$route.params.itemName if (!technicalName) { @@ -656,6 +684,8 @@ export default { inset-inline-start: 0; top: 0; z-index: 15; + display: flex; + align-items: center; background-color: var(--color-main-background); width: var(--app-content-width, 100%); } @@ -664,6 +694,10 @@ export default { display: inline-flex; } + &__overview-link { + margin-inline: auto calc(4 * var(--default-grid-baseline)); + } + &__description { margin: calc(3 * var(--default-grid-baseline, 4px)); max-width: 790px; diff --git a/src/router.js b/src/router.js index 9cfb313453..adfe3c1cb7 100644 --- a/src/router.js +++ b/src/router.js @@ -28,6 +28,11 @@ export default createRouter({ component: Context, name: 'contextPage', }, + { + path: '/application/:contextId/overview', + component: Context, + name: 'contextOverview', + }, { path: '/application/:contextId/menu/:itemName', component: Context, diff --git a/src/shared/components/ncContextResource/MenuItemsEditor.vue b/src/shared/components/ncContextResource/MenuItemsEditor.vue index 502cf63864..f6cdc94301 100644 --- a/src/shared/components/ncContextResource/MenuItemsEditor.vue +++ b/src/shared/components/ncContextResource/MenuItemsEditor.vue @@ -46,8 +46,8 @@ ([]), }, + // The pages of the application an item can open; an application being created has none yet + pages: { + type: Array, + default: () => ([]), + }, }, emits: ['update:items'], data() { @@ -203,12 +209,23 @@ export default { computed: { ...mapState(useTablesStore, ['tables', 'views']), /** - * The tables and views the menu can open: only those the user can manage, because the server - * adds them to the application as resources, like the resource picker in the same dialog. + * The pages of the application, and the tables and views the menu can open: only those the + * user can manage, because the server adds them to the application as resources, like the + * resource picker in the same dialog. * * @return {Array} the options of the target select */ targetOptions() { + const pages = this.pages.map(page => { + const title = pageTitle(page) + return { + id: 'page-' + page.id, + targetType: 'page', + targetId: page.id, + title, + label: t('tables', '{title} (page)', { title }, undefined, { escape: false }), + } + }) const views = this.views.filter(view => this.canManageElement(view)).map(view => { const title = (view.emoji ? view.emoji + ' ' : '') + view.title return { @@ -216,9 +233,7 @@ export default { targetType: 'view', targetId: view.id, title, - label: view.type === 'grid' - ? t('tables', '{title} (page with widgets)', { title }, undefined, { escape: false }) - : t('tables', '{title} (view)', { title }, undefined, { escape: false }), + label: t('tables', '{title} (view)', { title }, undefined, { escape: false }), } }) const tables = this.tables.filter(table => this.canManageTable(table)).map(table => { @@ -231,7 +246,7 @@ export default { label: t('tables', '{title} (table)', { title }, undefined, { escape: false }), } }) - return [...views, ...tables, { id: URL_OPTION_ID, targetType: 'url', targetId: null, title: '', label: t('tables', 'External link') }] + return [...pages, ...views, ...tables, { id: URL_OPTION_ID, targetType: 'url', targetId: null, title: '', label: t('tables', 'External link') }] }, /** * What is missing or wrong in every item, by position. @@ -257,6 +272,7 @@ export default { indentItem, moveItem, newMenuItem, + opensNewStartPage, outdentItem, removeItem, /** @@ -275,7 +291,7 @@ export default { * @return {object|null} the selected option, or null when its target is not offered */ targetOption(item) { - if (item.targetType === 'page') { + if (item.targetType === 'page' && (opensNewStartPage(item) || !this.pages.some(page => page.id === item.targetId))) { return { id: 'page-' + (item.targetId ?? 'start'), targetType: 'page', @@ -313,7 +329,7 @@ export default { * @param {object|null} option the chosen option */ setTarget(index, option) { - if (!option || this.items[index].targetType === 'page') { + if (!option || opensNewStartPage(this.items[index])) { return } const changes = { targetType: option.targetType, targetId: option.targetId } diff --git a/src/shared/utils/menuItems.js b/src/shared/utils/menuItems.js index b44ce0816d..f93ef40e0c 100644 --- a/src/shared/utils/menuItems.js +++ b/src/shared/utils/menuItems.js @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { translate as t } from '@nextcloud/l10n' + /* * The menu of an application as the editor holds it: a flat, ordered list where a child carries * the key of its parent. A parent always precedes its children, there is one level of nesting, @@ -46,6 +48,25 @@ export function opensNewStartPage(item) { return item?.opensNewStartPage === true && item.targetType === 'page' } +/** + * Menu items as the API stores them, as items for the editor, with a stable key for list rendering. + * + * @param {Array|undefined|null} menuItems items from the application + * @return {Array} the items for the editor + */ +export function toEditableMenuItems(menuItems) { + return (menuItems ?? []).map((item, index) => ({ + key: 'item-' + (item.id ?? index), + label: item.label ?? '', + icon: item.icon ?? null, + targetType: item.targetType ?? 'url', + targetId: item.targetId ?? null, + url: item.url ?? '', + technicalName: item.technicalName ?? null, + parentKey: item.parentId != null ? 'item-' + item.parentId : null, + })) +} + /** * The payload the API expects for one menu item. * @@ -358,3 +379,13 @@ export function menuTree(menuItems) { const roots = sorted.filter(item => item.parentId == null) return roots.map(item => ({ item, children: sorted.filter(child => child.parentId === item.id) })) } + +/** + * The name of a page of an application: its title, or "Dashboard" for an untitled start page. + * + * @param {object} page a page as the context or the page API returns it + * @return {string} + */ +export function pageTitle(page) { + return page.title || ((page.page_type ?? page.pageType) === 'startpage' ? t('tables', 'Dashboard') : t('tables', 'Untitled page')) +} diff --git a/src/store/store.js b/src/store/store.js index 35362262f7..92f88aab0a 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -236,6 +236,33 @@ export const useTablesStore = defineStore('store', { return res.data.id }, + /** + * Replaces the menu of an application. + * + * @param {object} payload the application and its new menu + * @param {number} payload.id the id of the application + * @param {Array} payload.menuItems the menu as the API takes it + * @return {Promise} the updated application, or false when it could not be saved + */ + async updateContextMenuItems({ id, menuItems }) { + let res = null + try { + res = await axios.put(generateOcsUrl('/apps/tables/api/2/contexts/' + id), { menuItems }) + } catch (e) { + displayError(e, t('tables', 'Could not update application.')) + return false + } + + // The update answers with the application as stored; the read endpoint shapes its pages + // the way the rest of the app reads them + try { + await this.loadContext({ id }) + } catch { + // loadContext reported the error; the menu itself was saved + } + return this.contexts.find(c => c.id === id) ?? res.data.ocs.data + }, + async updateView({ id, data }) { let res = null