From 7184a4a22df83553bdd36dacacb7f3d1eee1ffe7 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Mon, 12 Oct 2026 12:09:15 +0200 Subject: [PATCH 01/18] feat(cmdb-import): offer the missing-records choice and an Archived quick filter The CMDB import section lets the admin archive or delete applications missing from the export, or keep them, and shows the archived, unarchived, deleted and restored outcomes. The Applications and Applications in use pages get an Archived quick filter. Vitest and the Playwright spec cover it; the feature docs and the changelog describe it. Third of three slices of #1281, on top of the reconciliation slice. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 9 ++ docs/features/README.md | 2 +- docs/features/cmdb-import.md | 90 +++++++++++-- src/icons.js | 2 + src/manifest.d/usages.json | 3 +- src/manifest.json | 7 + src/utils/cmdbImport.js | 72 +++++++++- src/utils/cmdbImport.spec.js | 58 +++++++- .../settings/sections/CmdbImport.spec.js | 58 ++++++++ src/views/settings/sections/CmdbImport.vue | 77 ++++++++++- tests/e2e/spec-coverage/cmdb-import.spec.ts | 125 +++++++++++++++++- tests/vitest/cmdbArchiveQuickFilter.spec.js | 62 +++++++++ 12 files changed, 540 insertions(+), 25 deletions(-) create mode 100644 tests/vitest/cmdbArchiveQuickFilter.spec.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 87505881a..69d2c85b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ sheets becomes or updates a module, its vendor, a usage for the municipality and a contact person for its owner; a repeat import matches on APPID, so it updates instead of duplicating. See `docs/features/cmdb-import.md`. +- CMDB import, repeat imports: applications that left the CMDB sheets are + archived when the sheet "Gearchiveerde Applicaties" lists them and moved to + OpenRegister's trash when no sheet does; one that returns is unarchived or + restored, never duplicated. The new option `missingRecords` (`archive`, the + default, or `keep`) is a choice in the CMDB import section, and the report + counts archived, unarchived, deleted and restored applications. The module + (0.3.9) and usage (1.5.7) schemas opt into OpenRegister's archive state, and + the Applications and Applications in use pages get an "Archived" quick + filter. (cmdb-import-archive-reconciliation) ### Changed - EOL feed: the register and schema slugs the feature reads from are corrected. diff --git a/docs/features/README.md b/docs/features/README.md index 7fa964f0a..c57ff8486 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -158,7 +158,7 @@ The ArchiMate integration maps GEMMA Softwarecatalogus objects to ArchiMate appl ## CMDB Import -Import a TOPdesk CMDB export (`.xlsx`) for one municipality from the **CMDB import** section of the admin settings. Every application row of the two CMDB sheets ("Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB") becomes or updates a module, its vendor as a Supplier organisation, a usage that links it to the municipality and records whether maintenance is arranged, and a contact person for its owner (identity in Nextcloud Contacts, never public). A repeat import matches on APPID per municipality, so it updates instead of duplicating, and leaves applications missing from the newer export as they are. The column mapping is declarative JSON executed by OpenRegister's mapping engine. +Import a TOPdesk CMDB export (`.xlsx`) for one municipality from the **CMDB import** section of the admin settings. Every application row of the two CMDB sheets ("Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB") becomes or updates a module, its vendor as a Supplier organisation, a usage that links it to the municipality and records whether maintenance is arranged, and a contact person for its owner (identity in Nextcloud Contacts, never public). A repeat import matches on APPID per municipality, so it updates instead of duplicating. By default it archives the applications that moved to the sheet "Gearchiveerde Applicaties" and moves those on no sheet any more to OpenRegister's trash, and brings either back when it returns; the admin can choose to keep them instead. The Applications and Applications in use pages list the archive under the quick filter "Archived". The column mapping is declarative JSON executed by OpenRegister's mapping engine. See [CMDB import](cmdb-import.md) for the steps, the expected file structure, the error codes and how to adjust the mapping. diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index b9d505abc..d4077f959 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -17,9 +17,11 @@ updates: in Nextcloud Contacts. Owners are never readable by the public. All of it is stored as OpenRegister objects in the stackiq register. Import a -newer export later and the same applications are updated, not duplicated. +newer export later and the same applications are updated, not duplicated; +applications that left the export are archived or moved to the trash (see +[Repeat imports](#repeat-imports)). -Specification: [`openspec/specs/cmdb-export-import/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/cmdb-export-import/spec.md) (change archived in [`openspec/changes/archive/2026-10-05-cmdb-export-import/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/archive/2026-10-05-cmdb-export-import)). +Specification: [`openspec/specs/cmdb-export-import/spec.md`](https://github.com/ConductionNL/stackiq/blob/development/openspec/specs/cmdb-export-import/spec.md) (change archived in [`openspec/changes/archive/2026-10-05-cmdb-export-import/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/archive/2026-10-05-cmdb-export-import); archiving and deleting on a repeat import in [`openspec/changes/cmdb-import-archive-reconciliation/`](https://github.com/ConductionNL/stackiq/tree/development/openspec/changes/cmdb-import-archive-reconciliation)). ## Who can import @@ -77,9 +79,15 @@ page in stackiq. and the summary counts them as *created unpublished*. Applications that were imported before keep their publication as it is, whichever you choose. -6. Press **Import**. A progress bar shows how many rows have been processed. - **Cancel import** stops the import before the next row; rows that were - already processed stay imported. The section says whether the server +6. **Applications missing from the export.** **Archive or delete them** is + the default: an application of this municipality that is on neither CMDB + sheet any more is archived or moved to the trash after the last row (see + [Repeat imports](#repeat-imports)). Choose **Keep them as they are** to + leave such applications untouched. +7. Press **Import**. A progress bar shows how many rows have been processed. + **Cancel import** stops the import before the next row, or, while the + missing applications are compared, before the next application; what + was already processed stays imported. The section says whether the server accepted the cancel; one pressed before the server has started on the rows cannot take effect yet, and the section says so. @@ -92,11 +100,15 @@ When the import finishes, the section shows: - the **summary**: rows read, created, updated, unchanged, skipped, failed and warnings, and, when publishing was off, how many applications were - created unpublished; + created unpublished; after a repeat import also how many applications + were archived, unarchived, deleted (moved to the trash) or restored, when + there are any; - **warnings for the whole file**, for example an optional column that is missing; - the **rows** table: sheet, row number, APPID, application, outcome, - and the reasons and warnings for that row. Filter it with **Show rows with + and the reasons and warnings for that row. An application archived or + deleted because it left the export has no row number; its sheet is + `Gearchiveerde Applicaties` when that sheet listed it. Filter it with **Show rows with outcome**, and sort it by clicking a column header. It shows 100 rows at a time; **Show 100 more rows** adds the next ones. The application name links to the module in stackiq. @@ -106,8 +118,10 @@ the same organisation. ## The file -The import reads the two CMDB sheets of the export and ignores all others, -including the `Invoer` sheets they are derived from: +The import reads the two CMDB sheets of the export, and of the sheet +`Gearchiveerde Applicaties` only the `APPID` column (see +[Repeat imports](#repeat-imports)). It ignores all other sheets, including +the `Invoer` sheets they are derived from: | Sheet | What it holds | Recorded on the usage | |---|---|---| @@ -213,9 +227,9 @@ colliding. - **Known APPID, nothing changed**: nothing is saved; the row is reported as *unchanged*. Importing the same export twice creates nothing the second time. -- **APPID missing from a newer export**: the application, its usage and - its contact persons are left as they are. They are not changed, depublished - or deleted. +- **APPID missing from a newer export**: see + [Applications that left the export](#applications-that-left-the-export) + below. - Each application keeps exactly one usage for the municipality. - **Known APPID, but the application belongs to another organisation**: an application found by its match key is only updated when the municipality @@ -243,6 +257,55 @@ OpenRegister refuses to save it, that row is reported as *failed* with the step that failed, and the other rows are imported. Importing again completes the failed row. +### Applications that left the export + +With **Archive or delete them** (the default; `missingRecords=archive` in +the API), the import compares the municipality's applications with the +export after the last row. It looks only at the municipality's own usages +and the applications its import created, never at an application only +another organisation uses or one added by hand. For an application whose +APPID is on neither CMDB sheet: + +| The APPID is | The application and its usage | +|---|---| +| on the sheet `Gearchiveerde Applicaties` | are **archived**: OpenRegister's archive state, with the reason `cmdb-import: on sheet "Gearchiveerde Applicaties"`. Reported as *archived*. | +| on no sheet at all | are **deleted**: moved to OpenRegister's trash, where they stay restorable for 30 days. Reported as *deleted*. | +| on no sheet, and the workbook has no sheet `Gearchiveerde Applicaties` (or it has no `APPID` column) | are **archived**, never deleted, and the result warns that the sheet was not found. A workbook saved without that sheet therefore never moves anything to the trash. | + +An archived or deleted application disappears from the stackiq lists, from +OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick +filter **Archived** on the **Applications** and **Applications in use** pages +lists the archive. An OpenCatalogi caller finds archived applications by +adding `_archived=true` to its request. Contact persons are never archived +or deleted: an owner is shared with the municipality's other applications. +An application that is already in the state the export asks for is left +alone and not reported again. + +When an archived or deleted application **returns** in a later export, it is +taken out of the archive (*unarchived*) or out of the trash (*restored*), +and then updated as usual. No second application is created. With **Update +existing records** off, it is skipped as `exists` and stays archived or in +the trash. When an application was deleted by hand and a later import +created it again, the working one is updated and the deleted one stays in +the trash. + +The comparison runs only when the import went through all rows: an import +cancelled or failed before the comparison archives or deletes nothing. +**Cancel import** during the comparison stops it before the next +application; what was archived or deleted until then stays so, and the +result lists it. The progress bar shows the +step as *Checking the applications missing from the export*. Each +application is handled on its own; one that cannot be archived is reported +as *failed* with the step (`archive`, `restore` or `delete`) and the others +continue. Archiving needs module schema 0.3.9 and usage schema 1.5.7 or +later, which declare that their objects can be archived; on an older register configuration each application fails +with the step `archive`, and **Force Update** at the top of the stackiq admin +settings fixes it. + +With **Keep them as they are** (`missingRecords=keep`), the application, its +usage and its contact persons are left as they are: not changed, archived, +depublished or deleted. + ## Owners The owner becomes a **contact person of the municipality**, never a @@ -293,7 +356,8 @@ and the section shows the reason and the error code. | `IMPORT_IN_PROGRESS` | Another CMDB import is running. Only one import runs at a time. | Wait until it has finished and try again. | | `FIELD_INVALID` | A form field of the request has a value the import does not accept, for example an `updateExisting` or `publish` that is neither `true` nor `false`. The message names the field. | Not reachable from the section; reported for API callers. | | `UPLOAD_FAILED` | The file reached the server but could not be stored there. | Try again; the Nextcloud log has the details. | -| `MISSING_RECORDS_UNSUPPORTED` | The request asked to mark or remove records missing from the export. Only keeping them is supported. | Not reachable from the section; reported for API callers. | +| `MISSING_RECORDS_UNSUPPORTED` | The request asked for something other than `keep` or `archive` for records missing from the export, for example `mark` or `remove`. The details list the accepted values. | Not reachable from the section; reported for API callers. | +| `ARCHIVE_UNAVAILABLE` | **Archive or delete them** was chosen, but the OpenRegister on this server cannot archive or restore objects (it is missing or too old). Nothing was read or written. | Choose **Keep them as they are** and import again, or update OpenRegister. | | `MAPPING_UNAVAILABLE` | OpenRegister's mapping engine is missing, or one of the mapping files is invalid. | Update OpenRegister. If you changed a mapping file, check it against the Nextcloud log. | | `READER_UNAVAILABLE` | The Excel reader that ships with OpenRegister cannot be loaded. | Make sure OpenRegister is installed and enabled. | | `NOT_CONFIGURED` | The stackiq register or its schemas cannot be found. | Run **Auto Configure** at the top of the stackiq admin settings. | diff --git a/src/icons.js b/src/icons.js index 865c514c0..34ffbfdfb 100644 --- a/src/icons.js +++ b/src/icons.js @@ -17,6 +17,7 @@ import AccountGroup from 'vue-material-design-icons/AccountGroup.vue' import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue' import AlertCircle from 'vue-material-design-icons/AlertCircle.vue' import ApplicationOutline from 'vue-material-design-icons/ApplicationOutline.vue' +import ArchiveOutline from 'vue-material-design-icons/ArchiveOutline.vue' import ArrowRight from 'vue-material-design-icons/ArrowRight.vue' import BookOpenVariant from 'vue-material-design-icons/BookOpenVariant.vue' import BookOpenVariantOutline from 'vue-material-design-icons/BookOpenVariantOutline.vue' @@ -79,6 +80,7 @@ export default { AccountMultiple, AlertCircle, ApplicationOutline, + ArchiveOutline, ArrowRight, BookOpenVariant, BookOpenVariantOutline, diff --git a/src/manifest.d/usages.json b/src/manifest.d/usages.json index 66990ad7d..eec9d1242 100644 --- a/src/manifest.d/usages.json +++ b/src/manifest.d/usages.json @@ -35,7 +35,8 @@ { "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" } } + { "label": "Phased out", "filter": { "status": "Phased out" } }, + { "label": "Archived", "filter": { "_archived": "true" }, "icon": "ArchiveOutline" } ], "sidebar": { "enabled": true, "showMetadata": true }, "documentationUrl": "https://stackiq.conduction.nl" diff --git a/src/manifest.json b/src/manifest.json index 22b321684..7df63875f 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -662,6 +662,13 @@ "dpiaStatus": ["not required", "required"] }, "icon": "AlertCircle" + }, + { + "label": "Archived", + "filter": { + "_archived": "true" + }, + "icon": "ArchiveOutline" } ] } diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js index ab7fa7080..64d7df36d 100644 --- a/src/utils/cmdbImport.js +++ b/src/utils/cmdbImport.js @@ -42,8 +42,27 @@ export const PROFILE_DEFAULTS = Object.freeze({ */ export const AS_TEXT = Object.freeze({ escape: false, sanitize: false }) -/** Every row outcome the report can carry, in display order. */ -export const OUTCOMES = ['created', 'updated', 'unchanged', 'skipped', 'failed'] +/** + * Every row outcome the report can carry, in display order: the outcomes of a + * sheet row, then those of an application that left the export or came back. + */ +export const OUTCOMES = [ + 'created', + 'updated', + 'unchanged', + 'skipped', + 'failed', + 'unarchived', + 'restored', + 'archived', + 'deleted', +] + +/** + * What the import may do with applications missing from the export (the + * profile's `missingRecords`), the default first. + */ +export const MISSING_RECORDS_MODES = Object.freeze(['archive', 'keep']) /** * The words for one row outcome. @@ -51,6 +70,7 @@ export const OUTCOMES = ['created', 'updated', 'unchanged', 'skipped', 'failed'] * @param {string} outcome The outcome key from the report * @return {string} The label * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome-req-cmdb-011 + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014 */ export function outcomeLabel(outcome) { switch (outcome) { @@ -64,6 +84,14 @@ export function outcomeLabel(outcome) { return t('stackiq', 'Skipped') case 'failed': return t('stackiq', 'Failed') + case 'unarchived': + return t('stackiq', 'Unarchived') + case 'restored': + return t('stackiq', 'Restored') + case 'archived': + return t('stackiq', 'Archived') + case 'deleted': + return t('stackiq', 'Deleted') default: return String(outcome ?? '') } @@ -141,16 +169,19 @@ export function checkFile(file) { * @param {{uuid: string|null, name: string}} options.municipality The chosen municipality: an existing one has a uuid, a new one only a name * @param {boolean} options.updateExisting Whether matched rows are updated * @param {boolean} [options.publish] Whether the modules the import creates are published; true when left out + * @param {string} [options.missingRecords] What happens to applications missing from the export: 'archive' (when left out) or 'keep' * @param {string} options.operationId The progress operation id * @return {FormData} The body * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-every-import-shall-have-exactly-one-consuming-municipality-chosen-by-the-admin-req-cmdb-004 * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-newly-created-module-shall-get-a-publicationdate-when-the-admin-publishes-and-an-existing-one-shall-keep-its-own-req-cmdb-007 + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012 */ export function buildImportForm({ file, municipality, updateExisting, publish = true, + missingRecords = MISSING_RECORDS_MODES[0], operationId, }) { const form = new FormData() @@ -162,7 +193,7 @@ export function buildImportForm({ } form.append('updateExisting', updateExisting ? 'true' : 'false') form.append('publish', publish ? 'true' : 'false') - form.append('missingRecords', 'keep') + form.append('missingRecords', missingRecords) form.append('operationId', operationId) return form } @@ -465,6 +496,7 @@ const KNOWN_ERRORS = new Set([ 'SCHEMA_OUTDATED', 'IMPORT_IN_PROGRESS', 'MUNICIPALITY_AMBIGUOUS', + 'ARCHIVE_UNAVAILABLE', 'OPERATION_NOT_FOUND', 'IMPORT_INTERRUPTED', 'NOT_SIGNED_IN', @@ -626,7 +658,7 @@ export function errorText(error) { return { title: t( 'stackiq', - 'Records missing from the export can only be kept.', + 'Applications missing from the export can only be archived or kept.', ), hint: '', } @@ -807,6 +839,17 @@ export function errorText(error) { 'Choose the municipality from the list instead of typing its name. Nothing was imported.', ), } + case 'ARCHIVE_UNAVAILABLE': + return { + title: t( + 'stackiq', + 'Applications missing from the export cannot be archived.', + ), + hint: t( + 'stackiq', + 'OpenRegister is missing or too old to archive objects. Choose "Keep them as they are" and import again, or update OpenRegister. Nothing was imported.', + ), + } case 'OPERATION_NOT_FOUND': return { title: t('stackiq', 'This import is no longer running.'), @@ -886,9 +929,13 @@ export function cancelFailureText(error) { * server has set them, because the tracker's own percentage is weighted by * the phases of the ArchiMate import. * + * In the phase `reconciling` the counts also hold the applications the + * import checks against the export after its rows. + * * @param {object|null} progress The snapshot from `GET /api/progress/{operationId}` * @return {{percentage: number, detail: string}|null} The view, or null before any progress * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-a-running-import-shall-report-its-progress-and-shall-stop-when-cancelled-req-cmdb-013 + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015 */ export function cmdbProgressView(progress) { if (!progress) { @@ -900,6 +947,17 @@ export function cmdbProgressView(progress) { total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : Number(progress.percentage) || 0 + if (total > 0 && progress.phase === 'reconciling') { + return { + percentage, + detail: t( + 'stackiq', + 'Checking the applications missing from the export: {processed} of {total}', + { processed, total }, + AS_TEXT, + ), + } + } return { percentage, detail: @@ -960,9 +1018,13 @@ export function sortReportRows(rows, key, order) { /** * The rows of the report as the table shows them. * + * An application the import archived or deleted after its rows has row 0 + * in the report and no row number in the table. + * * @param {Array} rows The report's `rows` * @return {Array} The table rows * @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-each-row-shall-be-processed-in-isolation-and-reported-with-its-outcome-req-cmdb-011 + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014 */ export function reportRows(rows) { if (!Array.isArray(rows)) { @@ -971,7 +1033,7 @@ export function reportRows(rows) { return rows.map((row, index) => ({ key: `${row.sheet ?? ''}:${row.row ?? index}:${index}`, sheet: String(row.sheet ?? ''), - row: row.row ?? '', + row: Number(row.row) > 0 ? row.row : '', appId: String(row.appId ?? ''), name: String(row.name ?? ''), outcome: String(row.outcome ?? ''), diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js index 8f566d6cb..9377f3b59 100644 --- a/src/utils/cmdbImport.spec.js +++ b/src/utils/cmdbImport.spec.js @@ -22,6 +22,7 @@ import { interruptedImportError, isKnownError, makeCmdbOperationId, + MISSING_RECORDS_MODES, municipalityOptions, normaliseError, outcomeLabel, @@ -58,6 +59,7 @@ const SERVER_CODES = [ 'SCHEMA_OUTDATED', 'IMPORT_IN_PROGRESS', 'MUNICIPALITY_AMBIGUOUS', + 'ARCHIVE_UNAVAILABLE', 'OPERATION_NOT_FOUND', 'IMPORT_INTERRUPTED', 'NOT_SIGNED_IN', @@ -198,10 +200,26 @@ describe('buildImportForm', () => { expect(form.get('municipalityUuid')).toBe('uuid-1') expect(form.has('municipalityName')).toBe(false) expect(form.get('updateExisting')).toBe('true') - expect(form.get('missingRecords')).toBe('keep') + expect(form.get('missingRecords')).toBe('archive') expect(form.get('operationId')).toBe('cmdb-abcdefgh') }) + it('sends the choice for applications missing from the export, archive by default', () => { + const options = { + file, + municipality: { uuid: 'uuid-1', name: 'Tilburg' }, + updateExisting: true, + operationId: 'cmdb-abcdefgh', + } + expect(MISSING_RECORDS_MODES).toEqual(['archive', 'keep']) + expect(buildImportForm(options).get('missingRecords')).toBe('archive') + expect( + buildImportForm({ ...options, missingRecords: 'keep' }).get( + 'missingRecords', + ), + ).toBe('keep') + }) + it('sends a typed new municipality by name, and updateExisting as an explicit false', () => { const form = buildImportForm({ file, @@ -642,6 +660,19 @@ describe('cmdbProgressView', () => { ).toEqual({ percentage: 25, detail: '1 of 4 rows processed' }) }) + it('says when the import checks the applications missing from the export', () => { + expect( + cmdbProgressView({ + phase: 'reconciling', + processed_items: 3, + total_items: 4, + }), + ).toEqual({ + percentage: 75, + detail: 'Checking the applications missing from the export: 3 of 4', + }) + }) + it('shows nothing before any progress', () => { expect(cmdbProgressView(null)).toBeNull() }) @@ -683,6 +714,31 @@ describe('the report table', () => { expect(reportRows(null)).toEqual([]) }) + it('shows no row number for an application archived or deleted after the rows', () => { + const [row] = reportRows([ + { + sheet: 'Gearchiveerde Applicaties', + row: 0, + appId: '7', + name: 'Zeven', + outcome: 'archived', + reasons: [], + warnings: [], + moduleUuid: 'm-7', + }, + ]) + expect(row.row).toBe('') + expect(outcomeLabel('archived')).toBe('Archived') + expect(OUTCOMES).toEqual( + expect.arrayContaining([ + 'archived', + 'unarchived', + 'deleted', + 'restored', + ]), + ) + }) + it('sorts numbers by value and keeps the report order for ties', () => { const rows = [ { key: 'a', row: 10, sheet: 'B' }, diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js index 931f7edbc..7a16b9858 100644 --- a/src/views/settings/sections/CmdbImport.spec.js +++ b/src/views/settings/sections/CmdbImport.spec.js @@ -147,3 +147,61 @@ describe('CmdbImport municipality chooser', () => { expect(wrapper.vm.report).toBe(null) }) }) + +/** + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014 + */ +describe('CmdbImport applications missing from the export', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('archives by default and posts the choice the admin made', async () => { + const wrapper = await mountSection() + axios.post.mockResolvedValue({ + data: { success: true, summary: {}, rows: [] }, + }) + wrapper.vm.municipality = wrapper.vm.municipalityOptions[0] + wrapper.vm.selectedFile = new File(['x'], 'export.xlsx') + + expect(wrapper.vm.missingRecords).toBe('archive') + await wrapper.vm.startImport() + wrapper.vm.missingRecords = 'keep' + await wrapper.vm.startImport() + + expect( + axios.post.mock.calls.map(([, form]) => form.get('missingRecords')), + ).toEqual(['archive', 'keep']) + }) + + it('shows the archived, unarchived, deleted and restored counts only when there are any', async () => { + const wrapper = await mountSection() + const tileKeys = () => wrapper.vm.summaryTiles.map((tile) => tile.key) + + wrapper.vm.report = { summary: { rowsRead: 2, created: 2 }, rows: [] } + expect(tileKeys()).not.toContain('archived') + + wrapper.vm.report = { + summary: { + rowsRead: 1, + archived: 1, + deleted: 1, + unarchived: 0, + restored: 2, + }, + rows: [], + } + expect(tileKeys()).toEqual( + expect.arrayContaining(['archived', 'deleted', 'restored']), + ) + expect(tileKeys()).not.toContain('unarchived') + expect(wrapper.vm.outcomeFilterOptions.map((option) => option.id)).toEqual( + expect.arrayContaining([ + 'archived', + 'unarchived', + 'deleted', + 'restored', + ]), + ) + }) +}) diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue index f8bbc1e4b..00af68892 100644 --- a/src/views/settings/sections/CmdbImport.vue +++ b/src/views/settings/sections/CmdbImport.vue @@ -143,6 +143,40 @@ }}

