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
25 changes: 15 additions & 10 deletions lib/Controller/PageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -226,18 +226,23 @@ public function index(string $deepLink = ''): TemplateResponse {
->setQuicksearchFallbackTarget($quicksearchFallback)
->apply();

// REQ-SHELL-001: pass the chrome slot ids so Nextcloud treats
// `#app-workspace` as the main content slot and allocates no left
// navigation panel (the runtime shell renders its own slide-in
// sidebar via `dashboard-switcher-sidebar`). Renderer parameter
// names match the Nextcloud chrome conventions.
// 🔴 NO CHROME SLOT IDS. This used to pass
// `'id-app-navigation' => null` (REQ-SHELL-001), which suppressed
// Nextcloud's left navigation panel because the app rendered its own
// slide-in sidebar and nothing else.
//
// `launchpad-manifest-tier-3` roots the app on `CnAppRoot`, which
// renders `NcContent` — and `NcContent` allocates that panel for
// `CnAppNav` itself. Suppressing it here would leave the shared
// chrome, ADR-114's four footer destinations included, with nowhere to
// render: not an error, an empty rail. Every other app in the fleet
// passes no slot ids and lets `NcContent` do it.
//
// The slide-in sidebar is unaffected; it lives inside the workspace
// view, not in the chrome slot.
$response = new TemplateResponse(
appName: Application::APP_ID,
templateName: 'index',
params: [
'id-app-content' => '#app-workspace',
'id-app-navigation' => null,
]
templateName: 'index'
);

$response->setContentSecurityPolicy(csp: $this->buildWorkspaceCsp());
Expand Down
48 changes: 36 additions & 12 deletions openspec/changes/launchpad-manifest-tier-3/proposal.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# LaunchPad manifest tier 3 — serve the pages the manifest declares

> **Status: implemented 2026-09-05.** Decisions below were taken by Ruben;
> what changed is in `tasks.md`.

`src/manifest.json` declares nine pages and eight menu entries. LaunchPad
serves one. `/store`, `/reports` and `/flows` each redirect to `/dashboard`,
and `/reports/dashboards` renders the workspace grid.
Expand Down Expand Up @@ -63,18 +66,39 @@ surface without breaking the first".
- `tests/e2e/app-chrome.spec.ts` — the four `test.fail()` markers this change
is named in come off, and the assertions become ordinary ones.

## Decisions needed

- **Does `CnAppRoot` host the workspace, or the other way round?** The org
navigation rail and the slide-in sidebar are LaunchPad's own and have no
equivalent in `CnAppNav`. Nesting the workspace inside `CnAppRoot`'s content
area gives two navigation systems on one page unless the rail is reconciled
with the nav.
- **The skip link.** `CnAppRoot` brings Nextcloud's; LaunchPad writes its own
against `#launchpad-main-content`. Two bypass links is worse than one.
- **Whether the dashboard keeps its URL.** Dashboards switch in Pinia today, so
a dashboard has no address. `dashboard-deeplinking` (an existing spec) wants
one. Routing is the natural place to settle that, and it widens this change.
## Decisions taken (2026-09-05)

- **`CnAppRoot` hosts the workspace, with the default `CnAppNav`.** The org
navigation rail is NOT an app menu and could not fold into one: it renders an
org-wide link tree fetched from `GET /api/admin/org-navigation`, group-
filtered, with its own position setting (REQ-ONAV-002/004/005), while
`CnAppNav` renders `manifest.menu`. They coexist, and the rail already renders
only when an admin has configured a tree — so a default instance has one
navigation, not two.
- **Nextcloud's skip link, not LaunchPad's.** The bespoke one existed *because*
the app did not root on `NcContent`; `CnAppRoot` renders it, so the platform
link is there and two bypass links would be worse than one.
`#launchpad-main-content` keeps `tabindex="-1"`, which the quick-search Esc
contract needs independently of any link.
- **Dashboard URLs are in scope.** `/dashboards/:id` was already declared in the
manifest and routed nowhere. `WorkspaceApp` watches the route param and calls
`switchDashboard`, so a dashboard can be linked, bookmarked and reopened
(`dashboard-deeplinking`).

## Found while implementing

Two of the nine declared pages were not in-app pages at all.
`admin-settings` named a component — `AdminSettingsPage` — that **has never
existed**, and `admin-templates-index` named `TemplatesPage`, which is a TAB
inside the Nextcloud admin section (`lib/Settings/LaunchPadAdmin.php`, mounted
by `src/admin.js`). Routing them would have rendered half an admin surface
beside the real one.

Both now resolve to `AdminSettingsRedirect`, which sends the operator to
`/settings/admin/launchpad`. The menu entries stop being dead links and land
where the functionality lives. The redirect renders a real anchor as well as
navigating, because a redirect that only runs in `mounted` leaves a blank page
for anyone whose navigation is slow or blocked.

## Alternative considered

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
status: in-progress
---

# Manifest routing

## Purpose

Make a page LaunchPad declares a page LaunchPad serves.

Before this change the app had no `vue-router` at all — `createRouter` appeared
nowhere in `src/` — and navigation was Pinia state that never touched the URL.
`src/manifest.json` declared nine pages and the app served one: `/store`,
`/reports` and `/flows` each redirected to `/dashboard`.

## Requirements

### Requirement: REQ-ROUTE-001 The route table is derived, not written

The router MUST build its routes from `manifest.pages`, so a declared page is a
routed page by construction and the two cannot drift.

The catch-all MUST use vue-router 4's named-parameter form
(`path: '/:pathMatch(.*)*'`). The bare `'*'` wildcard was removed in v4 and does
not warn — it simply never matches, and an unknown URL renders the shell with an
empty content area.

#### Scenario: A declared page resolves

- GIVEN `src/manifest.json` declares a page with a `route`
- WHEN a user navigates to that route
- THEN the router MUST match it
- AND the declared page type MUST render

### Requirement: REQ-ROUTE-002 The router base accepts both URL forms

Nextcloud serves an app under both `/apps/launchpad/…` and
`/index.php/apps/launchpad/…`. The router base MUST be read from the location,
falling back to `generateUrl`. A base that assumes one form makes every route on
the other miss, which presents as an empty content area rather than an error.

#### Scenario: index.php form

- GIVEN a visitor arrives on `/index.php/apps/launchpad/reports`
- THEN the Reports page MUST render

### Requirement: REQ-ROUTE-003 A dashboard has an address

`/dashboards/:id` MUST select that dashboard, so a dashboard can be linked,
bookmarked and reopened (`dashboard-deeplinking`).

An absent or unknown id MUST be left alone: the dashboard store's own resolver
already picks a sensible active dashboard, and overriding it would make a bad
link empty the page instead of falling back.

#### Scenario: Cold deep link

- GIVEN a user opens `/dashboards/<id>` in a fresh browser
- THEN that dashboard MUST become the active one

#### Scenario: Unknown id

- GIVEN a user opens `/dashboards/does-not-exist`
- THEN the store's resolved dashboard MUST still render

### Requirement: REQ-ROUTE-004 The shared chrome renders, with the workspace inside it

The app MUST root on `CnAppRoot`, which renders `CnAppNav` from `manifest.menu`
— including ADR-114's four footer destinations — around a `router-view`.

**This supersedes REQ-SHELL-001's chrome-slot clause.** That requirement had
`PageController` pass `'id-app-navigation' => null` to suppress Nextcloud's left
navigation panel, because the app rendered its own slide-in sidebar and nothing
else. `NcContent` allocates that panel for `CnAppNav`, so suppressing it would
leave the shared chrome with nowhere to render — an empty rail, not an error.
`PageController` MUST pass no chrome slot ids, as every other app in the fleet
does.

The slide-in sidebar is unaffected: it lives inside the workspace view, not in
the chrome slot.

#### Scenario: Both are present on the dashboard route

- GIVEN a user opens `/`
- THEN `CnAppNav` MUST render
- AND `.workspace-shell` MUST render inside the content area

### Requirement: REQ-ROUTE-005 One skip link

The app MUST NOT write its own skip link. It had one because it did not root on
`NcContent` and so did not inherit Nextcloud's; `CnAppRoot` renders `NcContent`,
and two bypass links are worse than one.

`#launchpad-main-content` MUST keep `tabindex="-1"`, which the quick-search Esc
contract needs independently of any link.

### Requirement: REQ-ROUTE-006 A declared page names a component that exists

A `type: custom` page MUST name a component the registry resolves.

`admin-settings` named `AdminSettingsPage`, which has never existed, and
`admin-templates-index` named `TemplatesPage`, which is a tab inside the
Nextcloud admin section rather than a page. Both MUST resolve to a redirect to
`/settings/admin/launchpad`, which is where that functionality lives — rendering
half an admin surface beside the real one would be worse than the dead links
they were.

The redirect MUST render a real anchor as well as navigating, because a redirect
that only runs in `mounted` leaves a blank page for anyone whose navigation is
slow or blocked, and gives a keyboard user nothing to act on.

#### Scenario: The admin menu entry is not a dead link

- GIVEN a user activates the Admin settings entry
- THEN they MUST arrive at `/settings/admin/launchpad`
52 changes: 36 additions & 16 deletions openspec/changes/launchpad-manifest-tier-3/tasks.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,51 @@
# Tasks: LaunchPad manifest tier 3

## Decide first
## Decided

- [ ] Settle whether `CnAppRoot` hosts `WorkspaceApp` or the workspace shell keeps the page and only borrows the footer chrome.
- [ ] Settle the skip link: one bypass target, not two.
- [ ] Settle whether a dashboard gets a URL, and whether that is this change or `dashboard-deeplinking`.
- [x] `CnAppRoot` hosts the workspace, with the default `CnAppNav`. The org rail is org-wide API data, not an app menu, so it coexists rather than folding in.
- [x] One skip link: Nextcloud's. `#launchpad-main-content` keeps `tabindex="-1"` for the quick-search Esc contract.
- [x] Dashboard URLs in scope.

## Routing

- [ ] Add `vue-router` and create the router in `src/main.js` in **path** mode (`createWebHistory`), matching the fleet: a hash route would be ignored and land silently on the dashboard.
- [ ] Derive the route table from `manifest.pages` rather than hand-wiring it, so a declared page is a routed page by construction.
- [ ] `/dashboard` (and `/`) render `WorkspaceApp`; `#launchpad-main-content` keeps `tabindex="-1"` and stays the bypass target.
- [ ] `/store`, `/reports`, `/reports/dashboards` and `/features-roadmap` render their declared page types.
- [x] `vue-router` (already a dependency, never used) created in `src/main.js` in **path** mode via `createWebHistory(routerBase())`. `routerBase()` reads the location and falls back to `generateUrl`, because Nextcloud serves the app under both `/apps/launchpad/…` and `/index.php/apps/launchpad/…` and a base that assumes one makes every route on the other miss — silently, as an empty content area.
- [x] `routesFromManifest()` derives the table from `manifest.pages`, so a declared page is a routed page by construction.
- [x] The v4 catch-all is `path: '/:pathMatch(.*)*'`. The bare `'*'` was removed in vue-router 4 and does not warn: it simply never matches.
- [x] `/` and `/dashboards/:id` render `WorkspaceApp`; `/store`, `/reports`, `/reports/dashboards`, `/features-roadmap`, `/flows` and `/flows/:id` render their declared page types.

## Manifest

- [x] `Workspace` declared at `/`. The app's home had no page at all.
- [x] `dashboard-detail` changed from `type: dashboard` to `custom` + `WorkspaceApp`. As a declarative `dashboard` page the renderer would have replaced this app's GridStack surface with the generic widget renderer.
- [x] `admin-settings` and `admin-templates-index` resolve to `AdminSettingsRedirect` (see the proposal's "Found while implementing").
- [x] Every `type: custom` page carries a `_note`.

## Shell

- [ ] Root on `CnAppRoot` per the decision above, keeping the org navigation rail and slide-in sidebar working.
- [ ] One skip link.
- [ ] `.workspace-shell` still renders on the dashboard route — the chrome spec's tripwire test asserts the shell, and it should keep passing.
- [x] `src/App.vue` roots on `CnAppRoot`, passing the live manifest, the registry and `defaultPageTypes`.
- [x] `src/registry.js` created — the v2 kind-tagged registry `CnPageRenderer` resolves `custom` components against.
- [x] The bespoke skip link and its `focusMainContent` handler are gone.
- [x] `OrgNavigationPanel` stays inside `WorkspaceApp`, unchanged.

## Dashboard URLs

- [x] `WorkspaceApp` watches `$route.params.id` (`immediate`, so a cold deep link selects on arrival) and calls `switchDashboard`. An absent or unknown id is left alone: the store's resolver already picks a sensible active dashboard, and overriding it would make a bad link empty the page instead of falling back.

## Tests

- [ ] Remove the four `test.fail()` markers in `tests/e2e/app-chrome.spec.ts`. They are expected-to-fail today; Playwright fails the run if a `test.fail()` test passes, so landing routing turns them red until the markers come off. That is the intended signal.
- [ ] The tripwire test `the shell is LaunchPad's own, not the shared CnAppNav one` asserts `cn-nav` has count 0. If `CnAppRoot` is adopted it must be rewritten, not deleted.
- [ ] A route test per declared page, so a page added to the manifest without a component fails.
- [x] The four `test.fail()` markers are removed — this is the change that earns it.
- [x] The tripwire test is INVERTED, not deleted: it asserted the absence of `CnAppNav` and now asserts the shared chrome renders *with the workspace still inside it*.
- [x] A new test asserts a dashboard has an address. It asks the app which dashboards the user has rather than seeding an id, because a hardcoded id passes on one instance and nowhere else.

## Verify

- [ ] gate-107 still reports 5 of 5, and now truthfully.
- [ ] The E2E leg is green on the `development` push run — it has not been since 2026-09-03.
- [x] `check:manifest`, `format`, `check:schema-l10n`, `lint` — this repo's CI checks — all pass.
- [x] `npm run build` compiles (webpack exit 0; the three warnings are the pre-existing bundle-size ones).
- [x] `vitest` WorkspaceApp: 14 passed.
- [x] `playwright test --list`: 9 tests collect.
- [ ] The E2E leg on the `development` push run. It runs only there, so this is where routing is actually proven.

## Left for later, deliberately

- [ ] The org rail renders only inside `WorkspaceApp`, so it appears on the dashboard routes and not on `/store`, `/reports` or `/features-roadmap`. That matches today's behaviour exactly (there were no other routes), but it is now a visible inconsistency. Lifting it to the shell is its own change.
- [ ] `DashboardsReport` is `type: dashboard` — a declarative page over LaunchPad's own register. It was unroutable before, so it has never rendered; whether its widgets resolve is unproven until the E2E runs.
Loading
Loading