Skip to content
Merged
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
27 changes: 25 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
import { generateUrl } from '@nextcloud/router'
import { setActivePinia } from 'pinia'
import { createApp, h, markRaw } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import appIcons from './icons.js'
import bundledManifest from './manifest.json'
Expand Down Expand Up @@ -263,6 +263,29 @@ const registryProp = { ...registry }
setActivePinia(pinia)
registerObjectTypes()

/**
* The router base for THIS page load.
*
* ⚠️ `generateUrl('/apps/pipelinq')` alone is not enough. Nextcloud serves the
* app under BOTH `/apps/pipelinq/...` and `/index.php/apps/pipelinq/...`, but
* `generateUrl()` returns only the form the instance is configured for. A
* visitor arriving on the other form — a bookmark, an emailed deep link, an
* integration that hardcodes `/index.php` — falls outside the router base,
* vue-router cannot resolve the path, and the catch-all redirects to `/`. They
* land on the dashboard with no error: the deep link is silently swallowed.
*
* Hash routing never had this, because the route travelled in the fragment and
* the path prefix was irrelevant. This app's e2e suite pins the `/index.php`
* form (`const APP = '/index.php/apps/pipelinq'` in several specs), so without
* this every deep link the suite makes would break.
*
* @return {string} The base path vue-router should strip from the URL.
*/
function routerBase() {
const match = window.location.pathname.match(/^(.*\/apps\/pipelinq)(?:\/|$)/)
return match ? match[1] : generateUrl('/apps/pipelinq')
}

/**
* Mount the Vue instance onto #content. The router is built here — not at
* module scope — because persisted overrides can add or remove pages, and
Expand All @@ -273,7 +296,7 @@ registerObjectTypes()
function mountApp(manifest) {
const router = createRouter({
// vue-router 4 replaces `mode: 'hash'` + `base` with a history object.
history: createWebHashHistory(generateUrl('/apps/pipelinq')),
history: createWebHistory(routerBase()),
routes: routesFromManifest(manifest),
})
// Vue 3: `createApp(...).mount()` replaces `new Vue(...).$mount()`, and
Expand Down
18 changes: 13 additions & 5 deletions tests/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ test.describe('Sales dashboard', () => {
})

test('renders the dashboard page for the default route', async ({ page }) => {
await expect(page).toHaveURL(/#\/$|#\/$|\/apps\/pipelinq\/(#\/)?$/)
// History routing: the default route is the app root as a plain path.
// Anchored at the end so it holds under both the `/apps/...` and
// `/index.php/apps/...` bases Nextcloud serves.
await expect(page).toHaveURL(/\/apps\/pipelinq\/$/)
await expect(
page.locator('[data-testid="cn-page"]'),
'the default route must render a page, not an empty shell',
Expand Down Expand Up @@ -107,16 +110,21 @@ test.describe('Sales dashboard', () => {
})

test('each KPI widget links into a filtered view', async ({ page }) => {
// The KPI tiles are anchors into hash routes. Asserting that each one
// HAS a hash href catches the regression that matters (a tile that
// The KPI tiles are anchors into in-app routes. Asserting that each one
// HAS an in-app href catches the regression that matters (a tile that
// stopped linking) without pinning the exact destination of every tile,
// which is manifest configuration and moves with the product.
//
// The shell routes on HISTORY now, so these are paths rather than `#/`
// fragments, and the base differs between the `/apps/...` and
// `/index.php/apps/...` forms Nextcloud serves — hence a contains-match
// on the app segment rather than a prefix-match on the whole href.
const linked = page.locator(
'[role="group"][aria-label="pipeline-coverage"] a[href^="#/"]',
'[role="group"][aria-label="pipeline-coverage"] a[href*="/apps/pipelinq/"]',
)
await expect(
linked.first(),
'the pipeline-coverage tile must link into a filtered view',
).toHaveAttribute('href', /^#\//)
).toHaveAttribute('href', /\/apps\/pipelinq\/.+/)
})
})
7 changes: 4 additions & 3 deletions tests/e2e/event-wiring.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ async function openProjectDetail(
// openApp() already ran in beforeEach — calling it again here doubled the
// shell boot and pushed each test past its budget.
//
// The shell routes on the hash; a path deep-link resets it to the Dashboard
// (see helpers/pipelinq.ts).
await page.goto(`/apps/pipelinq/#/projects/${PROJECT_ID}`)
// The shell routes on HISTORY, so this path deep-link lands on the project
// detail directly. It used to reset the SPA to the Dashboard under hash
// routing (see helpers/pipelinq.ts).
await page.goto(`/apps/pipelinq/projects/${PROJECT_ID}`)
await expect(page.locator('.wbs-tree, [class*=wbs]').first()).toBeVisible({
timeout: 30000,
})
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/global-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ async function assertAppBoots(page: import('@playwright/test').Page): Promise<vo

for (const route of [
'/index.php/apps/pipelinq/',
'/index.php/apps/pipelinq/#/clients',
'/index.php/apps/pipelinq/clients',
]) {
consoleErrors.length = 0
await page.goto(route, { waitUntil: 'domcontentloaded' })
Expand Down
23 changes: 16 additions & 7 deletions tests/e2e/helpers/pipelinq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
*
* Shared helpers for the spec-coverage e2e suite.
*
* Pipelinq mounts at #content-vue. Deep-link `page.goto('/apps/pipelinq/<route>')`
* resets the SPA back to the Dashboard, so navigation between pages MUST go
* through a sidebar nav-click. Two app-chrome overlays can cover the content
* Pipelinq mounts at #content-vue. The shell routes on HISTORY, so
* `page.goto('/apps/pipelinq/<route>')` is a valid deep link and lands on that
* route — it used to reset the SPA to the Dashboard under hash routing, which
* is why older specs navigate by sidebar click instead. Both work now. Two app-chrome overlays can cover the content
* and intercept clicks — the fleet-wide `cn-support-dialog` and the first-visit
* `cn-walkthrough` product tour. Both are dismissed before interacting; see
* dismissSupportDialog() and dismissWalkthrough().
Expand Down Expand Up @@ -190,12 +191,20 @@ export async function revealNavEntryByTestId(
* against correct navigation.
*/
export async function revealNavEntry(page: Page, label: string): Promise<Locator> {
// Restrict to the leaf ENTRY anchor (href contains `#/<route>`), NOT the nav
// GROUP CAPTION. getByRole('link', { name }) would otherwise match the
// caption first and the click is a no-op (router stays on `#/`).
// Restrict to the leaf ENTRY anchor, NOT the nav GROUP CAPTION.
// getByRole('link', { name }) would otherwise match the caption first and
// the click is a no-op (the router stays put).
//
// The discriminator is the href: under history routing a leaf entry points
// at `/apps/pipelinq/<route>`, while a collapsible group caption still
// renders a bare `href="#"` — so matching the app segment selects exactly
// the leaves. (This used to filter on `href*="#/"`, which stopped matching
// anything the moment the shell left hash routing.)
// Filter by exact visible text so the entry — not the caption — is targeted.
const link = page
.locator('#app-navigation-vue a.app-navigation-entry-link[href*="#/"]')
.locator(
'#app-navigation-vue a.app-navigation-entry-link[href*="/apps/pipelinq/"]',
)
.filter({
hasText: new RegExp(
`^\\s*${label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
Expand Down
74 changes: 47 additions & 27 deletions tests/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@
* the navigation, so every assertion here goes through the manifest page
* id instead — `data-testid="cn-nav-entry-<pageId>"`, untranslated by
* construction.
* 2. PATH ROUTES — `href="/apps/pipelinq/clients"`. The shell routes on the
* HASH (`#/clients`), and helpers/pipelinq.ts documents why a path
* deep-link is worse than wrong: it resets the SPA to the Dashboard.
* 2. PATH ROUTES — `href="/apps/pipelinq/clients"`. That was WRONG when this
* file was rewritten, because the shell routed on the hash (`#/clients`)
* and a path deep-link reset the SPA to the Dashboard. It is RIGHT again
* as of the move to history routing — but the assertion now compares the
* route as a SUFFIX, because the base differs between the `/apps/...` and
* `/index.php/apps/...` forms and both are legitimate.
* 3. FLAT VISIBILITY. Since the 2026-07 IA revision most leaves sit inside a
* collapsed group. Measured on this build: 37 entries in the DOM, 11
* visible at load. `toBeVisible()` on a nested leaf asserts the old flat
Expand All @@ -36,26 +39,33 @@ import { test, expect } from '@playwright/test'
import { openApp, revealNavEntryByTestId } from './helpers/pipelinq'

/**
* Manifest page id → hash route. Verified live against the running app on
* 2026-08-24 by reading every `[data-testid^="cn-nav-entry-"]` and its href.
* Manifest page id → route PATH. Verified live against the running app by
* reading every `[data-testid^="cn-nav-entry-"]` and its href.
*
* These are compared as a SUFFIX of the href, not as the whole value, because
* the shell now routes on history and the router base legitimately differs by
* URL form: Nextcloud serves the app as both `/apps/pipelinq/...` and
* `/index.php/apps/pipelinq/...`, and vue-router emits whichever base the page
* was loaded under. Asserting the full href would pin the test to one of the
* two and fail on the other for a reason that is not a routing defect.
*/
const REQUIRED_ENTRIES: Record<string, string> = {
Dashboard: '#/',
Clients: '#/clients',
Contacts: '#/contacts',
Leads: '#/leads',
Tickets: '#/tickets',
Tasks: '#/tasks',
Products: '#/products',
Pipeline: '#/pipeline',
Queues: '#/queues',
Contracts: '#/contracts',
MyWork: '#/my-work',
Prospects: '#/prospects',
Forecast: '#/forecast',
Services: '#/services',
Resources: '#/resources',
Bookings: '#/bookings',
Dashboard: '/',
Clients: '/clients',
Contacts: '/contacts',
Leads: '/leads',
Tickets: '/tickets',
Tasks: '/tasks',
Products: '/products',
Pipeline: '/pipeline',
Queues: '/queues',
Contracts: '/contracts',
MyWork: '/my-work',
Prospects: '/prospects',
Forecast: '/forecast',
Services: '/services',
Resources: '/resources',
Bookings: '/bookings',
}

/**
Expand Down Expand Up @@ -97,22 +107,27 @@ test.describe('Sidebar navigation (manifest-driven shell)', () => {
)
})

test('each sidebar entry points at its hash route', async ({ page }) => {
test('each sidebar entry points at its route', async ({ page }) => {
const wrong: string[] = []
for (const [pageId, href] of Object.entries(REQUIRED_ENTRIES)) {
for (const [pageId, route] of Object.entries(REQUIRED_ENTRIES)) {
const link = page
.locator(
`#app-navigation-vue [data-testid="cn-nav-entry-${pageId}"]`,
)
.locator('xpath=descendant-or-self::a[1]')
.first()
const actual = await link.getAttribute('href').catch(() => null)
if (actual !== href)
wrong.push(`${pageId}: expected ${href}, got ${actual}`)
// Suffix, not equality — see the note on REQUIRED_ENTRIES: the base
// differs between the `/apps/...` and `/index.php/apps/...` forms and
// both are correct.
if (actual === null || actual.endsWith(route) === false)
wrong.push(
`${pageId}: expected an href ending ${route}, got ${actual}`,
)
}
expect(
wrong,
'the shell routes on the hash; a path href resets the SPA to the Dashboard',
'the shell routes on history; every nav href must end with its manifest route',
).toEqual([])
})

Expand Down Expand Up @@ -140,7 +155,12 @@ test.describe('Sidebar navigation (manifest-driven shell)', () => {
const link = await revealNavEntryByTestId(page, 'Clients')
await expect(link).toBeVisible({ timeout: 10000 })
await link.click()
await expect(page).toHaveURL(/#\/clients/, { timeout: 10000 })
// History routing: the URL is a real path. Matched at the END so the
// assertion holds under both the `/apps/...` and `/index.php/apps/...`
// bases Nextcloud serves.
await expect(page).toHaveURL(/\/apps\/pipelinq\/clients$/, {
timeout: 10000,
})
await expect(
page.locator('[data-testid="cn-index-page"]'),
'the Clients route must render its index page, not an empty shell',
Expand Down
6 changes: 3 additions & 3 deletions tests/e2e/rapportage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ test.describe('Rapportage (Reporting)', () => {
// route directly via the SPA hash. A path-form goto boots the shell at the
// Dashboard; a hash goto mounts the target view. Reload once so the view
// re-queries its KPI data after the same-document hash change.
await page.goto('/apps/pipelinq/#/rapportage/contactmomenten')
await page.goto('/apps/pipelinq/rapportage/contactmomenten')
await expect(page.locator('body')).not.toContainText('Internal Server Error')
await page.reload()
})
Expand Down Expand Up @@ -107,15 +107,15 @@ test.describe('Rapportage (Reporting)', () => {
test('channel analytics page loads', async ({ page }) => {
// Deep-link via the SPA hash; a path-form goto boots the shell at the
// Dashboard instead of the target view.
await page.goto('/apps/pipelinq/#/rapportage/channels')
await page.goto('/apps/pipelinq/rapportage/channels')
await page.reload()
await expect(
page.getByRole('heading', { name: /Channel Analytics|Kanaalanalyse/i }),
).toBeVisible({ timeout: 15000 })
})

test('agent performance page loads', async ({ page }) => {
await page.goto('/apps/pipelinq/#/rapportage/agents')
await page.goto('/apps/pipelinq/rapportage/agents')
await page.reload()
await expect(
page.getByRole('heading', {
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/spec-coverage/align-claims-first-hour.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ test.describe('Operational dashboard — no permanently-null widgets', () => {
test.beforeEach(async ({ page }) => {
// The shared dev instance can be slow to fire `load`; DOMContentLoaded
// is enough — the assertions below wait for the widgets themselves.
await page.goto('/apps/pipelinq/#/operational', {
await page.goto('/apps/pipelinq/operational', {
waitUntil: 'domcontentloaded',
})
await expect(page.locator('body')).not.toContainText('Internal Server Error')
Expand Down Expand Up @@ -130,7 +130,7 @@ test.describe('Demo-data seed setup action', () => {
test('seeded demo clients render in the Clients list', async ({ page }) => {
test.setTimeout(90000)
await autoDismissWalkthrough(page)
await page.goto('/apps/pipelinq/#/clients')
await page.goto('/apps/pipelinq/clients')
await page.reload()

// Wait for the table to load rows.
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/spec-coverage/appointment-booking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ function hoursFromNow(hours: number): string {
* spec-coverage/outbound-messaging.spec.ts).
*/
async function gotoHash(page: Page, hash: string): Promise<void> {
await page.goto(`/apps/pipelinq/#${hash}`)
await page.goto(`/apps/pipelinq/${hash}`)
await expect(page.locator('#content-vue')).toBeVisible({ timeout: 15000 })
await dismissWalkthrough(page)
await dismissSupportDialog(page)
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/spec-coverage/bi-export-jobs-bug.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { openApp, assertNoHardError, trackPipelinqErrors } from '../helpers/pipe
// reload so the index re-queries its data (matches bi-export.spec.ts).
async function gotoExportJobs(page: Page) {
await openApp(page)
await page.goto('/apps/pipelinq/#/export/jobs')
await page.goto('/apps/pipelinq/export/jobs')
await page.reload()
await page
.locator('#content-vue')
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/spec-coverage/billing-categories.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ test('Billing categories: the widget renders on the Operational dashboard', asyn
page,
}) => {
await openApp(page)
await page.goto('/apps/pipelinq/#/operational')
await page.goto('/apps/pipelinq/operational')
await page.reload()

const content = page.locator('#content-vue')
Expand Down Expand Up @@ -63,7 +63,7 @@ test('Billing categories: the widget sits alongside the other operational widget
page,
}) => {
await openApp(page)
await page.goto('/apps/pipelinq/#/operational')
await page.goto('/apps/pipelinq/operational')
await page.reload()

const content = page.locator('#content-vue')
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/spec-coverage/commercial-dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test('Operational dashboard: previous widgets remain reachable from the nav', as

// Deep-link the OperationalDashboard via the SPA hash (`/operational`); a
// path-form goto boots the shell at the default Commercial dashboard.
await page.goto('/apps/pipelinq/#/operational')
await page.goto('/apps/pipelinq/operational')
await page.reload()

const content = page.locator('#content-vue')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ test.beforeEach(() => {

/** Open the first client's detail page and return the quick-log form. */
async function openQuickLog(page: Page) {
await page.goto('/apps/pipelinq/#/clients')
await page.goto('/apps/pipelinq/clients')
await dismissWalkthrough(page)
await dismissSupportDialog(page)

Expand All @@ -47,7 +47,7 @@ async function openQuickLog(page: Page) {
})
const id = await row.getAttribute('data-testid-row-id')
expect(id, 'the client row must carry its id').toBeTruthy()
await page.goto(`/apps/pipelinq/#/clients/${id}`)
await page.goto(`/apps/pipelinq/clients/${id}`)

const client = page.locator('[data-testid="contactmoment-form-client"]')
await expect(
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/spec-coverage/dashboard-analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { openApp, trackPipelinqErrors, assertNoHardError } from '../helpers/pipe
* surface as spurious "Failed to fetch" console noise.
*/
async function openOperational(page) {
await page.goto('/apps/pipelinq/#/operational')
await page.goto('/apps/pipelinq/operational')
await expect(page.locator('#app-navigation-vue')).toBeVisible({ timeout: 15000 })
await page.reload()
await page
Expand Down
Loading
Loading