From dca352f72e172acdcc5049a73f4c950068f65229 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 8 Oct 2026 16:02:46 +0200 Subject: [PATCH 1/3] fix(ui): name references in the catalogue tables and narrow the Applications list by _ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Applications page sent the facet/search match as a plain `id` filter, which OpenRegister's objects endpoint ignores (verified on 2.1.36: `id[]=` returns the whole list, `_ids[]=` narrows), so a search or GEMMA facet selection narrowed nothing while the counts said it did. The narrowing filter now uses `_ids` (and the no-match sentinel still yields an empty list); the helper has its own spec. Reference columns rendered the raw uuid: `provider` on Applications, the organisation on an application's Usages, the application on an organisation's usages, connections and compliance claims, and the module on Module versions and Applications in use. Those columns are now column objects with the library's `fkResolve` cell widget (uuid → name through the object store) and their own label — an object-typed property gets no schema-generated column, so a bare key showed the key as its header. An organisation's Applications list named no application (only licence and BBN level) and had no way to the rest beyond "+N more"; it now leads with the name and links to the Applications page filtered on the organisation. Co-Authored-By: Claude Fable 5.1 --- src/customComponents.js | 2 +- src/manifest.d/connections.json | 10 ++++++- src/manifest.d/usages.json | 10 ++++++- src/manifest.json | 40 +++++++++++++-------------- src/store/modules/facets.js | 4 +-- src/utils/facetNarrowing.js | 30 ++++++++++++++++++++ src/utils/facetNarrowing.spec.js | 25 +++++++++++++++++ src/views/FacetedCatalogIndexView.vue | 20 ++++++-------- 8 files changed, 105 insertions(+), 36 deletions(-) create mode 100644 src/utils/facetNarrowing.js create mode 100644 src/utils/facetNarrowing.spec.js diff --git a/src/customComponents.js b/src/customComponents.js index d3c8c0f77..f6ec30d97 100644 --- a/src/customComponents.js +++ b/src/customComponents.js @@ -151,7 +151,7 @@ export default { // object) and `referentiecomponent`/`standaard` are exposed here by display // NAME, not the identifiers the schema stores — feeding them through that // path would break the list. Stays a custom page (wrapping `CnFacetSidebar` - // + a standalone `CnIndexPage` narrowed via `{ id: matchedObjectIds }`) until + // + a standalone `CnIndexPage` narrowed via `{ _ids: matchedObjectIds }`) until // the lib grows a facet-sidebar mode whose counts/narrowing are computed by // an external, non-schema-field aggregation (see FacetedCatalogIndexView.vue). FacetedCatalogIndexView, diff --git a/src/manifest.d/connections.json b/src/manifest.d/connections.json index 2e9072796..075d92be3 100644 --- a/src/manifest.d/connections.json +++ b/src/manifest.d/connections.json @@ -19,7 +19,15 @@ "register": "@resolve:voorzieningen_register", "schema": "connection", "description": "Every connection between applications, and from an application to a national provision.", - "columns": ["name", "type", "status", "moduleA", "moduleB", "nonMunicipalProvision", "dataExchangeDirection"], + "columns": [ + "name", + "type", + "status", + { "key": "moduleA", "label": "From application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, + { "key": "moduleB", "label": "To application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, + "nonMunicipalProvision", + "dataExchangeDirection" + ], "filterMenu": true, "quickFilters": [ { "label": "All", "filter": {}, "default": true }, diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json index 1ff71f97e..66990ad7d 100644 --- a/src/manifest.d/usages.json +++ b/src/manifest.d/usages.json @@ -19,7 +19,15 @@ "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", "serviceDeskRecordId"], + "columns": [ + { "key": "module", "label": "Application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, + { "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "version" } }, + "status", + "businessOwner", + "technicalOwner", + "timeClassification", + "serviceDeskRecordId" + ], "filterMenu": true, "quickFilters": [ { "label": "All", "filter": {}, "default": true }, diff --git a/src/manifest.json b/src/manifest.json index 13080eed0..22b321684 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -421,8 +421,8 @@ { "id": "org-stats-applications", "type": "stats-block", "title": "Applications", "icon": "ChartBar", "content": { "entries": [ { "title": "Applications", "register": "@resolve:voorzieningen_register", "schema": "module", "metric": "count", "filter": { "provider": "@objectId" } } ] } }, { "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-modules", "type": "object-list", "title": "Applications", "icon": "Package", "content": { "register": "@resolve:voorzieningen_register", "schema": "module", "filter": { "provider": "@objectId" }, "columns": [ { "key": "name", "label": "Name" }, { "key": "licentietype", "label": "License type" }, { "key": "bbnLevel", "label": "BBN level" } ], "limit": 25, "rowRoute": "ModuleDetail", "viewAllRoute": "Modules", "viewAllQuery": { "provider": "@objectId" }, "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", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "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": [ @@ -511,9 +511,9 @@ { "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, "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-usages", "type": "object-list", "title": "Usages", "icon": "OfficeBuilding", "content": { "register": "@resolve:voorzieningen_register", "schema": "usage", "filter": { "module": "@objectId" }, "columns": [ { "key": "consumer", "label": "Organisation", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "organization", "labelField": "name" } }, { "key": "moduleVersion", "label": "Version", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "labelField": "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", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "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", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "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" } }, { "id": "md-maintenance", "type": "object-list", "title": "Planned maintenance", "icon": "Calendar", "content": { "register": "@resolve:voorzieningen_register", "schema": "maintenanceWindow", "filter": { "module": "@objectId" }, "sort": { "field": "startsAt", "dir": "asc" }, "columns": [ { "key": "title", "label": "Title" }, { "key": "startsAt", "label": "Starts at" }, { "key": "endsAt", "label": "Ends at" }, { "key": "impact", "label": "Impact" }, { "key": "status", "label": "Status" } ], "limit": 25, "addLabel": "Announce maintenance", "formIncludeFields": [ "title", "description", "moduleVersion", "startsAt", "endsAt", "impact" ], "emptyText": "No maintenance planned on this application" } } ], @@ -616,20 +616,20 @@ "type": "custom", "title": "Applications", "component": "FacetedCatalogIndexView", - "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { id: matchedObjectIds }. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { id: ... } narrowing (disjoint keys, additive AND, no clobber).", + "_note": "Faceted index page (gemma-faceted-search): CnFacetSidebar (live GEMMA-dimension counts from FacetService) + a standalone CnIndexPage narrowed via { _ids: matchedObjectIds } (OpenRegister's list parameter; the objects endpoint ignores a plain id filter). Columns are objects: `provider` is a reference, so it renders through the fkResolve cell widget (uuid → organisation name) and carries its own label — object-typed properties get no schema-generated column, so a bare key showed the raw uuid under the header 'provider'. Stays custom rather than type:index — see customComponents.js's FacetedCatalogIndexView entry for why CnIndexPage's own embedded facet sidebar cannot drive this narrowing. bio-compliance-assessment: bbnLevel/dpiaStatus columns + BBN/DPIA quickFilters reconciled onto this page after gemma-faceted-search (#382) merged — see FacetedCatalogIndexView.vue's quickFilters prop docblock for how the quick filter's field constraints compose with the facet { _ids: ... } narrowing (disjoint keys, additive AND, no clobber).", "config": { "register": "@resolve:voorzieningen_register", "schema": "module", "detailRoute": "ModuleDetail", "description": "Browse the application (module) catalogue, filtered by GEMMA architecture dimension.", "columns": [ - "name", - "provider", - "type", - "licentietype", - "publicationDate", - "bbnLevel", - "dpiaStatus" + { "key": "name", "label": "Name" }, + { "key": "provider", "label": "Supplier", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "organization", "labelField": "name" } }, + { "key": "type", "label": "Type" }, + { "key": "licentietype", "label": "License type" }, + { "key": "publicationDate", "label": "Publication date" }, + { "key": "bbnLevel", "label": "BBN level" }, + { "key": "dpiaStatus", "label": "DPIA status" } ], "quickFilters": [ { @@ -756,7 +756,7 @@ { "id": "st-data", "type": "data", "title": "Standard", "icon": "ShieldCheckOutline", "content": { "columns": 2 } }, { "id": "st-files", "type": "integration", "integrationId": "files", "title": "Specification documents", "icon": "FolderOutline" }, { "id": "st-related", "type": "related", "title": "Related", "icon": "LinkVariant" }, - { "id": "st-compliance", "type": "object-list", "title": "Compliance claims for this standard", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "standardGemma": "@object.name" }, "columns": [ { "key": "module", "label": "Module" }, { "key": "standardVersion", "label": "Standard version" }, { "key": "url", "label": "Evidence URL" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } } + { "id": "st-compliance", "type": "object-list", "title": "Compliance claims for this standard", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "standardGemma": "@object.name" }, "columns": [ { "key": "module", "label": "Module", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "standardVersion", "label": "Standard version" }, { "key": "url", "label": "Evidence URL" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } } ], "layout": [ { "id": "1", "widgetId": "st-data", "gridX": 0, "gridY": 0, "gridWidth": 8, "gridHeight": 4 }, @@ -808,7 +808,7 @@ "_note": "Catalog archetype (a BIO 2.0 measure), mirroring StandaardDetail's shape for the parallel bioMaatregel relation (design.md Decision 1: compliancy links to a standaardversie OR a bioMaatregel, never a third parallel model). Body: measure data 8-wide (code, naam, omschrijving, thema, bioVersie, bbnNiveau, bron). Below, a compliancy object-list shows every compliance claim referencing this measure (compliancy.bioMaatregel = @objectId), mirroring how StandaardDetail's st-compliance widget shows claims for a standard.", "widgets": [ { "id": "bm-data", "type": "data", "title": "BIO measure", "icon": "ShieldCheckOutline", "content": { "columns": 2 } }, - { "id": "bm-compliance", "type": "object-list", "title": "Compliance claims for this measure", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "bioMeasure": "@objectId" }, "columns": [ { "key": "module", "label": "Module" }, { "key": "url", "label": "Evidence URL" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } } + { "id": "bm-compliance", "type": "object-list", "title": "Compliance claims for this measure", "icon": "ClipboardCheckOutline", "content": { "register": "@resolve:voorzieningen_register", "schema": "compliancy", "filter": { "bioMeasure": "@objectId" }, "columns": [ { "key": "module", "label": "Module", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, { "key": "url", "label": "Evidence URL" } ], "limit": 50, "rowRoute": "KompliantieDetail", "allowCreate": false, "emptyText": "No compliance claims yet" } } ], "layout": [ { "id": "1", "widgetId": "bm-data", "gridX": 0, "gridY": 0, "gridWidth": 12, "gridHeight": 4 }, @@ -943,11 +943,11 @@ "register": "@resolve:voorzieningen_register", "schema": "moduleVersion", "columns": [ - "version", - "module", - "dateInDevelopment", - "dateInUse", - "status" + { "key": "version", "label": "Version" }, + { "key": "module", "label": "Application", "widget": "fkResolve", "widgetProps": { "register": "@resolve:voorzieningen_register", "schema": "module", "labelField": "name" } }, + { "key": "dateInDevelopment", "label": "In development since" }, + { "key": "dateInUse", "label": "In use since" }, + { "key": "status", "label": "Status" } ], "quickFilters": [ { "label": "All", "filter": {}, "default": true }, diff --git a/src/store/modules/facets.js b/src/store/modules/facets.js index 0f7ddfe3b..a8a322d79 100644 --- a/src/store/modules/facets.js +++ b/src/store/modules/facets.js @@ -35,7 +35,7 @@ import { * `standaardVersies`, … — see design.md). Letting the bare dimension name * leak into `$route.query` would make CnIndexPage attempt an incorrect * direct-field filter (near-guaranteed zero results) IN ADDITION to this - * feature's own `{ id: matchedObjectIds }` narrowing. The `_gf_` prefix + * feature's own `{ _ids: matchedObjectIds }` narrowing. The `_gf_` prefix * keeps GEMMA facet state in the URL (deep-linkable, per spec) while staying * invisible to that generic passthrough (`_`-prefixed keys are reserved/ * skipped there). @@ -158,7 +158,7 @@ export const useFacetStore = defineStore('facets', { /** * The RBAC/filter/search-scoped object id set the last-fetched facet * response describes (`_meta.matchedObjectIds`) — used to narrow the - * schema's own object-list query via `{ id: [...] }` (see + * schema's own object-list query via `{ _ids: [...] }` (see * `FacetService::computeFacetsForRequest()`'s docblock for why an * id-based filter is used instead of re-deriving one from the facet * selection: `domain`/`applicationService` are not module/catalogService diff --git a/src/utils/facetNarrowing.js b/src/utils/facetNarrowing.js new file mode 100644 index 000000000..b04604156 --- /dev/null +++ b/src/utils/facetNarrowing.js @@ -0,0 +1,30 @@ +/** + * The `CnIndexPage` filter that narrows a self-fetch object list to the ids + * a facets response matched (gemma-faceted-search). + * + * The filter key is OpenRegister's `_ids` list parameter, NOT a plain `id` + * field filter: the objects endpoint ignores `id` (`id[]=` returned + * the whole list on OpenRegister 2.1.36), so a search or GEMMA facet + * selection narrowed nothing while the facet counts said it did. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search + */ + +/** + * Id that no real object carries. Sent when the facets matched nothing, so + * the list is empty instead of `CnIndexPage` treating an empty id list as + * "no filter" and showing everything. + */ +export const FACET_NO_MATCH_SENTINEL = '__gemma_facet_no_match__' + +/** + * Build the narrowing filter for a matched id set. + * + * @param {Array} ids The `_meta.matchedObjectIds` of the last facets response. + * @return {{ _ids: Array }} The `CnIndexPage` `filter` prop value. + */ +export function facetNarrowingFilter(ids) { + const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id !== '') : [] + return { _ids: list.length > 0 ? list : [FACET_NO_MATCH_SENTINEL] } +} diff --git a/src/utils/facetNarrowing.spec.js b/src/utils/facetNarrowing.spec.js new file mode 100644 index 000000000..6f2fff22e --- /dev/null +++ b/src/utils/facetNarrowing.spec.js @@ -0,0 +1,25 @@ +/** + * facetNarrowing.spec.js — the filter that narrows the Applications list to + * the ids a facets response matched uses OpenRegister's `_ids` parameter. + * + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search + */ +import { FACET_NO_MATCH_SENTINEL, facetNarrowingFilter } from '../../src/utils/facetNarrowing.js' + +describe('facetNarrowingFilter', () => { + it('sends the matched ids as the _ids list parameter, never as a plain id field filter', () => { + const filter = facetNarrowingFilter(['a1', 'b2']) + expect(filter).toEqual({ _ids: ['a1', 'b2'] }) + expect(filter).not.toHaveProperty('id') + }) + + it('sends the no-match sentinel when nothing matched, so the list is empty rather than unfiltered', () => { + expect(facetNarrowingFilter([])).toEqual({ _ids: [FACET_NO_MATCH_SENTINEL] }) + expect(facetNarrowingFilter(undefined)).toEqual({ _ids: [FACET_NO_MATCH_SENTINEL] }) + }) + + it('drops blank entries before deciding whether anything matched', () => { + expect(facetNarrowingFilter(['', null, 'c3'])).toEqual({ _ids: ['c3'] }) + expect(facetNarrowingFilter(['', null])).toEqual({ _ids: [FACET_NO_MATCH_SENTINEL] }) + }) +}) diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue index cac15d0eb..8cfd7c0f2 100644 --- a/src/views/FacetedCatalogIndexView.vue +++ b/src/views/FacetedCatalogIndexView.vue @@ -21,7 +21,7 @@ display NAME, not the `referentieComponenten`/`standaardVersies` identifiers the schema actually stores. Feeding GEMMA facet keys through that path would apply an incorrect direct-field filter (near-guaranteed empty results) alongside this feature's own narrowing. This view instead narrows the -self-fetch list via the bounded, RBAC-scoped `{ id: matchedObjectIds }` list +self-fetch list via the bounded, RBAC-scoped `{ _ids: matchedObjectIds }` list `FacetService` already computes (`_meta.matchedObjectIds` — see `lib/Service/FacetService.php::computeFacetsForRequest()`), and keeps GEMMA facet state in the URL under `_gf_`-prefixed query keys so CnIndexPage's own @@ -147,6 +147,7 @@ import FolderStarOutline from 'vue-material-design-icons/FolderStarOutline.vue' import SaveFacetViewModal from '../modals/SaveFacetViewModal.vue' import { useFacetStore } from '../store/modules/facets.js' import { rowDetailLocation } from '../utils/applicationContracts.js' +import { facetNarrowingFilter } from '../utils/facetNarrowing.js' import { buildFacetDimensionSchema } from '../utils/facetSchema.js' /** Dimension key -> translated label, matching `FacetController`'s query params. */ @@ -226,10 +227,10 @@ export default { * with the GEMMA facet narrowing below: `CnIndexPage`'s self-fetch * merges `{ ...base(filter prop), ...activeTab.filter }` (see * `useSelfFetchList.js#fixedFilters`), and this view's own `filter` - * prop only ever carries the single `id` key - * (`{ id: matchedObjectIds }`, see `listFilter` below). A quick + * prop only ever carries the single `_ids` key + * (`{ _ids: matchedObjectIds }`, see `listFilter` below). A quick * filter's keys (`bbnLevel`, `dpiaStatus`, …) never collide with - * `id`, so the merge is additive — both the facet-matched id set AND + * `_ids`, so the merge is additive — both the facet-matched id set AND * the active quick filter's field constraints apply together * (logical AND), never clobbering one another. */ @@ -307,10 +308,11 @@ export default { }, /** - * Bounded `{ id: [...] }` narrowing filter for `CnIndexPage`'s + * Bounded `{ _ids: [...] }` narrowing filter for `CnIndexPage`'s * self-fetch object list, sourced from the last facets response's * `_meta.matchedObjectIds`. Empty object (no narrowing) when no facet - * filter or search term is active. + * filter or search term is active. `facetNarrowingFilter` says why the + * key is `_ids` and what is sent when nothing matched. * * @return {object} The `CnIndexPage` `filter` prop value. * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe @@ -320,11 +322,7 @@ export default { return {} } - const ids = this.facetStore.matchedObjectIdsFor(this.schema) - // A real (if unlikely) id can never collide with this sentinel — - // forces a correct EMPTY list rather than `CnIndexPage` treating - // an empty `id` array as "no filter" (showing everything). - return { id: ids.length > 0 ? ids : ['__gemma_facet_no_match__'] } + return facetNarrowingFilter(this.facetStore.matchedObjectIdsFor(this.schema)) }, /** From 3ee26f50f87a91539f4f66bc88aa8dbb51e80ef7 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 8 Oct 2026 16:25:35 +0200 Subject: [PATCH 2/3] fix(l10n): translate the two Module versions column labels Review of #1275 (f1): "In development since" and "In use since" were the only two of the thirteen new column labels missing from nl.json, so Dutch users saw them in English. Added to en.json and nl.json and the browser files rebuilt with `npm run l10n:build`; `npm run test:l10n` passes (every used key present in en.json). Co-Authored-By: Claude Fable 5.1 --- l10n/en.js | 4 +++- l10n/en.json | 4 +++- l10n/nl.js | 4 +++- l10n/nl.json | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/l10n/en.js b/l10n/en.js index 70c3bfcef..8dd84a2f4 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1130,7 +1130,9 @@ OC.L10N.register( "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.", "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.", "The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.", - "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads." + "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.", + "In development since": "In development since", + "In use since": "In use since" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index c1996692c..313f968ea 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1129,6 +1129,8 @@ "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.", "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.", "The workbook holds more than {count} different texts, the most the import reads.": "The workbook holds more than {count} different texts, the most the import reads.", - "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads." + "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.", + "In development since": "In development since", + "In use since": "In use since" } } diff --git a/l10n/nl.js b/l10n/nl.js index b2c3a6257..3151813d6 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1198,7 +1198,9 @@ OC.L10N.register( "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.", "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.", "The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.", - "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest." + "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.", + "In development since": "In ontwikkeling sinds", + "In use since": "In gebruik sinds" }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index e226a52ca..680f6f853 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1197,6 +1197,8 @@ "The part {part} of the workbook is too large to import: unpacked it is {size}, and the import reads at most {limit} of one part.": "Het onderdeel {part} van de werkmap is te groot om te importeren: uitgepakt is het {size}, en de import leest van één onderdeel hooguit {limit}.", "Delete the sheets the import does not read from a copy of the export (it reads only \"{first}\" and \"{second}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen \"{first}\" en \"{second}\"), en importeer die kopie.", "The workbook holds more than {count} different texts, the most the import reads.": "De werkmap bevat meer dan {count} verschillende teksten, het maximum dat de import leest.", - "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest." + "Together, the cells of the workbook reference more than {size} of shared text, the most the import reads.": "Samen verwijzen de cellen van de werkmap naar meer dan {size} gedeelde tekst, het maximum dat de import leest.", + "In development since": "In ontwikkeling sinds", + "In use since": "In gebruik sinds" } } From 50e116ff4074ede27e1ba8afca2465af771fc74c Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 8 Oct 2026 17:43:18 +0200 Subject: [PATCH 3/3] chore(ui): prettier formatting and a @spec tag on the narrowing helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on #1275: `quality / Frontend Check (format)` flagged the three new or changed JS/Vue files (`npx prettier --write` on them; no semantic change, `npm run format` now clean) and Hydra gate-16 spec-coverage flagged `facetNarrowingFilter` as a changed method without an @spec tag — the tag now sits on the function docblock (both anchors are requirement headings in openspec/specs/gemma-faceted-search/spec.md). eslint 0 errors; the helper's spec still passes (3 tests). Co-Authored-By: Claude Fable 5.1 --- src/utils/facetNarrowing.js | 6 +++++- src/utils/facetNarrowing.spec.js | 13 ++++++++++--- src/views/FacetedCatalogIndexView.vue | 4 +++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/utils/facetNarrowing.js b/src/utils/facetNarrowing.js index b04604156..58b6a9047 100644 --- a/src/utils/facetNarrowing.js +++ b/src/utils/facetNarrowing.js @@ -23,8 +23,12 @@ export const FACET_NO_MATCH_SENTINEL = '__gemma_facet_no_match__' * * @param {Array} ids The `_meta.matchedObjectIds` of the last facets response. * @return {{ _ids: Array }} The `CnIndexPage` `filter` prop value. + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facet-counts-reflect-the-currently-filtered-set-not-the-unfiltered-universe + * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search */ export function facetNarrowingFilter(ids) { - const list = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id !== '') : [] + const list = Array.isArray(ids) + ? ids.filter((id) => typeof id === 'string' && id !== '') + : [] return { _ids: list.length > 0 ? list : [FACET_NO_MATCH_SENTINEL] } } diff --git a/src/utils/facetNarrowing.spec.js b/src/utils/facetNarrowing.spec.js index 6f2fff22e..06f101c92 100644 --- a/src/utils/facetNarrowing.spec.js +++ b/src/utils/facetNarrowing.spec.js @@ -4,7 +4,10 @@ * * @spec openspec/specs/gemma-faceted-search/spec.md#requirement-facets-combine-with-free-text-search */ -import { FACET_NO_MATCH_SENTINEL, facetNarrowingFilter } from '../../src/utils/facetNarrowing.js' +import { + FACET_NO_MATCH_SENTINEL, + facetNarrowingFilter, +} from '../../src/utils/facetNarrowing.js' describe('facetNarrowingFilter', () => { it('sends the matched ids as the _ids list parameter, never as a plain id field filter', () => { @@ -15,11 +18,15 @@ describe('facetNarrowingFilter', () => { it('sends the no-match sentinel when nothing matched, so the list is empty rather than unfiltered', () => { expect(facetNarrowingFilter([])).toEqual({ _ids: [FACET_NO_MATCH_SENTINEL] }) - expect(facetNarrowingFilter(undefined)).toEqual({ _ids: [FACET_NO_MATCH_SENTINEL] }) + expect(facetNarrowingFilter(undefined)).toEqual({ + _ids: [FACET_NO_MATCH_SENTINEL], + }) }) it('drops blank entries before deciding whether anything matched', () => { expect(facetNarrowingFilter(['', null, 'c3'])).toEqual({ _ids: ['c3'] }) - expect(facetNarrowingFilter(['', null])).toEqual({ _ids: [FACET_NO_MATCH_SENTINEL] }) + expect(facetNarrowingFilter(['', null])).toEqual({ + _ids: [FACET_NO_MATCH_SENTINEL], + }) }) }) diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue index 8cfd7c0f2..f80deda08 100644 --- a/src/views/FacetedCatalogIndexView.vue +++ b/src/views/FacetedCatalogIndexView.vue @@ -322,7 +322,9 @@ export default { return {} } - return facetNarrowingFilter(this.facetStore.matchedObjectIdsFor(this.schema)) + return facetNarrowingFilter( + this.facetStore.matchedObjectIdsFor(this.schema), + ) }, /**