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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
<a href="https://github.com/ConductionNL/mydash/releases"><img src="https://img.shields.io/github/v/release/ConductionNL/mydash" alt="Latest release"></a>
<a href="https://github.com/ConductionNL/mydash/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue" alt="License"></a>
<a href="https://github.com/ConductionNL/mydash/actions"><img src="https://img.shields.io/github/actions/workflow/status/ConductionNL/mydash/code-quality.yml?label=quality" alt="Code quality"></a>
<a href="https://mydash.app"><img src="https://img.shields.io/badge/docs-mydash.app-green" alt="Documentation"></a>
<a href="https://mydash.conduction.nl"><img src="https://img.shields.io/badge/docs-mydash.conduction.nl-green" alt="Documentation"></a>
</p>

---

MyDash supercharges the Nextcloud dashboard. Create multiple personalized workspaces with drag-and-drop widgets, custom shortcut tiles, and smart visibility rules — then let admins roll out templates to entire teams. It works with every existing Nextcloud dashboard widget out of the box, supporting both the v1 and v2 Dashboard APIs.

📚 **[Step-by-step tutorials](https://mydash.conduction.nl/docs/category/tutorials)** — user + admin walkthroughs with screenshots, kept in sync with the live UI via the [journeydoc](https://github.com/ConductionNL/hydra/blob/development/openspec/architecture/adr-030-journeydoc-pattern.md) capture spec.

## Screenshots

<table>
Expand Down
91 changes: 91 additions & 0 deletions docs/features/dashboard-deeplinking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Dashboard deep-linking

Each dashboard has its own URL — paste, bookmark, or share it. Visit
`/apps/mydash/{slug-chain}` and the workspace opens directly on that
dashboard. Switching dashboards in the sidebar updates the URL via
`pushState`; back/forward navigates between dashboards.

## URL format

```
/apps/mydash/ → resolver default
/apps/mydash/finance → top-level slug
/apps/mydash/finance/q1-roadmap → nested via slug-chain
/apps/mydash/finance/q1-roadmap/details → arbitrarily deep
```

Slugs use lowercase ASCII letters, digits, and dashes (per the
dashboards capability's REQ-DASH-024). Multi-segment chains reflect
parent → child hierarchy.

## How it works

### Inbound (URL → state)

1. User visits `/apps/mydash/some-slug`.
2. PHP catch-all route `page#deepLink` matches and dispatches to
`PageController::deepLink($deepLink)`.
3. The controller resolves the slug-chain via
`DashboardTreeService::resolvePath()`.
4. If resolved AND the user can read the dashboard → uses it as
active.
5. If unresolved or no permission → silently falls back to the
seven-step resolver, logs a warning. **Never 404s** — old
bookmarks always land on something.
6. Server pushes `deepLinkPath` (the canonical slug-chain) into
initial state.
7. Frontend reads `deepLinkPath` and `replaceState`s the URL to the
canonical form. Stale URLs (parent renamed) get normalised
in-place without a reload.

### Outbound (state → URL)

1. User clicks a row in the sidebar.
2. `switchDashboard()` updates `activeDashboard.uuid`.
3. A watcher on `activeDashboard.uuid` fetches the canonical path
via `GET /api/dashboards/{uuid}/path`.
4. If non-empty → `history.pushState({uuid}, '', '/apps/mydash/' + path)`.
5. If empty (NULL slug — unaddressable dashboard) → no `pushState`.

### Back / forward

A `popstate` listener on `Views.vue` mount:

1. Strips `/apps/mydash/` from `window.location.pathname`.
2. Calls `getDashboardByPath(path)` to resolve.
3. Calls `switchDashboard(uuid)`.

Back / forward between dashboards then matches user expectation.

## Why the catch-all is safe for `/api/...`

The route registration:

```php
['name' => 'page#deepLink', 'url' => '/{deepLink}', 'verb' => 'GET',
'requirements' => ['deepLink' => '(?!api(?:/|$)).+']],
```

The negative-lookahead `(?!api(?:/|$))` excludes any path starting
with `api/` (or just `api`). The Newman integration suite asserts
`GET /api/health` returns 200 as a regression check.

The catch-all MUST stay LAST in `appinfo/routes.php` so every literal
`/api/...` route matches first.

## Endpoints

| Method | Endpoint | Purpose |
|---|---|---|
| GET | `/api/dashboards/by-path/{path}` | Resolve slug-chain → dashboard envelope (requirements `path: .+`) |
| GET | `/api/dashboards/{uuid}/path` | Compute canonical slug-chain for a dashboard UUID |
| GET | `/{deepLink}` (catch-all) | Renders the workspace page with the dashboard pre-resolved |

## Tutorials

- [Bookmark or share a dashboard URL](/docs/tutorials/user/deep-link)

## Spec reference

[`openspec/specs/dashboard-deeplinking/spec.md`](../../openspec/specs/dashboard-deeplinking/spec.md)
— REQ-DDL-001 through REQ-DDL-007.
114 changes: 114 additions & 0 deletions docs/features/default-widget-bundle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Default widget bundle

Every newly-created personal dashboard ships with four pre-configured
widgets so first-create users land on a non-empty grid. Plus a loading
shim that hides the empty-state CTA during the initial fetch.

## What you get

A new dashboard renders with this layout on a 12-column grid:

| Position | Widget | Where it points |
|---|---|---|
| top-left (0–3, 0–2) | Conduction tile | https://conduction.nl |
| top-middle (4–7, 0–2) | Sendent tile | https://sendent.com |
| top-right (8–11, 0–2) | Nextcloud tile | https://nextcloud.com |
| bottom (0–11, 3–7) | Files widget | user's root folder |

Users can immediately drag, resize, replace, or remove any of these.

## When it fires

| Path | Seeded? |
|---|---|
| Sidebar **+ Add dashboard** button (`POST /api/dashboard`) | ✅ yes |
| First-login bootstrap when no admin template applies | ✅ yes (via `createDefaultPlacements()` delegate) |
| First-login bootstrap when an admin template applies | ❌ no — template-defined widgets only |
| First-login bootstrap when role-defaults seed something | ❌ no — role-defaults take precedence |

## How it's wired

```
POST /api/dashboard
→ DashboardApiController::create()
→ DashboardService::createDashboard(seedDefaults: true)
→ DashboardService::seedDefaultWidgets($dashboardId)
→ 4 × placementMapper->insert()
← envelope: { dashboard, placements: [4 entries] }
```

The frontend store reads `response.data.placements ?? []` and
populates `widgetPlacements` directly — no second round-trip.

## Why `tileType='preset'`

Each tile has `tileType='preset'`. This is required for
[`WidgetPlacement::jsonSerialize()`](../../lib/Db/WidgetPlacement.php)
to emit the flat `tile*` fields the renderer reads. The `'preset'`
sentinel is intentionally distinct from the legacy `'custom'` value
used by the deprecated `oc_mydash_tiles` table — `'custom'` routes
through the pre-registry tile path in `DashboardGrid.vue`, while
`'preset'` keeps the placements on the registry-backed `TileWidget`
renderer.

## Loading shim

The empty-state CTA ("No dashboard yet" / Create dashboard) used to
flash during the initial fetch because `activeDashboard` is null
until `loadDashboards()` resolves. [Views.vue](../../src/views/Views.vue)
now renders `NcLoadingIcon` while `loading=true` and
`activeDashboard` is null:

```html
<DashboardGrid v-if="activeDashboard" … />
<div v-else-if="loading" class="mydash-loading">
<NcLoadingIcon :size="48" />
</div>
<div v-else class="mydash-empty">
<NcEmptyContent … />
</div>
```

The empty-state still renders for the legitimate "no dashboard
exists yet" case (e.g. personal-dashboards disabled by admin AND no
group default).

## Tutorials

- [Open MyDash for the first time](/docs/tutorials/user/first-launch)
- [Create a new dashboard](/docs/tutorials/user/create-dashboard)

## Spec reference

[`openspec/specs/default-widget-bundle/spec.md`](../../openspec/specs/default-widget-bundle/spec.md)
— REQ-DWB-001 through REQ-DWB-006.

## API endpoint shape

```http
POST /api/dashboard
Content-Type: application/json

{
"name": "My Dashboard",
"description": "Optional",
"icon": null
}

→ 201 Created
{
"data": {
"dashboard": { "id": …, "uuid": …, "name": "My Dashboard", … },
"placements": [
{
"widgetId": "tile",
"tileType": "preset",
"tileTitle": "Conduction",
"tileLinkValue": "https://conduction.nl",
"gridX": 0, "gridY": 0, "gridWidth": 4, "gridHeight": 3
},
… (3 more)
]
}
}
```
67 changes: 67 additions & 0 deletions docs/features/effective-default-marker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Effective default marker

The dashboard switcher sidebar marks the user's *effective default
dashboard* — the one MyDash opens automatically when they visit
`/apps/mydash/` cold — with a small ★ icon and a hover tooltip.

## What you see

A star icon between the dashboard's own icon and its label, on
whichever sidebar row is the user's effective default:

![Sidebar with star marker](/screenshots/tutorials/user/07-default-marker.png)

The marker carries:

- `title="Default dashboard — opens automatically when you visit MyDash"` — browser tooltip on hover
- `aria-label="Default dashboard"` — for screen readers
- `color: var(--color-warning)` — picks up Nextcloud theme overrides

## "Effective default" — the chain

The marker mirrors the resolver's first five steps (REQ-DASH-018):

```
1. defaultUuid (the user's pin) ← always wins
2. primary-group row with isDefault=1 ← admin-set group default
3. default-group row with isDefault=1 ← org-wide group default
4. first primary-group row ← when no flag
5. first default-group row ← when no flag
6. first personal dashboard ← intentionally NOT marked
```

Step 6 is excluded by design — silently starring an arbitrary
personal dashboard the user never marked is more confusing than
helpful. If no pin and no group default applies, no marker renders.

## Setting / clearing the pin

1. Open the sidebar.
2. Click the cog (⚙️) on the row you want as your default.
3. Click **Set as default**.
4. The marker appears next to that row immediately.

To clear: same flow, the menu entry now reads **Default dashboard**
with a filled-star icon. Clicking it again clears the pin.

After clearing, the marker either disappears (if no group default
applies) or moves to the resolver's group fallback.

## Where in the code

| What | File |
|---|---|
| `effectiveDefaultUuid` computed | [DashboardSwitcherSidebar.vue](../../src/components/Workspace/DashboardSwitcherSidebar.vue) |
| `isDefaultDashboard()` helper | same file |
| `<span class="…__default-marker">` rendering | same file (3 row templates: primary-group, default-group, personal) |
| Pin persistence (server) | `setDefaultDashboardPreference()` in `dashboard.js` store |
| Pin endpoint | `POST /api/dashboards/default` |

## Tutorials

- [Set a default dashboard](/docs/tutorials/user/set-default)

## Spec reference

[`openspec/specs/effective-default-marker/spec.md`](../../openspec/specs/effective-default-marker/spec.md)
— REQ-EDM-001 through REQ-EDM-005.
Loading
Loading