From 7a472a6fded62262bb77dc9c64ca23d75bb6cc49 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 12:09:17 +0200
Subject: [PATCH 01/14] feat(cmdb-import): show the import mapping read-only in
the CMDB import section
A read-only mapping view in the CMDB import section shows which sheet
column feeds which field. Vitest and the Playwright spec cover it; the
feature docs describe it.
Second of two slices of #1280, on top of the endpoint slice.
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 35 +-
l10n/en.js | 35 +-
l10n/en.json | 35 +-
l10n/nl.js | 35 +-
l10n/nl.json | 35 +-
src/utils/cmdbImport.js | 222 +++++++++++
.../settings/sections/CmdbImport.spec.js | 6 +
src/views/settings/sections/CmdbImport.vue | 38 +-
.../settings/sections/CmdbImportMapping.vue | 358 ++++++++++++++++++
.../spec-coverage/cmdb-import-mapping.spec.ts | 231 +++++++++++
tests/vitest/cmdbImportMapping.spec.js | 281 ++++++++++++++
11 files changed, 1300 insertions(+), 11 deletions(-)
create mode 100644 src/views/settings/sections/CmdbImportMapping.vue
create mode 100644 tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
create mode 100644 tests/vitest/cmdbImportMapping.spec.js
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index b9d505abc..0cdc4f481 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -334,10 +334,37 @@ pass PHP's `upload_max_filesize` and `post_max_size` and the web server's
request size limit. Like the mapping files, the profile is part of the app:
a change made on the server is overwritten by the next app update.
+## Viewing the mapping
+
+The section shows the mapping the next import runs, read-only. Open
+**Administration settings → Stackiq → CMDB import** and press **Mapping
+(read-only)** under the import form. The block shows:
+
+- the sheets the import reads, the match column (`APPID`), the name column,
+ the required columns and the date columns;
+- one table per pack (application, Supplier organisation, municipality,
+ usage, business owner), with the pack's file, name and version;
+- per row of a table: the column in the export, the field it goes to,
+ whether the column is required, the transformation (as is, trim, date,
+ lookup, yes/no lookup, join, constant) and its details, such as the export
+ values a lookup recognises and what each becomes.
+
+The block reads the files through the same loader the import uses, so what
+it shows is what the import does. When a file is invalid, the block shows
+`MAPPING_UNAVAILABLE` with the file and the reason instead of the tables, and
+the import refuses to run with the same code. The sheet names in the
+section's help text come from the same answer.
+
+The block is only for viewing: nothing in it changes the mapping. The data
+behind it is also available to Nextcloud administrators as
+`GET /apps/stackiq/api/settings/cmdb-import/mapping`.
+
## Adjusting the mapping
The mapping from columns to fields is not in code. It is a set of JSON files
-in `lib/Settings/cmdb-import/`, executed by OpenRegister's mapping engine:
+in `lib/Settings/cmdb-import/`, executed by OpenRegister's mapping engine.
+It cannot be changed in the section; **Mapping (read-only)** shows which
+file and which entry hold a column, so you know what to change:
| File | What it maps |
|---|---|
@@ -361,5 +388,7 @@ it to the `map` of the hosting-model lookup in `topdesk-module.json`:
To accept a new "Applicatie Status" value, add it to the `map` of the status
lookup in `topdesk-usage.json`. The packs are checked by OpenRegister when an import
starts; an invalid pack stops the import with `MAPPING_UNAVAILABLE` before
-any row is read. A mapping file changed on the server is overwritten by the
-next app update, so propose lasting changes to the app itself.
+any row is read. After saving a file, reload the settings page: **Mapping
+(read-only)** then shows the new mapping, or the reason the file is
+refused. A mapping file changed on the server is overwritten by the next app
+update, so propose lasting changes to the app itself.
diff --git a/l10n/en.js b/l10n/en.js
index 8dd84a2f4..60a730e51 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1132,7 +1132,40 @@ 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",
+ "Application (module)": "Application (module)",
+ "Supplier organisation (Vendor)": "Supplier organisation (Vendor)",
+ "Municipality (from the import options)": "Municipality (from the import options)",
+ "Business owner (contact person)": "Business owner (contact person)",
+ "As is": "As is",
+ "Trim": "Trim",
+ "Date": "Date",
+ "Lookup": "Lookup",
+ "Yes/no lookup": "Yes/no lookup",
+ "Join": "Join",
+ "Constant": "Constant",
+ "Any other value: {value}": "Any other value: {value}",
+ "Joined with the columns {columns}, separated by \"{separator}\"": "Joined with the columns {columns}, separated by \"{separator}\"",
+ "Value: {value}": "Value: {value}",
+ "Read as {source}, stored as {target}": "Read as {source}, stored as {target}",
+ "Mapping (read-only)": "Mapping (read-only)",
+ "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.",
+ "Loading the mapping…": "Loading the mapping…",
+ "The mapping could not be loaded.": "The mapping could not be loaded.",
+ "{file}: {name}, version {version}": "{file}: {name}, version {version}",
+ "This pack maps no columns": "This pack maps no columns",
+ "Yes": "Yes",
+ "No": "No",
+ "Sheets read": "Sheets read",
+ "Match column": "Match column",
+ "Name column": "Name column",
+ "Required columns": "Required columns",
+ "Date columns": "Date columns",
+ "Column in the export": "Column in the export",
+ "Field": "Field",
+ "Transformation": "Transformation",
+ "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 313f968ea..5e5a32434 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1131,6 +1131,39 @@
"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",
+ "Application (module)": "Application (module)",
+ "Supplier organisation (Vendor)": "Supplier organisation (Vendor)",
+ "Municipality (from the import options)": "Municipality (from the import options)",
+ "Business owner (contact person)": "Business owner (contact person)",
+ "As is": "As is",
+ "Trim": "Trim",
+ "Date": "Date",
+ "Lookup": "Lookup",
+ "Yes/no lookup": "Yes/no lookup",
+ "Join": "Join",
+ "Constant": "Constant",
+ "Any other value: {value}": "Any other value: {value}",
+ "Joined with the columns {columns}, separated by \"{separator}\"": "Joined with the columns {columns}, separated by \"{separator}\"",
+ "Value: {value}": "Value: {value}",
+ "Read as {source}, stored as {target}": "Read as {source}, stored as {target}",
+ "Mapping (read-only)": "Mapping (read-only)",
+ "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.",
+ "Loading the mapping…": "Loading the mapping…",
+ "The mapping could not be loaded.": "The mapping could not be loaded.",
+ "{file}: {name}, version {version}": "{file}: {name}, version {version}",
+ "This pack maps no columns": "This pack maps no columns",
+ "Yes": "Yes",
+ "No": "No",
+ "Sheets read": "Sheets read",
+ "Match column": "Match column",
+ "Name column": "Name column",
+ "Required columns": "Required columns",
+ "Date columns": "Date columns",
+ "Column in the export": "Column in the export",
+ "Field": "Field",
+ "Transformation": "Transformation",
+ "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 3151813d6..e51e4af38 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1200,7 +1200,40 @@ 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",
+ "Application (module)": "Applicatie (module)",
+ "Supplier organisation (Vendor)": "Leveranciersorganisatie (Vendor)",
+ "Municipality (from the import options)": "Gemeente (uit de importopties)",
+ "Business owner (contact person)": "Business owner (contactpersoon)",
+ "As is": "Ongewijzigd",
+ "Trim": "Spaties weghalen",
+ "Date": "Datum",
+ "Lookup": "Opzoektabel",
+ "Yes/no lookup": "Ja/nee-opzoektabel",
+ "Join": "Samenvoegen",
+ "Constant": "Vaste waarde",
+ "Any other value: {value}": "Elke andere waarde: {value}",
+ "Joined with the columns {columns}, separated by \"{separator}\"": "Samengevoegd met de kolommen {columns}, gescheiden door \"{separator}\"",
+ "Value: {value}": "Waarde: {value}",
+ "Read as {source}, stored as {target}": "Gelezen als {source}, opgeslagen als {target}",
+ "Mapping (read-only)": "Mapping (alleen-lezen)",
+ "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per pack, welke kolom van de export naar welk veld gaat. Hij wordt gelezen uit de bestanden onder lib/Settings/cmdb-import van de app op de server; een bestand dat daar is aangepast wordt bij de volgende app-update overschreven.",
+ "Loading the mapping…": "Mapping laden…",
+ "The mapping could not be loaded.": "De mapping kon niet worden geladen.",
+ "{file}: {name}, version {version}": "{file}: {name}, versie {version}",
+ "This pack maps no columns": "Dit pack koppelt geen kolommen",
+ "Yes": "Ja",
+ "No": "Nee",
+ "Sheets read": "Gelezen tabbladen",
+ "Match column": "Koppelkolom",
+ "Name column": "Naamkolom",
+ "Required columns": "Verplichte kolommen",
+ "Date columns": "Datumkolommen",
+ "Column in the export": "Kolom in de export",
+ "Field": "Veld",
+ "Transformation": "Transformatie",
+ "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 680f6f853..6ab4bbf98 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1199,6 +1199,39 @@
"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",
+ "Application (module)": "Applicatie (module)",
+ "Supplier organisation (Vendor)": "Leveranciersorganisatie (Vendor)",
+ "Municipality (from the import options)": "Gemeente (uit de importopties)",
+ "Business owner (contact person)": "Business owner (contactpersoon)",
+ "As is": "Ongewijzigd",
+ "Trim": "Spaties weghalen",
+ "Date": "Datum",
+ "Lookup": "Opzoektabel",
+ "Yes/no lookup": "Ja/nee-opzoektabel",
+ "Join": "Samenvoegen",
+ "Constant": "Vaste waarde",
+ "Any other value: {value}": "Elke andere waarde: {value}",
+ "Joined with the columns {columns}, separated by \"{separator}\"": "Samengevoegd met de kolommen {columns}, gescheiden door \"{separator}\"",
+ "Value: {value}": "Waarde: {value}",
+ "Read as {source}, stored as {target}": "Gelezen als {source}, opgeslagen als {target}",
+ "Mapping (read-only)": "Mapping (alleen-lezen)",
+ "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per pack, welke kolom van de export naar welk veld gaat. Hij wordt gelezen uit de bestanden onder lib/Settings/cmdb-import van de app op de server; een bestand dat daar is aangepast wordt bij de volgende app-update overschreven.",
+ "Loading the mapping…": "Mapping laden…",
+ "The mapping could not be loaded.": "De mapping kon niet worden geladen.",
+ "{file}: {name}, version {version}": "{file}: {name}, versie {version}",
+ "This pack maps no columns": "Dit pack koppelt geen kolommen",
+ "Yes": "Ja",
+ "No": "Nee",
+ "Sheets read": "Gelezen tabbladen",
+ "Match column": "Koppelkolom",
+ "Name column": "Naamkolom",
+ "Required columns": "Verplichte kolommen",
+ "Date columns": "Datumkolommen",
+ "Column in the export": "Kolom in de export",
+ "Field": "Veld",
+ "Transformation": "Transformatie",
+ "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
}
}
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index ab7fa7080..4646c5a53 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -984,3 +984,225 @@ export function reportRows(rows) {
moduleUuid: row.moduleUuid ? String(row.moduleUuid) : '',
}))
}
+
+/**
+ * The URL of the mapping overview endpoint.
+ *
+ * @return {string} The URL
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export function mappingUrl() {
+ return generateUrl('/apps/stackiq/api/settings/cmdb-import/mapping')
+}
+
+/**
+ * Read the mapping the import uses: the profile and one entry per pack.
+ *
+ * @param {object} options The options
+ * @param {object} options.http An axios-like client with get
+ * @return {Promise<{profile: object, packs: Array}>} The server's answer
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export async function loadCmdbMapping({ http }) {
+ const response = await http.get(mappingUrl())
+ return response.data
+}
+
+/**
+ * The names of the source sheets: the profile's when the mapping has loaded,
+ * the shipped defaults until then, when it could not be loaded, or when it
+ * names fewer than the two sheets the help texts name.
+ *
+ * @param {object|null} mapping The mapping endpoint's answer, or null
+ * @return {Array} At least the two default names
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+export function mappingSheetNames(mapping) {
+ const sheets = mapping?.profile?.sheets
+ const names = (Array.isArray(sheets) ? sheets : [])
+ .map((sheet) =>
+ typeof sheet === 'string' ? sheet : String(sheet?.name ?? ''),
+ )
+ .filter((name) => name !== '')
+ return names.length >= 2 ? names : [...PROFILE_DEFAULTS.sheets]
+}
+
+/**
+ * The words for a pack's target: what the pack's rows become.
+ *
+ * @param {string} target The target as the profile names it (module, manufacturer, …)
+ * @return {string} The translated name, or the target itself when it is unknown
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export function packTargetLabel(target) {
+ switch (target) {
+ case 'module':
+ return t('stackiq', 'Application (module)')
+ case 'manufacturer':
+ return t('stackiq', 'Supplier organisation (Vendor)')
+ case 'municipality':
+ return t('stackiq', 'Municipality (from the import options)')
+ case 'usage':
+ return t('stackiq', 'Usage')
+ case 'businessOwner':
+ return t('stackiq', 'Business owner (contact person)')
+ default:
+ return String(target ?? '')
+ }
+}
+
+/**
+ * The words for a transformation type of OpenRegister's mapping engine.
+ *
+ * @param {string|null|undefined} type The transform type, or nothing when the value is copied as is
+ * @return {string} The translated name, or the type itself when it is unknown
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export function transformLabel(type) {
+ switch (type) {
+ case undefined:
+ case null:
+ case '':
+ return t('stackiq', 'As is')
+ case 'trim':
+ return t('stackiq', 'Trim')
+ case 'date':
+ return t('stackiq', 'Date')
+ case 'lookup':
+ return t('stackiq', 'Lookup')
+ case 'bool-map':
+ return t('stackiq', 'Yes/no lookup')
+ case 'concat':
+ return t('stackiq', 'Join')
+ case 'const':
+ return t('stackiq', 'Constant')
+ default:
+ return String(type)
+ }
+}
+
+/**
+ * One text per value, for the details column: a JSON array is shown as its
+ * members, a null as a dash, anything else as a string.
+ *
+ * @param {unknown} value The stored value
+ * @return {string} The text
+ */
+function valueText(value) {
+ if (value === null || value === undefined) {
+ return '—'
+ }
+ if (Array.isArray(value)) {
+ return value.map((member) => valueText(member)).join(', ')
+ }
+ if (typeof value === 'object') {
+ return JSON.stringify(value)
+ }
+ return String(value)
+}
+
+/**
+ * The details of a transformation, as lines: the pairs of a lookup, the
+ * extra columns of a join, the value of a constant, the formats of a date.
+ * A key the page does not know is listed as it is, so nothing the engine
+ * reads is hidden.
+ *
+ * @param {object|null} transform The transform as the pack stores it
+ * @return {Array} The lines, empty for a plain trim
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export function transformDetails(transform) {
+ if (!transform || typeof transform !== 'object') {
+ return []
+ }
+ const lines = []
+ const known = new Set(['type'])
+ if (transform.map && typeof transform.map === 'object') {
+ known.add('map')
+ for (const [from, to] of Object.entries(transform.map)) {
+ lines.push(`${from} → ${valueText(to)}`)
+ }
+ }
+ if (Object.hasOwn(transform, 'default')) {
+ known.add('default')
+ lines.push(
+ t(
+ 'stackiq',
+ 'Any other value: {value}',
+ { value: valueText(transform.default) },
+ AS_TEXT,
+ ),
+ )
+ }
+ if (Array.isArray(transform.fields)) {
+ known.add('fields')
+ known.add('separator')
+ lines.push(
+ t(
+ 'stackiq',
+ 'Joined with the columns {columns}, separated by "{separator}"',
+ {
+ columns: transform.fields
+ .map((field) => String(field))
+ .join(', '),
+ separator: String(transform.separator ?? ''),
+ },
+ AS_TEXT,
+ ),
+ )
+ }
+ if (Object.hasOwn(transform, 'value')) {
+ known.add('value')
+ lines.push(
+ t(
+ 'stackiq',
+ 'Value: {value}',
+ { value: valueText(transform.value) },
+ AS_TEXT,
+ ),
+ )
+ }
+ if (transform.sourceFormat || transform.targetFormat) {
+ known.add('sourceFormat')
+ known.add('targetFormat')
+ lines.push(
+ t(
+ 'stackiq',
+ 'Read as {source}, stored as {target}',
+ {
+ source: String(transform.sourceFormat ?? '—'),
+ target: String(transform.targetFormat ?? 'Y-m-d'),
+ },
+ AS_TEXT,
+ ),
+ )
+ }
+ for (const [key, value] of Object.entries(transform)) {
+ if (!known.has(key)) {
+ lines.push(`${key}: ${valueText(value)}`)
+ }
+ }
+ return lines
+}
+
+/**
+ * The rows of one pack's table: one per field mapping, in the pack's order.
+ *
+ * @param {object} pack One entry of the endpoint's `packs`
+ * @return {Array<{key: string, source: string, target: string, required: boolean, transform: string, details: Array}>} The rows
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export function mappingRows(pack) {
+ const mappings = pack?.fieldMappings
+ if (!Array.isArray(mappings)) {
+ return []
+ }
+ return mappings.map((mapping, index) => ({
+ key: `${mapping?.source ?? ''}:${mapping?.target ?? ''}:${index}`,
+ source: String(mapping?.source ?? ''),
+ target: String(mapping?.target ?? ''),
+ required: Boolean(mapping?.required),
+ transform: transformLabel(mapping?.transform?.type),
+ details: transformDetails(mapping?.transform),
+ }))
+}
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index 931f7edbc..d0598b407 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -55,6 +55,12 @@ jest.mock('vue-material-design-icons/DatabaseImport.vue', () => ({
jest.mock('vue-material-design-icons/TrayArrowUp.vue', () => ({
render: () => null,
}))
+jest.mock('vue-material-design-icons/ChevronDown.vue', () => ({
+ render: () => null,
+}))
+jest.mock('vue-material-design-icons/ChevronUp.vue', () => ({
+ render: () => null,
+}))
jest.mock('../../../components/AlwaysVisibleSection.vue', () => ({
render: () => null,
}))
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index f8bbc1e4b..8b5a6ff62 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -89,8 +89,8 @@
'stackiq',
'Excel workbook (.xlsx) with the sheet "{first}" or "{second}". By default the file may be at most {size}.',
{
- first: profileDefaults.sheets[0],
- second: profileDefaults.sheets[1],
+ first: sheetNames[0],
+ second: sheetNames[1],
size: formatMegabytes(profileDefaults.maxFileBytes),
},
asText,
@@ -361,6 +361,9 @@
+
+
+
@@ -382,8 +385,8 @@
'stackiq',
'The sheets "{first}" (applications without arranged maintenance) and "{second}" (with arranged maintenance) are read; other sheets, including the "Invoer" sheets, are ignored.',
{
- first: profileDefaults.sheets[0],
- second: profileDefaults.sheets[1],
+ first: sheetNames[0],
+ second: sheetNames[1],
},
asText,
)
@@ -436,6 +439,7 @@ import Close from 'vue-material-design-icons/Close.vue'
import DatabaseImport from 'vue-material-design-icons/DatabaseImport.vue'
import TrayArrowUp from 'vue-material-design-icons/TrayArrowUp.vue'
import AlwaysVisibleSection from '../../../components/AlwaysVisibleSection.vue'
+import CmdbImportMapping from './CmdbImportMapping.vue'
import { startProgressPolling } from '../../../utils/archiMateImportProgress.js'
import {
AS_TEXT,
@@ -451,6 +455,7 @@ import {
interruptedImportError,
isKnownError,
makeCmdbOperationId,
+ mappingSheetNames,
moduleUrl,
municipalityOptions,
normaliseError,
@@ -478,6 +483,7 @@ export default {
components: {
AlwaysVisibleSection,
+ CmdbImportMapping,
CnDataTable,
CnStatusBadge,
NcButton,
@@ -515,6 +521,7 @@ export default {
reportPageSize: REPORT_PAGE_SIZE,
visibleRowCount: REPORT_PAGE_SIZE,
profileDefaults: PROFILE_DEFAULTS,
+ mapping: null,
asText: AS_TEXT,
outcomeColors: {
created: 'success',
@@ -527,6 +534,18 @@ export default {
},
computed: {
+ /**
+ * The names of the sheets the import reads, for the help texts: the
+ * profile's once the mapping block has loaded it, the shipped names
+ * until then or when it could not be loaded.
+ *
+ * @return {Array} At least two names
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ sheetNames() {
+ return mappingSheetNames(this.mapping)
+ },
+
/**
* Whether everything the import needs has been chosen.
*
@@ -771,6 +790,17 @@ export default {
moduleUrl,
outcomeLabel,
+ /**
+ * Keep the mapping the block loaded, so the help text names its sheets.
+ *
+ * @param {object} mapping The mapping endpoint's answer
+ * @return {void}
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ onMappingLoaded(mapping) {
+ this.mapping = mapping
+ },
+
/**
* Read the organisations of type Municipality from OpenRegister.
*
diff --git a/src/views/settings/sections/CmdbImportMapping.vue b/src/views/settings/sections/CmdbImportMapping.vue
new file mode 100644
index 000000000..127226a9a
--- /dev/null
+++ b/src/views/settings/sections/CmdbImportMapping.vue
@@ -0,0 +1,358 @@
+
+
+
+
+
+
+
+
+
+ {{ t('stackiq', 'Mapping (read-only)') }}
+
+
+
+
+ {{
+ t(
+ 'stackiq',
+ 'The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.',
+ )
+ }}
+
+
+
+
+ {{ t('stackiq', 'Loading the mapping…') }}
+
+
+
+
+
+ {{ errorView.reason }}
+
+
+ {{ errorView.message }}
+
+
+ {{
+ t(
+ 'stackiq',
+ 'Error code: {code}',
+ { code: errorView.code },
+ asText,
+ )
+ }}
+
+
+
+
+
+
+
{{ fact.label }}
+ {{ fact.value }}
+
+
+
+
+
+ {{ packTargetLabel(pack.target) }}
+
+
+ {{
+ t(
+ 'stackiq',
+ '{file}: {name}, version {version}',
+ {
+ file: pack.file,
+ name: pack.name,
+ version: pack.version,
+ },
+ asText,
+ )
+ }}
+
+
+ {{ pack.description }}
+
+
+
+ {{
+ row.source
+ }}
+
+
+ {{ row.target }}
+
+
+ {{
+ row.required
+ ? t('stackiq', 'Yes')
+ : t('stackiq', 'No')
+ }}
+
+
+
+ —
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
new file mode 100644
index 000000000..df1164397
--- /dev/null
+++ b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
@@ -0,0 +1,231 @@
+// SPDX-License-Identifier: EUPL-1.2
+// SPDX-FileCopyrightText: 2026 Conduction B.V.
+/**
+ * E2e coverage for openspec/changes/cmdb-import-mapping-view (the read-only
+ * "Mapping (read-only)" block of the "CMDB import" settings section).
+ *
+ * Every scenario the delta spec tags
+ * `@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts` is driven here:
+ * the endpoint as an admin, the block on the real settings page, the sheet
+ * names in the help text (also with the endpoint answering 503, through a
+ * route intercept, so no shipped file is broken on a shared instance), and
+ * the refusal for a signed-in user who is not a Nextcloud admin.
+ *
+ * The shipped profile and packs are the expected values; nothing is written.
+ */
+
+import type { Page } from '@playwright/test'
+
+import { expect, request as playwrightRequest, test } from '@playwright/test'
+import { BASE_URL, newApiContext, RUN_ID } from '../workflows/_fixtures.ts'
+
+const MAPPING_PATH = '/index.php/apps/stackiq/api/settings/cmdb-import/mapping'
+// The page builds its URL with generateUrl(), which drops `/index.php` on an
+// instance with pretty URLs, so the browser-side matcher uses the path tail.
+const MAPPING_ROUTE = '**/apps/stackiq/api/settings/cmdb-import/mapping'
+const TARGETS = ['module', 'manufacturer', 'municipality', 'usage', 'businessOwner']
+const SHIPPED_SHEETS = ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']
+
+/**
+ * Close Nextcloud's first-run wizard when it shows, the way
+ * cmdb-import.spec.ts does: mark it as seen and load the page again.
+ *
+ * @param page The page
+ * @return True when the page was reloaded
+ */
+async function dismissFirstRunWizard(page: Page): Promise {
+ const wizard = page.locator('.first-run-wizard[role="dialog"]')
+ try {
+ await wizard.waitFor({ state: 'visible', timeout: 3000 })
+ } catch {
+ return false
+ }
+ await page.evaluate(async () => {
+ const oc = (
+ window as unknown as {
+ OC: { requestToken: string; generateUrl: (u: string) => string }
+ }
+ ).OC
+ await fetch(oc.generateUrl('/apps/firstrunwizard/wizard'), {
+ method: 'DELETE',
+ headers: { requesttoken: oc.requestToken },
+ })
+ })
+ await page.reload({ waitUntil: 'domcontentloaded' })
+ return true
+}
+
+/**
+ * Open stackiq's admin settings and return the CMDB import section.
+ *
+ * @param page The page
+ */
+async function gotoCmdbSection(page: Page) {
+ await page.goto('/settings/admin/stackiq', { waitUntil: 'domcontentloaded' })
+ const section = page.locator('[data-testid="cmdb-import"]')
+ await expect(section).toBeVisible({ timeout: 30000 })
+ if (await dismissFirstRunWizard(page)) {
+ await expect(section).toBeVisible({ timeout: 30000 })
+ }
+ await section.scrollIntoViewIfNeeded()
+ return section
+}
+
+test.describe('CMDB import mapping (read-only)', () => {
+ // @e2e cmdb-export-import::an-admin-reads-the-mapping-the-import-uses
+ test('an admin reads the mapping the import uses', async () => {
+ const ctx = await newApiContext()
+ try {
+ const res = await ctx.get(MAPPING_PATH)
+ expect(res.status(), await res.text()).toBe(200)
+ const body = await res.json()
+
+ expect(Object.keys(body).sort()).toEqual(['packs', 'profile'])
+ expect(
+ body.profile.sheets.map((sheet: { name: string }) => sheet.name),
+ ).toEqual(SHIPPED_SHEETS)
+ expect(body.profile.keyColumn).toBe('APPID')
+ expect(body.profile.requiredColumns).toEqual([
+ 'APPID',
+ 'Applicatie Naam',
+ ])
+
+ expect(
+ body.packs.map((pack: { target: string }) => pack.target),
+ ).toEqual(TARGETS)
+ for (const pack of body.packs) {
+ expect(pack.id, pack.target).not.toBe('')
+ expect(pack.name, pack.target).not.toBe('')
+ expect(pack.version, pack.target).not.toBe('')
+ }
+
+ const usage = body.packs.find(
+ (pack: { target: string }) => pack.target === 'usage',
+ )
+ const status = usage.fieldMappings.find(
+ (mapping: { source: string }) =>
+ mapping.source === 'Applicatie Status',
+ )
+ expect(status.target).toBe('status')
+ expect(status.transform.type).toBe('lookup')
+ expect(status.transform.map['In productie']).toBe('In production')
+ } finally {
+ await ctx.dispose()
+ }
+ })
+
+ // @e2e cmdb-export-import::the-section-shows-one-table-per-pack
+ test('the section shows one table per pack, without editing', async ({
+ page,
+ }) => {
+ const section = await gotoCmdbSection(page)
+ const block = section.locator('[data-testid="cmdb-import-mapping"]')
+ const toggle = block.locator('[data-testid="cmdb-import-mapping-toggle"]')
+
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false')
+ await toggle.click()
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true')
+
+ for (const target of TARGETS) {
+ await expect(
+ block.locator(`[data-testid="cmdb-import-mapping-${target}"]`),
+ target,
+ ).toBeVisible({ timeout: 30000 })
+ }
+
+ const statusRow = block
+ .locator('[data-testid="cmdb-import-mapping-usage"]')
+ .getByRole('row')
+ .filter({ hasText: 'Applicatie Status' })
+ await expect(statusRow).toContainText('status')
+ await expect(statusRow).toContainText(/Lookup|Opzoektabel/)
+ await expect(statusRow).toContainText('In productie → In production')
+
+ // Read-only: the toggle is the block's only control.
+ await expect(block.getByRole('button')).toHaveCount(1)
+ await expect(block.locator('input, textarea, select')).toHaveCount(0)
+ })
+
+ // @e2e cmdb-export-import::the-sheet-names-in-the-help-come-from-the-server
+ test('the help text names the sheets from the server, and the shipped ones when it fails', async ({
+ page,
+ }) => {
+ // The endpoint answers other names: the help must follow it.
+ await page.route(MAPPING_ROUTE, async (route) => {
+ const response = await route.fetch()
+ const body = await response.json()
+ body.profile.sheets = [
+ { name: `Sheet one ${RUN_ID}`, constants: {}, absentColumns: [] },
+ { name: `Sheet two ${RUN_ID}`, constants: {}, absentColumns: [] },
+ ]
+ await route.fulfill({ response, json: body })
+ })
+ await gotoCmdbSection(page)
+ const help = page.locator('#cmdb-import-file-help')
+ await expect(help).toContainText(`Sheet one ${RUN_ID}`)
+ await expect(help).toContainText(`Sheet two ${RUN_ID}`)
+
+ // The endpoint fails: the shipped names, and the block shows the error.
+ await page.unroute(MAPPING_ROUTE)
+ await page.route(MAPPING_ROUTE, (route) =>
+ route.fulfill({
+ status: 503,
+ json: {
+ success: false,
+ error: 'MAPPING_UNAVAILABLE',
+ message: 'The import mapping cannot be shown.',
+ details: { reason: 'topdesk-usage.json: refused in this test' },
+ },
+ }),
+ )
+ const section = await gotoCmdbSection(page)
+ for (const sheet of SHIPPED_SHEETS) {
+ await expect(help).toContainText(sheet)
+ }
+ await section.locator('[data-testid="cmdb-import-mapping-toggle"]').click()
+ const error = section.locator('[data-testid="cmdb-import-mapping-error"]')
+ await expect(error).toBeVisible()
+ await expect(error).toContainText('MAPPING_UNAVAILABLE')
+ await expect(error).toContainText('topdesk-usage.json: refused in this test')
+ await expect(
+ section.locator('[data-testid="cmdb-import-mapping-usage"]'),
+ ).toHaveCount(0)
+ })
+
+ // @e2e cmdb-export-import::a-user-who-is-not-a-nextcloud-admin-cannot-read-the-mapping
+ test('a signed-in user who is not a Nextcloud admin is refused', async () => {
+ const ctx = await newApiContext()
+ const userId = `cmdbmap-${RUN_ID}`
+ const password = `Cmdbmap-${RUN_ID}-Pw!9`
+ const created = await ctx.post('/ocs/v2.php/cloud/users?format=json', {
+ form: { userid: userId, password },
+ })
+ expect(created.ok(), await created.text()).toBe(true)
+ const user = await playwrightRequest.newContext({
+ baseURL: BASE_URL,
+ storageState: { cookies: [], origins: [] },
+ httpCredentials: { username: userId, password, send: 'always' },
+ extraHTTPHeaders: { 'OCS-APIREQUEST': 'true' },
+ })
+ try {
+ // Prove the context is that user, so a 403 is the rule and not a failed login.
+ const whoami = await user.get('/ocs/v2.php/cloud/user?format=json')
+ expect(whoami.status()).toBe(200)
+ expect((await whoami.json()).ocs.data.id).toBe(userId)
+
+ const res = await user.get(MAPPING_PATH)
+ expect(res.status()).toBe(403)
+
+ // The admin settings page that holds the section is not theirs either:
+ // Nextcloud refuses or redirects it, it never renders it (2xx).
+ const settings = await user.get('/index.php/settings/admin/stackiq', {
+ maxRedirects: 0,
+ })
+ expect(settings.ok(), `status ${settings.status()}`).toBe(false)
+ } finally {
+ await user.dispose()
+ await ctx.delete(`/ocs/v2.php/cloud/users/${userId}?format=json`)
+ await ctx.dispose()
+ }
+ })
+})
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
new file mode 100644
index 000000000..b7ad8715f
--- /dev/null
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -0,0 +1,281 @@
+// @vitest-environment jsdom
+
+/**
+ * SPDX-FileCopyrightText: 2026 Conduction B.V.
+ * SPDX-License-Identifier: EUPL-1.2
+ *
+ * The read-only "Mapping (read-only)" block of the CMDB import section, and
+ * the helpers that shape the mapping endpoint's answer into its tables.
+ *
+ * The block is mounted against a mocked endpoint answer: one table per pack,
+ * the lookup pairs of a row, the `loaded` event the section takes its sheet
+ * names from, and the error state of a 503.
+ *
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, h } from 'vue'
+
+const http = vi.hoisted(() => ({ get: vi.fn() }))
+
+vi.mock('@nextcloud/axios', () => ({ default: http }))
+
+// `@conduction/nextcloud-vue` is not resolvable in this offline suite. The
+// stub renders the rows through the same `column-` slots the real table
+// offers, so the block's own cell markup is what is asserted.
+vi.mock('@conduction/nextcloud-vue', () => ({
+ CnDataTable: defineComponent({
+ name: 'CnDataTable',
+ props: {
+ rows: { type: Array, default: () => [] },
+ columns: { type: Array, default: () => [] },
+ rowKey: { type: String, default: 'id' },
+ emptyText: { type: String, default: '' },
+ },
+ setup(props, { slots }) {
+ return () =>
+ h('table', [
+ h(
+ 'thead',
+ h(
+ 'tr',
+ props.columns.map((column) => h('th', column.label)),
+ ),
+ ),
+ h(
+ 'tbody',
+ props.rows.map((row) =>
+ h(
+ 'tr',
+ { key: row[props.rowKey] },
+ props.columns.map((column) => {
+ const slot = slots['column-' + column.key]
+ return h(
+ 'td',
+ slot
+ ? slot({ row })
+ : String(row[column.key]),
+ )
+ }),
+ ),
+ ),
+ ),
+ ])
+ },
+ }),
+}))
+
+const {
+ mappingRows,
+ mappingSheetNames,
+ packTargetLabel,
+ PROFILE_DEFAULTS,
+ transformDetails,
+ transformLabel,
+} = await import('../../src/utils/cmdbImport.js')
+const CmdbImportMapping = (
+ await import('../../src/views/settings/sections/CmdbImportMapping.vue')
+).default
+
+/**
+ * A small answer of the mapping endpoint, shaped like the shipped files.
+ *
+ * @return {{profile: object, packs: Array}} The answer
+ */
+function answer() {
+ const pack = (target, fieldMappings = []) => ({
+ target,
+ file: `topdesk-${target}.json`,
+ id: `stackiq-topdesk-${target}`,
+ name: `TOPdesk ${target}`,
+ version: '1.0.0',
+ description: '',
+ fieldMappings,
+ })
+ return {
+ profile: {
+ id: 'topdesk-cmdb',
+ sheets: [
+ { name: 'Sheet A', constants: {}, absentColumns: [] },
+ { name: 'Sheet B', constants: {}, absentColumns: [] },
+ ],
+ keyColumn: 'APPID',
+ nameColumn: 'Applicatie Naam',
+ requiredColumns: ['APPID', 'Applicatie Naam'],
+ dateColumns: ['Datum'],
+ missingRecords: ['keep'],
+ },
+ packs: [
+ pack('module', [
+ {
+ source: 'Applicatie Naam',
+ target: 'name',
+ required: true,
+ transform: { type: 'trim' },
+ },
+ ]),
+ pack('manufacturer'),
+ pack('municipality'),
+ pack('usage', [
+ {
+ source: 'Applicatie Status',
+ target: 'status',
+ required: false,
+ transform: {
+ type: 'lookup',
+ map: { 'In productie': 'In production' },
+ default: null,
+ },
+ },
+ ]),
+ pack('businessOwner'),
+ ],
+ }
+}
+
+describe('CmdbImportMapping', () => {
+ beforeEach(() => {
+ http.get.mockReset()
+ })
+
+ it('renders one table per pack from the endpoint and emits the answer', async () => {
+ http.get.mockResolvedValue({ data: answer() })
+
+ const wrapper = mount(CmdbImportMapping)
+ await flushPromises()
+
+ expect(http.get).toHaveBeenCalledWith(
+ expect.stringContaining(
+ '/apps/stackiq/api/settings/cmdb-import/mapping',
+ ),
+ )
+ expect(wrapper.emitted('loaded')[0][0].profile.keyColumn).toBe('APPID')
+
+ const toggle = wrapper.find('[data-testid="cmdb-import-mapping-toggle"]')
+ expect(toggle.attributes('aria-expanded')).toBe('false')
+ await toggle.trigger('click')
+ expect(toggle.attributes('aria-expanded')).toBe('true')
+
+ for (const target of [
+ 'module',
+ 'manufacturer',
+ 'municipality',
+ 'usage',
+ 'businessOwner',
+ ]) {
+ expect(
+ wrapper
+ .find(`[data-testid="cmdb-import-mapping-${target}"]`)
+ .exists(),
+ target,
+ ).toBe(true)
+ }
+
+ const usage = wrapper.find('[data-testid="cmdb-import-mapping-usage"]')
+ expect(usage.text()).toContain('Applicatie Status')
+ expect(usage.text()).toContain('status')
+ expect(usage.text()).toContain('Lookup')
+ expect(usage.text()).toContain('In productie → In production')
+ expect(
+ wrapper.find('[data-testid="cmdb-import-mapping-profile"]').text(),
+ ).toContain('Sheet A, Sheet B')
+ expect(
+ wrapper.find('[data-testid="cmdb-import-mapping-error"]').exists(),
+ ).toBe(false)
+ // Read-only: the only button is the toggle.
+ expect(wrapper.findAll('button')).toHaveLength(1)
+ })
+
+ it('shows the error, its reason and its code on a 503, and no table', async () => {
+ http.get.mockRejectedValue({
+ response: {
+ status: 503,
+ data: {
+ success: false,
+ error: 'MAPPING_UNAVAILABLE',
+ message: 'The import mapping cannot be shown.',
+ details: { reason: 'topdesk-usage.json: unknown transform' },
+ },
+ },
+ })
+
+ const wrapper = mount(CmdbImportMapping)
+ await flushPromises()
+ await wrapper
+ .find('[data-testid="cmdb-import-mapping-toggle"]')
+ .trigger('click')
+
+ const error = wrapper.find('[data-testid="cmdb-import-mapping-error"]')
+ expect(error.exists()).toBe(true)
+ expect(error.text()).toContain('topdesk-usage.json: unknown transform')
+ expect(error.text()).toContain('MAPPING_UNAVAILABLE')
+ expect(
+ wrapper.find('[data-testid="cmdb-import-mapping-usage"]').exists(),
+ ).toBe(false)
+ expect(wrapper.emitted('loaded')).toBeUndefined()
+ })
+})
+
+describe('cmdbImport mapping helpers', () => {
+ it('takes the sheet names from the answer, and the shipped ones without it', () => {
+ expect(mappingSheetNames(answer())).toEqual(['Sheet A', 'Sheet B'])
+ expect(mappingSheetNames(null)).toEqual(PROFILE_DEFAULTS.sheets)
+ expect(mappingSheetNames({ profile: { sheets: [] } })).toEqual(
+ PROFILE_DEFAULTS.sheets,
+ )
+ })
+
+ it('keeps the shipped sheet names when the answer names only one sheet', () => {
+ expect(
+ mappingSheetNames({ profile: { sheets: [{ name: 'Sheet A' }] } }),
+ ).toEqual(PROFILE_DEFAULTS.sheets)
+ })
+
+ it('describes every transformation the packs use', () => {
+ expect(transformLabel(undefined)).toBe('As is')
+ expect(transformLabel('bool-map')).toBe('Yes/no lookup')
+ expect(transformLabel('something-new')).toBe('something-new')
+ expect(transformDetails({ type: 'trim' })).toEqual([])
+ expect(
+ transformDetails({
+ type: 'concat',
+ fields: ['Cluster', 'Afdeling'],
+ separator: ' / ',
+ }),
+ ).toEqual(['Joined with the columns Cluster, Afdeling, separated by " / "'])
+ expect(transformDetails({ type: 'const', value: 'Gemeente' })).toEqual([
+ 'Value: Gemeente',
+ ])
+ expect(
+ transformDetails({
+ type: 'date',
+ sourceFormat: 'd-m-Y',
+ targetFormat: 'Y-m-d',
+ }),
+ ).toEqual(['Read as d-m-Y, stored as Y-m-d'])
+ expect(
+ transformDetails({ type: 'lookup', map: { Ja: true }, extra: 1 }),
+ ).toEqual(['Ja → true', 'extra: 1'])
+ })
+
+ it('builds one row per field mapping and names each target', () => {
+ const rows = mappingRows(answer().packs[3])
+ expect(rows).toHaveLength(1)
+ expect(rows[0]).toMatchObject({
+ source: 'Applicatie Status',
+ target: 'status',
+ required: false,
+ transform: 'Lookup',
+ })
+ expect(rows[0].details).toEqual([
+ 'In productie → In production',
+ 'Any other value: —',
+ ])
+ expect(mappingRows({})).toEqual([])
+ expect(packTargetLabel('businessOwner')).toBe(
+ 'Business owner (contact person)',
+ )
+ })
+})
From a2b951dad8d8c3415f31d90b049db3a74ad2129b Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:17:33 +0200
Subject: [PATCH 02/14] fix(cmdb-import): show the import's write rules in the
mapping block
The mapping tables listed every mapped field as written on each import, but
the import writes the usage's interneAnnotation and timeClassification only
on create or when the stored value is empty, sets the module type on create,
and never writes the module's publication dates on an existing record. The
block now shows, per row, when the import writes the field, and under each
table the pack defaults, the create-only values and the never-written fields
that the endpoint answers since the previous slice. New strings in en and nl;
REQ-CMDB-020, the user docs, the vitest and the e2e API check name them.
Review #1288 f2
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 7 ++
l10n/en.js | 10 +-
l10n/en.json | 10 +-
l10n/nl.js | 10 +-
l10n/nl.json | 10 +-
.../specs/cmdb-export-import/spec.md | 3 +-
src/utils/cmdbImport.js | 106 +++++++++++++++++-
.../settings/sections/CmdbImportMapping.vue | 23 ++++
.../spec-coverage/cmdb-import-mapping.spec.ts | 14 +++
tests/vitest/cmdbImportMapping.spec.js | 71 +++++++++++-
10 files changed, 253 insertions(+), 11 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index 0cdc4f481..d6c0d7468 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -348,6 +348,13 @@ The section shows the mapping the next import runs, read-only. Open
whether the column is required, the transformation (as is, trim, date,
lookup, yes/no lookup, join, constant) and its details, such as the export
values a lookup recognises and what each becomes.
+- per row, when the import writes the field: on create and update, only on
+ create or when the stored value is empty (such as the usage's TIME
+ classification and internal annotation), or never on an existing record;
+- under each table, the pack's write rules that are not columns: the pack's
+ defaults, the values set only when a record is created (such as the module
+ type `Application`) and the fields never written on an existing record
+ (the module's publication and depublication dates).
The block reads the files through the same loader the import uses, so what
it shows is what the import does. When a file is invalid, the block shows
diff --git a/l10n/en.js b/l10n/en.js
index 60a730e51..8c8fc1d29 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1165,7 +1165,15 @@ OC.L10N.register(
"Field": "Field",
"Transformation": "Transformation",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Written": "Written",
+ "On create and update": "On create and update",
+ "Only on create, or when empty": "Only on create, or when empty",
+ "Never on an existing record": "Never on an existing record",
+ "Pack defaults: {values}": "Pack defaults: {values}",
+ "Set only when the record is created: {values}": "Set only when the record is created: {values}",
+ "Written on create, or on update only when the stored value is empty: {fields}": "Written on create, or on update only when the stored value is empty: {fields}",
+ "Never written on an existing record: {fields}": "Never written on an existing record: {fields}"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 5e5a32434..14cf19581 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1164,6 +1164,14 @@
"Field": "Field",
"Transformation": "Transformation",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Written": "Written",
+ "On create and update": "On create and update",
+ "Only on create, or when empty": "Only on create, or when empty",
+ "Never on an existing record": "Never on an existing record",
+ "Pack defaults: {values}": "Pack defaults: {values}",
+ "Set only when the record is created: {values}": "Set only when the record is created: {values}",
+ "Written on create, or on update only when the stored value is empty: {fields}": "Written on create, or on update only when the stored value is empty: {fields}",
+ "Never written on an existing record: {fields}": "Never written on an existing record: {fields}"
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index e51e4af38..7f0a389f7 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1233,7 +1233,15 @@ OC.L10N.register(
"Field": "Veld",
"Transformation": "Transformatie",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Written": "Geschreven",
+ "On create and update": "Bij aanmaken en bijwerken",
+ "Only on create, or when empty": "Alleen bij aanmaken, of als leeg",
+ "Never on an existing record": "Nooit op een bestaand record",
+ "Pack defaults: {values}": "Standaardwaarden van het pack: {values}",
+ "Set only when the record is created: {values}": "Alleen ingesteld bij het aanmaken van het record: {values}",
+ "Written on create, or on update only when the stored value is empty: {fields}": "Geschreven bij aanmaken, of bij bijwerken alleen als de opgeslagen waarde leeg is: {fields}",
+ "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}"
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 6ab4bbf98..6d9121ab5 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1232,6 +1232,14 @@
"Field": "Veld",
"Transformation": "Transformatie",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Written": "Geschreven",
+ "On create and update": "Bij aanmaken en bijwerken",
+ "Only on create, or when empty": "Alleen bij aanmaken, of als leeg",
+ "Never on an existing record": "Nooit op een bestaand record",
+ "Pack defaults: {values}": "Standaardwaarden van het pack: {values}",
+ "Set only when the record is created: {values}": "Alleen ingesteld bij het aanmaken van het record: {values}",
+ "Written on create, or on update only when the stored value is empty: {fields}": "Geschreven bij aanmaken, of bij bijwerken alleen als de opgeslagen waarde leeg is: {fields}",
+ "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}"
}
}
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 9d807394b..7ff22e728 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -16,7 +16,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
### Requirement: The admin settings SHALL show the mapping the import uses (REQ-CMDB-020)
-`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values set on create only, field => value), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
+`GET /api/settings/cmdb-import/mapping` SHALL be reachable only by Nextcloud admins and SHALL require Nextcloud's CSRF token; like the import routes (REQ-CMDB-001) it SHALL NOT carry `#[AuthorizedAdminSetting]`, `#[NoAdminRequired]` or `#[NoCSRFRequired]`. It SHALL load the import profile and every pack it names through the same loader and validator the import uses, and SHALL answer 200 with a flat envelope of two keys: `profile` (the profile's `id`, `name`, `version`, `sheets` with each sheet's `name`, `constants` and `absentColumns`, `sheetPrecedence`, `keyColumn`, `nameColumn`, `requiredColumns`, `dateColumns`, `idColumns`, `emptyValues`, `externalKeyPrefix`, `missingRecords` and `profileFile`) and `packs`, one entry per target in the order module, manufacturer, municipality, usage, businessOwner, each with `target`, `file`, `id`, `name`, `version`, `description`, `fieldMappings` and the write rules the import applies besides the mapping: `defaults` (the pack's values, field => value), `createOnlyDefaults` (values set on create only, field => value), `createOnlyFields` (mapped fields written on create or, on an existing object, only when the stored value is empty) and `neverWritten` (fields the import never writes on an existing object); the last three come from the profile's `createOnly` and `neverWritten` through the accessors the import itself uses. Each field mapping SHALL carry `source`, `target`, `required` (a boolean) and `transform` as the pack stores it: the `type`, and for `lookup` and `bool-map` the `map` and `default`, for `concat` the `fields` and `separator`, for `const` the `value`, for `date` the `sourceFormat` and `targetFormat`. The endpoint SHALL read nothing but those files and SHALL write nothing. When the validator is missing, or the profile or a pack is unreadable or invalid, it SHALL answer 503 `MAPPING_UNAVAILABLE` in the import's error envelope (`success`, `error`, `message`, `details`) with the loader's reason in `details.reason`, so the administrator who edited a file learns what is wrong without the Nextcloud log. The "CMDB import" section SHALL show the answer in a collapsible block "Mapping (read-only)": the sheets, the key, name, required and date columns, and one table per pack with the source column, the target field, whether it is required, the transformation and its details, and when the import writes the field (on create and update, only on create or when the stored value is empty, or never on an existing record). Below each table the block SHALL list the pack's write rules that are not columns: its `defaults`, the `createOnlyDefaults` and the `neverWritten` fields. The block SHALL show a loading state until the endpoint answers and the error with its code when it fails, and SHALL offer no editing: the mapping is changed in the files on the server, as the documentation describes.
#### Scenario: An admin reads the mapping the import uses
@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
@@ -35,6 +35,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
- **WHEN** they open "Mapping (read-only)" in the "CMDB import" section
- **THEN** the section SHALL show five tables, one per pack, each named after its target and its file
- **AND** the usage table SHALL have a row for the column "Applicatie Status" with the field `status`, the transformation Lookup and the pair "In productie" to "In production"
+- **AND** the usage table SHALL show "Only on create, or when empty" for the fields `interneAnnotation` and `timeClassification`, and the module table SHALL list `type = Application` as set on create only and `publicationDate` and `depublicationDate` as never written on an existing record
- **AND** the block SHALL have no control that edits a mapping
#### Scenario: A broken pack is reported with its reason
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 4646c5a53..07f494e7d 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -1185,11 +1185,114 @@ export function transformDetails(transform) {
return lines
}
+/**
+ * A list of names from the endpoint, as strings.
+ *
+ * @param {unknown} values A list, or anything else
+ * @return {Array} The names, empty when it is not a list
+ */
+function nameList(values) {
+ return Array.isArray(values) ? values.map((value) => String(value)) : []
+}
+
+/**
+ * A field => value object of the endpoint as "field = value" pairs.
+ *
+ * @param {unknown} values The object, or anything else
+ * @return {string} The pairs, joined with commas; empty when there are none
+ */
+function pairsText(values) {
+ if (!values || typeof values !== 'object' || Array.isArray(values)) {
+ return ''
+ }
+ return Object.entries(values)
+ .map(([field, value]) => `${field} = ${valueText(value)}`)
+ .join(', ')
+}
+
+/**
+ * When the import writes a mapped field: on create and update, only on
+ * create or when the stored value is empty, or never on an existing record.
+ * Never wins when a profile lists the field in both.
+ *
+ * @param {object} pack One entry of the endpoint's `packs`
+ * @param {string} target The field a mapping writes to
+ * @return {string} The translated words
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export function writtenLabel(pack, target) {
+ if (nameList(pack?.neverWritten).includes(target)) {
+ return t('stackiq', 'Never on an existing record')
+ }
+ if (nameList(pack?.createOnlyFields).includes(target)) {
+ return t('stackiq', 'Only on create, or when empty')
+ }
+ return t('stackiq', 'On create and update')
+}
+
+/**
+ * The write rules of a pack that its table rows cannot show, as lines: the
+ * pack's defaults, the values set on create only, and the fields the import
+ * leaves alone on an existing record (which need not be mapped columns).
+ *
+ * @param {object} pack One entry of the endpoint's `packs`
+ * @return {Array} The lines, empty when the pack has no such rule
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
+ */
+export function packRules(pack) {
+ const lines = []
+ const defaults = pairsText(pack?.defaults)
+ if (defaults !== '') {
+ lines.push(t('stackiq', 'Pack defaults: {values}', { values: defaults }, AS_TEXT))
+ }
+ const createOnly = pairsText(pack?.createOnlyDefaults)
+ if (createOnly !== '') {
+ lines.push(
+ t(
+ 'stackiq',
+ 'Set only when the record is created: {values}',
+ { values: createOnly },
+ AS_TEXT,
+ ),
+ )
+ }
+ const setOnCreate = Object.keys(
+ pack?.createOnlyDefaults && !Array.isArray(pack.createOnlyDefaults)
+ ? pack.createOnlyDefaults
+ : {},
+ )
+ const createOnlyFields = nameList(pack?.createOnlyFields).filter(
+ (field) => !setOnCreate.includes(field),
+ )
+ if (createOnlyFields.length > 0) {
+ lines.push(
+ t(
+ 'stackiq',
+ 'Written on create, or on update only when the stored value is empty: {fields}',
+ { fields: createOnlyFields.join(', ') },
+ AS_TEXT,
+ ),
+ )
+ }
+ const never = nameList(pack?.neverWritten)
+ if (never.length > 0) {
+ lines.push(
+ t(
+ 'stackiq',
+ 'Never written on an existing record: {fields}',
+ { fields: never.join(', ') },
+ AS_TEXT,
+ ),
+ )
+ }
+ return lines
+}
+
/**
* The rows of one pack's table: one per field mapping, in the pack's order.
*
* @param {object} pack One entry of the endpoint's `packs`
- * @return {Array<{key: string, source: string, target: string, required: boolean, transform: string, details: Array}>} The rows
+ * @return {Array<{key: string, source: string, target: string, required: boolean, transform: string, details: Array, written: string}>} The rows
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
*/
export function mappingRows(pack) {
@@ -1204,5 +1307,6 @@ export function mappingRows(pack) {
required: Boolean(mapping?.required),
transform: transformLabel(mapping?.transform?.type),
details: transformDetails(mapping?.transform),
+ written: writtenLabel(pack, String(mapping?.target ?? '')),
}))
}
diff --git a/src/views/settings/sections/CmdbImportMapping.vue b/src/views/settings/sections/CmdbImportMapping.vue
index 127226a9a..06928f735 100644
--- a/src/views/settings/sections/CmdbImportMapping.vue
+++ b/src/views/settings/sections/CmdbImportMapping.vue
@@ -118,6 +118,11 @@
: t('stackiq', 'No')
}}
+
+ {{
+ row.written
+ }}
+
+
@@ -149,6 +162,7 @@ import {
loadCmdbMapping,
mappingRows,
normaliseError,
+ packRules,
packTargetLabel,
} from '../../../utils/cmdbImport.js'
@@ -260,6 +274,7 @@ export default {
{ key: 'required', label: t('stackiq', 'Required') },
{ key: 'transform', label: t('stackiq', 'Transformation') },
{ key: 'details', label: t('stackiq', 'Details') },
+ { key: 'written', label: t('stackiq', 'Written') },
]
},
},
@@ -277,6 +292,7 @@ export default {
methods: {
t,
mappingRows,
+ packRules,
packTargetLabel,
/**
@@ -350,6 +366,13 @@ export default {
overflow-wrap: anywhere;
}
+.cmdb-import-mapping__rules {
+ margin: 0.5rem 0 0;
+ padding-inline-start: 1.25rem;
+ list-style: disc;
+ font-size: 0.875rem;
+}
+
.cmdb-import-mapping__details {
margin: 0;
padding-inline-start: 1.25rem;
diff --git a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
index df1164397..a4c41c44b 100644
--- a/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
+++ b/tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts
@@ -109,6 +109,20 @@ test.describe('CMDB import mapping (read-only)', () => {
expect(status.target).toBe('status')
expect(status.transform.type).toBe('lookup')
expect(status.transform.map['In productie']).toBe('In production')
+
+ // The write rules the import applies besides the mapping.
+ expect(usage.createOnlyFields).toEqual([
+ 'interneAnnotation',
+ 'timeClassification',
+ ])
+ const moduleRules = body.packs.find(
+ (pack: { target: string }) => pack.target === 'module',
+ )
+ expect(moduleRules.createOnlyDefaults).toEqual({ type: 'Application' })
+ expect(moduleRules.neverWritten).toEqual([
+ 'publicationDate',
+ 'depublicationDate',
+ ])
} finally {
await ctx.dispose()
}
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
index b7ad8715f..ee8e70f16 100644
--- a/tests/vitest/cmdbImportMapping.spec.js
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -70,6 +70,7 @@ vi.mock('@conduction/nextcloud-vue', () => ({
const {
mappingRows,
mappingSheetNames,
+ packRules,
packTargetLabel,
PROFILE_DEFAULTS,
transformDetails,
@@ -85,7 +86,7 @@ const CmdbImportMapping = (
* @return {{profile: object, packs: Array}} The answer
*/
function answer() {
- const pack = (target, fieldMappings = []) => ({
+ const pack = (target, fieldMappings = [], rules = {}) => ({
target,
file: `topdesk-${target}.json`,
id: `stackiq-topdesk-${target}`,
@@ -93,6 +94,11 @@ function answer() {
version: '1.0.0',
description: '',
fieldMappings,
+ defaults: {},
+ createOnlyDefaults: {},
+ createOnlyFields: [],
+ neverWritten: [],
+ ...rules,
})
return {
profile: {
@@ -115,8 +121,14 @@ function answer() {
required: true,
transform: { type: 'trim' },
},
- ]),
- pack('manufacturer'),
+ ], {
+ createOnlyDefaults: { type: 'Application' },
+ createOnlyFields: ['type'],
+ neverWritten: ['publicationDate', 'depublicationDate'],
+ }),
+ pack('manufacturer', [], {
+ defaults: { type: 'Supplier', status: 'Active' },
+ }),
pack('municipality'),
pack('usage', [
{
@@ -129,7 +141,13 @@ function answer() {
default: null,
},
},
- ]),
+ {
+ source: 'Classificatie',
+ target: 'timeClassification',
+ required: false,
+ transform: { type: 'trim' },
+ },
+ ], { createOnlyFields: ['timeClassification'] }),
pack('businessOwner'),
],
}
@@ -178,6 +196,30 @@ describe('CmdbImportMapping', () => {
expect(usage.text()).toContain('status')
expect(usage.text()).toContain('Lookup')
expect(usage.text()).toContain('In productie → In production')
+ // The write rules the import applies besides the mapping (Review #1288 f2).
+ const writtenOf = (table) =>
+ table
+ .findAll('[data-testid="cmdb-import-mapping-written"]')
+ .map((cell) => cell.text())
+ expect(writtenOf(usage)).toEqual([
+ 'On create and update',
+ 'Only on create, or when empty',
+ ])
+ const rules = (target) =>
+ wrapper
+ .find(`[data-testid="cmdb-import-mapping-${target}"]`)
+ .element.parentElement.querySelectorAll(
+ '[data-testid="cmdb-import-mapping-rules"] li',
+ )
+ expect(Array.from(rules('module'), (li) => li.textContent.trim())).toEqual([
+ 'Set only when the record is created: type = Application',
+ 'Never written on an existing record: publicationDate, depublicationDate',
+ ])
+ expect(Array.from(rules('manufacturer'), (li) => li.textContent.trim())).toEqual([
+ 'Pack defaults: type = Supplier, status = Active',
+ ])
+ expect(rules('usage')).toHaveLength(1)
+ expect(rules('municipality')).toHaveLength(0)
expect(
wrapper.find('[data-testid="cmdb-import-mapping-profile"]').text(),
).toContain('Sheet A, Sheet B')
@@ -233,6 +275,23 @@ describe('cmdbImport mapping helpers', () => {
).toEqual(PROFILE_DEFAULTS.sheets)
})
+ it('words the write rules of a pack, and tolerates an answer without them', () => {
+ expect(packRules({})).toEqual([])
+ expect(packRules({ defaults: [], createOnlyDefaults: [], createOnlyFields: [] })).toEqual([])
+ expect(
+ packRules({
+ createOnlyDefaults: { type: 'Application' },
+ createOnlyFields: ['type', 'note'],
+ }),
+ ).toEqual([
+ 'Set only when the record is created: type = Application',
+ 'Written on create, or on update only when the stored value is empty: note',
+ ])
+ expect(mappingRows({ fieldMappings: [{ source: 'A', target: 'x' }], neverWritten: ['x'] })[0].written).toBe(
+ 'Never on an existing record',
+ )
+ })
+
it('describes every transformation the packs use', () => {
expect(transformLabel(undefined)).toBe('As is')
expect(transformLabel('bool-map')).toBe('Yes/no lookup')
@@ -262,7 +321,7 @@ describe('cmdbImport mapping helpers', () => {
it('builds one row per field mapping and names each target', () => {
const rows = mappingRows(answer().packs[3])
- expect(rows).toHaveLength(1)
+ expect(rows).toHaveLength(2)
expect(rows[0]).toMatchObject({
source: 'Applicatie Status',
target: 'status',
@@ -273,6 +332,8 @@ describe('cmdbImport mapping helpers', () => {
'In productie → In production',
'Any other value: —',
])
+ expect(rows[0].written).toBe('On create and update')
+ expect(rows[1].written).toBe('Only on create, or when empty')
expect(mappingRows({})).toEqual([])
expect(packTargetLabel('businessOwner')).toBe(
'Business owner (contact person)',
From 2e381f7b195f46b9ba58f8176e3034a328349585 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:22:32 +0200
Subject: [PATCH 03/14] fix(cmdb-import): name every sheet the profile reads in
the help texts
mappingSheetNames() fell back to the shipped names unless the profile had at
least two sheets, and the file help and the info panel only read the first
two entries and tied "(applications without arranged maintenance)" to
position 0. A profile with one sheet showed two sheets the import does not
read, one with three lost the third, and a reordered profile mislabelled both.
The profile's list is now used whenever it names a sheet, and both texts are
built from the whole list, with a singular and a plural form and no
position-bound description. The two replaced strings are removed from en and
nl; the vitest pins the 0, 1, 2 and 3 sheet cases.
Review #1289 f1
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 8 +-
l10n/en.json | 8 +-
l10n/nl.js | 8 +-
l10n/nl.json | 8 +-
.../cmdb-import-mapping-view/design.md | 2 +-
.../specs/cmdb-export-import/spec.md | 3 +-
.../changes/cmdb-import-mapping-view/tasks.md | 1 +
src/utils/cmdbImport.js | 78 +++++++++++++++++--
src/views/settings/sections/CmdbImport.vue | 51 ++++++------
tests/vitest/cmdbImportMapping.spec.js | 42 ++++++++--
10 files changed, 159 insertions(+), 50 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 8c8fc1d29..ad4e755b9 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1088,8 +1088,6 @@ OC.L10N.register(
"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.",
- "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.",
"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.",
@@ -1173,7 +1171,11 @@ OC.L10N.register(
"Pack defaults: {values}": "Pack defaults: {values}",
"Set only when the record is created: {values}": "Set only when the record is created: {values}",
"Written on create, or on update only when the stored value is empty: {fields}": "Written on create, or on update only when the stored value is empty: {fields}",
- "Never written on an existing record: {fields}": "Never written on an existing record: {fields}"
+ "Never written on an existing record: {fields}": "Never written on an existing record: {fields}",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 14cf19581..c18324251 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1087,8 +1087,6 @@
"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.",
- "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.",
"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.",
@@ -1172,6 +1170,10 @@
"Pack defaults: {values}": "Pack defaults: {values}",
"Set only when the record is created: {values}": "Set only when the record is created: {values}",
"Written on create, or on update only when the stored value is empty: {fields}": "Written on create, or on update only when the stored value is empty: {fields}",
- "Never written on an existing record: {fields}": "Never written on an existing record: {fields}"
+ "Never written on an existing record: {fields}": "Never written on an existing record: {fields}",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index 7f0a389f7..dbd4d8771 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1156,8 +1156,6 @@ OC.L10N.register(
"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.",
- "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.",
"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.",
@@ -1241,7 +1239,11 @@ OC.L10N.register(
"Pack defaults: {values}": "Standaardwaarden van het pack: {values}",
"Set only when the record is created: {values}": "Alleen ingesteld bij het aanmaken van het record: {values}",
"Written on create, or on update only when the stored value is empty: {fields}": "Geschreven bij aanmaken, of bij bijwerken alleen als de opgeslagen waarde leeg is: {fields}",
- "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}"
+ "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 6d9121ab5..4f89dd5b9 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1155,8 +1155,6 @@
"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.",
- "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.",
"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.",
@@ -1240,6 +1238,10 @@
"Pack defaults: {values}": "Standaardwaarden van het pack: {values}",
"Set only when the record is created: {values}": "Alleen ingesteld bij het aanmaken van het record: {values}",
"Written on create, or on update only when the stored value is empty: {fields}": "Geschreven bij aanmaken, of bij bijwerken alleen als de opgeslagen waarde leeg is: {fields}",
- "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}"
+ "Never written on an existing record: {fields}": "Nooit geschreven op een bestaand record: {fields}",
+ "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
+ "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd."
}
}
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index 225ede564..23b3357b9 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -50,7 +50,7 @@ The write rules the import applies besides the mapping are answered per pack, bu
### D4. The section takes the sheet names from the endpoint, with the constant as fallback
-`CmdbImportMapping.vue` fetches once on creation and emits `loaded` with the answer; `CmdbImport.vue` computes `sheetNames` from it, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails. The constant stays, as the design of cmdb-export-import intended: a fallback, never the source.
+`CmdbImportMapping.vue` fetches once on creation and emits `loaded` with the answer; `CmdbImport.vue` computes `sheetNames` from it, or from `PROFILE_DEFAULTS.sheets` until it arrives or when it fails. The constant stays, as the design of cmdb-export-import intended: a fallback, never the source. The help text and the info text name every sheet the profile lists, whatever their number: they are built from the list (`quotedSheetList`), not from its first two entries, and say nothing that depends on a sheet's position.
### D5. One component, fetched once, collapsed by default
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 7ff22e728..68351e7cc 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -59,7 +59,7 @@ Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\Mi
### 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, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. The section SHALL also show the mapping the import uses in a collapsible block "Mapping (read-only)" (REQ-CMDB-020), and the sheet names in its help text SHALL come from that mapping, with the shipped names as the fallback until the mapping has loaded or when it cannot be loaded. 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, and start the import. While the import runs it SHALL show a progress bar and a Cancel button. Afterwards it SHALL show the summary and a report table that can be filtered by outcome. The section SHALL also show the mapping the import uses in a collapsible block "Mapping (read-only)" (REQ-CMDB-020), and the sheet names in its help text SHALL come from that mapping, every sheet it names and in its order, whatever their number, with the shipped names as the fallback until the mapping has loaded or when it cannot be loaded. Every control SHALL have a visible label, and every string SHALL be translatable.
(Previously: the section had no mapping block, and the sheet names in its help text were a constant in the page, which could drift from the profile on the server.)
@@ -78,6 +78,7 @@ Stackiq's admin settings page SHALL show a section "CMDB import", rendered by th
- **GIVEN** a Nextcloud admin on stackiq's admin settings page
- **WHEN** the mapping endpoint has answered
- **THEN** the help text under the file control SHALL name the sheets the endpoint returned
+- **AND** a profile with one, two or three sheets SHALL give a help text and an info text that name exactly those sheets, with no sheet the profile does not name and no description tied to a sheet's position
- **AND** when the endpoint fails, it SHALL name the shipped sheets and the mapping block SHALL show the error
## Non-Functional Requirements
diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md
index 36fe883c7..4e5d964bc 100644
--- a/openspec/changes/cmdb-import-mapping-view/tasks.md
+++ b/openspec/changes/cmdb-import-mapping-view/tasks.md
@@ -31,6 +31,7 @@ Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.m
- GIVEN the endpoint's answer WHEN the block is expanded THEN one table per pack renders with `data-testid="cmdb-import-mapping-"`, the usage table holding "Applicatie Status" → `status` with the lookup pairs
- GIVEN a 503 WHEN the block loads THEN it shows the error and the code and no table
- GIVEN the answer WHEN the section renders its help text THEN the sheet names are the endpoint's; before and on failure they are `PROFILE_DEFAULTS.sheets`
+ - GIVEN a profile with one, two or three sheets WHEN the section renders its help text and its info text THEN both name exactly those sheets in the profile's order
- [x] Implement
- [x] Test
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 07f494e7d..883d15e56 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -1009,12 +1009,13 @@ export async function loadCmdbMapping({ http }) {
}
/**
- * The names of the source sheets: the profile's when the mapping has loaded,
- * the shipped defaults until then, when it could not be loaded, or when it
- * names fewer than the two sheets the help texts name.
+ * The names of the source sheets: the profile's when the mapping has loaded
+ * and names at least one sheet, the shipped defaults until then or when it
+ * could not be loaded. Every sheet the profile names is returned, in the
+ * profile's order, so the help texts and the block show the same list.
*
* @param {object|null} mapping The mapping endpoint's answer, or null
- * @return {Array} At least the two default names
+ * @return {Array} The sheet names, never empty
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
*/
export function mappingSheetNames(mapping) {
@@ -1024,7 +1025,74 @@ export function mappingSheetNames(mapping) {
typeof sheet === 'string' ? sheet : String(sheet?.name ?? ''),
)
.filter((name) => name !== '')
- return names.length >= 2 ? names : [...PROFILE_DEFAULTS.sheets]
+ return names.length >= 1 ? names : [...PROFILE_DEFAULTS.sheets]
+}
+
+/**
+ * The sheet names as one quoted list: "A", "B", "C".
+ *
+ * @param {Array} names The sheet names
+ * @return {string} The list
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+export function quotedSheetList(names) {
+ return names.map((name) => `"${name}"`).join(', ')
+}
+
+/**
+ * The help text under the file chooser: which sheets the workbook needs and
+ * the default size limit. It names every sheet the profile reads, whatever
+ * their number, and does not depend on their order.
+ *
+ * @param {Array} names The sheet names, from mappingSheetNames()
+ * @param {number} maxFileBytes The default size limit in bytes
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+export function fileHelpText(names, maxFileBytes) {
+ const values = {
+ sheets: quotedSheetList(names),
+ size: formatMegabytes(maxFileBytes),
+ }
+ if (names.length === 1) {
+ return t(
+ 'stackiq',
+ 'Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.',
+ values,
+ AS_TEXT,
+ )
+ }
+ return t(
+ 'stackiq',
+ 'Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.',
+ values,
+ AS_TEXT,
+ )
+}
+
+/**
+ * The sentence of the info panel that says which sheets are read.
+ *
+ * @param {Array} names The sheet names, from mappingSheetNames()
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+export function sheetsReadText(names) {
+ const values = { sheets: quotedSheetList(names) }
+ if (names.length === 1) {
+ return t(
+ 'stackiq',
+ 'The sheet {sheets} is read; other sheets, including the "Invoer" sheets, are ignored.',
+ values,
+ AS_TEXT,
+ )
+ }
+ return t(
+ 'stackiq',
+ 'The sheets {sheets} are read; other sheets, including the "Invoer" sheets, are ignored.',
+ values,
+ AS_TEXT,
+ )
}
/**
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 8b5a6ff62..8fb27fdc5 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -84,18 +84,7 @@
}}
- {{
- t(
- 'stackiq',
- 'Excel workbook (.xlsx) with the sheet "{first}" or "{second}". By default the file may be at most {size}.',
- {
- first: sheetNames[0],
- second: sheetNames[1],
- size: formatMegabytes(profileDefaults.maxFileBytes),
- },
- asText,
- )
- }}
+ {{ fileHelp }}
@@ -380,17 +369,7 @@
{{ t('stackiq', 'The file') }}
- {{
- t(
- 'stackiq',
- 'The sheets "{first}" (applications without arranged maintenance) and "{second}" (with arranged maintenance) are read; other sheets, including the "Invoer" sheets, are ignored.',
- {
- first: sheetNames[0],
- second: sheetNames[1],
- },
- asText,
- )
- }}
+ {{ sheetsReadInfo }}
{{
@@ -449,8 +428,8 @@ import {
checkFile,
cmdbProgressView,
errorText,
+ fileHelpText,
followInterruptedImport,
- formatMegabytes,
importUrl,
interruptedImportError,
isKnownError,
@@ -464,6 +443,7 @@ import {
PROFILE_DEFAULTS,
REPORT_PAGE_SIZE,
reportRows,
+ sheetsReadText,
sortReportRows,
typedMunicipalityOption,
} from '../../../utils/cmdbImport.js'
@@ -539,13 +519,33 @@ export default {
* profile's once the mapping block has loaded it, the shipped names
* until then or when it could not be loaded.
*
- * @return {Array} At least two names
+ * @return {Array} Every sheet the profile names, at least one
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
*/
sheetNames() {
return mappingSheetNames(this.mapping)
},
+ /**
+ * The help text under the file chooser, naming every sheet the import reads.
+ *
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ fileHelp() {
+ return fileHelpText(this.sheetNames, this.profileDefaults.maxFileBytes)
+ },
+
+ /**
+ * The info-panel sentence naming the sheets the import reads.
+ *
+ * @return {string} The text
+ * @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014
+ */
+ sheetsReadInfo() {
+ return sheetsReadText(this.sheetNames)
+ },
+
/**
* Whether everything the import needs has been chosen.
*
@@ -786,7 +786,6 @@ export default {
methods: {
t,
- formatMegabytes,
moduleUrl,
outcomeLabel,
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
index ee8e70f16..e7c21781a 100644
--- a/tests/vitest/cmdbImportMapping.spec.js
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -68,11 +68,13 @@ vi.mock('@conduction/nextcloud-vue', () => ({
}))
const {
+ fileHelpText,
mappingRows,
mappingSheetNames,
packRules,
packTargetLabel,
PROFILE_DEFAULTS,
+ sheetsReadText,
transformDetails,
transformLabel,
} = await import('../../src/utils/cmdbImport.js')
@@ -269,11 +271,41 @@ describe('cmdbImport mapping helpers', () => {
)
})
- it('keeps the shipped sheet names when the answer names only one sheet', () => {
- expect(
- mappingSheetNames({ profile: { sheets: [{ name: 'Sheet A' }] } }),
- ).toEqual(PROFILE_DEFAULTS.sheets)
- })
+ it.each([
+ [0, PROFILE_DEFAULTS.sheets],
+ [1, ['Sheet A']],
+ [2, ['Sheet A', 'Sheet B']],
+ [3, ['Sheet A', 'Sheet B', 'Sheet C']],
+ ])(
+ 'names every sheet of a profile with %i sheets in both help texts, in its order',
+ (count, expected) => {
+ const names = ['Sheet A', 'Sheet B', 'Sheet C'].slice(0, count)
+ const mapping = {
+ profile: { sheets: names.map((name) => ({ name })) },
+ }
+ const sheets = mappingSheetNames(mapping)
+ expect(sheets).toEqual(expected)
+
+ const quoted = expected.map((name) => `"${name}"`).join(', ')
+ const help = fileHelpText(sheets, 10 * 1024 * 1024)
+ expect(help).toContain(quoted)
+ expect(help).toContain('at most 10 MB')
+ expect(help).toContain(
+ expected.length === 1 ? 'with the sheet ' : 'with one of the sheets ',
+ )
+ const info = sheetsReadText(sheets)
+ expect(info).toContain(quoted)
+ expect(info).toContain(expected.length === 1 ? 'is read' : 'are read')
+ // No sheet the profile does not name, and no position-bound description.
+ for (const shipped of PROFILE_DEFAULTS.sheets) {
+ if (!expected.includes(shipped)) {
+ expect(help).not.toContain(shipped)
+ expect(info).not.toContain(shipped)
+ }
+ }
+ expect(info).not.toContain('maintenance')
+ },
+ )
it('words the write rules of a pack, and tolerates an answer without them', () => {
expect(packRules({})).toEqual([])
From ae929478c2e394c07a1c5352025444737ae1d3b5 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:30:34 +0200
Subject: [PATCH 04/14] test(cmdb-import): run the sheet-name wiring and the
mapping error states
CmdbImport.spec.js shallow-mounted the section, so the mapping block was
stubbed and never emitted `loaded`: no executed test failed when
onMappingLoaded, the sheetNames computed or the help-text binding went
missing. The section is now mounted with a wrapper that renders its slots; the
test emits `loaded` from the mapping block and asserts the help text and the
info panel name the emitted sheets, the shipped ones before it, and the one
sheet of a one-sheet profile. The vitest suite gains the mapping block's
errors without a reason (403 with a message, no answer, 500), which only the
503 path covered.
Review #1289 f3
Co-Authored-By: Claude Opus 5.5
---
.../settings/sections/CmdbImport.spec.js | 105 +++++++++++++++++-
tests/vitest/cmdbImportMapping.spec.js | 56 ++++++++++
2 files changed, 159 insertions(+), 2 deletions(-)
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index d0598b407..b20691f00 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -10,7 +10,9 @@
import axios from '@nextcloud/axios'
import { flushPromises, shallowMount } from '@vue/test-utils'
+import { h } from 'vue'
import CmdbImport from './CmdbImport.vue'
+import CmdbImportMapping from './CmdbImportMapping.vue'
// Virtual: the package's `exports` field declares only the `import` condition, which jest does not resolve.
jest.mock(
@@ -97,7 +99,7 @@ const ORGANISATIONS = [
*
* @return {Promise} The wrapper, after the municipalities loaded
*/
-async function mountSection() {
+async function mountSection(options = {}) {
axios.get.mockImplementation((url) =>
Promise.resolve(
url.includes('/voorzieningen/config')
@@ -105,7 +107,7 @@ async function mountSection() {
: { data: { results: ORGANISATIONS } },
),
)
- const wrapper = shallowMount(CmdbImport)
+ const wrapper = shallowMount(CmdbImport, options)
await flushPromises()
return wrapper
}
@@ -153,3 +155,102 @@ describe('CmdbImport municipality chooser', () => {
expect(wrapper.vm.report).toBe(null)
})
})
+
+// shallowMount stubs the section's wrapper without its slots; this one renders them, so the help text and the info panel are in the DOM.
+const SLOT_RENDERING_SECTION = {
+ render() {
+ return h('div', [
+ this.$slots.default?.(),
+ this.$slots['info-content']?.(),
+ ])
+ },
+}
+
+/**
+ * Mount the section with the wrapper rendering its slots.
+ *
+ * @return {Promise} The wrapper
+ */
+function mountWithSlots() {
+ return mountSection({
+ global: { stubs: { AlwaysVisibleSection: SLOT_RENDERING_SECTION } },
+ })
+}
+
+describe('CmdbImport sheet names in the help texts', () => {
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ const SHIPPED = ['Onbeh Applicaties CMDB', 'Beheerde Applicaties CMDB']
+
+ /**
+ * The mapping endpoint's answer for a profile with the given sheets.
+ *
+ * @param {Array} names The sheet names
+ * @return {object} The answer, reduced to what the section reads
+ */
+ function mappingOf(names) {
+ return {
+ profile: { sheets: names.map((name) => ({ name })) },
+ packs: [],
+ }
+ }
+
+ /**
+ * The help text under the file chooser and the info panel's sheet sentence.
+ *
+ * @param {object} wrapper The mounted section
+ * @return {{help: string, info: string}} Both texts, as rendered
+ */
+ function renderedTexts(wrapper) {
+ return {
+ help: wrapper.find('#cmdb-import-file-help').text(),
+ info: wrapper
+ .findAll('.cmdb-import-info li')
+ .map((li) => li.text())
+ .find((text) => /sheets? .* (is|are) read/.test(text)),
+ }
+ }
+
+ it('names the shipped sheets until the mapping block has loaded', async () => {
+ const wrapper = await mountWithSlots()
+
+ const { help, info } = renderedTexts(wrapper)
+ expect(help).toContain('"' + SHIPPED[0] + '", "' + SHIPPED[1] + '"')
+ expect(info).toContain('"' + SHIPPED[0] + '", "' + SHIPPED[1] + '"')
+ })
+
+ it('names the sheets the mapping block emits, in the help text and the info panel', async () => {
+ const wrapper = await mountWithSlots()
+
+ wrapper
+ .findComponent(CmdbImportMapping)
+ .vm.$emit('loaded', mappingOf(['Alpha', 'Beta', 'Gamma']))
+ await flushPromises()
+
+ const { help, info } = renderedTexts(wrapper)
+ expect(help).toContain('"Alpha", "Beta", "Gamma"')
+ expect(info).toContain('"Alpha", "Beta", "Gamma"')
+ for (const shipped of SHIPPED) {
+ expect(help).not.toContain(shipped)
+ expect(info).not.toContain(shipped)
+ }
+ })
+
+ it('names only the one sheet of a profile with one sheet', async () => {
+ const wrapper = await mountWithSlots()
+
+ wrapper
+ .findComponent(CmdbImportMapping)
+ .vm.$emit('loaded', mappingOf(['Alpha']))
+ await flushPromises()
+
+ const { help, info } = renderedTexts(wrapper)
+ expect(help).toContain('with the sheet "Alpha".')
+ expect(info).toContain('The sheet "Alpha" is read')
+ for (const shipped of SHIPPED) {
+ expect(help).not.toContain(shipped)
+ }
+ })
+})
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
index e7c21781a..b12c1b956 100644
--- a/tests/vitest/cmdbImportMapping.spec.js
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -262,6 +262,62 @@ describe('CmdbImportMapping', () => {
})
})
+describe('CmdbImportMapping errors without a reason', () => {
+ beforeEach(() => {
+ http.get.mockReset()
+ })
+
+ /**
+ * Load the block against a failing endpoint and return its error card.
+ *
+ * @param {object} failure The rejection the endpoint produces
+ * @return {Promise} The error card
+ */
+ async function errorCard(failure) {
+ http.get.mockRejectedValue(failure)
+ const wrapper = mount(CmdbImportMapping)
+ await flushPromises()
+ await wrapper
+ .find('[data-testid="cmdb-import-mapping-toggle"]')
+ .trigger('click')
+ expect(wrapper.emitted('loaded')).toBeUndefined()
+ return wrapper.find('[data-testid="cmdb-import-mapping-error"]')
+ }
+
+ it('shows the server message and the code on a 403 without a reason', async () => {
+ const error = await errorCard({
+ response: {
+ status: 403,
+ data: { success: false, error: 'NOT_ADMIN', message: 'Admins only.' },
+ },
+ })
+
+ expect(error.exists()).toBe(true)
+ expect(error.text()).toContain('Admins only.')
+ expect(error.text()).toContain('NOT_ADMIN')
+ })
+
+ it('shows only the code when the request never got an answer', async () => {
+ const error = await errorCard(new Error('Network Error'))
+
+ expect(error.exists()).toBe(true)
+ expect(error.text()).toContain('NETWORK_ERROR')
+ expect(error.findAll('p')).toHaveLength(1)
+ })
+
+ it('shows the code of a 500 whose body carries no reason', async () => {
+ const error = await errorCard({
+ response: {
+ status: 500,
+ data: { success: false, error: 'IMPORT_FAILED', details: {} },
+ },
+ })
+
+ expect(error.text()).toContain('IMPORT_FAILED')
+ expect(error.findAll('p')).toHaveLength(1)
+ })
+})
+
describe('cmdbImport mapping helpers', () => {
it('takes the sheet names from the answer, and the shipped ones without it', () => {
expect(mappingSheetNames(answer())).toEqual(['Sheet A', 'Sheet B'])
From 7db0b2a09f0bf439100467d125f310009f0ae918 Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:30:57 +0200
Subject: [PATCH 05/14] docs(cmdb-import): narrow the mapping block's claim to
what it shows
The user docs said the block "shows what the import does", and that it shows
which file and entry hold a column. It shows the column-to-field mappings and,
since the write-rule rows, when the import writes each field; the sheet
constants, absent columns, sheet precedence, id columns, empty-value
placeholders, missingRecords modes and limits are returned by the endpoint but
not displayed, and a pack's idStrategy is only in its file. The docs now say
that and point to the files that hold them. The same overclaim in the change's
proposal, design and spec purpose is narrowed the same way.
Review #1289 f2
Co-Authored-By: Claude Opus 5.5
---
docs/features/cmdb-import.md | 22 +++++++++++++------
.../cmdb-import-mapping-view/design.md | 2 +-
.../cmdb-import-mapping-view/proposal.md | 2 +-
.../specs/cmdb-export-import/spec.md | 2 +-
4 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md
index d6c0d7468..e6f08ed07 100644
--- a/docs/features/cmdb-import.md
+++ b/docs/features/cmdb-import.md
@@ -356,11 +356,18 @@ The section shows the mapping the next import runs, read-only. Open
type `Application`) and the fields never written on an existing record
(the module's publication and depublication dates).
-The block reads the files through the same loader the import uses, so what
-it shows is what the import does. When a file is invalid, the block shows
-`MAPPING_UNAVAILABLE` with the file and the reason instead of the tables, and
-the import refuses to run with the same code. The sheet names in the
-section's help text come from the same answer.
+The block reads the files through the same loader the import uses, so the
+column-to-field mappings and the write rules it shows are the ones the next
+import applies. It does not show everything in the files: the constant each
+sheet adds to its rows (`Beheer`) and the columns a sheet is known to lack,
+the order in which sheets win a duplicate `APPID`, the id columns, the
+placeholder values that mean empty (such as `NB` in `BNN Classificatie`), the
+`missingRecords` modes and the limits are in the files and in the
+endpoint's answer, and a pack's `idStrategy` only in the file; none of them
+is in the block. When a file is
+invalid, the block shows `MAPPING_UNAVAILABLE` with the file and the reason
+instead of the tables, and the import refuses to run with the same code. The
+sheet names in the section's help text come from the same answer.
The block is only for viewing: nothing in it changes the mapping. The data
behind it is also available to Nextcloud administrators as
@@ -370,8 +377,9 @@ behind it is also available to Nextcloud administrators as
The mapping from columns to fields is not in code. It is a set of JSON files
in `lib/Settings/cmdb-import/`, executed by OpenRegister's mapping engine.
-It cannot be changed in the section; **Mapping (read-only)** shows which
-file and which entry hold a column, so you know what to change:
+It cannot be changed in the section; **Mapping (read-only)** shows the pack
+file and the column mapping that hold a column, and the files below hold the
+settings the block does not show, so you know what to change:
| File | What it maps |
|---|---|
diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md
index 23b3357b9..6b3a29e96 100644
--- a/openspec/changes/cmdb-import-mapping-view/design.md
+++ b/openspec/changes/cmdb-import-mapping-view/design.md
@@ -8,7 +8,7 @@ The CMDB import (archived change `2026-10-05-cmdb-export-import`) maps a TOPdesk
**Goals**
-- An administrator sees, in the section, exactly the mapping the next import runs.
+- An administrator sees, in the section, the column mapping and the write rules the next import applies, read from the files the import loads. The profile's other settings (sheet constants, absent columns, sheet precedence, id columns, empty-value placeholders, missing-records modes, limits) are returned by the endpoint but not shown; the user documentation says so.
- The section's sheet names cannot drift from the profile.
- Nothing is written; nothing new is parsed.
diff --git a/openspec/changes/cmdb-import-mapping-view/proposal.md b/openspec/changes/cmdb-import-mapping-view/proposal.md
index e651febe6..b537011dd 100644
--- a/openspec/changes/cmdb-import-mapping-view/proposal.md
+++ b/openspec/changes/cmdb-import-mapping-view/proposal.md
@@ -7,7 +7,7 @@ depends_on: []
## Summary
-The "CMDB import" section of stackiq's admin settings gets a collapsible, read-only block "Mapping (read-only)" that shows the column mapping the import really uses: the sheets, the key and required columns, and one table per migration pack with the source column, the target field, whether it is required, and the transformation with its lookup values. A new admin endpoint `GET /api/settings/cmdb-import/mapping` loads the import profile and the five packs through the same loader and validator the import uses, so what the administrator sees is what the next import runs. Editing stays in the JSON files on the server; the documentation says where they are and what an administrator can change.
+The "CMDB import" section of stackiq's admin settings gets a collapsible, read-only block "Mapping (read-only)" that shows the column mapping the import really uses: the sheets, the key and required columns, and one table per migration pack with the source column, the target field, whether it is required, the transformation with its lookup values and when the import writes the field, plus each pack's defaults, create-only values and never-written fields. A new admin endpoint `GET /api/settings/cmdb-import/mapping` loads the import profile and the five packs through the same loader and validator the import uses, so the column mapping and write rules the administrator sees are the ones the next import applies (the profile's sheet constants, empty-value placeholders and similar settings are in the answer but not in the block). Editing stays in the JSON files on the server; the documentation says where they are and what an administrator can change.
## Motivation
diff --git a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
index 68351e7cc..174b1ce02 100644
--- a/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
+++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md
@@ -8,7 +8,7 @@
## Purpose
-The CMDB import maps the columns of a TOPdesk export to stackiq fields through an import profile and five migration packs, JSON files under `lib/Settings/cmdb-import/` that OpenRegister's mapping engine executes (REQ-CMDB-005). Until now an administrator could only learn that mapping by reading the JSON on the server. This delta adds a read-only view of it to the "CMDB import" section of the admin settings, served by an endpoint that loads the files through the same loader the import uses, so what the administrator sees is what the next import runs (Jira WOO-588, sub-task of WOO-586).
+The CMDB import maps the columns of a TOPdesk export to stackiq fields through an import profile and five migration packs, JSON files under `lib/Settings/cmdb-import/` that OpenRegister's mapping engine executes (REQ-CMDB-005). Until now an administrator could only learn that mapping by reading the JSON on the server. This delta adds a read-only view of it to the "CMDB import" section of the admin settings, served by an endpoint that loads the files through the same loader the import uses, so the column mapping and the write rules the administrator sees are the ones the next import applies; the profile's other settings (sheet constants, absent columns, sheet precedence, id columns, empty-value placeholders, missing-records modes) are in the answer but not in the block (Jira WOO-588, sub-task of WOO-586).
Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\MigrationPack\PackDefinitionValidator` (through the existing `CmdbImportProfile`, guarded).
From e7f9125cf467430f43b98adcb5f9e16efbdfc94e Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:46:22 +0200
Subject: [PATCH 06/14] test(cmdb-import): fixture follows the overview's
createOnlyFields
The endpoint no longer lists the module's create-only default `type` under
createOnlyFields; the vitest answer fixture mirrors that, and the block still
shows `type = Application` as set on create only.
Review pre-push p6
Co-Authored-By: Claude Opus 5.5
---
tests/vitest/cmdbImportMapping.spec.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/vitest/cmdbImportMapping.spec.js b/tests/vitest/cmdbImportMapping.spec.js
index b12c1b956..f5ddf9062 100644
--- a/tests/vitest/cmdbImportMapping.spec.js
+++ b/tests/vitest/cmdbImportMapping.spec.js
@@ -125,7 +125,7 @@ function answer() {
},
], {
createOnlyDefaults: { type: 'Application' },
- createOnlyFields: ['type'],
+ createOnlyFields: [],
neverWritten: ['publicationDate', 'depublicationDate'],
}),
pack('manufacturer', [], {
From f04bf8233df4ccc62cf76308f8f590ec22d0dc4c Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 17:47:46 +0200
Subject: [PATCH 07/14] fix(cmdb-import): name every sheet in the missing-sheet
and too-large hints
The NO_SOURCE_SHEET hint read only the first two expected sheets (or the
shipped names) and the WORKBOOK_TOO_LARGE hint always named the shipped two,
so a one- or three-sheet profile was misreported there too, unlike the help
texts. errorText() now takes the profile's sheet names, the section passes the
ones from the mapping block, and both hints list all of them; the server's
`expected` still wins for NO_SOURCE_SHEET. The three replaced strings are
removed from en and nl, with a singular and a plural form for the missing-sheet
hint. The jest cases pin a one- and a three-sheet profile, in the helper and
through the section.
Review pre-push p7
Co-Authored-By: Claude Opus 5.5
---
l10n/en.js | 7 +--
l10n/en.json | 7 +--
l10n/nl.js | 7 +--
l10n/nl.json | 7 +--
src/utils/cmdbImport.js | 43 +++++++++++--------
src/utils/cmdbImport.spec.js | 31 +++++++++++--
.../settings/sections/CmdbImport.spec.js | 19 ++++++++
src/views/settings/sections/CmdbImport.vue | 2 +-
8 files changed, 88 insertions(+), 35 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index ad4e755b9..f4d72a00c 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -974,7 +974,6 @@ OC.L10N.register(
"Error code: {code}": "Error code: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.",
"Failed": "Failed",
"Import": "Import",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses",
@@ -1126,7 +1125,6 @@ OC.L10N.register(
"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.",
"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.",
"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",
@@ -1175,7 +1173,10 @@ OC.L10N.register(
"Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
"Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
"The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
- "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored."
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Expected the sheet {sheets}. Sheet names must match exactly.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Expected one of the sheets {sheets}. Sheet names must match exactly.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index c18324251..5ffcb140a 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -973,7 +973,6 @@
"Error code: {code}": "Error code: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Existing municipalities could not be loaded. You can still type the name of a municipality.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.",
"Failed": "Failed",
"Import": "Import",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses",
@@ -1125,7 +1124,6 @@
"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.",
"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.",
"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",
@@ -1174,6 +1172,9 @@
"Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.",
"Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.",
"The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.",
- "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored."
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Expected the sheet {sheets}. Sheet names must match exactly.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Expected one of the sheets {sheets}. Sheet names must match exactly.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index dbd4d8771..460350e2c 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1042,7 +1042,6 @@ OC.L10N.register(
"Error code: {code}": "Foutcode: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.",
"Failed": "Mislukt",
"Import": "Importeren",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt",
@@ -1194,7 +1193,6 @@ OC.L10N.register(
"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.",
"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.",
"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",
@@ -1243,7 +1241,10 @@ OC.L10N.register(
"Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
"Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
"The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
- "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd."
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Verwacht werd het tabblad {sheets}. De naam van het tabblad moet precies overeenkomen.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Verwacht werd een van de tabbladen {sheets}. De namen van de tabbladen moeten precies overeenkomen.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen {sheets}), en importeer die kopie."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 4f89dd5b9..fdbee7ae0 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1041,7 +1041,6 @@
"Error code: {code}": "Foutcode: {code}",
"Excel workbook (.xlsx), at most 10 MB, with the sheet \"Onbeh Applicaties CMDB\" or \"Beheerde Applicaties CMDB\".": "Excel-werkmap (.xlsx), hoogstens 10 MB, met het tabblad \"Onbeh Applicaties CMDB\" of \"Beheerde Applicaties CMDB\".",
"Existing municipalities could not be loaded. You can still type the name of a municipality.": "Bestaande gemeenten konden niet worden geladen. U kunt nog steeds de naam van een gemeente typen.",
- "Expected a sheet named \"{first}\" or \"{second}\". Sheet names must match exactly.": "Verwacht werd een tabblad met de naam \"{first}\" of \"{second}\". De naam van het tabblad moet precies overeenkomen.",
"Failed": "Mislukt",
"Import": "Importeren",
"Import a TOPdesk CMDB export (.xlsx) as the applications one municipality uses": "Importeer een TOPdesk CMDB-export (.xlsx) als de applicaties die één gemeente gebruikt",
@@ -1193,7 +1192,6 @@
"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.",
"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.",
"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",
@@ -1242,6 +1240,9 @@
"Excel workbook (.xlsx) with the sheet {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met het tabblad {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
"Excel workbook (.xlsx) with one of the sheets {sheets}. By default the file may be at most {size}.": "Excel-werkmap (.xlsx) met een van de tabbladen {sheets}. Standaard mag het bestand hoogstens {size} zijn.",
"The sheet {sheets} is read; other sheets, including the \"Invoer\" sheets, are ignored.": "Het tabblad {sheets} wordt gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
- "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd."
+ "The sheets {sheets} are read; other sheets, including the \"Invoer\" sheets, are ignored.": "De tabbladen {sheets} worden gelezen; andere tabbladen, ook de \"Invoer\"-tabbladen, worden genegeerd.",
+ "Expected the sheet {sheets}. Sheet names must match exactly.": "Verwacht werd het tabblad {sheets}. De naam van het tabblad moet precies overeenkomen.",
+ "Expected one of the sheets {sheets}. Sheet names must match exactly.": "Verwacht werd een van de tabbladen {sheets}. De namen van de tabbladen moeten precies overeenkomen.",
+ "Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.": "Verwijder in een kopie van de export de tabbladen die de import niet leest (hij leest alleen {sheets}), en importeer die kopie."
}
}
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 883d15e56..379eae833 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -490,10 +490,11 @@ export function isKnownError(code) {
* to which sheet or part, and what to do about it.
*
* @param {object} details The error details: `maxPartBytes`, `part`, `size` and, for a sheet, `sheet`; `maxSharedStrings`; `maxReferencedStringBytes`; or `maxUncompressedBytes`
+ * @param {Array} sheetNames The sheets the import reads, for the hint
* @return {{title: string, hint: string}} The words
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-the-workbook-shall-be-read-as-stored-data-without-evaluating-formulas-or-following-links-req-cmdb-002
*/
-function workbookTooLarge(details) {
+function workbookTooLarge(details, sheetNames) {
const compressed = t(
'stackiq',
'An .xlsx file is compressed, so a file of a few MB can be much larger once unpacked. Nothing was imported.',
@@ -532,11 +533,8 @@ 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.',
- {
- first: PROFILE_DEFAULTS.sheets[0],
- second: PROFILE_DEFAULTS.sheets[1],
- },
+ 'Delete the sheets the import does not read from a copy of the export (it reads only {sheets}), and import that copy.',
+ { sheets: quotedSheetList(sheetNames) },
AS_TEXT,
)
if (Number(details.maxSharedStrings) > 0) {
@@ -584,10 +582,11 @@ function workbookTooLarge(details) {
* when the server's message is not.
*
* @param {{error: string, message?: string, details?: object}} error The error in the server's shape
+ * @param {Array} [sheetNames] The sheets the import reads, from mappingSheetNames(); the shipped names when left out. Hints name all of them, whatever their number
* @return {{title: string, hint: string}} The words
* @spec openspec/changes/cmdb-export-import/specs/cmdb-export-import/spec.md#requirement-columns-shall-be-resolved-by-header-name-and-a-missing-required-column-shall-stop-the-import-with-422-req-cmdb-003
*/
-export function errorText(error) {
+export function errorText(error, sheetNames = PROFILE_DEFAULTS.sheets) {
const details = error?.details || {}
switch (error?.error) {
case 'NO_FILE_UPLOADED':
@@ -682,22 +681,28 @@ export function errorText(error) {
case 'NO_SOURCE_SHEET': {
const expected =
Array.isArray(details.expected) && details.expected.length > 0
- ? details.expected
- : PROFILE_DEFAULTS.sheets
+ ? details.expected.map((name) => String(name))
+ : sheetNames
+ const values = { sheets: quotedSheetList(expected) }
return {
title: t(
'stackiq',
'The workbook has none of the sheets the import reads.',
),
- hint: t(
- 'stackiq',
- 'Expected a sheet named "{first}" or "{second}". Sheet names must match exactly.',
- {
- first: String(expected[0] ?? PROFILE_DEFAULTS.sheets[0]),
- second: String(expected[1] ?? PROFILE_DEFAULTS.sheets[1]),
- },
- AS_TEXT,
- ),
+ hint:
+ expected.length === 1
+ ? t(
+ 'stackiq',
+ 'Expected the sheet {sheets}. Sheet names must match exactly.',
+ values,
+ AS_TEXT,
+ )
+ : t(
+ 'stackiq',
+ 'Expected one of the sheets {sheets}. Sheet names must match exactly.',
+ values,
+ AS_TEXT,
+ ),
}
}
case 'MISSING_COLUMN':
@@ -767,7 +772,7 @@ export function errorText(error) {
),
}
case 'WORKBOOK_TOO_LARGE':
- return workbookTooLarge(details)
+ return workbookTooLarge(details, sheetNames)
case 'SCHEMA_OUTDATED':
return {
title: details.schema
diff --git a/src/utils/cmdbImport.spec.js b/src/utils/cmdbImport.spec.js
index 8f566d6cb..2e4ac7942 100644
--- a/src/utils/cmdbImport.spec.js
+++ b/src/utils/cmdbImport.spec.js
@@ -363,12 +363,37 @@ describe('errorText', () => {
error: 'NO_SOURCE_SHEET',
details: { expected: ['Blad A', 'Blad B'] },
}).hint,
- ).toContain('"Blad A" or "Blad B"')
+ ).toContain('one of the sheets "Blad A", "Blad B"')
expect(errorText({ error: 'NO_SOURCE_SHEET', details: {} }).hint).toContain(
- PROFILE_DEFAULTS.sheets[1],
+ '"' + PROFILE_DEFAULTS.sheets.join('", "') + '"',
)
})
+ it.each([
+ [1, ['Blad A'], 'Expected the sheet "Blad A".'],
+ [3, ['Blad A', 'Blad B', 'Blad C'], 'one of the sheets "Blad A", "Blad B", "Blad C".'],
+ ])(
+ 'names all %i sheets of the profile in the missing-sheet and too-large hints',
+ (count, names, expected) => {
+ const quoted = names.map((name) => '"' + name + '"').join(', ')
+
+ const missing = errorText({ error: 'NO_SOURCE_SHEET', details: {} }, names)
+ expect(missing.hint).toContain(expected)
+ for (const shipped of PROFILE_DEFAULTS.sheets) {
+ expect(missing.hint).not.toContain(shipped)
+ }
+
+ const tooLarge = errorText(
+ { error: 'WORKBOOK_TOO_LARGE', details: { maxSharedStrings: 200000 } },
+ names,
+ )
+ expect(tooLarge.hint).toContain('(it reads only ' + quoted + ')')
+ for (const shipped of PROFILE_DEFAULTS.sheets) {
+ expect(tooLarge.hint).not.toContain(shipped)
+ }
+ },
+ )
+
it('names the unpacked size limit the server applied', () => {
expect(
errorText({
@@ -408,7 +433,7 @@ describe('errorText', () => {
expect(
errorText({ error: 'WORKBOOK_TOO_LARGE', details }).hint,
).toContain(
- '(it reads only "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB")',
+ '(it reads only "Onbeh Applicaties CMDB", "Beheerde Applicaties CMDB")',
)
}
})
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index b20691f00..dbe41aa47 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -253,4 +253,23 @@ describe('CmdbImport sheet names in the help texts', () => {
expect(help).not.toContain(shipped)
}
})
+
+ it('names the profile\'s sheets in the section\'s missing-sheet and too-large errors', async () => {
+ const wrapper = await mountWithSlots()
+ wrapper
+ .findComponent(CmdbImportMapping)
+ .vm.$emit('loaded', mappingOf(['Alpha', 'Beta', 'Gamma']))
+ await flushPromises()
+
+ await wrapper.setData({ error: { error: 'NO_SOURCE_SHEET', details: {} } })
+ expect(wrapper.vm.errorView.hint).toContain('"Alpha", "Beta", "Gamma"')
+
+ await wrapper.setData({
+ error: {
+ error: 'WORKBOOK_TOO_LARGE',
+ details: { maxUncompressedBytes: 50 * 1024 * 1024 },
+ },
+ })
+ expect(wrapper.vm.errorView.hint).toContain('(it reads only "Alpha", "Beta", "Gamma")')
+ })
})
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 8fb27fdc5..d2dc4b0a4 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -582,7 +582,7 @@ export default {
if (!this.error) {
return null
}
- const words = errorText(this.error)
+ const words = errorText(this.error, this.sheetNames)
return {
...words,
code: this.error.error,
From 9450c8497177ea285758d1c60fada2a1aa9b756c Mon Sep 17 00:00:00 2001
From: WilcoLouwerse
Date: Mon, 12 Oct 2026 18:52:16 +0200
Subject: [PATCH 08/14] feat(cmdb-import): show where each import pack comes
from and link to its editor in OpenRegister
The Mapping block of the CMDB import section no longer lists each pack's
columns. Per pack it now shows one line: what the pack's rows become,
whether the import takes it from OpenRegister's pack store or from the
shipped file, the stored and shipped versions, an Edited marker, a hint
when a newer shipped version was not applied because the pack was edited,
and an Edit in OpenRegister link (http(s) or server-relative addresses
only). The municipality pack says it maps the import options and is not
edited there. When OpenRegister's pack store is not available, the block
says the shipped mapping is used.
The transform helpers the tables used are removed, the unused strings
are dropped from l10n, and the vitest and jest specs cover the new view.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
l10n/en.js | 35 +-
l10n/en.json | 35 +-
l10n/nl.js | 35 +-
l10n/nl.json | 35 +-
src/utils/cmdbImport.js | 169 ++------
.../settings/sections/CmdbImport.spec.js | 3 +
src/views/settings/sections/CmdbImport.vue | 2 +-
.../settings/sections/CmdbImportMapping.vue | 229 ++++++-----
tests/vitest/cmdbImportMapping.spec.js | 366 +++++++++++-------
9 files changed, 469 insertions(+), 440 deletions(-)
diff --git a/l10n/en.js b/l10n/en.js
index 60a730e51..571939b25 100644
--- a/l10n/en.js
+++ b/l10n/en.js
@@ -1137,35 +1137,28 @@ OC.L10N.register(
"Supplier organisation (Vendor)": "Supplier organisation (Vendor)",
"Municipality (from the import options)": "Municipality (from the import options)",
"Business owner (contact person)": "Business owner (contact person)",
- "As is": "As is",
- "Trim": "Trim",
- "Date": "Date",
- "Lookup": "Lookup",
- "Yes/no lookup": "Yes/no lookup",
- "Join": "Join",
- "Constant": "Constant",
- "Any other value: {value}": "Any other value: {value}",
- "Joined with the columns {columns}, separated by \"{separator}\"": "Joined with the columns {columns}, separated by \"{separator}\"",
- "Value: {value}": "Value: {value}",
- "Read as {source}, stored as {target}": "Read as {source}, stored as {target}",
- "Mapping (read-only)": "Mapping (read-only)",
- "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.",
"Loading the mapping…": "Loading the mapping…",
"The mapping could not be loaded.": "The mapping could not be loaded.",
- "{file}: {name}, version {version}": "{file}: {name}, version {version}",
- "This pack maps no columns": "This pack maps no columns",
- "Yes": "Yes",
- "No": "No",
"Sheets read": "Sheets read",
"Match column": "Match column",
"Name column": "Name column",
"Required columns": "Required columns",
"Date columns": "Date columns",
- "Column in the export": "Column in the export",
- "Field": "Field",
- "Transformation": "Transformation",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Mapping": "Mapping",
+ "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.": "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.",
+ "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.": "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Shipped file",
+ "Version {version}": "Version {version}",
+ "Version {version} (shipped: {shipped})": "Version {version} (shipped: {shipped})",
+ "Edited": "Edited",
+ "Edit in OpenRegister": "Edit in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Edit in OpenRegister: {target} (opens in a new tab)",
+ "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.": "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/en.json b/l10n/en.json
index 5e5a32434..9e3614a50 100644
--- a/l10n/en.json
+++ b/l10n/en.json
@@ -1136,34 +1136,27 @@
"Supplier organisation (Vendor)": "Supplier organisation (Vendor)",
"Municipality (from the import options)": "Municipality (from the import options)",
"Business owner (contact person)": "Business owner (contact person)",
- "As is": "As is",
- "Trim": "Trim",
- "Date": "Date",
- "Lookup": "Lookup",
- "Yes/no lookup": "Yes/no lookup",
- "Join": "Join",
- "Constant": "Constant",
- "Any other value: {value}": "Any other value: {value}",
- "Joined with the columns {columns}, separated by \"{separator}\"": "Joined with the columns {columns}, separated by \"{separator}\"",
- "Value: {value}": "Value: {value}",
- "Read as {source}, stored as {target}": "Read as {source}, stored as {target}",
- "Mapping (read-only)": "Mapping (read-only)",
- "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.",
"Loading the mapping…": "Loading the mapping…",
"The mapping could not be loaded.": "The mapping could not be loaded.",
- "{file}: {name}, version {version}": "{file}: {name}, version {version}",
- "This pack maps no columns": "This pack maps no columns",
- "Yes": "Yes",
- "No": "No",
"Sheets read": "Sheets read",
"Match column": "Match column",
"Name column": "Name column",
"Required columns": "Required columns",
"Date columns": "Date columns",
- "Column in the export": "Column in the export",
- "Field": "Field",
- "Transformation": "Transformation",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "The import mapping could not be read. The details are in the Nextcloud log.",
+ "Mapping": "Mapping",
+ "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.": "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.",
+ "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.": "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Shipped file",
+ "Version {version}": "Version {version}",
+ "Version {version} (shipped: {shipped})": "Version {version} (shipped: {shipped})",
+ "Edited": "Edited",
+ "Edit in OpenRegister": "Edit in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Edit in OpenRegister: {target} (opens in a new tab)",
+ "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.": "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost."
}
}
diff --git a/l10n/nl.js b/l10n/nl.js
index e51e4af38..e3cb1682f 100644
--- a/l10n/nl.js
+++ b/l10n/nl.js
@@ -1205,35 +1205,28 @@ OC.L10N.register(
"Supplier organisation (Vendor)": "Leveranciersorganisatie (Vendor)",
"Municipality (from the import options)": "Gemeente (uit de importopties)",
"Business owner (contact person)": "Business owner (contactpersoon)",
- "As is": "Ongewijzigd",
- "Trim": "Spaties weghalen",
- "Date": "Datum",
- "Lookup": "Opzoektabel",
- "Yes/no lookup": "Ja/nee-opzoektabel",
- "Join": "Samenvoegen",
- "Constant": "Vaste waarde",
- "Any other value: {value}": "Elke andere waarde: {value}",
- "Joined with the columns {columns}, separated by \"{separator}\"": "Samengevoegd met de kolommen {columns}, gescheiden door \"{separator}\"",
- "Value: {value}": "Waarde: {value}",
- "Read as {source}, stored as {target}": "Gelezen als {source}, opgeslagen als {target}",
- "Mapping (read-only)": "Mapping (alleen-lezen)",
- "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per pack, welke kolom van de export naar welk veld gaat. Hij wordt gelezen uit de bestanden onder lib/Settings/cmdb-import van de app op de server; een bestand dat daar is aangepast wordt bij de volgende app-update overschreven.",
"Loading the mapping…": "Mapping laden…",
"The mapping could not be loaded.": "De mapping kon niet worden geladen.",
- "{file}: {name}, version {version}": "{file}: {name}, versie {version}",
- "This pack maps no columns": "Dit pack koppelt geen kolommen",
- "Yes": "Ja",
- "No": "Nee",
"Sheets read": "Gelezen tabbladen",
"Match column": "Koppelkolom",
"Name column": "Naamkolom",
"Required columns": "Verplichte kolommen",
"Date columns": "Datumkolommen",
- "Column in the export": "Kolom in de export",
- "Field": "Veld",
- "Transformation": "Transformatie",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Mapping": "Mapping",
+ "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per soort gegeven, het pack dat bepaalt welke kolom van de export naar welk veld gaat.",
+ "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.": "De packs staan in OpenRegister, waar ze kunnen worden bekeken en bewerkt. Een pack dat daar is verwijderd, wordt bij de volgende import hersteld uit het meegeleverde bestand.",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "De packopslag van OpenRegister is niet beschikbaar, dus de import gebruikt de mapping die met stackiq wordt meegeleverd. Werk OpenRegister bij of schakel het in om de mapping daar te bekijken en te bewerken.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Meegeleverd bestand",
+ "Version {version}": "Versie {version}",
+ "Version {version} (shipped: {shipped})": "Versie {version} (meegeleverd: {shipped})",
+ "Edited": "Bewerkt",
+ "Edit in OpenRegister": "Bewerken in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Bewerken in OpenRegister: {target} (opent in een nieuw tabblad)",
+ "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.": "Dit pack koppelt de importopties, geen kolom van de export, en wordt daarom niet in OpenRegister bewerkt.",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq levert een nieuwere versie van dit pack mee ({version}). Die is niet toegepast, omdat het pack in OpenRegister is bewerkt. Verwijder het pack daar om vanaf de volgende import de meegeleverde versie te gebruiken; de wijzigingen gaan dan verloren."
},
"nplurals=2; plural=(n != 1);"
)
diff --git a/l10n/nl.json b/l10n/nl.json
index 6ab4bbf98..58b39d6d4 100644
--- a/l10n/nl.json
+++ b/l10n/nl.json
@@ -1204,34 +1204,27 @@
"Supplier organisation (Vendor)": "Leveranciersorganisatie (Vendor)",
"Municipality (from the import options)": "Gemeente (uit de importopties)",
"Business owner (contact person)": "Business owner (contactpersoon)",
- "As is": "Ongewijzigd",
- "Trim": "Spaties weghalen",
- "Date": "Datum",
- "Lookup": "Opzoektabel",
- "Yes/no lookup": "Ja/nee-opzoektabel",
- "Join": "Samenvoegen",
- "Constant": "Vaste waarde",
- "Any other value: {value}": "Elke andere waarde: {value}",
- "Joined with the columns {columns}, separated by \"{separator}\"": "Samengevoegd met de kolommen {columns}, gescheiden door \"{separator}\"",
- "Value: {value}": "Waarde: {value}",
- "Read as {source}, stored as {target}": "Gelezen als {source}, opgeslagen als {target}",
- "Mapping (read-only)": "Mapping (alleen-lezen)",
- "The mapping the next import runs: the sheets it reads and, per pack, which column of the export goes to which field. It is read from the files under lib/Settings/cmdb-import of the app on the server; a file changed there is overwritten by the next app update.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per pack, welke kolom van de export naar welk veld gaat. Hij wordt gelezen uit de bestanden onder lib/Settings/cmdb-import van de app op de server; een bestand dat daar is aangepast wordt bij de volgende app-update overschreven.",
"Loading the mapping…": "Mapping laden…",
"The mapping could not be loaded.": "De mapping kon niet worden geladen.",
- "{file}: {name}, version {version}": "{file}: {name}, versie {version}",
- "This pack maps no columns": "Dit pack koppelt geen kolommen",
- "Yes": "Ja",
- "No": "Nee",
"Sheets read": "Gelezen tabbladen",
"Match column": "Koppelkolom",
"Name column": "Naamkolom",
"Required columns": "Verplichte kolommen",
"Date columns": "Datumkolommen",
- "Column in the export": "Kolom in de export",
- "Field": "Veld",
- "Transformation": "Transformatie",
"The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.": "De mapping van de import kan niet worden getoond: OpenRegister ontbreekt of een mappingbestand is ongeldig.",
- "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek."
+ "The import mapping could not be read. The details are in the Nextcloud log.": "De mapping van de import kon niet worden gelezen. De details staan in het Nextcloud-logboek.",
+ "Mapping": "Mapping",
+ "The mapping the next import runs: the sheets it reads and, for each kind of record, the pack that says which column of the export goes to which field.": "De mapping die de volgende import uitvoert: de tabbladen die hij leest en, per soort gegeven, het pack dat bepaalt welke kolom van de export naar welk veld gaat.",
+ "The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.": "De packs staan in OpenRegister, waar ze kunnen worden bekeken en bewerkt. Een pack dat daar is verwijderd, wordt bij de volgende import hersteld uit het meegeleverde bestand.",
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.": "De packopslag van OpenRegister is niet beschikbaar, dus de import gebruikt de mapping die met stackiq wordt meegeleverd. Werk OpenRegister bij of schakel het in om de mapping daar te bekijken en te bewerken.",
+ "OpenRegister": "OpenRegister",
+ "Shipped file": "Meegeleverd bestand",
+ "Version {version}": "Versie {version}",
+ "Version {version} (shipped: {shipped})": "Versie {version} (meegeleverd: {shipped})",
+ "Edited": "Bewerkt",
+ "Edit in OpenRegister": "Bewerken in OpenRegister",
+ "Edit in OpenRegister: {target} (opens in a new tab)": "Bewerken in OpenRegister: {target} (opent in een nieuw tabblad)",
+ "This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.": "Dit pack koppelt de importopties, geen kolom van de export, en wordt daarom niet in OpenRegister bewerkt.",
+ "Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.": "Stackiq levert een nieuwere versie van dit pack mee ({version}). Die is niet toegepast, omdat het pack in OpenRegister is bewerkt. Verwijder het pack daar om vanaf de volgende import de meegeleverde versie te gebruiken; de wijzigingen gaan dan verloren."
}
}
diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js
index 4646c5a53..228d5a1fe 100644
--- a/src/utils/cmdbImport.js
+++ b/src/utils/cmdbImport.js
@@ -1052,157 +1052,66 @@ export function packTargetLabel(target) {
}
/**
- * The words for a transformation type of OpenRegister's mapping engine.
+ * The words for where the import takes a pack from: OpenRegister's pack
+ * store, or the file shipped with stackiq.
*
- * @param {string|null|undefined} type The transform type, or nothing when the value is copied as is
- * @return {string} The translated name, or the type itself when it is unknown
+ * @param {string} source The pack's `source` from the endpoint (`openregister` or `file`)
+ * @return {string} The translated name, or the source itself when it is unknown
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
*/
-export function transformLabel(type) {
- switch (type) {
- case undefined:
- case null:
- case '':
- return t('stackiq', 'As is')
- case 'trim':
- return t('stackiq', 'Trim')
- case 'date':
- return t('stackiq', 'Date')
- case 'lookup':
- return t('stackiq', 'Lookup')
- case 'bool-map':
- return t('stackiq', 'Yes/no lookup')
- case 'concat':
- return t('stackiq', 'Join')
- case 'const':
- return t('stackiq', 'Constant')
+export function packSourceLabel(source) {
+ switch (source) {
+ case 'openregister':
+ return t('stackiq', 'OpenRegister')
+ case 'file':
+ return t('stackiq', 'Shipped file')
default:
- return String(type)
+ return String(source ?? '')
}
}
/**
- * One text per value, for the details column: a JSON array is shown as its
- * members, a null as a dash, anything else as a string.
+ * The version of a pack as the import runs it: the stored version for a
+ * pack from OpenRegister, with the shipped version next to it when the two
+ * differ, and the shipped version for a pack read from the file.
*
- * @param {unknown} value The stored value
- * @return {string} The text
- */
-function valueText(value) {
- if (value === null || value === undefined) {
- return '—'
- }
- if (Array.isArray(value)) {
- return value.map((member) => valueText(member)).join(', ')
- }
- if (typeof value === 'object') {
- return JSON.stringify(value)
- }
- return String(value)
-}
-
-/**
- * The details of a transformation, as lines: the pairs of a lookup, the
- * extra columns of a join, the value of a constant, the formats of a date.
- * A key the page does not know is listed as it is, so nothing the engine
- * reads is hidden.
- *
- * @param {object|null} transform The transform as the pack stores it
- * @return {Array} The lines, empty for a plain trim
+ * @param {object} pack One entry of the endpoint's `packs`
+ * @return {string} The text, empty when the pack names no version
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
*/
-export function transformDetails(transform) {
- if (!transform || typeof transform !== 'object') {
- return []
- }
- const lines = []
- const known = new Set(['type'])
- if (transform.map && typeof transform.map === 'object') {
- known.add('map')
- for (const [from, to] of Object.entries(transform.map)) {
- lines.push(`${from} → ${valueText(to)}`)
- }
- }
- if (Object.hasOwn(transform, 'default')) {
- known.add('default')
- lines.push(
- t(
- 'stackiq',
- 'Any other value: {value}',
- { value: valueText(transform.default) },
- AS_TEXT,
- ),
- )
- }
- if (Array.isArray(transform.fields)) {
- known.add('fields')
- known.add('separator')
- lines.push(
- t(
+export function packVersionText(pack) {
+ const shipped = String(pack?.shippedVersion ?? pack?.version ?? '')
+ const stored = String(pack?.storedVersion ?? '')
+ if (pack?.source === 'openregister' && stored !== '') {
+ if (shipped !== '' && shipped !== stored) {
+ return t(
'stackiq',
- 'Joined with the columns {columns}, separated by "{separator}"',
- {
- columns: transform.fields
- .map((field) => String(field))
- .join(', '),
- separator: String(transform.separator ?? ''),
- },
+ 'Version {version} (shipped: {shipped})',
+ { version: stored, shipped },
AS_TEXT,
- ),
- )
- }
- if (Object.hasOwn(transform, 'value')) {
- known.add('value')
- lines.push(
- t(
- 'stackiq',
- 'Value: {value}',
- { value: valueText(transform.value) },
- AS_TEXT,
- ),
- )
- }
- if (transform.sourceFormat || transform.targetFormat) {
- known.add('sourceFormat')
- known.add('targetFormat')
- lines.push(
- t(
- 'stackiq',
- 'Read as {source}, stored as {target}',
- {
- source: String(transform.sourceFormat ?? '—'),
- target: String(transform.targetFormat ?? 'Y-m-d'),
- },
- AS_TEXT,
- ),
- )
- }
- for (const [key, value] of Object.entries(transform)) {
- if (!known.has(key)) {
- lines.push(`${key}: ${valueText(value)}`)
+ )
}
+ return t('stackiq', 'Version {version}', { version: stored }, AS_TEXT)
}
- return lines
+ if (shipped === '') {
+ return ''
+ }
+ return t('stackiq', 'Version {version}', { version: shipped }, AS_TEXT)
}
/**
- * The rows of one pack's table: one per field mapping, in the pack's order.
+ * The address of OpenRegister's editor for a pack, when the page may link
+ * to it: the pack is editable and the server gave an http(s) or
+ * server-relative address. Anything else (a `javascript:` address, a
+ * protocol-relative one, no address at all) gives no link.
*
* @param {object} pack One entry of the endpoint's `packs`
- * @return {Array<{key: string, source: string, target: string, required: boolean, transform: string, details: Array}>} The rows
+ * @return {string|null} The address, or null for no link
* @spec openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020
*/
-export function mappingRows(pack) {
- const mappings = pack?.fieldMappings
- if (!Array.isArray(mappings)) {
- return []
+export function packEditUrl(pack) {
+ if (pack?.editable !== true || typeof pack.editUrl !== 'string') {
+ return null
}
- return mappings.map((mapping, index) => ({
- key: `${mapping?.source ?? ''}:${mapping?.target ?? ''}:${index}`,
- source: String(mapping?.source ?? ''),
- target: String(mapping?.target ?? ''),
- required: Boolean(mapping?.required),
- transform: transformLabel(mapping?.transform?.type),
- details: transformDetails(mapping?.transform),
- }))
+ return /^(https?:\/\/|\/(?!\/))/i.test(pack.editUrl) ? pack.editUrl : null
}
diff --git a/src/views/settings/sections/CmdbImport.spec.js b/src/views/settings/sections/CmdbImport.spec.js
index d0598b407..b1b0b884a 100644
--- a/src/views/settings/sections/CmdbImport.spec.js
+++ b/src/views/settings/sections/CmdbImport.spec.js
@@ -61,6 +61,9 @@ jest.mock('vue-material-design-icons/ChevronDown.vue', () => ({
jest.mock('vue-material-design-icons/ChevronUp.vue', () => ({
render: () => null,
}))
+jest.mock('vue-material-design-icons/OpenInNew.vue', () => ({
+ render: () => null,
+}))
jest.mock('../../../components/AlwaysVisibleSection.vue', () => ({
render: () => null,
}))
diff --git a/src/views/settings/sections/CmdbImport.vue b/src/views/settings/sections/CmdbImport.vue
index 8b5a6ff62..294062392 100644
--- a/src/views/settings/sections/CmdbImport.vue
+++ b/src/views/settings/sections/CmdbImport.vue
@@ -362,7 +362,7 @@
-
+
diff --git a/src/views/settings/sections/CmdbImportMapping.vue b/src/views/settings/sections/CmdbImportMapping.vue
index 127226a9a..77861847f 100644
--- a/src/views/settings/sections/CmdbImportMapping.vue
+++ b/src/views/settings/sections/CmdbImportMapping.vue
@@ -15,7 +15,7 @@
- {{ t('stackiq', 'Mapping (read-only)') }}
+ {{ t('stackiq', 'Mapping') }}
+
+ {{
+ t(
+ 'stackiq',
+ 'The packs are kept in OpenRegister, where you can view and edit them. A pack deleted there is restored from the shipped file at the next import.',
)
}}
@@ -39,7 +49,7 @@
{{ t('stackiq', 'Loading the mapping…') }}
-
+
+
+
+ {{
+ t(
+ 'stackiq',
+ "OpenRegister's pack store is not available, so the import uses the mapping shipped with stackiq. Update or enable OpenRegister to view and edit the mapping there.",
+ )
+ }}
+
+
+
@@ -73,92 +97,117 @@
-
-
- {{ packTargetLabel(pack.target) }}
-
-
- {{
- t(
- 'stackiq',
- '{file}: {name}, version {version}',
- {
- file: pack.file,
- name: pack.name,
- version: pack.version,
- },
- asText,
- )
- }}
-
-
- {{ pack.description }}
-
-
+
-
- {{
- row.source
- }}
-
-
- {{ row.target }}
-
-
+
+
+ {{ packTargetLabel(pack.target) }}
+
+
+
+ {{ packVersionText(pack) }}
+
+
+
+
+
+
+ {{ t('stackiq', 'Edit in OpenRegister') }}
+
+
+
+ {{
+ t(
+ 'stackiq',
+ 'This pack maps the import options, not a column of the export, so it is not edited in OpenRegister.',
+ )
+ }}
+
+
{{
- row.required
- ? t('stackiq', 'Yes')
- : t('stackiq', 'No')
+ t(
+ 'stackiq',
+ 'Stackiq ships a newer version of this pack ({version}). It was not applied, because the pack was edited in OpenRegister. Delete the pack there to use the shipped version from the next import on; your edits are then lost.',
+ { version: String(pack.shippedVersion ?? '') },
+ asText,
+ )
}}
-
-
-
- —
-
-
-
+
+
+