Skip to content

fix(dashboards): ship a dashboard, and let the API resolve the one the shell shows - #361

Merged
rubenvdlinde merged 3 commits into
developmentfrom
fix/default-dashboard-on-fresh-install
Aug 28, 2026
Merged

rubenvdlinde merged 3 commits into
developmentfrom
fix/default-dashboard-on-fresh-install

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Backlog item 12 from the 2026-08-27 new-env review: "launchpad: fresh install ships NO default dashboard. Should ship a default Nextcloud + Sendent + Conduction dashboard."

What Ruben saw

/apps/launchpad/ rendered "No dashboards available — contact your administrator". Measured on the running instance: GET /api/dashboards answered {"items": []} with HTTP 200, and oc_launchpad_dashboards had zero rows.

Two separate defects, and the first one hides the second.

1. Nothing ever created a dashboard

allowUserDashboards defaults to false on purpose (REQ-ASET-003: an admin opts in before users may create personal dashboards). That default closes the only branch of tryCreateFromTemplate() that WRITES, so on a brand-new instance no dashboard was created by the app or by the user, and every rung of resolveActiveDashboard() returned null.

The default widget bundle — the Conduction, Sendent and Nextcloud tiles plus a Files widget — had shipped and had unit tests (DashboardServiceCreateDefaultsTest). Nothing ever called it, because nothing ever created the dashboard it seeds.

New repair step SeedDefaultDashboard provisions it on install and post-migration, idempotently.

The shape is load-bearing

This app has two resolution chains and they do not agree:

Caller Method Consults admin templates? Consults group_shared?
Page shell (PageController::index) resolveActiveDashboard() no yes (steps 2-5)
GET /api/dashboard getEffectiveDashboard() yes no (before this PR)

So seeding a default admin template writes four placements the workspace still will not render. Measured, not reasoned: the first cut of this fix seeded exactly that, the row landed in the DB, and the app kept showing its empty state.

The shape the shell resolves is a group_shared row on the reserved default group (REQ-DASH-012) with isDefault = 1, created published because filterByPublicationState() hides drafts from every non-owner and this row has none.

2. The two chains disagreed, silently

With the row seeded, the sidebar listed "DEFAULT ▸ Dashboard" and the grid drew its empty state on top of it: GET /api/dashboard answered {"error": "No dashboard available"} for the same user the shell had just resolved. getEffectiveDashboard() had no group-shared rung at all — with no preference set it went straight from owned dashboards to shares and templates.

It now resolves the default sentinel the same way resolveActiveDashboard() steps 3 and 5 do. Only the sentinel: a per-group default needs the caller's primary group, which this signature does not carry, and guessing one would make the two chains disagree in a new way.

Verification

On the running dev instance:

$ occ maintenance:repair
  - LaunchPad default dashboard seeded (Conduction, Sendent, Nextcloud + Files).

 id |   name    |     type     | group_id | is_default | publication_status | permission_level
----+-----------+--------------+----------+------------+--------------------+------------------
  2 | Dashboard | group_shared | default  |          1 | published          | view_only

/apps/launchpad/ then renders Conduction | Sendent | Nextcloud across the top row with the Files widget below.

  • 1577 unit tests pass (2 new, covering the seeded shape and idempotency)
  • phpcs, psalm, phpstan clean on the changed files
  • lint:licenses clean; lint:spec-annotations failures are pre-existing and do not name any file in this PR

…e shell shows

A fresh install of launchpad had no dashboard at all. `/apps/launchpad/`
rendered "No dashboards available, contact your administrator",
`GET /api/dashboards` answered `{"items": []}` with HTTP 200, and
`oc_launchpad_dashboards` was empty. Two separate defects, and the first
one hides the second.

1. NOTHING EVER CREATED A DASHBOARD. `allowUserDashboards` defaults to
   `false` on purpose (REQ-ASET-003: an admin opts in before users may
   create personal dashboards). That default closes the only branch of
   `tryCreateFromTemplate()` that WRITES, so on a brand-new instance no
   dashboard was created by the app or by the user, and every rung of
   `resolveActiveDashboard()` returned null.

   The default widget bundle (the Conduction, Sendent and Nextcloud
   tiles plus a Files widget) had shipped and had unit tests. Nothing
   ever called it, because nothing ever created the dashboard it seeds.

   `SeedDefaultDashboard` now provisions it on install and after every
   upgrade, idempotently.

   THE SHAPE IS LOAD-BEARING. This app has two resolution chains and
   they do not agree. `getEffectiveDashboard()` consults admin
   templates; the page shell calls `resolveActiveDashboard()`, which
   reads only `getVisibleToUser()` and never looks at a template. A
   default ADMIN TEMPLATE therefore seeds four placements the workspace
   still will not render. Measured, not reasoned: the first cut of this
   fix seeded exactly that, and the app kept showing its empty state.
   The shape the shell resolves is a `group_shared` row on the reserved
   `default` group (REQ-DASH-012) with `isDefault = 1`, created
   `published` because `filterByPublicationState()` hides drafts from
   every non-owner and this row has none.

