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 @@
+
+
+
+ {{ t('tables', 'The menu is what people see on the left when they open the application. Order it the way they work.') }}
+
+ {{ t('tables', 'The menu is empty. Add a page, or edit the menu to link pages, tables and external links.') }}
+ {{ t('tables', 'Pages') }}
+
+
+
+
+
+
+
+
+
+ {{ t('tables', 'Position') }}
+
+
+ {{ t('tables', 'Page') }}
+
+
+ {{ t('tables', 'Type') }}
+
+
+ {{ t('tables', 'Shows') }}
+
+
+ {{ t('tables', 'Actions') }}
+
+
+
+
+
+ {{ index + 1 }}
+
+
+
+ {{ row.typeLabel }}
+
+ {{ row.description }}
+
+
+
+ {{ t('tables', 'Menu') }}
+