From fdc854d3065d28094cd80f12ec68c2027866ea38 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 11:01:38 +1000 Subject: [PATCH 01/12] fix(i18n): add missing locale keys and fix mistyped keys Rename typo'd locale keys (PASSOWRD_MATCH, APP_KEYS_SCOPES_REQUIRE, APPLICATIONS_SAVE_*), swap the inverted AUTHENTICATION_SAVE_* messages, add missing en-AU keys, and fix mistyped keys in code. Translate the zone trigger remove tooltip and the confirm modal default title. Co-Authored-By: Claude Opus 5.5 (1M context) --- public/assets/locale/ar.json | 8 +++---- public/assets/locale/en-AU.json | 23 ++++++++++++------- public/assets/locale/en-GB.json | 8 +++---- public/assets/locale/en-US.json | 8 +++---- public/assets/locale/es.json | 8 +++---- public/assets/locale/fr.json | 8 +++---- public/assets/locale/jp.json | 8 +++---- src/app/admin/broker-form.component.ts | 8 +++---- src/app/admin/staff-tenant-modal.component.ts | 6 ++--- src/app/overlays/confirm-modal.component.ts | 2 +- .../ui/custom-fields/date-field.component.ts | 2 +- src/app/ui/extension-outlet.component.ts | 2 +- .../ui/forms/ldap-source-form.component.ts | 2 +- .../ui/forms/oauth-source-form.component.ts | 2 +- .../ui/forms/saml-source-form.component.ts | 2 +- .../forms/trigger-action-modal.component.ts | 2 +- src/app/zones/zone-triggers.component.ts | 2 +- 17 files changed, 54 insertions(+), 47 deletions(-) diff --git a/public/assets/locale/ar.json b/public/assets/locale/ar.json index 76712cc89..b7761f068 100644 --- a/public/assets/locale/ar.json +++ b/public/assets/locale/ar.json @@ -626,7 +626,7 @@ "STAFF_CARD": "رقم البطاقة", "PASSWORD_REQUIRED": "مطلوب كلمة مرور صالحة", "PASSWORD_CONFIRM": "تأكيد كلمة المرور", - "PASSOWRD_MATCH": "كلمات المرور غير متطابقة" + "PASSWORDS_MATCH": "كلمات المرور غير متطابقة" }, "DOMAINS": { "SINGULAR": "مجال", @@ -686,8 +686,8 @@ "AUTHENTICATION_EMPTY": "لا توجد طرق مصادقة لهذا المجال", "AUTHENTICATION_SOURCE_TYPE": "نوع مصدر المصادقة", "AUTHENTICATION_SOURCE_SELECT": "اختر مصدر المصادقة...", - "AUTHENTICATION_SAVE_ERROR": "تم تحديث مصدر المصادقة بنجاح", - "AUTHENTICATION_SAVE_SUCCESS": "فشل تحديث مصدر المصادقة. الخطأ: {{ error }}", + "AUTHENTICATION_SAVE_SUCCESS": "تم تحديث مصدر المصادقة بنجاح", + "AUTHENTICATION_SAVE_ERROR": "فشل تحديث مصدر المصادقة. الخطأ: {{ error }}", "AUTHENTICATION_NAME_REQUIRED": "مطلوب اسم لمصدر المصادقة", "OAUTH_SITE": "الموقع", "OAUTH_SITE_PLACEHOLDER": "عنوان URL لموفر SSO", @@ -896,7 +896,7 @@ "APP_KEYS_LAST_DETAILS": "آخر تفاصيل مفتاح تطبيق", "APP_KEYS_FIELD_SCOPES": "نطاقات", "APP_KEYS_FIELD_PERMISSIONS": "الوصول", - "APP_KEYS_SCOPES_REQUIRE": "مطلوب نطاق واحد على الأقل", + "APP_KEYS_SCOPES_REQUIRED": "مطلوب نطاق واحد على الأقل", "APP_KEYS_NAME_REQUIRED": "مطلوب اسم", "APP_KEYS_PERMISSIONS_EMPTY": "لا توجد أذونات", "APP_KEYS_COPIED": "تم نسخ مفتاح التطبيق إلى الحافظة", diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index 602f9c5a0..776266469 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -49,6 +49,9 @@ "CANCEL": "Cancel", "CLOSE": "Close", "CONFIRM": "Confirm", + "ITEM_REMOVE": "Remove {{ item }}", + "FIELD_REQUIRED": "This field is required", + "DATE_EMPTY": "No date selected", "SAVE": "Save", "SAVE_ALL": "Save All", "CLEAR": "Clear Changes", @@ -679,6 +682,7 @@ "NEW": "New user", "BULK": "Bulk add users", "REMOVE": "Remove user", + "DELETE": "Delete user", "DELETE_MSG": "

Are you sure you want delete this user?

The user will be removed from the system within 24 hours