2. THE TWO CHAINS DISAGREED, SILENTLY. With the row seeded, the sidebar
   listed "DEFAULT / Dashboard" and the grid drew its empty state on top
   of it: `GET /api/dashboard` answered `{"error": "No dashboard
   available"}` for the same user the shell had just resolved.
   `getEffectiveDashboard()` had no group-shared rung at all. With no
   preference set it went straight from owned dashboards to shares and
   templates. It now resolves the `default` sentinel the same way
   `resolveActiveDashboard()` steps 3 and 5 do.

   Only the sentinel. A per-group default needs the caller's primary
   group, which this signature does not carry, and guessing one would
   make the two chains disagree in a new way.

Verified on a running instance: `occ maintenance:repair` seeds the row
(`group_shared` / `default` / `isDefault=1` / `published`) and
`/apps/launchpad/` renders Conduction, Sendent, Nextcloud and Files.
1577 unit tests pass; phpcs, psalm and phpstan clean on the changed files.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/launchpad @ c6d7705

Check PHP Vue Security License Tests
lint ✅
phpcs ✅
phpmd ❌
psalm ✅
phpstan ✅
phpmetrics ✅
eslint ✅
stylelint ✅
build ✅
check-manifest ✅
format ✅
check-schema-l10n ✅
composer ✅ ✅ 104/104
npm ✅ ✅ 538/538
app:check-code ⏭️
info.xml ✅
REUSE ❌
PHPUnit ❌
Newman ✅
Playwright ❌
Hydra gates ❌

Quality workflow — 2026-08-27 22:30 UTC

Download the full PDF report from the workflow artifacts.

`Guard coverage baseline` was red on the scoped ratchet, not on the suite —
PHPUnit itself passed (1577 tests, 4059 assertions):

    Surviving code, head:   75.05%  (716/954 statements)
    Surviving code, base:   76.83%  (693/902 statements)
    FAIL: coverage of the code this change KEEPS or ADDS dropped by 1.78%.

The PR added 52 statements and covered 23 of them. `lib/Repair/SeedDefaultDashboard.php`
(125 lines) arrived with no test at all; DashboardService got one, the repair
step did not.

Five tests over all four paths: the step's name, a seed that provisions, a seed
that finds one already there, a seed that throws, and the container failing to
resolve the service at all.

The two failure tests are the ones worth having. A repair step that lets an
exception escape aborts `occ upgrade` for the WHOLE instance — a missing demo
dashboard is cosmetic, a failed upgrade is not — so the step swallows and warns,
and without a test that asserts the swallow a later refactor could turn the one
into the other silently.

Both info branches are asserted on their MESSAGE, not just their call count:
they both call $output->info(), so a count-only test would pass with the two
swapped.

Local: OK (5 tests, 17 assertions).
…method past phpmd

`PHP Quality (phpmd)` was red on one violation:

    lib/Service/DashboardService.php:435  ExcessiveMethodLength
    The method getEffectiveDashboard() has 104 lines of code.
    Current threshold is set to 100.

The group-shared rung this PR added is what pushed it over, so that is what
comes out — into resolveDefaultGroupDashboard(), carrying its own explanation
of why the rung exists at all (the page shell and the API resolved differently,
silently, so the sidebar listed a dashboard the grid drew an empty state over).

Behaviour is unchanged: same resolveGroupSharedDashboard() call with the same
DEFAULT_GROUP_ID sentinel, in the same position in the chain, returning the same
built result. The early-return is inverted only to keep the extracted method
flat.

Verified locally: php -l clean, phpmd exit 0 on BOTH legs (the ruleset and the
unused-params ruleset, as CI runs them), PHPUnit OK (1582 tests, 4076
assertions).
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/launchpad @ 4a61a82

Check PHP Vue Security License Tests
lint ⏭️
phpcs ⏭️
phpmd ⏭️
psalm ⏭️
phpstan ⏭️
phpmetrics ⏭️
eslint ⏭️
stylelint ⏭️
build ⏭️
composer ⏭️ ⏭️
npm ⏭️ ⏭️
app:check-code ⏭️
info.xml ⏭️
REUSE ⏭️
PHPUnit ❌
Newman ❌
Playwright ❌
Hydra gates ❌

