diff --git a/openspec/changes/openbuilt-templates-marketplace/.openspec.yaml b/openspec/changes/archive/2026-05-12-openbuilt-page-editor/.openspec.yaml similarity index 100% rename from openspec/changes/openbuilt-templates-marketplace/.openspec.yaml rename to openspec/changes/archive/2026-05-12-openbuilt-page-editor/.openspec.yaml diff --git a/openspec/changes/archive/2026-05-12-openbuilt-page-editor/design.md b/openspec/changes/archive/2026-05-12-openbuilt-page-editor/design.md new file mode 100644 index 00000000..1bc868f2 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-openbuilt-page-editor/design.md @@ -0,0 +1,432 @@ +## Context + +OpenBuilt spec #1 (`bootstrap-openbuilt`) shipped a JSON textarea as +the only path to author a virtual app's manifest. The textarea +proves the runtime contract (load → validate → save → render via a +nested `CnAppRoot`) but is unusable for citizen developers — the +canonical `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` +(v1.4.0) is a 1500+ line OpenAPI document with a closed 9-page-type +enum, seven `$defs` for the recurring sub-shapes +(`column` / `action` / `widgetDef` / `layoutItem` / `formField` / +`sidebarSection` / `sidebarTab`), and per-type config sub-shapes +that vary substantially (an `index` page's `config` looks nothing +like a `chat` page's). + +This spec replaces the textarea-as-only-editor with a tabbed view +whose default tab is a visual designer. The textarea persists as the +"Raw JSON" fallback tab for integrators and for the corner cases the +visual designer cannot express (yet). Everything ships in the +existing `openbuilt` Nextcloud app's frontend — no new backend code, +no new schemas, no new register namespaces. Manifest CRUD continues +to flow through OR REST per ADR-022. + +The chain dependency on `nextcloud-vue-in-memory-manifest` (chain +spec #2) shapes the design of the live-preview pane: that spec adds +the `useAppManifest(appId, manifestObject)` in-memory overload the +preview pane mounts against. Spec #2 is parallel to this one in the +9-spec chain; the design assumes it MAY not be shipped when this +editor lands, and provides a degraded "save & reload" preview +fallback. + +## Goals / Non-Goals + +**Goals** + +- Replace the spec #1 textarea-as-only-editor with a visual designer + that authors every shape declared in the canonical manifest + schema's closed 9-page-type enum. +- Keep the textarea reachable as a "Raw JSON" fallback tab so power + users and integrators are not regressed. +- Validate-as-you-type via `validateManifest`, surfacing errors both + in a side panel and as inline marks on the offending field. +- Provide a live-preview pane when the in-memory manifest loader from + chain spec #2 is available; gracefully degrade to "save & reload" + when it is not. +- Stay strictly inside the canonical schema. The editor MUST NOT + emit shapes outside the schema's enum/closed-set boundaries, and + it MUST round-trip externally authored manifests losslessly. +- Stay strictly inside ADR-022 — no per-app REST wrappers, no + controller additions; the designer reads/writes Application + objects via OR REST. + +**Non-Goals (deferred to chain or out of scope)** + +- Versioning / draft / publish UX (chain spec #6 `openbuilt-versioning`). +- Per-built-app permission management surface (chain spec #7 + `openbuilt-rbac`). +- Starter-template gallery (chain spec #8 + `openbuilt-templates-marketplace`). +- Real-app export of the manifest to a `src/manifest.json` file in a + target Nextcloud-app repo (chain spec #9 + `openbuilt-export-to-real-app`). +- Editing the underlying schemas a page binds to (that is the + `openbuilt-schema-editor` spec #4 — this spec only *picks* from + the registers/schemas OR already exposes). +- Real-time multi-user collaborative editing (see Open Questions). +- Undo / redo within a single editing session (see Open Questions). +- A `customComponents` registry-management surface — the + custom-page sub-editor picks from whatever registry the running + `CnAppRoot` exposes, but it does not let the user *create* new + registry entries. + +## Decisions + +### Decision 1 — One Vue component per page type (vs polymorphic sub-editor) + +The canonical schema declares nine page types and their `config` +sub-shapes diverge sharply (an `index` page's `config` shape is +register/schema/columns/actions; a `chat` page's is +`conversationSource`/`postUrl`/`schema`; a `dashboard` page's is +`widgets`/`layout`). Reviewing these shapes side-by-side, **one +component per type is the right unit of decomposition**: the schema +itself partitions cleanly by type, and any "polymorphic" sub-editor +ends up being nine `v-if` branches stitched together inside one +file — the same component count without the file boundaries. + +The decomposition is exactly: + +| Page type | Component | +|-------------|----------------------------| +| `index` | `IndexPageEditor.vue` | +| `detail` | `DetailPageEditor.vue` | +| `dashboard` | `DashboardPageEditor.vue` | +| `logs` | `LogsPageEditor.vue` | +| `settings` | `SettingsPageEditor.vue` | +| `chat` | `ChatPageEditor.vue` | +| `files` | `FilesPageEditor.vue` | +| `form` | `FormPageEditor.vue` | +| `custom` | `CustomPageEditor.vue` | + +Each sub-editor receives `v-model="page.config"` (the in-flight +config block for the page) and emits `update:modelValue` events with +the new shape. Shared `$def` sub-shapes (columns / actions / widgets +/ layout items / form fields / sidebar sections / sidebar tabs) live +in a separate `src/components/page-editor/fields/` directory and +mount inside whichever sub-editors need them — `ColumnBuilder.vue` +mounts inside `IndexPageEditor.vue` and `LogsPageEditor.vue`, +`FormFieldBuilder.vue` mounts inside `FormPageEditor.vue` and +`SettingsPageEditor.vue` (the settings page's +`sections[].fields[]` reuses the same `formField` `$def`), and so +on. + +**Alternatives considered** + +- *One polymorphic `PageConfigEditor.vue` with type-keyed branches*. + Rejected. The branches are large enough that a single file + becomes hard to navigate, code reviews lose their per-type focus, + and adding a tenth page type (the canonical schema is extensible + via the `customComponents` registry, but new built-in types would + land via a future schema bump) means surgery in a hot file rather + than a new file. +- *Code-generate the sub-editors from the JSON schema*. Tempting, + but the canonical schema's `pages[].config` description block is + a free-text discriminator (one giant description string covering + all nine types) rather than a typed `oneOf` — code-generation + would need a separate machine-readable mapping table that is + itself bespoke. Defer to a future spec if the schema is + refactored into a clean `oneOf`. + +### Decision 2 — Drag-drop library reuse over hand-rolled + +The menu-tree editor and the page-list editor both need +drag-reorder. `@nextcloud/vue` re-exports `vue-draggable` / +`vuedraggable` as part of its component set (used by +`NcAppNavigation` internally). The decision is: **reuse whatever +`@conduction/nextcloud-vue` and `@nextcloud/vue` pull in +transitively** before adding a direct dependency. + +The apply step's first task is `npm ls vuedraggable` to determine +the current dependency state. If it is present transitively, the +editor imports from `vuedraggable` directly (the transitive copy +serves both the library wrappers and our direct use). If it is +absent, we add it as a direct devDep — the package is small (~10kb +minified) and stable. We do **not** hand-roll drag-drop: every +hand-rolled DnD implementation in our codebase to date has accreted +edge cases (touch support, autoscroll, keyboard a11y). + +For the menu tree, the two-level nesting constraint (top-level + +children, no third level) is enforced at the editor layer, not at +the drag-drop library layer — `vuedraggable` happily supports +arbitrary depth, but the canonical schema's `menu[].children[]` +shape has no further `children[]`, so we cap at depth two by +refusing to render a third-level drop zone. + +**Alternatives considered** + +- *Hand-rolled HTML5 DnD wrappers*. Rejected per the accretion + argument above. We get a11y, keyboard handling, and autoscroll + for free from `vuedraggable`. +- *`@nextcloud/vue`'s `NcAppNavigation` drag wrappers*. Rejected for + the menu/page editor surface: those wrappers are designed for + navigation entries with specific NC styling expectations; using + them for a builder canvas fights their assumptions. Reuse the + underlying `vuedraggable` directly instead. + +### Decision 3 — Live preview depends on chain spec #2; degrade gracefully + +The right-hand pane is a **sandboxed `CnAppRoot`** mount that +renders the in-flight (unsaved) manifest live as the user edits. +This is the user-facing payoff of the visual editor — without it, +the designer feels like editing forms, not building an app. + +The sandbox requires the in-memory `useAppManifest(appId, +manifestObject)` overload that **chain spec #2 +(`nextcloud-vue-in-memory-manifest`) ships in the `@conduction/nextcloud-vue` +library repo**. Spec #2 runs parallel to this spec in the 9-spec +chain (per ADR-032 and the `chain:` block in proposal.md), so this +spec MAY ship before spec #2. + +**Behaviour when spec #2 is available:** +The right-hand pane mounts ``. Each manifest +edit re-renders the preview after the debounced validator pass +clears (no flickering on invalid in-flight states). The sandbox +`appId = openbuilt-preview-{slug}` so it does not collide with the +production-mounted `openbuilt-{slug}`. + +**Behaviour when spec #2 is unavailable:** +`useLivePreview.js` feature-detects the overload by inspecting +`useAppManifest.length` (arity: spec #2 adds a second positional +parameter). When the overload is missing, the right-hand pane +collapses to a button that (a) saves the current manifest via the +spec-1 REST path and (b) opens `/builder/:slug` in a new browser +tab. An i18n note +(`openbuilt.page-designer.preview.unavailable`) explains the +limitation. The fallback is intentionally one composable deep so +the rewrite when spec #2 lands is small. + +**Alternatives considered** + +- *Block this spec on spec #2 landing first*. Rejected. Sequential + chains pay coordination costs without yielding architectural + benefit; spec #5 can ship usable without spec #2 because the + designer itself works without preview — preview is a productivity + enhancement, not a correctness requirement. +- *Mount the sandbox via the per-slug manifest endpoint workaround + spec #1 uses*. Rejected: that workaround requires a saved + manifest in OR, which defeats the entire purpose of a live + preview (rendering UNSAVED edits). The in-memory overload is the + only viable path for live preview. + +### Decision 4 — Validation surface: side panel plus inline marks, debounced 300ms + +The designer runs `validateManifest` against the in-flight manifest +on every edit, debounced to at most once every 300ms. Errors +surface twice: in the right-hand error-list side panel (a +collapsible band when the live-preview pane occupies the right +column instead), and as inline marks on the specific editor field +whose JSON path matches the error path. + +Path mapping leans on `validateManifest`'s structured error output — +each error carries a JSON Pointer (`/pages/1/config/columns/0`) +that the editor's path-to-field map resolves to the offending Vue +component (the page-list editor's second row, the index sub-editor's +first column row). The map lives in +`useManifestValidator.js` as a registered set of path-prefix → +field-component-ref entries; sub-editors register their fields on +mount and unregister on unmount. + +300ms was chosen because (a) `validateManifest` on a typical +1-2KB manifest completes in ~5-20ms in dev measurements — fast +enough that "live" is the right framing — and (b) anything tighter +than 300ms surfaces transient errors in the middle of multi-character +edits (e.g. typing "submitMethod" briefly flags "submitMetho" as +invalid). 300ms is also the Vue community default for input +debouncing. + +The validator MUST NOT block the editor: the run happens +asynchronously and the UI stays responsive. The composable +internally uses a worker-free `setTimeout` debouncer; if profiling +reveals validator cost on a large manifest (>10KB) it MAY be moved +to a Web Worker in a follow-on spec, but for the v1 surface area +(~1-5KB manifests) the synchronous path is adequate. + +**Alternatives considered** + +- *Validate only on save*. Rejected: defers feedback to the moment + the user thinks they are done, which is the worst time. The + whole point of the visual designer is real-time validity + feedback. +- *Validate on blur*. Rejected: half of the editor's surface is + drag-drop / select / pick, which don't have a meaningful blur + event; "as you type" + debounce covers both keyboard and + drag-driven edits uniformly. + +### Decision 5 — Custom-page handling defers customComponents registry management + +The canonical schema's `type: custom` page renders against a key in +the consuming app's `customComponents` registry. The registry is +**not** OpenBuilt's concern in this spec — registry composition is +how a builder configures *what* custom components a virtual app has +access to, and configuring that registry needs its own surface +(deferred to a follow-on spec; the candidates are a new +`openbuilt-custom-components-registry` capability or folding it into +the marketplace spec #8). + +For this spec, `CustomPageEditor.vue` reads the registry **only at +runtime** from the sandboxed `CnAppRoot`'s injected `customComponents` +prop (which today is whatever the OpenBuilt host app passes — +typically an empty `{}` until a future spec wires in component +discovery). When the live-preview pane is active, the picker is a +dropdown over the registry's keys. When the preview pane is +unavailable, the picker degrades to free text with a warning that +the value cannot be validated until preview is enabled. + +The free-form `config` block for a custom page is an embedded +`` JSON editor (a single-purpose mini-textarea) because +the canonical schema explicitly allows custom pages' `config` to be +"any shape the custom component expects" — we cannot structure-edit +something we don't know the shape of. This is the one part of the +designer that mirrors the Raw JSON tab. + +**Alternatives considered** + +- *Ship registry management in this spec*. Rejected. Registry + composition is a sibling concern to manifest authoring, not a + child concern — folding it in would balloon the spec scope and + blur the line between "what does my app look like" (this spec) + and "what custom components does my app have access to" (a + future spec). +- *Hide the custom page type entirely from the page-type picker + until a registry exists*. Rejected. The closed enum is closed — + hiding a type would either lie about the canonical schema or + break externally authored manifests that already use `type: + custom`. Better to keep the type visible with a degraded picker. + +### Declarative-vs-imperative call-out (ADR-031) + +The Page Designer's output **is** the manifest blob. The manifest +itself is the canonical declarative artefact for the OpenBuilt +ecosystem (it declares pages, menus, routes, sidebars, widgets, +form fields — *what* the app is, not *how* it runs). This spec +introduces **no service class**: no `PageBuilderService`, no +`ManifestComposerService`, no `PageTypeRegistry`. The +per-page-type sub-editors are dumb-form components that +read/write the matching `pages[].config` sub-shape — they have no +internal state machine, no derived behaviour, nothing that would +trigger ADR-031's anti-pattern test. + +The closest the editor comes to "logic" is `useManifestValidator.js` +(debounced wrapper around the library's `validateManifest`) and +`useLivePreview.js` (feature-detect + sandbox mount). Both are +composables-of-glue, not domain logic. Neither encodes a state +machine, aggregation, calculation, or notification. The save path +is a single PUT to OR's REST endpoint, mediated by the existing +spec-1 Pinia store action — no new service. + +If during apply a future iteration finds itself reaching for a +"build the manifest from scratch" or "auto-fix validation errors" +behaviour, that behaviour SHALL land as a declarative manifest +**template** or **transform** declared as data, not as a PHP/JS +service class — and SHALL be deferred to a separate spec for +review under ADR-031 in isolation. + +## Risks / Trade-offs + +- **Risk** — *Editor drifts from the canonical schema as v1.5.x → + v1.6.x lands in `@conduction/nextcloud-vue`.* → Mitigation: each + sub-editor's allowed input shapes are checked against + `validateManifest` on every keystroke — a schema bump that + invalidates an editor field surfaces immediately as a validation + error, prompting an editor patch. The editor pins to the schema + version shipped by the `@conduction/nextcloud-vue` version listed + in `package.json`; on a library bump, run the + `hello-world`/decidesk reference manifests through the editor's + round-trip test (load → re-serialise → diff) and patch any + divergence. +- **Risk** — *Round-trip-losslessness regressions on externally + authored manifests.* → Mitigation: every sub-editor MUST keep + unknown fields it does not understand (the canonical schema + declares `additionalProperties: true` on the outer `config` block + for per-type scalars and consumer-app extension keys). The editor + stores the unmodified Application object in the Pinia store and + surgical-merges its UI-controlled fields back on save, rather + than re-serialising the whole shape from UI state. Tested by + the `manifest-roundtrip.spec.js` Vitest suite (load decidesk's + `src/manifest.json` → mount editor → re-serialise → assert + bytewise equivalence ignoring whitespace). +- **Risk** — *Live-preview pane re-mounts thrash on rapid edits.* → + Mitigation: the preview's `:key` binds to a content hash, not a + timestamp; identical-content edits do not re-mount. The + debounced validator gates the preview-mount path so invalid + in-flight states never trigger a sandbox render. +- **Risk** — *Custom-page free-form JSON editor regresses into a + second textarea.* → Acceptable trade-off for v1: the canonical + schema allows any shape for custom `config`, and we don't know + the consuming component's expectations. When `customComponents` + registry management ships (deferred spec), the registry can + declare each component's expected `config` shape and the editor + can structure-edit that block too. +- **Trade-off** — *Nine sub-editor files vs one polymorphic file.* + See Decision 1. Nine files is the right unit. +- **Trade-off** — *Live preview depends on chain spec #2.* See + Decision 3. The fallback path keeps this spec independently + shippable. + +## Migration Plan + +This spec ships a frontend-only change inside the existing +`openbuilt` Nextcloud app. There is no database migration, no +schema change, no API change. Deployment steps: + +1. Land the change on a feature branch from `development`. +2. CI runs the existing Newman suite (unchanged — no API surface + change) plus the new Vitest suite for the designer components + and a Playwright test that walks the seeded `hello-world` + Application through the visual designer end-to-end. +3. Merge into `development`. On next deploy, the Application + editor view opens with the Design tab as default; existing + manifests load and render in the designer; the Raw JSON tab + surfaces the unchanged spec-1 textarea. +4. **Rollback** — front-end rollback only; redeploy the previous + build. Application objects in OR are unchanged by this spec, so + there is no data to revert. +5. **Chain coordination** — when chain spec #2 lands in + `@conduction/nextcloud-vue`, bump the library version in + `package.json` and verify the live-preview pane activates. No + editor code change should be required because the feature + detection is at runtime. + +## Open Questions + +- **OQ-1 — Undo/redo within a single editing session.** The visual + designer is a many-knob surface; users will mis-click. Do we + ship a built-in undo/redo stack (typically a `useUndoable.js` + composable over the Pinia store) in this spec, or defer to a + follow-on? *Provisional decision*: **defer**. The user can + always switch to the Raw JSON tab and fix mistakes there, and + the save-after-validate flow prevents persisting broken + manifests. Adding undo here doubles the scope of the spec for a + productivity feature that doesn't affect correctness. File a + follow-on issue. +- **OQ-2 — Real-time multi-user editing.** Two users editing the + same Application's manifest concurrently could clobber each + other's edits on save. Do we add optimistic concurrency control + (an ETag header on the PUT) or accept last-write-wins for v1? + *Provisional decision*: **defer to chain spec #6 (versioning)**. + Versioning will introduce explicit version snapshots which + naturally surface concurrency conflicts as "your edit is based + on version N but the current is N+1". Until versioning ships, + the textarea-from-spec-1 has the same last-write-wins behaviour + and no user has hit it; the visual designer does not change the + risk profile. +- **OQ-3 — Embedded i18n key picker vs free-text label binding.** + Every editor field that binds an i18n key (menu label, page + title, button label) currently accepts a free-text string the + user must type. Should the editor pick from the consuming app's + registered i18n key set (read at runtime from `t()` / `n()` / + the i18n catalogue)? *Provisional decision*: **defer to a + follow-on**. For v1 the editor accepts free text and surfaces a + warning when the saved key does not resolve in the running + catalogue; a registry-backed picker is a fit-and-finish + improvement that needs its own design pass on how to expose the + catalogue to the editor without coupling OpenBuilt to every + consuming app's i18n loader. +- **OQ-4 — Default page type on "Add page".** When the user clicks + "Add page", do we pre-select a page type (e.g. `index`) or + force a pick before any other field is shown? *Provisional + decision*: **force a pick** — the per-type sub-editor mounts as + soon as the type is chosen and the page row's other fields make + sense only in the context of the type. Forcing the pick keeps + the flow consistent and avoids the "create-then-discard" pattern + if the user actually wanted a different type. diff --git a/openspec/changes/archive/2026-05-12-openbuilt-page-editor/proposal.md b/openspec/changes/archive/2026-05-12-openbuilt-page-editor/proposal.md new file mode 100644 index 00000000..edaed5af --- /dev/null +++ b/openspec/changes/archive/2026-05-12-openbuilt-page-editor/proposal.md @@ -0,0 +1,151 @@ +--- +kind: code +depends_on: [bootstrap-openbuilt] +chain: + - bootstrap-openbuilt + - openbuilt-page-editor # THIS spec +--- + +## Why + +Spec #1 (`bootstrap-openbuilt`) shipped a textarea-based JSON manifest +editor as the integrator-only entry point for authoring a virtual +app. That textarea proves the runtime contract but is unusable for +the citizen-developer audience OpenBuilt actually targets: hand-typing +a manifest means knowing the canonical +`@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` (v1.4.0) +by heart, including its closed 9-type page enum, the per-type config +sub-shapes, the menu/permission/route grammar, and the `$ref`'d +`column` / `action` / `widgetDef` / `layoutItem` / `formField` / +`sidebarSection` / `sidebarTab` sub-shapes. A visual designer is the +missing piece between "we can store and render a manifest" and "a +non-technical user can build one". + +This is spec #5 of the 9-spec OpenBuilt chain. It is **purely a +frontend code change** inside the existing `openbuilt` Nextcloud app — +no new schemas, no new backend controllers, no new register +namespaces. The editor reads/writes the same `Application.manifest` +JSON blob the textarea already reads/writes (via OR REST per ADR-022), +just through a graphical UI instead of a raw text area. + +## What Changes + +- **NEW** `src/views/PageDesigner.vue` — top-level visual manifest + designer view, routed at `/applications/:slug/design`. Split into + three panes: a left **page list + menu tree editor**, a centre + **per-page-type config sub-editor** (one component per page type), + and an optional right **live preview pane** (mounts a sandboxed + `CnAppRoot` against the in-flight manifest when the in-memory + manifest loader from chain spec #2 is available; otherwise falls + back to a "save & reload" preview). +- **NEW** per-page-type sub-editor components under + `src/components/page-editor/` — one Vue component for each of the + nine canonical page types declared by ADR-024: + - `IndexPageEditor.vue` — register + schema picker, column + selector (with `@self.*` metadata options), action declarations, + sidebar/card-component config. + - `DetailPageEditor.vue` — sidebar tab config (open-enum tab + builder), route param schema, sidebar widget list. + - `DashboardPageEditor.vue` — widget list + grid layout editor. + - `LogsPageEditor.vue` — register/schema or `source` picker + + columns. + - `SettingsPageEditor.vue` — section list builder (fields vs + component vs widgets exactly-one-of). + - `ChatPageEditor.vue` — `conversationSource` or `postUrl` picker + + optional `schema`. + - `FilesPageEditor.vue` — folder picker + allowed-types selector. + - `FormPageEditor.vue` — field list (reuses the `formField` + builder), validation rules, `submitHandler` vs `submitEndpoint` + exactly-one-of, method/mode pickers. + - `CustomPageEditor.vue` — `customComponents` registry-name picker + (read from the running `CnAppRoot`'s injected registry) + free-form + slot/config JSON for shapes the registry component expects. +- **NEW** `src/components/page-editor/MenuTreeEditor.vue` — drag-reorder + menu builder with two-level nesting (top-level entries + their + `children[]`), `target: main | settings` picker, i18n-key label + binding, `permission` and `action` field support. +- **NEW** `src/components/page-editor/PageListEditor.vue` — + page-list view with add/remove, drag-reorder, uniqueness + enforcement on `id`, and route-pattern validation. +- **NEW** shared field-builder components under + `src/components/page-editor/fields/` (`ColumnBuilder.vue`, + `ActionBuilder.vue`, `WidgetBuilder.vue`, `LayoutItemBuilder.vue`, + `FormFieldBuilder.vue`, `SidebarSectionBuilder.vue`, + `SidebarTabBuilder.vue`) that match the canonical `$defs` and are + reused across the per-page-type sub-editors. +- **NEW** `src/composables/useManifestValidator.js` — debounced + wrapper around `validateManifest` from + `@conduction/nextcloud-vue` that surfaces errors in the side-panel + error list and decorates each editor pane with inline marks + pointing at the offending requirement. +- **NEW** `src/composables/useLivePreview.js` — feature-detects the + in-memory `useAppManifest` overload shipped by chain spec #2 and + either mounts a sandboxed `CnAppRoot` (preview pane active) or + falls back to a "save & reload" link that opens + `/builder/:slug` in a new tab. +- **MODIFIED** `src/views/ApplicationEditor.vue` (from spec #1) — + the existing textarea becomes one of two tabs (the **"Raw JSON"** + fallback tab); the new **"Design"** tab embeds `PageDesigner.vue` + and is the default. The Application save path (PUT to OR REST) is + unchanged. +- **MODIFIED** `src/router/index.js` (from spec #1) — adds a + `/applications/:slug/design` route alias that opens the editor + pre-focused on the Design tab. +- **NEW** i18n strings under `l10n/en.json` + `l10n/nl.json` for + every editor pane label, button, validation message, and empty + state (per the workspace i18n requirement). + +### Capabilities + +#### New Capabilities + +- `openbuilt-page-designer`: The visual manifest / page designer that + produces output validating against + `@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json`. + Owns the menu-tree editor, page-list editor, per-page-type + sub-editors for all nine canonical types, the shared field + builders for the seven `$defs`, the live-preview pane (when the + in-memory manifest loader is available), and the validator + surface. Reads/writes the `Application.manifest` blob via OR REST — + no new backend. + +#### Modified Capabilities + +- `openbuilt-runtime`: The editor swap. The Application edit view + registered by spec #1 (`ApplicationEditor.vue` — single-textarea) + is reshaped into a tabbed editor with a "Design" tab (the new + `PageDesigner.vue`) as default and a "Raw JSON" tab (the existing + textarea) as the integrator fallback. The runtime contract + (manifest endpoint, nested `CnAppRoot` mount, seeded `hello-world`) + is unchanged. + +## Impact + +- **New code** — `src/views/PageDesigner.vue` (~300 LOC), + `src/components/page-editor/*.vue` (9 page-type sub-editors plus + menu/page/field builders, ~150-250 LOC each), + `src/composables/useManifestValidator.js` + `useLivePreview.js`, + i18n strings in `l10n/en.json` + `l10n/nl.json`. +- **Modified code** — `src/views/ApplicationEditor.vue` (tabbed shell + around the existing textarea + new designer), `src/router/index.js` + (design route alias). +- **External dependency** — `@conduction/nextcloud-vue` (already + installed) for `validateManifest` re-export and — when chain spec + #2 ships — the in-memory `useAppManifest` overload. The editor + feature-detects the overload and degrades to "save & reload" + preview when it is absent, so this spec does **not** block on spec + #2. +- **Drag-and-drop dependency** — uses `vuedraggable` if already + pulled in transitively by `@nextcloud/vue` / `@conduction/nextcloud-vue`; + otherwise adds it as a direct devDep. Decided during apply via the + `npm ls vuedraggable` check. +- **No backend changes** — manifest CRUD continues to flow through + OR REST per ADR-022. No new PHP, no new routes, no migration. +- **No breaking changes** — the existing textarea path is preserved as + the "Raw JSON" tab; users with externally authored manifests keep + their workflow. +- **Foundational ADRs honoured** — ADR-024 (manifest contract / closed + 9-type enum), ADR-022 (no per-app REST wrappers), ADR-031 (no + service classes — editor output is the declarative manifest blob + itself; see `design.md` for the explicit declarative-vs-imperative + call-out). diff --git a/openspec/changes/archive/2026-05-12-openbuilt-page-editor/specs/openbuilt-page-designer/spec.md b/openspec/changes/archive/2026-05-12-openbuilt-page-editor/specs/openbuilt-page-designer/spec.md new file mode 100644 index 00000000..4f8f7dc4 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-openbuilt-page-editor/specs/openbuilt-page-designer/spec.md @@ -0,0 +1,338 @@ +## ADDED Requirements + +### Requirement: REQ-OBPD-001 Menu tree editor with two-level nesting + +The system SHALL provide a `MenuTreeEditor.vue` component that +authors the manifest's `menu[]` array. The editor SHALL support +drag-reordering of top-level entries and their `children[]`, with a +**maximum nesting depth of two** (a child MUST NOT itself declare +`children[]`). Each entry SHALL surface form fields for `id`, +`label` (bound as an i18n key, not a literal), `icon`, `route`, +`order`, `permission`, `target` (closed enum: `main` | `settings`, +default `main`), `href`, and `action` (closed enum: `user-settings`, +optional). The editor MUST enforce the canonical schema rule that +`action`, when set, makes `route` and `href` ignored, by surfacing a +disabled-with-tooltip state on those fields. + +#### Scenario: Drag-reorder updates the manifest in order + +- **WHEN** the editor lists three menu entries and the user drags the + third entry to the first position +- **THEN** the in-flight manifest's `menu[]` array reflects the new + ordering with `order` integers re-assigned monotonically (0, 1, 2) +- **AND** the change appears in the next validator pass + +#### Scenario: Child nesting blocks third-level adds + +- **WHEN** the user attempts to add a child entry inside a + `menu[].children[].children[]` slot +- **THEN** the editor refuses the action with the i18n message + `openbuilt.page-designer.menu.error.nesting-depth` +- **AND** the manifest remains unchanged + +#### Scenario: Action field disables route and href + +- **WHEN** the user sets `action` on a menu entry to `user-settings` +- **THEN** the editor disables the `route` and `href` input fields +- **AND** displays an inline note explaining the canonical rule +- **AND** clears any pre-existing values from `route` and `href` in the + manifest output + +### Requirement: REQ-OBPD-002 Page list editor with uniqueness and route-pattern validation + +The system SHALL provide a `PageListEditor.vue` component that +authors the manifest's `pages[]` array. The editor SHALL support +adding, removing, and drag-reordering pages, and MUST enforce the +following invariants before allowing a save: + +- Every `pages[].id` SHALL be unique within the manifest. +- Every `pages[].route` SHALL match the vue-router pattern grammar + (`/`, `/:param`, `/segment/:param`, `/:catchAll(.*)?`). +- Adding a page SHALL prompt for the page `type` from the canonical + closed enum (`index` | `detail` | `dashboard` | `logs` | `settings` + | `chat` | `files` | `form` | `custom`) before any other field is + shown, so the correct per-type sub-editor mounts immediately. + +#### Scenario: Duplicate id blocks save with a marked error + +- **WHEN** two pages in the list share the same `id` value +- **THEN** both rows display an inline error mark +- **AND** the manifest validator surfaces the duplication in the + side-panel error list +- **AND** the parent editor's Save button is disabled + +#### Scenario: Page-type pick mounts the matching sub-editor + +- **WHEN** the user clicks "Add page" and selects `type: form` +- **THEN** the centre pane mounts `FormPageEditor.vue` +- **AND** the new page is appended to `pages[]` with `type: 'form'` + and a placeholder `id` + `route` the user is prompted to fill in + +### Requirement: REQ-OBPD-003 Per-page-type config sub-editor for each of the nine canonical types + +The system SHALL ship one Vue sub-editor component per canonical page +type declared in the +`@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json` +v1.4.0+ page enum: `IndexPageEditor.vue`, `DetailPageEditor.vue`, +`DashboardPageEditor.vue`, `LogsPageEditor.vue`, +`SettingsPageEditor.vue`, `ChatPageEditor.vue`, +`FilesPageEditor.vue`, `FormPageEditor.vue`, `CustomPageEditor.vue`. +Each sub-editor SHALL author **only** the `pages[].config` block +appropriate to its type and MUST NOT emit keys outside that type's +config sub-shape declared in the canonical schema's `pages[].config` +description block. The page-list editor SHALL mount the sub-editor +whose name matches the selected page's `type` field. + +#### Scenario: Switching page type swaps the sub-editor + +- **WHEN** the user edits an existing `type: index` page and changes + its type to `dashboard` +- **THEN** the centre pane unmounts `IndexPageEditor.vue` and mounts + `DashboardPageEditor.vue` +- **AND** the previous `config` block is replaced by the + dashboard-shaped default (`{ widgets: [], layout: [] }`) +- **AND** the side-panel validator confirms the new shape against the + canonical schema + +### Requirement: REQ-OBPD-004 Index-page sub-editor: register, schema, columns, actions + +`IndexPageEditor.vue` SHALL author the index-type `config` block per +the canonical schema. It SHALL expose: + +- A **register picker** populated from the user's accessible + OpenRegister registers (via OR REST). +- A **schema picker** filtered to the selected register, also via OR + REST. +- A **column selector** that lists every property of the selected + schema, plus the `@self.*` metadata virtual columns (`@self.uuid`, + `@self.created`, `@self.updated`, `@self.owner`, + `@self.organisation`, `@self.locked`). Columns SHALL be addable in + either string shorthand or as a typed `column` object (the editor + MUST round-trip both forms losslessly). +- An **action declarations** list reusing `ActionBuilder.vue` for + each `actions[]` entry. +- Optional **sidebar** + **cardComponent** sub-blocks matching the + canonical schema's index `sidebar` and `cardComponent` shapes. + +#### Scenario: Column picker offers @self.* metadata fields + +- **WHEN** the user opens the column-selector dropdown for an index + page bound to `register: openbuilt, schema: hello-message` +- **THEN** the dropdown lists each `hello-message` property AND the + six `@self.*` metadata entries +- **AND** selecting `@self.created` adds the column to `columns[]` in + string shorthand `"@self.created"` + +### Requirement: REQ-OBPD-005 Detail-page sub-editor: sidebar tabs and route param schema + +`DetailPageEditor.vue` SHALL author the detail-type `config` block. It +SHALL expose: + +- **Register + schema picker** mirroring the index sub-editor. +- A **route-param schema** that auto-derives expected `$route.params` + from the parent page's `route` string (e.g. `/messages/:id` → + `{ id: }`) and surfaces a warning if + the route has no `:param` segment. +- A **sidebar config** block supporting both the legacy boolean shape + (`sidebar: true|false`) and the v1.2.0+ object shape + (`{ enabled, show, columnGroups[], facets, showMetadata, search }`). +- A **sidebarProps.tabs** list reusing `SidebarTabBuilder.vue` for each + open-enum tab definition (`{ id, label, icon?, widgets?, component?, + order? }`). + +#### Scenario: Tab list overrides the built-in sidebar tabs + +- **WHEN** the user adds three tabs (`overview`, `audit`, `relations`) + via `SidebarTabBuilder.vue` +- **THEN** the manifest's `config.sidebarProps.tabs` contains exactly + those three entries in the authored order +- **AND** the validator confirms each tab declares exactly one of + `widgets[]` OR `component` + +### Requirement: REQ-OBPD-006 Form-page sub-editor with exactly-one-of submit handling + +`FormPageEditor.vue` SHALL author the form-type `config` block. It +SHALL expose: + +- A **field list** built from `FormFieldBuilder.vue` matching the + canonical `formField` `$def`, including validation rules + (`required`, `pattern`, `min`, `max`, `enum`). +- An **exactly-one-of** picker for `submitHandler` (a + customComponents registry-name string) **OR** `submitEndpoint` + (a URL string with `:paramName` segments). The editor MUST refuse + to allow both to be set simultaneously. +- A **submitMethod** picker (closed enum: `POST` | `PUT` | `PATCH`; + default `POST`). +- A **mode** picker (closed enum: `edit` | `create` | `public`; + default `public`). +- Optional **submitLabel**, **successMessage**, **initialValue** + inputs. + +#### Scenario: Setting submitHandler clears submitEndpoint + +- **WHEN** the user enters a `submitEndpoint` and then types a value + into `submitHandler` +- **THEN** the editor clears `submitEndpoint` from the manifest +- **AND** the picker visibly reflects the active branch as + `submitHandler` +- **AND** the validator passes + +#### Scenario: Invalid submitMethod blocks save + +- **WHEN** the user attempts (via raw JSON tab) to set + `submitMethod: 'DELETE'` on a form page +- **THEN** the validator marks the page with an error referencing the + closed enum +- **AND** the parent editor's Save button is disabled + +### Requirement: REQ-OBPD-007 Custom-page sub-editor reads the customComponents registry + +`CustomPageEditor.vue` SHALL surface a **component-name picker** +populated from the consuming app's `customComponents` registry — +specifically, the keys of the `customComponents` prop passed to the +sandboxed `CnAppRoot` mounted by the live-preview pane (REQ-OBPD-008). +When the live-preview pane is unavailable (chain spec #2 not yet +shipped), the picker SHALL accept a free-text string and surface a +warning that the value cannot be validated until preview is enabled. +The sub-editor SHALL also expose a free-form JSON editor for the +`config` sub-shape, because the canonical schema explicitly allows +`type: custom` configs to be "any shape the custom component +expects". + +#### Scenario: Registry-backed picker lists known components + +- **WHEN** the live-preview pane is active and the registry exposes + three custom-component keys +- **THEN** the picker dropdown lists exactly those three keys +- **AND** selecting one writes the chosen key to `pages[].component` +- **AND** the free-form JSON editor opens with an empty `config: {}` + +#### Scenario: Free-text fallback when preview is unavailable + +- **WHEN** the live-preview pane is unavailable (in-memory manifest + loader from chain spec #2 not detected) +- **THEN** the picker renders as a free-text input +- **AND** an i18n warning explains the validation-deferral +- **AND** the value writes through to `pages[].component` unchanged + +### Requirement: REQ-OBPD-008 Live-preview pane mounts a sandboxed CnAppRoot when available + +The Page Designer SHALL provide an optional right-hand pane that +mounts a **sandboxed** `CnAppRoot` instance configured from the +in-flight (unsaved) manifest, so the user sees their edits render +live without saving. The pane SHALL be considered available **only +when** the in-memory `useAppManifest(appId, manifestObject)` +overload from chain spec #2 (`nextcloud-vue-in-memory-manifest`) is +detected at runtime. When the overload is absent, the pane SHALL +collapse to a "save & reload" affordance that opens +`/builder/:slug` in a new browser tab against the last saved +manifest, with an inline i18n note explaining the limitation. + +The sandboxed `CnAppRoot` SHALL: + +- Use a unique `appId` of `openbuilt-preview-{slug}` so its state + does not collide with the production-mounted virtual app. +- Receive the manifest as an in-memory object (no fetch). +- Re-mount via a `:key` bound to the manifest's content hash, so any + manifest edit re-renders the preview cleanly. + +#### Scenario: Preview pane renders the in-flight manifest + +- **WHEN** chain spec #2's in-memory manifest loader is available +- **AND** the user edits a page's `title` field +- **THEN** the right-hand pane re-renders the preview with the new + title visible +- **AND** no PUT request is sent to OR + +#### Scenario: Fallback when in-memory loader is unavailable + +- **WHEN** chain spec #2's in-memory manifest loader is NOT detected +- **THEN** the right-hand pane displays a "Save & open preview" button +- **AND** an i18n note (`openbuilt.page-designer.preview.unavailable`) + explains the limitation +- **AND** clicking the button saves the manifest and opens + `/builder/:slug` in a new tab + +### Requirement: REQ-OBPD-009 Save flow PUTs the manifest via OpenRegister REST + +The Page Designer's Save action SHALL serialise the in-flight +manifest, validate it via +`@conduction/nextcloud-vue`'s `validateManifest` export, and PUT the +updated `Application` object via OpenRegister's existing REST API at +`/index.php/apps/openregister/api/objects/openbuilt/application/{uuid}` +— the same path the spec #1 textarea editor already uses. The +designer MUST NOT introduce a new openbuilt-side controller for +manifest writes (ADR-022). + +#### Scenario: Save persists via OR REST + +- **WHEN** the user clicks Save with a valid manifest +- **THEN** the editor sends a PUT to OR's + `/api/objects/openbuilt/application/{uuid}` endpoint with the full + Application body and the updated `manifest` field +- **AND** the response is `200` +- **AND** the editor's "dirty" indicator clears + +#### Scenario: Save is blocked when validator reports errors + +- **WHEN** the validator has at least one open error in the + side-panel error list +- **THEN** the Save button is disabled +- **AND** clicking the (disabled) button surfaces a tooltip + enumerating the blocking error count + +### Requirement: REQ-OBPD-010 Raw JSON fallback tab preserves the spec-1 textarea + +The Application edit view SHALL retain the textarea-based JSON +manifest editor shipped by spec #1 (`bootstrap-openbuilt`) as a +secondary tab labelled "Raw JSON". The Design tab (the new +`PageDesigner.vue`) SHALL be the default tab on view load. The two +tabs SHALL share the same in-flight manifest state, so edits made in +one tab are visible in the other when the user switches tabs without +saving. + +#### Scenario: Switching tabs preserves unsaved edits + +- **WHEN** the user edits a page title in the Design tab and switches + to the Raw JSON tab without saving +- **THEN** the textarea content reflects the unsaved page title +- **AND** the dirty indicator persists across the tab switch + +#### Scenario: Raw JSON edits flow back to the designer + +- **WHEN** the user edits the JSON directly in the Raw JSON tab and + switches back to the Design tab +- **THEN** the designer re-parses the JSON and re-renders the + relevant panes +- **AND** if the JSON is invalid, the designer surfaces a parse error + in its side-panel error list and disables the Design tab inputs + until the JSON is valid again + +### Requirement: REQ-OBPD-011 Debounced validator surface decorates editor panes inline + +The system SHALL provide `useManifestValidator.js`, a composable +that wraps `validateManifest` from `@conduction/nextcloud-vue` and +re-runs the validator at most every 300ms of editor-state change. +Each validation error SHALL be surfaced **twice**: in the right-hand +error-list side panel (or a collapsible band when the live preview +pane occupies the right column), and as an **inline mark** on the +specific editor field whose JSON path matches the error path. The +composable MUST NOT block the editor on validation — the UI stays +responsive and the validator output catches up asynchronously. + +#### Scenario: Error path maps to inline field mark + +- **WHEN** the validator reports an error at JSON path + `pages[1].config.columns[0]` +- **THEN** the page-list editor highlights the second page row +- **AND** the index sub-editor's column-selector first row shows an + inline error mark +- **AND** the side-panel error list contains the same error with a + click-to-focus link to the column row + +#### Scenario: Validator debounce coalesces rapid edits + +- **WHEN** the user types eight characters into a field within 200ms +- **THEN** the validator runs at most once during that window +- **AND** the editor remains responsive (no input lag attributable to + validation) diff --git a/openspec/changes/archive/2026-05-12-openbuilt-page-editor/specs/openbuilt-runtime/spec.md b/openspec/changes/archive/2026-05-12-openbuilt-page-editor/specs/openbuilt-runtime/spec.md new file mode 100644 index 00000000..8a8ae640 --- /dev/null +++ b/openspec/changes/archive/2026-05-12-openbuilt-page-editor/specs/openbuilt-runtime/spec.md @@ -0,0 +1,56 @@ +## MODIFIED Requirements + +### Requirement: REQ-OBR-005 Textarea manifest editor saves to the Application object + +The OpenBuilt shell SHALL render a **tabbed Application editor** for +the `manifest` field of an `Application` object, composed of two +sibling tabs sharing one in-flight manifest state: + +1. **"Design"** (default tab) — mounts the visual `PageDesigner.vue` + shipped by the `openbuilt-page-designer` capability. The designer + authors the manifest through structured per-page-type sub-editors + and a menu-tree editor; see the `openbuilt-page-designer` + capability spec for its full requirements. +2. **"Raw JSON"** — the original JSON `