", "DELETE_LOADING": "Deleting user...", "DELETE_SUCCESS": "Successfully deleted user.", @@ -729,7 +733,7 @@ "STAFF_CARD": "Card Number", "PASSWORD_REQUIRED": "A valid password is required", "PASSWORD_CONFIRM": "Confirm Password", - "PASSOWRD_MATCH": "Passwords do not match", + "PASSWORDS_MATCH": "Passwords do not match", "IMAGE": "Profile Image", "IMAGE_INVALID": "A valid URL is required for profile image", "LOCATABLE": "Enable Location Tracking" @@ -867,9 +871,9 @@ "FIELD_USER": "User", "FIELD_ROLE": "Role", "APPLICATIONS_EMPTY": "No applications for this domain", - "APPLICATIONS_SAVING": "Saving domain application...", - "APPLICATIONS_SAVE_ERROR": "Failed to save domain application", - "APPLICATIONS_SAVE_SUCCESS": "Successfully saved domain application", + "APPLICATION_SAVING": "Saving domain application...", + "APPLICATION_SAVE_ERROR": "Failed to save domain application. Error: {{ error }}", + "APPLICATION_SAVE_SUCCESS": "Successfully saved domain application", "COPY_SECRET": "Copy secret to Clipboard", "COPIED_SECRET": "Copied client secret to Clipboard", "VIEW_SECRET": "View Secret", @@ -891,8 +895,8 @@ "AUTHENTICATION_EMPTY": "No authentication methods for this domain", "AUTHENTICATION_SOURCE_TYPE": "Authentication Source Type", "AUTHENTICATION_SOURCE_SELECT": "Select authentication source...", - "AUTHENTICATION_SAVE_ERROR": "Successfully updated authentication source", - "AUTHENTICATION_SAVE_SUCCESS": "Failed to update authentication source. Error: {{ error }}", + "AUTHENTICATION_SAVE_SUCCESS": "Successfully updated authentication source", + "AUTHENTICATION_SAVE_ERROR": "Failed to update authentication source. Error: {{ error }}", "AUTHENTICATION_NAME_REQUIRED": "An auth source name is required", "OAUTH_SITE": "Site", "OAUTH_SITE_PLACEHOLDER": "URL of the SSO provider", @@ -1020,7 +1024,7 @@ "BROKERS_AUTH_TYPE_NONE": "No Auth", "BROKERS_FILTERS_EMPTY": "No filters", "BROKERS_NAME_REQUIRED": "A broker name is required", - "BROKERS_HOST_REQUIRED": "A broker name is required", + "BROKERS_HOST_REQUIRED": "A broker host is required", "BROKERS_PORT_REQUIRED": "A valid port number between 1 - 65535 is required", "BROKERS_USERNAME": "Username", "BROKERS_USERNAME_REQUIRED": "A valid username is required", @@ -1028,6 +1032,9 @@ "BROKERS_PASSWORD_REQUIRED": "A valid password is required ", "BROKERS_CERT": "Certificate", "BROKERS_CERT_REQUIRED": "A valid certificate is required ", + "BROKERS_SAVING": "Saving MQTT broker...", + "BROKERS_SAVE_ERROR": "Failed to save MQTT broker. Error: {{ error }}", + "BROKERS_SAVE_SUCCESS": "Successfully saved MQTT broker.", "TENANTS_HEADER": "Staff API Tenants", "TENANTS_ADD": "Add Tenant", "TENANTS_NEW": "New Tenant", @@ -1122,7 +1129,7 @@ "APP_KEYS_TTL_MONTH": "30 Days", "APP_KEYS_TTL_90_DAYS": "90 Days", "APP_KEYS_TTL_YEAR": "1 Year", - "APP_KEYS_SCOPES_REQUIRE": "At least one scope is required", + "APP_KEYS_SCOPES_REQUIRED": "At least one scope is required", "APP_KEYS_NAME_REQUIRED": "A name is required", "APP_KEYS_PERMISSIONS_EMPTY": "No permissions", "APP_KEYS_COPIED": "Copied Application Key to clipboard", diff --git a/public/assets/locale/en-GB.json b/public/assets/locale/en-GB.json index 7d4b66638..2f8e6fc40 100644 --- a/public/assets/locale/en-GB.json +++ b/public/assets/locale/en-GB.json @@ -639,7 +639,7 @@ "STAFF_CARD": "Card Number", "PASSWORD_REQUIRED": "A valid password is required", "PASSWORD_CONFIRM": "Confirm Password", - "PASSOWRD_MATCH": "Passwords do not match" + "PASSWORDS_MATCH": "Passwords do not match" }, "DOMAINS": { "SINGULAR": "Domain", @@ -699,8 +699,8 @@ "AUTHENTICATION_EMPTY": "No authentication methods for this domain", "AUTHENTICATION_SOURCE_TYPE": "Authentication Source Type", "AUTHENTICATION_SOURCE_SELECT": "Select authentication source...", - "AUTHENTICATION_SAVE_ERROR": "Successfully updated authentication source", - "AUTHENTICATION_SAVE_SUCCESS": "Failed to update authentication source. Error: {{ error }}", + "AUTHENTICATION_SAVE_SUCCESS": "Successfully updated authentication source", + "AUTHENTICATION_SAVE_ERROR": "Failed to update authentication source. Error: {{ error }}", "AUTHENTICATION_NAME_REQUIRED": "An auth source name is required", "OAUTH_SITE": "Site", "OAUTH_SITE_PLACEHOLDER": "URL of the SSO provider", @@ -927,7 +927,7 @@ "APP_KEYS_TTL_MONTH": "30 Days", "APP_KEYS_TTL_90_DAYS": "90 Days", "APP_KEYS_TTL_YEAR": "1 Year", - "APP_KEYS_SCOPES_REQUIRE": "At least one scope is required", + "APP_KEYS_SCOPES_REQUIRED": "At least one scope is required", "APP_KEYS_NAME_REQUIRED": "A name is required", "APP_KEYS_PERMISSIONS_EMPTY": "No permissions", "APP_KEYS_COPIED": "Copied Application Key to clipboard", diff --git a/public/assets/locale/en-US.json b/public/assets/locale/en-US.json index 838c470e1..d07b2812f 100644 --- a/public/assets/locale/en-US.json +++ b/public/assets/locale/en-US.json @@ -636,7 +636,7 @@ "STAFF_CARD": "Card Number", "PASSWORD_REQUIRED": "A valid password is required", "PASSWORD_CONFIRM": "Confirm Password", - "PASSOWRD_MATCH": "Passwords do not match" + "PASSWORDS_MATCH": "Passwords do not match" }, "DOMAINS": { "SINGULAR": "Domain", @@ -696,8 +696,8 @@ "AUTHENTICATION_EMPTY": "No authentication methods for this domain", "AUTHENTICATION_SOURCE_TYPE": "Authentication Source Type", "AUTHENTICATION_SOURCE_SELECT": "Select authentication source...", - "AUTHENTICATION_SAVE_ERROR": "Successfully updated authentication source", - "AUTHENTICATION_SAVE_SUCCESS": "Failed to update authentication source. Error: {{ error }}", + "AUTHENTICATION_SAVE_SUCCESS": "Successfully updated authentication source", + "AUTHENTICATION_SAVE_ERROR": "Failed to update authentication source. Error: {{ error }}", "AUTHENTICATION_NAME_REQUIRED": "An auth source name is required", "OAUTH_SITE": "Site", "OAUTH_SITE_PLACEHOLDER": "URL of the SSO provider", @@ -924,7 +924,7 @@ "APP_KEYS_TTL_MONTH": "30 Days", "APP_KEYS_TTL_90_DAYS": "90 Days", "APP_KEYS_TTL_YEAR": "1 Year", - "APP_KEYS_SCOPES_REQUIRE": "At least one scope is required", + "APP_KEYS_SCOPES_REQUIRED": "At least one scope is required", "APP_KEYS_NAME_REQUIRED": "A name is required", "APP_KEYS_PERMISSIONS_EMPTY": "No permissions", "APP_KEYS_COPIED": "Copied Application Key to clipboard", diff --git a/public/assets/locale/es.json b/public/assets/locale/es.json index 58d2cfbd6..6cbc94d2a 100644 --- a/public/assets/locale/es.json +++ b/public/assets/locale/es.json @@ -626,7 +626,7 @@ "STAFF_CARD": "Número de tarjeta", "PASSWORD_REQUIRED": "Se requiere una contraseña válida", "PASSWORD_CONFIRM": "Confirmar contraseña", - "PASSOWRD_MATCH": "Las contraseñas no coinciden" + "PASSWORDS_MATCH": "Las contraseñas no coinciden" }, "DOMAINS": { "SINGULAR": "Dominio", @@ -686,8 +686,8 @@ "AUTHENTICATION_EMPTY": "No hay métodos de autenticación para este dominio", "AUTHENTICATION_SOURCE_TYPE": "Tipo de fuente de autenticación", "AUTHENTICATION_SOURCE_SELECT": "Seleccionar fuente de autenticación...", - "AUTHENTICATION_SAVE_ERROR": "Fuente de autenticación actualizada correctamente", - "AUTHENTICATION_SAVE_SUCCESS": "Error al actualizar la fuente de autenticación. Error: {{ error }}", + "AUTHENTICATION_SAVE_SUCCESS": "Fuente de autenticación actualizada correctamente", + "AUTHENTICATION_SAVE_ERROR": "Error al actualizar la fuente de autenticación. Error: {{ error }}", "AUTHENTICATION_NAME_REQUIRED": "Se requiere un nombre de fuente de autenticación", "OAUTH_SITE": "Sitio", "OAUTH_SITE_PLACEHOLDER": "URL del proveedor SSO", @@ -909,7 +909,7 @@ "APP_KEYS_TTL_MONTH": "30 Días", "APP_KEYS_TTL_90_DAYS": "90 Días", "APP_KEYS_TTL_YEAR": "1 Año", - "APP_KEYS_SCOPES_REQUIRE": "Se requiere al menos un alcance", + "APP_KEYS_SCOPES_REQUIRED": "Se requiere al menos un alcance", "APP_KEYS_NAME_REQUIRED": "Se requiere un nombre", "APP_KEYS_PERMISSIONS_EMPTY": "Sin permisos", "APP_KEYS_COPIED": "Clave de aplicación copiada al portapapeles", diff --git a/public/assets/locale/fr.json b/public/assets/locale/fr.json index 70884d172..d36467645 100644 --- a/public/assets/locale/fr.json +++ b/public/assets/locale/fr.json @@ -626,7 +626,7 @@ "STAFF_CARD": "Numéro de carte", "PASSWORD_REQUIRED": "Un mot de passe valide est requis", "PASSWORD_CONFIRM": "Confirmer le mot de passe", - "PASSOWRD_MATCH": "Les mots de passe ne correspondent pas" + "PASSWORDS_MATCH": "Les mots de passe ne correspondent pas" }, "DOMAINS": { "SINGULAR": "Domaine", @@ -686,8 +686,8 @@ "AUTHENTICATION_EMPTY": "Aucune méthode d'authentification pour ce domaine", "AUTHENTICATION_SOURCE_TYPE": "Type de source d'authentification", "AUTHENTICATION_SOURCE_SELECT": "Sélectionner la source d'authentification...", - "AUTHENTICATION_SAVE_ERROR": "Mise à jour de la source d'authentification réussie", - "AUTHENTICATION_SAVE_SUCCESS": "Échec de la mise à jour de la source d'authentification. Erreur : {{ error }}", + "AUTHENTICATION_SAVE_SUCCESS": "Mise à jour de la source d'authentification réussie", + "AUTHENTICATION_SAVE_ERROR": "Échec de la mise à jour de la source d'authentification. Erreur : {{ error }}", "AUTHENTICATION_NAME_REQUIRED": "Un nom de source d'authentification est requis", "OAUTH_SITE": "Site", "OAUTH_SITE_PLACEHOLDER": "URL du fournisseur SSO", @@ -896,7 +896,7 @@ "APP_KEYS_LAST_DETAILS": "Détails de la dernière clé d'application", "APP_KEYS_FIELD_SCOPES": "Périmètres", "APP_KEYS_FIELD_PERMISSIONS": "Accès", - "APP_KEYS_SCOPES_REQUIRE": "Au moins un périmètre est requis", + "APP_KEYS_SCOPES_REQUIRED": "Au moins un périmètre est requis", "APP_KEYS_NAME_REQUIRED": "Un nom est requis", "APP_KEYS_PERMISSIONS_EMPTY": "Aucune permission", "APP_KEYS_COPIED": "Clé d'application copiée dans le presse-papiers", diff --git a/public/assets/locale/jp.json b/public/assets/locale/jp.json index b700ba625..258da0806 100644 --- a/public/assets/locale/jp.json +++ b/public/assets/locale/jp.json @@ -629,7 +629,7 @@ "STAFF_CARD": "カード番号", "PASSWORD_REQUIRED": "有効なパスワードが必要です", "PASSWORD_CONFIRM": "パスワード(確認)", - "PASSOWRD_MATCH": "パスワードが一致しません" + "PASSWORDS_MATCH": "パスワードが一致しません" }, "DOMAINS": { "SINGULAR": "ドメイン", @@ -689,8 +689,8 @@ "AUTHENTICATION_EMPTY": "このドメインには認証方法がありません", "AUTHENTICATION_SOURCE_TYPE": "認証ソースタイプ", "AUTHENTICATION_SOURCE_SELECT": "認証ソースを選択...", - "AUTHENTICATION_SAVE_ERROR": "認証ソースの更新に成功しました", - "AUTHENTICATION_SAVE_SUCCESS": "認証ソースの更新に失敗しました。エラー:{{ error }}", + "AUTHENTICATION_SAVE_SUCCESS": "認証ソースの更新に成功しました", + "AUTHENTICATION_SAVE_ERROR": "認証ソースの更新に失敗しました。エラー:{{ error }}", "AUTHENTICATION_NAME_REQUIRED": "認証ソース名が必要です", "OAUTH_SITE": "サイト", "OAUTH_SITE_PLACEHOLDER": "SSOプロバイダのURL", @@ -899,7 +899,7 @@ "APP_KEYS_LAST_DETAILS": "最後のアプリケーションキー詳細", "APP_KEYS_FIELD_SCOPES": "スコープ", "APP_KEYS_FIELD_PERMISSIONS": "アクセス権", - "APP_KEYS_SCOPES_REQUIRE": "少なくとも1つのスコープが必要です", + "APP_KEYS_SCOPES_REQUIRED": "少なくとも1つのスコープが必要です", "APP_KEYS_NAME_REQUIRED": "名前が必要です", "APP_KEYS_PERMISSIONS_EMPTY": "権限なし", "APP_KEYS_COPIED": "アプリケーションキーをクリップボードにコピーしました", diff --git a/src/app/admin/broker-form.component.ts b/src/app/admin/broker-form.component.ts index f439be5cc..8a8f65302 100644 --- a/src/app/admin/broker-form.component.ts +++ b/src/app/admin/broker-form.component.ts @@ -305,7 +305,7 @@ import { type="button" matChipRemove [attr.aria-label]=" - 'COMMON.REMOVE_ITEM' + 'COMMON.ITEM_REMOVE' | translate : { item: filter, @@ -408,7 +408,7 @@ export class BrokerFormComponent extends AsyncHandler implements OnInit { public async submit(): Promise { await submit(this.form, async () => { const item = this._data.item; - this.loading.set(i18n(`${this._name}.SAVING`)); + this.loading.set(i18n(`${this._name}_SAVING`)); this._dialog_ref.disableClose = true; const item_json = item.toJSON ? item.toJSON() : item; const form_item = ( @@ -429,7 +429,7 @@ export class BrokerFormComponent extends AsyncHandler implements OnInit { this.loading.set(null); this._dialog_ref.disableClose = false; notifyError( - i18n(`${this._name}.SAVE_ERROR`, { + i18n(`${this._name}_SAVE_ERROR`, { error: await readError(err), }), ); @@ -438,7 +438,7 @@ export class BrokerFormComponent extends AsyncHandler implements OnInit { if (!result) return; this._dialog_ref.disableClose = false; this.event.emit({ reason: 'done', metadata: { item: result } }); - notifySuccess(i18n(`${this._name}.SAVE_SUCCESS`)); + notifySuccess(i18n(`${this._name}_SAVE_SUCCESS`)); this._dialog_ref.close(); }); if (this.form().invalid()) { diff --git a/src/app/admin/staff-tenant-modal.component.ts b/src/app/admin/staff-tenant-modal.component.ts index 4a5b8e757..3e37fb6e0 100644 --- a/src/app/admin/staff-tenant-modal.component.ts +++ b/src/app/admin/staff-tenant-modal.component.ts @@ -312,7 +312,7 @@ export interface StaffTenantModalData { } {{ - 'ADMIN.TENANT_ITEM_REQUIRED' + 'ADMIN.TENANTS_ITEM_REQUIRED' | translate: { name: item.key } }} @@ -591,8 +591,8 @@ export class StaffTenantModalComponent { const tenant = await call.catch((__) => null); this.loading.set(''); this._dialog_ref.disableClose = false; - if (!tenant) return notifyError(i18n('ADMIN.TENANATS_SAVE_ERROR')); - notifySuccess(i18n('ADMIN.TENANATS_SAVE_SUCCESS')); + if (!tenant) return notifyError(i18n('ADMIN.TENANTS_SAVE_ERROR')); + notifySuccess(i18n('ADMIN.TENANTS_SAVE_SUCCESS')); this._dialog_ref.close(); } diff --git a/src/app/overlays/confirm-modal.component.ts b/src/app/overlays/confirm-modal.component.ts index 474c33a50..7024f6cce 100644 --- a/src/app/overlays/confirm-modal.component.ts +++ b/src/app/overlays/confirm-modal.component.ts @@ -181,7 +181,7 @@ export async function openConfirmModal( class="bg-base-200 sticky top-0 z-10 m-2 flex h-14 w-[calc(100%-1rem)] min-w-[20rem] items-center rounded-sm border-none p-2" >

- {{ result() ? result().title : title }} + {{ result() ? result().title : (title | translate) }}

@if (result(); as receipt) { diff --git a/src/app/ui/custom-fields/date-field.component.ts b/src/app/ui/custom-fields/date-field.component.ts index 0874262fc..85d7160eb 100644 --- a/src/app/ui/custom-fields/date-field.component.ts +++ b/src/app/ui/custom-fields/date-field.component.ts @@ -51,7 +51,7 @@ export enum TimezoneDiffRange { {{ date() | date: date_format }} } @else { {{ - 'FORM.DATE_EMPTY' | translate + 'COMMON.DATE_EMPTY' | translate }} } diff --git a/src/app/ui/extension-outlet.component.ts b/src/app/ui/extension-outlet.component.ts index ab4f4f525..ff92f40fe 100644 --- a/src/app/ui/extension-outlet.component.ts +++ b/src/app/ui/extension-outlet.component.ts @@ -187,7 +187,7 @@ export class ExtensionOutletComponent extends AsyncHandler { description: `Metadata from ${this.url()}`, details: typeof message.content === 'object' ? message.content : {}, }); - notifySuccess(i18n('COMMON.METADTA_SAVE')); + notifySuccess(i18n('COMMON.METADATA_SAVE')); this._postMessage({ id: message.id, type: 'backoffice', diff --git a/src/app/ui/forms/ldap-source-form.component.ts b/src/app/ui/forms/ldap-source-form.component.ts index 9f781271c..cf6cb4bf1 100644 --- a/src/app/ui/forms/ldap-source-form.component.ts +++ b/src/app/ui/forms/ldap-source-form.component.ts @@ -32,7 +32,7 @@ import { TranslatePipe } from '../translate.pipe'; @if (form().name().invalid()) { {{ - 'DOMAINS.AUTHENTICATION_NAME_REQUIRE' + 'DOMAINS.AUTHENTICATION_NAME_REQUIRED' | translate }} diff --git a/src/app/ui/forms/oauth-source-form.component.ts b/src/app/ui/forms/oauth-source-form.component.ts index 9b52a664b..1f6c5daa1 100644 --- a/src/app/ui/forms/oauth-source-form.component.ts +++ b/src/app/ui/forms/oauth-source-form.component.ts @@ -43,7 +43,7 @@ import { TranslatePipe } from '../translate.pipe'; @if (form().name().invalid()) { {{ - 'DOMAINS.AUTHENTICATION_NAME_REQUIRE' + 'DOMAINS.AUTHENTICATION_NAME_REQUIRED' | translate }} diff --git a/src/app/ui/forms/saml-source-form.component.ts b/src/app/ui/forms/saml-source-form.component.ts index 43d47b788..0f3a28606 100644 --- a/src/app/ui/forms/saml-source-form.component.ts +++ b/src/app/ui/forms/saml-source-form.component.ts @@ -46,7 +46,7 @@ import { TranslatePipe } from '../translate.pipe'; @if (form().name().invalid()) { {{ - 'DOMAINS.AUTHENTICATION_NAME_REQUIRE' + 'DOMAINS.AUTHENTICATION_NAME_REQUIRED' | translate }} diff --git a/src/app/ui/forms/trigger-action-modal.component.ts b/src/app/ui/forms/trigger-action-modal.component.ts index cfbd2d052..9acac7be8 100644 --- a/src/app/ui/forms/trigger-action-modal.component.ts +++ b/src/app/ui/forms/trigger-action-modal.component.ts @@ -126,7 +126,7 @@ export interface TriggerActionModalData { type="button" matChipRemove [attr.aria-label]=" - 'COMMON.REMOVE_ITEM' + 'COMMON.ITEM_REMOVE' | translate : { item: item, diff --git a/src/app/zones/zone-triggers.component.ts b/src/app/zones/zone-triggers.component.ts index 7de41aa86..d78011402 100644 --- a/src/app/zones/zone-triggers.component.ts +++ b/src/app/zones/zone-triggers.component.ts @@ -93,7 +93,7 @@ import { ZonesStateService } from './zones-state.service'; default error matRipple - [matTooltip]="'TRIGGERS.REMOVE'" + [matTooltip]="'TRIGGERS.REMOVE' | translate" (click)="deleteTrigger(row)" > delete From 47cfd313aa68d361483b49f46e0f3ee3d420f8c5 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 11:06:12 +1000 Subject: [PATCH 02/12] fix: correct small logic and markup bugs - function-argument: guard against a missing method or order list - binding directive: test the model value, not the signal object - counter: keep the last valid value on non-numeric input - saml-source-form: give each mapping update its own timeout - user-menu-tooltip: use rel="noopener noreferrer" - simple-table: render the table as a div, not a button - timezone-helpers: parse longOffset so zones like Sydney and Kolkata give correct offsets - item.service: read route segments with parseUrl, so query params do not leak into the resource type or item id - driver-form: drop a date comparison that was always true - item-sidebar: load groups on NavigationEnd only, and stop the parent walk on cyclic data - settings-field: stop recreating Monaco on window resize, and show an empty editor for null values - new-terminal: escape log HTML before adding colour spans Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/common/item.service.ts | 17 ++++--- src/app/common/timezone-helpers.ts | 42 +++++++++-------- src/app/drivers/driver-form.component.ts | 5 +- src/app/ui/binding.directive.ts | 6 +-- src/app/ui/counter.component.ts | 7 +++ .../custom-fields/settings-field.component.ts | 3 +- .../function-argument.component.ts | 2 +- .../ui/forms/saml-source-form.component.ts | 4 +- src/app/ui/item-sidebar.component.ts | 16 ++++++- src/app/ui/new-terminal.component.ts | 42 ++++++++++------- src/app/ui/simple-table.component.ts | 6 +-- src/app/ui/user-menu-tooltip.component.ts | 2 +- src/tests/common/item-group-switch.spec.ts | 5 +- src/tests/common/timezone-helpers.spec.ts | 31 ++++++------ .../components/new-terminal.component.spec.ts | 47 ++++++++++++------- src/tests/users/user-lifecycle.spec.ts | 4 +- 16 files changed, 140 insertions(+), 99 deletions(-) diff --git a/src/app/common/item.service.ts b/src/app/common/item.service.ts index 46602babc..497073cf8 100644 --- a/src/app/common/item.service.ts +++ b/src/app/common/item.service.ts @@ -212,8 +212,7 @@ export class ActiveItemService extends AsyncHandler { (!this.active_item || this.active_item.id !== id) && id.length > 2 ) { - const url = this._router.url.split('/'); - this._type = url[1] as ResourceType; + this._type = (this._urlSegments()[0] ?? '') as ResourceType; if (!this.type) return this.timeout('setItem', () => this.setItem(id)); this._loading.set(true); @@ -595,10 +594,16 @@ export class ActiveItemService extends AsyncHandler { } } + /** Path segments of the current route, without query params or fragment */ + private _urlSegments(): string[] { + const tree = this._router.parseUrl(this._router.url); + return tree.root.children['primary']?.segments.map((_) => _.path) ?? []; + } + private async updateType() { - const url = this._router.url.split('/'); + const url = this._urlSegments(); const old_type = this._type; - this._type = url[1] as ResourceType; + this._type = (url[0] ?? '') as ResourceType; if (old_type !== this._type) { this._include_deleted.set(false); this._list_version++; @@ -612,8 +617,8 @@ export class ActiveItemService extends AsyncHandler { this._show_options.set(true); this.updateList(); } - if (this._type !== 'admin' && url[2]) { - await this.setItem(url[2]); + if (this._type !== 'admin' && url[1]) { + await this.setItem(url[1]); } if (this._type === 'admin') { this._active_item.set({ name: 'PlaceOS Admin' } as PlaceResource); diff --git a/src/app/common/timezone-helpers.ts b/src/app/common/timezone-helpers.ts index acdb9eff5..da240b2f8 100644 --- a/src/app/common/timezone-helpers.ts +++ b/src/app/common/timezone-helpers.ts @@ -7,7 +7,7 @@ export function getTimezoneOffsetString(tz: string) { const offset = getTimezoneOffsetInMinutes(tz); const hours = Math.floor(Math.abs(offset) / 60); const minutes = Math.abs(offset) % 60; - const output = `${offset > 0 ? '+' : '-'}${padLength(hours, 2)}${padLength( + const output = `${offset >= 0 ? '+' : '-'}${padLength(hours, 2)}${padLength( minutes, 2, )}`; @@ -15,28 +15,30 @@ export function getTimezoneOffsetString(tz: string) { return output; } +/** + * Offset of the given timezone from UTC in minutes, at the given date. + * Positive values are ahead of UTC, e.g. `Asia/Kolkata` is `330`. + */ export function getTimezoneOffsetInMinutes(timeZone, date = new Date()) { - const options: Intl.DateTimeFormatOptions = { + // `longOffset` always gives a numeric offset (e.g. "GMT+05:30"), where + // `short` gives an abbreviation (e.g. "AEST") for many zones. + // A fixed locale keeps the digits and format predictable. + const formatter = new Intl.DateTimeFormat('en-US', { timeZone, - hour12: false, - timeZoneName: 'short', - }; - const formatter = new Intl.DateTimeFormat([], options); - const parts = formatter.formatToParts(date); + timeZoneName: 'longOffset', + }); + const tz_offset_part = formatter + .formatToParts(date) + .find((part) => part.type === 'timeZoneName'); + // UTC is given as plain "GMT", which does not match + const offset_match = tz_offset_part?.value.match( + /GMT([+-])(\d{2}):(\d{2})/, + ); + if (!offset_match) return 0; - // Find the timeZoneName part which contains the GMT offset - const tzOffsetPart = parts.find((part) => part.type === 'timeZoneName'); - const tzOffsetString = tzOffsetPart ? tzOffsetPart.value : 'GMT'; - - // Match the offset from the string (e.g., "GMT+0530") - const offsetMatch = tzOffsetString.match(/GMT([+-])(\d{1,2})(\d{2})?/); - if (!offsetMatch) { - return 0; // If no match, assume UTC (offset 0) - } - - const sign = offsetMatch[1] === '+' ? 1 : -1; - const hours = parseInt(offsetMatch[2], 10); - const minutes = offsetMatch[3] ? parseInt(offsetMatch[3], 10) : 0; + const sign = offset_match[1] === '+' ? 1 : -1; + const hours = parseInt(offset_match[2], 10); + const minutes = parseInt(offset_match[3], 10); return sign * (hours * 60 + minutes); } diff --git a/src/app/drivers/driver-form.component.ts b/src/app/drivers/driver-form.component.ts index 1d662b301..ae7eb749c 100644 --- a/src/app/drivers/driver-form.component.ts +++ b/src/app/drivers/driver-form.component.ts @@ -33,7 +33,6 @@ import { } from '@placeos/ts-client'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { format, isAfter, subMinutes } from 'date-fns'; import { AsyncHandler } from '../common/async-handler.class'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -604,9 +603,7 @@ export class DriverFormComponent extends AsyncHandler implements OnInit { return list.map((item) => ({ id: item.commit, name: `${item.subject}`, - extra: isAfter(item.date, subMinutes(item.date, 1)) - ? this._date_pipe.transform(item.date.valueOf()) - : format(item.date, 'dd MMM yyyy'), + extra: this._date_pipe.transform(item.date), })); } finally { this._setLoadingType('commits', false); diff --git a/src/app/ui/binding.directive.ts b/src/app/ui/binding.directive.ts index 44d7234fc..5d4bbd9e5 100644 --- a/src/app/ui/binding.directive.ts +++ b/src/app/ui/binding.directive.ts @@ -69,11 +69,7 @@ export class BindingDirective this.bindVariable(); } const model = this.model(); - if ( - changes.modelInput && - this._old_model !== model && - this.model != null - ) { + if (changes.modelInput && this._old_model !== model && model != null) { this._old_model = model; this.execute(); } diff --git a/src/app/ui/counter.component.ts b/src/app/ui/counter.component.ts index 7b12400a1..7fb833e63 100644 --- a/src/app/ui/counter.component.ts +++ b/src/app/ui/counter.component.ts @@ -91,6 +91,8 @@ export class CounterComponent implements ControlValueAccessor { /** Whether control key is being held by the user */ public ctrl_key: boolean; public focused = false; + /** Last valid value. Restored when the user enters a non-numeric value */ + private _last_value: number | null = null; /** Form control on change handler */ private _onChange: (_: number) => void; @@ -153,6 +155,9 @@ export class CounterComponent implements ControlValueAccessor { */ public setValue(new_value: number): void { if (this.disabled()) return; + if (!Number.isFinite(new_value)) { + new_value = this._last_value ?? this.min(); + } if (new_value < this.min()) new_value = this.min(); if (new_value > this.max()) new_value = this.max(); if ((new_value / this.step()) % 1 !== 0) { @@ -160,6 +165,7 @@ export class CounterComponent implements ControlValueAccessor { Math.round(new_value * (1 / this.step())) / (1 / this.step()); } this.value.set(new_value); + this._last_value = new_value; /* istanbul ignore else */ if (this._onChange) { this._onChange(new_value); @@ -173,6 +179,7 @@ export class CounterComponent implements ControlValueAccessor { */ public writeValue(value: number) { this.value.set(value); + this._last_value = value; } /* istanbul ignore next */ diff --git a/src/app/ui/custom-fields/settings-field.component.ts b/src/app/ui/custom-fields/settings-field.component.ts index 147a1db0f..dbc234864 100644 --- a/src/app/ui/custom-fields/settings-field.component.ts +++ b/src/app/ui/custom-fields/settings-field.component.ts @@ -37,7 +37,6 @@ import { TranslatePipe } from '../translate.pipe'; [class.h-full]="fill()" [class.min-h-0]="fill()" editor - (window:resize)="resizeEditor()" #editor > `, @@ -172,7 +171,7 @@ export class SettingsFieldComponent * @param value The new value for the component */ public writeValue(value: string) { - this.settings_string = `${value}`; + this.settings_string = `${value ?? ''}`; if (this.editor) { this.editor.getModel().detectIndentation(true, 4); if (this.readonly()) { diff --git a/src/app/ui/custom-fields/system-exec/function-argument.component.ts b/src/app/ui/custom-fields/system-exec/function-argument.component.ts index b3623bd3c..a82a87229 100644 --- a/src/app/ui/custom-fields/system-exec/function-argument.component.ts +++ b/src/app/ui/custom-fields/system-exec/function-argument.component.ts @@ -139,7 +139,7 @@ export class FunctionArgumentComponent public loadForm() { const method = this.method(); - if (!method && !method.order.length) return; + if (!method || !method.order?.length) return; const form_values: HashMap = {}; for (const prop in method.params) { const prop_details = method.params[prop] as unknown as Record< diff --git a/src/app/ui/forms/saml-source-form.component.ts b/src/app/ui/forms/saml-source-form.component.ts index 0f3a28606..d79727cb8 100644 --- a/src/app/ui/forms/saml-source-form.component.ts +++ b/src/app/ui/forms/saml-source-form.component.ts @@ -444,7 +444,7 @@ export class SamlSourceFormComponent extends AsyncHandler implements OnChanges { mappings: { name: string; mappings: string }[], ) { this.timeout( - 'mappings', + 'attribute_statements', () => { const map: HashMap = {}; for (const pair of mappings) { @@ -467,7 +467,7 @@ export class SamlSourceFormComponent extends AsyncHandler implements OnChanges { */ public updateRuntimeParams(mappings: { name: string; mapping: string }[]) { this.timeout( - 'mappings', + 'runtime_params', () => { const map: HashMap = {}; for (const pair of mappings) { diff --git a/src/app/ui/item-sidebar.component.ts b/src/app/ui/item-sidebar.component.ts index ca829bb19..d1445df11 100644 --- a/src/app/ui/item-sidebar.component.ts +++ b/src/app/ui/item-sidebar.component.ts @@ -18,7 +18,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { Router, RouterModule } from '@angular/router'; +import { NavigationEnd, Router, RouterModule } from '@angular/router'; import { PlaceDriverRole, PlaceGroup, @@ -28,6 +28,7 @@ import { PlaceZone, queryGroups, } from '@placeos/ts-client'; +import { filter } from 'rxjs'; import { AsyncHandler } from '../common/async-handler.class'; import { ActiveItemService } from '../common/item.service'; import { toSignal } from '../common/signals'; @@ -381,6 +382,11 @@ export class ItemSidebarComponent private readonly _route_change = toSignal(this._router.events, { initialValue: null, }); + /** Changes once per completed navigation, not for every router event */ + private readonly _navigation_end = toSignal( + this._router.events.pipe(filter((e) => e instanceof NavigationEnd)), + { initialValue: null }, + ); public readonly title = input('Systems'); public readonly route = input('systems'); @@ -461,6 +467,9 @@ export class ItemSidebarComponent effect(() => { this._route_change(); this.subroute.set(this._router.url.split('/')[3] || ''); + }); + effect(() => { + this._navigation_end(); if (this.route() === 'groups') void this.loadGroupHierarchy(); }); effect(() => { @@ -628,8 +637,11 @@ export class ItemSidebarComponent private expandGroupPath(group_id: string) { const path: string[] = []; + // Visited set stops the walk if the parent links contain a cycle + const visited = new Set([group_id]); let group = this.findGroup(group_id); - while (group?.parent_id) { + while (group?.parent_id && !visited.has(group.parent_id)) { + visited.add(group.parent_id); path.unshift(group.parent_id); group = this.findGroup(group.parent_id); } diff --git a/src/app/ui/new-terminal.component.ts b/src/app/ui/new-terminal.component.ts index 6e2cad21c..bfcfe52d8 100644 --- a/src/app/ui/new-terminal.component.ts +++ b/src/app/ui/new-terminal.component.ts @@ -3,7 +3,6 @@ import { computed, effect, ElementRef, - inject, input, model, OnChanges, @@ -14,8 +13,6 @@ import { import { FormsModule } from '@angular/forms'; import { AsyncHandler } from '../common/async-handler.class'; import { IconComponent } from './icon.component'; -import { SafePipe } from './pipes/safe.pipe'; -import { SanitizePipe } from './pipes/sanitise.pipe'; import { TranslatePipe } from './translate.pipe'; import { VirtualScrollComponent } from './virtual-scroll.component'; @@ -23,10 +20,7 @@ import { VirtualScrollComponent } from './virtual-scroll.component'; selector: 'new-terminal', template: ` -
+
([]); public readonly search = model(''); public readonly resize = input(0); @@ -172,13 +162,15 @@ export class NewTerminalComponent extends AsyncHandler implements OnChanges { ); } + /** + * Wrap a raw log line to the terminal width and convert it to HTML. + * The line is split before escaping so that entities are not cut in half. + */ private _formatLineWithHTML(line: string) { - const sanitized_line = this._sanitize_pipe.transform(line).toString(); const max_length = this.line_length(); - if (sanitized_line.length <= max_length) - return [setTermColorsForLine(sanitized_line)]; + if (line.length <= max_length) return [formatTermLine(line)]; const lines = []; - let remaining = sanitized_line; + let remaining = line; let count = 0; while (count < 128 && remaining.length > 0) { let break_at = max_length; @@ -196,7 +188,7 @@ export class NewTerminalComponent extends AsyncHandler implements OnChanges { lines.push( `${ count > 0 ? '    ' : '' - }${setTermColorsForLine(segment)}`, + }${formatTermLine(segment)}`, ); count += 1; } @@ -226,8 +218,22 @@ export class NewTerminalComponent extends AsyncHandler implements OnChanges { } } -function setTermColorsForLine(line: string) { - return `${line.replace( +const HTML_ENTITIES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +/** Escape HTML so log text renders as text, not markup */ +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (char) => HTML_ENTITIES[char]); +} + +/** Escape a log line, then convert ANSI colour codes to styled spans */ +function formatTermLine(line: string) { + return `${escapeHtml(line).replace( // eslint-disable-next-line no-control-regex /\u001b?\[([0-9]*)m/g, '', diff --git a/src/app/ui/simple-table.component.ts b/src/app/ui/simple-table.component.ts index 588b4b4dd..87af4d2dd 100644 --- a/src/app/ui/simple-table.component.ts +++ b/src/app/ui/simple-table.component.ts @@ -36,7 +36,7 @@ type TableData = T[] | Signal | Subscribable; @Component({ selector: 'simple-table', template: ` -
} - + @if (selectable()) {
= T[] | Signal | Subscribable; styles: [ ` :host, - :host > button { + :host > [role='table'] { min-width: 100%; overflow: hidden; } diff --git a/src/app/ui/user-menu-tooltip.component.ts b/src/app/ui/user-menu-tooltip.component.ts index be3637151..cdc1af651 100644 --- a/src/app/ui/user-menu-tooltip.component.ts +++ b/src/app/ui/user-menu-tooltip.component.ts @@ -90,7 +90,7 @@ import { TranslatePipe } from './translate.pipe'; matRipple type="button" target="_blank" - ref="noopener noreferer" + rel="noopener noreferrer" report [href]="github_link | safe: 'url'" class="gap-2" diff --git a/src/tests/common/item-group-switch.spec.ts b/src/tests/common/item-group-switch.spec.ts index e619b8c2a..cefdbf8f2 100644 --- a/src/tests/common/item-group-switch.spec.ts +++ b/src/tests/common/item-group-switch.spec.ts @@ -1,7 +1,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; -import { NavigationEnd, Router } from '@angular/router'; +import { DefaultUrlSerializer, NavigationEnd, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { afterEach, expect, it, vi } from 'vitest'; import { HotkeysService } from '../../app/common/hotkeys.service'; @@ -68,6 +68,7 @@ it('clears the active resource and ignores old list and detail responses after s const router = { url: '/systems/-', events, + parseUrl: (url: string) => new DefaultUrlSerializer().parse(url), navigate: vi.fn(async () => { router.url = '/systems/-'; events.next(new NavigationEnd(1, router.url, router.url)); @@ -131,6 +132,8 @@ it('reloads users with the deleted filter and ignores an older response', async useValue: { url: '/users/-', events: new Subject(), + parseUrl: (url: string) => + new DefaultUrlSerializer().parse(url), }, }, { provide: MatDialog, useValue: {} }, diff --git a/src/tests/common/timezone-helpers.spec.ts b/src/tests/common/timezone-helpers.spec.ts index 2cf643350..ac59fb203 100644 --- a/src/tests/common/timezone-helpers.spec.ts +++ b/src/tests/common/timezone-helpers.spec.ts @@ -10,10 +10,8 @@ describe('timezone-helpers.ts utilities', () => { expect(getTimezoneOffsetInMinutes('UTC')).toBe(0); }); - it('should return a number for valid timezones', () => { - // jsdom may not fully support timezone parsing, so we test the return type - const offset = getTimezoneOffsetInMinutes('Asia/Kolkata'); - expect(typeof offset).toBe('number'); + it('should return the half hour offset for Asia/Kolkata', () => { + expect(getTimezoneOffsetInMinutes('Asia/Kolkata')).toBe(330); }); it('should return a number for western timezones', () => { @@ -21,9 +19,15 @@ describe('timezone-helpers.ts utilities', () => { expect(typeof offset).toBe('number'); }); - it('should handle Australia/Sydney', () => { - const offset = getTimezoneOffsetInMinutes('Australia/Sydney'); - expect(typeof offset).toBe('number'); + it('should handle daylight saving for Australia/Sydney', () => { + const summer = new Date(Date.UTC(2024, 0, 15)); + const winter = new Date(Date.UTC(2024, 6, 15)); + expect(getTimezoneOffsetInMinutes('Australia/Sydney', summer)).toBe( + 660, + ); + expect(getTimezoneOffsetInMinutes('Australia/Sydney', winter)).toBe( + 600, + ); }); it('should accept custom date parameter', () => { @@ -53,17 +57,12 @@ describe('timezone-helpers.ts utilities', () => { }); describe('getTimezoneOffsetString', () => { - it('should return correct format for UTC', () => { - const result = getTimezoneOffsetString('UTC'); - // Should be a 5 character string like +0000 or -0000 - expect(result.length).toBe(5); - expect(result).toMatch(/^[+-]\d{4}$/); + it('should return +0000 for UTC', () => { + expect(getTimezoneOffsetString('UTC')).toBe('+0000'); }); - it('should return 5 character string', () => { - const result = getTimezoneOffsetString('Asia/Kolkata'); - expect(result.length).toBe(5); - expect(result).toMatch(/^[+-]\d{4}$/); + it('should include the minutes for Asia/Kolkata', () => { + expect(getTimezoneOffsetString('Asia/Kolkata')).toBe('+0530'); }); it('should return formatted string for western timezones', () => { diff --git a/src/tests/ui/components/new-terminal.component.spec.ts b/src/tests/ui/components/new-terminal.component.spec.ts index 128220a10..a00fa1687 100644 --- a/src/tests/ui/components/new-terminal.component.spec.ts +++ b/src/tests/ui/components/new-terminal.component.spec.ts @@ -1,48 +1,61 @@ import { TestBed } from '@angular/core/testing'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { NewTerminalComponent } from '../../../app/ui/new-terminal.component'; -import { SanitizePipe } from '../../../app/ui/pipes/sanitise.pipe'; vi.mock('../../../app/ui/translate.pipe', () => ({ TranslatePipe: class {} })); +function createTerminal() { + TestBed.overrideComponent(NewTerminalComponent, { + set: { template: '', imports: [] }, + }); + const fixture = TestBed.createComponent(NewTerminalComponent); + return { fixture, terminal: fixture.componentInstance }; +} + describe('terminal formatting', () => { + afterEach(() => vi.restoreAllMocks()); + it('formats only new messages, reuses filtered messages, and rewraps on resize', () => { - const transform = vi.fn((value: string) => value); - TestBed.overrideComponent(NewTerminalComponent, { - set: { - template: '', - imports: [], - providers: [{ provide: SanitizePipe, useValue: { transform } }], - }, - }); - const fixture = TestBed.createComponent(NewTerminalComponent); - const terminal = fixture.componentInstance; + const format = vi.spyOn( + NewTerminalComponent.prototype as any, + '_formatLineWithHTML', + ); + const { fixture, terminal } = createTerminal(); fixture.componentRef.setInput('lines', [ 'first message', 'second message', ]); expect(terminal.displayed_lines()).toHaveLength(2); - expect(transform).toHaveBeenCalledTimes(2); + expect(format).toHaveBeenCalledTimes(2); fixture.componentRef.setInput('lines', [ 'first message', 'second message', 'third message', ]); expect(terminal.displayed_lines()).toHaveLength(3); - expect(transform).toHaveBeenCalledTimes(3); + expect(format).toHaveBeenCalledTimes(3); terminal.search.set('second'); expect(terminal.displayed_lines()).toHaveLength(1); terminal.search.set(''); expect(terminal.displayed_lines()).toHaveLength(3); - expect(transform).toHaveBeenCalledTimes(3); + expect(format).toHaveBeenCalledTimes(3); terminal.line_length.set(8); expect(terminal.displayed_lines().length).toBeGreaterThan(3); - expect(transform).toHaveBeenCalledTimes(6); + expect(format).toHaveBeenCalledTimes(6); fixture.componentRef.setInput('lines', []); expect(terminal.displayed_lines()).toEqual([]); fixture.componentRef.setInput('lines', ['first message']); terminal.displayed_lines(); - expect(transform).toHaveBeenCalledTimes(7); + expect(format).toHaveBeenCalledTimes(7); + fixture.destroy(); + }); + + it('shows HTML in log lines as text and keeps ANSI colours', () => { + const { fixture, terminal } = createTerminal(); + fixture.componentRef.setInput('lines', ['bold \u001b[31mred']); + expect(terminal.displayed_lines()).toEqual([ + '<b>bold</b> red', + ]); fixture.destroy(); }); }); diff --git a/src/tests/users/user-lifecycle.spec.ts b/src/tests/users/user-lifecycle.spec.ts index 76f6b7138..bbffdd237 100644 --- a/src/tests/users/user-lifecycle.spec.ts +++ b/src/tests/users/user-lifecycle.spec.ts @@ -1,7 +1,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; -import { Router } from '@angular/router'; +import { DefaultUrlSerializer, Router } from '@angular/router'; import { PlaceUser, removeUser, reviveUser } from '@placeos/ts-client'; import { Subject } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -86,6 +86,8 @@ beforeEach(async () => { useValue: { url: '/users/-', events: new Subject(), + parseUrl: (url: string) => + new DefaultUrlSerializer().parse(url), navigate: vi.fn(), }, }, From b6094f4f732025e196eac4d8395852d1e7f283eb Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 30 Sep 2026 11:09:11 +1000 Subject: [PATCH 03/12] fix(a11y): link form labels to inputs and label password toggles Add ids so label for values match their inputs in the user, broker, domain, application, zone and metadata forms. Use aria-labelledby for mat-select fields. Replace copy-pasted chip grid aria-labels. Make the user form show-password toggles real buttons with an aria-label. Co-Authored-By: Claude Opus 5.5 (1M context) --- public/assets/locale/en-AU.json | 1 + src/app/admin/broker-form.component.ts | 22 ++++++++-- src/app/domains/application-form.component.ts | 4 ++ src/app/domains/domain-form.component.ts | 9 +++- .../metadata-details-modal.component.ts | 8 +++- src/app/systems/system-form.component.ts | 4 +- src/app/users/user-form.component.ts | 44 ++++++++++++++----- src/app/zones/zone-form.component.ts | 8 ++++ 8 files changed, 82 insertions(+), 18 deletions(-) diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index 776266469..9aea4d550 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -73,6 +73,7 @@ "ITEM_ADD_SELECTED": "Add selected", "PROCESSING_REQUEST": "Processing request...", "PASSWORD": "Password", + "PASSWORD_TOGGLE": "Show password", "IMAGE_ADD_URL": "Add image via URL", "IMAGE_UPLOADS": "Upload Image(s)", "DROP_UPLOAD_MSG": "Drop files to upload them to the cloud", diff --git a/src/app/admin/broker-form.component.ts b/src/app/admin/broker-form.component.ts index 8a8f65302..ade3a8667 100644 --- a/src/app/admin/broker-form.component.ts +++ b/src/app/admin/broker-form.component.ts @@ -62,6 +62,7 @@ import { - + - + @for (item of editors; track item) {
{{ item }}
@@ -78,6 +80,7 @@ export interface MetadataDetailsModalData { }
diff --git a/src/app/systems/system-form.component.ts b/src/app/systems/system-form.component.ts index 57d0cead3..95964308e 100644 --- a/src/app/systems/system-form.component.ts +++ b/src/app/systems/system-form.component.ts @@ -285,7 +285,9 @@ import { @for (item of feature_list(); track item) {
-
-