Quality workflow — 2026-08-28 09:38 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit c9e5808 into development Aug 28, 2026
34 checks passed
@rubenvdlinde
rubenvdlinde deleted the fix/default-dashboard-on-fresh-install branch August 28, 2026 10:00
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/launchpad @ 1eb1669

Check PHP Vue Security License Tests
lint ✅
phpcs ✅
phpmd ✅
psalm ✅
phpstan ✅
phpmetrics ✅
eslint ✅
stylelint ✅
build ✅
check-manifest ✅
format ✅
check-schema-l10n ✅
composer ✅ ✅ 104/104
npm ✅ ✅ 538/538
app:check-code ⏭️
info.xml ✅
REUSE ❌
PHPUnit ✅
Newman ✅
Playwright 🚨 NO VERDICT — enabled but never ran
Hydra gates ✅

Quality workflow — 2026-08-28 10:22 UTC

Download the full PDF report from the workflow artifacts.

rubenvdlinde added a commit that referenced this pull request Aug 28, 2026
)

Three specs assert "No dashboards available". #361 (c9e5808) exists to
make that message impossible: SeedDefaultDashboard provisions an
INSTANCE-WIDE, group-shared dashboard on install and after every upgrade,
because a fresh install greeting a user with an empty shell was the defect.

So the specs are not wrong about the app; the app deliberately removed the
state they describe. They have been red on development since that commit.

Handled three different ways, on purpose:

1. active-dashboard-resolution.spec.ts — INVERTED, not deleted.
   The valuable assertion is the opposite of what it said: a brand-new
   account, which owns no dashboard of its own, must still resolve to the
   seeded one. That is exactly the guarantee #361 introduced, and nothing
   was testing it.

2. runtime-shell-canEdit.spec.ts x2 — excluded WITH a reason.
   These assert the presence/absence of a Create CTA *inside* the empty
   state, so inverting them is not possible: the container is gone. The
   rule underneath (REQ-ASET-003 — allowUserDashboards gates whether a
   user may create a dashboard) is still worth covering and now needs an
   assertion that does not route through the empty state. The reason says
   so, names the commit, and reaches the Playwright report — so the
   skip-discipline gate reads it as attributed rather than silent.

3. fixtures/secondary-user.ts — the docblock said a brand-new account has
   zero dashboards "personal, group, or default", and that this was how
   the empty-state scenarios reached their state. That premise is now
   false and would have sent the next person down the same path.

Verified: npm ci rc=0, prettier clean, `playwright test --list` compiles
11 tests across both files.

Refs #372.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde added a commit that referenced this pull request Aug 28, 2026
E2E fails on development:

  ADR-023: fresh install is usable by non-admins — the empty-state Create
  CTA it is offered actually works
  locator(.workspace-shell__empty-cta) — element(s) not found

#361 (SeedDefaultDashboard) provisions an instance-wide, group-shared
dashboard on install, so a brand-new non-admin resolves to THAT and never
reaches the empty state this test clicked through.

#373 handled the two REQ-SHELL-005 specs in this same file for exactly
that reason and said the rule underneath now needs an assertion that does
not route through the empty state. This third test in the same file was
left behind. This is that assertion.

The create affordance a non-admin is offered today is the switcher
sidebars Add dashboard button, gated on the same allowUserDashboards the
test already sets. The valuable part is unchanged and is why the test
exists: the POST must not come back 401/403/404/5xx.

