From 08478db311263504de0fc7d10ec57b78ce30455e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 1 Sep 2026 09:30:41 +0200 Subject: [PATCH] feat(router): move opencatalogi off hash routing to clean path URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third app in the move off `#` routing, after stackiq (softwarecatalog#899) and zaakafhandelapp (#609). Same two parts as those: 1. createWebHashHistory -> createWebHistory. 2. routerBase(), derived from the URL actually being served. Nextcloud serves the app under BOTH /apps/opencatalogi/... and /index.php/apps/opencatalogi/..., but generateUrl() returns only the form the instance is configured for. Arriving on the other leaves the path outside the router base, vue-router cannot resolve it, and the catch-all redirects to '/' -- the visitor lands on the dashboard with no error and the deep link is silently swallowed. This app matters more than most here: its own e2e suite uses BOTH spellings -- /index.php/apps/opencatalogi in _nav.ts and the visual spec, /apps/opencatalogi in docs-screenshots.spec.ts -- so either would have broken without routerBase(). 7 spec files built URLs as `${APP}/#/` (and one as `${APP}/#${route}`); all now use real paths. Verified: eslint exits 0, prettier --check on the FULL glob (**/*.{js,ts,vue,css,scss}, tests included) is clean, webpack compiles. ⚠️ NOT yet verified in a browser. The shared dev container has been cycling in and out of maintenance mode throughout, so every deep-link probe returned 503. The pattern is proven on the two apps above (stackiq 18/18, zaakafhandelapp 24/24 after re-running three environment-flap failures), but this app's own deep links and reload behaviour still need a run against a stable instance before merge. --- src/main.js | 29 +++++++++++++++++-- tests/e2e/spec-coverage/_nav.ts | 2 +- .../spec-coverage/catalog-detail-page.spec.ts | 2 +- tests/e2e/spec-coverage/gate19.spec.ts | 2 +- .../e2e/spec-coverage/page-components.spec.ts | 2 +- .../usage-analytics-page.spec.ts | 6 ++-- tests/e2e/visual/opencatalogi.visual.spec.ts | 6 ++-- tests/e2e/workflows/federation-search.spec.ts | 4 +-- 8 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/main.js b/src/main.js index bc87d5cbb..c99e15463 100644 --- a/src/main.js +++ b/src/main.js @@ -31,7 +31,7 @@ import { import { generateUrl } from '@nextcloud/router' import hljs from 'highlight.js' import { createApp, h } from 'vue' -import { createRouter, createWebHashHistory } from 'vue-router' +import { createRouter, createWebHistory } from 'vue-router' import App from './App.vue' import AuditTrailWidget from './components/widgets/AuditTrailWidget.vue' import ThemePreviewWidget from './components/widgets/ThemePreviewWidget.vue' @@ -284,8 +284,33 @@ function routesFromManifest(manifest) { return routes } +/** + * The router base for THIS page load. + * + * ⚠️ `generateUrl('/apps/opencatalogi')` alone is not enough. Nextcloud serves + * the app under BOTH `/apps/opencatalogi/...` and + * `/index.php/apps/opencatalogi/...`, 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 own e2e suite uses BOTH spellings + * — `/index.php/apps/opencatalogi` in `_nav.ts` and the visual spec, + * `/apps/opencatalogi` in `docs-screenshots.spec.ts` — so either would break + * without this. + * + * @return {string} The base path vue-router should strip from the URL. + */ +function routerBase() { + const match = window.location.pathname.match(/^(.*\/apps\/opencatalogi)(?:\/|$)/) + return match ? match[1] : generateUrl('/apps/opencatalogi') +} + const router = createRouter({ - history: createWebHashHistory(generateUrl('/apps/opencatalogi')), + history: createWebHistory(routerBase()), routes: routesFromManifest(resolvedManifest), }) diff --git a/tests/e2e/spec-coverage/_nav.ts b/tests/e2e/spec-coverage/_nav.ts index a56999afc..daf3b1495 100644 --- a/tests/e2e/spec-coverage/_nav.ts +++ b/tests/e2e/spec-coverage/_nav.ts @@ -287,7 +287,7 @@ export function fatalErrors(errors: string[]): string[] { * @param route The in-app route, leading slash included (e.g. '/glossary'). */ export async function navToRoute(page: Page, route: string): Promise { - await page.goto(`${APP}/#${route}`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP}${route}`, { waitUntil: 'domcontentloaded' }) await page.waitForTimeout(1500) await dismissOverlays(page) await expect(page.locator('[data-testid="cn-nav"]').first()).toBeVisible({ diff --git a/tests/e2e/spec-coverage/catalog-detail-page.spec.ts b/tests/e2e/spec-coverage/catalog-detail-page.spec.ts index d291496ac..5844a2589 100644 --- a/tests/e2e/spec-coverage/catalog-detail-page.spec.ts +++ b/tests/e2e/spec-coverage/catalog-detail-page.spec.ts @@ -91,7 +91,7 @@ test.describe('catalog-detail-page', () => { }) expect(id, 'a catalog id must be resolvable from the list').toBeTruthy() - await page.goto(`${APP}/#/catalogi/${id}`, { + await page.goto(`${APP}/catalogi/${id}`, { waitUntil: 'domcontentloaded', }) await page.waitForTimeout(1500) diff --git a/tests/e2e/spec-coverage/gate19.spec.ts b/tests/e2e/spec-coverage/gate19.spec.ts index 32faf815a..e9afdbded 100644 --- a/tests/e2e/spec-coverage/gate19.spec.ts +++ b/tests/e2e/spec-coverage/gate19.spec.ts @@ -101,7 +101,7 @@ async function openIndexRoute(page: Page, route: string): Promise { * @param route The in-app route (e.g. '/catalogi/123'). */ async function gotoHash(page: Page, route: string): Promise { - await page.goto(`${APP}/#${route}`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP}${route}`, { waitUntil: 'domcontentloaded' }) await page.waitForTimeout(1500) await dismissOverlays(page) } diff --git a/tests/e2e/spec-coverage/page-components.spec.ts b/tests/e2e/spec-coverage/page-components.spec.ts index 85a4ca50b..e7e57b930 100644 --- a/tests/e2e/spec-coverage/page-components.spec.ts +++ b/tests/e2e/spec-coverage/page-components.spec.ts @@ -157,7 +157,7 @@ async function gotoHash( page: import('@playwright/test').Page, route: string, ): Promise { - await page.goto(`${APP}/#${route}`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP}${route}`, { waitUntil: 'domcontentloaded' }) await page.waitForTimeout(1500) await dismissOverlays(page) } diff --git a/tests/e2e/spec-coverage/usage-analytics-page.spec.ts b/tests/e2e/spec-coverage/usage-analytics-page.spec.ts index 2db5ec665..561143ded 100644 --- a/tests/e2e/spec-coverage/usage-analytics-page.spec.ts +++ b/tests/e2e/spec-coverage/usage-analytics-page.spec.ts @@ -153,7 +153,7 @@ test.describe('usage-analytics', () => { // The Publications index for the catalog (hash route — path-form // gotos boot the Dashboard in this hash-mode SPA). - await page.goto(`${APP}/#/publications/${slug}`, { + await page.goto(`${APP}/publications/${slug}`, { waitUntil: 'domcontentloaded', }) await page.waitForTimeout(1500) @@ -163,7 +163,7 @@ test.describe('usage-analytics', () => { ).toBeVisible({ timeout: 15000 }) // The publication detail page for a real publication. - await page.goto(`${APP}/#/publications/${slug}/${pubId}`, { + await page.goto(`${APP}/publications/${slug}/${pubId}`, { waitUntil: 'domcontentloaded', }) await page.waitForTimeout(1500) @@ -208,7 +208,7 @@ test.describe('usage-analytics', () => { const slug = await resolveCatalogSlug(request) const pubId = await resolvePublicationId(request) await bootApp(page) - await page.goto(`${APP}/#/publications/${slug}/${pubId}`, { + await page.goto(`${APP}/publications/${slug}/${pubId}`, { waitUntil: 'domcontentloaded', }) await page.waitForTimeout(1500) diff --git a/tests/e2e/visual/opencatalogi.visual.spec.ts b/tests/e2e/visual/opencatalogi.visual.spec.ts index 1781401f9..ae5a4d44c 100644 --- a/tests/e2e/visual/opencatalogi.visual.spec.ts +++ b/tests/e2e/visual/opencatalogi.visual.spec.ts @@ -34,7 +34,7 @@ const APP = '/index.php/apps/opencatalogi' test.describe('OpenCatalogi — visual baselines', () => { test('dashboard', async ({ page }) => { - await shootSurface(page, `${APP}/#/`, 'dashboard.png') + await shootSurface(page, `${APP}/`, 'dashboard.png') }) test('publications list', async ({ page, request }) => { @@ -54,10 +54,10 @@ test.describe('OpenCatalogi — visual baselines', () => { // Boot the SPA, then take the in-app hash route (path-form gotos boot // the Dashboard in this hash-mode SPA; see tests/e2e/spec-coverage/_nav.ts). - await page.goto(`${APP}/#/`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' }) await dismissSupportDialog(page) await waitForContentReady(page) - await page.goto(`${APP}/#/publications/${slug}`, { + await page.goto(`${APP}/publications/${slug}`, { waitUntil: 'domcontentloaded', }) await page.waitForTimeout(1500) diff --git a/tests/e2e/workflows/federation-search.spec.ts b/tests/e2e/workflows/federation-search.spec.ts index 20ea15cc0..191a01359 100644 --- a/tests/e2e/workflows/federation-search.spec.ts +++ b/tests/e2e/workflows/federation-search.spec.ts @@ -100,7 +100,7 @@ test.describe('Federation search page', () => { } }) - await page.goto(`${APP}/#/search`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP}/search`, { waitUntil: 'domcontentloaded' }) await dismissSupportDialog(page) // The page must actually mount a search surface. Asserting on the @@ -139,7 +139,7 @@ test.describe('Federation search page', () => { } }) - await page.goto(`${APP}/#/search`, { waitUntil: 'domcontentloaded' }) + await page.goto(`${APP}/search`, { waitUntil: 'domcontentloaded' }) await dismissSupportDialog(page) const searchInput = appSearchBox(page)