From bf00c407a125248769ced6dabf235e0345fd2c90 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 19 Mar 2026 15:00:50 +0100 Subject: [PATCH 1/6] Added application metadata --- src/app/applications/application.model.ts | 2 + .../form-body-application.component.html | 6 ++ .../form-body-application.component.ts | 65 +++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/src/app/applications/application.model.ts b/src/app/applications/application.model.ts index 63fd9a97..4b2ff0c7 100644 --- a/src/app/applications/application.model.ts +++ b/src/app/applications/application.model.ts @@ -37,6 +37,7 @@ export class Application { public permissions: PermissionResponse[]; public permissionIds: number[]; public dataTargets: Datatarget[]; + public metadata?: string; } export class ApplicationRequest { @@ -56,6 +57,7 @@ export class ApplicationRequest { public controlledProperties?: ControlledPropertyTypes[]; public deviceTypes?: ApplicationDeviceTypeUnion[]; public permissionIds: number[]; + public metadata?: string; } export interface ApplicationData { diff --git a/src/app/shared/components/forms/form-body-application/form-body-application.component.html b/src/app/shared/components/forms/form-body-application/form-body-application.component.html index 016b9c63..3ca3d47e 100644 --- a/src/app/shared/components/forms/form-body-application/form-body-application.component.html +++ b/src/app/shared/components/forms/form-body-application/form-body-application.component.html @@ -10,6 +10,12 @@ } + +
+

{{ "QUESTION.METADATA" | translate }}

+ +
+
diff --git a/src/app/shared/components/forms/form-body-application/form-body-application.component.ts b/src/app/shared/components/forms/form-body-application/form-body-application.component.ts index 6e9d2fc5..8741ff58 100644 --- a/src/app/shared/components/forms/form-body-application/form-body-application.component.ts +++ b/src/app/shared/components/forms/form-body-application/form-body-application.component.ts @@ -16,6 +16,7 @@ import { SharedVariableService } from "@shared/shared-variable/shared-variable.s import { isPhoneNumberValid } from "@shared/validators/phone-number.validator"; import { ReplaySubject, Subject, Subscription } from "rxjs"; import { takeUntil } from "rxjs/operators"; +import { jsonToList } from "@shared/helpers/json.helper"; export class User { public name: string; @@ -58,6 +59,8 @@ export class FormBodyApplicationComponent implements OnInit, OnDestroy { public filteredPermissionsMulti: ReplaySubject = new ReplaySubject(1); private id: number; private _onDestroy = new Subject(); + metadataTags: { key?: string; value?: string }[] = []; + errorMetadataFieldId: string | undefined; constructor( private restService: RestService, @@ -157,6 +160,9 @@ export class FormBodyApplicationComponent implements OnInit, OnDestroy { this.application.deviceTypes = application.deviceTypes.map(deviceType => deviceType.type); this.application.permissionIds = application.permissionIds; this.permissionMultiCtrl.setValue(this.application.permissionIds); + if (application.metadata) { + this.metadataTags = jsonToList(application.metadata) ?? []; + } this.fillDefaultMetadata(); }); @@ -177,6 +183,20 @@ export class FormBodyApplicationComponent implements OnInit, OnDestroy { this.application.endDate = this.serializedEndDate.value?.toISOString(); this.application.contactPhone = this.phoneCtrl.value ? this.phoneCtrl.value : null; + // Lifted from IotDeviceEditComponent + if (this.metadataTags.length === 0) { + this.application.metadata = JSON.stringify({}); + } else if (this.isMetadataSet()) { + const invalidKey = this.validateMetadata(); + + if (!invalidKey) { + this.setMetadata(); + } else { + this.handleMetadataError(invalidKey); + return; + } + } + if (this.id) { this.updateApplication(this.id); } else { @@ -291,4 +311,49 @@ export class FormBodyApplicationComponent implements OnInit, OnDestroy { this.formFailedSubmit = true; } + + // Lifted from IotDeviceEditComponent + private isMetadataSet(): boolean { + return this.metadataTags.length && this.metadataTags.some(tag => tag.key && tag.value); + } + + private validateMetadata(): string | undefined { + const seen = new Set(); + + for (const tag of this.metadataTags) { + if (seen.size === seen.add(tag.key).size) { + return tag.key; + } + } + } + + private setMetadata(): void { + if (this.metadataTags.length && this.metadataTags.some(tag => tag.key && tag.value)) { + const metadata: Record = {}; + this.metadataTags.forEach(tag => { + if (!tag.key) { + return; + } + metadata[tag.key] = tag.value; + }); + this.application.metadata = JSON.stringify(metadata); + } + } + + private handleMetadataError(invalidKey: string) { + this.handleError({ + error: { + message: [ + { + field: "metadata", + message: "MESSAGE.DUPLICATE-METADATA-KEY", + }, + ], + }, + }); + this.errorMetadataFieldId = invalidKey; + this.formFailedSubmit = true; + } + + protected readonly JSON = JSON; } From 5311a879b577ba11cbd5776d424c2581cd800cb5 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 19 Mar 2026 15:47:10 +0100 Subject: [PATCH 2/6] Stored application metadata as real JSON --- src/app/applications/application.model.ts | 6 +- .../form-body-application.component.ts | 67 +++---------------- src/app/shared/helpers/metadata.helper.ts | 39 +++++++++++ src/app/shared/types/metadata.ts | 1 + 4 files changed, 53 insertions(+), 60 deletions(-) create mode 100644 src/app/shared/helpers/metadata.helper.ts create mode 100644 src/app/shared/types/metadata.ts diff --git a/src/app/applications/application.model.ts b/src/app/applications/application.model.ts index 4b2ff0c7..4bbc90cb 100644 --- a/src/app/applications/application.model.ts +++ b/src/app/applications/application.model.ts @@ -7,6 +7,8 @@ import { Organisation } from "../admin/organisation/organisation.model"; import { ApplicationStatus } from "./enums/status.enum"; import { IotDevice } from "./iot-devices/iot-device.model"; import { ApplicationDeviceType } from "./models/application-device-type.model"; +import { KeyValue } from "@shared/types/tuple.type"; +import { Metadata } from "@shared/types/metadata"; export type ApplicationWithStatus = Application & { statusCheck: "stable" | "alert" }; @@ -37,7 +39,7 @@ export class Application { public permissions: PermissionResponse[]; public permissionIds: number[]; public dataTargets: Datatarget[]; - public metadata?: string; + public metadata?: Metadata; } export class ApplicationRequest { @@ -57,7 +59,7 @@ export class ApplicationRequest { public controlledProperties?: ControlledPropertyTypes[]; public deviceTypes?: ApplicationDeviceTypeUnion[]; public permissionIds: number[]; - public metadata?: string; + public metadata?: Metadata; } export interface ApplicationData { diff --git a/src/app/shared/components/forms/form-body-application/form-body-application.component.ts b/src/app/shared/components/forms/form-body-application/form-body-application.component.ts index 8741ff58..cd932518 100644 --- a/src/app/shared/components/forms/form-body-application/form-body-application.component.ts +++ b/src/app/shared/components/forms/form-body-application/form-body-application.component.ts @@ -17,6 +17,7 @@ import { isPhoneNumberValid } from "@shared/validators/phone-number.validator"; import { ReplaySubject, Subject, Subscription } from "rxjs"; import { takeUntil } from "rxjs/operators"; import { jsonToList } from "@shared/helpers/json.helper"; +import { keyValueListToMetadata, metadataToKeyValueList } from "@shared/helpers/metadata.helper"; export class User { public name: string; @@ -161,9 +162,11 @@ export class FormBodyApplicationComponent implements OnInit, OnDestroy { this.application.permissionIds = application.permissionIds; this.permissionMultiCtrl.setValue(this.application.permissionIds); if (application.metadata) { - this.metadataTags = jsonToList(application.metadata) ?? []; + this.metadataTags = metadataToKeyValueList(application.metadata) ?? []; } + console.debug(application.metadata, this.metadataTags); + this.fillDefaultMetadata(); }); } @@ -183,18 +186,11 @@ export class FormBodyApplicationComponent implements OnInit, OnDestroy { this.application.endDate = this.serializedEndDate.value?.toISOString(); this.application.contactPhone = this.phoneCtrl.value ? this.phoneCtrl.value : null; - // Lifted from IotDeviceEditComponent - if (this.metadataTags.length === 0) { - this.application.metadata = JSON.stringify({}); - } else if (this.isMetadataSet()) { - const invalidKey = this.validateMetadata(); - - if (!invalidKey) { - this.setMetadata(); - } else { - this.handleMetadataError(invalidKey); - return; - } + try { + this.application.metadata = keyValueListToMetadata(this.metadataTags); + } catch (error) { + this.handleError(this.buildErrorMessage(error.message), "application.metadata"); + return; } if (this.id) { @@ -311,49 +307,4 @@ export class FormBodyApplicationComponent implements OnInit, OnDestroy { this.formFailedSubmit = true; } - - // Lifted from IotDeviceEditComponent - private isMetadataSet(): boolean { - return this.metadataTags.length && this.metadataTags.some(tag => tag.key && tag.value); - } - - private validateMetadata(): string | undefined { - const seen = new Set(); - - for (const tag of this.metadataTags) { - if (seen.size === seen.add(tag.key).size) { - return tag.key; - } - } - } - - private setMetadata(): void { - if (this.metadataTags.length && this.metadataTags.some(tag => tag.key && tag.value)) { - const metadata: Record = {}; - this.metadataTags.forEach(tag => { - if (!tag.key) { - return; - } - metadata[tag.key] = tag.value; - }); - this.application.metadata = JSON.stringify(metadata); - } - } - - private handleMetadataError(invalidKey: string) { - this.handleError({ - error: { - message: [ - { - field: "metadata", - message: "MESSAGE.DUPLICATE-METADATA-KEY", - }, - ], - }, - }); - this.errorMetadataFieldId = invalidKey; - this.formFailedSubmit = true; - } - - protected readonly JSON = JSON; } diff --git a/src/app/shared/helpers/metadata.helper.ts b/src/app/shared/helpers/metadata.helper.ts new file mode 100644 index 00000000..079a8ec4 --- /dev/null +++ b/src/app/shared/helpers/metadata.helper.ts @@ -0,0 +1,39 @@ +import { KeyValue } from "@shared/types/tuple.type"; +import { Metadata } from "@shared/types/metadata"; + +export const metadataToKeyValueList = (metadata: object, shouldThrowOnError = false): KeyValue[] => { + try { + const tags: KeyValue[] = []; + + for (const key of Object.keys(metadata)) { + tags.push({ key, value: metadata[key] }); + } + + return tags; + } catch (error) { + if (shouldThrowOnError) { + throw error; + } + } +}; + +export const keyValueListToMetadata = (pairs: KeyValue[]): Metadata => { + const metadata = {} + + for (const pair of pairs) { + const key = pair.key?.trim(); + const value = pair.value?.trim(); + if (!key) { + throw new Error('Empty metadata key') + } + if (!value) { + throw new Error("Empty metadata value for key "+key); + } + if (key in metadata) { + throw new Error("Duplicate metadata key: "+key) + } + metadata[key] = value + } + + return metadata; +}; diff --git a/src/app/shared/types/metadata.ts b/src/app/shared/types/metadata.ts new file mode 100644 index 00000000..bd55cad8 --- /dev/null +++ b/src/app/shared/types/metadata.ts @@ -0,0 +1 @@ +export type Metadata = Record From ccb9211df95c848db13f1bc049a6f5025e2e4810 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 19 Mar 2026 16:15:10 +0100 Subject: [PATCH 3/6] Translated error messages --- .../form-body-application.component.html | 10 +++++----- .../form-body-application.component.ts | 10 ++++++++-- src/app/shared/helpers/metadata.helper.ts | 10 +++++----- src/app/shared/translatable-error.ts | 12 ++++++++++++ src/assets/i18n/da.json | 5 ++++- 5 files changed, 34 insertions(+), 13 deletions(-) create mode 100644 src/app/shared/translatable-error.ts diff --git a/src/app/shared/components/forms/form-body-application/form-body-application.component.html b/src/app/shared/components/forms/form-body-application/form-body-application.component.html index 3ca3d47e..b2b8bcde 100644 --- a/src/app/shared/components/forms/form-body-application/form-body-application.component.html +++ b/src/app/shared/components/forms/form-body-application/form-body-application.component.html @@ -11,11 +11,6 @@
} -
-

