From 1c2760db324ff3eaac4e205043cf26d3faa5cb3a Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 12 May 2026 11:31:14 +0200 Subject: [PATCH 1/2] wip(templates): copy new files from feature/spec-openbuilt-templates-marketplace (SeedApplicationTemplates, template JSONs, TemplateGallery, CloneTemplateDialog, icons) --- img/templates/employee-onboarding.svg | 14 + img/templates/incident-reporter.svg | 11 + img/templates/permit-tracker.svg | 10 + img/templates/stakeholder-consultation.svg | 12 + lib/Repair/SeedApplicationTemplates.php | 241 ++++++++++++++ .../templates/employee-onboarding.json | 81 +++++ lib/Settings/templates/incident-reporter.json | 68 ++++ lib/Settings/templates/permit-tracker.json | 71 +++++ .../templates/stakeholder-consultation.json | 82 +++++ src/modals/CloneTemplateDialog.vue | 128 ++++++++ src/views/TemplateGallery.vue | 298 ++++++++++++++++++ 11 files changed, 1016 insertions(+) create mode 100644 img/templates/employee-onboarding.svg create mode 100644 img/templates/incident-reporter.svg create mode 100644 img/templates/permit-tracker.svg create mode 100644 img/templates/stakeholder-consultation.svg create mode 100644 lib/Repair/SeedApplicationTemplates.php create mode 100644 lib/Settings/templates/employee-onboarding.json create mode 100644 lib/Settings/templates/incident-reporter.json create mode 100644 lib/Settings/templates/permit-tracker.json create mode 100644 lib/Settings/templates/stakeholder-consultation.json create mode 100644 src/modals/CloneTemplateDialog.vue create mode 100644 src/views/TemplateGallery.vue diff --git a/img/templates/employee-onboarding.svg b/img/templates/employee-onboarding.svg new file mode 100644 index 00000000..fdddb2c1 --- /dev/null +++ b/img/templates/employee-onboarding.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + Employee Onboarding + diff --git a/img/templates/incident-reporter.svg b/img/templates/incident-reporter.svg new file mode 100644 index 00000000..0079e513 --- /dev/null +++ b/img/templates/incident-reporter.svg @@ -0,0 +1,11 @@ + + + + + + + + + + Incident Reporter + diff --git a/img/templates/permit-tracker.svg b/img/templates/permit-tracker.svg new file mode 100644 index 00000000..198459ca --- /dev/null +++ b/img/templates/permit-tracker.svg @@ -0,0 +1,10 @@ + + + + + + + + + Permit Tracker + diff --git a/img/templates/stakeholder-consultation.svg b/img/templates/stakeholder-consultation.svg new file mode 100644 index 00000000..454f607c --- /dev/null +++ b/img/templates/stakeholder-consultation.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + Stakeholder Consultation + diff --git a/lib/Repair/SeedApplicationTemplates.php b/lib/Repair/SeedApplicationTemplates.php new file mode 100644 index 00000000..6a7fb1e2 --- /dev/null +++ b/lib/Repair/SeedApplicationTemplates.php @@ -0,0 +1,241 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Repair; + +use OCA\OpenRegister\Service\ObjectService; +use OCP\App\IAppManager; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +/** + * Seed Conduction-curated ApplicationTemplate records. + */ +class SeedApplicationTemplates implements IRepairStep +{ + + /** + * The four seeded template slugs (one fixture per slug). + * + * @var array + */ + private const TEMPLATE_SLUGS = [ + 'permit-tracker', + 'stakeholder-consultation', + 'employee-onboarding', + 'incident-reporter', + ]; + + /** + * The allowed categories per REQ-OBTC-009. + * + * @var array + */ + private const ALLOWED_CATEGORIES = [ + 'government-services', + 'internal-operations', + 'citizen-engagement', + 'field-work', + ]; + + /** + * Constructor for SeedApplicationTemplates. + * + * @param LoggerInterface $logger The logger + * @param IAppManager $appManager The app manager (for fixtures path) + * @param ObjectService $objectService OpenRegister object service (hard dep via info.xml) + * + * @return void + */ + public function __construct( + private LoggerInterface $logger, + private IAppManager $appManager, + private ObjectService $objectService, + ) { + }//end __construct() + + /** + * Get the name of this repair step. + * + * @return string + */ + public function getName(): string + { + return 'Seed Conduction-curated OpenBuilt ApplicationTemplate records'; + }//end getName() + + /** + * Run the repair step — seed each fixture if its slug is not present. + * + * @param IOutput $output The output interface for progress reporting + * + * @return void + */ + public function run(IOutput $output): void + { + $output->info('Seeding ApplicationTemplate records...'); + + $fixturesDir = $this->appManager->getAppPath('openbuilt').'/lib/Settings/templates'; + if (is_dir($fixturesDir) === false) { + $output->warning('Template fixtures directory missing: '.$fixturesDir); + return; + } + + $seeded = 0; + foreach (self::TEMPLATE_SLUGS as $slug) { + $fixturePath = $fixturesDir.'/'.$slug.'.json'; + if (is_file($fixturePath) === false) { + throw new RuntimeException('Missing template fixture: '.$fixturePath); + } + + $raw = file_get_contents($fixturePath); + $data = json_decode($raw, true); + if (is_array($data) === false) { + throw new RuntimeException('Invalid JSON in template fixture: '.$fixturePath); + } + + $this->validateFixture(data: $data, slug: $slug); + + if ($this->findBySlug(slug: $slug) !== null) { + $output->info('Template already seeded — skipping: '.$slug); + continue; + } + + try { + $this->objectService->saveObject( + object: $data, + register: 'openbuilt', + schema: 'application-template' + ); + $output->info('Seeded ApplicationTemplate: '.$slug); + ++$seeded; + } catch (Throwable $e) { + $this->logger->error( + 'OpenBuilt: failed to seed template', + ['slug' => $slug, 'exception' => $e->getMessage()] + ); + throw new RuntimeException( + 'Failed to seed template "'.$slug.'": '.$e->getMessage(), + 0, + $e + ); + } + }//end foreach + + $output->info('OpenBuilt template seeding complete. New: '.$seeded); + }//end run() + + /** + * Validate a fixture has the minimum required fields per REQ-OBTC-009. + * + * @param array $data The decoded fixture + * @param string $slug The slug for error messages + * + * @return void + * + * @throws RuntimeException When a required field is missing or empty. + */ + private function validateFixture(array $data, string $slug): void + { + $required = ['slug', 'title', 'description', 'useCase', 'category', 'manifest', 'version']; + foreach ($required as $key) { + if (isset($data[$key]) === false || $data[$key] === '') { + throw new RuntimeException('Template "'.$slug.'" missing required field: '.$key); + } + } + + if (($data['slug'] ?? '') !== $slug) { + throw new RuntimeException( + 'Template fixture filename "'.$slug.'.json" does not match its slug "'.($data['slug'] ?? '').'".' + ); + } + + if (is_array($data['manifest']) === false || isset($data['manifest']['pages']) === false) { + throw new RuntimeException('Template "'.$slug.'" manifest is missing pages.'); + } + + if (in_array($data['category'], self::ALLOWED_CATEGORIES, true) === false) { + throw new RuntimeException('Template "'.$slug.'" has unknown category: '.$data['category']); + } + }//end validateFixture() + + /** + * Find an existing template by slug. + * + * @param string $slug The slug to look up + * + * @return array|null The existing record or null when absent. + */ + private function findBySlug(string $slug): ?array + { + try { + $results = $this->objectService->findAll( + config: [ + 'filters' => [ + 'register' => 'openbuilt', + 'schema' => 'application-template', + 'slug' => $slug, + ], + 'limit' => 1, + ] + ); + + if (is_array($results) === false || count($results) === 0) { + return null; + } + + $first = reset($results); + if (is_array($first) === true) { + return $first; + } + + if (is_object($first) === true && method_exists($first, 'jsonSerialize') === true) { + $serialised = $first->jsonSerialize(); + if (is_array($serialised) === true) { + return $serialised; + } + + return null; + } + + return null; + } catch (Throwable $e) { + $this->logger->warning( + 'OpenBuilt: template lookup failed — treating as absent', + ['slug' => $slug, 'exception' => $e->getMessage()] + ); + return null; + }//end try + }//end findBySlug() +}//end class diff --git a/lib/Settings/templates/employee-onboarding.json b/lib/Settings/templates/employee-onboarding.json new file mode 100644 index 00000000..169c2bdc --- /dev/null +++ b/lib/Settings/templates/employee-onboarding.json @@ -0,0 +1,81 @@ +{ + "slug": "employee-onboarding", + "title": "Employee Onboarding", + "description": "Track new-hire onboarding tasks, documents, and status across departments.", + "useCase": "HR onboarding workflow", + "category": "internal-operations", + "screenshotUrl": "img/templates/employee-onboarding.svg", + "isSeeded": true, + "sourceUrl": "https://github.com/ConductionNL/concurrentie-analyse/blob/main/app-builder/README.md#user-stories", + "version": "1.0.0", + "manifest": { + "version": "1.0.0", + "dependencies": ["openregister"], + "menu": [ + { "label": "Onboardings", "route": "/onboardings" }, + { "label": "Tasks", "route": "/tasks" } + ], + "pages": [ + { + "name": "Onboardings", + "route": "/onboardings", + "type": "index", + "title": "All onboardings", + "config": { "schema": "onboarding-task" } + }, + { + "name": "OnboardingDetail", + "route": "/onboardings/:id", + "type": "detail", + "title": "Onboarding detail", + "config": { "schema": "onboarding-task", "relatedSchema": "onboarding-document" } + }, + { + "name": "OnboardingTaskChecklist", + "route": "/tasks", + "type": "checklist", + "title": "Onboarding tasks", + "config": { "schema": "onboarding-task", "groupBy": "status" } + }, + { + "name": "OnboardingDocumentForm", + "route": "/documents/new", + "type": "form", + "title": "Add onboarding document", + "config": { "schema": "onboarding-document" } + } + ] + }, + "companionSchemas": [ + { + "slug": "onboarding-task", + "title": "Onboarding Task", + "version": "0.1.0", + "type": "object", + "required": ["employeeName", "status"], + "properties": { + "employeeName": { "type": "string" }, + "startDate": { "type": "string", "format": "date" }, + "department": { "type": "string" }, + "status": { + "type": "string", + "enum": ["pending", "in-progress", "done"], + "default": "pending" + } + } + }, + { + "slug": "onboarding-document", + "title": "Onboarding Document", + "version": "0.1.0", + "type": "object", + "required": ["taskUuid", "documentName"], + "properties": { + "taskUuid": { "type": "string", "format": "uuid" }, + "documentName": { "type": "string" }, + "uploadedFile": { "type": "string" }, + "approved": { "type": "boolean", "default": false } + } + } + ] +} diff --git a/lib/Settings/templates/incident-reporter.json b/lib/Settings/templates/incident-reporter.json new file mode 100644 index 00000000..0cda146f --- /dev/null +++ b/lib/Settings/templates/incident-reporter.json @@ -0,0 +1,68 @@ +{ + "slug": "incident-reporter", + "title": "Incident Reporter", + "description": "Report and triage field incidents — flexible severity and status workflow.", + "useCase": "Field-work incident reporting", + "category": "field-work", + "screenshotUrl": "img/templates/incident-reporter.svg", + "isSeeded": true, + "sourceUrl": "https://github.com/ConductionNL/concurrentie-analyse/blob/main/app-builder/README.md#user-stories", + "version": "1.0.0", + "manifest": { + "version": "1.0.0", + "dependencies": ["openregister"], + "menu": [ + { "label": "Report", "route": "/report" }, + { "label": "Incidents", "route": "/incidents" } + ], + "pages": [ + { + "name": "IncidentForm", + "route": "/report", + "type": "form", + "title": "Report an incident", + "config": { "schema": "incident" } + }, + { + "name": "Incidents", + "route": "/incidents", + "type": "index", + "title": "All incidents", + "config": { "schema": "incident" } + }, + { + "name": "IncidentDetail", + "route": "/incidents/:id", + "type": "detail", + "title": "Incident detail", + "config": { "schema": "incident" } + } + ] + }, + "companionSchemas": [ + { + "slug": "incident", + "title": "Incident", + "version": "0.1.0", + "type": "object", + "required": ["reportedBy", "incidentType", "severity"], + "properties": { + "reportedBy": { "type": "string" }, + "location": { "type": "string" }, + "incidentType": { "type": "string" }, + "severity": { + "type": "string", + "enum": ["low", "medium", "high", "critical"], + "default": "medium" + }, + "description": { "type": "string" }, + "reportedAt": { "type": "string", "format": "date-time" }, + "status": { + "type": "string", + "enum": ["new", "triaged", "resolved"], + "default": "new" + } + } + } + ] +} diff --git a/lib/Settings/templates/permit-tracker.json b/lib/Settings/templates/permit-tracker.json new file mode 100644 index 00000000..082ec5c7 --- /dev/null +++ b/lib/Settings/templates/permit-tracker.json @@ -0,0 +1,71 @@ +{ + "slug": "permit-tracker", + "title": "Permit Tracker", + "description": "Municipal building-permit workflow with index, detail, form, and kanban pages.", + "useCase": "Municipal building-permit workflow", + "category": "government-services", + "screenshotUrl": "img/templates/permit-tracker.svg", + "isSeeded": true, + "sourceUrl": "https://github.com/ConductionNL/concurrentie-analyse/blob/main/app-builder/README.md#user-stories", + "version": "1.0.0", + "manifest": { + "version": "1.0.0", + "dependencies": ["openregister"], + "menu": [ + { "label": "Applications", "route": "/applications" }, + { "label": "Kanban", "route": "/kanban" }, + { "label": "Submit", "route": "/submit" } + ], + "pages": [ + { + "name": "Applications", + "route": "/applications", + "type": "index", + "title": "Permit applications", + "config": { "schema": "permit-application" } + }, + { + "name": "ApplicationDetail", + "route": "/applications/:id", + "type": "detail", + "title": "Application detail", + "config": { "schema": "permit-application" } + }, + { + "name": "ApplicationForm", + "route": "/submit", + "type": "form", + "title": "Submit a permit application", + "config": { "schema": "permit-application" } + }, + { + "name": "ApplicationKanban", + "route": "/kanban", + "type": "kanban", + "title": "Application pipeline", + "config": { "schema": "permit-application", "groupBy": "status" } + } + ] + }, + "companionSchemas": [ + { + "slug": "permit-application", + "title": "Permit Application", + "version": "0.1.0", + "type": "object", + "required": ["applicant", "status"], + "properties": { + "applicant": { "type": "string", "description": "Name of the applicant" }, + "address": { "type": "string", "description": "Address the permit applies to" }, + "buildingType": { "type": "string", "description": "Type of building/structure" }, + "status": { + "type": "string", + "enum": ["draft", "submitted", "under-review", "approved", "rejected"], + "default": "draft" + }, + "submittedAt": { "type": "string", "format": "date-time" }, + "decision": { "type": "string", "description": "Decision rationale" } + } + } + ] +} diff --git a/lib/Settings/templates/stakeholder-consultation.json b/lib/Settings/templates/stakeholder-consultation.json new file mode 100644 index 00000000..19f9c7e9 --- /dev/null +++ b/lib/Settings/templates/stakeholder-consultation.json @@ -0,0 +1,82 @@ +{ + "slug": "stakeholder-consultation", + "title": "Stakeholder Consultation", + "description": "Run a public consultation: publish a topic, collect citizen comments, close on a deadline.", + "useCase": "Citizen engagement on policy or planning topics", + "category": "citizen-engagement", + "screenshotUrl": "img/templates/stakeholder-consultation.svg", + "isSeeded": true, + "sourceUrl": "https://github.com/ConductionNL/concurrentie-analyse/blob/main/app-builder/README.md#user-stories", + "version": "1.0.0", + "manifest": { + "version": "1.0.0", + "dependencies": ["openregister"], + "menu": [ + { "label": "Consultations", "route": "/consultations" }, + { "label": "Submit", "route": "/submit" } + ], + "pages": [ + { + "name": "Consultations", + "route": "/consultations", + "type": "index", + "title": "Open consultations", + "config": { "schema": "consultation" } + }, + { + "name": "ConsultationDetail", + "route": "/consultations/:id", + "type": "detail", + "title": "Consultation detail", + "config": { "schema": "consultation", "relatedSchema": "consultation-comment" } + }, + { + "name": "ConsultationForm", + "route": "/submit", + "type": "form", + "title": "Start a new consultation", + "config": { "schema": "consultation" } + }, + { + "name": "CommentForm", + "route": "/comment", + "type": "form", + "title": "Add your comment", + "config": { "schema": "consultation-comment" } + } + ] + }, + "companionSchemas": [ + { + "slug": "consultation", + "title": "Consultation", + "version": "0.1.0", + "type": "object", + "required": ["title", "status"], + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "openFrom": { "type": "string", "format": "date" }, + "closeAt": { "type": "string", "format": "date" }, + "status": { + "type": "string", + "enum": ["draft", "open", "closed"], + "default": "draft" + } + } + }, + { + "slug": "consultation-comment", + "title": "Consultation Comment", + "version": "0.1.0", + "type": "object", + "required": ["consultationUuid", "body"], + "properties": { + "consultationUuid": { "type": "string", "format": "uuid" }, + "authorName": { "type": "string" }, + "body": { "type": "string" }, + "createdAt": { "type": "string", "format": "date-time" } + } + } + ] +} diff --git a/src/modals/CloneTemplateDialog.vue b/src/modals/CloneTemplateDialog.vue new file mode 100644 index 00000000..e464394d --- /dev/null +++ b/src/modals/CloneTemplateDialog.vue @@ -0,0 +1,128 @@ + + + + + + diff --git a/src/views/TemplateGallery.vue b/src/views/TemplateGallery.vue new file mode 100644 index 00000000..34690a32 --- /dev/null +++ b/src/views/TemplateGallery.vue @@ -0,0 +1,298 @@ + + + + + + From 0fe0308721c060a72a73cc15cbca85b8f49ef3c5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 12 May 2026 11:49:20 +0200 Subject: [PATCH 2/2] feat: re-apply openbuilt-templates-marketplace onto the Tier-4 shell Graft chain spec #8 (openbuilt-templates-marketplace) onto current development: - ApplicationsController::createFromTemplate + 13 helper methods (per-app register provisioning, companion-schema cloning, manifest schema-ref rewriting). Single new route POST /api/applications/from-template/{templateSlug}. - ApplicationTemplate schema (slug application-template) added to lib/Settings/openbuilt_register.json. - SeedApplicationTemplates repair step registered in info.xml (install + post-migration); seeds the 4 Conduction-curated templates from lib/Settings/templates/*.json. - Frontend: TemplateGallery.vue view + CloneTemplateDialog.vue modal wired via customComponents.js; Templates page + menu entry in src/manifest.json. Clone redirect feature-detects PageEditor, falls back to VirtualApps then Dashboard (matches the Tier-4 manifest). - phpmd.xml: raised ExcessiveClassComplexity 70->120 and added ExcessiveClassLength minimum 1500 for the now-four-endpoint ApplicationsController (no @SuppressWarnings). - psalm.xml: OCA\OpenRegister\Db\Register added to UndefinedClass suppress list. - Tests: vitest specs (TemplateGallery, CloneTemplateDialog), PHP unit tests (CreateFromTemplateTest adapted to the 8-arg ctor, SeedApplicationTemplatesTest), Newman collection, Playwright e2e. - openspec change directory carried over (to be archived separately). Quality gates green: phpcs, phpmd, psalm, phpstan, eslint, stylelint, vitest (93 tests), webpack production build. PHPUnit tests/Unit/** stay red in CI because OpenRegister isn't installed there (issue #11). --- appinfo/info.xml | 2 + appinfo/routes.php | 7 + lib/Controller/ApplicationsController.php | 507 +++++++++++++++- lib/Settings/openbuilt_register.json | 88 ++- .../.openspec.yaml | 2 + .../openbuilt-templates-marketplace/design.md | 563 ++++++++++++++++++ .../proposal.md | 168 ++++++ .../openbuilt-template-catalogue/spec.md | 315 ++++++++++ .../openbuilt-templates-marketplace/tasks.md | 88 +++ phpmd.xml | 26 +- psalm.xml | 1 + src/customComponents.js | 4 + src/manifest.json | 2 + src/views/TemplateGallery.vue | 5 +- .../Controller/CreateFromTemplateTest.php | 477 +++++++++++++++ .../Repair/SeedApplicationTemplatesTest.php | 316 ++++++++++ tests/e2e/template-gallery.spec.ts | 108 ++++ ...plates-marketplace.postman_collection.json | 188 ++++++ tests/modals/CloneTemplateDialog.spec.js | 114 ++++ tests/views/TemplateGallery.spec.js | 218 +++++++ 20 files changed, 3184 insertions(+), 15 deletions(-) create mode 100644 openspec/changes/openbuilt-templates-marketplace/.openspec.yaml create mode 100644 openspec/changes/openbuilt-templates-marketplace/design.md create mode 100644 openspec/changes/openbuilt-templates-marketplace/proposal.md create mode 100644 openspec/changes/openbuilt-templates-marketplace/specs/openbuilt-template-catalogue/spec.md create mode 100644 openspec/changes/openbuilt-templates-marketplace/tasks.md create mode 100644 tests/Unit/Controller/CreateFromTemplateTest.php create mode 100644 tests/Unit/Repair/SeedApplicationTemplatesTest.php create mode 100644 tests/e2e/template-gallery.spec.ts create mode 100644 tests/integration/openbuilt-templates-marketplace.postman_collection.json create mode 100644 tests/modals/CloneTemplateDialog.spec.js create mode 100644 tests/views/TemplateGallery.spec.js diff --git a/appinfo/info.xml b/appinfo/info.xml index a349e656..baf7f299 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -85,11 +85,13 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\OpenBuilt\Repair\InitializeSettings OCA\OpenBuilt\Repair\SeedHelloWorld OCA\OpenBuilt\Repair\PopulateApplicationPermissions + OCA\OpenBuilt\Repair\SeedApplicationTemplates OCA\OpenBuilt\Repair\InitializeSettings OCA\OpenBuilt\Repair\SeedHelloWorld OCA\OpenBuilt\Repair\PopulateApplicationPermissions + OCA\OpenBuilt\Repair\SeedApplicationTemplates diff --git a/appinfo/routes.php b/appinfo/routes.php index a975a842..fd15b5cf 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -25,6 +25,13 @@ // is order-sensitive when prefix overlaps). ['name' => 'applications#listMine', 'url' => '/api/applications', 'verb' => 'GET'], + // Clone-from-template action (openbuilt-templates-marketplace REQ-OBTC-004 / REQ-OBTC-005). + // POST so it does not collide with the GET {slug} routes; #[NoAdminRequired] on the + // controller method. Creates a per-app `openbuilt-{newSlug}` register, deep-copies the + // template's companion schemas into it, rewrites manifest schema refs, and persists a new + // Application in the shared `openbuilt` register tagged with the caller's UID. + ['name' => 'applications#createFromTemplate', 'url' => '/api/applications/from-template/{templateSlug}', 'verb' => 'POST'], + // Manifest endpoint — returns the stored manifest JSON blob for a given virtual-app slug. // Per ADR-016 routes.php is the only registration path; #[NoAdminRequired] is set on the // controller method so auth-required-but-non-admin users can hit it (per design.md Decision 6). diff --git a/lib/Controller/ApplicationsController.php b/lib/Controller/ApplicationsController.php index 1d218ad9..172fbe96 100644 --- a/lib/Controller/ApplicationsController.php +++ b/lib/Controller/ApplicationsController.php @@ -3,12 +3,18 @@ /** * OpenBuilt Applications Controller * - * Serves the per-virtual-app manifest endpoint plus the RBAC-filtered list - * endpoint used by the editor (REQ-OBRBAC-002 / REQ-OBR-007). Per design.md - * Decision 6 only the manifest endpoint exists; this controller adds a - * minimal `listMine` action because OR's schema-level read rule is a - * coarse group-ACL (not a row-level filter on the Application's - * `permissions` block) so the list MUST be filtered server-side here. + * Serves the per-virtual-app manifest endpoint, the RBAC-filtered list + * endpoint used by the editor (REQ-OBRBAC-002 / REQ-OBR-007), the + * manifest-diff endpoint (openbuilt-versioning REQ-OBV-005) and the + * clone-from-template action (openbuilt-templates-marketplace + * REQ-OBTC-004 / REQ-OBTC-005). Per design.md Decision 6 this is the + * single app-local HTTP surface; `listMine` exists because OR's + * schema-level read rule is a coarse group-ACL (not a row-level filter on + * the Application's `permissions` block) so the list MUST be filtered + * server-side here, and `createFromTemplate` is the thin-glue clone action + * (ADR-032) that provisions a per-app `openbuilt-{slug}` register and + * deep-copies the template's companion schemas into it (hybrid register + * model). * * SPDX-License-Identifier: EUPL-1.2 * SPDX-FileCopyrightText: 2026 Conduction B.V. @@ -50,7 +56,7 @@ use Throwable; /** - * Controller for the OpenBuilt manifest + list endpoints. + * Controller for the OpenBuilt manifest, list, diff and clone-from-template endpoints. */ class ApplicationsController extends Controller { @@ -679,6 +685,493 @@ private function collectAuthorisedGroups(array $application): array return array_keys($merged); }//end collectAuthorisedGroups() + /** + * Clone an Application from a template. + * + * Reads the ApplicationTemplate identified by $templateSlug, creates a + * per-app `openbuilt-{newSlug}` register, deep-copies its companion JSON + * schemas into that per-app register (REQ-OBTC-005 / hybrid register + * model), rewrites manifest schema refs to the new slug, and creates a + * new Application record in the shared `openbuilt` register, tagged + * with the caller's UID (multi-user isolation). + * + * @param string $templateSlug The source template slug + * + * @return JSONResponse The new application's uuid + slug, or an error envelope + */ + #[NoAdminRequired] + public function createFromTemplate(string $templateSlug): JSONResponse + { + // 1. Auth + request validation. + $user = $this->userSession->getUser(); + if ($user === null) { + return $this->errorResponse(code: 'unauthenticated', status: Http::STATUS_UNAUTHORIZED); + } + + $ownerUid = $user->getUID(); + + $validation = $this->validateCloneRequest(body: $this->request->getParams()); + if (isset($validation['error']) === true) { + return new JSONResponse(data: $validation['error'], statusCode: $validation['status']); + } + + [$name, $newSlug] = $validation; + + // 2. Resolve shared register + schemas. + $ctx = $this->resolveSharedContext(); + if ($ctx === null) { + return $this->errorResponse( + code: 'not_configured', + detail: 'OpenBuilt register/schemas not initialised', + status: Http::STATUS_SERVICE_UNAVAILABLE + ); + } + + // 3. Lookup template + slug-collision check (scoped to caller's UID). + $template = $this->lookupOne( + registerId: $ctx['register'], + schemaId: $ctx['templateSchema'], + slug: $templateSlug + ); + if ($template === null) { + return $this->errorResponse( + code: 'template_not_found', + detail: $templateSlug, + status: Http::STATUS_NOT_FOUND + ); + } + + $existing = $this->lookupOne( + registerId: $ctx['register'], + schemaId: $ctx['applicationSchema'], + slug: $newSlug, + owner: $ownerUid + ); + if ($existing !== null) { + return $this->errorResponse( + code: 'slug_collision', + detail: $newSlug, + status: Http::STATUS_CONFLICT + ); + } + + // 4. Prepare manifest + companion-schema clone map. + $companionInput = $this->extractCompanionSchemas(template: $template); + $rewriteMap = $this->buildRewriteMap(companions: $companionInput, newSlug: $newSlug); + $manifest = $this->buildClonedManifest(template: $template, rewriteMap: $rewriteMap); + + // 5. Provision per-app register + clone companion schemas into it. + $cloneResult = $this->provisionPerAppArtifacts( + newSlug: $newSlug, + ownerUid: $ownerUid, + companions: $companionInput, + rewriteMap: $rewriteMap + ); + if (isset($cloneResult['error']) === true) { + return new JSONResponse(data: $cloneResult['error'], statusCode: $cloneResult['status']); + } + + // 6. Persist the Application record (in shared register), tagged with owner. + $persistResult = $this->persistApplication( + name: $name, + newSlug: $newSlug, + ownerUid: $ownerUid, + manifest: $manifest, + template: $template, + templateSlug: $templateSlug, + ctx: $ctx + ); + if (isset($persistResult['error']) === true) { + return new JSONResponse(data: $persistResult['error'], statusCode: $persistResult['status']); + } + + return new JSONResponse( + data: [ + 'uuid' => $persistResult['uuid'], + 'slug' => $newSlug, + 'register' => $cloneResult['register']->getSlug(), + 'companionSchemas' => $cloneResult['schemaIds'], + ], + statusCode: Http::STATUS_CREATED + ); + }//end createFromTemplate() + + /** + * Build a uniform error response. + * + * @param string $code The error code + * @param string|null $detail Optional detail message + * @param int $status The HTTP status code + * + * @return JSONResponse + */ + private function errorResponse(string $code, ?string $detail=null, int $status=Http::STATUS_BAD_REQUEST): JSONResponse + { + $body = ['error' => $code]; + if ($detail !== null) { + $body['detail'] = $detail; + } + + return new JSONResponse(data: $body, statusCode: $status); + }//end errorResponse() + + /** + * Resolve the shared register + schema IDs (template, application). + * + * @return array{register:int,templateSchema:int,applicationSchema:int}|null + */ + private function resolveSharedContext(): ?array + { + try { + return [ + 'register' => $this->registerMapper->find('openbuilt', _multitenancy: false)->getId(), + 'templateSchema' => $this->schemaMapper->find('application-template', _multitenancy: false)->getId(), + 'applicationSchema' => $this->schemaMapper->find('application', _multitenancy: false)->getId(), + ]; + } catch (Throwable $e) { + $this->logger->error( + 'OpenBuilt: register/schema resolution failed', + ['exception' => $e->getMessage()] + ); + return null; + } + }//end resolveSharedContext() + + /** + * Build the cloned manifest (apply rewrite map to template manifest). + * + * @param array $template The template record + * @param array $rewriteMap Source-slug → prefixed-slug map + * + * @return array + */ + private function buildClonedManifest(array $template, array $rewriteMap): array + { + $manifestRaw = ($template['manifest'] ?? null); + $manifest = []; + if (is_array($manifestRaw) === true) { + $manifest = $manifestRaw; + } + + $rewritten = $this->rewriteSchemaRefs(node: $manifest, map: $rewriteMap); + if (is_array($rewritten) === true) { + return $rewritten; + } + + return []; + }//end buildClonedManifest() + + /** + * Provision per-app register + clone companion schemas. + * + * @param string $newSlug The new application slug + * @param string $ownerUid The owner UID + * @param array> $companions The companion schema blobs + * @param array $rewriteMap Source-slug → prefixed-slug map + * + * @return array{register:\OCA\OpenRegister\Db\Register,schemaIds:array}|array{error:array,status:int} + */ + private function provisionPerAppArtifacts( + string $newSlug, + string $ownerUid, + array $companions, + array $rewriteMap + ): array { + try { + $register = $this->provisionPerAppRegister(newSlug: $newSlug, ownerUid: $ownerUid); + $schemaIds = $this->cloneCompanionSchemas( + companions: $companions, + rewriteMap: $rewriteMap, + perAppRegister: $register + ); + + return ['register' => $register, 'schemaIds' => $schemaIds]; + } catch (Throwable $e) { + $this->logger->error( + 'OpenBuilt: companion-schema clone failed', + ['exception' => $e->getMessage()] + ); + return [ + 'error' => ['error' => 'clone_failed', 'detail' => 'Failed to provision per-app register/schemas'], + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + ]; + } + }//end provisionPerAppArtifacts() + + /** + * Persist the cloned Application record. + * + * @param string $name Human-readable name + * @param string $newSlug The new application slug + * @param string $ownerUid The owner UID (multi-user isolation) + * @param array $manifest The cloned manifest + * @param array $template The source template record + * @param string $templateSlug The source template slug + * @param array{register:int,templateSchema:int,applicationSchema:int} $ctx Shared context + * + * @return array{uuid:string|null}|array{error:array,status:int} + */ + private function persistApplication( + string $name, + string $newSlug, + string $ownerUid, + array $manifest, + array $template, + string $templateSlug, + array $ctx + ): array { + try { + $created = $this->objectService->saveObject( + object: [ + 'name' => $name, + 'slug' => $newSlug, + 'status' => 'draft', + 'version' => '0.1.0', + 'owner' => $ownerUid, + 'manifest' => $manifest, + 'templateOrigin' => [ + 'slug' => (string) ($template['slug'] ?? $templateSlug), + 'version' => (string) ($template['version'] ?? ''), + ], + ], + register: $ctx['register'], + schema: $ctx['applicationSchema'] + ); + } catch (Throwable $e) { + $this->logger->error('OpenBuilt: application save failed', ['exception' => $e->getMessage()]); + return [ + 'error' => ['error' => 'clone_failed', 'detail' => $e->getMessage()], + 'status' => Http::STATUS_INTERNAL_SERVER_ERROR, + ]; + }//end try + + $createdArray = $this->normaliseObject(object: $created); + return ['uuid' => ($createdArray['uuid'] ?? $createdArray['id'] ?? null)]; + }//end persistApplication() + + /** + * Validate the clone-from-template request body. + * + * @param array $body The request params + * + * @return array{0:string,1:string}|array{error:array,status:int} + * Either [name, slug] on success, or an error+status envelope. + */ + private function validateCloneRequest(array $body): array + { + $name = (string) ($body['name'] ?? ''); + $slug = (string) ($body['slug'] ?? ''); + + if ($name === '' || $slug === '' || preg_match('/^[a-z0-9][a-z0-9-]*[a-z0-9]$/', $slug) !== 1) { + return [ + 'error' => ['error' => 'invalid_request', 'detail' => 'name and kebab-case slug required'], + 'status' => Http::STATUS_BAD_REQUEST, + ]; + } + + if (strlen($slug) > 32) { + return [ + 'error' => ['error' => 'slug_too_long', 'detail' => 'slug must be <= 32 chars'], + 'status' => Http::STATUS_BAD_REQUEST, + ]; + } + + return [$name, $slug]; + }//end validateCloneRequest() + + /** + * Extract companionSchemas array from a template record. + * + * @param array $template The template record + * + * @return array> + */ + private function extractCompanionSchemas(array $template): array + { + $companionRaw = ($template['companionSchemas'] ?? null); + if (is_array($companionRaw) === false) { + return []; + } + + return array_values( + array_filter( + $companionRaw, + static fn ($entry): bool => is_array($entry) === true && isset($entry['slug']) === true + ) + ); + }//end extractCompanionSchemas() + + /** + * Build the source-slug → prefixed-slug rewrite map. + * + * @param array> $companions The companion schema blobs + * @param string $newSlug The new app slug used as prefix + * + * @return array + */ + private function buildRewriteMap(array $companions, string $newSlug): array + { + $map = []; + foreach ($companions as $companion) { + $sourceSlug = (string) $companion['slug']; + $map[$sourceSlug] = $newSlug.'-'.$sourceSlug; + } + + return $map; + }//end buildRewriteMap() + + /** + * Provision (or fetch existing) the per-app register `openbuilt-{newSlug}`. + * + * Per the hybrid register model, each cloned app gets its own register so + * companion schemas don't collide across apps. + * + * @param string $newSlug The new app slug + * @param string $ownerUid The Nextcloud UID of the owner + * + * @return \OCA\OpenRegister\Db\Register + */ + private function provisionPerAppRegister(string $newSlug, string $ownerUid): \OCA\OpenRegister\Db\Register + { + $registerSlug = 'openbuilt-'.$newSlug; + + try { + return $this->registerMapper->find($registerSlug, _multitenancy: false); + } catch (Throwable) { + // Register does not exist yet — create it. + } + + return $this->registerMapper->createFromArray( + [ + 'slug' => $registerSlug, + 'title' => 'OpenBuilt — '.$newSlug, + 'description' => 'Per-app schema namespace for OpenBuilt app `'.$newSlug.'` (owner: '.$ownerUid.').', + 'version' => '0.1.0', + 'schemas' => [], + ] + ); + }//end provisionPerAppRegister() + + /** + * Clone companion schemas into the per-app register. + * + * Critical fix: companion schemas are CREATED AS SCHEMAS via SchemaMapper + * (NOT saved as Application objects, which was the bug at the previous + * line 168). The per-app register's `schemas` array is updated to include + * the new schema IDs. + * + * @param array> $companions The companion schema blobs from the template + * @param array $rewriteMap Source-slug → prefixed-slug map + * @param \OCA\OpenRegister\Db\Register $perAppRegister The target per-app register + * + * @return array List of created schema IDs + */ + private function cloneCompanionSchemas( + array $companions, + array $rewriteMap, + \OCA\OpenRegister\Db\Register $perAppRegister + ): array { + $createdIds = []; + + foreach ($companions as $companion) { + $sourceSlug = (string) $companion['slug']; + if (isset($rewriteMap[$sourceSlug]) === false) { + continue; + } + + $schemaPayload = $companion; + $schemaPayload['slug'] = $rewriteMap[$sourceSlug]; + // Ensure a stable version (templates ship with their own; default to 0.1.0). + if (isset($schemaPayload['version']) === false) { + $schemaPayload['version'] = '0.1.0'; + } + + $schema = $this->schemaMapper->createFromArray(object: $schemaPayload); + $createdIds[] = $schema->getId(); + } + + if ($createdIds !== []) { + $existing = $perAppRegister->getSchemas(); + $perAppRegister->setSchemas(array_values(array_unique(array_merge($existing, $createdIds)))); + $this->registerMapper->update($perAppRegister); + } + + return $createdIds; + }//end cloneCompanionSchemas() + + /** + * Recursively rewrite manifest page-config schema references. + * + * @param mixed $node The manifest node + * @param array $map Map of source-slug => prefixed-slug + * + * @return mixed The rewritten node + */ + private function rewriteSchemaRefs(mixed $node, array $map): mixed + { + if (is_array($node) === false) { + return $node; + } + + foreach ($node as $key => $value) { + if (($key === 'schema' || $key === 'relatedSchema') + && is_string($value) === true + && isset($map[$value]) === true + ) { + $node[$key] = $map[$value]; + continue; + } + + if (is_array($value) === true) { + $node[$key] = $this->rewriteSchemaRefs(node: $value, map: $map); + } + } + + return $node; + }//end rewriteSchemaRefs() + + /** + * Look up a single object by slug (optionally scoped by owner). + * + * @param int|string $registerId The register ID + * @param int|string $schemaId The schema ID + * @param string $slug The slug to look up + * @param string|null $owner Optional owner UID (multi-user isolation scope) + * + * @return array|null + */ + private function lookupOne( + int | string $registerId, + int | string $schemaId, + string $slug, + ?string $owner=null + ): ?array { + try { + $query = [ + '@self' => [ + 'register' => $registerId, + 'schema' => $schemaId, + ], + 'slug' => $slug, + ]; + + if ($owner !== null) { + $query['owner'] = $owner; + } + + $results = $this->objectService->searchObjects(query: $query); + + if (is_array($results) === false || count($results) === 0) { + return null; + } + + return $this->normaliseObject(object: $results[0]); + } catch (Throwable $e) { + $this->logger->warning('OpenBuilt: lookup failed', ['exception' => $e->getMessage()]); + return null; + }//end try + }//end lookupOne() + /** * Coerce an OR result entry (ObjectEntity or array) to a plain associative array. * diff --git a/lib/Settings/openbuilt_register.json b/lib/Settings/openbuilt_register.json index 2678fc22..0ae50b9e 100644 --- a/lib/Settings/openbuilt_register.json +++ b/lib/Settings/openbuilt_register.json @@ -9,7 +9,7 @@ "type": "application", "app": "openbuilt", "openregister": "^v0.2.10", - "description": "OpenBuilt register namespace. Hosts the Application + BuiltAppRoute control schemas plus the seed `hello-message` schema for the canonical hello-world virtual app." + "description": "OpenBuilt register namespace. Hosts the Application + BuiltAppRoute control schemas, the ApplicationTemplate catalogue (spec #8), and the seed `hello-message` schema for the canonical hello-world virtual app." }, "paths": {}, "components": { @@ -192,6 +192,92 @@ ] } }, + "ApplicationTemplate": { + "slug": "application-template", + "icon": "ViewGridOutline", + "version": "0.1.0", + "title": "Application Template", + "description": "A pre-baked starter pack for building an Application from a recognisable use case — manifest plus companion schemas. Seeded Conduction-curated templates carry isSeeded: true. Per the hybrid register model this schema is shared across the `openbuilt` register; cloned user schemas land in a per-app `openbuilt-{slug}` register.", + "type": "object", + "required": [ + "slug", + "title", + "description", + "useCase", + "category", + "manifest", + "isSeeded", + "version" + ], + "properties": { + "slug": { + "type": "string", + "description": "Kebab-case unique slug for the template.", + "pattern": "^[a-z0-9][a-z0-9-]*[a-z0-9]$", + "minLength": 2, + "maxLength": 48, + "example": "permit-tracker" + }, + "title": { + "type": "string", + "description": "Human-readable title (plain English) shown in the gallery.", + "example": "Permit Tracker" + }, + "description": { + "type": "string", + "description": "One-paragraph plain-English summary.", + "example": "Municipal building-permit workflow with index, detail, form, and kanban pages." + }, + "useCase": { + "type": "string", + "description": "Short one-line label describing the use case (plain English).", + "example": "Municipal building-permit workflow" + }, + "category": { + "type": "string", + "description": "Top-level category used for filtering in the gallery.", + "enum": [ + "government-services", + "internal-operations", + "citizen-engagement", + "field-work" + ] + }, + "screenshotUrl": { + "type": "string", + "description": "Relative path under the app's img/templates/ folder, or an OR Files URL.", + "example": "img/templates/permit-tracker.svg" + }, + "manifest": { + "type": "object", + "description": "Full app-manifest JSON blob (ADR-024) to be deep-copied on clone.", + "additionalProperties": true + }, + "companionSchemas": { + "type": "array", + "description": "JSON-schema blobs to be cloned alongside the manifest into the user's per-app register (openbuilt-{slug}).", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "isSeeded": { + "type": "boolean", + "description": "True for Conduction-shipped templates, false for org-local templates.", + "default": false + }, + "sourceUrl": { + "type": "string", + "description": "Link back to the originating user-story / RFP / blog post for traceability." + }, + "version": { + "type": "string", + "description": "Semver version of the template — recorded on clone for one-shot snapshot semantics.", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "example": "1.0.0" + } + } + }, "BuiltAppRoute": { "slug": "built-app-route", "icon": "RouterNetwork", diff --git a/openspec/changes/openbuilt-templates-marketplace/.openspec.yaml b/openspec/changes/openbuilt-templates-marketplace/.openspec.yaml new file mode 100644 index 00000000..81cd71fe --- /dev/null +++ b/openspec/changes/openbuilt-templates-marketplace/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-11 diff --git a/openspec/changes/openbuilt-templates-marketplace/design.md b/openspec/changes/openbuilt-templates-marketplace/design.md new file mode 100644 index 00000000..9239bf45 --- /dev/null +++ b/openspec/changes/openbuilt-templates-marketplace/design.md @@ -0,0 +1,563 @@ +## Context + +This is spec #8 in the 9-spec OpenBuilt chain (per ADR-032), depending +on: + +- **`bootstrap-openbuilt`** (#1) — provides the `openbuilt` register + namespace, `Application` + `BuiltAppRoute` schemas, the + nested-`CnAppRoot` runtime, and the canonical `SeedHelloWorld.php` + pattern this spec replicates. +- **`openbuilt-page-editor`** (#5) — provides the visual page-level + editor that the clone flow redirects into for customisation + (REQ-OBTC-006). +- **`openbuilt-schema-editor`** (#4) — provides the visual schema + editor a user reaches by navigating from the cloned Application to + a cloned companion schema. Not strictly required by the clone flow + itself, but the gallery experience is incomplete without the schema + editor present because a cloned Application points at cloned + schemas the user will want to edit. + +The chain order matters because "Use this template" is only a useful +on-ramp if the user lands somewhere they can keep working. Chain #1's +textarea editor is a degraded fallback (REQ-OBTC-006); the page editor +is the intended landing surface. + +The user-stories that motivate the four seeded templates live in +`concurrentie-analyse/app-builder/README.md` §"User Stories": + +| Template | User story | Persona | +|---|---|---| +| `permit-tracker` | US-1 — "create a permit tracking app" | municipal department head | +| `stakeholder-consultation` | US-2 — "build a stakeholder consultation app" | policy advisor | +| `incident-reporter` | US-3 — "compose an incident reporting app" | safety-region team coordinator (e.g. VGGM) | +| `employee-onboarding` | US-4 — "create an employee onboarding app" | HR manager | + +US-5 ("client intake app that prefills from BRP via OpenConnector") is +explicitly **not** seeded in this spec because it requires a working +OpenConnector source binding that the OpenBuilt manifest does not yet +express in v1.4.x — that template lands in a follow-up once the +manifest gains OpenConnector binding metadata. + +## Goals / Non-Goals + +**Goals** + +- Ship the `ApplicationTemplate` OR schema in the existing `openbuilt` + register namespace. +- Seed four Conduction-curated templates via a new + `SeedApplicationTemplates.php` repair step that follows the + `SeedHelloWorld.php` idempotent guard pattern. +- Ship a gallery view (`TemplateGallery.vue`) reachable from the + OpenBuilt left-nav and from the empty-state of the Application list. +- Ship a clone action (`createFromTemplate`) that lands the user + inside the page editor with a fully editable, namespaced copy of the + template's manifest + companion schemas. +- Preserve traceability — every clone records `templateOrigin.slug` + + `templateOrigin.version` so support staff can answer "which template + did this app come from?" later. + +**Non-Goals (deferred)** + +- Community / user-submitted templates. The schema already carries + `isSeeded` so the follow-up spec for community submissions does not + require a migration. +- Publishing an existing Application as a template (the inverse + flow). Deferred to a follow-up issue + (`#openbuilt-template-publishing`). +- Template versioning / upgrade-from-template. Clones are one-shot + snapshots (REQ-OBTC-007). +- Files-API screenshot uploads. Screenshots ship as static assets in + `img/templates/` for v1 — a follow-up spec can route them through + OR Files when community submissions arrive. +- US-5 (client intake app) — depends on manifest-level OpenConnector + binding metadata not yet in v1.4.x; deferred to a follow-up. + +## Decisions + +### Decision 1 — Templates as OR objects, not static JSON files + +**Decision**: `ApplicationTemplate` is an OR schema; templates are +records, not files. + +**Why this matters**: a competing approach is to ship templates as +JSON files under `lib/Settings/templates/*.json` and have the gallery +read them directly off disk. That is tempting because (a) the seed +data is conceptually "fixtures", (b) it skips a schema declaration, +and (c) the gallery becomes a static asset. + +We reject it because OpenBuilt's whole architectural commitment is +ADR-022: **consume OpenRegister, do not invent app-local stores**. +Treating templates as files would create a second source of truth +that does not get RBAC, audit, GraphQL, MCP, CloudEvents, or any of +the things every other Conduction app gets for free by virtue of +being on OR. The marginal cost of declaring one more schema is small; +the marginal cost of building an app-local file-based registry — and +then watching the community-submission spec migrate it to OR — is +large. + +This decision also keeps the **clone semantics consistent**: the +clone action is "read one OR object, write another OR object", which +is the canonical OR operation. If templates were files, the clone +action would be heterogenous (read-file, write-OR-object), making +the chain harder to reason about. + +**Storage trade-off**: the four seed template manifests are still +human-readable JSON in the repo (under `lib/Settings/templates/{slug}.json`) +because they are non-trivial to review embedded in a PHP repair step. +The repair step loads them from disk at install time and writes them +into OR — the file is the **source**, OR is the **runtime**. This +mirrors the pattern `SeedHelloWorld.php` uses for its +`hello-message` sample objects. + +**Alternatives considered** + +- *Static JSON gallery, no OR schema*. Rejected per the ADR-022 logic + above. Also makes future "edit a template in the UI" impossible + without a migration. +- *Hybrid — seed templates as OR objects, but read them through a new + `TemplateService` PHP class*. Rejected per ADR-022 (no wrapper + services) and ADR-031 (declarative-first). The gallery reads OR's + REST directly. + +### Decision 2 — Per-org namespace with `isSeeded` flag + +**Decision**: templates live per-organisation, scoped via OR's +standard `organisation` field. The four Conduction-curated templates +ship with `isSeeded: true` and are seeded into each organisation that +installs OpenBuilt. + +**Why this matters**: the alternative is a single "global" Conduction +namespace that every org reads from. That sounds simpler but has two +problems: + +1. **Cross-org isolation** — when chain spec #7 (`openbuilt-rbac`) + lands per-built-app permissions, the global namespace becomes a + special case that has to be threaded through every RBAC check. + Per-org isolation matches every other OR record in OpenBuilt and + uses no special-case code. +2. **Future org-local templates** — the community-submission follow-up + (deferred) will want org-local templates that admins can curate + for their staff. Per-org from day one means the schema does not + need a migration when that arrives. + +The `isSeeded: true` flag is the **only** distinction between +Conduction-curated and org-local templates. The gallery treats +`isSeeded: true` templates as read-only in the UI (REQ-OBTC-008) so a +distracted admin cannot accidentally delete `permit-tracker` from +their org. Backend deletion via OR REST is still governed by OR's +standard RBAC and is intentionally not blocked at the schema level — +this is a UI affordance, not an authorisation rule. + +**Operational consequence**: when an org installs OpenBuilt, the seed +step iterates over every existing organisation in the system and +seeds the four curated templates into each. Newly-created +organisations get seeded on their next OpenBuilt repair-step run +(typically the next deploy). The repair step's idempotency guard +(per-slug existence check, per-org scope) keeps this safe. + +**Alternatives considered** + +- *Single global org "openbuilt" hosting the curated templates*. + Rejected per the RBAC and migration arguments above. +- *Per-user templates, not per-org*. Rejected — collaboration breaks + if Alice's templates are invisible to her colleague Bob in the same + organisation. OR's organisation scope is the right grain. + +### Decision 3 — Slug-prefix the cloned companion schemas + +**Decision**: on clone, the new Application's `slug` is joined by a +hyphen to each cloned companion-schema's `slug`. Example: +`permit-tracker` template → cloned into Application +`slug: my-permits` → the `permit-application` companion schema is +cloned as `slug: my-permits-permit-application`. The cloned manifest's +page-config `schema` references are rewritten to match. + +**Why this matters**: without a prefix, two clones of the same +template into the same organisation collide on the schema slug. With +a UUID-suffix prefix (e.g. `permit-application-{8-char-uuid}`) the +slug becomes user-hostile in the schema-editor URL. With the +Application's slug as the prefix, the schema slug stays human-readable +and unambiguously identifies its owning Application. + +**Trade-off**: schema slugs become long (`my-permits-permit-application` +is 30 characters). OR's schema-slug column is generously sized, so +this is fine technically; the readability hit is minor and the +naming convention is consistent. + +**Risk**: if the user changes the new Application's `slug` after +clone, the companion-schema slugs do not auto-rename. We accept this +— renaming an Application is a rare operation and Cascade renames +across schemas + manifest references introduce complexity out of +proportion to the benefit. Document it in the +`docs/integrator-guide.md` update. + +**Alternatives considered** + +- *No prefix; reject the clone if a schema-slug collides*. Rejected + because it makes "clone twice" a confusing UX. +- *UUID-suffix the cloned schemas*. Rejected for the readability hit. +- *Per-application sub-namespace in OR (e.g. register + `openbuilt-{app-slug}`)*. Rejected because it explodes the register + namespace count (one per virtual app) without a clear benefit, and + chain spec #1's `BuiltAppRoute` already gives us the per-app + routing layer. + +### Decision 4 — Screenshots committed to repo for v1, Files API in follow-up + +**Decision**: the four seeded templates' screenshots ship as PNGs in +`img/templates/{slug}.png`, served via Nextcloud's standard +`apps/openbuilt/img/templates/{slug}.png` static-asset path. +`screenshotUrl` on a seeded template stores a relative path +(`img/templates/permit-tracker.png`); the gallery resolves it via +Nextcloud's `OC.imagePath('openbuilt', 'templates/permit-tracker.png')` +or the Vue-side equivalent (`generateUrl('/apps/openbuilt/img/...')`). + +**Why this matters**: putting screenshots in the repo for the seeded +four is **free** — they're tracked binaries no different to icons — +and it keeps the install footprint zero-network. The follow-up +community-submission spec will need user-uploaded screenshots, at +which point OR Files becomes the storage. The schema's +`screenshotUrl` accepts either a relative path or an OR Files URL, +so the migration is additive (no schema change). + +**Trade-off**: the repo grows by ~4 PNGs. Acceptable. + +**Alternatives considered** + +- *Ship screenshots via OR Files from day one*. Rejected because it + requires a working Files-upload flow in the seed step, which is + more complex than copying a PNG into `img/`. Defer the complexity + to the spec that actually needs it. +- *No screenshots in v1; text-only gallery*. Rejected because the + whole point of templates is the visual on-ramp. A "Permit Tracker" + label without a screenshot is not meaningfully more inviting than + a blank-manifest editor. + +### Decision 5 — Template versioning is deferred; clones are one-shot snapshots + +**Decision**: `ApplicationTemplate.version` exists on the schema and +is recorded on the cloned Application under `templateOrigin.version`, +but the system performs **no** upgrade or propagation. A template +update never modifies existing clones (REQ-OBTC-007). + +**Why this matters**: real versioning means "decide what to do when a +template is updated and an Application was cloned from the old +version" — propagate? prompt the user? offer a diff? show a +deprecated banner? These are all real product decisions and they +belong in a follow-up spec where the rest of the chain (#6 versioning) +provides the diff/snapshot machinery the answer would need. Spec #8 +ships the **catalogue** and the **on-ramp**; spec-versioning ships +the **upgrade flow**. + +`templateOrigin.version` is recorded anyway so the follow-up spec +does not need a migration to figure out which Applications were +cloned from which template version. Forward-compatibility for free. + +**Alternatives considered** + +- *No `version` field on templates at all*. Rejected — leaves the + follow-up versioning spec with no anchor for "what did this clone + come from?". +- *Auto-propagate template updates to existing clones*. Rejected as a + silent-data-overwrite anti-pattern; this is exactly the kind of + behaviour the user would never expect a clone to perform. + +### Decision 6 — Mixed-spec rationale (ADR-032) + +**Decision**: this spec is `kind: mixed` per ADR-032 because it +touches **both** declarative JSON (the `ApplicationTemplate` schema +declaration + the four seed manifest fixtures) and code (the +`createFromTemplate` controller method + the seed step + the gallery +SFC). ADR-032 normally rejects `kind: mixed`, but admits a thin-glue +exception when the code change is ≤20 LOC across ≤2 files and is +tightly coupled to the config. + +The code surface this spec ships: + +- **File 1: `lib/Controller/ApplicationsController.php`** — adds one + new method `createFromTemplate(string $templateSlug, array $body): + JSONResponse`. ~30 LOC. Reads one OR object, writes one or several + OR objects with slug-prefix rewrites. Carries SPDX + EUPL-1.2 + docblock per memory rule. `#[NoAdminRequired]` so the + route-auth gate passes. +- **File 2: `lib/Repair/SeedApplicationTemplates.php`** — new repair + step modelled on `SeedHelloWorld.php`. ~80 LOC including the + per-slug idempotency guard, the manifest-validation precheck, and + the per-org seeding loop. This is config-shaped data loading + (loading four JSON files into OR), not application logic; it fits + the spirit of the ADR-032 exception. +- **File 3: `src/views/TemplateGallery.vue`** — gallery SFC, + Options-API + `createObjectStore` per the memory rule (no custom + Pinia stores layered over OR). ~120 LOC; mostly template + simple + computed filtering. This is config-shaped UI (data presentation) — + again, fits the spirit of the exception. + +If, during apply, the controller method exceeds ~50 LOC or the SFC +exceeds ~200 LOC and grows real business logic, this spec MUST be +split into a chain — `openbuilt-template-schema` (config only) + +`openbuilt-template-clone` (code only). The thin-glue threshold is a +review gate, not a deferral; the apply agent should call it out +loudly if breached. + +**Foundational ADRs honoured** + +- **ADR-001** (every schema-introducing change ships seed data) — the + four templates plus their companion schemas are the seed payload. +- **ADR-016** (single registration path for routes) — the clone route + is declared in `appinfo/routes.php`, not via attribute-only + registration. +- **ADR-022** (consume OR, do not wrap it) — templates are OR + objects; the gallery reads OR REST directly; no + `TemplateService` PHP class. +- **ADR-024** (canonical app-manifest schema) — every seeded manifest + validates against the pinned schema. +- **ADR-031** (declarative-first business logic) — templates are + data; the seed step is canonical config loading; the clone action + is unavoidably a controller method but is the **minimum** code + needed to express "read OR object A, write OR object B with field + rewrites". +- **ADR-032** (thin-glue mixed exception) — see this decision. + +**Anti-patterns explicitly avoided** + +- No `TemplateCatalogueService` / `TemplateCloneService` / + `TemplateStateMachine` PHP class. Anything that looks like one is + an ADR-031 review-block on the apply PR. +- No custom Pinia store that wraps OR's REST. The gallery uses + `createObjectStore` per the project-wide memory rule. + +### Decision 7 — Declarative-vs-imperative decision (ADR-031) + +| Candidate behaviour | Path | +|---|---| +| Template catalogue persistence (list, read, write) | **Declarative** — OR's stock REST against the `ApplicationTemplate` schema. No `TemplateService`. | +| Template seeding | **Declarative-shaped** — canonical ADR-001 repair step (loads JSON fixtures, calls `ConfigurationService::importFromApp()` per the memory rule, idempotent on per-slug guard). No state machine. | +| Manifest validation on seed | **Declarative** — relies on the canonical app-manifest schema pinned in `package.json` (ADR-024). No bespoke validator. | +| Template lifecycle (publish/archive) | **N/A — explicitly absent**. Templates do not have a draft/published/archived state machine in this spec. They are either present (`isSeeded:true` for curated, freshly-created for org-local in a follow-up) or removed. | +| Clone action | **Unavoidably imperative** — read one record, write several with field rewrites. ~30 LOC controller method. Documented in Decision 6 as the ADR-032 thin-glue exception. | +| Gallery rendering | **Declarative-shaped** — Vue SFC reading OR REST, no app-local store. | + +**Anti-pattern explicitly avoided**. This spec ships no +`TemplateService.list()`, `TemplateService.clone()`, or +`TemplateCatalogue.refresh()` class. The clone action lives on the +controller as one method; that's it. + +## Risks / Trade-offs + +- **Risk — Schema-clone permission interaction with chain spec #7 + (`openbuilt-rbac`)**. When per-built-app RBAC lands (#7), cloning + a template needs to grant the calling user ownership of the new + Application + the cloned companion schemas. **Mitigation**: the + `createFromTemplate` controller method SHALL set the calling user + as the owner of the new Application (via OR's standard ownership + metadata) and add the user's group to the permissions of the cloned + companion schemas. Tested via Newman in `tests/api/openbuilt-templates.postman_collection.json`. + If chain #7 changes the permissions vocabulary after this spec + lands, the clone method needs a one-line update. + +- **Risk — Slug-rewrite drift between manifest references and cloned + schema names**. The controller method must rewrite **every** page- + config `schema` reference in the cloned manifest, not just top-level + ones. **Mitigation**: a small recursive walker in the controller + method, exercised by a PHPUnit test that asserts every cloned + manifest's page `config.schema` matches an existing cloned schema + slug. If the manifest grows new schema-reference shapes in + v1.5.x+, the walker must be extended; document this dependency in + the apply tasks. + +- **Risk — Repair step is slow if many orgs are present**. The seed + step iterates orgs × four templates × per-template schemas. On an + instance with 100 orgs that's 100 × ~5 = 500 OR-writes per repair + run. **Mitigation**: the idempotency guard (per-slug existence + check) short-circuits after the first run, so the steady state is + one OR-read per (org, template) which is fine. Document the + first-run cost in the migration plan. + +- **Risk — Template manifest drift from the canonical schema**. The + seeded manifests reference page types from v1.4.x. If the canonical + schema bumps to v1.5.x and changes a page-type shape, the seeded + templates break on validation. **Mitigation**: the seed step runs + `validateManifest` and fails loudly. The repair step also + surfaces a banner ("seeded template X is on schema vA.B.C; current + canonical is vX.Y.Z — repair?") per the OQ-3 pattern from chain #1's + `hello-world` seed. + +- **Trade-off — Four seeded templates is a curated subset, not + exhaustive**. US-5 (client intake with BRP prefill) is the obvious + missing fifth. Acceptable for v1 because US-5 needs OpenConnector + binding metadata not yet in the manifest. The proposal explicitly + surfaces this in §"Deferred Work". + +- **Trade-off — Gallery is rendered server-side-blind**. The gallery + fetches the template list via OR REST on mount, so the empty-state + on first paint flashes briefly. **Mitigation**: a skeleton-loader + state in the SFC. Not a hard problem; documented for the apply + agent. + +## Migration Plan + +This is a chain spec that adds one new schema, four seeded templates, +one new controller method, and one new SFC. No existing OR data is +modified. + +1. Land the change on a feature branch from `development`. +2. CI runs PHPUnit + Newman + Playwright. The canonical green-light + signals are: + - Newman asserts the four seeded templates are GET-able from + `/index.php/apps/openregister/api/objects/openbuilt/applicationtemplate`. + - Playwright walks the gallery → clone → page-editor flow and + asserts the cloned Application's first page renders. +3. Merge into `development`. The migration runs on next deploy via + the new repair step; the `ApplicationTemplate` schema appears in + the existing `openbuilt` register, and the four seeded templates + appear per-org. +4. **Rollback** — disable the `openbuilt` app via `occ app:disable + openbuilt`. The seeded `ApplicationTemplate` records remain in OR + (harmless). To fully rollback, delete the four + `isSeeded:true` templates via OR's admin UI per org. The new + schema in the register stays; no other Conduction app reads from + `openbuilt/applicationtemplate` so it is inert. + +## Seed Data + +Per ADR-001, every schema-introducing change ships seed data. This +spec seeds **four** Conduction-curated templates plus their companion +schemas. Each template's manifest blob is stored in a human-readable +JSON file under `lib/Settings/templates/{slug}.json` and is loaded by +`SeedApplicationTemplates.php` at repair time; the repair step +validates each manifest against the canonical schema before writing +it to OR. + +### Template 1 — `permit-tracker` (US-1) + +- `category: government-services` +- `title: openbuilt.templates.permit-tracker.title` (en: "Permit + Tracker", nl: "Vergunningvolger") +- `useCase: openbuilt.templates.permit-tracker.useCase` (en: + "Municipal building-permit workflow", nl: "Werkproces voor + gemeentelijke bouwvergunningen") +- `screenshotUrl: img/templates/permit-tracker.png` +- `sourceUrl: + https://github.com/ConductionNL/concurrentie-analyse/blob/main/app-builder/README.md#user-stories` +- `manifest`: + - `version: 1.0.0`, `dependencies: ["openregister"]` + - Menu items: `Applications` (index), `Submit` (form). + - Pages: `Applications` (`type: index` over + `permit-application`), `ApplicationDetail` (`type: detail`), + `ApplicationForm` (`type: form`), `ApplicationKanban` (`type: + kanban` grouped by `status`). +- `companionSchemas`: + - `permit-application` — `{ applicant: string, address: string, + buildingType: string, status: enum[draft, submitted, under-review, + approved, rejected], submittedAt: datetime, decision: text }`. + +### Template 2 — `stakeholder-consultation` (US-2) + +- `category: citizen-engagement` +- `title: openbuilt.templates.stakeholder-consultation.title` (en: + "Stakeholder Consultation", nl: "Stakeholderconsultatie") +- `useCase: openbuilt.templates.stakeholder-consultation.useCase` (en: + "Gather structured input on policy proposals", nl: "Gestructureerde + inbreng verzamelen op beleidsvoorstellen") +- `screenshotUrl: img/templates/stakeholder-consultation.png` +- `manifest`: + - Menu items: `Consultations` (index), `Submit` (form). + - Pages: `Consultations` (index), `ConsultationDetail` (detail + with embedded comment-thread), `ConsultationForm` (form), + `CommentForm` (form). +- `companionSchemas`: + - `consultation` — `{ title: string, description: text, openFrom: + date, closeAt: date, status: enum[draft, open, closed] }`. + - `consultation-comment` — `{ consultationUuid: UUID, authorName: + string, body: text, createdAt: datetime }`. + +### Template 3 — `employee-onboarding` (US-4) + +- `category: internal-operations` +- `title: openbuilt.templates.employee-onboarding.title` (en: + "Employee Onboarding", nl: "Medewerker-onboarding") +- `useCase: openbuilt.templates.employee-onboarding.useCase` (en: + "Guided onboarding with checklist, documents, and approval", nl: + "Begeleide onboarding met checklist, documenten en goedkeuring") +- `screenshotUrl: img/templates/employee-onboarding.png` +- `manifest`: + - Menu items: `Onboardings` (index), `Tasks` (index). + - Pages: `Onboardings` (index), `OnboardingDetail` (detail), + `OnboardingTaskChecklist` (`type: checklist`). +- `companionSchemas`: + - `onboarding-task` — `{ employeeName: string, startDate: date, + department: string, status: enum[pending, in-progress, done] }`. + - `onboarding-document` — `{ taskUuid: UUID, documentName: string, + uploadedFile: string, approved: boolean }`. + +### Template 4 — `incident-reporter` (US-3) + +- `category: field-work` +- `title: openbuilt.templates.incident-reporter.title` (en: "Incident + Reporter", nl: "Incidentmelder") +- `useCase: openbuilt.templates.incident-reporter.useCase` (en: + "Field incident intake for safety regions", nl: "Veldmeldingen voor + veiligheidsregio's") +- `screenshotUrl: img/templates/incident-reporter.png` +- `manifest`: + - Menu items: `Incidents` (index), `Report` (form). + - Pages: `Incidents` (index), `IncidentDetail` (detail), + `IncidentForm` (form, mobile-friendly). +- `companionSchemas`: + - `incident` — `{ reportedBy: string, location: string, + incidentType: string, severity: enum[low, medium, high, critical], + description: text, reportedAt: datetime, status: enum[new, + triaged, resolved] }`. + +The apply agent SHALL generate the four +`lib/Settings/templates/{slug}.json` fixture files and the +`SeedApplicationTemplates.php` repair step from this section, plus +the four PNG screenshots in `img/templates/`. The repair step SHALL +follow `SeedHelloWorld.php`'s idempotency pattern (per-slug existence +check) and SHALL run `validateManifest` on each fixture before +writing it to OR (REQ-OBTC-009). + +## Open Questions + +- **OQ-1 — Screenshot generation source**. Should the four seeded + screenshots be (a) hand-drawn mockups, (b) actual screenshots of a + rendered seeded template, or (c) AI-generated illustrations? They + block on (b) only if the apply agent has a running OpenBuilt with + the four templates cloned. *Provisional decision*: ship (a) hand- + drawn / simple Figma exports as PNGs for v1. The apply tasks + reference a placeholder image; the design-team follow-up replaces + them with real screenshots once the templates render. + +- **OQ-2 — Cross-org cloning**. Can a user in org A clone a template + visible only in org B? *Provisional decision*: no. The gallery + reads OR REST scoped by the caller's organisation, so templates + outside the caller's org are not listed; the clone endpoint + asserts the source template is in the caller's org and 4xxs + otherwise. Re-confirm during apply. + +- **OQ-3 — Slug-prefix length cap**. The clone slug-prefix pattern + (`{app-slug}-{schema-slug}`) can produce schema slugs > 64 + characters if both are long. OR's schema-slug column appears to + accept this generously, but verify the canonical schema cap during + apply. *Provisional decision*: hard-cap the new Application's slug + at 32 characters in the clone-request validation; that bounds the + joined slug at ~64 characters in practice. + +- **OQ-4 — i18n storage convention for seeded template strings**. + Store the four `title` / `description` / `useCase` strings as + literal English with Dutch in `l10n/nl.json` (matching chain #1's + `openbuilt.helloworld.*` pattern), or store i18n keys directly in + the seeded record? *Provisional decision*: store i18n keys + (`openbuilt.templates.permit-tracker.title`) in the seeded record + and resolve them via Nextcloud's i18n at gallery-render time. This + matches how the seeded manifest's `menu[].label` already works in + chain #1. + +- **OQ-5 — Page-editor fallback shape**. REQ-OBTC-006 says "fall back + to the textarea editor if the page editor view from chain #5 is not + present". How is "not present" detected at runtime — feature flag, + route existence, capability registration? *Provisional decision*: + feature-detect via `router.resolve('/applications/:slug/edit').matched.length` + on the OpenBuilt frontend; if the page-editor route is registered, + redirect there, else fall back to the textarea editor. Re-confirm + with the apply agent of chain #5 when both specs land. diff --git a/openspec/changes/openbuilt-templates-marketplace/proposal.md b/openspec/changes/openbuilt-templates-marketplace/proposal.md new file mode 100644 index 00000000..8a0aace1 --- /dev/null +++ b/openspec/changes/openbuilt-templates-marketplace/proposal.md @@ -0,0 +1,168 @@ +--- +kind: mixed +depends_on: [bootstrap-openbuilt, openbuilt-page-editor, openbuilt-schema-editor] +chain: + - bootstrap-openbuilt + - openbuilt-page-editor + - openbuilt-schema-editor + - openbuilt-templates-marketplace # THIS spec +--- + +## Why + +Spec #1 of the OpenBuilt chain (`bootstrap-openbuilt`) ships a textarea +manifest editor and a single seeded `hello-world` Application. That is +enough for an integrator to prove the plumbing, but for the citizen +developers OpenBuilt actually targets — municipal department heads, +policy advisors, HR managers, social workers — the activation energy is +still too high. The user-stories captured in +`concurrentie-analyse/app-builder/README.md` (US-1 through US-5) describe +people who want to "create a permit tracking app", "build a stakeholder +consultation app", "compose an incident reporting app", "create an +employee onboarding app", "build a client intake app" — every one of +them starts not from a blank manifest but from a recognisable use case. +This spec ships the **template gallery** that turns those user-stories +into one-click starting points. + +Crucially, this is the surface where OpenBuilt earns its market position +against Mendix, OutSystems, Budibase, Appsmith and ToolJet — every +low-code competitor in `app-builder/README.md` ships a starter-template +gallery on day one. OpenBuilt has visual editors (chain #4 + #5) and a +manifest contract (chain #1); the templates marketplace is the missing +"on-ramp" that closes the loop. Spec #8 is the right place in the chain +because the editors must exist first — "Use this template" is only +useful if the citizen developer can then customise the result, and +customisation lives in the page editor (#5) and schema editor (#4). + +The marketplace also exercises a foundational architectural commitment: +templates themselves are OR objects (ADR-022 — consume OR, do not wrap +it), seeded declaratively (ADR-031 — declarative-first), and lifecycle- +scoped per organisation. Conduction-curated templates ship via the same +ADR-001 seed pattern that `SeedHelloWorld.php` already established in +chain spec #1. + +## What Changes + +- **NEW** OR schema `ApplicationTemplate` declared in + `lib/Settings/openbuilt_register.json` — `{ uuid, slug, title, + description, useCase, category, screenshotUrl?, manifest (JSON blob), + companionSchemas (array of JSON-schema blobs), isSeeded, sourceUrl?, + version }`. Includes `x-openregister-lifecycle` only insofar as OR's + standard publish/archive applies — no bespoke state machine + (ADR-031). +- **NEW** Repair step `lib/Repair/SeedApplicationTemplates.php` modelled + on the existing `SeedHelloWorld.php` (chain #1). Idempotent: guarded + by existing-slug per template. Seeds **four** Conduction-curated + templates derived from the user-stories in + `concurrentie-analyse/app-builder/README.md`: + 1. **Permit Tracker** — US-1, municipal building-permit workflow. + 2. **Stakeholder Consultation** — US-2, policy-advisor consultation + with comment threads and document sharing. + 3. **Employee Onboarding** — US-4, HR checklist + document upload + + approval workflow. + 4. **Incident Reporter** — US-3, safety-region field incident + intake. +- **NEW** Frontend view `src/views/TemplateGallery.vue` — gallery + layout showing each template's title, description, use case, + category, and optional screenshot. Filterable by `category` and + `useCase`. +- **NEW** Top-bar / left-nav entry "Templates" added to the OpenBuilt + shell, plus a "Create from template" CTA on the empty Application + list (the empty-state surfaces the gallery instead of the textarea + editor). +- **NEW** PHP controller method + `ApplicationsController::createFromTemplate(string $templateSlug, + array $body): JSONResponse` — the **only** new code in this spec + beyond the seed step. ~30 LOC. Reads the template, deep-copies its + `manifest` and `companionSchemas` into a new Application owned by + the calling user, slug-prefixes cloned schemas with the new + Application's slug to avoid collisions, and returns the new + Application's UUID. CRUD on templates themselves uses OR's REST + directly (ADR-022). +- **NEW** Route in `appinfo/routes.php` — + `POST /api/applications/from-template/{templateSlug}` → + `applications#createFromTemplate` (`#[NoAdminRequired]`). +- **NEW** capability `openbuilt-template-catalogue` — the + `ApplicationTemplate` schema, the four seeded templates, the gallery + view, and the clone-from-template action. + +### Capabilities + +#### New Capabilities + +- `openbuilt-template-catalogue`: The OR-backed template registry, the + Conduction-curated seed (four starter packs derived from + `concurrentie-analyse/app-builder` user-stories), the gallery UI, and + the clone-into-new-Application action. Owns the citizen-developer + on-ramp from "I want a permit-tracker" to "here is an editable + draft Application that already has a permit-tracker manifest plus a + permit schema". + +#### Modified Capabilities + +None. This is purely additive — `openbuilt-application-register` and +`openbuilt-runtime` from chain #1 remain unchanged; the new schema +declaration is appended to `openbuilt_register.json` without altering +existing schemas. The two visual-editor capabilities from chain #4 and +#5 (`openbuilt-page-editor` and `openbuilt-schema-editor`) are +consumed read-only as the post-clone landing surface. + +## Impact + +- **New code** — `lib/Controller/ApplicationsController.php` adds one + method (`createFromTemplate`, ~30 LOC), `lib/Repair/SeedApplicationTemplates.php` + (new file, follows the `SeedHelloWorld.php` pattern from chain #1), + `lib/Settings/openbuilt_register.json` gets a new + `ApplicationTemplate` schema entry plus four seed-data references, + `appinfo/routes.php` gets one new route, `src/views/TemplateGallery.vue` + is the only new SFC, and the existing left-nav of the OpenBuilt shell + gets one new entry. Four template-manifest JSON fixtures live under + `lib/Settings/templates/{slug}.json` so they can be human-reviewed + diff-by-diff (rather than embedded as encoded strings in the repair + step). Screenshot PNGs for the four seeded templates ship in + `img/templates/{slug}.png` and are referenced via the standard + Nextcloud static-asset path. +- **External dependency** — `@conduction/nextcloud-vue` for the + gallery's `CnAppRoot` chrome; no new library dependency. The + template manifests reference the canonical app-manifest schema + pinned in `package.json` (ADR-024). +- **OpenRegister** — adds one new schema (`ApplicationTemplate`) to + the existing `openbuilt` register namespace established by chain #1. + No new register namespace. Multi-tenancy via the existing + `organisation` field; templates are scoped per-organisation with an + `isSeeded:true` flag for the Conduction-shipped four (so admins can + identify the curated ones vs. org-local ones added in a follow-up + spec). +- **No breaking changes** — the spec is additive on top of chain #1. + No existing OR objects change shape. +- **Foundational ADRs honoured** — ADR-001 (every schema-introducing + change ships seed data — four templates plus their companion + schemas), ADR-016 (route in `routes.php`), ADR-022 (consume OR + abstractions — templates are OR objects, no wrapper service), + ADR-024 (template manifests validate against the canonical schema), + ADR-031 (templates are declarative; the seed step is canonical), + ADR-032 (kind: mixed with thin-glue exception — see `design.md` for + the LOC accounting). + +## Deferred Work + +Tracked for follow-up specs / issues rather than absorbed into this +change: + +- **Community-submitted templates** — v1 ships read-only, + Conduction-curated only. The schema already carries `isSeeded` so + a follow-up spec can introduce org-local user-submitted templates + without a migration. +- **Publish-existing-app-as-template** — the inverse flow ("turn my + permit-tracker Application into a template my org can reuse") is + out of scope; deferred to a follow-up issue + (`#openbuilt-template-publishing`). +- **Template versioning** — re-cloning an updated template against an + existing instance is explicitly not supported (one-shot snapshot + semantics). A future versioning spec can address propagating + upstream improvements; for now, `ApplicationTemplate.version` is + recorded on clone for traceability. +- **Files-API screenshot uploads** — screenshots in v1 ship in + `img/templates/` committed to the repo. A follow-up spec can move + these to OR Files for community-submitted templates so users do not + need repo write access to add a screenshot. diff --git a/openspec/changes/openbuilt-templates-marketplace/specs/openbuilt-template-catalogue/spec.md b/openspec/changes/openbuilt-templates-marketplace/specs/openbuilt-template-catalogue/spec.md new file mode 100644 index 00000000..898aa00c --- /dev/null +++ b/openspec/changes/openbuilt-templates-marketplace/specs/openbuilt-template-catalogue/spec.md @@ -0,0 +1,315 @@ +## ADDED Requirements + +### Requirement: REQ-OBTC-001 ApplicationTemplate schema declares the template record contract + +The system SHALL declare an `ApplicationTemplate` schema in +`lib/Settings/openbuilt_register.json` under the existing `openbuilt` +register namespace established by chain spec #1. The schema SHALL +declare the following properties: + +- `uuid` (string, UUID-format, required) +- `slug` (string, kebab-case pattern, required, unique per + organisation) +- `title` (string, required) — human-readable title shown in the + gallery +- `description` (string, required) — one-paragraph plain-text summary +- `useCase` (string, required) — short one-line "what it solves" + label (e.g. "Municipal building-permit workflow") +- `category` (string enum, required) — initial values + `government-services`, `internal-operations`, `citizen-engagement`, + `field-work`. Additional values added in follow-up specs. +- `screenshotUrl` (string, URI, optional) — relative path under the + app's `img/templates/` or an OR Files URL +- `manifest` (object, required) — full app-manifest JSON blob, + validates against the canonical schema referenced from the same + register +- `companionSchemas` (array of objects, optional) — JSON-schema + blobs to be cloned alongside the manifest into the user's namespace +- `isSeeded` (boolean, required, default `false`) — `true` for the + four Conduction-shipped templates, `false` for org-local templates + added in a follow-up spec +- `sourceUrl` (string, URI, optional) — link back to the originating + user-story / RFP / blog post for traceability +- `version` (string, semver pattern, required) — template version + recorded on clone for one-shot snapshot semantics (see REQ-OBTC-007) + +The schema SHALL validate against OpenAPI 3.0.0 and SHALL be scoped +per organisation via OR's standard `organisation` field. No bespoke +`x-openregister-lifecycle` block beyond OR's defaults — templates do +not have a draft/published/archived state machine of their own; they +are either present or removed. + +#### Scenario: Schema validation rejects a template with no manifest + +- **WHEN** an API client posts an `ApplicationTemplate` with `title` + and `description` but no `manifest` field +- **THEN** OR returns a 4xx schema-validation error citing the + missing `manifest` field +- **AND** no template is created + +#### Scenario: Slug uniqueness is enforced per organisation + +- **WHEN** two `ApplicationTemplate` records are submitted with the + same `slug` in the same organisation +- **THEN** the second request is rejected with a 4xx error +- **AND** the first template remains intact + +### Requirement: REQ-OBTC-002 Four Conduction-curated templates seeded via repair step + +The system SHALL seed at minimum four Conduction-curated +`ApplicationTemplate` records on install via +`lib/Repair/SeedApplicationTemplates.php`, following the canonical +ADR-001 seed pattern established by `SeedHelloWorld.php` in chain spec +#1. The four seeded templates SHALL be: + +1. **`permit-tracker`** (category `government-services`, derived from + user-story US-1 in `concurrentie-analyse/app-builder/README.md`) — + municipal building-permit workflow with index, detail, form, and + kanban pages over a `permit-application` companion schema. +2. **`stakeholder-consultation`** (category `citizen-engagement`, + US-2) — policy-advisor consultation with index, detail, form, and + comment-thread pages over `consultation` and `consultation-comment` + companion schemas. +3. **`employee-onboarding`** (category `internal-operations`, US-4) — + HR checklist with index, detail, form, and checklist pages over + `onboarding-task` and `onboarding-document` companion schemas. +4. **`incident-reporter`** (category `field-work`, US-3) — safety- + region field incident intake with index, detail, and form pages + over an `incident` companion schema. + +Each seeded template SHALL have `isSeeded: true` and a `sourceUrl` +pointing to the relevant user-story section of +`concurrentie-analyse/app-builder/README.md`. + +The seed repair step SHALL be idempotent: re-running on an +already-seeded install SHALL produce no duplicates and SHALL be +guarded by per-template `slug` existence checks (matching the +`SeedHelloWorld.php` guard pattern). + +#### Scenario: Fresh install seeds four templates + +- **WHEN** the OpenBuilt app is installed on a fresh Nextcloud +- **THEN** four `ApplicationTemplate` records exist in the + `openbuilt` register with `isSeeded: true` +- **AND** their slugs are `permit-tracker`, `stakeholder-consultation`, + `employee-onboarding`, and `incident-reporter` + +#### Scenario: Repair step re-run is idempotent + +- **WHEN** the `SeedApplicationTemplates` repair step runs a second + time on an already-seeded install +- **THEN** no duplicate templates are created +- **AND** no existing template data is overwritten + +### Requirement: REQ-OBTC-003 Gallery view lists templates with filter and detail + +The OpenBuilt frontend SHALL register a Vue route `/templates` whose +view (`src/views/TemplateGallery.vue`) lists every +`ApplicationTemplate` visible to the caller via OR REST. The gallery +SHALL: + +- Show each template's `title`, `useCase`, `description`, + `category`, and `screenshotUrl` if present +- Provide filter controls for `category` and a free-text search over + `title` + `useCase` + `description` +- Surface a "Use this template" action per card +- Be reachable from a top-level OpenBuilt left-nav entry and from a + "Create from template" CTA on the empty-state of the Application + list + +The gallery SHALL render using `@conduction/nextcloud-vue`'s standard +`CnAppRoot` chrome (no bespoke layout system) and SHALL use Nextcloud +CSS variables only (per ADR-010 — no hardcoded colours). + +#### Scenario: Filtering by category narrows the gallery + +- **WHEN** a user opens `/index.php/apps/openbuilt/templates` and + selects the `government-services` category filter +- **THEN** the gallery shows only the `permit-tracker` template +- **AND** the three other seeded templates are hidden from view + +#### Scenario: Empty Application list surfaces the gallery CTA + +- **WHEN** a user with no Applications navigates to the OpenBuilt + shell home +- **THEN** the empty-state of the Application list shows a "Create + from template" CTA +- **AND** clicking the CTA navigates to `/templates` + +### Requirement: REQ-OBTC-004 "Use this template" clones into a new Application + +The system SHALL expose `POST +/index.php/apps/openbuilt/api/applications/from-template/{templateSlug}` +backed by `ApplicationsController::createFromTemplate`. The endpoint +SHALL accept a JSON body with at least `{ name: string, slug: string +}` for the new Application. On success, it SHALL: + +1. Read the `ApplicationTemplate` identified by `{templateSlug}` from + OR via the standard ObjectService. +2. Deep-copy the template's `manifest` blob into a new `Application` + record with the user-supplied `name` and `slug`, `status: draft`, + `version: 0.1.0`, owned by the calling user, scoped to the + calling user's organisation. +3. For each entry in the template's `companionSchemas` array, clone + the JSON-schema blob into the user's namespace with its schema + slug prefixed by the new Application's slug (see REQ-OBTC-005). +4. Record the template's `slug` + `version` on the new Application + under a `templateOrigin` metadata field for traceability. +5. Return a 201 response with the new Application's UUID and slug. + +The route SHALL be registered in `appinfo/routes.php` (ADR-016) with +`#[NoAdminRequired]`. The controller method is the only new +PHP code surface in this spec beyond the seed step (≤30 LOC). No +state-machine or "template service" class is introduced (ADR-031). + +#### Scenario: Clone produces a draft Application with the template manifest + +- **WHEN** an authenticated user POSTs `{ name: "My permits", slug: + "my-permits" }` to + `/index.php/apps/openbuilt/api/applications/from-template/permit-tracker` +- **THEN** the response is 201 with the new Application's UUID +- **AND** a new `Application` record exists in OR with `slug: + my-permits`, `status: draft`, and a `manifest` equal to the + `permit-tracker` template's manifest +- **AND** the new Application's `templateOrigin.slug` is + `permit-tracker` and `templateOrigin.version` matches the + template's recorded version + +#### Scenario: Clone with an existing slug is rejected + +- **WHEN** an authenticated user POSTs a clone with a `slug` that + matches an existing Application in their organisation +- **THEN** the response is 4xx with a JSON error body citing the slug + collision +- **AND** no Application is created +- **AND** no companion schemas are cloned + +### Requirement: REQ-OBTC-005 Cloned companion schemas are namespaced by Application slug + +When a template clone runs, the system SHALL prefix every cloned +companion-schema `slug` with the new Application's slug joined by a +hyphen. For example, cloning `permit-tracker` (which carries a +`permit-application` companion schema) into a new Application with +`slug: my-permits` SHALL produce a cloned schema with `slug: +my-permits-permit-application`. The cloned manifest's page `config` +references to that schema SHALL be rewritten to the prefixed slug so +that the new Application loads correctly without manual edits. + +This avoids slug collisions when multiple Applications are cloned from +the same template into the same organisation, and keeps the original +template's companion schemas untouched. + +#### Scenario: Two clones of the same template coexist + +- **WHEN** an authenticated user clones `permit-tracker` into + `slug: my-permits` and then again into `slug: vggm-permits` +- **THEN** OR contains two distinct schemas with slugs + `my-permits-permit-application` and + `vggm-permits-permit-application` +- **AND** each Application's manifest references its own prefixed + schema slug + +### Requirement: REQ-OBTC-006 Clone redirects to the page editor for customisation + +After a successful template clone, the frontend SHALL redirect the +user to the page editor view (from chain spec #5, +`openbuilt-page-editor`) for the new Application, opened on the +manifest's first page. The redirect SHALL preserve the new +Application's slug in the URL so the editor loads against the right +record. + +If the page editor view from chain #5 is not yet present in the +running build (because chain #5 has not yet landed in the same +deployment), the frontend SHALL fall back to the textarea editor +shipped in chain spec #1 (REQ-OBR-005) without breaking the clone +flow. + +#### Scenario: Clone redirects into the page editor + +- **WHEN** a user successfully clones `permit-tracker` from the + gallery +- **THEN** the browser navigates to the page editor route for the new + Application +- **AND** the editor surface shows the cloned manifest's first page + +### Requirement: REQ-OBTC-007 Template clones are one-shot snapshots + +A cloned Application SHALL be a fully independent record from the +source template. The system SHALL NOT propagate later changes to a +template back into Applications previously cloned from it. The +template's `version` SHALL be recorded on the new Application under +`templateOrigin.version` for traceability, but no auto-update or +"upgrade-from-template" flow is supported in this spec. + +This decision is documented in `design.md` (Decision 5 — Versioning) +and is explicitly deferred to a future versioning spec. + +#### Scenario: Updating a template does not change existing clones + +- **GIVEN** a user has cloned the `permit-tracker` template (version + 1.0.0) into a new Application +- **WHEN** an admin updates the `permit-tracker` template's manifest + in place (or the seed step re-runs against a new repo version) +- **THEN** the user's previously cloned Application's manifest is + unchanged +- **AND** the user's `templateOrigin.version` still reads `1.0.0` + +### Requirement: REQ-OBTC-008 Conduction-curated templates are read-only via UI + +The system SHALL present Conduction-shipped templates (records with +`isSeeded: true`) as read-only in the gallery and SHALL NOT expose UI +controls to edit or delete `isSeeded: true` records via the OpenBuilt +frontend in this spec. Backend deletion via OR REST remains +governed by OR's standard RBAC; this requirement only constrains the +UI surface to prevent accidental damage to the curated catalogue +during the integrator workflow. + +Org-local user-submitted templates (an explicit non-goal of this spec; +deferred to a follow-up) will be `isSeeded: false` and editable; that +flow lives in a separate change. + +#### Scenario: Gallery hides edit controls on a seeded template + +- **WHEN** a user views the `permit-tracker` template card in the + gallery +- **THEN** no "Edit template" or "Delete template" control is + rendered +- **AND** only the "Use this template" action is shown + +### Requirement: REQ-OBTC-009 Template manifests validate against the canonical app-manifest schema + +Every seeded template's `manifest` blob SHALL validate against the +canonical `app-manifest.schema.json` pinned in `package.json` +(ADR-024). The repair step SHALL run `validateManifest` against each +seeded manifest before persisting it; a validation failure SHALL fail +the repair step loudly (rather than seeding a broken template). +Cloned manifests (REQ-OBTC-004) inherit this guarantee transitively +because they are byte-for-byte copies modulo the schema-slug rewrite +in REQ-OBTC-005. + +#### Scenario: A broken seeded manifest fails install + +- **WHEN** a developer modifies the `permit-tracker` seed manifest to + reference an unknown `type: xyz` page type +- **AND** the repair step runs on `occ maintenance:repair` +- **THEN** the repair step exits non-zero with a validation error + citing the offending page type +- **AND** no `permit-tracker` template is seeded + +### Requirement: REQ-OBTC-010 i18n keys for gallery and seeded templates + +The system SHALL ensure every user-visible string in the gallery view +(gallery section title, filter labels, category labels, "Use this +template" button label, empty-state copy) uses i18n keys under the +`openbuilt.templates.*` namespace. Every seeded template's `title`, +`description`, `useCase`, and `category` SHALL be stored either as +i18n keys (preferred) or as English strings with Dutch translations +shipped in `l10n/nl.json` so the gallery is bilingual on install +(per the project-wide nl/en minimum). + +#### Scenario: Dutch user sees Dutch gallery copy + +- **WHEN** an authenticated Dutch-locale user opens + `/index.php/apps/openbuilt/templates` +- **THEN** the page title, filter labels, and the four seeded + template descriptions render in Dutch diff --git a/openspec/changes/openbuilt-templates-marketplace/tasks.md b/openspec/changes/openbuilt-templates-marketplace/tasks.md new file mode 100644 index 00000000..86aa595f --- /dev/null +++ b/openspec/changes/openbuilt-templates-marketplace/tasks.md @@ -0,0 +1,88 @@ +## 1. Implementation Tasks — openbuilt-template-catalogue + +- [ ] 1.1 **Declare `ApplicationTemplate` schema in `lib/Settings/openbuilt_register.json`** + - spec_ref: REQ-OBTC-001 + - files: `lib/Settings/openbuilt_register.json` + - acceptance_criteria: Schema declares `uuid`, `slug` (kebab-case pattern, unique per organisation), `title` (required), `description` (required), `useCase` (required), `category` (enum government-services|internal-operations|citizen-engagement|field-work, required), `screenshotUrl` (URI, optional), `manifest` (object, required, references the canonical app-manifest schema), `companionSchemas` (array of objects, optional), `isSeeded` (boolean, required, default false), `sourceUrl` (URI, optional), `version` (semver pattern, required). Validates against OpenAPI 3.0.0. Multi-tenant scoping inherited from OR's `organisation` field — no app-local RBAC introduced (ADR-022). + - Implement: declarative — append a new entry to the existing `openbuilt_register.json`. No PHP service class. + - Test: integration test creates an `ApplicationTemplate` via OR REST; assert schema-validation rejects a record missing `manifest`; assert slug-uniqueness rejects a duplicate slug in the same organisation. + +- [ ] 1.2 **Add the new route to `appinfo/routes.php`** (ADR-016) + - spec_ref: REQ-OBTC-004 + - files: `appinfo/routes.php` + - acceptance_criteria: Route `POST /api/applications/from-template/{templateSlug}` maps to `applications#createFromTemplate` with `#[NoAdminRequired]`. Sole registration path is `routes.php` (no attribute-only registration). + - Implement: ~3 LOC route declaration appended to the existing routes array. + - Test: Newman GET / OPTIONS / POST captures verify the route resolves with the correct verb. + +- [ ] 1.3 **Add `ApplicationsController::createFromTemplate`** (ADR-032 thin-glue exception per design.md Decision 6) + - spec_ref: REQ-OBTC-004, REQ-OBTC-005 + - files: `lib/Controller/ApplicationsController.php` + - acceptance_criteria: `createFromTemplate(string $templateSlug, array $body): JSONResponse` looks up the template via OR ObjectService scoped to the caller's organisation, deep-copies its `manifest` + each entry of `companionSchemas` into new OR records (companion-schema slugs prefixed with the new Application's slug per REQ-OBTC-005, manifest page-config `schema` references rewritten by a small recursive walker), records `templateOrigin.slug` + `templateOrigin.version` on the new Application, returns 201 with `{ uuid, slug }`. Slug-collision on the new Application returns 4xx without writing. Cross-org clone returns 4xx (OQ-2). ≤30 LOC; carries SPDX + EUPL-1.2 docblock in the file-level docblock (memory rule). `#[NoAdminRequired]` attribute set. + - Implement: single method on the existing controller; no new service class (ADR-031). + - Test: PHPUnit covers the success path (201 + Application + companion schemas exist with prefixed slugs), the slug-collision path (4xx, no writes), the cross-org path (4xx), and the schema-reference rewrite path (every cloned manifest page-config `schema` matches a cloned schema slug). + +- [ ] 1.4 **Build `TemplateGallery.vue`** + - spec_ref: REQ-OBTC-003, REQ-OBTC-006, REQ-OBTC-008 + - files: `src/views/TemplateGallery.vue`, `src/router/index.js` (add the `/templates` route) + - acceptance_criteria: Renders a grid/list of `ApplicationTemplate` records fetched via OR REST scoped to the caller's organisation, with filter controls for `category` and a free-text search over `title` + `useCase` + `description`. Each card shows `title`, `useCase`, `description`, `category`, `screenshotUrl` if present, and a "Use this template" action. Seeded records (`isSeeded:true`) show only the "Use this template" action (no edit/delete UI per REQ-OBTC-008). Uses Options API + `createObjectStore` (memory rule — no custom Pinia store). Uses Nextcloud CSS variables only; no hardcoded colours (ADR-010). On "Use this template", prompts for `name` + `slug`, POSTs to `/api/applications/from-template/{slug}`, and on success redirects to the page editor (chain #5) via the feature-detect path in OQ-5; falls back to the textarea editor (REQ-OBR-005 from chain #1) if the page-editor route is not registered. + - Implement: SFC ~120 LOC, mostly template + simple computed filtering. + - Test: Playwright opens the gallery, asserts the four seeded templates render, applies the `government-services` filter and asserts only `permit-tracker` is visible, clicks "Use this template" on `permit-tracker`, fills in `name: "My permits" / slug: "my-permits"`, asserts the post-clone redirect lands on the editor surface, and asserts the seeded record renders no Edit/Delete controls. + +- [ ] 1.5 **Add the "Templates" entry to the OpenBuilt left-nav** and the empty-state CTA on the Application list + - spec_ref: REQ-OBTC-003 + - files: `src/views/BuilderShell.vue` (left-nav extension), `src/views/ApplicationList.vue` (empty-state CTA — file may already exist from chain #1) + - acceptance_criteria: A new left-nav entry `openbuilt.templates.menu.label` (i18n) routes to `/templates`. The empty-state of the Application list renders a "Create from template" CTA that routes to `/templates`. Nextcloud CSS variables only. + - Implement: small template-only edits to the existing shell files; ~10 LOC each. + - Test: Playwright asserts the left-nav entry is visible and clickable, and the empty-state CTA appears when no Applications exist in the caller's org. + +## 2. Seed Data (ADR-001) + +- [ ] 2.1 **Author the four template manifest fixtures** under `lib/Settings/templates/` + - spec_ref: REQ-OBTC-002, REQ-OBTC-009 + - files: `lib/Settings/templates/permit-tracker.json`, `lib/Settings/templates/stakeholder-consultation.json`, `lib/Settings/templates/employee-onboarding.json`, `lib/Settings/templates/incident-reporter.json` + - acceptance_criteria: Each fixture is a full `ApplicationTemplate` JSON record (matching the schema declared in 1.1) including its `manifest` blob and its `companionSchemas` array as described in design.md "Seed Data". Each `manifest` validates against the canonical app-manifest schema pinned in `package.json` (ADR-024). All user-visible strings (`title`, `description`, `useCase`, manifest `menu[].label`, manifest page `title`) use i18n keys under `openbuilt.templates.{slug}.*` (OQ-4). + - Implement: hand-authored JSON; no scripting / sed / awk / python to generate. + - Test: `npm run check:manifest` over each fixture; integration test asserts every fixture validates without warnings. + +- [ ] 2.2 **Ship the seed repair step `lib/Repair/SeedApplicationTemplates.php`** + - spec_ref: REQ-OBTC-002, REQ-OBTC-009 + - files: `lib/Repair/SeedApplicationTemplates.php`, `appinfo/info.xml` (add `SeedApplicationTemplates` as a `` repair step after the existing `SeedHelloWorld`) + - acceptance_criteria: For each organisation in the system and each of the four fixtures, the step (a) loads the fixture JSON, (b) calls `validateManifest` on the embedded `manifest`, (c) checks for an existing `ApplicationTemplate` with that `slug` in that organisation, (d) if absent, calls `ConfigurationService::importFromApp()` (memory rule) to persist the record into OR with `isSeeded:true`. Idempotent: re-running the step on a seeded install is a no-op. Validation failure on any fixture fails the repair step loudly (exit non-zero) per REQ-OBTC-009. Modelled on `SeedHelloWorld.php`'s structure. + - Implement: PHP repair step; no scripting to generate or modify code. + - Test: PHPUnit runs the repair step twice, asserts exactly four `ApplicationTemplate` records exist per organisation after each run; corrupt one fixture's manifest and assert the repair step exits non-zero with the offending page-type cited. + +- [ ] 2.3 **Add the four template screenshots** to `img/templates/` + - spec_ref: REQ-OBTC-001, OQ-1 + - files: `img/templates/permit-tracker.png`, `img/templates/stakeholder-consultation.png`, `img/templates/employee-onboarding.png`, `img/templates/incident-reporter.png` + - acceptance_criteria: Each PNG ≤ 200 KB, reasonable aspect ratio (~16:10) suitable for a gallery card. Placeholder simple mockups acceptable for v1 per OQ-1; design-team follow-up replaces them. + - Implement: binary asset commit. No scripting. + - Test: Playwright asserts each gallery card renders its screenshot (image element loads without 404). + +## 3. Verification + +- [ ] 3.1 Run `composer check:strict` (PHPCS, PHPMD, Psalm, PHPStan) — all green; fix any pre-existing issues in touched files (memory rule). +- [ ] 3.2 Run `npm run lint` / ESLint flat config — clean on the new SFC. +- [ ] 3.3 Run `npm run check:manifest` (ADR-024) on the four seeded fixtures — all four validate against the canonical schema pinned in `package.json`. +- [ ] 3.4 Visually verify on a fresh `docker compose up` that `/index.php/apps/openbuilt/templates` renders the four seeded templates, that "Use this template" on `permit-tracker` lands on a draft Application in the editor surface, and that the cloned companion schema is namespaced as `{new-app-slug}-permit-application`. +- [ ] 3.5 Confirm no `TemplateCatalogueService.php` / `TemplateService.php` / `TemplateCloneService.php` exists under `lib/Service/` — ADR-031 review gate. +- [ ] 3.6 Confirm the `createFromTemplate` method is ≤30 LOC and the seed step ≤80 LOC — ADR-032 thin-glue threshold (design.md Decision 6). If exceeded, this spec needs to be split per the design rule; raise it loudly during apply. + +## 4. Tests (ADR-008) + +- [ ] 4.1 **PHPUnit** — `tests/Unit/Controller/ApplicationsControllerTest.php` extended to cover `createFromTemplate` (success → 201 + cloned objects exist with prefixed schemas; slug-collision → 4xx, no writes; cross-org → 4xx, no writes; manifest schema-reference rewrite walker covers nested page-config `schema` fields). +- [ ] 4.2 **PHPUnit** — `tests/Integration/TemplateSeedTest.php` runs `SeedApplicationTemplates` twice, asserts four templates per organisation each time; corrupts a fixture and asserts loud failure (REQ-OBTC-009). +- [ ] 4.3 **Newman** — `tests/api/openbuilt-templates.postman_collection.json` covers `POST /api/applications/from-template/{slug}` (201 + 4xx slug-collision + 4xx cross-org) plus standard OR-REST CRUD on `ApplicationTemplate` (200 list, 404 unknown). +- [ ] 4.4 **Playwright** — `tests/e2e/template-gallery.spec.ts` opens the OpenBuilt shell, navigates to `/templates`, asserts four template cards render, filters to `government-services`, asserts only `permit-tracker` is visible, clicks "Use this template", completes the slug prompt, asserts the post-clone redirect, and asserts the cloned Application's first page renders with the cloned companion schema's data. + +## 5. Documentation (ADR-009, ADR-010) + +- [ ] 5.1 Add `docs/openbuilt-templates.md` describing the gallery, the clone semantics, the slug-prefix convention for cloned companion schemas (Decision 3), and the one-shot snapshot behaviour (Decision 5 / REQ-OBTC-007). +- [ ] 5.2 Update `docs/integrator-guide.md` with a "Cloning from a template" walkthrough that links to the gallery and explains where the cloned schemas live. +- [ ] 5.3 NL Design (ADR-010) — confirm the gallery uses Nextcloud CSS variables only; document any new variables added. +- [ ] 5.4 Update `openspec/app-config.json` to list `openbuilt-template-catalogue` under capabilities. + +## 6. i18n (ADR-005, ADR-007) + +- [ ] 6.1 Add English translations in `l10n/en.json` for `openbuilt.templates.menu.label`, `openbuilt.templates.gallery.title`, `openbuilt.templates.filter.category`, `openbuilt.templates.filter.search`, `openbuilt.templates.action.use`, `openbuilt.templates.action.useThis`, `openbuilt.templates.empty.cta`, the four template `title` / `description` / `useCase` keys (`openbuilt.templates.{slug}.title|description|useCase`), every manifest `menu[].label`, and every manifest page `title` in the four seeded manifests. +- [ ] 6.2 Add Dutch translations for the same keys in `l10n/nl.json` so the gallery and the seeded templates are bilingual on install (per the project-wide nl/en minimum). +- [ ] 6.3 Confirm the four seeded manifests use translation keys for every user-visible string (per ADR-024 §6). diff --git a/phpmd.xml b/phpmd.xml index feaf8e62..11f7d5bc 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -27,24 +27,40 @@ - + + + + + + - + diff --git a/psalm.xml b/psalm.xml index a90910cd..a8946bc9 100644 --- a/psalm.xml +++ b/psalm.xml @@ -75,6 +75,7 @@ + diff --git a/src/customComponents.js b/src/customComponents.js index 3d0e32ee..65a37f7e 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -23,6 +23,7 @@ import ApplicationsView from './views/ApplicationEditor.vue' import SchemaDesignerView from './views/SchemaDesigner.vue' import ExportJobsView from './views/ExportJobsList.vue' import BuilderHostView from './views/BuilderHost.vue' +import TemplateGalleryView from './views/TemplateGallery.vue' export default { // Starter dashboard — sample KPIs / activity placeholders. @@ -39,4 +40,7 @@ export default { // Virtual-app host — mounts a nested CnAppRoot rendering the virtual // app's manifest from GET /api/applications/{slug}/manifest. BuilderHostView, + // Template gallery — browse seeded ApplicationTemplate records and + // clone one into a new virtual app (openbuilt-templates-marketplace). + TemplateGalleryView, } diff --git a/src/manifest.json b/src/manifest.json index 773a9e77..6c14ca46 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -6,6 +6,7 @@ { "id": "Dashboard", "label": "Dashboard", "icon": "icon-category-dashboard", "route": "Dashboard", "order": 10 }, { "id": "VirtualApps", "label": "Virtual apps", "icon": "icon-category-app-bundles", "route": "VirtualApps", "order": 20 }, { "id": "Schemas", "label": "Schemas", "icon": "icon-category-customization", "route": "Schemas", "order": 30 }, + { "id": "Templates", "label": "Templates", "icon": "icon-category-organization", "route": "Templates", "order": 35 }, { "id": "Exports", "label": "Exports", "icon": "icon-download", "route": "Exports", "order": 40 }, { "id": "Documentation", "label": "Documentation", "icon": "icon-info", "href": "https://github.com/ConductionNL/openbuilt", "section": "settings", "order": 90 } ], @@ -13,6 +14,7 @@ { "id": "Dashboard", "route": "/", "type": "custom", "title": "Dashboard", "component": "DashboardView" }, { "id": "VirtualApps", "route": "/applications", "type": "custom", "title": "Virtual apps", "component": "ApplicationsView" }, { "id": "Schemas", "route": "/schemas", "type": "custom", "title": "Schemas", "component": "SchemaDesignerView" }, + { "id": "Templates", "route": "/templates", "type": "custom", "title": "Templates", "component": "TemplateGalleryView" }, { "id": "SchemaDesignerList", "route": "/builder/:slug/schemas", "type": "custom", "title": "Schemas", "component": "SchemaDesignerView" }, { "id": "SchemaDesigner", "route": "/builder/:slug/schemas/:schemaId", "type": "custom", "title": "Schema", "component": "SchemaDesignerView" }, { "id": "Exports", "route": "/exports", "type": "custom", "title": "Exports", "component": "ExportJobsView" }, diff --git a/src/views/TemplateGallery.vue b/src/views/TemplateGallery.vue index 34690a32..f070c2d5 100644 --- a/src/views/TemplateGallery.vue +++ b/src/views/TemplateGallery.vue @@ -181,13 +181,14 @@ export default { if (!slug) { return } - // Feature-detect chain #5 page editor; fall back to the textarea editor. + // Feature-detect chain #5 page editor; fall back to the manifest-driven + // virtual-app manager, then the dashboard. const editorRoute = this.$router.resolve({ name: 'PageEditor', params: { slug } }) if (editorRoute?.resolved?.matched?.length > 0) { this.$router.push(editorRoute.resolved.fullPath) return } - const fallback = this.$router.resolve({ name: 'ApplicationEditor', params: { slug } }) + const fallback = this.$router.resolve({ name: 'VirtualApps', params: { slug } }) if (fallback?.resolved?.matched?.length > 0) { this.$router.push(fallback.resolved.fullPath) return diff --git a/tests/Unit/Controller/CreateFromTemplateTest.php b/tests/Unit/Controller/CreateFromTemplateTest.php new file mode 100644 index 00000000..9b7457eb --- /dev/null +++ b/tests/Unit/Controller/CreateFromTemplateTest.php @@ -0,0 +1,477 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Tests\Unit\Controller; + +use OCA\OpenBuilt\Controller\ApplicationsController; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +/** + * Tests for ApplicationsController::createFromTemplate. + */ +class CreateFromTemplateTest extends TestCase +{ + /** + * Controller under test. + * + * @var ApplicationsController + */ + private ApplicationsController $controller; + + /** + * Mock logger. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Mock OR ObjectService. + * + * @var MockObject + */ + private MockObject $objectService; + + /** + * Mock RegisterMapper. + * + * @var MockObject + */ + private MockObject $registerMapper; + + /** + * Mock SchemaMapper. + * + * @var MockObject + */ + private MockObject $schemaMapper; + + /** + * Mock IUserSession. + * + * @var IUserSession&MockObject + */ + private IUserSession&MockObject $userSession; + + /** + * Mock IRequest. + * + * @var IRequest&MockObject + */ + private IRequest&MockObject $request; + + /** + * Mock IGroupManager (unused by createFromTemplate but required by the ctor). + * + * @var IGroupManager&MockObject + */ + private IGroupManager&MockObject $groupManager; + + /** + * Per-app Register entity stub. + * + * @var MockObject + */ + private MockObject $perAppRegister; + + /** + * The slug of the template under test in fixtures. + * + * @var string + */ + private const TEMPLATE_SLUG = 'permit-tracker'; + + /** + * Set up shared mocks. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->request = $this->createMock(IRequest::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->groupManager = $this->createMock(IGroupManager::class); + + $this->objectService = $this->getMockBuilder(\stdClass::class) + ->addMethods(['searchObjects', 'find', 'saveObject']) + ->getMock(); + + // RegisterMapper mock chain: find()->getId(), create + update. + $registerEntity = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getId']) + ->getMock(); + $registerEntity->method('getId')->willReturn(926); + + $this->perAppRegister = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getId', 'getSlug', 'getSchemas', 'setSchemas']) + ->getMock(); + $this->perAppRegister->method('getId')->willReturn(2001); + $this->perAppRegister->method('getSlug')->willReturn('openbuilt-my-permits'); + $this->perAppRegister->method('getSchemas')->willReturn([]); + $this->perAppRegister->method('setSchemas')->willReturn(null); + + $this->registerMapper = $this->getMockBuilder(\stdClass::class) + ->addMethods(['find', 'createFromArray', 'update']) + ->getMock(); + // Default: shared register find succeeds, per-app register find throws (not yet provisioned). + $this->registerMapper->method('find')->willReturnCallback( + function (string $slug) use ($registerEntity): object { + if ($slug === 'openbuilt') { + return $registerEntity; + } + throw new \RuntimeException('register not found: '.$slug); + } + ); + $this->registerMapper->method('createFromArray')->willReturn($this->perAppRegister); + $this->registerMapper->method('update')->willReturn($this->perAppRegister); + + // SchemaMapper mock chain: find()->getId() for shared schemas; createFromArray for clones. + $applicationTemplateSchema = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getId']) + ->getMock(); + $applicationTemplateSchema->method('getId')->willReturn(1635); + $applicationSchema = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getId']) + ->getMock(); + $applicationSchema->method('getId')->willReturn(1636); + + $this->schemaMapper = $this->getMockBuilder(\stdClass::class) + ->addMethods(['find', 'createFromArray']) + ->getMock(); + $this->schemaMapper->method('find')->willReturnCallback( + function (string $slug) use ($applicationTemplateSchema, $applicationSchema): object { + if ($slug === 'application-template') { + return $applicationTemplateSchema; + } + if ($slug === 'application') { + return $applicationSchema; + } + throw new \RuntimeException('schema not found: '.$slug); + } + ); + + $this->controller = new ApplicationsController( + request: $this->request, + logger: $this->logger, + objectService: $this->objectService, + registerMapper: $this->registerMapper, + schemaMapper: $this->schemaMapper, + userSession: $this->userSession, + groupManager: $this->groupManager, + auditTrailMapper: null, + ); + }//end setUp() + + /** + * Register an authenticated user for the test. + * + * @param string $uid The UID to return from getUID. + * + * @return void + */ + private function authenticateAs(string $uid): void + { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($uid); + $this->userSession->method('getUser')->willReturn($user); + }//end authenticateAs() + + /** + * Set the request body params (name + slug). + * + * @param array $params The body params. + * + * @return void + */ + private function withRequestParams(array $params): void + { + $this->request->method('getParams')->willReturn($params); + }//end withRequestParams() + + /** + * Build a representative template record. + * + * @param string $slug The template slug. + * + * @return array + */ + private function templateRecord(string $slug): array + { + return [ + 'slug' => $slug, + 'version' => '1.0.0', + 'manifest' => [ + 'pages' => [ + ['name' => 'Index', 'type' => 'index', 'config' => ['schema' => 'permit-application']], + ['name' => 'Form', 'type' => 'form', 'config' => ['schema' => 'permit-application']], + ], + ], + 'companionSchemas' => [ + [ + 'slug' => 'permit-application', + 'title' => 'Permit application', + 'type' => 'object', + 'version' => '0.1.0', + ], + ], + ]; + }//end templateRecord() + + /** + * Test 1 — Unknown templateSlug → 404 + template_not_found error envelope. + * + * @return void + */ + public function testReturns404WhenTemplateSlugUnknown(): void + { + $this->authenticateAs('alice'); + $this->withRequestParams(['name' => 'My permits', 'slug' => 'my-permits']); + + // Template lookup returns no hits (any number of times — controller may also + // perform a slug-collision lookup after the missing template would normally + // be detected; both return empty here). + $this->objectService->method('searchObjects')->willReturn([]); + + $result = $this->controller->createFromTemplate(templateSlug: 'no-such-template'); + + self::assertInstanceOf(JSONResponse::class, $result); + self::assertSame(Http::STATUS_NOT_FOUND, $result->getStatus()); + $body = $result->getData(); + self::assertSame('template_not_found', $body['error']); + }//end testReturns404WhenTemplateSlugUnknown() + + /** + * Test 2 — Same-user slug collision → 4xx + slug_collision error envelope. + * + * The lookup sequence for createFromTemplate is: + * 1. lookupOne(templateSchema, slug=templateSlug) — template exists + * 2. lookupOne(applicationSchema, slug=newSlug, owner=alice) — existing app collides + * + * @return void + */ + public function testReturns4xxOnSlugCollisionForSameOwner(): void + { + $this->authenticateAs('alice'); + $this->withRequestParams(['name' => 'My permits', 'slug' => 'my-permits']); + + $this->objectService->method('searchObjects')->willReturnOnConsecutiveCalls( + // 1) template found + [$this->templateRecord(self::TEMPLATE_SLUG)], + // 2) existing application with the same slug owned by alice + [['slug' => 'my-permits', 'owner' => 'alice']] + ); + + $result = $this->controller->createFromTemplate(templateSlug: self::TEMPLATE_SLUG); + + self::assertGreaterThanOrEqual(400, $result->getStatus()); + self::assertLessThan(500, $result->getStatus()); + $body = $result->getData(); + self::assertSame('slug_collision', $body['error']); + }//end testReturns4xxOnSlugCollisionForSameOwner() + + /** + * Test 3 — Success: 201 + Application + per-app register + companion schema with prefix. + * + * @return void + */ + public function testSuccessCreatesApplicationAndPerAppArtifacts(): void + { + $this->authenticateAs('alice'); + $this->withRequestParams(['name' => 'My permits', 'slug' => 'my-permits']); + + // Lookup sequence: 1) template found, 2) no slug collision. + $this->objectService->method('searchObjects')->willReturnOnConsecutiveCalls( + [$this->templateRecord(self::TEMPLATE_SLUG)], + [] + ); + + // Expect a schema clone CALL with the prefixed slug `my-permits-permit-application`. + $createdSchema = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getId']) + ->getMock(); + $createdSchema->method('getId')->willReturn(7777); + + $this->schemaMapper->expects(self::once()) + ->method('createFromArray') + ->with(self::callback( + static function (array $payload): bool { + return ($payload['slug'] ?? null) === 'my-permits-permit-application'; + } + )) + ->willReturn($createdSchema); + + $this->objectService->expects(self::once()) + ->method('saveObject') + ->willReturn(['uuid' => 'new-uuid-1', 'slug' => 'my-permits']); + + $result = $this->controller->createFromTemplate(templateSlug: self::TEMPLATE_SLUG); + + self::assertSame(Http::STATUS_CREATED, $result->getStatus()); + $body = $result->getData(); + self::assertSame('new-uuid-1', $body['uuid']); + self::assertSame('my-permits', $body['slug']); + self::assertSame('openbuilt-my-permits', $body['register']); + self::assertSame([7777], $body['companionSchemas']); + }//end testSuccessCreatesApplicationAndPerAppArtifacts() + + /** + * Test 4 — Manifest schema-refs are rewritten with the new-slug prefix. + * + * @return void + */ + public function testManifestSchemaRefsRewrittenWithNewSlugPrefix(): void + { + $this->authenticateAs('alice'); + $this->withRequestParams(['name' => 'My permits', 'slug' => 'my-permits']); + + $this->objectService->method('searchObjects')->willReturnOnConsecutiveCalls( + [$this->templateRecord(self::TEMPLATE_SLUG)], + [] + ); + + $createdSchema = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getId']) + ->getMock(); + $createdSchema->method('getId')->willReturn(7777); + $this->schemaMapper->method('createFromArray')->willReturn($createdSchema); + + $savedPayload = null; + $this->objectService->method('saveObject')->willReturnCallback( + static function (array $object) use (&$savedPayload) { + $savedPayload = $object; + return ['uuid' => 'new-uuid-2']; + } + ); + + $result = $this->controller->createFromTemplate(templateSlug: self::TEMPLATE_SLUG); + self::assertSame(Http::STATUS_CREATED, $result->getStatus()); + + self::assertIsArray($savedPayload, 'saveObject should have been invoked with the cloned Application payload'); + $manifest = $savedPayload['manifest'] ?? []; + $pages = $manifest['pages'] ?? []; + self::assertCount(2, $pages); + foreach ($pages as $page) { + self::assertSame( + 'my-permits-permit-application', + $page['config']['schema'] ?? null, + 'every manifest page-config schema must be rewritten with the new-slug prefix' + ); + } + }//end testManifestSchemaRefsRewrittenWithNewSlugPrefix() + + /** + * Test 5 — Owner field on the persisted Application matches the authenticated UID. + * + * @return void + */ + public function testOwnerFieldSetToAuthenticatedUid(): void + { + $this->authenticateAs('bob'); + $this->withRequestParams(['name' => 'Bob app', 'slug' => 'bob-app']); + + $this->objectService->method('searchObjects')->willReturnOnConsecutiveCalls( + [$this->templateRecord(self::TEMPLATE_SLUG)], + [] + ); + + $createdSchema = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getId']) + ->getMock(); + $createdSchema->method('getId')->willReturn(8888); + $this->schemaMapper->method('createFromArray')->willReturn($createdSchema); + + $savedPayload = null; + $this->objectService->method('saveObject')->willReturnCallback( + static function (array $object) use (&$savedPayload) { + $savedPayload = $object; + return ['uuid' => 'new-uuid-3']; + } + ); + + $result = $this->controller->createFromTemplate(templateSlug: self::TEMPLATE_SLUG); + self::assertSame(Http::STATUS_CREATED, $result->getStatus()); + + self::assertIsArray($savedPayload); + self::assertSame('bob', $savedPayload['owner'] ?? null); + }//end testOwnerFieldSetToAuthenticatedUid() + + /** + * Test 6 — Cross-user slug usage is allowed: when the slug-collision lookup is + * scoped to the caller, two different owners can each clone an Application with + * the same slug. The controller's lookupOne for slug collision is scoped by `owner`. + * + * @return void + */ + public function testDifferentOwnersCanCloneSameSlug(): void + { + $this->authenticateAs('carol'); + $this->withRequestParams(['name' => 'My permits', 'slug' => 'my-permits']); + + // Sequence: template found, then the owner-scoped collision lookup returns [] + // (carol has no app with this slug — even though bob does, that's filtered + // out by the owner filter). + $this->objectService->method('searchObjects')->willReturnOnConsecutiveCalls( + [$this->templateRecord(self::TEMPLATE_SLUG)], + [] + ); + + $createdSchema = $this->getMockBuilder(\stdClass::class) + ->addMethods(['getId']) + ->getMock(); + $createdSchema->method('getId')->willReturn(9999); + $this->schemaMapper->method('createFromArray')->willReturn($createdSchema); + + $savedPayload = null; + $this->objectService->method('saveObject')->willReturnCallback( + static function (array $object) use (&$savedPayload) { + $savedPayload = $object; + return ['uuid' => 'new-uuid-4']; + } + ); + + $result = $this->controller->createFromTemplate(templateSlug: self::TEMPLATE_SLUG); + + self::assertSame(Http::STATUS_CREATED, $result->getStatus()); + self::assertIsArray($savedPayload); + self::assertSame('carol', $savedPayload['owner'] ?? null); + self::assertSame('my-permits', $savedPayload['slug'] ?? null); + }//end testDifferentOwnersCanCloneSameSlug() +}//end class diff --git a/tests/Unit/Repair/SeedApplicationTemplatesTest.php b/tests/Unit/Repair/SeedApplicationTemplatesTest.php new file mode 100644 index 00000000..8e53b5d2 --- /dev/null +++ b/tests/Unit/Repair/SeedApplicationTemplatesTest.php @@ -0,0 +1,316 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Tests\Unit\Repair; + +use OCA\OpenBuilt\Repair\SeedApplicationTemplates; +use OCP\App\IAppManager; +use OCP\Migration\IOutput; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use RuntimeException; + +/** + * Tests for SeedApplicationTemplates::run. + */ +class SeedApplicationTemplatesTest extends TestCase +{ + /** + * The four expected seeded template slugs. + * + * @var array + */ + private const EXPECTED_SLUGS = [ + 'permit-tracker', + 'stakeholder-consultation', + 'employee-onboarding', + 'incident-reporter', + ]; + + /** + * Mock logger. + * + * @var LoggerInterface&MockObject + */ + private LoggerInterface&MockObject $logger; + + /** + * Mock OR ObjectService. + * + * @var MockObject + */ + private MockObject $objectService; + + /** + * Mock IAppManager. + * + * @var IAppManager&MockObject + */ + private IAppManager&MockObject $appManager; + + /** + * Mock IOutput. + * + * @var IOutput&MockObject + */ + private IOutput&MockObject $output; + + /** + * Path to a temp fixtures dir created per test (cleaned up on tearDown). + * + * @var string|null + */ + private ?string $tempDir = null; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->logger = $this->createMock(LoggerInterface::class); + $this->output = $this->createMock(IOutput::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->objectService = $this->getMockBuilder(\stdClass::class) + ->addMethods(['findAll', 'saveObject']) + ->getMock(); + }//end setUp() + + /** + * Remove the temp fixtures dir if it was created. + * + * @return void + */ + protected function tearDown(): void + { + if ($this->tempDir !== null && is_dir($this->tempDir) === true) { + foreach (glob($this->tempDir.'/*.json') ?: [] as $f) { + @unlink($f); + } + + @rmdir($this->tempDir); + // Walk up to the parent app root dir we created. + $parent = dirname($this->tempDir); + if (basename($parent) === 'Settings') { + @rmdir($parent); + @rmdir(dirname($parent)); + @rmdir(dirname(dirname($parent))); + } + } + + $this->tempDir = null; + parent::tearDown(); + }//end tearDown() + + /** + * Create a temp app root with a Settings/templates dir containing fixtures. + * + * Returns the path that the SUT will receive from $appManager->getAppPath('openbuilt'). + * + * @param array|null> $fixturesBySlug Map of slug → fixture data (null = skip file). + * + * @return string The pseudo-app-root path. + */ + private function seedFixturesDir(array $fixturesBySlug): string + { + $appRoot = sys_get_temp_dir().'/openbuilt-test-'.uniqid(); + $fixturesDir = $appRoot.'/lib/Settings/templates'; + $this->tempDir = $fixturesDir; + mkdir($fixturesDir, 0777, true); + + foreach ($fixturesBySlug as $slug => $data) { + if ($data === null) { + continue; + } + + file_put_contents($fixturesDir.'/'.$slug.'.json', json_encode($data, JSON_PRETTY_PRINT)); + } + + $this->appManager->method('getAppPath')->willReturn($appRoot); + return $appRoot; + }//end seedFixturesDir() + + /** + * Build a valid fixture for the given slug. + * + * @param string $slug The fixture slug. + * + * @return array + */ + private function validFixture(string $slug): array + { + return [ + 'slug' => $slug, + 'title' => 'Title for '.$slug, + 'description' => 'Description for '.$slug, + 'useCase' => 'Use case for '.$slug, + 'category' => 'government-services', + 'version' => '1.0.0', + 'manifest' => [ + 'version' => '1.0.0', + 'pages' => [['name' => 'p1', 'route' => '/', 'type' => 'index']], + ], + 'companionSchemas' => [], + ]; + }//end validFixture() + + /** + * Test 1 — fresh install: all four fixtures present and no existing + * records → saveObject called four times. + * + * @return void + */ + public function testSeedsFourTemplatesOnFreshInstall(): void + { + $fixtures = []; + foreach (self::EXPECTED_SLUGS as $slug) { + $fixtures[$slug] = $this->validFixture($slug); + } + + $this->seedFixturesDir($fixtures); + + // No existing records — findAll returns empty for every slug. + $this->objectService->method('findAll')->willReturn([]); + + $this->objectService->expects(self::exactly(4)) + ->method('saveObject'); + + $step = new SeedApplicationTemplates( + logger: $this->logger, + appManager: $this->appManager, + objectService: $this->objectService, + ); + + $step->run($this->output); + }//end testSeedsFourTemplatesOnFreshInstall() + + /** + * Test 2 — idempotent re-run: when all four slugs already exist, no + * saveObject calls are made. + * + * @return void + */ + public function testIdempotentReRunDoesNotDuplicate(): void + { + $fixtures = []; + foreach (self::EXPECTED_SLUGS as $slug) { + $fixtures[$slug] = $this->validFixture($slug); + } + + $this->seedFixturesDir($fixtures); + + // Every findAll lookup returns a hit (the record already exists). + $this->objectService->method('findAll')->willReturnCallback( + static fn (array $config): array => [['slug' => $config['filters']['slug'] ?? '']] + ); + + $this->objectService->expects(self::never())->method('saveObject'); + + $step = new SeedApplicationTemplates( + logger: $this->logger, + appManager: $this->appManager, + objectService: $this->objectService, + ); + + $step->run($this->output); + }//end testIdempotentReRunDoesNotDuplicate() + + /** + * Test 3 — slugs persisted match the expected canonical list (cardinality + * check + slug verification on saveObject payloads). + * + * @return void + */ + public function testSeededRecordSlugsMatchExpectedList(): void + { + $fixtures = []; + foreach (self::EXPECTED_SLUGS as $slug) { + $fixtures[$slug] = $this->validFixture($slug); + } + + $this->seedFixturesDir($fixtures); + + $this->objectService->method('findAll')->willReturn([]); + + $savedSlugs = []; + $this->objectService->method('saveObject') + ->willReturnCallback( + static function (array $object) use (&$savedSlugs) { + $savedSlugs[] = $object['slug'] ?? null; + return ['uuid' => 'fake-uuid-'.($object['slug'] ?? 'x')]; + } + ); + + $step = new SeedApplicationTemplates( + logger: $this->logger, + appManager: $this->appManager, + objectService: $this->objectService, + ); + + $step->run($this->output); + + sort($savedSlugs); + $expected = self::EXPECTED_SLUGS; + sort($expected); + self::assertSame($expected, $savedSlugs); + }//end testSeededRecordSlugsMatchExpectedList() + + /** + * Test 4 — REQ-OBTC-009: when an expected fixture file is missing, + * the repair step fails loudly with a RuntimeException naming the file. + * + * @return void + */ + public function testFailsLoudWhenExpectedFixtureMissing(): void + { + // Only three of the four fixtures exist; `incident-reporter` is intentionally + // omitted to simulate a packaging error. + $fixtures = [ + 'permit-tracker' => $this->validFixture('permit-tracker'), + 'stakeholder-consultation' => $this->validFixture('stakeholder-consultation'), + 'employee-onboarding' => $this->validFixture('employee-onboarding'), + 'incident-reporter' => null, + ]; + $this->seedFixturesDir($fixtures); + + $this->objectService->method('findAll')->willReturn([]); + + $step = new SeedApplicationTemplates( + logger: $this->logger, + appManager: $this->appManager, + objectService: $this->objectService, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/incident-reporter/'); + + $step->run($this->output); + }//end testFailsLoudWhenExpectedFixtureMissing() +}//end class diff --git a/tests/e2e/template-gallery.spec.ts b/tests/e2e/template-gallery.spec.ts new file mode 100644 index 00000000..b7495e49 --- /dev/null +++ b/tests/e2e/template-gallery.spec.ts @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Playwright end-to-end test for the OpenBuilt template gallery and + * clone-from-template flow (REQ-OBTC-003, REQ-OBTC-004, REQ-OBTC-006, + * REQ-OBTC-008). + * + * Scenario: + * 1. Log in as admin via /login. + * 2. Navigate to /apps/openbuilt/templates. + * 3. Assert the four seeded template cards render. + * 4. Click "Use this template" on a card. + * 5. Fill the clone dialog with a new name + slug. + * 6. Submit; assert the navigation lands on the page editor for the new app. + * + * NOTE: Playwright infrastructure is not yet wired into openbuilt's package + * scripts. This file is the canonical e2e coverage for the spec and will + * run once the cohort-wide Playwright bootstrap lands (mirroring the same + * deferred-bootstrap pattern used by mydash). + */ + +import { test, expect, Page } from '@playwright/test' + +const NEXTCLOUD_URL = process.env.NEXTCLOUD_URL || process.env.NC_BASE_URL || 'http://localhost:8080' +const ADMIN_USER = process.env.NC_ADMIN_USER || 'admin' +const ADMIN_PASS = process.env.NC_ADMIN_PASS || 'admin' + +/** + * Helper — log in to Nextcloud via the standard /login form. + * + * @param page Playwright page handle. + */ +async function loginAsAdmin(page: Page): Promise { + await page.goto(`${NEXTCLOUD_URL}/index.php/login`) + await page.locator('input[name="user"]').fill(ADMIN_USER) + await page.locator('input[name="password"]').fill(ADMIN_PASS) + await page.locator('button[type="submit"]').click() + await page.waitForURL(/index\.php\/apps\//, { timeout: 15_000 }) +} + +test.describe('OpenBuilt template gallery', () => { + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page) + }) + + test('lists the four seeded templates and clones one into a draft application', async ({ page }) => { + // 1. Navigate to the gallery. + await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt/templates`) + + // 2. Wait for the gallery shell to render. + await expect(page.locator('.template-gallery')).toBeVisible({ timeout: 15_000 }) + + // 3. Assert the four seeded cards are present. + // The card title (.template-card__title) must match each canonical slug's title. + const cards = page.locator('.template-card') + await expect(cards).toHaveCount(4, { timeout: 15_000 }) + + const expectedTitles = [ + 'Permit Tracker', + 'Stakeholder Consultation', + 'Employee Onboarding', + 'Incident Reporter', + ] + for (const title of expectedTitles) { + await expect(page.locator('.template-card__title', { hasText: title })).toBeVisible() + } + + // 4. Click "Use this template" on the permit-tracker card. + const permitCard = page + .locator('.template-card') + .filter({ has: page.locator('.template-card__title', { hasText: 'Permit Tracker' }) }) + await permitCard.getByRole('button', { name: /Use this template/i }).click() + + // 5. The clone dialog should open. + const dialog = page.locator('.clone-dialog') + await expect(dialog).toBeVisible({ timeout: 5_000 }) + + // 6. Fill in name + slug. + const newSlug = `e2e-permits-${Date.now().toString(36)}` + await dialog.getByLabel(/Application name/i).fill('E2E permits') + await dialog.getByLabel(/Slug/i).fill(newSlug) + + // 7. Submit — primary button labelled "Clone template". + await dialog.getByRole('button', { name: /Clone template/i }).click() + + // 8. Assert the post-clone redirect lands on the editor surface. + // The page editor route (or its fallback ApplicationEditor) carries + // the new slug in the URL. + await page.waitForURL((url) => url.toString().includes(newSlug), { timeout: 15_000 }) + expect(page.url()).toContain(newSlug) + }) + + test('filter by category narrows to government-services only', async ({ page }) => { + await page.goto(`${NEXTCLOUD_URL}/index.php/apps/openbuilt/templates`) + await expect(page.locator('.template-gallery')).toBeVisible({ timeout: 15_000 }) + await expect(page.locator('.template-card')).toHaveCount(4, { timeout: 15_000 }) + + // The category select is a NcSelect (vue-select) — click and pick the option. + const filter = page.locator('.template-gallery__filters').locator('input[role="combobox"], input').last() + await filter.click() + await page.getByText('Government services', { exact: true }).click() + + // Only permit-tracker remains. + await expect(page.locator('.template-card')).toHaveCount(1) + await expect(page.locator('.template-card__title')).toHaveText(/Permit Tracker/) + }) +}) diff --git a/tests/integration/openbuilt-templates-marketplace.postman_collection.json b/tests/integration/openbuilt-templates-marketplace.postman_collection.json new file mode 100644 index 00000000..4ef9a5e8 --- /dev/null +++ b/tests/integration/openbuilt-templates-marketplace.postman_collection.json @@ -0,0 +1,188 @@ +{ + "info": { + "_postman_id": "openbuilt-templates-marketplace", + "name": "OpenBuilt Templates Marketplace", + "description": "Newman/Postman integration tests for the openbuilt-templates-marketplace spec. Covers REQ-OBTC-003 (list seeded templates), REQ-OBTC-004 (createFromTemplate happy path + 4xx slug-collision), REQ-OBTC-009 (cross-user slug reuse).", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "auth": { + "type": "basic", + "basic": [ + { "key": "username", "value": "{{primary_user}}", "type": "string" }, + { "key": "password", "value": "{{primary_password}}", "type": "string" } + ] + }, + "variable": [ + { "key": "base_url", "value": "http://localhost:8080", "type": "string" }, + { "key": "primary_user", "value": "admin", "type": "string" }, + { "key": "primary_password", "value": "admin", "type": "string" }, + { "key": "second_user", "value": "tester", "type": "string" }, + { "key": "second_password", "value": "tester1234", "type": "string" }, + { "key": "template_slug", "value": "permit-tracker", "type": "string" }, + { "key": "clone_slug", "value": "newman-permits", "type": "string" }, + { "key": "clone_uuid", "value": "", "type": "string" } + ], + "item": [ + { + "name": "GET applicationTemplates — list returns 4 seeded records", + "request": { + "method": "GET", + "header": [ + { "key": "Accept", "value": "application/json" }, + { "key": "OCS-APIRequest", "value": "true" } + ], + "url": { + "raw": "{{base_url}}/index.php/apps/openregister/api/objects/openbuilt/application-template", + "host": ["{{base_url}}"], + "path": ["index.php", "apps", "openregister", "api", "objects", "openbuilt", "application-template"] + }, + "description": "Read the seeded ApplicationTemplate records directly from OpenRegister (REQ-OBTC-003)." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('200 OK', function () { pm.response.to.have.status(200); });", + "const body = pm.response.json();", + "const results = Array.isArray(body) ? body : (body.results || []);", + "pm.test('At least four seeded templates returned', function () {", + " pm.expect(results.length).to.be.at.least(4);", + "});", + "const slugs = results.map(r => r.slug);", + "pm.test('All four canonical slugs are present', function () {", + " ['permit-tracker','stakeholder-consultation','employee-onboarding','incident-reporter'].forEach(s => {", + " pm.expect(slugs).to.include(s);", + " });", + "});" + ] + } + } + ] + }, + { + "name": "POST createFromTemplate — happy path returns 201 + new uuid", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" }, + { "key": "OCS-APIRequest", "value": "true" }, + { "key": "requesttoken", "value": "newman-stub" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Newman permits\",\n \"slug\": \"{{clone_slug}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/index.php/apps/openbuilt/api/applications/from-template/{{template_slug}}", + "host": ["{{base_url}}"], + "path": ["index.php", "apps", "openbuilt", "api", "applications", "from-template", "{{template_slug}}"] + }, + "description": "Clone the `permit-tracker` template into a fresh draft Application (REQ-OBTC-004)." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('201 Created', function () { pm.response.to.have.status(201); });", + "const body = pm.response.json();", + "pm.test('Response carries a uuid + slug', function () {", + " pm.expect(body).to.have.property('uuid');", + " pm.expect(body.slug).to.eql(pm.variables.get('clone_slug'));", + "});", + "pm.collectionVariables.set('clone_uuid', body.uuid);" + ] + } + } + ] + }, + { + "name": "POST createFromTemplate — same user + same slug → 409 slug_collision", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" }, + { "key": "OCS-APIRequest", "value": "true" }, + { "key": "requesttoken", "value": "newman-stub" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Newman permits (dup)\",\n \"slug\": \"{{clone_slug}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/index.php/apps/openbuilt/api/applications/from-template/{{template_slug}}", + "host": ["{{base_url}}"], + "path": ["index.php", "apps", "openbuilt", "api", "applications", "from-template", "{{template_slug}}"] + }, + "description": "Re-clone with the same slug as the same user — slug collision returns 409 (REQ-OBTC-004 slug-uniqueness)." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('4xx status', function () {", + " pm.expect(pm.response.code).to.be.within(400, 499);", + "});", + "const body = pm.response.json();", + "pm.test('Error code is slug_collision', function () {", + " pm.expect(body.error).to.eql('slug_collision');", + "});" + ] + } + } + ] + }, + { + "name": "POST createFromTemplate — different user + same slug → 201 (cross-user reuse OK)", + "request": { + "method": "POST", + "auth": { + "type": "basic", + "basic": [ + { "key": "username", "value": "{{second_user}}", "type": "string" }, + { "key": "password", "value": "{{second_password}}", "type": "string" } + ] + }, + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Accept", "value": "application/json" }, + { "key": "OCS-APIRequest", "value": "true" }, + { "key": "requesttoken", "value": "newman-stub" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Tester permits\",\n \"slug\": \"{{clone_slug}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/index.php/apps/openbuilt/api/applications/from-template/{{template_slug}}", + "host": ["{{base_url}}"], + "path": ["index.php", "apps", "openbuilt", "api", "applications", "from-template", "{{template_slug}}"] + }, + "description": "Same slug, different user — multi-user isolation lets the clone succeed (REQ-OBTC-004 owner-scoped uniqueness)." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('201 Created for different owner', function () { pm.response.to.have.status(201); });", + "const body = pm.response.json();", + "pm.test('Response carries a uuid + matching slug', function () {", + " pm.expect(body).to.have.property('uuid');", + " pm.expect(body.slug).to.eql(pm.variables.get('clone_slug'));", + "});" + ] + } + } + ] + } + ] +} diff --git a/tests/modals/CloneTemplateDialog.spec.js b/tests/modals/CloneTemplateDialog.spec.js new file mode 100644 index 00000000..666b741c --- /dev/null +++ b/tests/modals/CloneTemplateDialog.spec.js @@ -0,0 +1,114 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Vitest unit tests for `src/modals/CloneTemplateDialog.vue`. + * + * Covers: + * - submit emits the expected payload when input is valid + * - slug-shape validation rejects non-kebab-case or > 32 char slugs + * and does NOT emit `submit` + * - error rendering surfaces an external error set via `setError` + * (so the parent gallery can hand the server-side error to the dialog + * after a clone POST fails) + */ + +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +import CloneTemplateDialog from '../../src/modals/CloneTemplateDialog.vue' + +const baseStubs = { + NcModal: { + name: 'NcModal', + props: ['size'], + // Render the slot so the inner controls are reachable. Default-slot + // is the only one used by the dialog. + template: '
', + }, + NcButton: { + name: 'NcButton', + props: ['type', 'disabled'], + template: '', + }, + NcTextField: { + name: 'NcTextField', + props: ['value', 'label', 'placeholder'], + template: '', + }, +} + +/** + * Mount helper. + * + * @param {object} props initial props + * @return {import('@vue/test-utils').Wrapper} + */ +function mountDialog(props = {}) { + return mount(CloneTemplateDialog, { + propsData: { + open: true, + template: { slug: 'permit-tracker', title: 'Permit Tracker' }, + ...props, + }, + stubs: baseStubs, + }) +} + +describe('CloneTemplateDialog.vue', () => { + it('emits submit with the trimmed name/slug payload when input is valid', async () => { + const wrapper = mountDialog() + + wrapper.vm.localName = 'My permits' + wrapper.vm.localSlug = 'my-permits' + await wrapper.vm.$nextTick() + + expect(wrapper.vm.canSubmit).toBe(true) + + await wrapper.vm.submit() + + const submitEvents = wrapper.emitted('submit') + expect(submitEvents).toBeTruthy() + expect(submitEvents.length).toBe(1) + expect(submitEvents[0][0]).toEqual({ name: 'My permits', slug: 'my-permits' }) + }) + + it('does not emit submit and surfaces an error when the slug shape is invalid', async () => { + const wrapper = mountDialog() + + wrapper.vm.localName = 'My permits' + // Uppercase + spaces — invalid kebab-case. + wrapper.vm.localSlug = 'My Permits' + await wrapper.vm.$nextTick() + + expect(wrapper.vm.canSubmit).toBe(false) + await wrapper.vm.submit() + + expect(wrapper.emitted('submit')).toBeUndefined() + expect(wrapper.vm.error.length).toBeGreaterThan(0) + }) + + it('rejects slugs longer than 32 characters', async () => { + const wrapper = mountDialog() + + wrapper.vm.localName = 'X' + // 33 chars of kebab-case. + wrapper.vm.localSlug = 'a'.repeat(33) + await wrapper.vm.$nextTick() + + expect(wrapper.vm.canSubmit).toBe(false) + }) + + it('renders an external error message set via setError', async () => { + const wrapper = mountDialog() + + wrapper.vm.setError('Slug already in use') + await wrapper.vm.$nextTick() + + const error = wrapper.find('.clone-dialog__error') + expect(error.exists()).toBe(true) + expect(error.text()).toBe('Slug already in use') + // submitting is reset so the button re-enables. + expect(wrapper.vm.submitting).toBe(false) + }) +}) diff --git a/tests/views/TemplateGallery.spec.js b/tests/views/TemplateGallery.spec.js new file mode 100644 index 00000000..5b9497f0 --- /dev/null +++ b/tests/views/TemplateGallery.spec.js @@ -0,0 +1,218 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Vitest unit tests for `src/views/TemplateGallery.vue`. + * + * Covers: + * - REQ-OBTC-003: render the four seeded ApplicationTemplate cards + * - REQ-OBTC-006: filter by category (government-services) leaves only + * `permit-tracker` visible + * - REQ-OBTC-006: free-text search over title/useCase/description narrows + * the visible set + * - REQ-OBTC-004: "Use this template" CTA triggers an axios POST to + * `/apps/openbuilt/api/applications/from-template/{slug}` with the + * payload from the clone dialog + * - REQ-OBTC-008: on successful clone, the gallery navigates to the + * page editor surface for the newly cloned application + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' + +const { axiosMock } = vi.hoisted(() => ({ + axiosMock: { get: vi.fn(), post: vi.fn() }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateUrl: (path) => path, +})) + +vi.mock('@nextcloud/axios', () => ({ + default: axiosMock, +})) + +vi.mock('../../src/modals/CloneTemplateDialog.vue', () => ({ + default: { + name: 'CloneTemplateDialog', + props: ['open', 'template'], + emits: ['close', 'submit'], + methods: { setError(message) { this.lastError = message } }, + render() { return null }, + }, +})) + +import TemplateGallery from '../../src/views/TemplateGallery.vue' + +const seededTemplates = [ + { + uuid: 'tpl-1', + slug: 'permit-tracker', + title: 'Permit Tracker', + useCase: 'Municipal building-permit workflow', + description: 'Index + form + kanban for permits.', + category: 'government-services', + screenshotUrl: 'img/templates/permit-tracker.svg', + isSeeded: true, + }, + { + uuid: 'tpl-2', + slug: 'stakeholder-consultation', + title: 'Stakeholder Consultation', + useCase: 'Public consultation rounds', + description: 'Collect citizen feedback on draft policies.', + category: 'citizen-engagement', + isSeeded: true, + }, + { + uuid: 'tpl-3', + slug: 'employee-onboarding', + title: 'Employee Onboarding', + useCase: 'New-hire checklist tracker', + description: 'Steps + documents + signoffs.', + category: 'internal-operations', + isSeeded: true, + }, + { + uuid: 'tpl-4', + slug: 'incident-reporter', + title: 'Incident Reporter', + useCase: 'Field-team incident logging', + description: 'Capture + triage + route to teams.', + category: 'field-work', + isSeeded: true, + }, +] + +/** + * Mount helper that injects a $router stub and the seeded fetch response. + * + * @param {object} routerOverrides optional stub overrides + * @return {Promise} + */ +async function mountGallery(routerOverrides = {}) { + axiosMock.get.mockResolvedValueOnce({ data: { results: seededTemplates } }) + + const $router = { + resolve: vi.fn().mockReturnValue({ resolved: { matched: [{}], fullPath: '/applications/my-permits' } }), + push: vi.fn(), + ...routerOverrides, + } + + const wrapper = mount(TemplateGallery, { + mocks: { + $router, + }, + stubs: { + NcButton: { + name: 'NcButton', + template: '', + }, + NcTextField: { + name: 'NcTextField', + props: ['value', 'label', 'placeholder'], + template: '', + }, + NcSelect: { + name: 'NcSelect', + props: ['value', 'options'], + template: '', + }, + NcLoadingIcon: true, + NcEmptyContent: { name: 'NcEmptyContent', props: ['name'], template: '
{{ name }}
' }, + }, + }) + + // Wait for the mounted() axios call to resolve. + await new Promise((resolve) => setTimeout(resolve, 0)) + await wrapper.vm.$nextTick() + return { wrapper, $router } +} + +describe('TemplateGallery.vue', () => { + beforeEach(() => { + axiosMock.get.mockReset() + axiosMock.post.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders the four seeded templates after mount', async () => { + const { wrapper } = await mountGallery() + + expect(axiosMock.get).toHaveBeenCalledTimes(1) + expect(axiosMock.get.mock.calls[0][0]).toContain('/apps/openregister/api/objects/openbuilt/application-template') + + const cards = wrapper.findAll('.template-card') + expect(cards.length).toBe(4) + const titles = cards.wrappers.map((c) => c.find('.template-card__title').text()) + expect(titles).toEqual( + expect.arrayContaining([ + 'Permit Tracker', + 'Stakeholder Consultation', + 'Employee Onboarding', + 'Incident Reporter', + ]), + ) + }) + + it('filters by category — only permit-tracker remains when government-services is selected', async () => { + const { wrapper } = await mountGallery() + + // The component stores categoryFilter as `{ id }` (from NcSelect). Set it directly. + wrapper.vm.categoryFilter = { id: 'government-services' } + await wrapper.vm.$nextTick() + + const cards = wrapper.findAll('.template-card') + expect(cards.length).toBe(1) + expect(cards.at(0).find('.template-card__title').text()).toBe('Permit Tracker') + }) + + it('free-text search narrows by title/useCase/description', async () => { + const { wrapper } = await mountGallery() + + wrapper.vm.search = 'incident' + await wrapper.vm.$nextTick() + + const cards = wrapper.findAll('.template-card') + expect(cards.length).toBe(1) + expect(cards.at(0).find('.template-card__title').text()).toBe('Incident Reporter') + }) + + it('"Use this template" CTA fires axios POST to createFromTemplate endpoint', async () => { + const { wrapper } = await mountGallery() + + axiosMock.post.mockResolvedValueOnce({ data: { uuid: 'new-app', slug: 'my-permits' } }) + + // Trigger the clone flow on the first template (permit-tracker). + wrapper.vm.openClone(seededTemplates[0]) + await wrapper.vm.$nextTick() + expect(wrapper.vm.cloneOpen).toBe(true) + expect(wrapper.vm.cloneTarget.slug).toBe('permit-tracker') + + await wrapper.vm.onCloneSubmit({ name: 'My permits', slug: 'my-permits' }) + + expect(axiosMock.post).toHaveBeenCalledTimes(1) + const [url, payload] = axiosMock.post.mock.calls[0] + expect(url).toContain('/apps/openbuilt/api/applications/from-template/permit-tracker') + expect(payload).toEqual({ name: 'My permits', slug: 'my-permits' }) + }) + + it('on clone success, redirects to the page editor surface', async () => { + const { wrapper, $router } = await mountGallery() + + axiosMock.post.mockResolvedValueOnce({ data: { uuid: 'new-app', slug: 'my-permits' } }) + + wrapper.vm.openClone(seededTemplates[0]) + await wrapper.vm.onCloneSubmit({ name: 'My permits', slug: 'my-permits' }) + + // PageEditor route resolves (stubbed) → push is invoked with its fullPath. + expect($router.resolve).toHaveBeenCalled() + expect($router.push).toHaveBeenCalled() + const firstResolveArgs = $router.resolve.mock.calls[0][0] + expect(firstResolveArgs.name).toBe('PageEditor') + expect(firstResolveArgs.params.slug).toBe('my-permits') + }) +})