The closing assertion changes with it. Asserting the empty state is gone
would prove nothing now — it was never there — so it asserts the users
own dashboard is listed in the switcher instead.
rubenvdlinde added a commit that referenced this pull request Aug 28, 2026
… state (#380)

The last red spec on launchpad's development. #362 fixme'd two empty-state
tests when #361 (c9e5808) made that state unreachable; this third one
used the same locator and was missed, so it kept failing:

    Error: expect(locator).toBeVisible() failed
    Locator: locator('.workspace-shell__empty-cta')
    Error: element(s) not found

SeedDefaultDashboard now provisions an instance-wide, group-shared
dashboard on install and after every upgrade, so no account resolves to
zero dashboards and no user ever sees the empty-state CTA.

The CTA was never the claim. The claim is REQ-ASET-003 — an ordinary user
may create a dashboard when allowUserDashboards is on — and that is what
actually regressed once: `dashboard.create` 403'd and the user was
stranded on the empty state. The sibling fixmes say so themselves: "the
underlying rule is still worth covering, but it needs an assertion that
does not route through the empty state."

This is that assertion. It POSTs to the endpoint as a throwaway
non-admin and requires 200/201.

Three details carried over from the file's own hard-won conventions,
rather than reinvented:

  - Basic-Auth + OCS-APIRequest via the existing `userApi` helper. A
    cookie-authenticated POST with no `requesttoken` is rejected by
    Nextcloud's CSRF check before reaching the controller, which would
    fail this test for a reason unrelated to authorization.
  - `expect([200, 201]).toContain(status)`, NOT `not.toBe(403)`. The
    weaker form also passes on 401 and 404, neither of which proves an
    ordinary user may create anything. That exact false green is
    documented two tests above.
  - The route is `dashboardApi#create`, `POST /api/dashboard`, read from
    appinfo/routes.php. The same file records an earlier draft that used
    a made-up path, got 404, and would have passed a weaker check.

`create()` takes only optional parameters and is `#[NoAdminRequired]`, so
a bare POST reaches `denyCreate()` — which is precisely the gate under
test.

Verified: npm ci rc=0, npm run lint rc=0, prettier clean, and
`playwright test --list` compiles all 8 tests in the file.

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
rubenvdlinde added a commit that referenced this pull request Sep 12, 2026
…its compulsory flag

Four defects, each measured on a live instance with PostgreSQL before the fix.

1. The seeding never ran: getEffectiveDashboard() resolved the instance-wide default group dashboard, seeded on install since #361, before it reached tryCreateFromTemplate(). A user in a group with layout defaults resolved to the shared dashboard and owned nothing. The role path is now tried first, and only for a user who actually has layout defaults.

2. The second user ever to auto-provision got HTTP 500 'Slug must be unique among siblings': root slugs share one namespace across owners and every auto-provisioned dashboard is named 'My Dashboard'. The slug now carries the owner.

3. The hardcoded tile/tile/tile/files set was created on top of the role layout, though the comment beside it called it a fallback. It is a fallback now.

4. isCompulsory was dropped while seeding, so a compulsory role widget arrived removable. REQ-RFP-002 names it.
rubenvdlinde added a commit that referenced this pull request Sep 14, 2026
…its compulsory flag (#634)

* test(enterprise-security): one verdict per scenario, all never built

The spec carried a single blanket exclusion over 15 scenarios. Each now carries its own reason. The widget id appears in no commit on any branch under src, lib, appinfo or templates, so every scenario is honestly unbuildable rather than untested, and each reason says what is missing.

* fix(role-layout): seed a new user from their role layout, once, with its compulsory flag

Four defects, each measured on a live instance with PostgreSQL before the fix.

1. The seeding never ran: getEffectiveDashboard() resolved the instance-wide default group dashboard, seeded on install since #361, before it reached tryCreateFromTemplate(). A user in a group with layout defaults resolved to the shared dashboard and owned nothing. The role path is now tried first, and only for a user who actually has layout defaults.

2. The second user ever to auto-provision got HTTP 500 'Slug must be unique among siblings': root slugs share one namespace across owners and every auto-provisioned dashboard is named 'My Dashboard'. The slug now carries the owner.

3. The hardcoded tile/tile/tile/files set was created on top of the role layout, though the comment beside it called it a fallback. It is a fallback now.

4. isCompulsory was dropped while seeding, so a compulsory role widget arrived removable. REQ-RFP-002 names it.

* test(db): pin the role layout seeding against a real database

A new user whose group carries RoleLayoutDefault rows is seeded from them,
once, with the compulsory flag kept, and a second auto-provisioned user no
longer collides on the root slug. Each assertion names one of the four
defects fixed in 4782cf9.

* fix(phpcs): give tryCreateFromTemplate() its docblock back

uniqueRootSlugFor() was inserted between the docblock and the method it
documents, which left tryCreateFromTemplate() undocumented and phpcs
reporting an error in lib.

* fix(role-layout): keep getEffectiveDashboard() under the NPath limit, and never return undefined placements

Two findings from the full suite, both introduced on this branch.

phpmd: the role layout step added two branches inline and took
getEffectiveDashboard() to an NPath of 432 against a threshold of 200. It
now lives in resolveRoleLayoutOrDefaultGroupDashboard(), which the method
takes as the single step the default group lookup already was.

phpstan: tryCreateFromTemplate() started $seeded at false, so with no
RoleFeaturePermissionService neither the > 0 nor the === 0 branch ran and
$placements was undefined. It starts at 0, the int the seeder returns, and
$placements starts empty.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants