diff --git a/appinfo/info.xml b/appinfo/info.xml index 0e8a8675..ccc72ed9 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -22,7 +22,7 @@ Vrij en open source onder de EUPL-1.2-licentie. **Ondersteuning:** Voor ondersteuning, neem contact op via support@conduction.nl. ]]> - 0.2.0 + 0.3.3 agpl Conduction OpenBuilt diff --git a/docs/tutorials/create-a-virtual-app.md b/docs/tutorials/create-a-virtual-app.md new file mode 100644 index 00000000..40302057 --- /dev/null +++ b/docs/tutorials/create-a-virtual-app.md @@ -0,0 +1,101 @@ +# Tutorial: Create a virtual app + +> **Audience**: OpenBuilt admins creating their first virtual app on a fresh install or alongside existing apps. +> **Prereqs**: OpenBuilt + OpenRegister installed and enabled. Signed in as a Nextcloud user (admin or otherwise). +> **Outcome**: A new virtual app named **Test App** with a `development → production` linear version chain, both versions seeded with an empty register and the default `hello-message` schema. Caller becomes the app's sole owner. + +## 1. Open the wizard + +Navigate to **Virtual apps** in the OpenBuilt sidebar and click **Add application** (the blue secondary button in the actions bar). + +The wizard opens on Step 1 — *App basics*. + +![Wizard step 1, empty form](./img/wizard-step1-empty.png) + +The dialog header shows a three-step indicator (1 → 2 → 3). Step 3 in this preset path is the Review screen; selecting the **Custom** preset later swaps in a chain composer as step 3 and pushes Review to step 4. + +## 2. Step 1 — App basics + +Fill in: + +- **Name** (required) — the human-readable display label. e.g. *Test App*. +- **Slug** — auto-derived from the name in `kebab-case`. Click the **Advanced** link if you want to override it manually (must match `^(?!_)[a-z0-9][a-z0-9-]*[a-z0-9]$`; leading underscores are reserved for openbuilt system use). +- **Description** (optional) — long-form text describing what the app does. +- **App icon** (optional, both light + dark) — upload SVGs that will appear in the Nextcloud top bar once the app is published. + +![Wizard step 1 with Name and Slug filled](./img/wizard-step1-filled.png) + +When **Name** is valid, the **Next** button in the footer enables. + +## 3. Step 2 — Choose a version preset + +Pick the shape of your version chain. ADR-002's linear-chain model uses these presets out of the box: + +| Preset | Chain | Best for | +|--------|-------|----------| +| **Single** | `production` | One-version apps with no staging environment. | +| **Development + Production** | `development → production` | The common "I want a safe playground" case. | +| **Development + Staging + Production** | `development → staging → production` | Classic three-tier pipeline for larger teams. | +| **Custom** | Admin-defined chain | When your team's deployment vocabulary doesn't fit the canned options. | + +![Wizard step 2, four preset cards](./img/wizard-step2-presets.png) + +Selecting **Custom** swaps in step 3 with an add-row composer where you type each version's name and the slug auto-derives; rows are drag-reorderable; top-to-bottom is upstream-to-downstream. + +For this tutorial we select **Development + Production**. + +![Wizard step 2 with Development + Production selected](./img/wizard-step2-selected.png) + +## 4. Step 3 — Review and create + +The Review screen shows everything the wizard is about to provision: + +- **Name** and **slug** from step 1. +- **Version chain** in arrow form (`development → production`). +- **Production version** callout naming which version end users will see at the canonical `/apps/openbuilt/{slug}` URL. + +![Wizard step 3, review and create](./img/wizard-step3-review.png) + +Click **Create** to submit. The backend then runs atomically: + +1. Validate the whole payload. +2. Create the `Application` record (caller becomes `owners` per RBAC). +3. For each version in chain order: create the `ApplicationVersion` record + provision the per-version register `openbuilt-{appSlug}-{versionSlug}` + install the default schema set with version-namespaced slugs. +4. Wire each non-terminal version's `promotesTo` to the next downstream version's UUID. +5. Set `Application.productionVersion` to the terminal version's UUID. + +On any failure, all already-created objects roll back in reverse creation order. On success, the wizard closes and you land on the new app's detail page at `/apps/openbuilt/applications/`. + +![Application detail page after wizard create](./img/app-detail-after-create.png) + +The detail page renders: +- The hero with status badge and **window toggle** (7d / 30d / 90d) +- Four KPI cards: **Active users**, **Object count**, **Files**, **Audit events** — all at 0 for a fresh app +- The **activity placeholder** ("No activity in the selected window") until the audit trail accumulates events +- The **Register** widget showing the production version's per-version OR register (`openbuilt-{appSlug}-production`) with `Schemas`, `Objects`, and `Files` counts +- The **Schemas** widget listing the seeded `{appSlug}-production-hello-message` schema (or "No schemas yet in this version" until the wizard's schema seed runs) +- The **+ Add schema** affordance to start designing +- Returning to **Virtual apps** confirms the new app appears in the index card grid: + +![Virtual apps index after wizard create](./img/index-after-wizard.png) + +## 5. What you have now + +- One `Application` record with your slug. +- Two `ApplicationVersion` records (`development`, `production`) chained linearly. +- Two per-version OR registers (`openbuilt-test-app-development`, `openbuilt-test-app-production`). +- Each register seeded with the `hello-message` schema (namespaced as `test-app-development-hello-message` and `test-app-production-hello-message` to satisfy OR's org-wide schema-slug uniqueness constraint). + +## What's next + +- **Schema design** — open the version's schema designer at `/builder/{slug}/schemas?_version=development` to model your domain. +- **Page design** — open the page designer at `/builder/{slug}/pages?_version=development` to lay out your app's screens. +- **Promote** — when development looks ready, use the Promote action on the version detail page to push your changes through to production. The promote dialog asks how to handle the target's existing data (start fresh from source / migrate-with-schema-changes / empty-start). + +## Troubleshooting + +- **"Add Item" button instead of "Add application"** — the manifest's `actionsComponent` must sit at page top-level (sibling to `id`/`route`/`type`/`title`), not nested inside `config`. The default CnIndexPage Add button is independent; suppress it with `config.showAdd: false` if needed. +- **`[object Object]` in OR API URLs** — your local nc-vue version pre-dates the positional-arg fix in `CnIndexPage.registerObjectType`. Update nc-vue or copy the fix from `feature/openbuilt-version-routing` / this branch. +- **Schema uniqueness violation on create** — the org-wide schema-slug constraint requires per-app namespacing of seed schema slugs. The wizard applies `{appSlug}-{versionSlug}-` as a prefix; if you fork the wizard's seed list, keep the namespacing pattern. +- **Hello World apps don't disappear after upgrade** — the green-field migration step's idempotency check is too eager. See [issue #69](https://github.com/ConductionNL/openbuilt/issues/69). +- **Icons return 404 from `/apps/openbuilt/icons/{slug}.svg`** — see [issue #68](https://github.com/ConductionNL/openbuilt/issues/68). diff --git a/docs/tutorials/img/app-detail-after-create.png b/docs/tutorials/img/app-detail-after-create.png new file mode 100644 index 00000000..888b3c1f Binary files /dev/null and b/docs/tutorials/img/app-detail-after-create.png differ diff --git a/docs/tutorials/img/index-after-wizard.png b/docs/tutorials/img/index-after-wizard.png new file mode 100644 index 00000000..827c232e Binary files /dev/null and b/docs/tutorials/img/index-after-wizard.png differ diff --git a/docs/tutorials/img/update-01-schemas-list.png b/docs/tutorials/img/update-01-schemas-list.png new file mode 100644 index 00000000..83962bda Binary files /dev/null and b/docs/tutorials/img/update-01-schemas-list.png differ diff --git a/docs/tutorials/img/update-02-schema-detail-404.png b/docs/tutorials/img/update-02-schema-detail-404.png new file mode 100644 index 00000000..9eb56586 Binary files /dev/null and b/docs/tutorials/img/update-02-schema-detail-404.png differ diff --git a/docs/tutorials/img/wizard-step1-empty.png b/docs/tutorials/img/wizard-step1-empty.png new file mode 100644 index 00000000..3a69ad8f Binary files /dev/null and b/docs/tutorials/img/wizard-step1-empty.png differ diff --git a/docs/tutorials/img/wizard-step1-filled.png b/docs/tutorials/img/wizard-step1-filled.png new file mode 100644 index 00000000..f4090761 Binary files /dev/null and b/docs/tutorials/img/wizard-step1-filled.png differ diff --git a/docs/tutorials/img/wizard-step2-presets.png b/docs/tutorials/img/wizard-step2-presets.png new file mode 100644 index 00000000..1de7b688 Binary files /dev/null and b/docs/tutorials/img/wizard-step2-presets.png differ diff --git a/docs/tutorials/img/wizard-step2-selected.png b/docs/tutorials/img/wizard-step2-selected.png new file mode 100644 index 00000000..6967a5b2 Binary files /dev/null and b/docs/tutorials/img/wizard-step2-selected.png differ diff --git a/docs/tutorials/img/wizard-step3-review.png b/docs/tutorials/img/wizard-step3-review.png new file mode 100644 index 00000000..66d5105e Binary files /dev/null and b/docs/tutorials/img/wizard-step3-review.png differ diff --git a/docs/tutorials/update-a-virtual-app.md b/docs/tutorials/update-a-virtual-app.md new file mode 100644 index 00000000..82dd83d3 --- /dev/null +++ b/docs/tutorials/update-a-virtual-app.md @@ -0,0 +1,80 @@ +# Tutorial: Update a virtual app + +> **Audience**: OpenBuilt admins who have already created an app via the wizard and want to evolve it — design schemas, edit pages, switch between versions, and promote changes through the chain. +> **Prereqs**: A virtual app already exists in OpenBuilt (see [Create a virtual app](./create-a-virtual-app.md)). The example here continues with `Permit Tracker` using the `development → production` preset. +> **Status**: Several parts of the update flow work today; some have known issues filed as follow-up bugs and noted inline. + +## 1. Open the app detail page + +Navigate to **Virtual apps** in the sidebar, then click your app's card. The detail page renders the maintainer dashboard: + +![Permit Tracker detail page](./img/app-detail-after-create.png) + +You see: +- **Hero**: app name, status badge (`draft`), role badge (`owner`). +- **Version pill tabs**: each ApplicationVersion in the chain. The production version is always present. +- **Window toggle** (7d / 30d / 90d): scopes the activity-graph + active-users KPI. +- **KPI cards** — 4 metrics scoped to the currently-selected version's register: Active users, Object count, Files, Audit events. +- **Activity graph** placeholder (empties out before any events accumulate). +- **Structural widgets** — Register, Schemas, Groups & users, Pages, Menu — each linking out to the corresponding designer. + +> **Known issue**: the development pill doesn't yet render alongside Production in some scenarios — see [issue tracker](https://github.com/ConductionNL/openbuilt/issues) for the latest. + +## 2. Open the schema designer for a version + +From the detail page, the **Schemas** widget shows the schemas installed in the currently-selected version's register, with a deep-link to the schema designer. Alternatively, type the URL directly: + +- Production: `/apps/openbuilt/builder/{appSlug}/schemas?_version=production` +- Development: `/apps/openbuilt/builder/{appSlug}/schemas?_version=development` + +The `_version` query parameter uses the underscore-prefix convention (see ADR-002 and the routing spec) so it can't collide with user-defined `?version=` params that virtual apps might surface. + +![Schema list for Permit Tracker production](./img/update-01-schemas-list.png) + +Each list row shows the schema's display name, slug, version, property count, and lifecycle. Note the schema slug is **namespaced** with `{appSlug}-{versionSlug}-` — every wizard-provisioned seed schema is prefixed this way to satisfy OR's organization-wide schema-slug uniqueness constraint. So `hello-message` becomes `permit-tracker-production-hello-message`. + +## 3. Edit a schema + +> **Known issue**: clicking a schema row to open its detail page currently returns "Schema not found": +> +> ![Schema not found error](./img/update-02-schema-detail-404.png) +> +> The list view correctly fetches schemas scoped by register, but the detail view's lookup pathway doesn't yet account for the namespaced slug. Filed as a follow-up bug — once it's resolved, the editor flow continues from this step. + +Until the detail view is fixed, schema edits need to go via the OR API directly, or via the OpenRegister admin UI at `/apps/openregister/schemas`. + +## 4. Switch versions + +The version pill tabs at the top of the detail page (and on builder pages) flip the `?_version=` URL parameter. Bookmark a specific tier (e.g. `/apps/openbuilt/applications/{uuid}?_version=development`) and your browser session pins to that tier until you switch. + +Non-admins only see the production tier. Editor + owner roles also see all non-production tiers (development, staging, etc.). Per ADR-002, NC admins are NOT auto-granted access — they must be in the Application's `permissions.{owners,editors}` arrays. + +## 5. Promote from development → production + +When development changes look ready, navigate to the development version's detail page or the version pill's actions menu and click **Promote**. A dialog opens asking how to handle the target's existing data: + +- **Start target with source data** — copy development's rows into production's register, applying development's schema set. Useful when "the test data IS the new shape of prod data." +- **Migrate target's existing data** — keep production's rows, apply development's schema set + any declared migration rules. Useful for genuine app upgrades where prod data must survive. +- **Empty start** — drop production's rows, install development's schema set. Most destructive — requires typing the app slug to confirm. + +The default per ADR-002 is **migrate-existing** when promoting to the production version (preserve prod data), **start-with-source** for promotions between non-production tiers. The dialog UI is implemented in `src/dialogs/PromoteVersionDialog.vue`; the backend endpoint is `POST /api/applications/{appUuid}/versions/{versionUuid}/promote`. + +> **End-to-end promotion testing is still gated** by the schema-detail bug above — until schemas can be edited, the development version's content has nothing new to promote. File or watch follow-up issues to confirm when the promote flow is testable end-to-end. + +## 6. What works today vs. what doesn't + +| Step | Status | +|------|--------| +| Open detail page | ✅ Works — hero, KPIs, structural widgets all render | +| List schemas for a version | ✅ Works — `/builder/{slug}/schemas?_version=...` shows namespaced slugs | +| Edit a schema | ❌ Detail view 404s on namespaced slug — see follow-up | +| Switch between versions via URL | ✅ Works via `?_version=` | +| See development pill alongside Production | ⚠ Only Production currently renders — follow-up | +| Promote via dialog | ⏸ Untested end-to-end (blocked by schema edit) | + +## Troubleshooting + +- **Hero shows "Untitled application"** — the header was binding to a slot prop that CnDetailPage doesn't forward. The fix in `feature/openbuilt-wizard-and-data-fetch` (commit `82dc21c`) makes the header fetch the Application record via OR's API on mount. +- **Schema detail shows "Schema not found"** — the schema-namespacing fix from the wizard creates slugs like `{appSlug}-{versionSlug}-{slug}`. The schema-designer detail view's lookup doesn't honor this prefix yet. Workaround: edit via OR's admin UI. +- **Register slug truncated to `openbuilt-{slug}-`** — display-only formatting issue in the Register widget; underlying register is correctly named `openbuilt-{appSlug}-{versionSlug}`. +- **Description field shows a number** — the header's `applicationDescription` computed reads the wrong field on the resolved Application object. Cosmetic. diff --git a/lib/Service/ApplicationCreationService.php b/lib/Service/ApplicationCreationService.php index a21374c4..4ff2707e 100644 --- a/lib/Service/ApplicationCreationService.php +++ b/lib/Service/ApplicationCreationService.php @@ -136,9 +136,10 @@ public function createApplication(array $payload): string // ---- State tracker for rollback ------------------------------------- // Indexed by version slug. $state = [ - 'applicationUuid' => null, - 'versionUuids' => [], - 'registerSlugs' => [], + 'applicationUuid' => null, + 'versionUuids' => [], + 'registerSlugs' => [], + 'versionPayloads' => [], ]; // ---- Step 2: Create Application ------------------------------------- @@ -231,6 +232,10 @@ public function createApplication(array $payload): string $versionUuid = (string) ($versionData['id'] ?? $versionData['uuid'] ?? ''); $state['versionUuids'][$versionSlug] = $versionUuid; $state['registerSlugs'][$versionSlug] = $registerSlug; + // Keep the full payload so we can do the chain-wiring patch + // below without OR rejecting a partial payload against the + // schema's `required[]` validator (issue #71). + $state['versionPayloads'][$versionSlug] = $versionPayload; } catch (Throwable $e) { $this->logger->error( 'OpenBuilt: wizard create-version failed for '.$versionSlug.': '.$e->getMessage(), @@ -302,8 +307,16 @@ public function createApplication(array $payload): string } try { + // OR's saveObject runs full-schema validation against `required[]` + // even when a UUID is passed (no separate PATCH semantics for an + // ApplicationVersion in this OR floor). Merge `promotesTo` into + // the full payload we kept from the create step so the validator + // sees all required fields. + $fullPayload = ($state['versionPayloads'][$currentSlug] ?? []); + $fullPayload['promotesTo'] = $nextUuid; + $this->objectService->saveObject( - object: ['promotesTo' => $nextUuid], + object: $fullPayload, register: ApplicationVersionService::REGISTER_SLUG, schema: ApplicationVersionService::APPLICATION_VERSION_SCHEMA, uuid: $currentUuid @@ -337,8 +350,23 @@ public function createApplication(array $payload): string $terminalUuid = (string) ($state['versionUuids'][$terminalSlug] ?? ''); try { + // OR runs full-schema validation on saveObject even with UUID set; + // build a full Application payload with the productionVersion field + // patched, mirroring the chain-wiring fix above (issue #71). + $applicationFullPayload = [ + 'slug' => $appSlug, + 'name' => $appName, + 'description' => $description, + 'permissions' => [ + 'owners' => ['user:'.$this->resolveCallerUid()], + 'editors' => [], + 'viewers' => [], + ], + 'productionVersion' => $terminalUuid, + ]; + $this->objectService->saveObject( - object: ['productionVersion' => $terminalUuid], + object: $applicationFullPayload, register: ApplicationVersionService::REGISTER_SLUG, schema: ApplicationVersionService::APPLICATION_SCHEMA, uuid: $state['applicationUuid'] @@ -580,9 +608,19 @@ private function provisionRegister( ); // Seed the default schema set into the freshly-provisioned register. + // Schema slugs are unique per organisation, so namespace each seed slug + // with the app+version prefix to avoid colliding with the same seed + // already installed in another register (e.g. the global `openbuilt` + // register or another wizard-provisioned app's register). + $slugPrefix = $appSlug.'-'.$versionSlug.'-'; $createdIds = []; foreach ($defaultSchemas as $schemaBlob) { - $schema = $this->schemaMapper->createFromArray(object: $schemaBlob); + $blob = $schemaBlob; + $originalSlug = (string) ($blob['slug'] ?? ''); + if ($originalSlug !== '') { + $blob['slug'] = $slugPrefix.$originalSlug; + } + $schema = $this->schemaMapper->createFromArray(object: $blob); $createdIds[] = $schema->getId(); } diff --git a/src/components/applicationDetail/ApplicationDetailHeader.vue b/src/components/applicationDetail/ApplicationDetailHeader.vue index 97da2a31..9547825e 100644 --- a/src/components/applicationDetail/ApplicationDetailHeader.vue +++ b/src/components/applicationDetail/ApplicationDetailHeader.vue @@ -209,6 +209,9 @@ export default { }, data() { return { + // CnDetailPage's #header slot only forwards presentational props + // (title/description/icon), not the resolved record — so we fetch + // the Application ourselves by UUID via OR's API on mount. application: this.object || null, versions: [], selectedWindow: '7d', @@ -374,8 +377,10 @@ export default { }, watch: { object(next) { - this.application = next || null - this.loadVersions() + if (next) { + this.application = next + this.loadVersions() + } }, objectId() { this.refreshApplication() @@ -396,7 +401,9 @@ export default { }, }, mounted() { - if (!this.application && this.objectId) { + // CnDetailPage's #header slot doesn't pass the resolved object, so we + // fetch the Application by UUID from the route params on mount. + if (!this.application) { this.refreshApplication() } else { this.loadVersions() @@ -511,9 +518,10 @@ export default { * @return {Promise} */ async refreshApplication() { - if (!this.objectId) return + const uuid = this.objectId || (this.$route && this.$route.params && this.$route.params.objectId) || '' + if (!uuid) return try { - const url = generateUrl(`/apps/openregister/api/objects/openbuilt/application/${encodeURIComponent(this.objectId)}`) + const url = generateUrl(`/apps/openregister/api/objects/openbuilt/application/${encodeURIComponent(uuid)}`) const { data } = await axios.get(url) this.application = (data && (data['@self'] ? { ...data, ...(data['@self'] || {}) } : data)) || null this.loadVersions() diff --git a/src/dialogs/CreateApplicationWizard.vue b/src/dialogs/CreateApplicationWizard.vue index 562b2078..a220cffe 100644 --- a/src/dialogs/CreateApplicationWizard.vue +++ b/src/dialogs/CreateApplicationWizard.vue @@ -70,8 +70,8 @@ - - + @@ -329,6 +329,19 @@ export default { min-height: 240px; } +.wizard__footer { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0 4px; + border-top: 1px solid var(--color-border, #ddd); + margin-top: 16px; +} + +.wizard__footer-spacer { + flex: 1 1 auto; +} + .wizard__error-banner { margin: 12px 0 0; padding: 10px 14px; diff --git a/src/manifest.json b/src/manifest.json index 880fdcab..283fb22c 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -65,13 +65,14 @@ "route": "/applications", "type": "index", "title": "Virtual apps", + "actionsComponent": "VirtualAppsActions", "config": { "register": "openbuilt", "schema": "application", "viewMode": "cards", - "actionsComponent": "VirtualAppsActions", "cardComponent": "ApplicationCard", "columns": ["name", "status", "version", "@self.updated"], + "showAdd": false, "sidebar": { "enabled": true, "showMetadata": true } } }, @@ -81,10 +82,10 @@ "type": "detail", "title": "Virtual app", "headerComponent": "ApplicationDetailHeader", + "actionsComponent": "ApplicationDetailActions", "config": { "register": "openbuilt", "schema": "application", - "actionsComponent": "ApplicationDetailActions", "sidebarTabs": [ { "id": "manifest", "label": "Manifest", "icon": "icon-edit", "component": "ApplicationManifestTab", "order": 20 }, { "id": "history", "label": "Version history", "icon": "icon-history", "component": "ApplicationVersionsTab", "order": 30 },