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
12 changes: 12 additions & 0 deletions playwright/e2e/application.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,24 @@
await page.getByRole('menuitem', { name: 'Edit name, icon and members' }).click()
await page.locator('[data-cy="editContextTechnicalName"]').fill(technicalName)
await page.locator('[data-cy="editContextSubmitBtn"]').click()
await expect(page.locator('[data-cy="context-address"]')).toContainText('/apps/tables/app/' + technicalName)

Check failure on line 93 in playwright/e2e/application.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright (stable35)

[chromium] › playwright/e2e/application.spec.ts:82:2 › Applications › An application with a technical name is opened at its readable address

1) [chromium] › playwright/e2e/application.spec.ts:82:2 › Applications › An application with a technical name is opened at its readable address Error: expect(locator).toContainText(expected) failed Locator: locator('[data-cy="context-address"]') Expected substring: "/apps/tables/app/intake_1791980401395" Received string: "http://localhost:8089/index.php/apps/tables/app/3" Timeout: 30000ms Call log: - Expect "toContainText" locator('[data-cy="context-address"]') with timeout 30000ms - waiting for locator('[data-cy="context-address"]') 64 × locator resolved to <a rel="noopener" target="_blank" data-v-d5157f51="" class="context__address" data-cy="context-address" href="http://localhost:8089/index.php/apps/tables/app/3">http://localhost:8089/index.php/apps/tables/app/3</a> - unexpected value "http://localhost:8089/index.php/apps/tables/app/3" 91 | await page.locator('[data-cy="editContextTechnicalName"]').fill(technicalName) 92 | await page.locator('[data-cy="editContextSubmitBtn"]').click() > 93 | await expect(page.locator('[data-cy="context-address"]')).toContainText('/apps/tables/app/' + technicalName) | ^ 94 | 95 | await page.goto('/index.php/apps/tables/app/' + technicalName) 96 | await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('named application') at /home/runner/work/tables/tables/playwright/e2e/application.spec.ts:93:61

Check failure on line 93 in playwright/e2e/application.spec.ts

View workflow job for this annotation

GitHub Actions / Playwright (stable34)

[chromium] › playwright/e2e/application.spec.ts:82:2 › Applications › An application with a technical name is opened at its readable address

1) [chromium] › playwright/e2e/application.spec.ts:82:2 › Applications › An application with a technical name is opened at its readable address Error: expect(locator).toContainText(expected) failed Locator: locator('[data-cy="context-address"]') Expected substring: "/apps/tables/app/intake_1791980422830" Received string: "http://localhost:8089/index.php/apps/tables/app/3" Timeout: 30000ms Call log: - Expect "toContainText" locator('[data-cy="context-address"]') with timeout 30000ms - waiting for locator('[data-cy="context-address"]') 64 × locator resolved to <a rel="noopener" target="_blank" data-v-d5157f51="" class="context__address" data-cy="context-address" href="http://localhost:8089/index.php/apps/tables/app/3">http://localhost:8089/index.php/apps/tables/app/3</a> - unexpected value "http://localhost:8089/index.php/apps/tables/app/3" 91 | await page.locator('[data-cy="editContextTechnicalName"]').fill(technicalName) 92 | await page.locator('[data-cy="editContextSubmitBtn"]').click() > 93 | await expect(page.locator('[data-cy="context-address"]')).toContainText('/apps/tables/app/' + technicalName) | ^ 94 | 95 | await page.goto('/index.php/apps/tables/app/' + technicalName) 96 | await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('named application') at /home/runner/work/tables/tables/playwright/e2e/application.spec.ts:93:61

await page.goto('/index.php/apps/tables/app/' + technicalName)
await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('named application')
})

