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 @@
@@ -451,6 +485,7 @@ import {
interruptedImportError,
isKnownError,
makeCmdbOperationId,
+ MISSING_RECORDS_MODES,
moduleUrl,
municipalityOptions,
normaliseError,
@@ -463,6 +498,15 @@ import {
typedMunicipalityOption,
} from '../../../utils/cmdbImport.js'
+/** The summary tiles shown only when their count is not zero. */
+const COUNTED_ONLY_TILES = [
+ 'unpublished',
+ 'unarchived',
+ 'restored',
+ 'archived',
+ 'deleted',
+]
+
/**
* The "CMDB import" section of stackiq's admin settings.
*
@@ -472,6 +516,7 @@ import {
* the text it renders; none of them may go into v-html.
*
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ * @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 default {
name: 'CmdbImport',
@@ -500,6 +545,7 @@ export default {
selectedFile: null,
updateExisting: true,
publish: true,
+ missingRecords: MISSING_RECORDS_MODES[0],
importing: false,
cancelling: false,
cancelStatus: '',
@@ -522,6 +568,10 @@ export default {
unchanged: 'default',
skipped: 'warning',
failed: 'error',
+ unarchived: 'info',
+ restored: 'info',
+ archived: 'default',
+ deleted: 'warning',
},
}
},
@@ -578,6 +628,7 @@ export default {
*
* @return {Array} One tile per count
* @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
*/
summaryTiles() {
const summary = this.report?.summary || {}
@@ -594,13 +645,21 @@ export default {
key: 'unpublished',
label: t('stackiq', 'Created unpublished'),
},
+ { key: 'unarchived', label: t('stackiq', 'Unarchived') },
+ { key: 'restored', label: t('stackiq', 'Restored') },
+ { key: 'archived', label: t('stackiq', 'Archived') },
+ { key: 'deleted', label: t('stackiq', 'Deleted') },
]
.map((tile) => ({
...tile,
value: Number(summary[tile.key]) || 0,
}))
- // Only an import run with publishing off leaves modules unpublished.
- .filter((tile) => tile.key !== 'unpublished' || tile.value > 0)
+ // Only an import run with publishing off leaves modules unpublished, and
+ // only a re-import archives, deletes or brings back applications.
+ .filter(
+ (tile) =>
+ !COUNTED_ONLY_TILES.includes(tile.key) || tile.value > 0,
+ )
)
},
@@ -920,6 +979,7 @@ export default {
},
updateExisting: this.updateExisting,
publish: this.publish,
+ missingRecords: this.missingRecords,
operationId: this.operationId,
})
const response = await axios.post(importUrl(), form)
@@ -1091,6 +1151,17 @@ export default {
color: var(--color-text-maxcontrast);
}
+.cmdb-import__choice {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.cmdb-import__legend {
+ padding: 0;
+ font-weight: bold;
+}
+
.cmdb-import__help--warning {
color: var(--color-warning-text);
}
diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts
index 6251f2d8d..82ae8e695 100644
--- a/tests/e2e/spec-coverage/cmdb-import.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -28,6 +28,13 @@
* The last test checks, without signing in, that the imported owners are
* not readable anonymously: neither through OpenRegister's objects API nor
* in an OpenCatalogi search hit (skipped when OpenCatalogi is not installed).
+ *
+ * openspec/changes/cmdb-import-archive-reconciliation: a later export that
+ * 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.
*/
import type { APIRequestContext, Locator, Page, Response } from '@playwright/test'
@@ -45,10 +52,18 @@ import {
resolveConfig,
RUN_ID,
} from '../workflows/_fixtures.ts'
+import { dismissSupportDialog, gotoAppRoute } from './_helpers.ts'
const FIXTURES_DIR = path.resolve(__dirname, '../../fixtures/cmdb')
const EXPORT_FIXTURE = path.join(FIXTURES_DIR, 'topdesk-export-anonymised.xlsx')
const MISSING_COLUMN_FIXTURE = path.join(FIXTURES_DIR, 'topdesk-missing-appid.xlsx')
+// The later export: APPID 1234 only on "Gearchiveerde Applicaties", APPID 2 on no sheet.
+const ARCHIVED_FIXTURE = path.join(
+ FIXTURES_DIR,
+ 'topdesk-archived-applications.xlsx',
+)
+// The application name of APPID 1234 in the anonymised export.
+const ARCHIVED_APPLICATION_NAME = 'Aangetekend Mailen'
// The owner values the anonymised export holds (tests/fixtures/cmdb/README.md).
const OWNER_VALUES = ['Achternaam', 'Voornaam', 'Teamleider Applicatiebeheer']
@@ -122,6 +137,37 @@ async function objectsOfMunicipality(
return rows.filter((row) => JSON.stringify(row).includes(uuid))
}
+/**
+ * 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.
+ *
+ * @param ctx The API context
+ * @param schema The schema id
+ * @param lens The list lenses
+ * @param lens.archived The `_archived` value
+ * @param lens.includeDeleted Whether the trash is included
+ * @param uuid The municipality, by default the run's chosen one
+ */
+async function objectsInLens(
+ ctx: APIRequestContext,
+ schema: string,
+ lens: { archived: 'true' | 'any'; includeDeleted: boolean },
+ uuid: string = municipalityUuid,
+): Promise>> {
+ 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 []
+ }
+ const body = await res.json()
+ const list = body?.results ?? body ?? []
+ return (Array.isArray(list) ? list : []).filter((row) =>
+ JSON.stringify(row).includes(uuid),
+ )
+}
+
/**
* The organisations of type Municipality with exactly this name.
*
@@ -312,9 +358,11 @@ test.describe.serial('CMDB import section', () => {
config.contactpersoon_schema,
config.module_schema,
]) {
- for (const row of await objectsOfMunicipality(
+ // Archived and deleted objects too: the archive test may have stopped half-way.
+ for (const row of await objectsInLens(
ctx,
schema,
+ { archived: 'any', includeDeleted: true },
uuid,
)) {
const id = String(
@@ -458,6 +506,81 @@ test.describe.serial('CMDB import section', () => {
await ctx.dispose()
})
+ // @e2e cmdb-export-import::an-application-that-moved-to-the-archive-sheet-is-archived
+ // @e2e cmdb-export-import::the-applications-page-lists-the-archive-on-request
+ // @e2e cmdb-export-import::an-archived-application-returns-to-the-export
+ test('a later export archives what moved to the archive sheet, deletes what left, and the original brings both back', async ({
+ page,
+ }) => {
+ requireFixture(EXPORT_FIXTURE)
+ requireFixture(ARCHIVED_FIXTURE)
+ const ctx = await newApiContext()
+ const before = await countWritten(ctx)
+ expect(
+ before.modules,
+ 'the first import must have written the modules this test archives and deletes',
+ ).toBeGreaterThan(0)
+ const archivedModules = async () =>
+ (
+ await objectsInLens(ctx, config.module_schema, {
+ archived: 'true',
+ includeDeleted: false,
+ })
+ ).map((row) => String(row.externalNumber ?? ''))
+
+ // 1. The later export, with the default choice: archive or delete.
+ let section = await gotoCmdbSection(page)
+ await chooseMunicipality(page)
+ await expect(
+ section.locator(
+ '[data-testid="cmdb-import-missing-records-archive"] input',
+ ),
+ ).toBeChecked()
+ let response = await runImport(page, ARCHIVED_FIXTURE)
+ expect(response.status()).toBe(200)
+
+ await expect(summaryValue(page, 'rowsRead')).toHaveText('0')
+ await expect(summaryValue(page, 'archived')).toHaveText('1')
+ await expect(summaryValue(page, 'deleted')).toHaveText('1')
+ await expect(
+ reportRows(page).locator('[data-outcome="archived"]'),
+ ).toHaveCount(1)
+ await expect(
+ reportRows(page).locator('[data-outcome="deleted"]'),
+ ).toHaveCount(1)
+
+ // 1234 is in the archive, 2 in the trash; neither is in the working set.
+ expect(await archivedModules()).toEqual(['1234'])
+ const working = await countWritten(ctx)
+ expect(working.modules).toBe(before.modules - 2)
+ expect(working.usages).toBe(before.usages - 2)
+ // Contact persons are never archived or deleted.
+ expect(working.contactPersons).toBe(before.contactPersons)
+
+ // 2. The Applications page lists 1234 only under the quick filter "Archived".
+ await gotoAppRoute(page, '/modules')
+ await dismissSupportDialog(page)
+ await page.getByRole('tab', { name: 'Archived', exact: true }).click()
+ await expect(page.getByText(ARCHIVED_APPLICATION_NAME).first()).toBeVisible({
+ timeout: 30000,
+ })
+
+ // 3. The original export brings both back: no duplicates.
+ section = await gotoCmdbSection(page)
+ await chooseMunicipality(page)
+ response = await runImport(page, EXPORT_FIXTURE)
+ expect(response.status()).toBe(200)
+
+ await expect(summaryValue(page, 'unarchived')).toHaveText('1')
+ await expect(summaryValue(page, 'restored')).toHaveText('1')
+ await expect(
+ section.locator('[data-testid="cmdb-import-error"]'),
+ ).toHaveCount(0)
+ expect(await archivedModules()).toEqual([])
+ expect(await countWritten(ctx)).toEqual(before)
+ await ctx.dispose()
+ })
+
// @e2e cmdb-export-import::a-missing-required-column-is-named-in-the-422-response
test('an export without a required column names the column and the sheet', async ({
page,
diff --git a/tests/vitest/cmdbArchiveQuickFilter.spec.js b/tests/vitest/cmdbArchiveQuickFilter.spec.js
new file mode 100644
index 000000000..0fef16d56
--- /dev/null
+++ b/tests/vitest/cmdbArchiveQuickFilter.spec.js
@@ -0,0 +1,62 @@
+/**
+ * The Applications and Applications in use pages list the archive on request.
+ *
+ * OpenRegister leaves archived objects out of every list unless the request
+ * asks for `_archived`; CnIndexPage spreads the active quick filter's map into
+ * the list request as it is, so a quick filter `{ _archived: 'true' }` lists
+ * the archive alone. The CMDB import archives applications that moved to the
+ * sheet "Gearchiveerde Applicaties", and without this filter an admin could
+ * not find them in stackiq any more.
+ *
+ * @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 fs from 'fs'
+import path from 'path'
+import { fileURLToPath } from 'url'
+import { describe, expect, it } from 'vitest'
+
+const here = path.dirname(fileURLToPath(import.meta.url))
+const repoRoot = path.resolve(here, '../..')
+
+/**
+ * Every page of the manifest and its fragments.
+ *
+ * @return {Array} The pages
+ */
+function pages() {
+ const files = [
+ path.join(repoRoot, 'src/manifest.json'),
+ ...fs
+ .readdirSync(path.join(repoRoot, 'src/manifest.d'))
+ .filter((name) => name.endsWith('.json'))
+ .map((name) => path.join(repoRoot, 'src/manifest.d', name)),
+ ]
+ return files.flatMap(
+ (file) => JSON.parse(fs.readFileSync(file, 'utf8')).pages ?? [],
+ )
+}
+
+describe('the archive quick filter', () => {
+ it.each(['Modules', 'Gebruik'])(
+ 'the %s page lists the archive alone under "Archived"',
+ (pageId) => {
+ const page = pages().find((candidate) => candidate.id === pageId)
+ const archived = (page?.config?.quickFilters ?? []).filter(
+ (tab) => tab.label === 'Archived',
+ )
+
+ expect(archived).toHaveLength(1)
+ expect(archived[0].filter).toEqual({ _archived: 'true' })
+ expect(archived[0].default).not.toBe(true)
+ },
+ )
+
+ it('uses an icon the app registers', () => {
+ const icons = fs.readFileSync(path.join(repoRoot, 'src/icons.js'), 'utf8')
+ expect(icons).toMatch(/^\tArchiveOutline,$/m)
+ })
+})
From f6ae270882b12ceb6f1c03f70376dd99f19770b8 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 14:41:15 +0200
Subject: [PATCH 02/18] fix(cmdb-import): stop telling the admin missing
applications stay and to split the export
With missingRecords archive as the default, two texts were now wrong:
- The section's notes said applications missing from a newer export "are
left as they are"; they are archived or deleted unless the admin keeps
them.
- The TOO_MANY_ROWS, FILE_TOO_LARGE and WORKBOOK_TOO_LARGE hints, in the
section and in the feature docs, advised splitting the export and
importing the parts one after the other. An archiving import of one part
treats the applications of the other parts as missing, so each part
archived or deleted the others. The hints now say to import every part
with "Keep them as they are".
Dutch follows in nl.js and nl.json. Checked with jest (cmdbImport.spec.js,
CmdbImport.spec.js: 86 passed), test:l10n and check:l10n-js; eslint could
not run locally (Node lacks module.findPackageJSON).
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 6 +++---
l10n/en.js | 10 +++++-----
l10n/en.json | 10 +++++-----
l10n/nl.js | 10 +++++-----
l10n/nl.json | 10 +++++-----
src/utils/cmdbImport.js | 8 ++++----
src/utils/cmdbImport.spec.js | 2 +-
src/views/settings/sections/CmdbImport.vue | 2 +-
8 files changed, 29 insertions(+), 29 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index d4077f959..184fc6932 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -344,14 +344,14 @@ and the section shows the reason and the error code.
| Error code | What it means | What to do |
|---|---|---|
| `NOT_XLSX` | The file is not an Excel workbook: wrong extension, or the content is not an `.xlsx` package. | Save the export as Excel workbook (`.xlsx`). |
-| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read, or split the export. |
+| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read. Or split the export and import every part with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. |
| `NO_FILE_UPLOADED` | No file arrived. | Choose the file again. |
| `MUNICIPALITY_REQUIRED` | No municipality was chosen. | Pick or type a municipality. |
| `MUNICIPALITY_INVALID` | The chosen organisation does not exist or is not of type Municipality. | Pick an organisation of type Municipality, or type a new name. |
| `NO_SOURCE_SHEET` | Neither `Onbeh Applicaties CMDB` nor `Beheerde Applicaties CMDB` is in the workbook. | Check the sheet names; they must match exactly. |
| `MISSING_COLUMN` | A present CMDB sheet has no `APPID` or `Applicatie Naam` column. The message names the sheet and the column. | Add the column to that sheet. |
-| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other. |
-| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import them one after the other. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
+| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. |
+| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
| `MUNICIPALITY_AMBIGUOUS` | More than one municipality has the typed name. The import does not guess which one. | Pick the municipality from the list instead of typing its name. |
| `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. |
diff --git a/l10n/en.js b/l10n/en.js
index 8e95ef029..b424aa790 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -982,7 +982,7 @@ OC.L10N.register(
"Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.",
"Import progress": "Import progress",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.",
"Importing the export…": "Importing the export…",
"Importing…": "Importing…",
"APPID": "APPID",
@@ -999,7 +999,7 @@ OC.L10N.register(
"Reasons and warnings": "Reasons and warnings",
"Records missing from the export can only be kept.": "Records missing from the export can only be kept.",
"Reload the page and try again.": "Reload the page and try again.",
- "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".",
"Row": "Row",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.",
"Rows": "Rows",
@@ -1100,8 +1100,8 @@ OC.L10N.register(
"The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.",
"The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.",
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
- "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
"The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
@@ -1139,7 +1139,7 @@ OC.L10N.register(
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.",
- "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"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.",
diff --git a/l10n/en.json b/l10n/en.json
index 0d85cfbaf..c4e679026 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -981,7 +981,7 @@
"Import for {name} cancelled after {read} rows.": "Import for {name} cancelled after {read} rows.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.",
"Import progress": "Import progress",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.",
"Importing the export…": "Importing the export…",
"Importing…": "Importing…",
"APPID": "APPID",
@@ -998,7 +998,7 @@
"Reasons and warnings": "Reasons and warnings",
"Records missing from the export can only be kept.": "Records missing from the export can only be kept.",
"Reload the page and try again.": "Reload the page and try again.",
- "Remove sheets the import does not read, or split the export, and try again.": "Remove sheets the import does not read, or split the export, and try again.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".",
"Row": "Row",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.",
"Rows": "Rows",
@@ -1099,8 +1099,8 @@
"The server could not store the uploaded file. The details are in the Nextcloud log.": "The server could not store the uploaded file. The details are in the Nextcloud log.",
"The file is larger than {size}, the most the import accepts.": "The file is larger than {size}, the most the import accepts.",
"The file is larger than the server accepts.": "The file is larger than the server accepts.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.",
- "Split the export and import the parts one after the other.": "Split the export and import the parts one after the other.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
"The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
@@ -1138,7 +1138,7 @@
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: the application with this import key is used by another organisation, so it is not changed",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.",
- "Split the rows over two copies of the export and import them one after the other.": "Split the rows over two copies of the export and import them one after the other.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"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.",
diff --git a/l10n/nl.js b/l10n/nl.js
index b03118333..b97e21dd2 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1050,7 +1050,7 @@ OC.L10N.register(
"Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.",
"Import progress": "Voortgang van de import",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd of verwijderd, tenzij je kiest ze te laten zoals ze zijn.",
"Importing the export…": "De export wordt geïmporteerd…",
"Importing…": "Importeren…",
"APPID": "APPID",
@@ -1067,7 +1067,7 @@ OC.L10N.register(
"Reasons and warnings": "Redenen en waarschuwingen",
"Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.",
"Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.",
- "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Verwijder tabbladen die de import niet leest en probeer het opnieuw. Of splits de export en importeer elk deel met \"Laat ze zoals ze zijn\".",
"Row": "Rij",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.",
"Rows": "Rijen",
@@ -1168,8 +1168,8 @@ OC.L10N.register(
"The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.",
"The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.",
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
- "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
"The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
@@ -1207,7 +1207,7 @@ OC.L10N.register(
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.",
- "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"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.",
diff --git a/l10n/nl.json b/l10n/nl.json
index 92a16d73e..b5b334fce 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1049,7 +1049,7 @@
"Import for {name} cancelled after {read} rows.": "Import voor {name} geannuleerd na {read} rijen.",
"Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.": "Import voor {name} voltooid. {read} rijen gelezen: {created} aangemaakt, {updated} bijgewerkt, {unchanged} ongewijzigd.",
"Import progress": "Voortgang van de import",
- "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, blijven zoals ze zijn.",
+ "Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.": "Een nieuwere export opnieuw importeren werkt dezelfde applicaties bij, herkend aan het APPID per gemeente. Applicaties die er niet meer in staan, worden gearchiveerd of verwijderd, tenzij je kiest ze te laten zoals ze zijn.",
"Importing the export…": "De export wordt geïmporteerd…",
"Importing…": "Importeren…",
"APPID": "APPID",
@@ -1066,7 +1066,7 @@
"Reasons and warnings": "Redenen en waarschuwingen",
"Records missing from the export can only be kept.": "Records die in de export ontbreken, kunnen alleen worden behouden.",
"Reload the page and try again.": "Laad de pagina opnieuw en probeer het nog eens.",
- "Remove sheets the import does not read, or split the export, and try again.": "Verwijder tabbladen die de import niet leest, of splits de export, en probeer het opnieuw.",
+ "Remove sheets the import does not read and try again. Or split the export and import every part with \"Keep them as they are\".": "Verwijder tabbladen die de import niet leest en probeer het opnieuw. Of splits de export en importeer elk deel met \"Laat ze zoals ze zijn\".",
"Row": "Rij",
"Row 1 holds the column names. \"APPID\" and \"Applicatie Naam\" are required; column order does not matter.": "Rij 1 bevat de kolomnamen. \"APPID\" en \"Applicatie Naam\" zijn verplicht; de volgorde van de kolommen maakt niet uit.",
"Rows": "Rijen",
@@ -1167,8 +1167,8 @@
"The server could not store the uploaded file. The details are in the Nextcloud log.": "De server kon het geüploade bestand niet opslaan. De details staan in het Nextcloud-logboek.",
"The file is larger than {size}, the most the import accepts.": "Het bestand is groter dan {size}, het maximum dat de import accepteert.",
"The file is larger than the server accepts.": "Het bestand is groter dan de server accepteert.",
- "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar.",
- "Split the export and import the parts one after the other.": "Splits de export en importeer de delen na elkaar.",
+ "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
+ "Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
"The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
@@ -1206,7 +1206,7 @@
"conflict: the application with this import key is used by another organisation, so it is not changed": "conflict: de applicatie met deze importsleutel wordt door een andere organisatie gebruikt en wordt daarom niet gewijzigd",
"When on, a re-import overwrites the application's name, descriptions, application type, hosting model, BBN level, source fields and supplier, and the usage's status, phase-out date and business owner, with the values from the export. The usage's TIME classification and internal note are only set when the usage is created or the field is empty, so changes made in stackiq stay.": "Staat dit aan, dan overschrijft een herhaalde import de naam, de omschrijvingen, het applicatietype, het hostingmodel, het BBN-niveau, de bronvelden en de leverancier van de applicatie, en de status, de uitfaseerdatum en de functioneel eigenaar van het gebruik, met de waarden uit de export. De TIME-classificatie en de interne notitie van het gebruik worden alleen gezet wanneer het gebruik wordt aangemaakt of het veld leeg is, zodat wijzigingen in stackiq blijven staan.",
"An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.": "Een .xlsx-bestand is gecomprimeerd: een bestand van een paar MB kan uitgepakt veel groter zijn. Er is niets geïmporteerd.",
- "Split the rows over two copies of the export and import them one after the other.": "Verdeel de rijen over twee kopieën van de export en importeer die na elkaar.",
+ "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"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.",
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 64d7df36d..da602f3a4 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -535,7 +535,7 @@ function workbookTooLarge(details) {
+ ' '
+ t(
'stackiq',
- 'Split the rows over two copies of the export and import them one after the other.',
+ 'Split the rows over two copies of the export and import both with "Keep them as they are": an import that archives treats the applications of the other copy as missing.',
)
if (Number(details.maxPartBytes) > 0) {
const sizes = {
@@ -651,7 +651,7 @@ export function errorText(error) {
),
hint: t(
'stackiq',
- 'Remove sheets the import does not read, or split the export, and try again.',
+ 'Remove sheets the import does not read and try again. Or split the export and import every part with "Keep them as they are".',
),
}
case 'MISSING_RECORDS_UNSUPPORTED':
@@ -765,13 +765,13 @@ export function errorText(error) {
Number(details.limit) > 0
? t(
'stackiq',
- 'A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other.',
+ 'A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with "Keep them as they are".',
{ limit: Number(details.limit).toLocaleString() },
AS_TEXT,
)
: t(
'stackiq',
- 'Split the export and import the parts one after the other.',
+ 'Split the export and import the parts one after the other with "Keep them as they are".',
),
}
case 'MAPPING_UNAVAILABLE':
diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js
index 9377f3b59..24126d167 100644
--- a/src/utils/cmdbImport.spec.js
+++ b/src/utils/cmdbImport.spec.js
@@ -413,7 +413,7 @@ describe('errorText', () => {
'The sheet "Beheerde Applicaties CMDB" is too large to import: unpacked it is 14.3 MB, and the import reads at most 10 MB of one sheet.',
)
expect(words.hint).toBe(
- 'An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported. Split the rows over two copies of the export and import them one after the other.',
+ 'An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported. Split the rows over two copies of the export and import both with "Keep them as they are": an import that archives treats the applications of the other copy as missing.',
)
})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 00af68892..6d728fec7 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -443,7 +443,7 @@
{{
t(
'stackiq',
- 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are left as they are.',
+ 'Importing a newer export again updates the same applications, matched on APPID per municipality. Applications missing from it are archived or deleted, unless you choose to keep them.',
)
}}
From 97c4e9992970f06fca91a36f36a419b588924093 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 14:47:17 +0200
Subject: [PATCH 03/18] docs(cmdb-import): say what a split import with Keep
leaves undone
A split export imported with Keep them as they are archives and deletes
nothing, so the FILE_TOO_LARGE and TOO_MANY_ROWS rows now say that
applications which left the export are then not archived or deleted.
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 184fc6932..ae9b2c240 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -344,13 +344,13 @@ and the section shows the reason and the error code.
| Error code | What it means | What to do |
|---|---|---|
| `NOT_XLSX` | The file is not an Excel workbook: wrong extension, or the content is not an `.xlsx` package. | Save the export as Excel workbook (`.xlsx`). |
-| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read. Or split the export and import every part with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. |
+| `FILE_TOO_LARGE` | The file is larger than the upload limit (10 MB by default). The message names the limit in force. | Remove sheets the import does not read. Or split the export and import every part with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
| `NO_FILE_UPLOADED` | No file arrived. | Choose the file again. |
| `MUNICIPALITY_REQUIRED` | No municipality was chosen. | Pick or type a municipality. |
| `MUNICIPALITY_INVALID` | The chosen organisation does not exist or is not of type Municipality. | Pick an organisation of type Municipality, or type a new name. |
| `NO_SOURCE_SHEET` | Neither `Onbeh Applicaties CMDB` nor `Beheerde Applicaties CMDB` is in the workbook. | Check the sheet names; they must match exactly. |
| `MISSING_COLUMN` | A present CMDB sheet has no `APPID` or `Applicatie Naam` column. The message names the sheet and the column. | Add the column to that sheet. |
-| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. |
+| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
| `MUNICIPALITY_AMBIGUOUS` | More than one municipality has the typed name. The import does not guess which one. | Pick the municipality from the list instead of typing its name. |
| `IMPORT_IN_PROGRESS` | Another CMDB import is running. Only one import runs at a time. | Wait until it has finished and try again. |
From 3671df07cb5c7a0fcad68fb4f85a56d260f3af55 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 16:54:37 +0200
Subject: [PATCH 04/18] fix(cmdb-import): stop counting rows as applications in
the reconciling progress
In the reconciling phase the service reports processed = rows + index and
total = rows + the municipality's applications, so the page showed
"410 of 810" as if it were applications missing from the export, and the bar
fell from 100% to about 50% when the phase began. The scope also holds
applications that are on the sheets and need no change.
The progress view now shows no count for that phase, says it checks the
applications of the municipality against the export, and keeps the bar full.
The l10n key is replaced in place; the docs describe the new progress line.
Review #1287 f3
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 7 ++++--
l10n/en.js | 2 +-
l10n/en.json | 2 +-
l10n/nl.js | 2 +-
l10n/nl.json | 2 +-
src/utils/cmdbImport.js | 16 ++++++++-----
src/utils/cmdbImport.spec.js | 45 ++++++++++++++++++++++++++++--------
7 files changed, 54 insertions(+), 22 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index ae9b2c240..b68dcd471 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -293,8 +293,11 @@ 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
+result lists it. During the comparison the progress line reads
+*Checking the applications of this municipality against the export* without
+a count, and the bar stays full: the comparison covers every application of
+the municipality, not only the ones missing from the export, so its length
+is not known in rows. 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
diff --git a/l10n/en.js b/l10n/en.js
index b424aa790..f3cb07c12 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1052,7 +1052,7 @@ OC.L10N.register(
"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.": "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.",
"Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
- "Checking the applications missing from the export: {processed} of {total}": "Checking the applications missing from the export: {processed} of {total}",
+ "Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
"Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.",
diff --git a/l10n/en.json b/l10n/en.json
index c4e679026..af61de33e 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1051,7 +1051,7 @@
"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.": "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.",
"Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
- "Checking the applications missing from the export: {processed} of {total}": "Checking the applications missing from the export: {processed} of {total}",
+ "Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
"Sheet \"%1$s\" has more than %2$s rows.": "Sheet \"%1$s\" has more than %2$s rows.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Sheet \"%1$s\" has no column \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is not configured: the register or its schemas cannot be found.",
diff --git a/l10n/nl.js b/l10n/nl.js
index b97e21dd2..325e4b120 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1120,7 +1120,7 @@ OC.L10N.register(
"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.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Staat dat tabblad niet in het bestand, dan worden applicaties alleen gearchiveerd. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
"Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
- "Checking the applications missing from the export: {processed} of {total}": "Applicaties controleren die in de export ontbreken: {processed} van {total}",
+ "Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
"Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.",
diff --git a/l10n/nl.json b/l10n/nl.json
index b5b334fce..9e075b958 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1119,7 +1119,7 @@
"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.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Staat dat tabblad niet in het bestand, dan worden applicaties alleen gearchiveerd. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
"Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
- "Checking the applications missing from the export: {processed} of {total}": "Applicaties controleren die in de export ontbreken: {processed} van {total}",
+ "Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
"Sheet \"%1$s\" has more than %2$s rows.": "Tabblad \"%1$s\" heeft meer dan %2$s rijen.",
"Sheet \"%1$s\" has no column \"%2$s\".": "Tabblad \"%1$s\" heeft geen kolom \"%2$s\".",
"Stackiq is not configured: the register or its schemas cannot be found.": "Stackiq is niet ingesteld: het register of de schema's ervan zijn niet gevonden.",
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index da602f3a4..0b834dd2f 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -929,8 +929,12 @@ 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.
+ * In the phase `reconciling` the server's counts add the applications of the
+ * municipality to the rows, and those applications include ones that are on
+ * the sheets and need no change. They are neither rows nor applications
+ * missing from the export, so the view shows no "N of M" for this phase and
+ * keeps the bar where the rows left it (full) instead of letting it fall
+ * back when the total grows.
*
* @param {object|null} progress The snapshot from `GET /api/progress/{operationId}`
* @return {{percentage: number, detail: string}|null} The view, or null before any progress
@@ -947,13 +951,13 @@ export function cmdbProgressView(progress) {
total > 0
? Math.min(100, Math.round((processed / total) * 100))
: Number(progress.percentage) || 0
- if (total > 0 && progress.phase === 'reconciling') {
+ if (progress.phase === 'reconciling') {
return {
- percentage,
+ percentage: 100,
detail: t(
'stackiq',
- 'Checking the applications missing from the export: {processed} of {total}',
- { processed, total },
+ 'Checking the applications of this municipality against the export',
+ {},
AS_TEXT,
),
}
diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js
index 24126d167..3282e3458 100644
--- a/src/utils/cmdbImport.spec.js
+++ b/src/utils/cmdbImport.spec.js
@@ -660,17 +660,42 @@ 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('says the import checks the applications, without counting them as rows', () => {
+ // The service reports rows + the municipality's applications as the total.
+ const view = cmdbProgressView({
+ phase: 'reconciling',
+ processed_items: 400,
+ total_items: 810,
+ })
+ expect(view.detail).toBe(
+ 'Checking the applications of this municipality against the export',
+ )
+ expect(view.detail).not.toMatch(/\d/)
+ })
+
+ it('does not let the bar fall when the phase changes from the rows to the comparison', () => {
+ const lastOfTheRows = cmdbProgressView({
+ phase: 'importing',
+ processed_items: 400,
+ total_items: 400,
+ })
+ const firstOfTheComparison = cmdbProgressView({
+ phase: 'reconciling',
+ processed_items: 400,
+ total_items: 810,
+ })
+ const midComparison = cmdbProgressView({
+ phase: 'reconciling',
+ processed_items: 600,
+ total_items: 810,
})
+ expect(lastOfTheRows.percentage).toBe(100)
+ expect(firstOfTheComparison.percentage).toBeGreaterThanOrEqual(
+ lastOfTheRows.percentage,
+ )
+ expect(midComparison.percentage).toBeGreaterThanOrEqual(
+ lastOfTheRows.percentage,
+ )
})
it('shows nothing before any progress', () => {
From bbf420962c760a492117e33470349e92b54d3f1b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:04:23 +0200
Subject: [PATCH 05/18] fix(cmdb-import): say applications, not only rows, in
the cancel texts
After Cancel the section said the import "stops before the next row" and
"the rows processed before it stopped are kept". During the comparison it
stops before the next application, and the applications archived or deleted
until then stay too; the cancelled banner told the admin nothing of that
while the report listed them.
Both strings now name applications and the l10n keys are replaced in place;
the docs say the same. The spec mounts the section for real so the banner and
the status line are read from the page.
Review #1287 f4
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 5 +-
l10n/en.js | 4 +-
l10n/en.json | 4 +-
l10n/nl.js | 4 +-
l10n/nl.json | 4 +-
.../settings/sections/CmdbImport.spec.js | 155 ++++++++++++++++--
src/views/settings/sections/CmdbImport.vue | 4 +-
7 files changed, 151 insertions(+), 29 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index b68dcd471..84bd60acd 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -86,8 +86,9 @@ page in stackiq.
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
+ applications are compared with the export, before the next application;
+ what was already processed stays: the rows imported, and the applications
+ archived or deleted so far. 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.
diff --git a/l10n/en.js b/l10n/en.js
index f3cb07c12..101da2012 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1017,7 +1017,7 @@ OC.L10N.register(
"The import failed unexpectedly.": "The import failed unexpectedly.",
"The import mapping cannot run.": "The import mapping cannot run.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.",
- "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "The import was cancelled. The rows and applications processed before it stopped are kept.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.",
"The server could not be reached.": "The server could not be reached.",
"The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".",
@@ -1108,7 +1108,7 @@ OC.L10N.register(
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
"The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.",
"Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.",
"Show {count} more rows": "Show {count} more rows",
"The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.",
diff --git a/l10n/en.json b/l10n/en.json
index af61de33e..f43f75422 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1016,7 +1016,7 @@
"The import failed unexpectedly.": "The import failed unexpectedly.",
"The import mapping cannot run.": "The import mapping cannot run.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.",
- "The import was cancelled. The rows processed before it stopped are kept.": "The import was cancelled. The rows processed before it stopped are kept.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "The import was cancelled. The rows and applications processed before it stopped are kept.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.",
"The server could not be reached.": "The server could not be reached.",
"The sheet \"{sheet}\" has no column \"{column}\".": "The sheet \"{sheet}\" has no column \"{column}\".",
@@ -1107,7 +1107,7 @@
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
"The import could not be cancelled: {reason}": "The import could not be cancelled: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "Cancelling the import. It stops before the next row; the rows already processed stay imported.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.",
"Showing {shown} of {total} rows.": "Showing {shown} of {total} rows.",
"Show {count} more rows": "Show {count} more rows",
"The request field \"{field}\" has a value the import does not accept.": "The request field \"{field}\" has a value the import does not accept.",
diff --git a/l10n/nl.js b/l10n/nl.js
index 325e4b120..a8753498c 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1085,7 +1085,7 @@ OC.L10N.register(
"The import failed unexpectedly.": "De import is onverwacht mislukt.",
"The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.",
- "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "De import is geannuleerd. De rijen en applicaties die vóór het stoppen zijn verwerkt, blijven behouden.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.",
"The server could not be reached.": "De server is niet bereikbaar.",
"The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".",
@@ -1176,7 +1176,7 @@ OC.L10N.register(
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
"The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "De import wordt geannuleerd. Hij stopt voor de volgende rij of, tijdens het vergelijken van de applicaties met de export, voor de volgende applicatie; wat al verwerkt is blijft staan.",
"Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.",
"Show {count} more rows": "Toon nog {count} rijen",
"The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.",
diff --git a/l10n/nl.json b/l10n/nl.json
index 9e075b958..eccc3a4c7 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1084,7 +1084,7 @@
"The import failed unexpectedly.": "De import is onverwacht mislukt.",
"The import mapping cannot run.": "De mapping van de import kan niet worden uitgevoerd.",
"The import reads workbooks with the spreadsheet library that ships with OpenRegister. Make sure OpenRegister is installed and enabled.": "De import leest werkmappen met de spreadsheetbibliotheek die met OpenRegister wordt meegeleverd. Zorg dat OpenRegister is geïnstalleerd en ingeschakeld.",
- "The import was cancelled. The rows processed before it stopped are kept.": "De import is geannuleerd. De rijen die vóór het stoppen zijn verwerkt, blijven behouden.",
+ "The import was cancelled. The rows and applications processed before it stopped are kept.": "De import is geannuleerd. De rijen en applicaties die vóór het stoppen zijn verwerkt, blijven behouden.",
"The organisation register is not configured, so existing municipalities cannot be listed. You can still type the name of a municipality.": "Het organisatieregister is niet ingesteld, dus bestaande gemeenten kunnen niet worden getoond. U kunt nog steeds de naam van een gemeente typen.",
"The server could not be reached.": "De server is niet bereikbaar.",
"The sheet \"{sheet}\" has no column \"{column}\".": "Het tabblad \"{sheet}\" heeft geen kolom \"{column}\".",
@@ -1175,7 +1175,7 @@
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
"The import could not be cancelled: {reason}": "De import kon niet worden geannuleerd: {reason}",
- "Cancelling the import. It stops before the next row; the rows already processed stay imported.": "De import wordt geannuleerd. Hij stopt voor de volgende rij; de rijen die al verwerkt zijn blijven geïmporteerd.",
+ "Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.": "De import wordt geannuleerd. Hij stopt voor de volgende rij of, tijdens het vergelijken van de applicaties met de export, voor de volgende applicatie; wat al verwerkt is blijft staan.",
"Showing {shown} of {total} rows.": "{shown} van {total} rijen weergegeven.",
"Show {count} more rows": "Toon nog {count} rijen",
"The request field \"{field}\" has a value the import does not accept.": "Het veld \"{field}\" van het verzoek heeft een waarde die de import niet accepteert.",
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index 7a16b9858..9c1f16dfe 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -9,7 +9,7 @@
*/
import axios from '@nextcloud/axios'
-import { flushPromises, shallowMount } from '@vue/test-utils'
+import { flushPromises, mount, shallowMount } from '@vue/test-utils'
import CmdbImport from './CmdbImport.vue'
// Virtual: the package's `exports` field declares only the `import` condition, which jest does not resolve.
@@ -26,25 +26,85 @@ jest.mock('@nextcloud/router', () => ({
url.replace(/{(\w+)}/g, (match, key) => String(params[key] ?? match)),
generateOcsUrl: (url) => url,
}))
-// The component libraries are stubbed by shallowMount; virtual for the same `exports` reason.
+// The component libraries are stubbed; virtual for the same `exports` reason.
+// shallowMount replaces them with empty stubs. The ones below also render what
+// the tests that mount the section for real (`mountFull`) need to see: the
+// slots, the radio's value and the table's cell slots.
jest.mock(
'@nextcloud/vue',
- () => ({
- NcButton: { render: () => null },
- NcCheckboxRadioSwitch: { render: () => null },
- NcLoadingIcon: { render: () => null },
- NcNoteCard: { render: () => null },
- NcProgressBar: { render: () => null },
- NcSelect: { render: () => null },
- }),
+ () => {
+ const { h } = require('vue')
+ const withSlot = {
+ render() {
+ return h('div', this.$slots.default?.())
+ },
+ }
+ return {
+ NcButton: {
+ render() {
+ return h('button', this.$slots.default?.())
+ },
+ },
+ NcCheckboxRadioSwitch: {
+ props: {
+ modelValue: { default: null },
+ value: { default: null },
+ name: { default: '' },
+ type: { default: 'checkbox' },
+ disabled: { default: false },
+ },
+ emits: ['update:modelValue'],
+ render() {
+ const radio = this.type === 'radio'
+ return h('label', [
+ h('input', {
+ type: radio ? 'radio' : 'checkbox',
+ name: this.name,
+ value: this.value,
+ disabled: this.disabled,
+ checked: radio
+ ? this.modelValue === this.value
+ : Boolean(this.modelValue),
+ onChange: (event) =>
+ this.$emit(
+ 'update:modelValue',
+ radio ? this.value : event.target.checked,
+ ),
+ }),
+ this.$slots.default?.(),
+ ])
+ },
+ },
+ NcLoadingIcon: { render: () => null },
+ NcNoteCard: withSlot,
+ NcProgressBar: { render: () => null },
+ NcSelect: { render: () => null },
+ }
+ },
{ virtual: true },
)
jest.mock(
'@conduction/nextcloud-vue',
- () => ({
- CnDataTable: { render: () => null },
- CnStatusBadge: { render: () => null },
- }),
+ () => {
+ const { h } = require('vue')
+ return {
+ // One element per row, with the cell slots the section fills.
+ CnDataTable: {
+ props: { rows: { default: () => [] } },
+ render() {
+ return h(
+ 'div',
+ this.rows.map((row) =>
+ h('div', { 'data-testid': 'table-row' }, [
+ h('span', this.$slots['column-name']?.({ row })),
+ ]),
+ ),
+ )
+ },
+ },
+ CnStatusBadge: { render: () => null },
+ }
+ },
{ virtual: true },
)
// The icon SFCs live in node_modules, which jest does not transform.
@@ -55,9 +115,14 @@ jest.mock('vue-material-design-icons/DatabaseImport.vue', () => ({
jest.mock('vue-material-design-icons/TrayArrowUp.vue', () => ({
render: () => null,
}))
-jest.mock('../../../components/AlwaysVisibleSection.vue', () => ({
- render: () => null,
-}))
+jest.mock('../../../components/AlwaysVisibleSection.vue', () => {
+ const { h } = require('vue')
+ return {
+ render() {
+ return h('div', this.$slots.default?.())
+ },
+ }
+})
const ORGANISATIONS = [
{
@@ -104,6 +169,24 @@ async function mountSection() {
return wrapper
}
+/**
+ * Mount the section with its child components rendering what the tests read.
+ *
+ * @return {Promise} The wrapper, after the municipalities loaded
+ */
+async function mountFull() {
+ axios.get.mockImplementation((url) =>
+ Promise.resolve(
+ url.includes('/voorzieningen/config')
+ ? { data: { config: { register: 7, organisatie_schema: 33 } } }
+ : { data: { results: ORGANISATIONS } },
+ ),
+ )
+ const wrapper = mount(CmdbImport)
+ await flushPromises()
+ return wrapper
+}
+
describe('CmdbImport municipality chooser', () => {
afterEach(() => {
jest.clearAllMocks()
@@ -205,3 +288,41 @@ describe('CmdbImport applications missing from the export', () => {
)
})
})
+
+/**
+ * @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
+ */
+describe('CmdbImport cancelling', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('says after Cancel that the comparison of the applications stops before the next application', async () => {
+ const wrapper = await mountFull()
+ axios.post.mockResolvedValue({ data: { success: true } })
+ await wrapper.setData({
+ importing: true,
+ operationId: 'cmdb-12345678',
+ progress: { phase: 'reconciling', processed_items: 400, total_items: 810 },
+ })
+
+ await wrapper.vm.cancelImport()
+ await flushPromises()
+
+ const status = wrapper.find('[data-testid="cmdb-import-cancel-status"]').text()
+ expect(status).toContain('before the next row')
+ expect(status).toContain('before the next application')
+ })
+
+ it('says that the applications processed before the stop are kept, not only rows', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: { cancelled: true, summary: { rowsRead: 400, archived: 3 }, rows: [] },
+ })
+
+ expect(wrapper.find('[data-testid="cmdb-import-cancelled"]').text()).toMatch(
+ /applications/,
+ )
+ })
+})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 6d728fec7..c4d804834 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -272,7 +272,7 @@
{{
t(
'stackiq',
- 'The import was cancelled. The rows processed before it stopped are kept.',
+ 'The import was cancelled. The rows and applications processed before it stopped are kept.',
)
}}
@@ -1082,7 +1082,7 @@ export default {
})
this.cancelStatus = t(
'stackiq',
- 'Cancelling the import. It stops before the next row; the rows already processed stay imported.',
+ 'Cancelling the import. It stops before the next row or, while the applications are compared with the export, before the next application; what was already processed stays.',
)
} catch (error) {
// The import keeps running; say why, and let the admin press Cancel again.
From ac3305a76fcea645f679b5aaa6f9db6c216b1f8d Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:08:31 +0200
Subject: [PATCH 06/18] fix(cmdb-import): name archived, unarchived, deleted
and restored in the finished sentence
The sentence the aria-live region announces (and the report heading shows)
only had the row counts, so an import that read 0 rows and archived one
application and deleted one was announced as "0 rows read: 0 created,
0 updated, 0 unchanged" and a screen reader user never heard that
applications were deleted (WCAG 4.1.3).
The sentence now ends with "Applications: ..." listing the non-zero counts,
after a cancel too, and nothing when no application was touched. The docs
mention it.
Review #1287 f5
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 3 +-
l10n/en.js | 7 +-
l10n/en.json | 7 +-
l10n/nl.js | 7 +-
l10n/nl.json | 7 +-
.../settings/sections/CmdbImport.spec.js | 54 +++++++++++++++
src/views/settings/sections/CmdbImport.vue | 67 ++++++++++++++++---
7 files changed, 137 insertions(+), 15 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 84bd60acd..477287026 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -103,7 +103,8 @@ When the import finishes, the section shows:
and warnings, and, when publishing was off, how many applications were
created unpublished; after a repeat import also how many applications
were archived, unarchived, deleted (moved to the trash) or restored, when
- there are any;
+ there are any. The sentence above it, which a screen reader announces,
+ names those counts too, for example *Applications: 1 archived, 1 deleted.*;
- **warnings for the whole file**, for example an optional column that is
missing;
- the **rows** table: sheet, row number, APPID, application, outcome,
diff --git a/l10n/en.js b/l10n/en.js
index 101da2012..3e3c7c0d7 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1146,7 +1146,12 @@ OC.L10N.register(
"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.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "{count} archived": "{count} archived",
+ "{count} unarchived": "{count} unarchived",
+ "{count} deleted": "{count} deleted",
+ "{count} restored": "{count} restored",
+ "Applications: {changes}.": "Applications: {changes}."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index f43f75422..e2d6fd9e0 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1145,6 +1145,11 @@
"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.",
"In development since": "In development since",
- "In use since": "In use since"
+ "In use since": "In use since",
+ "{count} archived": "{count} archived",
+ "{count} unarchived": "{count} unarchived",
+ "{count} deleted": "{count} deleted",
+ "{count} restored": "{count} restored",
+ "Applications: {changes}.": "Applications: {changes}."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index a8753498c..135559111 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1214,7 +1214,12 @@ OC.L10N.register(
"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.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "{count} archived": "{count} gearchiveerd",
+ "{count} unarchived": "{count} uit het archief gehaald",
+ "{count} deleted": "{count} verwijderd",
+ "{count} restored": "{count} teruggezet",
+ "Applications: {changes}.": "Applicaties: {changes}."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index eccc3a4c7..98cdc370d 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1213,6 +1213,11 @@
"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.",
"In development since": "In ontwikkeling sinds",
- "In use since": "In gebruik sinds"
+ "In use since": "In gebruik sinds",
+ "{count} archived": "{count} gearchiveerd",
+ "{count} unarchived": "{count} uit het archief gehaald",
+ "{count} deleted": "{count} verwijderd",
+ "{count} restored": "{count} teruggezet",
+ "Applications: {changes}.": "Applicaties: {changes}."
}
}
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index 9c1f16dfe..6896c2c33 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -326,3 +326,57 @@ describe('CmdbImport cancelling', () => {
)
})
})
+
+/**
+ * @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 finished announcement', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('names the archived and deleted applications in the sentence the live region announces', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: {
+ municipality: { name: 'Gemeente Bergen' },
+ summary: { rowsRead: 0, archived: 1, deleted: 1 },
+ rows: [],
+ },
+ })
+
+ const announced = wrapper.find('[data-testid="cmdb-import-finished"]').text()
+ expect(announced).toContain('0 rows read')
+ expect(announced).toContain('1 archived')
+ expect(announced).toContain('1 deleted')
+ expect(announced).not.toContain('unarchived')
+ expect(announced).not.toContain('restored')
+ })
+
+ it('names every kind of change after a cancel too, and adds nothing when no application was touched', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: {
+ cancelled: true,
+ municipality: { name: 'Gemeente Bergen' },
+ summary: { rowsRead: 5, archived: 2, unarchived: 3, deleted: 4, restored: 5 },
+ rows: [],
+ },
+ })
+ expect(wrapper.vm.finishedText).toBe(
+ 'Import for Gemeente Bergen cancelled after 5 rows. Applications: 2 archived, 3 unarchived, 4 deleted, 5 restored.',
+ )
+
+ // setData merges into the report; a new report replaces it.
+ wrapper.vm.report = {
+ municipality: { name: 'Gemeente Bergen' },
+ summary: { rowsRead: 2, created: 2 },
+ rows: [],
+ }
+ expect(wrapper.vm.finishedText).toBe(
+ 'Import for Gemeente Bergen finished. 2 rows read: 2 created, 0 updated, 0 unchanged.',
+ )
+ })
+})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index c4d804834..0f92b67ae 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -678,28 +678,75 @@ export default {
updated: Number(summary.updated) || 0,
unchanged: Number(summary.unchanged) || 0,
}
+ let sentence
if (this.report?.cancelled) {
- return t(
+ sentence = t(
'stackiq',
'Import for {name} cancelled after {read} rows.',
counts,
AS_TEXT,
)
- }
- if (this.report?.municipality?.created) {
- return t(
+ } else if (this.report?.municipality?.created) {
+ sentence = t(
'stackiq',
'Import finished. The municipality {name} was created. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.',
counts,
AS_TEXT,
)
+ } else {
+ sentence = t(
+ 'stackiq',
+ 'Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.',
+ counts,
+ AS_TEXT,
+ )
}
- return t(
- 'stackiq',
- 'Import for {name} finished. {read} rows read: {created} created, {updated} updated, {unchanged} unchanged.',
- counts,
- AS_TEXT,
- )
+ const applications = this.applicationChangesText
+ return applications === '' ? sentence : `${sentence} ${applications}`
+ },
+
+ /**
+ * What the import did to applications that are not on the sheets: the
+ * non-zero counts, for the sentence the live region announces.
+ *
+ * @return {string} A sentence, or '' when no application was touched
+ * @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
+ */
+ applicationChangesText() {
+ const summary = this.report?.summary || {}
+ const parts = [
+ [
+ 'archived',
+ (count) =>
+ t('stackiq', '{count} archived', { count }, AS_TEXT),
+ ],
+ [
+ 'unarchived',
+ (count) =>
+ t('stackiq', '{count} unarchived', { count }, AS_TEXT),
+ ],
+ [
+ 'deleted',
+ (count) =>
+ t('stackiq', '{count} deleted', { count }, AS_TEXT),
+ ],
+ [
+ 'restored',
+ (count) =>
+ t('stackiq', '{count} restored', { count }, AS_TEXT),
+ ],
+ ]
+ .map(([key, text]) => [Number(summary[key]) || 0, text])
+ .filter(([count]) => count > 0)
+ .map(([count, text]) => text(count))
+ return parts.length === 0
+ ? ''
+ : t(
+ 'stackiq',
+ 'Applications: {changes}.',
+ { changes: parts.join(', ') },
+ AS_TEXT,
+ )
},
/**
From f99c0630b2bd623e252c49215ea835ededb9c88a Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:17:18 +0200
Subject: [PATCH 07/18] fix(cmdb-import): let the Archived tab of the
Applications page ignore the facet id set
The Applications page narrows its list with `_ids` (the objects a GEMMA facet
or the search matched) and merges the active quick filter's map next to it,
so the tab's `_archived=true` was ANDed with an id set that FacetService
builds from the working set. OpenRegister's default lens leaves archived
objects out of that set, so with a facet selected the Archived tab asked for
live ids that are archived and listed nothing.
The Archived tab's filter now also carries an empty-string `_ids`, which wins
over the page's `_ids` in the merge and is left out of the request. An empty
array would not do: the filter resolver drops it before the merge. The tab
lists the whole archive and ignores the GEMMA selection and the search term;
the docs say so. The vitest runs the package's own filter resolver and query
builder, and pins that the active tab is merged last.
Read in @conduction/nextcloud-vue 2.65.0 (the installed version).
Review #1287 f6
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 5 +-
src/manifest.json | 3 +-
src/views/FacetedCatalogIndexView.vue | 12 +++
tests/vitest/cmdbArchiveQuickFilter.spec.js | 84 +++++++++++++++++----
4 files changed, 88 insertions(+), 16 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 477287026..f66fd6626 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -277,7 +277,10 @@ APPID is on neither CMDB sheet:
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
+lists the archive. On the **Applications** page the **Archived** tab lists
+the whole archive: it ignores the GEMMA filters and the search box, 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
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
diff --git a/src/manifest.json b/src/manifest.json
index 7df63875f..245f8c802 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -666,7 +666,8 @@
{
"label": "Archived",
"filter": {
- "_archived": "true"
+ "_archived": "true",
+ "_ids": ""
},
"icon": "ArchiveOutline"
}
diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue
index f80deda08..cc29bb2ec 100644
--- a/src/views/FacetedCatalogIndexView.vue
+++ b/src/views/FacetedCatalogIndexView.vue
@@ -233,6 +233,18 @@ export default {
* `_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.
+ *
+ * The exception is the Archived tab of the Applications page
+ * (cmdb-import-archive-reconciliation): the facet id set is built from
+ * the working set, which OpenRegister's default lens limits to objects
+ * that are not archived, so ANDing `_ids` with `_archived=true` could
+ * never match an archived application. That tab's filter therefore
+ * carries an empty-string `_ids` (not an empty array, which the filter
+ * resolver drops before the merge), which wins over this view's `_ids`
+ * and which the request leaves out: the tab lists the whole archive
+ * and ignores the GEMMA selection and the search term. The tab's
+ * filter, not this view, owns that, so it holds however the page's
+ * tab state is restored.
*/
quickFilters: {
type: Array,
diff --git a/tests/vitest/cmdbArchiveQuickFilter.spec.js b/tests/vitest/cmdbArchiveQuickFilter.spec.js
index 0fef16d56..c048abf8f 100644
--- a/tests/vitest/cmdbArchiveQuickFilter.spec.js
+++ b/tests/vitest/cmdbArchiveQuickFilter.spec.js
@@ -17,7 +17,14 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
+// 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 }))
+// 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))
const repoRoot = path.resolve(here, '../..')
@@ -41,19 +48,68 @@ function pages() {
}
describe('the archive quick filter', () => {
- it.each(['Modules', 'Gebruik'])(
- 'the %s page lists the archive alone under "Archived"',
- (pageId) => {
- const page = pages().find((candidate) => candidate.id === pageId)
- const archived = (page?.config?.quickFilters ?? []).filter(
- (tab) => tab.label === 'Archived',
- )
-
- expect(archived).toHaveLength(1)
- expect(archived[0].filter).toEqual({ _archived: 'true' })
- expect(archived[0].default).not.toBe(true)
- },
- )
+ it('the Gebruik page lists the archive alone under "Archived"', () => {
+ const page = pages().find((candidate) => candidate.id === 'Gebruik')
+ const archived = (page?.config?.quickFilters ?? []).filter(
+ (tab) => tab.label === 'Archived',
+ )
+
+ expect(archived).toHaveLength(1)
+ expect(archived[0].filter).toEqual({ _archived: 'true' })
+ expect(archived[0].default).not.toBe(true)
+ })
+
+ it('the Modules page lists the archive under "Archived" without the facet id set', () => {
+ const page = pages().find((candidate) => candidate.id === 'Modules')
+ const archived = (page?.config?.quickFilters ?? []).filter(
+ (tab) => tab.label === 'Archived',
+ )
+
+ expect(archived).toHaveLength(1)
+ expect(archived[0].filter).toEqual({ _archived: 'true', _ids: '' })
+ expect(archived[0].default).not.toBe(true)
+ })
+
+ // The Applications page sends `_ids` (the working set a facet or search
+ // 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`.
+ 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(
+ (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({}, {}, {}),
+ })
+
+ 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\)/,
+ )
+ })
it('uses an icon the app registers', () => {
const icons = fs.readFileSync(path.join(repoRoot, 'src/icons.js'), 'utf8')
From b1d249a606b89ad561339ee60e06468fe3aa427b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:22:39 +0200
Subject: [PATCH 08/18] test(cmdb-import): make the missing-applications choice
and the deleted-row rule testable
The section's tests set `missingRecords` on the component and stubbed the
radio away, so `v-model`, `value`, `name` and `:disabled` on the two radios
were never exercised, no test clicked "Keep them as they are", and the rule
that a deleted application has no link to its module had no test at all.
The jest spec now mounts the section with radio and table stubs that render
what the page does, clicks Keep and reads the posted form, checks the default,
the shared name and the disabled state, and asserts the report table links an
archived or failed row and not a deleted one, and shows no row number for a
reconciliation row. Each test goes red when the wiring or the rule is
mutated. The e2e adds a Keep import that must archive and delete nothing and
a check that the archived application is absent from the Applications page
before the Archived tab; the spec scenario for the choice now points at it.
Review #1287 f8
Co-Authored-By: Claude Opus 5.5
---
.../specs/cmdb-export-import/spec.md | 2 +-
.../settings/sections/CmdbImport.spec.js | 144 ++++++++++++++++++
tests/e2e/spec-coverage/cmdb-import.spec.ts | 62 ++++++++
3 files changed, 207 insertions(+), 1 deletion(-)
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
index 1ac827456..fd915d1f0 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -162,7 +162,7 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th
- **AND** filtering the table on `created` SHALL show the two imported rows
#### Scenario: The admin chooses to keep missing applications
-@e2e exclude Covered by the Jest test; src/utils/cmdbImport.spec.js asserts buildImportForm() sends `archive` by default and `keep` when chosen, and src/views/settings/sections/CmdbImport.spec.js asserts the section's default.
+@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts
- **GIVEN** the section
- **WHEN** the admin selects "Keep them as they are" and starts the import
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index 6896c2c33..ca01b9517 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -96,6 +96,11 @@ jest.mock(
'div',
this.rows.map((row) =>
h('div', { 'data-testid': 'table-row' }, [
+ h(
+ 'span',
+ { 'data-testid': 'table-row-number' },
+ this.$slots['column-row']?.({ row }),
+ ),
h('span', this.$slots['column-name']?.({ row })),
]),
),
@@ -380,3 +385,142 @@ describe('CmdbImport finished announcement', () => {
)
})
})
+
+/**
+ * @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 the choice for missing applications, as the admin makes it', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ const radio = (wrapper, which) =>
+ wrapper.find(`[data-testid="cmdb-import-missing-records-${which}"] input`)
+
+ it('starts on "Archive or delete them" and the radios share one name', async () => {
+ const wrapper = await mountFull()
+
+ expect(radio(wrapper, 'archive').element.checked).toBe(true)
+ expect(radio(wrapper, 'keep').element.checked).toBe(false)
+ expect(radio(wrapper, 'archive').attributes('name')).toBe(
+ radio(wrapper, 'keep').attributes('name'),
+ )
+ expect(radio(wrapper, 'archive').attributes('value')).toBe('archive')
+ expect(radio(wrapper, 'keep').attributes('value')).toBe('keep')
+ })
+
+ it('posts missingRecords=keep after the admin clicks "Keep them as they are", and only that field changes', async () => {
+ const wrapper = await mountFull()
+ axios.post.mockResolvedValue({
+ data: { success: true, summary: {}, rows: [] },
+ })
+ wrapper.vm.municipality = wrapper.vm.municipalityOptions[0]
+ wrapper.vm.selectedFile = new File(['x'], 'export.xlsx')
+
+ await radio(wrapper, 'keep').setValue(true)
+ await flushPromises()
+
+ expect(wrapper.vm.missingRecords).toBe('keep')
+ expect(radio(wrapper, 'keep').element.checked).toBe(true)
+ expect(radio(wrapper, 'archive').element.checked).toBe(false)
+ // The choice is not wired to another option: they keep their defaults.
+ expect(wrapper.vm.publish).toBe(true)
+ expect(wrapper.vm.updateExisting).toBe(true)
+
+ await wrapper.vm.startImport()
+ const form = axios.post.mock.calls[0][1]
+ expect(form.get('missingRecords')).toBe('keep')
+
+ await radio(wrapper, 'archive').setValue(true)
+ expect(wrapper.vm.missingRecords).toBe('archive')
+ })
+
+ it('disables both radios while an import runs', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({ importing: true })
+
+ expect(radio(wrapper, 'archive').attributes('disabled')).toBeDefined()
+ expect(radio(wrapper, 'keep').attributes('disabled')).toBeDefined()
+ })
+})
+
+/**
+ * @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 report rows of archived and deleted applications', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ const reconciliationRow = (outcome, extra = {}) => ({
+ sheet: 'Gearchiveerde Applicaties',
+ row: 0,
+ appId: outcome === 'deleted' ? '2' : '1234',
+ name: outcome === 'deleted' ? 'Naamtest' : 'Mailen',
+ outcome,
+ moduleUuid: 'module-uuid-' + outcome,
+ ...extra,
+ })
+
+ it('links an archived application to its module, and a deleted one to nothing', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: {
+ summary: { rowsRead: 0, archived: 1, deleted: 1 },
+ rows: [
+ reconciliationRow('archived'),
+ reconciliationRow('deleted'),
+ ],
+ },
+ })
+
+ const rows = wrapper.findAll('[data-testid="table-row"]')
+ expect(rows).toHaveLength(2)
+ const [archived, deleted] = rows
+ expect(
+ archived.find('[data-testid="cmdb-import-module-link"]').attributes('href'),
+ ).toBe('/apps/stackiq/modules/module-uuid-archived')
+ expect(deleted.find('[data-testid="cmdb-import-module-link"]').exists()).toBe(
+ false,
+ )
+ // The name of the deleted application is still shown, as text.
+ expect(deleted.text()).toContain('Naamtest')
+ })
+
+ it('shows no row number for a reconciliation row, and links one that failed', async () => {
+ const wrapper = await mountFull()
+
+ await wrapper.setData({
+ report: {
+ summary: { rowsRead: 1, failed: 1, archived: 1 },
+ rows: [
+ reconciliationRow('failed', {
+ reasons: ['archive: RuntimeException'],
+ }),
+ reconciliationRow('archived'),
+ {
+ sheet: 'Beheerde Applicaties CMDB',
+ row: 2,
+ appId: '5',
+ name: 'Gewoon',
+ outcome: 'created',
+ moduleUuid: 'module-uuid-created',
+ },
+ ],
+ },
+ })
+
+ const rows = wrapper.findAll('[data-testid="table-row"]')
+ expect(rows.map((row) => row.get('[data-testid="table-row-number"]').text())).toEqual(
+ ['', '', '2'],
+ )
+ expect(
+ rows[0].find('[data-testid="cmdb-import-module-link"]').exists(),
+ ).toBe(true)
+ expect(
+ rows[2].find('[data-testid="cmdb-import-module-link"]').exists(),
+ ).toBe(true)
+ })
+})
diff --git a/tests/e2e/spec-coverage/cmdb-import.spec.ts b/tests/e2e/spec-coverage/cmdb-import.spec.ts
index 82ae8e695..c0bd8d3e3 100644
--- a/tests/e2e/spec-coverage/cmdb-import.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import.spec.ts
@@ -560,6 +560,9 @@ 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')
+ 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({
timeout: 30000,
@@ -581,6 +584,65 @@ test.describe.serial('CMDB import section', () => {
await ctx.dispose()
})
+ // @e2e cmdb-export-import::the-admin-chooses-to-keep-missing-applications
+ test('choosing "Keep them as they are" leaves the applications a later export no longer lists as they are', async ({
+ page,
+ }) => {
+ requireFixture(EXPORT_FIXTURE)
+ requireFixture(ARCHIVED_FIXTURE)
+ const ctx = await newApiContext()
+ const before = await countWritten(ctx)
+ expect(
+ before.modules,
+ 'the first import must have written the modules this test keeps',
+ ).toBeGreaterThan(0)
+
+ // The same later export as the archive test, which archives 1234 and deletes 2.
+ const section = await gotoCmdbSection(page)
+ await chooseMunicipality(page)
+ const keep = section.locator(
+ '[data-testid="cmdb-import-missing-records-keep"] input',
+ )
+ await keep.check()
+ await expect(keep).toBeChecked()
+ await expect(
+ section.locator(
+ '[data-testid="cmdb-import-missing-records-archive"] input',
+ ),
+ ).not.toBeChecked()
+ const response = await runImport(page, ARCHIVED_FIXTURE)
+ expect(response.status()).toBe(200)
+ // The request carries the choice, not the default.
+ expect(response.request().postData() ?? '').toMatch(
+ /name="missingRecords"\s+keep/,
+ )
+
+ await expect(summaryValue(page, 'rowsRead')).toHaveText('0')
+ // The archived and deleted tiles only exist when the import did either.
+ await expect(
+ section.locator('[data-testid="cmdb-import-summary-archived"]'),
+ ).toHaveCount(0)
+ await expect(
+ section.locator('[data-testid="cmdb-import-summary-deleted"]'),
+ ).toHaveCount(0)
+ await expect(
+ reportRows(page).locator('[data-outcome="archived"]'),
+ ).toHaveCount(0)
+ await expect(
+ reportRows(page).locator('[data-outcome="deleted"]'),
+ ).toHaveCount(0)
+
+ // Both applications are still in the working set; nothing is archived.
+ expect(await countWritten(ctx)).toEqual(before)
+ expect(
+ await objectsInLens(ctx, config.module_schema, {
+ archived: 'true',
+ includeDeleted: false,
+ }),
+ ).toEqual([])
+ await ctx.dispose()
+ })
+
// @e2e cmdb-export-import::a-missing-required-column-is-named-in-the-422-response
test('an export without a required column names the column and the sheet', async ({
page,
From 8a1562aee83819584ff028ccf7ca0ed033d460ab Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:24:37 +0200
Subject: [PATCH 09/18] fix(cmdb-import): name the archive sheet where the
section says which sheets the import reads
The WORKBOOK_TOO_LARGE hint said the import "reads only" the two CMDB sheets
and told the admin to delete the sheets it does not read; the section's file
help said other sheets are ignored. The import also reads the APPID column of
"Gearchiveerde Applicaties", and an admin who followed the hint would delete
that sheet, so the next import archives and never deletes.
Both strings now name the archive sheet and its APPID column (replacing the
l10n keys in place), and the spec pins the new hint.
Review #1287 f9
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 4 ++--
l10n/en.json | 4 ++--
l10n/nl.js | 4 ++--
l10n/nl.json | 4 ++--
src/utils/cmdbImport.js | 5 ++++-
src/utils/cmdbImport.spec.js | 2 +-
src/views/settings/sections/CmdbImport.vue | 3 ++-
7 files changed, 15 insertions(+), 11 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 3e3c7c0d7..c56d7c8d4 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1103,7 +1103,7 @@ OC.L10N.register(
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
"Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
@@ -1142,7 +1142,7 @@ OC.L10N.register(
"Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"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.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), 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.",
"In development since": "In development since",
diff --git a/l10n/en.json b/l10n/en.json
index e2d6fd9e0..5eea76d88 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1102,7 +1102,7 @@
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".",
"Split the export and import the parts one after the other with \"Keep them as they are\".": "Split the export and import the parts one after the other with \"Keep them as they are\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.",
"The page got no answer from the import.": "The page got no answer from the import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.",
@@ -1141,7 +1141,7 @@
"Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.",
"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.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), 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.",
"In development since": "In development since",
diff --git a/l10n/nl.js b/l10n/nl.js
index 135559111..011cf1668 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1171,7 +1171,7 @@ OC.L10N.register(
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
@@ -1210,7 +1210,7 @@ OC.L10N.register(
"Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"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.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest \"{first}\" en \"{second}\", en de kolom APPID van \"{archive}\"), 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.",
"In development since": "In ontwikkeling sinds",
diff --git a/l10n/nl.json b/l10n/nl.json
index 98cdc370d..f327c032e 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1170,7 +1170,7 @@
"A source sheet may hold at most {limit} rows. Split the export and import the parts one after the other with \"Keep them as they are\".": "Een brontabblad mag hoogstens {limit} rijen bevatten. Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Split the export and import the parts one after the other with \"Keep them as they are\".": "Splits de export en importeer de delen na elkaar met \"Laat ze zoals ze zijn\".",
"Excel workbook (.xlsx) with the sheet \"{first}\" or \"{second}\". By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad \"{first}\" of \"{second}\". Standaard mag het bestand hoogstens {size} zijn.",
- "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets \"{first}\" (applications without arranged maintenance) and \"{second}\" (with arranged maintenance) are read, and of the sheet \"{archive}\" only the APPID column; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen \"{first}\" (applicaties zonder geregeld beheer) en \"{second}\" (met geregeld beheer) worden gelezen, en van het tabblad \"{archive}\" alleen de kolom APPID; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
"The page got no answer from the import.": "De pagina kreeg geen antwoord van de import.",
"The connection was cut off before the import answered, so it may still be running or may have finished. Wait a few minutes and check the applications of the municipality before you import again. Importing the same file again creates no duplicates.": "De verbinding werd verbroken voordat de import antwoordde, dus de import kan nog bezig zijn of al klaar zijn. Wacht een paar minuten en controleer de applicaties van de gemeente voordat u opnieuw importeert. Hetzelfde bestand opnieuw importeren maakt geen dubbele records aan.",
"The import cannot be cancelled now: the server has not started its rows yet, or has already finished them. If the import keeps running, press Cancel import again in a moment.": "De import kan nu niet worden geannuleerd: de server is nog niet aan de rijen begonnen, of is er al mee klaar. Loopt de import door, druk dan zo dadelijk opnieuw op Import annuleren.",
@@ -1209,7 +1209,7 @@
"Split the rows over two copies of the export and import both with \"Keep them as they are\": an import that archives treats the applications of the other copy as missing.": "Verdeel de rijen over twee kopieën van de export en importeer beide met \"Laat ze zoals ze zijn\": een import die archiveert, ziet de applicaties van de andere kopie als ontbrekend.",
"The sheet \"{sheet}\" is too large to import: unpacked it is {size}, and the import reads at most {limit} of one sheet.": "Het tabblad \"{sheet}\" is te groot om te importeren: uitgepakt is het {size}, en de import leest van één tabblad hooguit {limit}.",
"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.",
+ "Delete the sheets the import does not read from a copy of the export (it reads \"{first}\" and \"{second}\", and the APPID column of \"{archive}\"), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest \"{first}\" en \"{second}\", en de kolom APPID van \"{archive}\"), 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.",
"In development since": "In ontwikkeling sinds",
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 0b834dd2f..2c83018a0 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -31,6 +31,8 @@ export const PROFILE_DEFAULTS = Object.freeze({
maxFileBytes: 10 * 1024 * 1024,
maxRowsPerSheet: 10000,
sheets: Object.freeze(['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']),
+ // Read for its APPID column only, in every import.
+ archiveSheet: 'Gearchiveerde Applicaties',
})
/**
@@ -564,10 +566,11 @@ function workbookTooLarge(details) {
+ ' '
+ t(
'stackiq',
- '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 "{first}" and "{second}", and the APPID column of "{archive}"), and import that copy.',
{
first: PROFILE_DEFAULTS.sheets[0],
second: PROFILE_DEFAULTS.sheets[1],
+ archive: PROFILE_DEFAULTS.archiveSheet,
},
AS_TEXT,
)
diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js
index 3282e3458..a5579b985 100644
--- a/src/utils/cmdbImport.spec.js
+++ b/src/utils/cmdbImport.spec.js
@@ -426,7 +426,7 @@ describe('errorText', () => {
expect(
errorText({ error: 'WORKBOOK_TOO_LARGE', details }).hint,
).toContain(
- '(it reads only "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB")',
+ '(it reads "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB", and the APPID column of "Gearchiveerde Applicaties")',
)
}
})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 0f92b67ae..3590c47f7 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -414,10 +414,11 @@
{{
t(
'stackiq',
- 'The sheets "{first}" (applications without arranged maintenance) and "{second}" (with arranged maintenance) are read; other sheets, including the "Invoer" sheets, are ignored.',
+ 'The sheets "{first}" (applications without arranged maintenance) and "{second}" (with arranged maintenance) are read, and of the sheet "{archive}" only the APPID column; other sheets, including the "Invoer" sheets, are ignored.',
{
first: profileDefaults.sheets[0],
second: profileDefaults.sheets[1],
+ archive: profileDefaults.archiveSheet,
},
asText,
)
From cdbec3d3ac09a986bb82a49c16edc5da90dc4a42 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:24:48 +0200
Subject: [PATCH 10/18] docs(cmdb-import): say the archive sheet is read, and
bounded, in every import
The docs said the import "ignores all other sheets" and that a sheet it does
not read may exceed the part size limit. The archive sheet is read (its APPID
column only) in every import, also with Keep, so it counts against the row
span and part-size limits like a CMDB sheet and can refuse a keep import.
The file section, the row limit, the part-size limit and the WORKBOOK_TOO_LARGE
row now say so, and that deleting the sheet from a copy makes the import
archive and never delete.
Review #1285 f2
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index f66fd6626..1e33207d5 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -123,7 +123,12 @@ the same organisation.
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:
+the `Invoer` sheets they are derived from. The archive sheet is read in every
+import, also with **Keep them as they are**, and it is bounded like the two
+CMDB sheets (see [Limits](#limits)): an archive sheet over the limits refuses
+the import even when you keep missing applications. Delete the sheet from a
+copy of the export to import it without one; the import then archives and
+never deletes.
| Sheet | What it holds | Recorded on the usage |
|---|---|---|
@@ -136,8 +141,8 @@ surrounding spaces and a trailing `:` or `⚡` do not matter, and the order of
the columns does not matter. A column that one sheet has and the other has
not (such as `Nickname`, only on `Beheerde Applicaties CMDB`) is optional on
the sheet that lacks it. Empty rows, including formatted rows below the data,
-are ignored and not counted. A sheet may hold at most 10,000 rows with data
-by default (see [Limits](#limits)).
+are ignored and not counted. A sheet, the archive sheet included, may hold at
+most 10,000 rows with data by default (see [Limits](#limits)).
Two columns are **required** on every CMDB sheet that is present: `APPID`
and `Applicatie Naam`. Every other column is optional; when one is missing,
@@ -359,7 +364,7 @@ and the section shows the reason and the error code.
| `NO_SOURCE_SHEET` | Neither `Onbeh Applicaties CMDB` nor `Beheerde Applicaties CMDB` is in the workbook. | Check the sheet names; they must match exactly. |
| `MISSING_COLUMN` | A present CMDB sheet has no `APPID` or `Applicatie Naam` column. The message names the sheet and the column. | Add the column to that sheet. |
| `TOO_MANY_ROWS` | A CMDB sheet has more rows with data than the row limit (10,000 by default). The message names the sheet and the limit. | Split the export and import the parts one after the other with **Keep them as they are**: an import that archives treats the applications of the other parts as missing. Applications that left the export are then not archived or deleted. |
-| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
+| `WORKBOOK_TOO_LARGE` | Unpacked, the workbook is larger than the import reads: 50 MB in all, 10 MB for one sheet the import reads (the two CMDB sheets and the archive sheet, in every import) or for the table of texts the sheets share, or more than 200,000 different texts, by default. Sheets the import does not read may be larger than 10 MB. An `.xlsx` is a compressed package, so a file of a few MB can unpack to far more. The message names the limit, and for a sheet the sheet and its unpacked size. | For a sheet that is too large: split its rows over two copies of the export and import both with **Keep them as they are**. For a workbook that is too large in all: delete the sheets the import does not read from a copy and import that copy. |
| `MUNICIPALITY_AMBIGUOUS` | More than one municipality has the typed name. The import does not guess which one. | Pick the municipality from the list instead of typing its name. |
| `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. |
@@ -394,9 +399,9 @@ every import:
| Setting | Default | What it limits |
|---|---|---|
| `maxFileBytes` | `10485760` (10 MB) | the size of the uploaded file |
-| `maxRowsPerSheet` | `10000` | the rows with data on one CMDB sheet |
+| `maxRowsPerSheet` | `10000` | the rows with data on one CMDB sheet, and on the archive sheet |
| `maxUncompressedBytes` | `52428800` (50 MB) | the size of the workbook once unpacked, checked before a sheet is parsed |
-| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet |
+| `maxPartBytes` | `10485760` (10 MB) | the size of one part the import parses once unpacked, such as a source sheet or the shared-strings table, checked before a sheet is parsed; a sheet the import does not read may be larger, and is read as an empty sheet; the archive sheet is read, so it is bounded like a CMDB sheet |
| `maxSharedStrings` | `200000` | the number of different texts in the workbook's shared-strings table, each formatted piece of a text with mixed formatting counted as one more, also in text written in a cell itself, counted before a sheet is parsed |
| `maxReferencedStringBytes` | `67108864` (64 MB) | the shared text all cells of the workbook reference together, counted once per cell and once per CMDB sheet before a sheet is parsed; a long text that many cells repeat counts as often as it is repeated |
From 9fb2dfdb0d620cb8b4684935d91cc2cb47779202 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:46:12 +0200
Subject: [PATCH 11/18] docs(cmdb-import): state all three cases where an
import archives and never deletes
The docs and the choice's help said an import only archives when the archive
sheet is missing (the docs added "or has no APPID column" and said the result
warns the sheet was not found). The service also archives, with its own
warning for each case, when the sheet has no APPID column or an APPID in it
has no cached value.
The docs table row and the section help now name the three cases and say the
result warns; the l10n key is replaced in place and a jest case pins the help.
Review pre-push p1
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 2 +-
l10n/en.js | 2 +-
l10n/en.json | 2 +-
l10n/nl.js | 2 +-
l10n/nl.json | 2 +-
src/views/settings/sections/CmdbImport.spec.js | 11 +++++++++++
src/views/settings/sections/CmdbImport.vue | 2 +-
7 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 1e33207d5..364bcf5f6 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -277,7 +277,7 @@ 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, 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. |
+| 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 …* (the first rows are named). 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
diff --git a/l10n/en.js b/l10n/en.js
index 1c20fdbc0..0dd84f275 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1050,7 +1050,7 @@ OC.L10N.register(
"Applications missing from the export": "Applications missing from the export",
"Archive or delete them": "Archive or delete them",
"Keep them as they are": "Keep them as they are",
- "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.": "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.",
+ "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. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "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. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.",
"Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
"Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
diff --git a/l10n/en.json b/l10n/en.json
index faa7cf8db..e2f6ad6cf 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1049,7 +1049,7 @@
"Applications missing from the export": "Applications missing from the export",
"Archive or delete them": "Archive or delete them",
"Keep them as they are": "Keep them as they are",
- "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.": "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.",
+ "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. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "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. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.",
"Applications missing from the export cannot be archived.": "Applications missing from the export cannot be archived.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.",
"Checking the applications of this municipality against the export": "Checking the applications of this municipality against the export",
diff --git a/l10n/nl.js b/l10n/nl.js
index ce2e71c60..c3ac2b4b1 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1118,7 +1118,7 @@ OC.L10N.register(
"Applications missing from the export": "Applicaties die in de export ontbreken",
"Archive or delete them": "Archiveren of verwijderen",
"Keep them as they are": "Laat ze zoals ze zijn",
- "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.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Staat dat tabblad niet in het bestand, dan worden applicaties alleen gearchiveerd. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
+ "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. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
"Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
"Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
diff --git a/l10n/nl.json b/l10n/nl.json
index 059c7ada7..e62e0749a 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1117,7 +1117,7 @@
"Applications missing from the export": "Applicaties die in de export ontbreken",
"Archive or delete them": "Archiveren of verwijderen",
"Keep them as they are": "Laat ze zoals ze zijn",
- "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.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Staat dat tabblad niet in het bestand, dan worden applicaties alleen gearchiveerd. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
+ "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. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.": "Een applicatie van deze gemeente die op geen van beide CMDB-tabbladen meer staat, wordt gearchiveerd als het tabblad \"Gearchiveerde Applicaties\" haar noemt, en naar de prullenbak verplaatst als geen enkel tabblad dat doet. Beide verdwijnen uit de lijsten, uit OpenCatalogi en uit Portaliq; een applicatie die in een latere export terugkomt, wordt teruggezet. Ontbreekt dat tabblad, heeft het geen kolom APPID of heeft een APPID geen opgeslagen waarde, dan worden applicaties alleen gearchiveerd en zegt het resultaat dat. Kies Laat ze zoals ze zijn om ze ongemoeid te laten.",
"Applications missing from the export cannot be archived.": "Applicaties die in de export ontbreken, kunnen niet worden gearchiveerd.",
"OpenRegister is missing or too old to archive objects. Choose \"Keep them as they are\" and import again, or update OpenRegister. Nothing was imported.": "OpenRegister ontbreekt of is te oud om objecten te archiveren. Kies \"Laat ze zoals ze zijn\" en importeer opnieuw, of werk OpenRegister bij. Er is niets geïmporteerd.",
"Checking the applications of this municipality against the export": "De applicaties van deze gemeente met de export vergelijken",
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index ca01b9517..5f43536cc 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -435,6 +435,17 @@ describe('CmdbImport the choice for missing applications, as the admin makes it'
expect(wrapper.vm.missingRecords).toBe('archive')
})
+ it('says in the help when applications are only archived, never deleted', async () => {
+ const wrapper = await mountFull()
+
+ const help = wrapper.get('#cmdb-import-missing-records-help').text()
+
+ expect(help).toContain('is missing')
+ expect(help).toContain('has no APPID column')
+ expect(help).toContain('an APPID without a cached value')
+ expect(help).toContain('only archived')
+ })
+
it('disables both radios while an import runs', async () => {
const wrapper = await mountFull()
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 3590c47f7..88eaf5d75 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -172,7 +172,7 @@
{{
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.',
+ '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. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.',
)
}}
From 2d288acd3f113d8a2c6ae31635f8892089dd0081 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:46:29 +0200
Subject: [PATCH 12/18] docs(cmdb-import): correct the Modules page note on how
quick filters merge with the facet ids
The manifest note and the view's docblock said a quick filter composes with
the facet `_ids` as an additive AND that never clobbers. Since the Archived
tab carries an empty-string `_ids`, that tab replaces the facet id set and
lists the whole archive, ignoring the GEMMA facets and the search term. Both
now name that exception; the docs already said so. A vitest pins the note.
Review pre-push p2
Co-Authored-By: Claude Opus 5.5
---
src/manifest.json | 2 +-
src/views/FacetedCatalogIndexView.vue | 3 ++-
tests/vitest/cmdbArchiveQuickFilter.spec.js | 8 ++++++++
3 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/manifest.json b/src/manifest.json
index 245f8c802..1541f303e 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -616,7 +616,7 @@
"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 { _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).",
+ "_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). The one exception is the Archived tab: its filter carries an empty-string `_ids` that replaces the facet id set in the merge, because that set is built from the working set, which leaves archived applications out, so ANDing it with `_archived=true` could never match. That tab lists the whole archive and ignores the GEMMA facets and the search term.",
"config": {
"register": "@resolve:voorzieningen_register",
"schema": "module",
diff --git a/src/views/FacetedCatalogIndexView.vue b/src/views/FacetedCatalogIndexView.vue
index cc29bb2ec..427245c47 100644
--- a/src/views/FacetedCatalogIndexView.vue
+++ b/src/views/FacetedCatalogIndexView.vue
@@ -232,7 +232,8 @@ export default {
* filter's keys (`bbnLevel`, `dpiaStatus`, …) never collide with
* `_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.
+ * (logical AND), never clobbering one another. The one exception is
+ * the Archived tab of the Applications page, below.
*
* The exception is the Archived tab of the Applications page
* (cmdb-import-archive-reconciliation): the facet id set is built from
diff --git a/tests/vitest/cmdbArchiveQuickFilter.spec.js b/tests/vitest/cmdbArchiveQuickFilter.spec.js
index c048abf8f..ff4df73ee 100644
--- a/tests/vitest/cmdbArchiveQuickFilter.spec.js
+++ b/tests/vitest/cmdbArchiveQuickFilter.spec.js
@@ -111,6 +111,14 @@ describe('the archive quick filter', () => {
)
})
+ it('the Modules page note names the Archived tab as the exception to the additive merge', () => {
+ const page = pages().find((candidate) => candidate.id === 'Modules')
+
+ expect(page._note).toContain('additive AND')
+ expect(page._note).toMatch(/exception is the Archived tab/)
+ expect(page._note).toMatch(/ignores the GEMMA facets and the search term/)
+ })
+
it('uses an icon the app registers', () => {
const icons = fs.readFileSync(path.join(repoRoot, 'src/icons.js'), 'utf8')
expect(icons).toMatch(/^\tArchiveOutline,$/m)
From 323d7615d0480448f351907a6e191cd873e6d72a Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 18:09:42 +0200
Subject: [PATCH 13/18] docs(cmdb-import): quote both forms of the
uncached-APPID warning
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The warning has a one-row form and an "in N rows (…)" form that names the
first ten rows; the docs quoted only the first.
Review pre-push q4
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 364bcf5f6..1f06a572f 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -277,7 +277,7 @@ 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 …* (the first rows are named). 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) | 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. |
An archived or deleted application disappears from the stackiq lists, from
OpenCatalogi and from Portaliq's **Software we use**. In stackiq, the quick
From 8dfa03a1a1cb56ca90b2087f6ed982db6ef99742 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Tue, 13 Oct 2026 18:49:30 +0200
Subject: [PATCH 14/18] feat(cmdb-import): shorten the help of the
missing-applications choice
The help under "Applications missing from the export" was a paragraph of
seven lines. It is now one sentence; the full rules, including when
applications are only archived, moved to the section's information
panel.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
l10n/en.js | 3 +-
l10n/en.json | 3 +-
l10n/nl.js | 3 +-
l10n/nl.json | 3 +-
.../specs/cmdb-export-import/spec.md | 2 +-
.../settings/sections/CmdbImport.spec.js | 73 +++++++++++++------
src/views/settings/sections/CmdbImport.vue | 20 +++--
7 files changed, 74 insertions(+), 33 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 613cb76f2..fa52e1cda 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}.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 29d312f53..a26643b77 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}.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 1dc8b83d9..d98a24b44 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}.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 7963d0ebc..30c99f4e9 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}.",
+ "Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.": "Applicaties die niet meer in de export staan, worden gearchiveerd of naar de prullenbak verplaatst. De (i)-knop bovenaan legt uit wanneer."
}
}
diff --git a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
index f6a842422..c65412f52 100644
--- a/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-archive-reconciliation/specs/cmdb-export-import/spec.md
@@ -165,7 +165,7 @@ The import SHALL accept `missingRecords` with the values `keep` and `archive`; `
### Requirement: The admin settings SHALL offer a CMDB import section (REQ-CMDB-014)
-Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with help text that says what each does, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including the four new ones; a reconciliation row SHALL show no row number. Every control SHALL have a visible label, and every string SHALL be translatable.
+Stackiq's admin settings page SHALL show a section "CMDB import", rendered by the settings page and not registered as an in-app route. The section SHALL let the admin choose an existing municipality or type the name of a new one, choose an `.xlsx` file, choose what happens to applications missing from the export (archive, the default, or keep) with a one-sentence help text that says what each does and the details in the section's information panel, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary, including the archived, unarchived, deleted and restored counts when they are not zero, and a report table that can be filtered by outcome, including the four new ones; a reconciliation row SHALL show no row number. Every control SHALL have a visible label, and every string SHALL be translatable.
(Previously: the section had no choice for missing records; the request always sent `keep`.)
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index 5f43536cc..f30570388 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -10,6 +10,7 @@
import axios from '@nextcloud/axios'
import { flushPromises, mount, shallowMount } from '@vue/test-utils'
+import { h } from 'vue'
import CmdbImport from './CmdbImport.vue'
// Virtual: the package's `exports` field declares only the `import` condition, which jest does not resolve.
@@ -308,13 +309,19 @@ describe('CmdbImport cancelling', () => {
await wrapper.setData({
importing: true,
operationId: 'cmdb-12345678',
- progress: { phase: 'reconciling', processed_items: 400, total_items: 810 },
+ progress: {
+ phase: 'reconciling',
+ processed_items: 400,
+ total_items: 810,
+ },
})
await wrapper.vm.cancelImport()
await flushPromises()
- const status = wrapper.find('[data-testid="cmdb-import-cancel-status"]').text()
+ const status = wrapper
+ .find('[data-testid="cmdb-import-cancel-status"]')
+ .text()
expect(status).toContain('before the next row')
expect(status).toContain('before the next application')
})
@@ -323,7 +330,11 @@ describe('CmdbImport cancelling', () => {
const wrapper = await mountFull()
await wrapper.setData({
- report: { cancelled: true, summary: { rowsRead: 400, archived: 3 }, rows: [] },
+ report: {
+ cancelled: true,
+ summary: { rowsRead: 400, archived: 3 },
+ rows: [],
+ },
})
expect(wrapper.find('[data-testid="cmdb-import-cancelled"]').text()).toMatch(
@@ -366,7 +377,13 @@ describe('CmdbImport finished announcement', () => {
report: {
cancelled: true,
municipality: { name: 'Gemeente Bergen' },
- summary: { rowsRead: 5, archived: 2, unarchived: 3, deleted: 4, restored: 5 },
+ summary: {
+ rowsRead: 5,
+ archived: 2,
+ unarchived: 3,
+ deleted: 4,
+ restored: 5,
+ },
rows: [],
},
})
@@ -435,15 +452,30 @@ describe('CmdbImport the choice for missing applications, as the admin makes it'
expect(wrapper.vm.missingRecords).toBe('archive')
})
- it('says in the help when applications are only archived, never deleted', async () => {
+ it('says in short what happens, and in the info panel when applications are only archived', async () => {
const wrapper = await mountFull()
const help = wrapper.get('#cmdb-import-missing-records-help').text()
-
- expect(help).toContain('is missing')
- expect(help).toContain('has no APPID column')
- expect(help).toContain('an APPID without a cached value')
- expect(help).toContain('only archived')
+ expect(help).toContain('archived or moved to the trash')
+
+ // The info panel is the section's `info-content` slot; render it.
+ const panel = mount(CmdbImport, {
+ global: {
+ stubs: {
+ AlwaysVisibleSection: {
+ render() {
+ return h('div', [this.$slots['info-content']?.()])
+ },
+ },
+ },
+ },
+ })
+ await flushPromises()
+ const text = panel.text()
+ expect(text).toContain('is missing')
+ expect(text).toContain('has no APPID column')
+ expect(text).toContain('an APPID without a cached value')
+ expect(text).toContain('only archived')
})
it('disables both radios while an import runs', async () => {
@@ -480,10 +512,7 @@ describe('CmdbImport report rows of archived and deleted applications', () => {
await wrapper.setData({
report: {
summary: { rowsRead: 0, archived: 1, deleted: 1 },
- rows: [
- reconciliationRow('archived'),
- reconciliationRow('deleted'),
- ],
+ rows: [reconciliationRow('archived'), reconciliationRow('deleted')],
},
})
@@ -491,11 +520,13 @@ describe('CmdbImport report rows of archived and deleted applications', () => {
expect(rows).toHaveLength(2)
const [archived, deleted] = rows
expect(
- archived.find('[data-testid="cmdb-import-module-link"]').attributes('href'),
+ archived
+ .find('[data-testid="cmdb-import-module-link"]')
+ .attributes('href'),
).toBe('/apps/stackiq/modules/module-uuid-archived')
- expect(deleted.find('[data-testid="cmdb-import-module-link"]').exists()).toBe(
- false,
- )
+ expect(
+ deleted.find('[data-testid="cmdb-import-module-link"]').exists(),
+ ).toBe(false)
// The name of the deleted application is still shown, as text.
expect(deleted.text()).toContain('Naamtest')
})
@@ -524,9 +555,9 @@ describe('CmdbImport report rows of archived and deleted applications', () => {
})
const rows = wrapper.findAll('[data-testid="table-row"]')
- expect(rows.map((row) => row.get('[data-testid="table-row-number"]').text())).toEqual(
- ['', '', '2'],
- )
+ expect(
+ rows.map((row) => row.get('[data-testid="table-row-number"]').text()),
+ ).toEqual(['', '', '2'])
expect(
rows[0].find('[data-testid="cmdb-import-module-link"]').exists(),
).toBe(true)
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 88eaf5d75..801bc9b36 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -172,7 +172,7 @@
{{
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. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.',
+ 'Applications no longer in the export are archived or moved to the trash. The (i) button above explains when.',
)
}}
@@ -449,6 +449,15 @@
}}
+ {{ t('stackiq', 'Applications missing from the export') }}
+
+ {{
+ 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. When that sheet is missing, has no APPID column, or has an APPID without a cached value, applications are only archived and the result says so. Choose Keep to leave them as they are.',
+ )
+ }}
+
@@ -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)
+ })
})