+
+ + {{ t('stackiq', 'Applications missing from the export') }} + + + {{ t('stackiq', 'Archive or delete them') }} + + + {{ t('stackiq', 'Keep them as they are') }} + +

+ {{ + t( + 'stackiq', + 'An application of this municipality that is on neither CMDB sheet any more is archived when the sheet "Gearchiveerde Applicaties" lists it, and moved to the trash when no sheet does. Both disappear from the lists, from OpenCatalogi and from Portaliq; one that returns in a later export is brought back. Without that sheet in the file, applications are only archived. Choose Keep to leave them as they are.', + ) + }} +

+
@@ -304,7 +338,7 @@ @@ -718,8 +727,7 @@ export default { const parts = [ [ 'archived', - (count) => - t('stackiq', '{count} archived', { count }, AS_TEXT), + (count) => t('stackiq', '{count} archived', { count }, AS_TEXT), ], [ 'unarchived', @@ -728,13 +736,11 @@ export default { ], [ 'deleted', - (count) => - t('stackiq', '{count} deleted', { count }, AS_TEXT), + (count) => t('stackiq', '{count} deleted', { count }, AS_TEXT), ], [ 'restored', - (count) => - t('stackiq', '{count} restored', { count }, AS_TEXT), + (count) => t('stackiq', '{count} restored', { count }, AS_TEXT), ], ] .map(([key, text]) => [Number(summary[key]) || 0, text]) From c55a96d8810cd12c7e804406e0a81537649ea92b Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:07:37 +0200 Subject: [PATCH 15/18] test(cmdb-import): wait on a rendered list, not networkidle, and fail loudly on a bad lens read Closes "e2e waits for networkidle, which never settles on Nextcloud (gate-58)": the archive test now waits for a working application row to be visible before asserting that the archived application is absent, so the negative assertion cannot pass on a list that has not rendered. No other networkidle wait remains in the touched spec (git grep over tests/e2e shows only comments explaining why it is avoided). Closes thread 4248187506 (e2e cleanup comment): the header and the helper doc no longer claim that an interrupted run leaves nothing behind or that _includeDeleted adds the trash (a soft-deleted row cannot be listed, design D9); objectsInLens now fails the test on a non-OK response instead of returning an empty list that made cleanup skip silently. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/spec-coverage/cmdb-import.spec.ts | 31 +++++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts index c0bd8d3e3..e86047e41 100644 --- a/tests/e2e/spec-coverage/cmdb-import.spec.ts +++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -33,8 +33,10 @@ * moved APPID 1234 to "Gearchiveerde Applicaties" and dropped APPID 2 archives * the one and moves the other to the trash, the Applications page lists 1234 * under "Archived", and the original export brings both back. The cleanup - * looks through OpenRegister's archive and trash lenses too, so an - * interrupted run leaves nothing archived behind. + * looks through OpenRegister's archive lens too, so an interrupted run + * leaves no archived application of the run's municipality behind; a + * soft-deleted one cannot be listed and stays in the trash until its purge + * date (design.md, D9). */ import type { APIRequestContext, Locator, Page, Response } from '@playwright/test' @@ -140,7 +142,8 @@ async function objectsOfMunicipality( /** * The objects of a schema that mention a municipality, through OpenRegister's * archive and trash lenses: `_archived` `true` lists the archive alone, `any` - * the archive and the working set; `_includeDeleted` adds the trash. + * the archive and the working set. `_includeDeleted` is sent for completeness: + * the list cannot return a soft-deleted row even then (design.md, D9). * * @param ctx The API context * @param schema The schema id @@ -158,9 +161,10 @@ async function objectsInLens( const res = await ctx.get( `/index.php/apps/openregister/api/objects/${config.register}/${schema}?_limit=5000&_archived=${lens.archived}&_includeDeleted=${lens.includeDeleted}`, ) - if (!res.ok()) { - return [] - } + expect( + res.ok(), + `Listing ${schema} with _archived=${lens.archived} failed with ${res.status()}`, + ).toBe(true) const body = await res.json() const list = body?.results ?? body ?? [] return (Array.isArray(list) ? list : []).filter((row) => @@ -358,7 +362,8 @@ test.describe.serial('CMDB import section', () => { config.contactpersoon_schema, config.module_schema, ]) { - // Archived and deleted objects too: the archive test may have stopped half-way. + // Archived objects too: the archive test may have stopped half-way. A + // soft-deleted one is not listable and stays in the trash until it is purged. for (const row of await objectsInLens( ctx, schema, @@ -560,8 +565,16 @@ test.describe.serial('CMDB import section', () => { // 2. The Applications page lists 1234 only under the quick filter "Archived". await gotoAppRoute(page, '/modules') await dismissSupportDialog(page) - // Not under the default tab: wait for the list to settle, then look. - await page.waitForLoadState('networkidle') + // Not under the default tab. Nextcloud keeps polls open, so no network + // idle wait: wait until the list shows a working application (the + // export brought more than the two this test took out), then look. + expect( + working.modules, + 'the working set must still hold an application the list can show', + ).toBeGreaterThan(0) + await expect(page.locator('tbody tr').first()).toBeVisible({ + timeout: 30000, + }) await expect(page.getByText(ARCHIVED_APPLICATION_NAME)).toHaveCount(0) await page.getByRole('tab', { name: 'Archived', exact: true }).click() await expect(page.getByText(ARCHIVED_APPLICATION_NAME).first()).toBeVisible({ From 4b70494f196b0827470980ea242d6df8c4a9db9a Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:07:37 +0200 Subject: [PATCH 16/18] fix(applications): say on the Archived tab that facets and search are ignored Closes "Archived tab keeps facet sidebar and search active but ignores them": FacetedCatalogIndexView tracks the active quick-filter tab and shows a note under the toolbar while a tab whose filter carries the empty _ids override is active; en and nl strings and the generated l10n js are added. A mounted view test asserts the note appears on the Archived tab, is gone on another tab, and goes red with the note removed. Closes "Archived-tab facet override is tested against a local merge, not the view": the facet-override test now calls the library's own useSelfFetchList and reads the fixedFilters getter it hands the list composable, instead of restating the spread; only the list composable and store are replaced. It goes red when the _ids override is removed from the manifest. The source grep test that pinned the merge order is dropped as redundant. Class sweep: the Services page uses the same view but has no quick filter with the override, so it shows no note. Co-Authored-By: Claude Opus 5.5 --- l10n/en.js | 3 +- l10n/en.json | 3 +- l10n/nl.js | 3 +- l10n/nl.json | 3 +- src/views/FacetedCatalogIndexView.vue | 49 +++++++++- tests/vitest/cmdbArchiveQuickFilter.spec.js | 86 +++++++++++----- .../vitest/facetedCatalogArchiveNote.spec.js | 98 +++++++++++++++++++ 7 files changed, 214 insertions(+), 31 deletions(-) create mode 100644 tests/vitest/facetedCatalogArchiveNote.spec.js diff --git a/l10n/en.js b/l10n/en.js index 613cb76f2..c4ee74c0a 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1153,7 +1153,8 @@ OC.L10N.register( "{count} unarchived": "{count} unarchived", "{count} deleted": "{count} deleted", "{count} restored": "{count} restored", - "Applications: {changes}.": "Applications: {changes}." + "Applications: {changes}.": "Applications: {changes}.", + "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "This tab lists the whole archive. It ignores the GEMMA filters and the search term." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/en.json b/l10n/en.json index 29d312f53..22795a655 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1152,6 +1152,7 @@ "{count} unarchived": "{count} unarchived", "{count} deleted": "{count} deleted", "{count} restored": "{count} restored", - "Applications: {changes}.": "Applications: {changes}." + "Applications: {changes}.": "Applications: {changes}.", + "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "This tab lists the whole archive. It ignores the GEMMA filters and the search term." } } diff --git a/l10n/nl.js b/l10n/nl.js index 1dc8b83d9..7407af21b 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1221,7 +1221,8 @@ OC.L10N.register( "{count} unarchived": "{count} uit het archief gehaald", "{count} deleted": "{count} verwijderd", "{count} restored": "{count} teruggezet", - "Applications: {changes}.": "Applicaties: {changes}." + "Applications: {changes}.": "Applicaties: {changes}.", + "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "Dit tabblad toont het hele archief. De GEMMA-filters en de zoekterm worden genegeerd." }, "nplurals=2; plural=(n != 1);" ) diff --git a/l10n/nl.json b/l10n/nl.json index 7963d0ebc..26705400c 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1220,6 +1220,7 @@ "{count} unarchived": "{count} uit het archief gehaald", "{count} deleted": "{count} verwijderd", "{count} restored": "{count} teruggezet", - "Applications: {changes}.": "Applicaties: {changes}." + "Applications: {changes}.": "Applicaties: {changes}.", + "This tab lists the whole archive. It ignores the GEMMA filters and the search term.": "Dit tabblad toont het hele archief. De GEMMA-filters en de zoekterm worden genegeerd." } } diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue index 427245c47..cfb2737b2 100644 --- a/src/views/FacetedCatalogIndexView.vue +++ b/src/views/FacetedCatalogIndexView.vue @@ -92,6 +92,18 @@ generic route-query-to-filter passthrough never sees it (see the
+

+ {{ + t( + 'stackiq', + 'This tab lists the whole archive. It ignores the GEMMA filters and the search term.', + ) + }} +

+
+ @view="openDetail" + @quickFilterChange="onQuickFilterChange" />
@@ -281,6 +294,7 @@ export default { showSaveViewModal: false, savingView: false, searchDebounceTimer: null, + activeQuickFilterIndex: null, } }, @@ -340,6 +354,20 @@ export default { ) }, + /** + * Whether the active quick-filter tab drops the facet id set and the + * search term: a tab whose filter carries the empty-string `_ids` + * override (the Archived tab, see the `quickFilters` prop). The facet + * sidebar and the search box stay on screen, so the page says so. + * + * @return {boolean} True while such a tab is active. + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017 + */ + archivedTabIgnoresFilters() { + const tab = (this.quickFilters ?? [])[this.activeQuickFilterIndex] + return tab?.filter?._ids === '' + }, + /** * Remount key for `CnIndexPage`: its self-fetch mode has no reactive * watcher on the `filter` prop (only route/quick-filter/tab changes @@ -375,6 +403,18 @@ export default { }, methods: { + /** + * Remember which quick-filter tab is active (single-select mode emits the + * tab index; multi-select emits an array, which never selects the + * Archived tab here). + * + * @param {number|number[]} index The tab index, or the selected indices + * @return {void} + */ + onQuickFilterChange(index) { + this.activeQuickFilterIndex = Number.isInteger(index) ? index : null + }, + /** * Open the detail page of a clicked row, or of the row whose View action was used. * @@ -538,6 +578,13 @@ export default { flex: 1; } +.faceted-catalog-index__archive-note { + color: var(--color-text-maxcontrast); + margin: 0; + padding: 0 calc(var(--default-grid-baseline, 8px) * 2) + calc(var(--default-grid-baseline, 8px) * 2); +} + .faceted-catalog-index__body { display: flex; align-items: flex-start; diff --git a/tests/vitest/cmdbArchiveQuickFilter.spec.js b/tests/vitest/cmdbArchiveQuickFilter.spec.js index ff4df73ee..f93a42ba1 100644 --- a/tests/vitest/cmdbArchiveQuickFilter.spec.js +++ b/tests/vitest/cmdbArchiveQuickFilter.spec.js @@ -18,12 +18,34 @@ import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import { describe, expect, it, vi } from 'vitest' +import { effectScope } from 'vue' // resolveFilterTokens reads the current user at import time, which needs a // browser; the tokens this spec merges (`_archived`, `_ids`) resolve no user. vi.mock('@nextcloud/auth', () => ({ getCurrentUser: () => null })) +// useSelfFetchList is the real function; only the list composable it feeds its +// `fixedFilters` getter to is replaced, so the test can read the options it got. +const captured = vi.hoisted(() => ({ options: null })) +vi.mock( + '../../node_modules/@conduction/nextcloud-vue/src/composables/index.js', + () => ({ + useListView: (objectType, options) => { + captured.options = options + return { refresh: () => {}, searchTerm: { value: '' } } + }, + }), +) +vi.mock( + '../../node_modules/@conduction/nextcloud-vue/src/composables/useObjectSubscription.js', + () => ({ + useObjectSubscription: () => ({}), + }), +) +vi.mock('../../node_modules/@conduction/nextcloud-vue/src/store/index.js', () => ({ + useObjectStore: () => ({ registerObjectType: () => {} }), +})) +import { useSelfFetchList } from '../../node_modules/@conduction/nextcloud-vue/src/components/CnIndexPage/useSelfFetchList.js' // The REAL implementations from the installed package — not a local restatement. import { buildQueryString } from '../../node_modules/@conduction/nextcloud-vue/src/utils/headers.js' -import { resolveFilterMap } from '../../node_modules/@conduction/nextcloud-vue/src/utils/routeFilters.js' import { facetNarrowingFilter } from '../../src/utils/facetNarrowing.js' const here = path.dirname(fileURLToPath(import.meta.url)) @@ -74,41 +96,53 @@ describe('the archive quick filter', () => { // matched) and merges the active tab's filter over it. OpenRegister leaves // archived objects out of that id set, so `_ids` AND `_archived=true` lists // nothing: with a facet selected, the Archived tab must not send `_ids`. + // The merge is the library's own: useSelfFetchList hands useListView a + // `fixedFilters` getter, which is called here exactly as a fetch calls it. it('sends no _ids with the Archived tab when a facet narrowed the list', () => { const page = pages().find((candidate) => candidate.id === 'Modules') - const archived = page.config.quickFilters.find( + const quickFilters = page.config.quickFilters + const archivedIndex = quickFilters.findIndex( (tab) => tab.label === 'Archived', ) const facetFilter = facetNarrowingFilter(['live-1', 'live-2']) - // The merge useSelfFetchList#fixedFilters does in single-tab mode: - // the page's filter, then the active tab's filter spread last. - const archivedQuery = buildQueryString({ - ...facetFilter, - ...resolveFilterMap(archived.filter, {}, {}), - }) - const allQuery = buildQueryString({ - ...facetFilter, - ...resolveFilterMap({}, {}, {}), - }) + /** + * The query string the library builds for the list with one tab active. + * + * @param {number|null} tabIndex The active quick-filter tab + * @return {string} The query string + */ + function queryWithTab(tabIndex) { + const scope = effectScope() + let fixedFilters + captured.options = null + scope.run(() => { + const result = useSelfFetchList( + { + register: 'voorzieningen', + schema: 'module', + filter: facetFilter, + quickFilters, + columns: [], + }, + null, + () => null, + ) + result.activeQuickFilterIndex.value = tabIndex + fixedFilters = captured.options.fixedFilters + }) + const query = buildQueryString(fixedFilters()) + scope.stop() + return query + } + + const archivedQuery = queryWithTab(archivedIndex) + const otherQuery = queryWithTab(archivedIndex === 0 ? 1 : 0) expect(new URLSearchParams(archivedQuery).get('_archived')).toBe('true') expect(archivedQuery).not.toContain('_ids') // A tab without the override keeps the facet narrowing. - expect(allQuery).toContain('_ids') - }) - - it('still merges the active tab last, which the override above relies on', () => { - const source = fs.readFileSync( - path.join( - repoRoot, - 'node_modules/@conduction/nextcloud-vue/src/components/CnIndexPage/useSelfFetchList.js', - ), - 'utf8', - ) - expect(source).toMatch( - /\.\.\.base, \.\.\.resolveFilterMap\(tabFilter, params, ctx\)/, - ) + expect(otherQuery).toContain('_ids') }) it('the Modules page note names the Archived tab as the exception to the additive merge', () => { diff --git a/tests/vitest/facetedCatalogArchiveNote.spec.js b/tests/vitest/facetedCatalogArchiveNote.spec.js new file mode 100644 index 000000000..53f397737 --- /dev/null +++ b/tests/vitest/facetedCatalogArchiveNote.spec.js @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +/** + * The Archived tab of the Applications page says it ignores the facets and + * the search term. + * + * The facet sidebar and the search box stay on screen on that tab, but its + * filter carries an empty `_ids` that drops the facet id set. Without a note + * an admin who picks a facet sees an unchanged list and no reason. + * + * @spec openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017 + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' + +// CnIndexPage stands in as a component that emits what the real one emits +// when a quick-filter tab is chosen. +vi.mock('@conduction/nextcloud-vue', () => ({ + CnFacetSidebar: defineComponent({ + name: 'CnFacetSidebar', + render: () => h('div'), + }), + CnIndexPage: defineComponent({ + name: 'CnIndexPage', + emits: ['quick-filter-change'], + render() { + return h('div', { class: 'index-page-stub' }) + }, + }), +})) +vi.mock('../../src/modals/SaveFacetViewModal.vue', () => ({ + default: defineComponent({ name: 'SaveFacetViewModal', render: () => h('div') }), +})) + +import FacetedCatalogIndexView from '../../src/views/FacetedCatalogIndexView.vue' + +const NOTE = '.faceted-catalog-index__archive-note' +const QUICK_FILTERS = [ + { label: 'All', filter: {}, default: true }, + { label: 'Archived', filter: { _archived: 'true', _ids: '' } }, +] + +/** + * Mount the view with the facet store's network calls silenced. + * + * @return {object} The wrapper + */ +function mountView() { + setActivePinia(createPinia()) + return mount(FacetedCatalogIndexView, { + props: { + schema: 'module', + register: 'voorzieningen', + title: 'Applications', + quickFilters: QUICK_FILTERS, + }, + global: { + mocks: { + $route: { query: {} }, + $router: { push: vi.fn() }, + t: (app, text) => text, + }, + stubs: { + NcTextField: true, + NcButton: true, + NcActions: true, + NcActionButton: true, + NcActionCaption: true, + }, + plugins: [ + (app) => { + app.config.globalProperties.t = (a, text) => text + }, + ], + }, + }) +} + +describe('FacetedCatalogIndexView on the Archived tab', () => { + it('shows the note only while the Archived tab is active', async () => { + const wrapper = mountView() + const index = wrapper.findComponent({ name: 'CnIndexPage' }) + + expect(wrapper.find(NOTE).exists()).toBe(false) + + await index.vm.$emit('quick-filter-change', 1) + expect(wrapper.find(NOTE).exists()).toBe(true) + expect(wrapper.find(NOTE).text()).toContain('ignores the GEMMA filters') + + await index.vm.$emit('quick-filter-change', 0) + expect(wrapper.find(NOTE).exists()).toBe(false) + }) +}) From 3c17b477431b75c366a2ffc4b5665494817eac5f Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:07:37 +0200 Subject: [PATCH 17/18] docs(cmdb-import): tick the UI tasks and document the archive tab note and unreadable CMDB APPIDs Makes tasks.md true for this PR after the merge of the reconcile base: the status line names #1286 for tasks 1-5 and this PR for task 6 (ticked, with the new Archived-tab note as an acceptance line); task 7 has its implementation ticked and its Playwright run still open. docs/features/cmdb-import.md already described archive and soft delete (the old "left as they are" sentence only remains for the Keep choice); it now also says that an APPID without a cached value on a CMDB sheet archives instead of deleting, and that the Archived tab shows a note about the ignored facets and search. Co-Authored-By: Claude Opus 5.5 --- docs/features/cmdb-import.md | 4 ++-- .../changes/cmdb-import-archive-reconciliation/tasks.md | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index c4d839903..e71756d73 100644 --- a/docs/features/cmdb-import.md +++ b/docs/features/cmdb-import.md @@ -277,13 +277,13 @@ APPID is on neither CMDB sheet: |---|---| | on the sheet `Gearchiveerde Applicaties` | are **archived**: OpenRegister's archive state, with the reason `cmdb-import: on sheet "Gearchiveerde Applicaties"`. Reported as *archived*. | | on no sheet at all | are **deleted**: moved to OpenRegister's trash, where they stay restorable for 30 days. Reported as *deleted*. | -| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten). Such a workbook therefore never moves anything to the trash. | +| on no sheet, but the archive sheet cannot be relied on: the workbook has no sheet `Gearchiveerde Applicaties`, the sheet has no `APPID` column, or an `APPID` in it has no cached value (a formula whose result the file does not store), or an `APPID` on one of the two CMDB sheets has no cached value (the import cannot tell which application that row stands for) | are **archived**, never deleted. Each case has its own warning in the result: *sheet not found*, *no column APPID*, or *an APPID without a cached value in row …* (for more rows: *in N rows (…)*, naming the first ten; for a CMDB sheet the warning names that sheet). Such a workbook therefore never moves anything to the trash. | An archived or deleted application disappears from the stackiq lists, from OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick filter **Archived** on the **Applications** and **Applications in use** pages lists the archive. On the **Applications** page the **Archived** tab lists -the whole archive: it ignores the GEMMA filters and the search box, because +the whole archive: it ignores the GEMMA filters and the search box (the page says so in a note under the search box while that tab is active), because their counts and matches describe the working set, which leaves archived applications out. An OpenCatalogi caller finds archived applications by adding `_archived=true` to its request. Contact persons are never archived diff --git a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md index b75a1fb11..76d347861 100644 --- a/openspec/changes/cmdb-import-archive-reconciliation/tasks.md +++ b/openspec/changes/cmdb-import-archive-reconciliation/tasks.md @@ -4,7 +4,7 @@ Spec: `openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-imp ## Implementation Tasks -Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented here (the reader's tasks 1-2 come from the base PR). Tasks 6-7 stay unticked: the UI lands in #1287, and the docs and the e2e run of task 7 are not done yet. +Status in this PR (the reconciliation slice, on top of the reader slice): Tasks 1-5 are implemented in #1286 (the reader's tasks 1-2 come from the base PR). Task 6 (the UI) is implemented in #1287; task 7 has its fixture, docs and e2e spec written there, and the Playwright run is the one thing not done yet. ### Task 1: Schema opt-in and the import profile - **spec_ref**: `SPEC#requirement-archived-applications-and-usages-shall-be-listable-in-stackiq-and-shall-stay-out-of-opencatalogi-and-portaliq-by-default-req-cmdb-017`, `SPEC#requirement-records-missing-from-a-newer-export-shall-be-archived-by-default-or-kept-on-request-req-cmdb-012` @@ -71,9 +71,10 @@ Status in this PR (the reconciliation slice, on top of the reader slice): Tasks - GIVEN the section THEN a labelled choice "Applications missing from the export" offers Archive (default) and Keep with help text, and `buildImportForm()` sends the choice as `missingRecords` - GIVEN a report with the new outcomes THEN the summary shows their tiles, the outcome filter offers them, and a reconciliation row shows no row number - GIVEN the Applications and Applications in use pages THEN a quick filter "Archived" sends `_archived=true` + - GIVEN the Applications page with the "Archived" tab active THEN a note under the toolbar says the tab ignores the GEMMA filters and the search term, and it is gone on any other tab - Every new string is in `l10n/en.json` and `l10n/nl.json`, and the generated `l10n/*.js` are current -- [ ] Implement -- [ ] Test +- [x] Implement +- [x] Test ### Task 7: Fixture, docs and e2e - **spec_ref**: `SPEC#requirement-an-application-missing-from-the-cmdb-sheets-shall-be-archived-when-the-archive-sheet-lists-it-and-soft-deleted-when-no-sheet-does-req-cmdb-015` @@ -82,7 +83,7 @@ Status in this PR (the reconciliation slice, on top of the reader slice): Tasks - GIVEN `build-fixtures.py` THEN it derives `topdesk-archived-applications.xlsx`: APPID 1234 on the archive sheet only, APPID 2 on no sheet, both CMDB sheets present and empty; the hygiene test passes on it - GIVEN the e2e THEN after the first import, importing the variant archives 1234 and soft-deletes 2, the Applications page lists 1234 under "Archived", and importing the original again reports 1234 `unarchived` and 2 `restored` with the object counts back to the first import's - GIVEN the docs THEN "Repeat imports" describes archive, soft delete, the archive sheet, the option and the return of an application, and the error table has `ARCHIVE_UNAVAILABLE` and the new meaning of `MISSING_RECORDS_UNSUPPORTED` -- [ ] Implement +- [x] Implement - [ ] Test - Status: PHPUnit covers the fixture round trip (testTheArchivedApplicationsFixtureArchivesDeletesAndBringsBack); the Playwright test is written and not run yet, because no Nextcloud was reachable from the worktree. From 1719b19ee8def2633ba9fc35e7fa4f8df10f2ad5 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Tue, 13 Oct 2026 19:17:39 +0200 Subject: [PATCH 18/18] fix(applications): keep the Archived-tab note in step with the list's tab The view started with no known tab and only learned of a change from the tab's click, but it remounts CnIndexPage whenever the facet or search key changes, which puts the page back on its default tab; the note then showed over the default list. The view now starts from the tab the library starts on (first quick filter marked default, else the first) and returns to it whenever the remount key changes. Tests: the note follows the default tab at mount, and disappears when a search change remounts the list while the Archived tab is active; the remount test goes red without the reset. The e2e wait for a listed application is scoped to the Applications list container, so a row of another table cannot satisfy it. Co-Authored-By: Claude Opus 5.5 --- src/views/FacetedCatalogIndexView.vue | 29 ++++++++++++++++++- tests/e2e/spec-coverage/cmdb-import.spec.ts | 4 ++- .../vitest/facetedCatalogArchiveNote.spec.js | 29 +++++++++++++++++-- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue index cfb2737b2..6cb3f8aec 100644 --- a/src/views/FacetedCatalogIndexView.vue +++ b/src/views/FacetedCatalogIndexView.vue @@ -171,6 +171,21 @@ const DIMENSION_LABELS = { domain: () => t('stackiq', 'Domain'), } +/** + * The tab CnIndexPage starts on: the first quick filter marked `default`, + * else the first one (the library's own rule in `useSelfFetchList.js`). + * + * @param {Array|null} quickFilters The quick filters + * @return {number|null} The index, or null without quick filters + */ +function initialQuickFilterIndex(quickFilters) { + if (!Array.isArray(quickFilters) || quickFilters.length === 0) { + return null + } + const index = quickFilters.findIndex((tab) => tab && tab.default === true) + return index >= 0 ? index : 0 +} + /** Debounce delay (ms) between search keystrokes and the facets/list refetch. */ const SEARCH_DEBOUNCE_MS = 400 @@ -294,7 +309,7 @@ export default { showSaveViewModal: false, savingView: false, searchDebounceTimer: null, - activeQuickFilterIndex: null, + activeQuickFilterIndex: initialQuickFilterIndex(this.quickFilters), } }, @@ -383,6 +398,18 @@ export default { }, }, + watch: { + /** + * CnIndexPage is remounted when the key changes and starts on its + * default tab again, so the tab this view remembers goes back to it. + * + * @return {void} + */ + indexPageKey() { + this.activeQuickFilterIndex = initialQuickFilterIndex(this.quickFilters) + }, + }, + created() { this.facetStore.setFiltersFromQuery(this.schema, this.$route.query) }, diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts index e86047e41..b10deda29 100644 --- a/tests/e2e/spec-coverage/cmdb-import.spec.ts +++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts @@ -572,7 +572,9 @@ test.describe.serial('CMDB import section', () => { working.modules, 'the working set must still hold an application the list can show', ).toBeGreaterThan(0) - await expect(page.locator('tbody tr').first()).toBeVisible({ + await expect( + page.locator('.faceted-catalog-index__list tbody tr').first(), + ).toBeVisible({ timeout: 30000, }) await expect(page.getByText(ARCHIVED_APPLICATION_NAME)).toHaveCount(0) diff --git a/tests/vitest/facetedCatalogArchiveNote.spec.js b/tests/vitest/facetedCatalogArchiveNote.spec.js index 53f397737..23060dc1e 100644 --- a/tests/vitest/facetedCatalogArchiveNote.spec.js +++ b/tests/vitest/facetedCatalogArchiveNote.spec.js @@ -50,14 +50,14 @@ const QUICK_FILTERS = [ * * @return {object} The wrapper */ -function mountView() { +function mountView(quickFilters = QUICK_FILTERS) { setActivePinia(createPinia()) return mount(FacetedCatalogIndexView, { props: { schema: 'module', register: 'voorzieningen', title: 'Applications', - quickFilters: QUICK_FILTERS, + quickFilters, }, global: { mocks: { @@ -95,4 +95,29 @@ describe('FacetedCatalogIndexView on the Archived tab', () => { await index.vm.$emit('quick-filter-change', 0) expect(wrapper.find(NOTE).exists()).toBe(false) }) + + it('follows the default tab at mount, before any tab event', () => { + expect(mountView().find(NOTE).exists()).toBe(false) + + const archivedFirst = [ + { label: 'Archived', filter: { _archived: 'true', _ids: '' } }, + { label: 'All', filter: {} }, + ] + // No default marked: CnIndexPage starts on the first tab. + expect(mountView(archivedFirst).find(NOTE).exists()).toBe(true) + }) + + it('drops the note when a facet or search change remounts the list on its default tab', async () => { + const wrapper = mountView() + const index = wrapper.findComponent({ name: 'CnIndexPage' }) + await index.vm.$emit('quick-filter-change', 1) + expect(wrapper.find(NOTE).exists()).toBe(true) + + // A search term changes the key CnIndexPage is mounted under. + wrapper.vm.facetStore.module.search = 'mail' + await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() + + expect(wrapper.find(NOTE).exists()).toBe(false) + }) })