test('All applications are listed on one page that opens each of them', async ({ userPage: { page } }) => {
await page.goto('/index.php/apps/tables')
await createContext(page, 'listed application')

await page.locator('[data-cy="navigationAllApplications"] a').first().click()
const card = page.locator('[data-cy="application-card"]').filter({ hasText: 'listed application' })
await expect(card).toBeVisible()
await card.click()

await expect(page.locator('[data-cy="context-title"]')).toContainText('listed application')
})

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')
Expand Down
3 changes: 3 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ export default {
|| document.querySelector(`header .header-left .app-menu a[href="${url}"]`)
this.switchActiveMenuEntry(targetElement)
}
} else if (currentRoute.path === '/applications') {
this.setActiveContextId(null)
this.setPageTitle(t('tables', 'Applications'))
} else if (currentRoute.path.startsWith('/application/')) {
const contextId = parseInt(currentRoute.params.contextId)
this.setActiveContextId(contextId)
Expand Down
8 changes: 8 additions & 0 deletions src/modules/navigation/sections/Navigation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@
</template>
</NcAppNavigationCaption>

<NcAppNavigationItem :name="t('tables', 'All applications')" to="/applications" data-cy="navigationAllApplications">
<template #icon>
<ViewGridOutline :size="20" />
</template>
</NcAppNavigationItem>

<template v-for="node in getAllContexts" :key="node.id">
<NavigationContextItem :context="node" />
</template>
Expand Down Expand Up @@ -121,6 +127,7 @@ import { emit } from '@nextcloud/event-bus'
import { useEventBusSubscriptions } from '../../../shared/composables/useEventBusSubscriptions.js'
import Magnify from 'vue-material-design-icons/Magnify.vue'
import ArchiveOutline from 'vue-material-design-icons/ArchiveOutline.vue'
import ViewGridOutline from 'vue-material-design-icons/ViewGridOutline.vue'
import { getCurrentUser } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { mapState, mapActions } from 'pinia'
Expand All @@ -138,6 +145,7 @@ export default {
NcTextField,
Magnify,
ArchiveOutline,
ViewGridOutline,
NcButton,
NcCounterBubble,
NcEmptyContent,
Expand Down
147 changes: 147 additions & 0 deletions src/pages/Applications.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="applications-page" data-cy="applications-page">
<div class="applications-page__header">
<h1>{{ t('tables', 'Applications') }}</h1>
<NcButton variant="primary" data-cy="applications-create" @click="createContext">
<template #icon>
<Plus :size="20" />
</template>
{{ t('tables', 'Create application') }}
</NcButton>
</div>

<NcEmptyContent v-if="contexts.length === 0"
:name="t('tables', 'No applications yet')"
:description="t('tables', 'An application bundles views and tables behind one menu.')">
<template #icon>
<ViewGridOutline />
</template>
</NcEmptyContent>

<ul v-else class="applications-page__list">
<li v-for="context in sortedContexts" :key="context.id">
<router-link :to="'/application/' + context.id" class="application-card" data-cy="application-card">
<ApplicationIcon :icon-name="context.iconName" :size="32" />
<div class="application-card__text">
<h2 class="application-card__title">
{{ context.name }}
</h2>
<p v-if="context.description" class="application-card__description">
{{ context.description }}
</p>
<p class="application-card__meta">
{{ n('tables', '%n menu item', '%n menu items', (context.menuItems ?? []).length) }}
<template v-if="context.technicalName">

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Minor — the card shows /technical_name for other people's applications

#29 addresses other people's applications by id, because technical names are only unique per owner and /apps/tables/app/{name} resolves to the viewer's own application first. This card still shows the name for a shared application, suggesting an address that may open a different app.

Impact: a misleading address on shared applications.

Suggested fix: v-if="context.technicalName && ownsContext(context)" (with permissionsMixin).

· /{{ context.technicalName }}
</template>
</p>
</div>
</router-link>
</li>
</ul>

<MainModals />
</div>
</template>

<script>
import { NcButton, NcEmptyContent } from '@nextcloud/vue'
import Plus from 'vue-material-design-icons/Plus.vue'
import ViewGridOutline from 'vue-material-design-icons/ViewGridOutline.vue'
import { emit } from '@nextcloud/event-bus'
import { mapState } from 'pinia'
import MainModals from '../modules/modals/Modals.vue'
import ApplicationIcon from '../shared/components/ApplicationIcon.vue'
import { useTablesStore } from '../store/store.js'

export default {
name: 'Applications',
components: {
NcButton,
NcEmptyContent,
Plus,
ViewGridOutline,
MainModals,
ApplicationIcon,
},
computed: {
...mapState(useTablesStore, ['contexts']),
sortedContexts() {
return [...this.contexts].sort((a, b) => a.name.localeCompare(b.name))
},
},
methods: {
createContext() {
emit('tables:context:create')
},
},
}
</script>

<style lang="scss" scoped>
.applications-page {
width: 100%;
padding: calc(4 * var(--default-grid-baseline));

&__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: calc(4 * var(--default-grid-baseline));
margin-bottom: calc(4 * var(--default-grid-baseline));

h1 {
// Leave room for the navigation toggle that floats over the top left corner of the content
margin: 0;
margin-inline-start: var(--default-clickable-area);
}
}

&__list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: calc(4 * var(--default-grid-baseline));
list-style: none;
}
}

.application-card {
display: flex;
gap: calc(3 * var(--default-grid-baseline));
padding: calc(4 * var(--default-grid-baseline));
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
background-color: var(--color-main-background);
color: inherit;
text-decoration: none;

&:hover,
&:focus-visible {
background-color: var(--color-background-hover);
}

&__text {
min-width: 0;
}

&__title {
margin: 0;
font-size: var(--default-font-size);
font-weight: bold;
}

&__description {
margin: var(--default-grid-baseline) 0 0;
}

&__meta {
margin: var(--default-grid-baseline) 0 0;
color: var(--color-text-maxcontrast);
}
}
</style>
6 changes: 6 additions & 0 deletions src/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import MainViewWrapper from './pages/View.vue'
import MainDashboardWrapper from './pages/Table.vue'
import Startpage from './pages/Startpage.vue'
import Context from './pages/Context.vue'
import Applications from './pages/Applications.vue'
import PublicTableView from './pages/PublicTableView.vue'

export default createRouter({
Expand All @@ -18,6 +19,11 @@ export default createRouter({
path: '/',
component: Startpage,
},
{
path: '/applications',
component: Applications,
name: 'applications',
},
{
path: '/application/:contextId',
component: Context,
Expand Down
Loading