diff --git a/appinfo/routes.php b/appinfo/routes.php index 9fc761db..9e023fb9 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -109,6 +109,9 @@ // @spec openspec/changes/cmdb-export-import/tasks.md#task-8 ['name' => 'cmdbImport#import', 'url' => '/api/cmdb-import', 'verb' => 'POST'], ['name' => 'cmdbImport#cancel', 'url' => '/api/cmdb-import/{operationId}/cancel', 'verb' => 'POST'], + // The mapping the CMDB import uses, read-only — same posture as the import (Nextcloud admins only, CSRF). + // @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 + ['name' => 'settings#getCmdbImportMapping', 'url' => '/api/settings/cmdb-import/mapping', 'verb' => 'GET'], // User Groups management routes ['name' => 'settings#getGenericUserGroups', 'url' => '/api/settings/user-groups/generic', 'verb' => 'GET'], diff --git a/docs/features/cmdb-import.md b/docs/features/cmdb-import.md index b9d505ab..0cdc4f48 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 8dd84a2f..60a730e5 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 313f968e..5e5a3243 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 3151813d..e51e4af3 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 680f6f85..6ab4bbf9 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/lib/Controller/SettingsController.php b/lib/Controller/SettingsController.php index 2e5fd4b2..e0c47c5b 100644 --- a/lib/Controller/SettingsController.php +++ b/lib/Controller/SettingsController.php @@ -26,8 +26,10 @@ use OCA\OpenRegister\Contract\ObjectServiceInterface; use OCA\OpenRegister\Service\ConfigurationService; +use OCA\Stackiq\Exception\CmdbImportException; use OCA\Stackiq\Service\ArchiMateImportService; use OCA\Stackiq\Service\ArchiMateService; +use OCA\Stackiq\Service\Cmdb\CmdbImportProfile; use OCA\Stackiq\Service\ConnectionReportService; use OCA\Stackiq\Service\EolSyncService; use OCA\Stackiq\Service\OrganizationSyncService; @@ -42,6 +44,7 @@ use OCP\IAppConfig; use OCP\IConfig; use OCP\IGroupManager; +use OCP\IL10N; use OCP\IRequest; use OCP\IUserSession; use Psr\Container\ContainerInterface; @@ -88,10 +91,13 @@ class SettingsController extends Controller { * @param EolSyncService $eolSyncService The EOL feed sync orchestration service. * @param LoggerInterface $logger The logger instance. * @param ConnectionReportService|null $connectionReports Asks integriq to look again after an email settings save. + * @param CmdbImportProfile|null $cmdbImportProfile The CMDB import profile and packs; null builds the shipped one. + * @param IL10N|null $l10n Translations of the CMDB mapping messages. * * @SuppressWarnings(PHPMD.ExcessiveParameterList) * * @spec openspec/changes/adopt-connection-registry/specs/admin-integrations/spec.md#requirement-req-stackiq-conn-002-a-save-asks-integriq-to-look-again-and-a-run-reports-what-it-met + * @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 */ public function __construct( $appName, @@ -108,6 +114,8 @@ public function __construct( private readonly EolSyncService $eolSyncService, private readonly LoggerInterface $logger, private readonly ?ConnectionReportService $connectionReports = null, + private readonly ?CmdbImportProfile $cmdbImportProfile = null, + private readonly ?IL10N $l10n = null, ) { parent::__construct(appName: $appName, request: $request); @@ -3883,4 +3891,76 @@ public function getEolSyncStatus(): JSONResponse { return $this->buildConfigReadErrorResponse(operationLabel: 'get EOL sync status', exception: $e); } }//end getEolSyncStatus() + + /** + * The column mapping the CMDB import uses, read-only. + * + * Loads the import profile and its packs through the loader the import + * uses (CmdbImportProfile), so the answer is what the next import runs. + * Success is the flat `{profile, packs}`; a failure uses the import's + * error envelope, so the section shows it like an import error, with the + * loader's reason in `details.reason` because the admin who edited a file + * needs to know which file and why. That reason names files and validator + * rules only, never a cell value or a person. + * + * @return JSONResponse `{profile, packs}` (200), 503 MAPPING_UNAVAILABLE, or 500 IMPORT_FAILED. + * + * @auth admin-only the import's configuration; the import writes with RBAC and multitenancy off, so its view keeps that posture. + * + * @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 + */ + public function getCmdbImportMapping(): JSONResponse { + $profile = ($this->cmdbImportProfile ?? new CmdbImportProfile(container: $this->container)); + + try { + $overview = $profile->mappingOverview(); + } catch (CmdbImportException $e) { + $this->logger->info( + 'SettingsController: CMDB import mapping unavailable', + ['error' => $e->getErrorCode(), 'reason' => $e->getMessage()] + ); + return new JSONResponse( + [ + 'success' => false, + 'error' => $e->getErrorCode(), + 'message' => $this->translate(text: 'The import mapping cannot be shown: OpenRegister is missing or a mapping file is invalid.'), + 'details' => (object)['reason' => $e->getMessage()], + ], + $e->getHttpStatus() + ); + } catch (\Throwable $e) { + $this->logger->error('SettingsController: CMDB import mapping failed', ['exception' => $e]); + return new JSONResponse( + [ + 'success' => false, + 'error' => 'IMPORT_FAILED', + 'message' => $this->translate(text: 'The import mapping could not be read. The details are in the Nextcloud log.'), + 'details' => (object)[], + ], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + }//end try + + return new JSONResponse($overview, Http::STATUS_OK); + }//end getCmdbImportMapping() + + /** + * Translate a message, or hand it back as it is when no translator was injected. + * + * IL10N is an optional constructor argument (cmdb-import-mapping-view, design D1), + * so a test that builds the controller without it gets the English text. + * + * @param string $text The English text. + * + * @return string + * + * @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 + */ + private function translate(string $text): string { + if ($this->l10n === null) { + return $text; + } + + return $this->l10n->t($text); + }//end translate() }//end class diff --git a/lib/Service/Cmdb/CmdbImportProfile.php b/lib/Service/Cmdb/CmdbImportProfile.php index 1e8805c9..238107cf 100644 --- a/lib/Service/Cmdb/CmdbImportProfile.php +++ b/lib/Service/Cmdb/CmdbImportProfile.php @@ -625,6 +625,77 @@ public function referencedColumns(): array { return array_values(array_unique($columns)); }//end referencedColumns() + /** + * The profile and the packs as the admin settings show them: what the next import runs. + * + * Loads and validates everything first, so a broken pack throws here as + * it does when an import starts. The transform of each field mapping is + * passed through as the pack stores it, so no key the engine reads is + * hidden from the admin; `required` is normalised to a boolean. + * + * @return array{profile: array, packs: array>} + * + * @throws CmdbImportException MAPPING_UNAVAILABLE when the validator is missing, + * or the profile or a pack is unreadable or invalid. + * + * @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 + */ + public function mappingOverview(): array { + $profile = $this->profile(); + + $packs = []; + foreach (self::TARGETS as $target) { + $pack = $this->pack(target: $target); + $mappings = []; + foreach (($pack['fieldMappings'] ?? []) as $mapping) { + if (is_array($mapping) === false) { + continue; + } + + $transform = $mapping['transform'] ?? null; + if (is_array($transform) === false) { + $transform = null; + } + + $mappings[] = [ + 'source' => (string)($mapping['source'] ?? ''), + 'target' => (string)($mapping['target'] ?? ''), + 'required' => (bool)($mapping['required'] ?? false), + 'transform' => $transform, + ]; + } + + $packs[] = [ + 'target' => $target, + 'file' => (string)($profile['packs'][$target] ?? ''), + 'id' => (string)($pack['id'] ?? ''), + 'name' => (string)($pack['name'] ?? ''), + 'version' => (string)($pack['version'] ?? ''), + 'description' => (string)($pack['description'] ?? ''), + 'fieldMappings' => $mappings, + ]; + }//end foreach + + return [ + 'profile' => [ + 'id' => (string)($profile['id'] ?? ''), + 'name' => (string)($profile['name'] ?? ''), + 'version' => (string)($profile['version'] ?? ''), + 'profileFile' => $this->profileFile, + 'sheets' => $this->sheets(), + 'sheetPrecedence' => $this->stringList(key: 'sheetPrecedence'), + 'keyColumn' => $this->keyColumn(), + 'nameColumn' => $this->nameColumn(), + 'requiredColumns' => $this->requiredColumns(), + 'dateColumns' => $this->dateColumns(), + 'idColumns' => $this->idColumns(), + 'emptyValues' => $this->emptyValues(), + 'missingRecords' => $this->missingRecordsModes(), + ], + 'packs' => $packs, + ]; + }//end mappingOverview() + /** * The loaded profile. * diff --git a/openspec/changes/cmdb-import-mapping-view/.openspec.yaml b/openspec/changes/cmdb-import-mapping-view/.openspec.yaml new file mode 100644 index 00000000..5c0f6674 --- /dev/null +++ b/openspec/changes/cmdb-import-mapping-view/.openspec.yaml @@ -0,0 +1,2 @@ +schema: conduction +created: 2026-10-09 diff --git a/openspec/changes/cmdb-import-mapping-view/design.md b/openspec/changes/cmdb-import-mapping-view/design.md new file mode 100644 index 00000000..ece7a508 --- /dev/null +++ b/openspec/changes/cmdb-import-mapping-view/design.md @@ -0,0 +1,105 @@ +# Design: cmdb-import-mapping-view + +## Context + +The CMDB import (archived change `2026-10-05-cmdb-export-import`) maps a TOPdesk export through `lib/Settings/cmdb-import/topdesk-profile.json` and five migration packs, loaded and validated by `CmdbImportProfile` and executed by OpenRegister's `MappingEngine`. The section `CmdbImport.vue` shows the upload form, the progress and the report; its help text names the two sheets from `PROFILE_DEFAULTS` in `src/utils/cmdbImport.js`. Nothing in stackiq or OpenRegister shows the profile or the packs (verified on OpenRegister `beta` ff4dad5c, 2026-10-09: APIs for mappings and migration packs exist, a screen does not). + +## Goals / Non-Goals + +**Goals** + +- An administrator sees, in the section, exactly the mapping the next import runs. +- The section's sheet names cannot drift from the profile. +- Nothing is written; nothing new is parsed. + +**Non-Goals** + +- Editing the mapping in a UI (see proposal, Out of Scope). +- Showing a pack from OpenRegister's migration-pack store; the import does not read that store. + +## Architecture Overview + +``` +Admin settings, "CMDB import" section (CmdbImport.vue) + └─ CmdbImportMapping.vue collapsible "Mapping (read-only)", one CnDataTable per pack + │ GET /api/settings/cmdb-import/mapping (+ requesttoken, via @nextcloud/axios) + ▼ +SettingsController::getCmdbImportMapping() Nextcloud admins only, CSRF + └─ CmdbImportProfile::mappingOverview() load() → PackDefinitionValidator → accessors + lib/Settings/cmdb-import/topdesk-profile.json + topdesk-{module,manufacturer,municipality,usage,business-owner}.json +``` + +## Decisions + +### D1. The endpoint lives in `SettingsController` and reuses `CmdbImportProfile` + +The route sits with the other `/api/settings/*` reads, and `SettingsController` gets `CmdbImportProfile` and `IL10N` as optional, trailing constructor arguments, so the eight existing tests that build the controller positionally keep working and Nextcloud's container injects both. The method loads through `CmdbImportProfile::load()`, the loader the import uses, so the view can never show a profile the import would refuse. + +- **Alternative: `CmdbImportController::mapping()`.** Rejected: the brief and the plan put the read with the settings reads; the profile is injected directly, so the import service is not needed. +- **Alternative: read the JSON files in the controller.** Rejected: that would bypass the validator and could show a pack the import refuses. + +### D2. Admin-only, CSRF, the import's error envelope + +The method carries no auth attribute (Nextcloud's default: admins with a CSRF token), with `@auth admin-only` and its reason, as `CmdbImportController` does. The settings page is admin-only anyway, so a delegated group never sees the section; the endpoint follows the import's posture so an API caller gets the same answer from both. Success is the flat `{profile, packs}` (ADR-050). Failure is the import's envelope `{success: false, error, message, details}`, so `cmdbImport.js`'s `normaliseError()` and `errorText()` handle it unchanged; `details.reason` carries the loader's message, because an administrator who edited a file needs to know which file and why, and that message names files and validator rules only. + +### D3. The transform is shown as stored + +`fieldMappings[].transform` is passed through as the pack stores it (`type` plus `map`/`default`, `fields`/`separator`, `value`, `sourceFormat`/`targetFormat`). Re-shaping per type would hide a key the engine reads. The page renders the known keys and lists any other scalar key as it is. `required` is normalised to a boolean. + +### 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. + +### D5. One component, fetched once, collapsed by default + +The block is collapsed by default so the import form stays the section's first thing; the fetch happens on creation anyway, because the help text needs the sheet names. The toggle is an `NcButton` with a visible label and `aria-expanded`/`aria-controls`, the pattern of `CollapsibleSection.vue`. + +## Declarative-vs-imperative decision (ADR-031) + +- **No new rule.** The change reads the declarative mapping (JSON packs in OpenRegister's format, executed by `MappingEngine`) and shows it; it adds no `x-openregister-*` block and no imperative rule. +- **Imperative, because it is an endpoint:** one controller method and one accessor that shape the loaded JSON into the answer. That is presentation of configuration, not object lifecycle, aggregation, notification or relation logic. +- **The mapping itself stays declarative:** a change to a pack changes the view and the import together, without PHP. + +## Seed Data + +No schema changes. No register fragment, seed object or migration is added or changed; the endpoint reads the six files that ship under `lib/Settings/cmdb-import/`. + +## Nextcloud Integration + +- Controller: `SettingsController::getCmdbImportMapping()`; route `settings#getCmdbImportMapping`, `GET /api/settings/cmdb-import/mapping`. +- Service: `CmdbImportProfile::mappingOverview()`. +- OCP: `OCP\IL10N`. +- OpenRegister: `Service\MigrationPack\PackDefinitionValidator`, through the existing guard; absent, the endpoint answers 503 `MAPPING_UNAVAILABLE`. + +## Security Considerations + +- Admin-only with CSRF (D2). The answer is configuration that ships with the app: column names, field names, lookup tables, file names. No object, no person. +- `details.reason` is stackiq's own composed message or the validator's structural messages; `message` is static and translated. An unexpected exception is logged and answered as 500 `IMPORT_FAILED` with a static message, never with `getMessage()`. +- Every value is rendered as text; nothing goes into `v-html`. + +## NL Design System + +Nextcloud and `@conduction/nextcloud-vue` components only (ADR-012): `NcButton` for the toggle, `NcLoadingIcon`, `NcNoteCard` for the error and `CnDataTable` for the tables, the same table the report uses. + +## File Structure + +``` +appinfo/routes.php (+ settings#getCmdbImportMapping) +lib/Controller/SettingsController.php (+ getCmdbImportMapping) +lib/Service/Cmdb/CmdbImportProfile.php (+ mappingOverview) +src/utils/cmdbImport.js (+ mappingUrl, loadCmdbMapping, mappingSheetNames, packTargetLabel, transformLabel, mappingRows) +src/views/settings/sections/CmdbImportMapping.vue (new) +src/views/settings/sections/CmdbImport.vue (mounts the block; sheetNames) +tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php +tests/Unit/Service/Cmdb/CmdbImportProfileTest.php (+ overview) +tests/vitest/cmdbImportMapping.spec.js +tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts +docs/features/cmdb-import.md +l10n/en.json, en.js, nl.json, nl.js +``` + +## Testing + +- PHPUnit: the route and its posture (no attribute, `@auth admin-only`, no exemption annotation); the shipped directory answers five packs that match the profile's accessors; a broken pack answers 503 with the reason; a missing validator answers 503; an unexpected error answers 500 and is logged. +- vitest: the component renders one table per pack from a mocked answer, emits `loaded`, and shows the error and code on a 503. +- Playwright (written, not run here: no Nextcloud reachable): an admin sees the five tables and the usage row; a non-admin gets 403 and no settings page. diff --git a/openspec/changes/cmdb-import-mapping-view/proposal.md b/openspec/changes/cmdb-import-mapping-view/proposal.md new file mode 100644 index 00000000..e651febe --- /dev/null +++ b/openspec/changes/cmdb-import-mapping-view/proposal.md @@ -0,0 +1,78 @@ +--- +kind: code +depends_on: [] +--- + +# Proposal: cmdb-import-mapping-view + +## 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. + +## Motivation + +The municipality's application manager (Jira WOO-588, sub-task of WOO-586) wants to see which TOPdesk column lands in which stackiq field, and which export values the import recognises, without reading JSON on the server. The mapping is declarative (REQ-CMDB-005) and OpenRegister has APIs for migration packs, but no screen for them, and stackiq offered no view of its profile or packs either: the section's help text even carried the sheet names as a constant in the page, which could drift from the profile. Options weighed on 2026-10-09: (A) an existing OpenRegister screen, which does not exist; (B) a read-only view in the section; (C) documentation only; (B+) editing through OpenRegister's packs. B was chosen: the question was "make it visible", the change stays in stackiq, and editing a mapping stays a rare, file-based act. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cmdb-export-import`: ADDED REQ-CMDB-020 (the mapping overview endpoint and the read-only block); MODIFIED REQ-CMDB-014 (the section shows the mapping, and its help text takes the sheet names from the endpoint). + +## Affected Projects + +- [ ] Project: `stackiq`: one endpoint in `SettingsController`, one accessor on `CmdbImportProfile`, a `CmdbImportMapping.vue` block in the "CMDB import" section, helpers in `src/utils/cmdbImport.js`, documentation, translations, tests. + +## Scope + +### In Scope + +- `GET /api/settings/cmdb-import/mapping`, for Nextcloud admins only and CSRF-protected, answering the profile and the packs as loaded by `CmdbImportProfile`, or 503 `MAPPING_UNAVAILABLE` with the loader's reason when a pack is broken. +- The collapsible block in `CmdbImport.vue`, with loading and error states, one table per pack and a `data-testid` per table. +- The sheet names in the section's help text come from the endpoint; the shipped names stay as the fallback. +- `docs/features/cmdb-import.md`: "Viewing the mapping", and "Adjusting the mapping" updated. +- PHPUnit, vitest and a Playwright spec. + +### Out of Scope + +- Editing the mapping in the UI, or through OpenRegister's migration-pack store (B+, rejected for now). +- New transformations in OpenRegister's mapping engine, or promoting `MappingEngine` to an OpenRegister contract. +- A sixth pack (technical owner): the functional administrator is not imported (cmdb-export-import, design D2). + +## Approach + +`CmdbImportProfile::mappingOverview()` builds the answer from the loaded profile and packs through the existing accessors, after `load()` validated every pack. `SettingsController::getCmdbImportMapping()` returns it as a flat 200, or translates `CmdbImportException` into the import's error envelope with the reason in `details.reason`. The section mounts a `CmdbImportMapping` component that fetches the endpoint once, renders the tables with `CnDataTable`, and emits the loaded mapping so the parent can take the sheet names from it. The transform is shown as the pack stores it: type, map or fields or value, and the formats of a date. + +## New Dependencies + +None. + +## Impact + +- **Backend**: `lib/Controller/SettingsController.php` (one method, two optional constructor arguments), `lib/Service/Cmdb/CmdbImportProfile.php` (one method), one route in `appinfo/routes.php`. +- **Frontend**: `src/views/settings/sections/CmdbImportMapping.vue` (new), `src/views/settings/sections/CmdbImport.vue` (mounts it, takes the sheet names from it), `src/utils/cmdbImport.js` (URL, request, row and label helpers). +- **Data**: none. The endpoint reads six files that ship with the app and writes nothing. + +## Cross-Project Dependencies + +- **openregister** (consumed, not changed): `MigrationPack\PackDefinitionValidator`, through the existing guard in `CmdbImportProfile`. + +## Risks + +### Risk 1: The view shows a mapping the import does not use +**Severity:** Low — **Mitigation:** the endpoint and the import share one loader (`CmdbImportProfile`), one directory and one validator; the unit test reads the shipped directory through the controller and compares it with the profile's own accessors. + +### Risk 2: The loader's reason leaks something it should not +**Severity:** Low — **Mitigation:** the reason is composed by stackiq (file name plus the validator's messages about the pack's structure) or is the fixed text that the validator is missing; it never holds a cell value or a person. The `message` stays static and translated (ADR-050). + +## Rollback Strategy + +Additive. Revert the PR to remove the route, the method, the component and the strings; the section then shows its constant sheet names again, as before. + +## Open Questions + +- Should a later change let an administrator override a pack through OpenRegister's migration-pack store (`POST /api/migration-packs/import`), as cmdb-export-import design D2 noted? Not for this change. 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 new file mode 100644 index 00000000..39afdb9e --- /dev/null +++ b/openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md @@ -0,0 +1,99 @@ +# cmdb-export-import Specification (delta: cmdb-import-mapping-view) + +**Status**: in-progress +**Scope**: stackiq +**OpenSpec changes**: +- [cmdb-export-import](../../../archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_ +- [cmdb-import-mapping-view](../../) + +## 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). + +Nextcloud OCP interfaces used: `OCP\IL10N` (messages). OpenRegister: `Service\MigrationPack\PackDefinitionValidator` (through the existing `CmdbImportProfile`, guarded). + +## ADDED Requirements + +### 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`, `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` and `fieldMappings`. 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. + +#### Scenario: An admin reads the mapping the import uses +@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts + +- **GIVEN** a Nextcloud admin with the shipped profile and packs +- **WHEN** they request `GET /api/settings/cmdb-import/mapping` +- **THEN** the endpoint SHALL answer 200 with `profile.sheets` naming "Onbeh Applicaties CMDB" and "Beheerde Applicaties CMDB", `profile.keyColumn` `APPID` and `profile.requiredColumns` `APPID` and `Applicatie Naam` +- **AND** `packs` SHALL hold five entries with targets module, manufacturer, municipality, usage and businessOwner, each with its pack id, name and version +- **AND** the usage pack SHALL list a mapping from "Applicatie Status" to `status` with a `lookup` transform whose `map` sends "In productie" to "In production" + +#### Scenario: The section shows one table per pack +@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts + +- **GIVEN** a Nextcloud admin on stackiq's admin settings page +- **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 block SHALL have no control that edits a mapping + +#### Scenario: A broken pack is reported with its reason +@e2e exclude Breaking a shipped file on a shared instance is not done in e2e; tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php asserts the 503 and the reason, and tests/vitest/cmdbImportMapping.spec.js asserts the section's error state. + +- **GIVEN** a pack file on the server that OpenRegister's validator refuses +- **WHEN** a Nextcloud admin requests the mapping +- **THEN** the endpoint SHALL answer 503 with error `MAPPING_UNAVAILABLE` +- **AND** `details.reason` SHALL name the pack file and the validator's reason +- **AND** the section SHALL show the error and the code `MAPPING_UNAVAILABLE` instead of the tables + +#### Scenario: A user who is not a Nextcloud admin cannot read the mapping +@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts + +- **GIVEN** a signed-in user who is not a Nextcloud admin +- **WHEN** they request `GET /api/settings/cmdb-import/mapping` +- **THEN** Nextcloud SHALL answer 403 +- **AND** the admin settings page that holds the section SHALL NOT be shown to them + +## MODIFIED Requirements + +### 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. + +(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.) + +#### Scenario: The admin runs an import from the settings page +@e2e tests/e2e/spec-coverage/cmdb-import.spec.ts + +- **GIVEN** a Nextcloud admin on stackiq's admin settings page +- **WHEN** they choose "Gemeente Voorbeeldstad", choose the anonymised export and press "Import" +- **THEN** a progress bar SHALL appear while the import runs +- **AND** afterwards the summary and the report table SHALL be shown +- **AND** filtering the table on `created` SHALL show the two imported rows + +#### Scenario: The sheet names in the help come from the server +@e2e tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts + +- **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** when the endpoint fails, it SHALL name the shipped sheets and the mapping block SHALL show the error + +## Non-Functional Requirements + +- **Performance:** the endpoint reads six small JSON files and validates five packs; it SHALL answer within the time an admin settings page loads and SHALL be called once per page view. +- **Security:** admin-only with CSRF, as the import routes. The answer holds column names, field names and lookup tables from files that ship with the app; no object data and no person data. The loader's reason in `details.reason` names a file and a validator message, never a cell value. +- **Accessibility:** Target WCAG 2.2 AA. The block's toggle is an `NcButton` with a visible label and `aria-expanded` (SC 4.1.2; gate `button-name`), the content it controls is referenced with `aria-controls`, and each table has header cells (SC 1.3.1; gate `table-headers`). New in 2.2: 2.4.11 Focus Not Obscured applies (the tables are in the page flow, nothing sticky covers them); 2.5.7 Dragging Movements does not apply; 2.5.8 Target Size applies to the toggle (Nextcloud default); 3.2.6 Consistent Help does not apply (no help mechanism added); 3.3.7 Redundant Entry does not apply (nothing is entered); 3.3.8 Accessible Authentication does not apply. +- **Internationalization:** Dutch and English MUST be supported (ADR-005) for the block, the table headers, the target and transformation names and the error. + +## Acceptance Criteria + +- [ ] A Nextcloud admin opens "Mapping (read-only)" in the "CMDB import" section and sees five tables, one per pack, with the columns of the shipped packs. +- [ ] The usage table shows "Applicatie Status" mapped to `status` with its lookup values. +- [ ] With a pack file broken on the server, the block shows `MAPPING_UNAVAILABLE` with the reason and the import refuses to run with the same code. +- [ ] A signed-in user who is not a Nextcloud admin gets 403 from the endpoint. +- [ ] The help text under the file control names the sheets from the endpoint. + +## Notes + +- Editing stays file-based (choice B, 2026-10-09): OpenRegister has APIs for migration packs but no screen, so a stackiq editor would have been a larger change for a mapping that changes rarely. A pack edited on the server is overwritten by the next app update; lasting changes go to the app itself. +- The transform is passed through as the pack stores it, not re-shaped per type, so a transform key the engine reads is never hidden from the administrator. diff --git a/openspec/changes/cmdb-import-mapping-view/tasks.md b/openspec/changes/cmdb-import-mapping-view/tasks.md new file mode 100644 index 00000000..fe8aa0e8 --- /dev/null +++ b/openspec/changes/cmdb-import-mapping-view/tasks.md @@ -0,0 +1,53 @@ +# Tasks: cmdb-import-mapping-view + +Spec: `openspec/changes/cmdb-import-mapping-view/specs/cmdb-export-import/spec.md` (`SPEC` below). + +## Implementation Tasks + +### Task 1: Mapping overview on the profile loader +- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020) +- **files**: `lib/Service/Cmdb/CmdbImportProfile.php`, `tests/Unit/Service/Cmdb/CmdbImportProfileTest.php` +- **acceptance_criteria**: + - GIVEN the shipped directory WHEN `mappingOverview()` is called THEN it loads and validates every pack first and answers `profile` and `packs` with five entries in TARGETS order, each with target, file, id, name, version, description and fieldMappings + - GIVEN a field mapping WHEN it is shaped THEN `required` is a boolean and `transform` is the stored array + - GIVEN a broken pack WHEN `mappingOverview()` is called THEN it throws `CmdbImportException` MAPPING_UNAVAILABLE with the file in its message +- [x] Implement +- [x] Test + +### Task 2: Endpoint `GET /api/settings/cmdb-import/mapping` +- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020) +- **files**: `lib/Controller/SettingsController.php`, `appinfo/routes.php`, `tests/Unit/Controller/SettingsControllerCmdbImportMappingTest.php` +- **acceptance_criteria**: + - GIVEN the route WHEN its method is reflected THEN it carries no attribute, declares `@auth admin-only` with a reason and no `AuthorizedAdminSetting`, `NoAdminRequired`, `NoCSRFRequired` or `PublicPage` annotation + - GIVEN the shipped directory WHEN an admin calls it THEN 200 with the flat `{profile, packs}` and five packs + - GIVEN a broken pack THEN 503 `MAPPING_UNAVAILABLE` with the reason in `details.reason`; GIVEN an unexpected error THEN 500 `IMPORT_FAILED`, logged, with a static message +- [x] Implement +- [x] Test + +### Task 3: The "Mapping (read-only)" block and the sheet names +- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020) and `SPEC#requirement-the-admin-settings-shall-offer-a-cmdb-import-section-req-cmdb-014` (cmdb-export-import#REQ-CMDB-014) +- **files**: `src/views/settings/sections/CmdbImportMapping.vue`, `src/views/settings/sections/CmdbImport.vue`, `src/utils/cmdbImport.js`, `tests/vitest/cmdbImportMapping.spec.js` +- **acceptance_criteria**: + - 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` +- [x] Implement +- [x] Test + +### Task 4: Translations and documentation +- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020) +- **files**: `l10n/en.json`, `l10n/nl.json`, `l10n/en.js`, `l10n/nl.js`, `docs/features/cmdb-import.md` +- **acceptance_criteria**: + - GIVEN the new strings WHEN `npm run test:l10n` and `npm run check:l10n-js` run THEN both pass, with Dutch for every string + - GIVEN the docs WHEN an administrator reads "Viewing the mapping" and "Adjusting the mapping" THEN they know where the block is, what it shows, which file to change for which column and that an app update overwrites a changed file +- [x] Implement +- [x] Test + +### Task 5: End-to-end coverage +- **spec_ref**: `SPEC#requirement-the-admin-settings-shall-show-the-mapping-the-import-uses-req-cmdb-020` (cmdb-export-import#REQ-CMDB-020) +- **files**: `tests/e2e/spec-coverage/cmdb-import-mapping.spec.ts` +- **acceptance_criteria**: + - GIVEN an admin on the settings page WHEN they expand the block THEN five tables are visible and the usage row for "Applicatie Status" is shown + - GIVEN a signed-in non-admin WHEN they call the endpoint THEN 403, and the admin settings page is not shown to them +- [x] Implement +- [ ] Test (written, not run: no Nextcloud was reachable in this build) diff --git a/openspec/specs/cmdb-export-import/spec.md b/openspec/specs/cmdb-export-import/spec.md index d2f7fd4b..8818d842 100644 --- a/openspec/specs/cmdb-export-import/spec.md +++ b/openspec/specs/cmdb-export-import/spec.md @@ -1,15 +1,16 @@ --- capability: cmdb-export-import -status: done +status: in-progress built_by: openspec/changes/archive/2026-10-05-cmdb-export-import --- # cmdb-export-import Specification -**Status**: done +**Status**: in-progress **Scope**: stackiq **OpenSpec changes**: - [cmdb-export-import](../../changes/archive/2026-10-05-cmdb-export-import/) _(archived 2026-10-05)_ — admin uploads a TOPdesk CMDB export (xlsx); stackiq upserts modules, vendor organisations, usages and owner contact persons for one municipality from the two CMDB sheets, matched on APPID, mapped by OpenRegister migration packs (kind: code) +- [cmdb-import-mapping-view](../../changes/cmdb-import-mapping-view/) — the "CMDB import" section shows, read-only, the mapping the import uses, served by `GET /api/settings/cmdb-import/mapping` through the import's own loader; editing stays file-based (kind: code) ## Purpose diff --git a/src/utils/cmdbImport.js b/src/utils/cmdbImport.js index ab7fa708..4646c5a5 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 931f7edb..d0598b40 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 f8bbc1e4..8b5a6ff6 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 @@ + + +