{{ "QUESTION.METADATA" | translate }}

- -
-
@@ -319,6 +314,11 @@

{{ "QUESTION.METADATA" | translate }}

+
+

{{ "QUESTION.METADATA" | translate }}

+ +
+
diff --git a/src/app/shared/components/forms/form-body-application/form-body-application.component.ts b/src/app/shared/components/forms/form-body-application/form-body-application.component.ts index cd932518..9beaf3dc 100644 --- a/src/app/shared/components/forms/form-body-application/form-body-application.component.ts +++ b/src/app/shared/components/forms/form-body-application/form-body-application.component.ts @@ -16,8 +16,9 @@ import { SharedVariableService } from "@shared/shared-variable/shared-variable.s import { isPhoneNumberValid } from "@shared/validators/phone-number.validator"; import { ReplaySubject, Subject, Subscription } from "rxjs"; import { takeUntil } from "rxjs/operators"; -import { jsonToList } from "@shared/helpers/json.helper"; import { keyValueListToMetadata, metadataToKeyValueList } from "@shared/helpers/metadata.helper"; +import { TranslatableError } from "@shared/translatable-error"; +import { addWarning } from "@angular-devkit/build-angular/src/utils/webpack-diagnostics"; export class User { public name: string; @@ -189,7 +190,12 @@ export class FormBodyApplicationComponent implements OnInit, OnDestroy { try { this.application.metadata = keyValueListToMetadata(this.metadataTags); } catch (error) { - this.handleError(this.buildErrorMessage(error.message), "application.metadata"); + let message = error.message; + if (error instanceof TranslatableError) { + // @todo Can we safely use `instant` here? + message = this.translate.instant(message, error.context); + } + this.handleError(this.buildErrorMessage(message), "application.metadata"); return; } diff --git a/src/app/shared/helpers/metadata.helper.ts b/src/app/shared/helpers/metadata.helper.ts index 079a8ec4..8b038448 100644 --- a/src/app/shared/helpers/metadata.helper.ts +++ b/src/app/shared/helpers/metadata.helper.ts @@ -1,5 +1,6 @@ import { KeyValue } from "@shared/types/tuple.type"; import { Metadata } from "@shared/types/metadata"; +import { TranslatableError } from "@shared/translatable-error"; export const metadataToKeyValueList = (metadata: object, shouldThrowOnError = false): KeyValue[] => { try { @@ -24,13 +25,12 @@ export const keyValueListToMetadata = (pairs: KeyValue[]): Metadata => { const key = pair.key?.trim(); const value = pair.value?.trim(); if (!key) { - throw new Error('Empty metadata key') + throw new TranslatableError("ERROR.METADATA-EMPTY-KEY"); + } else if (key in metadata) { + throw new TranslatableError("ERROR.METADATA-DUPLICATE-KEY", { key }); } if (!value) { - throw new Error("Empty metadata value for key "+key); - } - if (key in metadata) { - throw new Error("Duplicate metadata key: "+key) + throw new TranslatableError("ERROR.METADATA-EMPTY-VALUE", { key }); } metadata[key] = value } diff --git a/src/app/shared/translatable-error.ts b/src/app/shared/translatable-error.ts new file mode 100644 index 00000000..4e9c3258 --- /dev/null +++ b/src/app/shared/translatable-error.ts @@ -0,0 +1,12 @@ +import { InterpolationParameters } from "@ngx-translate/core"; + +export class TranslatableError extends Error { + constructor( + msg: string, + public readonly context?: InterpolationParameters + ) { + super(msg); + // Set the prototype explicitly. + Object.setPrototypeOf(this, TranslatableError.prototype); + } +} diff --git a/src/assets/i18n/da.json b/src/assets/i18n/da.json index c2249658..97654258 100644 --- a/src/assets/i18n/da.json +++ b/src/assets/i18n/da.json @@ -1290,7 +1290,10 @@ "USER-INACTIVE": "Login successfuldt, men din bruger er deaktiveret i OS2IoT. Kontakt din administrator for at få tildelt rettigheder" }, "ERROR": { - "SEMANTIC": "Semantisk fejl, verificer at navn og værdier er på det rigtige format" + "SEMANTIC": "Semantisk fejl, verificer at navn og værdier er på det rigtige format", + "METADATA-EMPTY-KEY": "Metadatanavnet må ikke være tomt", + "METADATA-EMPTY-VALUE": "Metadataværdien for \"{{key}}\" må ikke være tom", + "METADATA-DUPLICATE-KEY": "Metadatanavnet \"{{key}}\" er allerede brugt" }, "DIALOG": { "SAVE.TITLE": "Slet", From 2478d68278300c93b476a4aac7505a408c670c6b Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Thu, 19 Mar 2026 16:30:36 +0100 Subject: [PATCH 4/6] Displayed application metadata --- .../application-detail.component.html | 1 + .../metadata-details.component.html | 16 ++++++++++++++++ .../metadata-details.component.ts | 4 ++++ src/assets/i18n/da.json | 3 ++- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/app/applications/application-detail/application-detail.component.html b/src/app/applications/application-detail/application-detail.component.html index 1bff90fd..cee8771f 100644 --- a/src/app/applications/application-detail/application-detail.component.html +++ b/src/app/applications/application-detail/application-detail.component.html @@ -41,6 +41,7 @@

{{ "APPLICATION.DETAILS" | translate }}

[personalData]="application.personalData" [startDate]="application.startDate" [status]="application.status" + [metadata]="application.metadata" >
diff --git a/src/app/shared/components/metadata-details/metadata-details.component.html b/src/app/shared/components/metadata-details/metadata-details.component.html index 6b22ee26..d1d4dbba 100644 --- a/src/app/shared/components/metadata-details/metadata-details.component.html +++ b/src/app/shared/components/metadata-details/metadata-details.component.html @@ -69,3 +69,19 @@ >{{ deviceTypeText }}

} + +@if (metadata) { +

{{ "APPLICATION.METADATA-FIELD.METADATA" | translate }}

+ + + + {{ '' // @todo How can we iterate metadata (without using Object.keys }} + @for (key of Object.keys(metadata); track key) { + + + + + } + +
{{ key }}{{ metadata[key]}}
+} diff --git a/src/app/shared/components/metadata-details/metadata-details.component.ts b/src/app/shared/components/metadata-details/metadata-details.component.ts index 67ff019c..b330eddd 100644 --- a/src/app/shared/components/metadata-details/metadata-details.component.ts +++ b/src/app/shared/components/metadata-details/metadata-details.component.ts @@ -5,6 +5,7 @@ import { ApplicationDeviceType } from "@applications/models/application-device-t import { TranslateService } from "@ngx-translate/core"; import { toPascalKebabCase } from "@shared/helpers/string.helper"; import { ControlledProperty } from "@shared/models/controlled-property.model"; +import { Metadata } from "@shared/types/metadata"; @Component({ selector: "app-metadata-details", @@ -26,6 +27,7 @@ export class MetadataDetailsComponent implements OnInit { @Input() hardware?: string; @Input() controlledProperties?: ControlledProperty[]; @Input() deviceTypes?: ApplicationDeviceType[]; + @Input() metadata?: Metadata; controlledPropertyText: string; deviceTypeText: string; @@ -48,4 +50,6 @@ export class MetadataDetailsComponent implements OnInit { .join(", "); }); } + + protected readonly Object = Object; } diff --git a/src/assets/i18n/da.json b/src/assets/i18n/da.json index 97654258..9b49cc81 100644 --- a/src/assets/i18n/da.json +++ b/src/assets/i18n/da.json @@ -157,7 +157,8 @@ "PERSONAL-DATA": "Persondata", "HARDWARE": "Hardware", "CONTROLLED-PROPERTY": "Data", - "DEVICE-TYPE": "Forbindelsesteknologi" + "DEVICE-TYPE": "Forbindelsesteknologi", + "METADATA": "Metadata" }, "CHANGE-ORGANIZATION": { "TITLE": "Skift organisation", From be54a05caea65efdc9cc1baaf313be17ee5fa51e Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Fri, 27 Mar 2026 20:47:54 +0100 Subject: [PATCH 5/6] Cleaned up --- .../form-body-application/form-body-application.component.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/shared/components/forms/form-body-application/form-body-application.component.ts b/src/app/shared/components/forms/form-body-application/form-body-application.component.ts index 9beaf3dc..047567bc 100644 --- a/src/app/shared/components/forms/form-body-application/form-body-application.component.ts +++ b/src/app/shared/components/forms/form-body-application/form-body-application.component.ts @@ -166,8 +166,6 @@ export class FormBodyApplicationComponent implements OnInit, OnDestroy { this.metadataTags = metadataToKeyValueList(application.metadata) ?? []; } - console.debug(application.metadata, this.metadataTags); - this.fillDefaultMetadata(); }); } From 238e7480d0edd0e62cc3a4390c624ead90b815d3 Mon Sep 17 00:00:00 2001 From: Mikkel Ricky Date: Fri, 27 Mar 2026 20:48:07 +0100 Subject: [PATCH 6/6] Updated metadata display --- .../metadata-details.component.html | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/app/shared/components/metadata-details/metadata-details.component.html b/src/app/shared/components/metadata-details/metadata-details.component.html index d1d4dbba..e979d9fe 100644 --- a/src/app/shared/components/metadata-details/metadata-details.component.html +++ b/src/app/shared/components/metadata-details/metadata-details.component.html @@ -71,17 +71,14 @@ } @if (metadata) { -

{{ "APPLICATION.METADATA-FIELD.METADATA" | translate }}

- - - - {{ '' // @todo How can we iterate metadata (without using Object.keys }} - @for (key of Object.keys(metadata); track key) { - - - - - } - -
{{ key }}{{ metadata[key]}}
+

+ {{ "APPLICATION.METADATA-FIELD.METADATA" | translate }} +

+ @for (key of Object.keys(metadata); track key; let i = $index) { +

+ {{ i + 1 }}: {{ key }} + {{ metadata[key] }} +

+ } }