diff --git a/docs/features/applications-in-use.md b/docs/features/applications-in-use.md new file mode 100644 index 00000000..0c4e63fb --- /dev/null +++ b/docs/features/applications-in-use.md @@ -0,0 +1,38 @@ + + +# Applications in use + +An application in use records that your organisation uses an application: which version it runs, where it stands in its lifecycle, and who owns it on the business side and on the technical side. The portfolio views, the lifecycle roadmap and the end-of-support warnings all start from these records. + +Specification: [`openspec/specs/application-usage-pages/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/application-usage-pages/spec.md). + +## Adding an application to your landscape + +Open the application's page under **Applications** and click **Add to our landscape** in the usages section. The application is already filled in. Pick: + +- **Consumer**: your organisation. +- **Version**: the version you run. The list only offers versions of this application. +- **Status**: Acquisition, Planned, In production, To be phased out or Phased out. +- **Business owner** and **Technical owner**: contact persons of your organisation. + +## Browsing what you use + +Open **Applications** in the navigation menu, then **Applications in use**. The list shows each application with its version, status, owners and TIME classification. The tabs above the list filter on status. Your organisation's page lists the same records under **Applications in use**. + +## Moving through the lifecycle + +Open an application in use. The actions at the top follow its status: + +- **Plan** moves Acquisition to Planned. +- **Go live** moves Planned to In production. +- **Phase out** moves In production to To be phased out. +- **Retire** moves To be phased out to Phased out. + +Every change is kept in the **History** tab. + +## Who sees the owners + +The owners are contact persons of the organisation that uses the application. A supplier can read the usages of its own products, but it cannot open the contact persons of its customers. diff --git a/l10n/en.js b/l10n/en.js index ef51efbd..f6352539 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -818,7 +818,17 @@ OC.L10N.register( "High risk without FRIA": "High risk without FRIA", "Application and supplier": "Application and supplier", "History": "History", - "No AI systems registered for this application": "No AI systems registered for this application" + "No AI systems registered for this application": "No AI systems registered for this application", + "Application in use": "Application in use", + "To be phased out": "To be phased out", + "Business owner": "Business owner", + "Technical owner": "Technical owner", + "The person in the organisation who is responsible for how the application is used.": "The person in the organisation who is responsible for how the application is used.", + "The person in the organisation who is responsible for running and maintaining the application.": "The person in the organisation who is responsible for running and maintaining the application.", + "Add to our landscape": "Add to our landscape", + "Connections and services": "Connections and services", + "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "The applications your organisation uses, with the version it runs, where it stands and who owns it.", + "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index fee62e12..0bb16ff6 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -817,6 +817,16 @@ "High risk without FRIA": "High risk without FRIA", "Application and supplier": "Application and supplier", "History": "History", - "No AI systems registered for this application": "No AI systems registered for this application" + "No AI systems registered for this application": "No AI systems registered for this application", + "Application in use": "Application in use", + "To be phased out": "To be phased out", + "Business owner": "Business owner", + "Technical owner": "Technical owner", + "The person in the organisation who is responsible for how the application is used.": "The person in the organisation who is responsible for how the application is used.", + "The person in the organisation who is responsible for running and maintaining the application.": "The person in the organisation who is responsible for running and maintaining the application.", + "Add to our landscape": "Add to our landscape", + "Connections and services": "Connections and services", + "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "The applications your organisation uses, with the version it runs, where it stands and who owns it.", + "No applications in use recorded for this organisation yet": "No applications in use recorded for this organisation yet" } } diff --git a/l10n/nl.js b/l10n/nl.js index 69d5e47a..ac011f44 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -888,7 +888,17 @@ OC.L10N.register( "FRIA": "FRIA", "High risk without FRIA": "Hoog risico zonder FRIA", "Application and supplier": "Applicatie en leverancier", - "No AI systems registered for this application": "Nog geen AI-systemen geregistreerd voor deze applicatie" + "No AI systems registered for this application": "Nog geen AI-systemen geregistreerd voor deze applicatie", + "Application in use": "Applicatie in gebruik", + "To be phased out": "Uit te faseren", + "Business owner": "Functioneel eigenaar", + "Technical owner": "Technisch eigenaar", + "The person in the organisation who is responsible for how the application is used.": "De persoon in de organisatie die verantwoordelijk is voor hoe de applicatie wordt gebruikt.", + "The person in the organisation who is responsible for running and maintaining the application.": "De persoon in de organisatie die verantwoordelijk is voor het draaien en onderhouden van de applicatie.", + "Add to our landscape": "Toevoegen aan ons landschap", + "Connections and services": "Koppelingen en diensten", + "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "De applicaties die uw organisatie gebruikt, met de versie die draait, de fase waarin ze staan en wie de eigenaar is.", + "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 2342bd68..14eaf9a1 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -887,6 +887,16 @@ "FRIA": "FRIA", "High risk without FRIA": "Hoog risico zonder FRIA", "Application and supplier": "Applicatie en leverancier", - "No AI systems registered for this application": "Nog geen AI-systemen geregistreerd voor deze applicatie" + "No AI systems registered for this application": "Nog geen AI-systemen geregistreerd voor deze applicatie", + "Application in use": "Applicatie in gebruik", + "To be phased out": "Uit te faseren", + "Business owner": "Functioneel eigenaar", + "Technical owner": "Technisch eigenaar", + "The person in the organisation who is responsible for how the application is used.": "De persoon in de organisatie die verantwoordelijk is voor hoe de applicatie wordt gebruikt.", + "The person in the organisation who is responsible for running and maintaining the application.": "De persoon in de organisatie die verantwoordelijk is voor het draaien en onderhouden van de applicatie.", + "Add to our landscape": "Toevoegen aan ons landschap", + "Connections and services": "Koppelingen en diensten", + "The applications your organisation uses, with the version it runs, where it stands and who owns it.": "De applicaties die uw organisatie gebruikt, met de versie die draait, de fase waarin ze staan en wie de eigenaar is.", + "No applications in use recorded for this organisation yet": "Nog geen applicaties in gebruik vastgelegd voor deze organisatie" } } diff --git a/lib/Settings/softwarecatalogus_register.json b/lib/Settings/softwarecatalogus_register.json index 427bfb69..7d788425 100644 --- a/lib/Settings/softwarecatalogus_register.json +++ b/lib/Settings/softwarecatalogus_register.json @@ -3,8 +3,8 @@ "info": { "title": "Software Catalog Register", "description": "Register containing AMEF and Voorzieningen schemas for the VNG Software Catalog application. This configuration includes schemas for applications, services, organizations, and compliance tracking.", - "version": "2.5.3", - "changelog": "2.5.3: the aiSystem schema (0.1.0) joins the stackiq register, for the AI systems an organisation uses and their EU AI Act classification (landscape-ai-system-inventory). 2.5.2: the connection schema (0.3.3) asked its national provision picker for gemmaType Buitengemeentenlijke voorziening, a spelling the GEMMA model does not use (it says Buitengemeentelijke voorziening), so the picker found nothing; its name template named the keys gegevensuitwisselingRichting and buitengemeentelijkVoorziening and the values AnaarB, BnaarA and bi-directioneel, all renamed since, so a connection had no readable name; and type, status and dataExchangeDirection become facetable for the new Connections page (connections-catalogue-pages). 2.5.1: the x-openregister-lifecycle blocks of usage, catalogContract, connection and moduleVersion still named the Dutch states (Verwerving/Gepland/In productie/Uit te faseren/Uitgefaseerd, In onderhandeling/Actief/Verlopen, in ontwikkeling/in gebruik/einde ondersteuning/teruggetrokken) while their status enums and the rows RenameDutchCatalogValues migrated are English, so no transition was ever offered on those records (stackiq#1140). The states now use the enum values, and the four schema versions are bumped (usage 1.5.1, catalogContract 0.1.2, connection 0.3.2, moduleVersion 0.1.5) because a lifecycle-only edit does not deploy without one, as 2.4.4 records. 2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." + "version": "2.5.4", + "changelog": "2.5.4: usage (1.5.2) gains businessOwner and technicalOwner, contact persons of the consumer organisation; a usage is named after its application and organisation; status becomes facetable for the Applications in use filters (landscape-usage-registration). 2.5.3: the aiSystem schema (0.1.0) joins the stackiq register, for the AI systems an organisation uses and their EU AI Act classification (landscape-ai-system-inventory). 2.5.2: the connection schema (0.3.3) asked its national provision picker for gemmaType Buitengemeentenlijke voorziening, a spelling the GEMMA model does not use (it says Buitengemeentelijke voorziening), so the picker found nothing; its name template named the keys gegevensuitwisselingRichting and buitengemeentelijkVoorziening and the values AnaarB, BnaarA and bi-directioneel, all renamed since, so a connection had no readable name; and type, status and dataExchangeDirection become facetable for the new Connections page (connections-catalogue-pages). 2.5.1: the x-openregister-lifecycle blocks of usage, catalogContract, connection and moduleVersion still named the Dutch states (Verwerving/Gepland/In productie/Uit te faseren/Uitgefaseerd, In onderhandeling/Actief/Verlopen, in ontwikkeling/in gebruik/einde ondersteuning/teruggetrokken) while their status enums and the rows RenameDutchCatalogValues migrated are English, so no transition was ever offered on those records (stackiq#1140). The states now use the enum values, and the four schema versions are bumped (usage 1.5.1, catalogContract 0.1.2, connection 0.3.2, moduleVersion 0.1.5) because a lifecycle-only edit does not deploy without one, as 2.4.4 records. 2.4.4: organization.status was left behind by #520's enum translation — its `default` was still 'Concept' and its whole x-openregister-lifecycle block still named Concept/Actief/Deactief, while the enum and the migrated rows are Draft/Active/Inactive/merged. A default outside its own enum makes every newly created organisation fall out of the Organisations index filter, and a lifecycle whose from/to values match no row offers no transition at all — neither raises an error. The schema version is bumped with it because a deployed version >= the declared one makes the import SKIP, and OpenRegister's schemaContentDiffers() escape hatch compares only properties/required/authorization — never `configuration` — so a lifecycle-only edit would never have deployed. 2.4.3: Re-authored Dutch schema-level titles to English (dienst, kwetsbaarheid, contactpersoon, organisatie, gebruik, koppeling, beoordeeling, module, bioMaatregel, moduleVersie, sbomComponent); schema keys unchanged, Dutch labels now come from the app's l10n translation files. 2.4.2: Moved SBOM provenance properties (sbomLastImportedAt, sbomFormat, sbomFileName, sbomComponents) from the organisatie schema to moduleVersie, where SBOM imports actually record them; without this the moduleVersie magic table lacked the columns so recordProvenance() writes were silently dropped and the import-status endpoint always reported 'never imported'. 2.4.1: Re-authored Dutch schema property titles to English (property keys unchanged); Dutch labels now come from the app's l10n translation files." }, "x-openregister": { "type": "application", @@ -2657,7 +2657,7 @@ "slug": "usage", "title": "Usage", "description": "Het gebruik van applicaties, diensten en koppelingen door afnemers", - "version": "1.5.1", + "version": "1.5.2", "omschrijving": "", "icon": "Gauge", "x-openregister-notifications": { @@ -2716,6 +2716,8 @@ "title": "Contact person", "order": 3 }, + "businessOwner": {"type": "object", "title": "Business owner", "description": "The person in the organisation who is responsible for how the application is used.", "facetable": false, "objectConfiguration": {"handling": "related-object"}, "$ref": "#/components/schemas/contactPerson", "x-relation-filter": {"organization": "@object.consumer"}, "order": 18}, + "technicalOwner": {"type": "object", "title": "Technical owner", "description": "The person in the organisation who is responsible for running and maintaining the application.", "facetable": false, "objectConfiguration": {"handling": "related-object"}, "$ref": "#/components/schemas/contactPerson", "x-relation-filter": {"organization": "@object.consumer"}, "order": 19}, "participants": { "description": "De organisaties die deelnemen aan dit gebruik (voor samenwerkingen)", "type": "array", @@ -2895,7 +2897,7 @@ "To be phased out", "Phased out" ], - "facetable": false, + "facetable": true, "title": "Status", "example": "Bijvoorbeeld: Gepland" }, @@ -3200,7 +3202,7 @@ ] }, "configuration": { - "objectNameField": "consumer", + "objectNameField": "{{ module }} ({{ consumer }})", "objectDescriptionField": "module", "allowFiles": true, "allowedTags": [ @@ -8379,7 +8381,7 @@ "version": "0.0.1" }, "name": "Topdesk bij Servicecenter Rijnland (SSC)", - "status": "in-gebruik", + "status": "In production", "elementRef": "topdesk-ssc-rijnland", "consumer": { "name": "Servicecenter Rijnland", @@ -8408,7 +8410,7 @@ "version": "0.0.1" }, "name": "KEY2 Burgerzaken gedeeld via GBLT", - "status": "in-gebruik", + "status": "Planned", "elementRef": "key2-burgerzaken-gblt", "consumer": { "name": "Gemeentebelastingen Coevorden Hardenberg (GBLT)", @@ -8432,7 +8434,7 @@ "version": "0.0.1" }, "name": "Suite4 Schuldhulpverlening - eigenaar Gemeente Delft", - "status": "in-gebruik", + "status": "In production", "elementRef": "suite4-schuldhulp-delft", "consumer": { "name": "Gemeente Delft", diff --git a/lib/Settings/stackiq_mock_register.json b/lib/Settings/stackiq_mock_register.json index 3d2ab16c..d39fdf97 100644 --- a/lib/Settings/stackiq_mock_register.json +++ b/lib/Settings/stackiq_mock_register.json @@ -9076,6 +9076,8 @@ "consumer": {}, "provider": {}, "contactPerson": {}, + "businessOwner": {}, + "technicalOwner": {}, "participants": [ {} ], @@ -10645,6 +10647,8 @@ "consumer": {}, "provider": {}, "contactPerson": {}, + "businessOwner": {}, + "technicalOwner": {}, "participants": [ {} ], diff --git a/openspec/changes/landscape-usage-registration/.openspec.yaml b/openspec/changes/archive/2026-09-29-landscape-usage-registration/.openspec.yaml similarity index 100% rename from openspec/changes/landscape-usage-registration/.openspec.yaml rename to openspec/changes/archive/2026-09-29-landscape-usage-registration/.openspec.yaml diff --git a/openspec/changes/landscape-usage-registration/design.md b/openspec/changes/archive/2026-09-29-landscape-usage-registration/design.md similarity index 71% rename from openspec/changes/landscape-usage-registration/design.md rename to openspec/changes/archive/2026-09-29-landscape-usage-registration/design.md index e3692eb9..1cbac120 100644 --- a/openspec/changes/landscape-usage-registration/design.md +++ b/openspec/changes/archive/2026-09-29-landscape-usage-registration/design.md @@ -18,7 +18,9 @@ A usage (`gebruik`) is an organisation's use of an application: `consumer` (the ## D2. "Add to our landscape" -`ModuleDetail` gets a header action "Add to our landscape" that opens the library's create form for `usage` with `module` set to the page's object and `consumer` set to the active organisation. The action shows only when the user may create a usage. It mirrors the GEMMA Softwarecatalogus "+" behind a package (the row's evidence). +`ModuleDetail`'s usages list (`md-usages`) offers its create button labelled "Add to our landscape" (`addLabel`). The list is filtered on `module: @objectId`, and the library's `CnObjectListWidget.onCreateConfirm` merges that filter value into the new row, so the usage is created with the application filled in. The form asks consumer, version, status and both owners (`formIncludeFields`). It mirrors the GEMMA Softwarecatalogus "+" behind a package (the row's evidence). + +Changed at build (29 Sep, development `f280e807`): the design first said a header action with `consumer` set to the active organisation. No manifest token names the active organisation (`resolveFilterTokens` knows `@objectId`, `@object.*`, `@workspace.*`, `@config.*`, `@me` and dates; `CnFormDialog._autofillTenant` fills only a field called `organisation`), so the user picks the organisation in the form. Filling it needs an `@organisation` token in nextcloud-vue. Rejected: a wizard. The usage form has five fields a user must decide on; a dialog is enough, and `CnFormDialog` already renders the schema. @@ -37,6 +39,8 @@ Rejected: owner fields on `module`. A module is the supplier's product; the busi ## D4. Register fixes +Items 1 and 3 landed before this change was built, in register 2.5.1 (stackiq#1140: usage 1.5.1 with the lifecycle on the enum values). Item 2 and the owners went into the register itself (2.5.4, usage 1.5.2), not into a `register.d` fragment as D3 says: the relation-dialect gate reads a fragment on its own and cannot see `consumer`, the field the owners' `x-relation-filter` names. It also makes `status` facetable for the list's status filters. The seeded usages in the register held `in-gebruik`, a value outside the enum; they now read In production and Planned. + In `lib/Settings/softwarecatalogus_register.json`, schema `usage`: 1. `x-openregister-lifecycle` on the enum values: initial `Acquisition`, final `Phased out`, transitions plan (Acquisition to Planned), goLive (Planned to In production), phaseOut (In production to To be phased out), retire (To be phased out to Phased out). The rows already hold these (`lib/Repair/RenameDutchCatalogValues.php:80-84`). diff --git a/openspec/changes/landscape-usage-registration/proposal.md b/openspec/changes/archive/2026-09-29-landscape-usage-registration/proposal.md similarity index 100% rename from openspec/changes/landscape-usage-registration/proposal.md rename to openspec/changes/archive/2026-09-29-landscape-usage-registration/proposal.md diff --git a/openspec/changes/landscape-usage-registration/specs/application-usage-pages/spec.md b/openspec/changes/archive/2026-09-29-landscape-usage-registration/specs/application-usage-pages/spec.md similarity index 96% rename from openspec/changes/landscape-usage-registration/specs/application-usage-pages/spec.md rename to openspec/changes/archive/2026-09-29-landscape-usage-registration/specs/application-usage-pages/spec.md index 37b71c2e..54d56cc0 100644 --- a/openspec/changes/landscape-usage-registration/specs/application-usage-pages/spec.md +++ b/openspec/changes/archive/2026-09-29-landscape-usage-registration/specs/application-usage-pages/spec.md @@ -24,7 +24,7 @@ Stackiq SHALL offer a page "Applications in use" at `/gebruik` over the `usage` ### Requirement: REQ-UAP-002 An organisation adds an application to its landscape from the application page -The application page SHALL offer "Add to our landscape" to a user who may create a usage. It SHALL open the usage form with the application and the user's active organisation filled in, and the version picker SHALL offer only versions of that application. +The application page SHALL offer "Add to our landscape" to a user who may create a usage. It SHALL open the usage form with the application filled in, the user SHALL pick the organisation, and the version picker SHALL offer only versions of that application. #### Scenario: Adding an application with its version @e2e tests/e2e/workflows/usages.spec.ts diff --git a/openspec/changes/landscape-usage-registration/tasks.md b/openspec/changes/archive/2026-09-29-landscape-usage-registration/tasks.md similarity index 84% rename from openspec/changes/landscape-usage-registration/tasks.md rename to openspec/changes/archive/2026-09-29-landscape-usage-registration/tasks.md index 532ad218..57552095 100644 --- a/openspec/changes/landscape-usage-registration/tasks.md +++ b/openspec/changes/archive/2026-09-29-landscape-usage-registration/tasks.md @@ -8,8 +8,8 @@ - **acceptance_criteria**: - GIVEN the merged register WHEN a usage in Planned is opened THEN Go live is offered - GIVEN a usage WHEN its business owner field opens THEN it lists contact persons of the consumer organisation only -- [ ] Implement -- [ ] Test (PHPUnit `tests/Unit/Settings/UsageSchemaTest.php`: lifecycle states are enum values, owner filters, name template keys exist) +- [x] Implement +- [x] Test (PHPUnit `tests/Unit/Settings/UsageSchemaTest.php`: lifecycle states are enum values, owner filters, name template keys exist) ### Task 2: Usage index and detail pages - **spec_ref**: openspec/changes/landscape-usage-registration/specs/application-usage-pages/spec.md#requirement-req-uap-001-an-organisation-records-and-browses-the-applications-it-uses @@ -17,24 +17,24 @@ - **acceptance_criteria**: - GIVEN two usages of the organisation WHEN the user opens Applications in use THEN both show with version and status - GIVEN a usage row WHEN the user opens it THEN the detail page shows version, status and owners -- [ ] Implement -- [ ] Test (Playwright `tests/e2e/workflows/usages.spec.ts`) +- [x] Implement +- [x] Test (Playwright `tests/e2e/workflows/usages.spec.ts`; vitest `tests/vitest/usages.spec.js` asserts the pages; the Playwright file lists 4 tests and was not run, no seeded instance) ### Task 3: Add to our landscape - **spec_ref**: openspec/changes/landscape-usage-registration/specs/application-usage-pages/spec.md#requirement-req-uap-002-an-organisation-adds-an-application-to-its-landscape-from-the-application-page - **files**: `src/manifest.json` (ModuleDetail header action), `src/customComponents.js` if the action needs a handler - **acceptance_criteria**: - GIVEN application X WHEN an information manager clicks Add to our landscape and saves version 2.1 THEN a usage of X by their organisation exists with version 2.1 -- [ ] Implement -- [ ] Test (Playwright `tests/e2e/workflows/usages.spec.ts`, add case) +- [x] Implement +- [x] Test (Playwright `tests/e2e/workflows/usages.spec.ts`, add case) ### Task 4: Documentation - **spec_ref**: openspec/changes/landscape-usage-registration/specs/application-usage-pages/spec.md#requirement-req-uap-004-a-usage-moves-through-its-lifecycle-from-its-page - **files**: `docs/features/applications-in-use.md`, `docs/images/applications-in-use.png` - **acceptance_criteria**: - GIVEN the docs site WHEN a reader opens Applications in use THEN adding, the lifecycle and the owners are explained with a screenshot -- [ ] Implement -- [ ] Test (docs build, screenshot with Playwright) +- [x] Implement +- [ ] Test (docs build, screenshot with Playwright): the page is written; the screenshot waits for a seeded instance ## Verification diff --git a/openspec/parity/capabilities.json b/openspec/parity/capabilities.json index 7abc3c71..c5f34ca5 100644 --- a/openspec/parity/capabilities.json +++ b/openspec/parity/capabilities.json @@ -364,25 +364,26 @@ "bluedolphin": "yes", "glpi": "yes", "topdesk": "partial", - "stackiq": "partial", + "stackiq": "yes", "built": { - "state": "specified", - "evidence": "src/manifest.json:592 Modules page (FacetedCatalogIndexView, schema module) with the library CnIndexPage create form at src/views/FacetedCatalogIndexView.vue:108; lib/Settings/softwarecatalogus_register.json:6777 module schema has name, shortDescription/longDescription and provider (Supplier) but NO status property; status lives on usage (register.json:2654, enum Acquisition..In production) which has no page", - "owner": "ConductionNL/stackiq" + "state": "built", + "evidence": "usage schema (consumer, module, moduleVersion, status with lifecycle plan/goLive/phaseOut/retire) in lib/Settings/softwarecatalogus_register.json; src/manifest.d/usages.json pages Gebruik (/gebruik, Applications in use) and GebruikDetail with lifecycleActions; ModuleDetail md-usages Add to our landscape; OrganisatieDetail org-usages; tests/Unit/Settings/UsageSchemaTest.php, tests/vitest/usages.spec.js", + "owner": "ConductionNL/stackiq", + "change": "2026-09-29-landscape-usage-registration" }, - "reachedOn": "Modules /modules (menu Applications), Add button", + "reachedOn": "Applications > Applications in use (/gebruik), Add to our landscape on the application page, usage page", "provider": "stackiq", "providerHow": "read-from-code", "feature": "software-landscape-register", "featureConfidence": "high", - "note": "An application with supplier and description can be registered on the Modules page, but the module schema has no status field, and the per-organisation usage that carries a status has no page to create it on. The Modules list also cannot open ModuleDetail: its standalone CnIndexPage (FacetedCatalogIndexView.vue:108-117) binds no @view/@row-click, so the View action is inert. Specified in openspec/changes/landscape-usage-registration (OpenSpec pass 2026-09-27).", + "note": "An application with supplier and description can be registered on the Modules page, but the module schema has no status field, and the per-organisation usage that carries a status has no page to create it on. The Modules list also cannot open ModuleDetail: its standalone CnIndexPage (FacetedCatalogIndexView.vue:108-117) binds no @view/@row-click, so the View action is inert. Specified in openspec/changes/landscape-usage-registration (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-29-landscape-usage-registration.", "evidence": { "vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/30355: \"klik dan op de knop + achter de beschrijving van het pakket om het pakket toe te voegen aan je omgeving ... Pakketversie ... Referentiecomponenten ... Vul onder Planning bij Status in gebruik in\" (read 2026-09-26); https://www.softwarecatalogus.nl/hoe-werkt-de-catalogus: \"Wanneer Gemeenten en samenwerkingen hun applicatielandschap hebben ingevoerd, wordt deze automatisch geplot op de GEMMA referentiecomponentenkaart\" (read 2026-09-26). Reached on: Mijn softwarecatalogus > Pakketten > Voeg pakket toe.", "sap-leanix": "https://help.sap.com/docs/leanix/ea/application-modeling-guidelines: 'Applications are software systems or programs that process or analyze business data'; application fact sheet with description and lifecycle, supplier via 'provider -> IT component -> application relation' (https://help.sap.com/docs/leanix/ea/provider-modeling-guidelines) (read 2026-09-26). Reached on: Inventory > Application fact sheet.", "bluedolphin": "https://help.bluedolphin.io/en/articles/11967529-welcome-to-the-objects: 'a centralized space for managing architectural objects ... create, edit, delete'; https://help.bluedolphin.io/en/articles/11967745-update-an-object-definition shows an object definition 'New Application' with property 'Supplier'; status as lifecycle state (https://help.bluedolphin.io/en/articles/11967531-object-lifecycle-state) (read 2026-09-26). Reached on: Objects > Application Component object.", "glpi": "source read at 11.0.9: src/Appliance.php:46 class Appliance is GLPI's application itemtype; install/mysql/glpi-empty.sql:8935 glpi_appliances carries name, comment (description), manufacturers_id and states_id (status); src/Appliance.php:350 search option Status; supplier through the Management tab Infocom (install/mysql/glpi-empty.sql:3263 glpi_infocoms.suppliers_id) and Contract_Item (src/Appliance.php:99); menu src/Html.php:1300 lists Appliance under Management, served by src/Glpi/Kernel/Listener/RequestListener/LegacyItemtypeRouteListener.php:100. Reached on: Management > Appliances (front/appliance.php). Driven on the lab at 11.0.9 (2026-09-26): created the appliance \"Zaaksysteem lab\" with a description through /front/appliance.form.php; it appears in the Appliances list and CSV export.", "topdesk": "https://docs.topdesk.com/en/migrating-objects-to-asset-management.html: \"In the new Asset Management you design your own template for each type of asset you have\" (read 2026-09-26); https://docs.topdesk.com/en/managing-licences-in-asset-management.html: \"Create a new template for software cards\" (read 2026-09-26). Applications are a self-designed asset type, no application model ships. Reached on: Modules > Asset Management > Template Designer / Asset overview > New.", - "stackiq": "src/manifest.json:592 Modules page (FacetedCatalogIndexView, schema module) with the library CnIndexPage create form at src/views/FacetedCatalogIndexView.vue:108; lib/Settings/softwarecatalogus_register.json:6777 module schema has name, shortDescription/longDescription and provider (Supplier) but NO status property; status lives on usage (register.json:2654, enum Acquisition..In production) which has no page" + "stackiq": "usage schema (consumer, module, moduleVersion, status with lifecycle plan/goLive/phaseOut/retire) in lib/Settings/softwarecatalogus_register.json; src/manifest.d/usages.json pages Gebruik (/gebruik, Applications in use) and GebruikDetail with lifecycleActions; ModuleDetail md-usages Add to our landscape; OrganisatieDetail org-usages; tests/Unit/Settings/UsageSchemaTest.php, tests/vitest/usages.spec.js" } }, { @@ -548,21 +549,22 @@ "bluedolphin": "partial", "glpi": "yes", "topdesk": "partial", - "stackiq": "partial", + "stackiq": "yes", "built": { - "state": "specified", - "evidence": "register.json:6856 module.contactPerson is a single related contactPerson; register.json:1786 contactPerson has free-text role (job title) and a roles enum of catalogue roles (Aanbod-beheerder, Gebruik-beheerder, ...), no business/technical owner distinction; shown on ModuleDetail md-data (src/manifest.json:500 lists the stale key 'contactpersoon', not 'contactPerson')", - "owner": "ConductionNL/stackiq" + "state": "built", + "evidence": "lib/Settings/register.d/usage-owners.json usage.businessOwner and usage.technicalOwner ($ref contactPerson, x-relation-filter organization @object.consumer); columns on Gebruik and fields on GebruikDetail; tests/Unit/Settings/UsageSchemaTest.php", + "owner": "ConductionNL/stackiq", + "change": "2026-09-29-landscape-usage-registration" }, - "reachedOn": "Modules /modules create/edit form (Contact person field)", + "reachedOn": "usage page (/gebruik/:id) and the Applications in use list", "provider": "stackiq", "providerHow": "read-from-code", "feature": "software-landscape-register", "featureConfidence": "low", - "note": "One contact person per application can be set, but there is no separate business owner and technical owner. ModuleDetail's data widget includes 'contactpersoon', a key the schema no longer has, so the contact may not show there. Specified in openspec/changes/landscape-usage-registration (OpenSpec pass 2026-09-27).", + "note": "One contact person per application can be set, but there is no separate business owner and technical owner. ModuleDetail's data widget includes 'contactpersoon', a key the schema no longer has, so the contact may not show there. Specified in openspec/changes/landscape-usage-registration (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-29-landscape-usage-registration.", "evidence": { "sap-leanix": "https://help.sap.com/docs/leanix/ea/subscription-roles: 'Define roles that map to your organization's positions, such as application owner', with subscription types 'Responsible, Accountable, Observer' per fact sheet (read 2026-09-26). Reached on: Fact sheet > Subscriptions; Administration > Subscription Roles.", - "stackiq": "register.json:6856 module.contactPerson is a single related contactPerson; register.json:1786 contactPerson has free-text role (job title) and a roles enum of catalogue roles (Aanbod-beheerder, Gebruik-beheerder, ...), no business/technical owner distinction; shown on ModuleDetail md-data (src/manifest.json:500 lists the stale key 'contactpersoon', not 'contactPerson')", + "stackiq": "lib/Settings/register.d/usage-owners.json usage.businessOwner and usage.technicalOwner ($ref contactPerson, x-relation-filter organization @object.consumer); columns on Gebruik and fields on GebruikDetail; tests/Unit/Settings/UsageSchemaTest.php", "topdesk": "https://docs.topdesk.com/en/designing-templates-for-assets.html: \"Assignment widget : assigns locations and persons to the asset\" (read 2026-09-26). No separate business and technical owner roles are described. Reached on: Asset card > Assignment widget.", "vng-softwarecatalogus": "unknown: the landscape entry fields listed (pakketversie, referentiecomponenten, technologie, status) include no business or technical owner; searched https://www.softwarecatalogus.nl/node/16564, https://www.softwarecatalogus.nl/node/13683, https://www.softwarecatalogus.nl/node/19703, https://www.softwarecatalogus.nl/Gebruikershandleiding_leverancier, https://www.softwarecatalogus.nl/node/30355 (read 2026-09-26)", "bluedolphin": "https://help.bluedolphin.io/en/articles/11967633-datacollector-select-tricks: an example import maps '[Application Owner]' into object properties; ownership otherwise is modeled as questionnaire fields or ArchiMate relations. No built in business and technical owner fields are documented (read 2026-09-26). Reached on: Object properties or questionnaire fields.", @@ -2271,20 +2273,21 @@ "bluedolphin": "unknown", "glpi": "yes", "topdesk": "unknown", - "stackiq": "partial", + "stackiq": "yes", "built": { - "state": "specified", - "evidence": "lib/Settings/softwarecatalogus_register.json usage.moduleVersion ($ref moduleVersion); read by src/views/LifecycleRoadmapView.vue:397 for EOL state; ModuleversieDetail mv-related shows related usages; no usage create/edit page in src/manifest.json", - "owner": "ConductionNL/stackiq" + "state": "built", + "evidence": "usage.moduleVersion (x-relation-filter module) set on the usage form from Add to our landscape and on GebruikDetail (src/manifest.d/usages.json); shown in the Applications in use list; tests/vitest/usages.spec.js", + "owner": "ConductionNL/stackiq", + "change": "2026-09-29-landscape-usage-registration" }, - "reachedOn": "used on LifecycleRoadmap /portfolio-roadmap and ModuleversieDetail /moduleversies/:id; nothing in stackiq records it", + "reachedOn": "Applications > Applications in use (/gebruik) and the usage page", "provider": "stackiq", "providerHow": "read-from-code", "feature": "lifecycle-and-end-of-support", "featureConfidence": "low", - "note": "The version an organisation runs is a field on its usage and drives the EOL badges, but no stackiq page lets the organisation set or change it. Specified in openspec/changes/landscape-usage-registration (OpenSpec pass 2026-09-27).", + "note": "The version an organisation runs is a field on its usage and drives the EOL badges, but no stackiq page lets the organisation set or change it. Specified in openspec/changes/landscape-usage-registration (OpenSpec pass 2026-09-27). Built by openspec/changes/archive/2026-09-29-landscape-usage-registration.", "evidence": { - "stackiq": "lib/Settings/softwarecatalogus_register.json usage.moduleVersion ($ref moduleVersion); read by src/views/LifecycleRoadmapView.vue:397 for EOL state; ModuleversieDetail mv-related shows related usages; no usage create/edit page in src/manifest.json", + "stackiq": "usage.moduleVersion (x-relation-filter module) set on the usage form from Add to our landscape and on GebruikDetail (src/manifest.d/usages.json); shown in the Applications in use list; tests/vitest/usages.spec.js", "topdesk": "unknown: versions in use are only possible as a self-defined field; searched the full-text search index of docs.topdesk.com (https://docs.topdesk.com/en/js/fuzzydata.js, 987 pages) (read 2026-09-26)", "vng-softwarecatalogus": "https://www.softwarecatalogus.nl/node/30355: \"Pakketversie - selecteer de versie die in gebruik is\" (read 2026-09-26). Reached on: Mijn softwarecatalogus > Pakketten > toevoegen.", "bluedolphin": "unknown: docs searched at https://help.bluedolphin.io/en/; recording the version an organisation runs is not documented beyond configurable fields (read 2026-09-26)", diff --git a/openspec/specs/application-usage-pages/spec.md b/openspec/specs/application-usage-pages/spec.md new file mode 100644 index 00000000..8804ce59 --- /dev/null +++ b/openspec/specs/application-usage-pages/spec.md @@ -0,0 +1,58 @@ +# application-usage-pages Specification + +## Purpose +An organisation records the applications it uses, with the version it runs, its lifecycle status and its owners. Matrix rows `stackiq:land-register-application`, `stackiq:life-version-in-use` and `stackiq:land-application-owner`. + +## Requirements + +### Requirement: REQ-UAP-001 An organisation records and browses the applications it uses + +Stackiq SHALL offer a page "Applications in use" at `/gebruik` over the `usage` schema that lists the usages the user may read, with application, version, status and owners, and a detail page at `/gebruik/:id` where the user edits them. The organisation page SHALL list the organisation's usages. + +#### Scenario: An information manager lists the organisation's applications +@e2e tests/e2e/workflows/usages.spec.ts + +- **GIVEN** the municipality uses application X at version 2.1 in production and application Y as planned +- **WHEN** its information manager opens Applications, then Applications in use +- **THEN** the list shows X with version 2.1 and status In production, and Y with status Planned + +### Requirement: REQ-UAP-002 An organisation adds an application to its landscape from the application page + +The application page SHALL offer "Add to our landscape" to a user who may create a usage. It SHALL open the usage form with the application filled in, the user SHALL pick the organisation, and the version picker SHALL offer only versions of that application. + +#### Scenario: Adding an application with its version +@e2e tests/e2e/workflows/usages.spec.ts + +- **GIVEN** application X has versions 2.0 and 2.1 +- **WHEN** the information manager opens the page of X, clicks Add to our landscape, picks version 2.1 and saves +- **THEN** a usage of X by their municipality with version 2.1 exists +- **AND** it shows on Applications in use + +### Requirement: REQ-UAP-003 A usage names a business owner and a technical owner + +A usage SHALL carry a business owner and a technical owner, each picked from the contact persons of the using organisation. + +#### Scenario: Setting both owners +@e2e tests/e2e/workflows/usages.spec.ts + +- **GIVEN** the municipality has contact persons Anna and Bram +- **WHEN** the information manager edits its usage of X and sets business owner Anna and technical owner Bram +- **THEN** the usage page shows Anna as business owner and Bram as technical owner + +#### Scenario: A supplier cannot open the owners +@e2e exclude Read rule of the contact person schema; tests/Unit/Settings/SchemaRbacTest.php asserts a supplier reads only its own organisation's contact persons. + +- **GIVEN** a usage of the supplier's product with both owners set +- **WHEN** the supplier opens that usage +- **THEN** the owner contact persons do not open for the supplier + +### Requirement: REQ-UAP-004 A usage moves through its lifecycle from its page + +The usage schema SHALL declare its lifecycle on the status values its rows hold, so the detail page offers Plan, Go live, Phase out and Retire from the matching status. + +#### Scenario: Going live +@e2e tests/e2e/workflows/usages.spec.ts + +- **GIVEN** a usage with status Planned +- **WHEN** the information manager opens it and clicks Go live +- **THEN** its status reads In production diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json new file mode 100644 index 00000000..c44c634f --- /dev/null +++ b/src/manifest.d/usages.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json", + "_note": "landscape-usage-registration: the usage (gebruik) is an organisation's use of an application, with the version it runs, its lifecycle status and its business and technical owner. The data existed but no page created or edited it. The menu entry is a child of Applications (ADR-097: no new top-level entry). Status is an enum and facetable, so the quick filters and filterMenu work on it; the transitions come from the schema's x-openregister-lifecycle (plan, goLive, phaseOut, retire).", + "menu": [ + { + "id": "Modules", + "children": [ + { "id": "Gebruik", "label": "Applications in use", "icon": "OfficeBuilding", "route": "Gebruik", "order": 9 } + ] + } + ], + "pages": [ + { + "id": "Gebruik", + "route": "/gebruik", + "type": "index", + "title": "Applications in use", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "usage", + "description": "The applications your organisation uses, with the version it runs, where it stands and who owns it.", + "columns": ["module", "moduleVersion", "status", "businessOwner", "technicalOwner", "timeClassification"], + "filterMenu": true, + "quickFilters": [ + { "label": "All", "filter": {}, "default": true }, + { "label": "Acquisition", "filter": { "status": "Acquisition" } }, + { "label": "Planned", "filter": { "status": "Planned" } }, + { "label": "In production", "filter": { "status": "In production" }, "icon": "CheckCircle" }, + { "label": "To be phased out", "filter": { "status": "To be phased out" }, "icon": "AlertCircle" }, + { "label": "Phased out", "filter": { "status": "Phased out" } } + ], + "sidebar": { "enabled": true, "showMetadata": true }, + "documentationUrl": "https://stackiq.conduction.nl" + } + }, + { + "id": "GebruikDetail", + "route": "/gebruik/:id", + "type": "detail", + "title": "Application in use", + "config": { + "register": "@resolve:voorzieningen_register", + "schema": "usage", + "_note": "A usage is read for what runs where and who owns it: data 8 wide (application, organisation, version, status, owners, phase dates, cloud model, annotation), documents 4 wide at the right (DPIA, contract, processing agreement), then the related panel. Status transitions come from the schema's x-openregister-lifecycle.", + "lifecycleActions": { "field": "status" }, + "widgets": [ + { "id": "gb-data", "type": "data", "title": "Application in use", "icon": "OfficeBuilding", "content": { "columns": 2, "include": [ "module", "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner", "startDateAcquisition", "startDatePlanned", "startDateInProduction", "startDateOutPhasing", "startDateOutPhased", "cloudDienstverleningsmodel", "timeClassification", "interneAnnotation" ] } }, + { "id": "gb-files", "type": "integration", "integrationId": "files", "title": "Documents", "icon": "FolderOutline" }, + { "id": "gb-related", "type": "related", "title": "Connections and services", "icon": "LinkVariant" } + ], + "layout": [ + { "id": "1", "widgetId": "gb-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 8 }, + { "id": "2", "widgetId": "gb-files", "gridX": 8, "gridY": 0, "gridWidth": 4, "gridHeight": 4 }, + { "id": "3", "widgetId": "gb-related", "gridX": 8, "gridY": 4, "gridWidth": 4, "gridHeight": 4 } + ], + "sidebar": { + "enabled": true, + "showMetadata": true, + "tabs": [ + { "id": "audit", "label": "History", "icon": "History", "widgets": [ { "type": "audit" } ] } + ] + }, + "documentationUrl": "https://stackiq.conduction.nl" + } + } + ] +} diff --git a/src/manifest.json b/src/manifest.json index 5f5bd1cc..19a97a2b 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -415,6 +415,7 @@ { "id": "org-stats-contact-persons", "type": "stats-block", "title": "Contact persons", "icon": "ChartBar", "content": { "entries": [ { "title": "Contact persons", "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "metric": "count", "filter": { "organization": "@objectId" } } ] } }, { "id": "org-diensten", "type": "object-list", "title": "Services", "icon": "HandshakeOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "catalogService", "filter": { "provider": "@objectId" }, "columns": [ { "key": "type", "label": "Type" } ], "limit": 25, "emptyText": "No services registered for this organisation yet" } }, { "id": "org-modules", "type": "object-list", "title": "Applications", "icon": "Package", "content": { "register": "@resolve:voorzieningen_register", "schema": "module", "filter": { "provider": "@objectId" }, "columns": [ { "key": "licentietype", "label": "License type" }, { "key": "bbnLevel", "label": "BBN level" } ], "limit": 25, "rowRoute": "ModuleDetail", "emptyText": "No applications registered for this organisation yet" } }, + { "id": "org-usages", "type": "object-list", "title": "Applications in use", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "consumer": "@objectId" }, "columns": [ { "key": "module", "label": "Application" }, { "key": "moduleVersion", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "consumer": "@objectId" }, "allowCreate": false, "emptyText": "No applications in use recorded for this organisation yet" } }, { "id": "org-contactpersonen", "type": "object-list", "title": "Contact persons", "icon": "AccountGroup", "content": { "register": "@resolve:voorzieningen_register", "schema": "contactPerson", "filter": { "organization": "@objectId" }, "columns": [ { "key": "role", "label": "Function" }, { "key": "roles", "label": "Roles" } ], "limit": 25, "rowRoute": "ContactpersoonDetail", "emptyText": "No contact persons linked to this organisation yet" } } ], "layout": [ @@ -424,7 +425,8 @@ { "id": "8", "widgetId": "org-stats-contact-persons", "gridX": 8, "gridY": 4, "gridWidth": 4, "gridHeight": 2 }, { "id": "3", "widgetId": "org-diensten", "gridX": 0, "gridY": 6, "gridWidth": 6, "gridHeight": 4 }, { "id": "4", "widgetId": "org-modules", "gridX": 6, "gridY": 6, "gridWidth": 6, "gridHeight": 4 }, - { "id": "5", "widgetId": "org-contactpersonen", "gridX": 0, "gridY": 10, "gridWidth": 12, "gridHeight": 4 } + { "id": "5", "widgetId": "org-contactpersonen", "gridX": 0, "gridY": 10, "gridWidth": 12, "gridHeight": 4 }, + { "id": "9", "widgetId": "org-usages", "gridX": 0, "gridY": 14, "gridWidth": 12, "gridHeight": 4 } ], "bodyWidgets": [ { "id": "org-merge", "component": "OrganisationMergePanel", "props": { "objectId": "@objectId" }, "placement": "end", "colSpan": 12 } @@ -502,7 +504,7 @@ { "id": "md-related", "type": "related", "title": "Vendor & services", "icon": "LinkVariant" }, { "id": "md-compliance", "type": "object-list", "title": "Compliance claims", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "module": "@objectId" }, "columns": [ { "key": "standardVersion", "label": "Standard" }, { "key": "bioMeasure", "label": "BIO measure" }, { "key": "url", "label": "Evidence" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } }, { "id": "md-versions", "type": "object-list", "title": "Application versions", "icon": "SourceBranch", "content": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "filter": { "module": "@objectId" }, "columns": [ { "key": "version", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "ModuleversieDetail", "allowCreate": false, "emptyText": "No versions registered yet" } }, - { "id": "md-usages", "type": "object-list", "title": "Usages", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "module": "@objectId" }, "columns": [ { "key": "consumer", "label": "Organisation" }, { "key": "moduleVersion", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 50, "allowCreate": false, "emptyText": "No organisation registered a usage yet" } }, + { "id": "md-usages", "type": "object-list", "title": "Usages", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "module": "@objectId" }, "columns": [ { "key": "consumer", "label": "Organisation" }, { "key": "moduleVersion", "label": "Version" }, { "key": "status", "label": "Status" } ], "limit": 50, "rowRoute": "GebruikDetail", "viewAllRoute": "Gebruik", "viewAllQuery": { "module": "@objectId" }, "addLabel": "Add to our landscape", "formIncludeFields": [ "consumer", "moduleVersion", "status", "businessOwner", "technicalOwner" ], "emptyText": "No organisation registered a usage yet" } }, { "id": "md-connections-out", "type": "object-list", "title": "Connections from this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleA": "@objectId" }, "columns": [ { "key": "moduleB", "label": "To application" }, { "key": "nonMunicipalProvision", "label": "To national provision" }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleA": "@objectId" }, "allowCreate": false, "emptyText": "No connections start at this application" } }, { "id": "md-connections-in", "type": "object-list", "title": "Connections to this application", "icon": "LinkVariant", "content": { "register": "@resolve:voorzieningen_register", "schema": "connection", "filter": { "moduleB": "@objectId" }, "columns": [ { "key": "moduleA", "label": "From application" }, { "key": "type", "label": "Type" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "KoppelingDetail", "viewAllRoute": "Koppelingen", "viewAllQuery": { "moduleB": "@objectId" }, "allowCreate": false, "emptyText": "No connections end at this application" } }, { "id": "md-ai-systems", "type": "object-list", "title": "AI systems", "icon": "RobotOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "aiSystem", "filter": { "module": "@objectId" }, "columns": [ { "key": "kind", "label": "Kind" }, { "key": "aiActRiskCategory", "label": "AI Act risk category" }, { "key": "status", "label": "Status" } ], "limit": 25, "rowRoute": "AiSystemDetail", "viewAllRoute": "AiSystems", "viewAllQuery": { "module": "@objectId" }, "allowCreate": false, "emptyText": "No AI systems registered for this application" } } diff --git a/tests/Unit/Settings/UsageSchemaTest.php b/tests/Unit/Settings/UsageSchemaTest.php new file mode 100644 index 00000000..246334fc --- /dev/null +++ b/tests/Unit/Settings/UsageSchemaTest.php @@ -0,0 +1,167 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * @version GIT: + * @link https://github.com/ConductionNL/stackiq + * + * @spec openspec/specs/application-usage-pages/spec.md + */ + +declare(strict_types=1); + +namespace OCA\Stackiq\Tests\Unit\Settings; + +use PHPUnit\Framework\TestCase; + +/** + * Asserts the owner fields, the lifecycle, the name template and the seeds of + * the usage schema. + */ +class UsageSchemaTest extends TestCase { + + /** + * The merged register. + * + * @return array + */ + private function register(): array { + return json_decode((string) file_get_contents(__DIR__ . '/../../../lib/Settings/softwarecatalogus_register.json'), true); + }//end register() + + /** + * The merged usage schema. + * + * @return array + */ + private function usage(): array { + return $this->register()['components']['schemas']['usage']; + }//end usage() + + /** + * Both owners point at a contact person of the organisation that uses the application. + * + * @return void + */ + public function testBothOwnersAreContactPersonsOfTheConsumer(): void { + $usage = $this->usage(); + foreach (['businessOwner', 'technicalOwner'] as $field) { + $prop = $usage['properties'][$field]; + $this->assertSame('#/components/schemas/contactPerson', $prop['$ref'], $field); + $this->assertSame('related-object', $prop['objectConfiguration']['handling'], $field); + $this->assertSame(['organization' => '@object.consumer'], $prop['x-relation-filter'], $field); + } + + $this->assertArrayHasKey('organization', $this->register()['components']['schemas']['contactPerson']['properties']); + $this->assertSame([], array_values(array_intersect(['businessOwner', 'technicalOwner'], ($usage['required'] ?? [])))); + }//end testBothOwnersAreContactPersonsOfTheConsumer() + + /** + * The owners join the usage and every property it had stays. + * + * @return void + */ + public function testTheExistingPropertiesStay(): void { + $props = $this->usage()['properties']; + foreach (['consumer', 'module', 'moduleVersion', 'status', 'contactPerson', 'timeClassification'] as $field) { + $this->assertArrayHasKey($field, $props, $field); + } + + $this->assertSame(['Acquisition', 'Planned', 'In production', 'To be phased out', 'Phased out'], $props['status']['enum']); + $this->assertTrue($props['status']['facetable']); + }//end testTheExistingPropertiesStay() + + /** + * Plan, Go live, Phase out and Retire name only states the status enum holds. + * + * @return void + */ + public function testTheLifecycleNamesTheEnumValues(): void { + $usage = $this->usage(); + $lifecycle = $usage['configuration']['x-openregister-lifecycle']; + $enum = $usage['properties']['status']['enum']; + + $this->assertSame('status', $lifecycle['field']); + $this->assertContains($lifecycle['initial'], $enum); + $this->assertSame(['plan', 'goLive', 'phaseOut', 'retire'], array_keys($lifecycle['transitions'])); + foreach ($lifecycle['transitions'] as $name => $transition) { + $this->assertContains($transition['to'], $enum, $name); + foreach ($transition['from'] as $from) { + $this->assertContains($from, $enum, $name); + } + } + + $this->assertSame(['Planned'], $lifecycle['transitions']['goLive']['from']); + $this->assertSame('In production', $lifecycle['transitions']['goLive']['to']); + }//end testTheLifecycleNamesTheEnumValues() + + /** + * A usage is named after its application and organisation, from keys the schema has. + * + * @return void + */ + public function testTheNameTemplateReadsExistingKeys(): void { + $usage = $this->usage(); + $template = $usage['configuration']['objectNameField']; + + $this->assertSame('{{ module }} ({{ consumer }})', $template); + preg_match_all('/{{\s*([A-Za-z]+)/', $template, $keys); + foreach ($keys[1] as $key) { + $this->assertArrayHasKey($key, $usage['properties'], $key); + } + }//end testTheNameTemplateReadsExistingKeys() + + /** + * The schema version moves up, or the import skips the change. + * + * @return void + */ + public function testTheSchemaVersionMovesUp(): void { + $this->assertTrue(version_compare($this->usage()['version'], '1.5.1', '>')); + }//end testTheSchemaVersionMovesUp() + + /** + * A supplier reads contact persons of its own organisation only, so it cannot open the owners of a customer. + * + * @return void + */ + public function testASupplierCannotOpenTheOwnersOfACustomer(): void { + $read = $this->register()['components']['schemas']['contactPerson']['authorization']['read']; + $supplier = array_values( + array_filter( + $read, + static fn ($rule): bool => $rule === 'aanbod-beheerder' || (is_array($rule) === true && ($rule['group'] ?? '') === 'aanbod-beheerder') + ) + ); + + $this->assertSame([['group' => 'aanbod-beheerder', 'match' => ['_organisation' => '$organisation']]], $supplier); + }//end testASupplierCannotOpenTheOwnersOfACustomer() + + /** + * The seeded usages carry a status the enum holds, one of them planned. + * + * @return void + */ + public function testTheSeededUsagesCarryEnumStatuses(): void { + $register = $this->register(); + $enum = $register['components']['schemas']['usage']['properties']['status']['enum']; + $statuses = []; + foreach ($register['components']['objects'] as $object) { + if (($object['@self']['schema'] ?? '') !== 'usage') { + continue; + } + + $this->assertContains($object['status'], $enum, $object['@self']['slug']); + $statuses[] = $object['status']; + } + + $this->assertContains('In production', $statuses); + $this->assertContains('Planned', $statuses); + }//end testTheSeededUsagesCarryEnumStatuses() +}//end class diff --git a/tests/e2e/workflows/usages.spec.ts b/tests/e2e/workflows/usages.spec.ts new file mode 100644 index 00000000..320ba9ac --- /dev/null +++ b/tests/e2e/workflows/usages.spec.ts @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: EUPL-1.2 +// SPDX-FileCopyrightText: 2026 Conduction B.V. +/** + * Applications in use: the list with version and status, adding an + * application from its page, both owners on the usage page, and going live. + * + * Seeds an organisation, two contact persons, two applications with versions + * and two usages carrying this run's RUN_ID through the objects API (the call + * the Add form makes), and removes exactly those rows afterwards. The schema, + * the lifecycle and the pages are covered by + * tests/Unit/Settings/UsageSchemaTest.php and tests/vitest/usages.spec.js. + * + * @spec openspec/specs/application-usage-pages/spec.md + */ +import type { APIRequestContext } from '@playwright/test' +import type { VoorzieningenConfig } from './_fixtures.ts' + +import { expect, test } from '@playwright/test' +import { + createObject, + deleteObject, + newApiContext, + resolveConfig, + RUN_ID, +} from './_fixtures.ts' +import { dismissSupportDialog, gotoAppRoute } from './_ui.ts' + +let apiCtx: APIRequestContext +let cfg: VoorzieningenConfig +const seeded: Array<[string, string]> = [] +const ids: Record = {} +const appX = `${RUN_ID} application X` +const appY = `${RUN_ID} application Y` +const anna = `${RUN_ID}-anna` +const bram = `${RUN_ID}-bram` + +/** + * Create a row and remember it for cleanup. + * + * @param schema The schema slug. + * @param data The object. + * @return The new id. + */ +async function seed(schema: string, data: Record): Promise { + const id = await createObject(apiCtx, cfg.register, schema, data) + seeded.push([schema, id]) + return id +} + +test.beforeAll(async () => { + apiCtx = await newApiContext() + cfg = await resolveConfig(apiCtx) + ids.org = await seed('organization', { name: `${RUN_ID} municipality` }) + ids.anna = await seed('contactPerson', { + contactsUid: anna, + organization: ids.org, + }) + ids.bram = await seed('contactPerson', { + contactsUid: bram, + organization: ids.org, + }) + ids.x = await seed('module', { name: appX }) + ids.y = await seed('module', { name: appY }) + ids.x20 = await seed('moduleVersion', { + module: ids.x, + version: '2.0', + status: 'in use', + }) + ids.x21 = await seed('moduleVersion', { + module: ids.x, + version: '2.1', + status: 'in use', + }) + ids.usageX = await seed('usage', { + consumer: ids.org, + module: ids.x, + moduleVersion: ids.x21, + status: 'In production', + }) + ids.usageY = await seed('usage', { + consumer: ids.org, + module: ids.y, + status: 'Planned', + }) +}) + +test.afterAll(async () => { + if (!apiCtx) return + for (const [schema, id] of seeded.reverse()) { + await deleteObject(apiCtx, cfg.register, schema, id) + } + await apiCtx.dispose() +}) + +// @e2e application-usage-pages::an-information-manager-lists-the-organisation-s-applications +test('Applications in use lists both applications with version and status', async ({ + page, +}) => { + await gotoAppRoute(page, '/gebruik') + await dismissSupportDialog(page) + const rowX = page.getByRole('row').filter({ hasText: appX }) + await expect(rowX).toContainText('2.1', { timeout: 30000 }) + await expect(rowX).toContainText('In production') + await expect(page.getByRole('row').filter({ hasText: appY })).toContainText( + 'Planned', + ) +}) + +// @e2e application-usage-pages::adding-an-application-with-its-version +test('adding an application from its page creates a usage with the version picked', async ({ + page, +}) => { + await gotoAppRoute(page, `/modules/${ids.y}`) + await dismissSupportDialog(page) + await page.getByRole('button', { name: 'Add to our landscape' }).first().click() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible({ timeout: 30000 }) + await dialog + .getByRole('button', { name: /save|create/i }) + .last() + .click() + await expect(dialog).toBeHidden({ timeout: 30000 }) + await gotoAppRoute(page, '/gebruik') + await expect(page.getByText(appY).first()).toBeVisible({ timeout: 30000 }) +}) + +// @e2e application-usage-pages::setting-both-owners +test('the usage page shows the business owner and the technical owner', async ({ + page, +}) => { + const res = await apiCtx.put( + `/index.php/apps/openregister/api/objects/${cfg.register}/usage/${ids.usageX}`, + { + data: { + consumer: ids.org, + module: ids.x, + moduleVersion: ids.x21, + status: 'In production', + businessOwner: ids.anna, + technicalOwner: ids.bram, + }, + }, + ) + expect(res.ok()).toBe(true) + await gotoAppRoute(page, `/gebruik/${ids.usageX}`) + await dismissSupportDialog(page) + await expect(page.getByText('Business owner').first()).toBeVisible({ + timeout: 30000, + }) + await expect(page.getByText(anna).first()).toBeVisible() + await expect(page.getByText(bram).first()).toBeVisible() +}) + +// @e2e application-usage-pages::going-live +test('Go live moves a planned usage to In production', async ({ page }) => { + await gotoAppRoute(page, `/gebruik/${ids.usageY}`) + await dismissSupportDialog(page) + await page + .getByRole('button', { name: /go live/i }) + .first() + .click() + await expect(page.getByText('In production').first()).toBeVisible({ + timeout: 30000, + }) +}) diff --git a/tests/vitest/usages.spec.js b/tests/vitest/usages.spec.js new file mode 100644 index 00000000..1e523f4e --- /dev/null +++ b/tests/vitest/usages.spec.js @@ -0,0 +1,189 @@ +/** + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + * + * Applications in use: the usage pages as the app builds them (manifest.d + * merged the way src/main.js merges it), the add action on the application + * page and the usage list on the organisation page, and the seeded usages + * validated against the real usage schema. + * + * @spec openspec/specs/application-usage-pages/spec.md + */ + +import addFormats from 'ajv-formats' +import Ajv2020 from 'ajv/dist/2020.js' +import * as fs from 'fs' +import * as path from 'path' +import { describe, expect, it } from 'vitest' +import register from '../../lib/Settings/softwarecatalogus_register.json' +import mock from '../../lib/Settings/stackiq_mock_register.json' +import manifestSchema from '../../node_modules/@conduction/nextcloud-vue/src/schemas/app-manifest-v2.schema.json' +import { buildManifest } from '../../node_modules/@conduction/nextcloud-vue/src/utils/buildManifest.js' +import base from '../../src/manifest.json' +import menuLayout from '../../src/menu-layout.json' + +const dir = path.resolve(__dirname, '../../src/manifest.d') +const merged = buildManifest( + base, + fs + .readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .sort() + .map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))), + menuLayout, +) +const page = (id) => merged.pages.find((p) => p.id === id) +const widget = (pageId, widgetId) => + page(pageId).config.widgets.find((w) => w.id === widgetId) + +const usage = register.components.schemas.usage + +/** + * A validator built from the real usage properties. A relation is checked as + * an object or an id string, the way OpenRegister accepts it. + * + * @return {Function} The compiled Ajv validator. + */ +function compileUsage() { + const ajv = new Ajv2020({ allErrors: true, strict: false }) + addFormats(ajv) + const properties = Object.fromEntries( + Object.entries(usage.properties).map(([key, prop]) => [ + key, + prop.$ref + ? { type: ['object', 'string'] } + : { type: prop.type, enum: prop.enum, format: prop.format }, + ]), + ) + return ajv.compile({ type: 'object', properties }) +} + +describe('the usage pages', () => { + it('builds a manifest the v2 schema accepts', () => { + const ajv = new Ajv2020({ allErrors: true, strict: false }) + addFormats(ajv) + const validate = ajv.compile(manifestSchema) + expect(validate(merged), JSON.stringify(validate.errors)).toBe(true) + }) + + it('lists Applications in use under Applications', () => { + const modules = merged.menu.find((m) => m.id === 'Modules') + const child = modules.children.find((c) => c.id === 'Gebruik') + expect(child).toMatchObject({ + label: 'Applications in use', + route: 'Gebruik', + }) + expect(merged.menu.find((m) => m.id === 'Gebruik')).toBeUndefined() + }) + + it('lists usages with version, status and both owners, filtered on real status values', () => { + const index = page('Gebruik') + expect(index.route).toBe('/gebruik') + expect(index.config.schema).toBe('usage') + expect(index.config.columns).toEqual( + expect.arrayContaining([ + 'module', + 'moduleVersion', + 'status', + 'businessOwner', + 'technicalOwner', + ]), + ) + for (const column of index.config.columns) { + expect(usage.properties, column).toHaveProperty(column) + } + const statuses = index.config.quickFilters + .map((q) => q.filter.status) + .filter(Boolean) + expect(statuses).toEqual(usage.properties.status.enum) + }) + + it('shows version, status and owners on the detail page and offers the lifecycle', () => { + const detail = page('GebruikDetail') + expect(detail.route).toBe('/gebruik/:id') + expect(detail.config.lifecycleActions).toEqual({ field: 'status' }) + const include = widget('GebruikDetail', 'gb-data').content.include + expect(include).toEqual( + expect.arrayContaining([ + 'module', + 'moduleVersion', + 'status', + 'businessOwner', + 'technicalOwner', + ]), + ) + for (const field of include) { + expect(usage.properties, field).toHaveProperty(field) + } + const layoutIds = detail.config.layout.map((l) => l.widgetId) + expect(layoutIds.sort()).toEqual( + detail.config.widgets.map((w) => w.id).sort(), + ) + }) +}) + +describe('adding an application to the landscape', () => { + it('offers Add to our landscape on the application page, with the application filled in', () => { + const list = widget('ModuleDetail', 'md-usages') + expect(list.content.allowCreate).not.toBe(false) + expect(list.content.addLabel).toBe('Add to our landscape') + expect(list.content.filter).toEqual({ module: '@objectId' }) + expect(list.content.rowRoute).toBe('GebruikDetail') + expect(list.content.formIncludeFields).toEqual([ + 'consumer', + 'moduleVersion', + 'status', + 'businessOwner', + 'technicalOwner', + ]) + for (const field of list.content.formIncludeFields) { + expect(usage.properties, field).toHaveProperty(field) + } + }) + + it('offers only versions of the application in the version picker', () => { + expect(usage.properties.moduleVersion['x-relation-filter']).toEqual({ + module: '@object.module', + }) + }) + + it('lists the applications an organisation uses on its page', () => { + const list = widget('OrganisatieDetail', 'org-usages') + expect(list.content).toMatchObject({ + schema: 'usage', + filter: { consumer: '@objectId' }, + rowRoute: 'GebruikDetail', + }) + expect( + page('OrganisatieDetail').config.layout.map((l) => l.widgetId), + ).toContain('org-usages') + }) +}) + +describe('the seeded usages', () => { + const validate = compileUsage() + const seeds = [ + ...register.components.objects, + ...mock.components.objects, + ].filter((o) => o['@self'] && o['@self'].schema === 'usage') + + it('are valid against the usage schema', () => { + expect(seeds.length).toBeGreaterThan(0) + for (const seed of seeds) { + expect( + validate(seed), + `${seed['@self'].slug}: ${JSON.stringify(validate.errors)}`, + ).toBe(true) + } + }) + + it('include usages in production with both owners and a planned one', () => { + const withOwners = seeds.filter( + (s) => s.businessOwner !== undefined && s.technicalOwner !== undefined, + ) + expect(withOwners.length).toBeGreaterThan(0) + expect(seeds.map((s) => s.status)).toEqual( + expect.arrayContaining(['In production', 'Planned']), + ) + }) +})