diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json
index 531cf1b6a..49fe9fd03 100644
--- a/public/assets/locale/en-AU.json
+++ b/public/assets/locale/en-AU.json
@@ -48,7 +48,21 @@
"NONE": "None",
"CANCEL": "Cancel",
"CLOSE": "Close",
+ "MENU": "Menu",
+ "CLEAR_VALUE": "Clear",
+ "REMOVE": "Remove",
+ "VIEW": "View",
+ "MENU_EXPAND": "Expand menu",
+ "MENU_COLLAPSE": "Collapse menu",
+ "MORE_ACTIONS": "More actions",
+ "FILTER": "Filter",
+ "INCREASE": "Increase",
+ "DECREASE": "Decrease",
+ "DISCARD": "Discard",
+ "DISCARD_CHANGES": "Discard changes?",
+ "DISCARD_CHANGES_MSG": "You have unsaved changes. Close and lose them?",
"CONFIRM": "Confirm",
+ "DELETE": "Delete",
"ITEM_REMOVE": "Remove {{ item }}",
"FIELD_REQUIRED": "This field is required",
"DATE_EMPTY": "No date selected",
@@ -67,6 +81,7 @@
"COPY_LINK": "Copy Link",
"RETRY": "Retry",
"EDITOR_LOAD_ERROR": "Could not load the editor. Check your connection and retry.",
+ "LOAD_ERROR": "Failed to load data. Error: {{ error }}",
"IMAGES": "Images",
"NEW_ITEM": "New item",
"ITEM_ADD": "Add {{ item }}",
@@ -172,7 +187,7 @@
"GIT_PULL_LATEST": "Pull latest commit",
"COMMIT_HASH_COPIED": "Commit hash copied to clipboard",
"DESCRIPTION_EMPTY": "No description",
- "INVALID_FIELDS": "Some fields are invalid. [{{ field_list }}]",
+ "INVALID_FIELDS": "Check these fields: {{ field_list }}",
"BULK_ADD": "Bulk add {{ type }}",
"BULK_DOWNLOAD": "Download Template",
"BULK_DROP_MSG": "Select or drop TSV file to begin",
@@ -244,7 +259,7 @@
"ADD": "Add system",
"EDIT": "Edit system",
"DELETE": "Delete system",
- "DELETE_MSG": "
Are you sure you want delete this system({{ name }} )?
Deleting this will immediately delete modules that are not in another system
",
+ "DELETE_MSG": "Delete the system {{ name }} ?
Modules that are not in another system are deleted immediately .
",
"DELETE_LOADING": "Deleting system...",
"DELETE_SUCCESS": "Successfully deleted system.",
"DELETE_ERROR": "Failed to delete system. Error: {{ error }}",
@@ -332,7 +347,7 @@
"SEARCH": "Search for modules...",
"ADD": "Add module",
"DELETE": "Delete module",
- "DELETE_MSG": "Are you sure you want delete this module?
Deleting this will module immediately remove it from any system associated with it
",
+ "DELETE_MSG": "Delete the module {{ name }} ?
It is removed immediately from every system that uses it.
",
"DELETE_LOADING": "Deleting module...",
"DELETE_SUCCESS": "Successfully deleted module.",
"DELETE_ERROR": "Failed to delete module. Error: {{ error }}",
@@ -389,7 +404,7 @@
"ADD": "Add zone",
"EDIT": "Edit zone",
"DELETE": "Delete zone",
- "DELETE_MSG": "Are you sure you want delete this zone?
Zones beneath it, its triggers, metadata and settings are removed immediately . Systems in this zone are kept unless you also remove associated resources.
",
+ "DELETE_MSG": "Delete the zone {{ name }} ?
Zones beneath it, its triggers, metadata and settings are removed immediately . Systems in this zone are kept unless you also remove associated resources.
",
"DELETE_LOADING": "Deleting zone...",
"DELETE_SUCCESS": "Successfully deleted zone.",
"DELETE_ERROR": "Failed to delete zone. Error: {{ error }}",
@@ -463,7 +478,7 @@
"ADD": "Add driver",
"EDIT": "Edit driver",
"DELETE": "Delete driver",
- "DELETE_MSG": "Are you sure you want delete this driver?
",
+ "DELETE_MSG": "Delete the driver {{ name }} ?
",
"DELETE_LOADING": "Deleting driver...",
"DELETE_SUCCESS": "Successfully deleted driver.",
"DELETE_ERROR": "Failed to delete driver. Error: {{ error }}",
@@ -476,6 +491,7 @@
"UPDATE": "Update Drivers",
"UPDATE_COUNT": "{{ count }} updates available",
"UPDATE_SELECTED": "Update selected drivers",
+ "UPDATING": "Updating drivers...",
"NO_UPDATES": "No drivers require updating.",
"TAB_ABOUT": "About",
"TAB_DOCS": "Readme",
@@ -518,7 +534,7 @@
"DELETE": "Delete repository",
"REMOVE": "Remove repository",
"NEW": "New repository",
- "DELETE_MSG": "Deleting this repository will immediately remove assoicated drivers and modules
",
+ "DELETE_MSG": "Delete the repository {{ name }} ?
Its drivers and their modules are removed immediately .
",
"DELETE_LOADING": "Deleting repository...",
"DELETE_SUCCESS": "Successfully deleted repository.",
"DELETE_ERROR": "Failed to delete repository. Error: {{ error }}",
@@ -567,7 +583,7 @@
"DELETE": "Delete trigger",
"DELETE_INSTANCE": "Delete instance",
"REMOVE": "Remove trigger",
- "DELETE_MSG": "Are you sure you want delete this trigger?
Deleting this trigger will immediately remove it from all associated systems and zones
",
+ "DELETE_MSG": "Delete the trigger {{ name }} ?
It is removed immediately from all systems and zones that use it.
",
"DELETE_LOADING": "Deleting trigger...",
"DELETE_SUCCESS": "Successfully deleted trigger.",
"DELETE_ERROR": "Failed to delete trigger. Error: {{ error }}",
@@ -684,7 +700,7 @@
"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_MSG": "Delete the user {{ name }} ?
The user is removed within 24 hours.
",
"DELETE_LOADING": "Deleting user...",
"DELETE_SUCCESS": "Successfully deleted user.",
"DELETE_ERROR": "Failed to delete user. Error: {{ error }}",
@@ -748,7 +764,7 @@
"NEW": "New group",
"REMOVE": "Remove group",
"DELETE": "Delete group",
- "DELETE_MSG": "Are you sure you want delete this group?
The group will be deleted immediately.
",
+ "DELETE_MSG": "Delete the group {{ name }} ?
The group is deleted immediately .
",
"DELETE_LOADING": "Deleting group...",
"DELETE_SUCCESS": "Successfully deleted group.",
"DELETE_ERROR": "Failed to delete group. Error: {{ error }}",
@@ -842,7 +858,7 @@
"BULK": "Bulk add domains",
"REMOVE": "Remove domain",
"DELETE": "Delete domain",
- "DELETE_MSG": "Are you sure you want delete this domain?
Its users, auth sources and groups are removed immediately .
",
+ "DELETE_MSG": "Delete the domain {{ name }} ?
Its users, auth sources and groups are removed immediately .
",
"DELETE_LOADING": "Deleting domain...",
"DELETE_SUCCESS": "Successfully deleted domain.",
"DELETE_ERROR": "Failed to delete domain. Error: {{ error }}",
diff --git a/public/assets/locale/en-GB.json b/public/assets/locale/en-GB.json
index 0d0cca2ad..54f758772 100644
--- a/public/assets/locale/en-GB.json
+++ b/public/assets/locale/en-GB.json
@@ -80,7 +80,7 @@
"METADATA_REMOVE": "Remove Metadata block",
"METADATA_EMPTY": "No Metadata blocks found",
"METADATA_SAVE": "Successfully saved metadata",
- "TOTAL_ITEMS": "{{ count }} items(s)",
+ "TOTAL_ITEMS": "{{ count }} item(s)",
"ITEM_ERROR": "Failed to save item. Error: {{ error }}",
"ITEM_SAVE": "Successfully saved item",
"UPDATE_AVAILABLE": "Update Available",
@@ -152,7 +152,7 @@
"GIT_COMMIT": "Commit hash",
"GIT_PULL_LATEST": "Pull latest commit",
"DESCRIPTION_EMPTY": "No description",
- "INVALID_FIELDS": "Some fields are invalid. [{{ field_list }}]",
+ "INVALID_FIELDS": "Check these fields: {{ field_list }}",
"BULK_ADD": "Bulk add {{ type }}",
"BULK_DOWNLOAD": "Download Template",
"BULK_DROP_MSG": "Select or drop TSV file to begin",
@@ -213,7 +213,7 @@
"ADD": "Add system",
"EDIT": "Edit system",
"DELETE": "Delete system",
- "DELETE_MSG": "Are you sure you want delete this system({{ name }} )?
Deleting this will immediately delete modules that are not in another system
",
+ "DELETE_MSG": "Delete the system {{ name }} ?
Modules that are not in another system are deleted immediately .
",
"DELETE_LOADING": "Deleting system...",
"DELETE_SUCCESS": "Successfully deleted system.",
"DELETE_ERROR": "Failed to delete system. Error: {{ error }}",
@@ -292,7 +292,7 @@
"SEARCH": "Search for modules...",
"ADD": "Add module",
"DELETE": "Delete module",
- "DELETE_MSG": "Are you sure you want delete this module?
Deleting this will module immediately remove it from any system associated with it
",
+ "DELETE_MSG": "Delete the module {{ name }} ?
It is removed immediately from every system that uses it.
",
"DELETE_LOADING": "Deleting module...",
"DELETE_SUCCESS": "Successfully deleted module.",
"DELETE_ERROR": "Failed to delete module. Error: {{ error }}",
@@ -348,7 +348,7 @@
"ADD": "Add zone",
"EDIT": "Edit zone",
"DELETE": "Delete zone",
- "DELETE_MSG": "Are you sure you want delete this zone?
Zones beneath it, its triggers, metadata and settings are removed immediately . Systems in this zone are kept unless you also remove associated resources.
",
+ "DELETE_MSG": "Delete the zone {{ name }} ?
Zones beneath it, its triggers, metadata and settings are removed immediately . Systems in this zone are kept unless you also remove associated resources.
",
"DELETE_LOADING": "Deleting zone...",
"DELETE_SUCCESS": "Successfully deleted zone.",
"DELETE_ERROR": "Failed to delete zone. Error: {{ error }}",
@@ -403,7 +403,7 @@
"ADD": "Add driver",
"EDIT": "Edit driver",
"DELETE": "Delete driver",
- "DELETE_MSG": "Are you sure you want delete this driver?
",
+ "DELETE_MSG": "Delete the driver {{ name }} ?
",
"DELETE_LOADING": "Deleting driver...",
"DELETE_SUCCESS": "Successfully deleted driver.",
"DELETE_ERROR": "Failed to delete driver. Error: {{ error }}",
@@ -457,7 +457,7 @@
"DELETE": "Delete repository",
"REMOVE": "Remove repository",
"NEW": "New repository",
- "DELETE_MSG": "Deleting this repository will immediately remove assoicated drivers and modules
",
+ "DELETE_MSG": "Delete the repository {{ name }} ?
Its drivers and their modules are removed immediately .
",
"DELETE_LOADING": "Deleting repository...",
"DELETE_SUCCESS": "Successfully deleted repository.",
"DELETE_ERROR": "Failed to delete repository. Error: {{ error }}",
@@ -500,7 +500,7 @@
"DELETE": "Delete trigger",
"DELETE_INSTANCE": "Delete instance",
"REMOVE": "Remove trigger",
- "DELETE_MSG": "Are you sure you want delete this trigger?
Deleting this trigger will immediately remove it from all associated systems and zones
",
+ "DELETE_MSG": "Delete the trigger {{ name }} ?
It is removed immediately from all systems and zones that use it.
",
"DELETE_LOADING": "Deleting trigger...",
"DELETE_SUCCESS": "Successfully deleted trigger.",
"DELETE_ERROR": "Failed to delete trigger. Error: {{ error }}",
@@ -607,7 +607,7 @@
"NEW": "New user",
"BULK": "Bulk add users",
"REMOVE": "Remove user",
- "DELETE_MSG": "Are you sure you want delete this user?
The user will be removed from the system within 24 hours
",
+ "DELETE_MSG": "Delete the user {{ name }} ?
The user is removed within 24 hours.
",
"DELETE_LOADING": "Deleting user...",
"DELETE_SUCCESS": "Successfully deleted user.",
"DELETE_ERROR": "Failed to delete user. Error: {{ error }}",
@@ -651,7 +651,7 @@
"BULK": "Bulk add domains",
"REMOVE": "Remove domain",
"DELETE": "Delete domain",
- "DELETE_MSG": "Are you sure you want delete this domain?
Its users, auth sources and groups are removed immediately .
",
+ "DELETE_MSG": "Delete the domain {{ name }} ?
Its users, auth sources and groups are removed immediately .
",
"DELETE_LOADING": "Deleting domain...",
"DELETE_SUCCESS": "Successfully deleted domain.",
"DELETE_ERROR": "Failed to delete domain. Error: {{ error }}",
diff --git a/public/assets/locale/en-US.json b/public/assets/locale/en-US.json
index 74f37f815..fc048ed1c 100644
--- a/public/assets/locale/en-US.json
+++ b/public/assets/locale/en-US.json
@@ -76,7 +76,7 @@
"METADATA_REMOVE": "Remove Metadata block",
"METADATA_EMPTY": "No Metadata blocks found",
"METADATA_SAVE": "Successfully saved metadata",
- "TOTAL_ITEMS": "{{ count }} items(s)",
+ "TOTAL_ITEMS": "{{ count }} item(s)",
"ITEM_ERROR": "Failed to save item. Error: {{ error }}",
"ITEM_SAVE": "Successfully saved item",
"UPDATE_AVAILABLE": "Update Available",
@@ -149,7 +149,7 @@
"GIT_COMMIT": "Commit hash",
"GIT_PULL_LATEST": "Pull latest commit",
"DESCRIPTION_EMPTY": "No description",
- "INVALID_FIELDS": "Some fields are invalid. [{{ field_list }}]",
+ "INVALID_FIELDS": "Check these fields: {{ field_list }}",
"BULK_ADD": "Bulk add {{ type }}",
"BULK_DOWNLOAD": "Download Template",
"BULK_DROP_MSG": "Select or drop TSV file to begin",
@@ -210,7 +210,7 @@
"ADD": "Add system",
"EDIT": "Edit system",
"DELETE": "Delete system",
- "DELETE_MSG": "Are you sure you want delete this system({{ name }} )?
Deleting this will immediately delete modules that are not in another system
",
+ "DELETE_MSG": "Delete the system {{ name }} ?
Modules that are not in another system are deleted immediately .
",
"DELETE_LOADING": "Deleting system...",
"DELETE_SUCCESS": "Successfully deleted system.",
"DELETE_ERROR": "Failed to delete system. Error: {{ error }}",
@@ -289,7 +289,7 @@
"SEARCH": "Search for modules...",
"ADD": "Add module",
"DELETE": "Delete module",
- "DELETE_MSG": "Are you sure you want delete this module?
Deleting this will module immediately remove it from any system associated with it
",
+ "DELETE_MSG": "Delete the module {{ name }} ?
It is removed immediately from every system that uses it.
",
"DELETE_LOADING": "Deleting module...",
"DELETE_SUCCESS": "Successfully deleted module.",
"DELETE_ERROR": "Failed to delete module. Error: {{ error }}",
@@ -345,7 +345,7 @@
"ADD": "Add zone",
"EDIT": "Edit zone",
"DELETE": "Delete zone",
- "DELETE_MSG": "Are you sure you want delete this zone?
Zones beneath it, its triggers, metadata and settings are removed immediately . Systems in this zone are kept unless you also remove associated resources.
",
+ "DELETE_MSG": "Delete the zone {{ name }} ?
Zones beneath it, its triggers, metadata and settings are removed immediately . Systems in this zone are kept unless you also remove associated resources.
",
"DELETE_LOADING": "Deleting zone...",
"DELETE_SUCCESS": "Successfully deleted zone.",
"DELETE_ERROR": "Failed to delete zone. Error: {{ error }}",
@@ -400,7 +400,7 @@
"ADD": "Add driver",
"EDIT": "Edit driver",
"DELETE": "Delete driver",
- "DELETE_MSG": "Are you sure you want delete this driver?
",
+ "DELETE_MSG": "Delete the driver {{ name }} ?
",
"DELETE_LOADING": "Deleting driver...",
"DELETE_SUCCESS": "Successfully deleted driver.",
"DELETE_ERROR": "Failed to delete driver. Error: {{ error }}",
@@ -454,7 +454,7 @@
"DELETE": "Delete repository",
"REMOVE": "Remove repository",
"NEW": "New repository",
- "DELETE_MSG": "Deleting this repository will immediately remove assoicated drivers and modules
",
+ "DELETE_MSG": "Delete the repository {{ name }} ?
Its drivers and their modules are removed immediately .
",
"DELETE_LOADING": "Deleting repository...",
"DELETE_SUCCESS": "Successfully deleted repository.",
"DELETE_ERROR": "Failed to delete repository. Error: {{ error }}",
@@ -497,7 +497,7 @@
"DELETE": "Delete trigger",
"DELETE_INSTANCE": "Delete instance",
"REMOVE": "Remove trigger",
- "DELETE_MSG": "Are you sure you want delete this trigger?
Deleting this trigger will immediately remove it from all associated systems and zones
",
+ "DELETE_MSG": "Delete the trigger {{ name }} ?
It is removed immediately from all systems and zones that use it.
",
"DELETE_LOADING": "Deleting trigger...",
"DELETE_SUCCESS": "Successfully deleted trigger.",
"DELETE_ERROR": "Failed to delete trigger. Error: {{ error }}",
@@ -604,7 +604,7 @@
"NEW": "New user",
"BULK": "Bulk add users",
"REMOVE": "Remove user",
- "DELETE_MSG": "Are you sure you want delete this user?
The user will be removed from the system within 24 hours
",
+ "DELETE_MSG": "Delete the user {{ name }} ?
The user is removed within 24 hours.
",
"DELETE_LOADING": "Deleting user...",
"DELETE_SUCCESS": "Successfully deleted user.",
"DELETE_ERROR": "Failed to delete user. Error: {{ error }}",
@@ -648,7 +648,7 @@
"BULK": "Bulk add domains",
"REMOVE": "Remove domain",
"DELETE": "Delete domain",
- "DELETE_MSG": "Are you sure you want delete this domain?
Its users, auth sources and groups are removed immediately .
",
+ "DELETE_MSG": "Delete the domain {{ name }} ?
Its users, auth sources and groups are removed immediately .
",
"DELETE_LOADING": "Deleting domain...",
"DELETE_SUCCESS": "Successfully deleted domain.",
"DELETE_ERROR": "Failed to delete domain. Error: {{ error }}",
diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts
index 9e7357e96..c67a3ec25 100644
--- a/src/app/admin/admin.component.ts
+++ b/src/app/admin/admin.component.ts
@@ -12,6 +12,7 @@ import { DebugOutputComponent } from '../ui/debug-output.component';
import { IconComponent } from '../ui/icon.component';
import { ItemTab } from '../ui/item-tablist.component';
import { SidebarMenuComponent } from '../ui/sidebar-menu.component';
+import { TranslatePipe } from '../ui/translate.pipe';
@Component({
selector: 'app-engine',
@@ -51,6 +52,7 @@ import { SidebarMenuComponent } from '../ui/sidebar-menu.component';
matRipple
class="absolute top-2 left-4 z-40 mr-2 sm:hidden"
(click)="open_menu = true"
+ [attr.aria-label]="'COMMON.MENU' | translate"
>
menu
@@ -81,6 +83,7 @@ import { SidebarMenuComponent } from '../ui/sidebar-menu.component';
`,
],
imports: [
+ TranslatePipe,
DebugOutputComponent,
IconComponent,
MatRippleModule,
diff --git a/src/app/admin/api-keys/api-key-modal.component.ts b/src/app/admin/api-keys/api-key-modal.component.ts
index 9f10a2b46..c6a3be488 100644
--- a/src/app/admin/api-keys/api-key-modal.component.ts
+++ b/src/app/admin/api-keys/api-key-modal.component.ts
@@ -112,7 +112,9 @@ export interface APIKeyModalData {
[matChipInputSeparatorKeyCodes]="separators"
[matChipInputAddOnBlur]="true"
(matChipInputTokenEnd)="addScope($event)"
- (input)="scope_search.set($any($event.target).value)"
+ (input)="
+ scope_search.set($any($event.target).value)
+ "
[matAutocomplete]="auto"
/>
@@ -178,6 +180,9 @@ export interface APIKeyModalData {
icon
type="button"
(click)="clearUser()"
+ [attr.aria-label]="
+ 'COMMON.CLEAR_VALUE' | translate
+ "
>
close
@@ -278,6 +283,9 @@ export interface APIKeyModalData {
matSuffix
type="button"
(click)="setExpiry('')"
+ [attr.aria-label]="
+ 'COMMON.CLEAR_VALUE' | translate
+ "
>
close
@@ -303,7 +311,10 @@ export interface APIKeyModalData {
{{
'ADMIN.APP_KEYS_TTL_NONE' | translate
}}
- @for (preset of ttl_presets; track preset.seconds) {
+ @for (
+ preset of ttl_presets;
+ track preset.seconds
+ ) {
{{
preset.label | translate
}}
@@ -406,7 +417,10 @@ export class APIKeyModalComponent extends AsyncHandler implements OnInit {
(a.name || '').localeCompare(b.name || ''),
);
// The selected user's label fills the input; don't filter it away.
- if (!search || search === this._userLabel(this.formModel().user).toLowerCase()) {
+ if (
+ !search ||
+ search === this._userLabel(this.formModel().user).toLowerCase()
+ ) {
return list;
}
return list.filter((user) =>
diff --git a/src/app/admin/api-keys/api-keys.component.ts b/src/app/admin/api-keys/api-keys.component.ts
index d187636ce..fa3ae7f42 100644
--- a/src/app/admin/api-keys/api-keys.component.ts
+++ b/src/app/admin/api-keys/api-keys.component.ts
@@ -78,7 +78,7 @@ import { APIKeyService } from './api-keys.service';
- {{ last_key()?.name || 'Unanamed API Key' }}
+ {{ last_key()?.name || 'Unnamed API Key' }}
{
- this._dialog_ref.disableClose = true;
this.loading.set(true);
const limits = this.formModel().booking_limits || [];
const booking_limits = {};
@@ -74,9 +73,8 @@ export class BookingLimitsModalComponent {
);
const resp = await call.catch(() => null);
this.loading.set(false);
- this._dialog_ref.disableClose = false;
- if (!resp) return notifyError('Error adding new tenant.');
- notifySuccess('Successfully added new tenant.');
+ if (!resp) return notifyError('Error saving booking limits.');
+ notifySuccess('Saved booking limits.');
this._dialog_ref.close({ ...this.tenant, booking_limits: resp });
});
}
diff --git a/src/app/admin/broker-form.component.ts b/src/app/admin/broker-form.component.ts
index f386c64fe..cffd5a02f 100644
--- a/src/app/admin/broker-form.component.ts
+++ b/src/app/admin/broker-form.component.ts
@@ -426,7 +426,6 @@ export class BrokerFormComponent extends AsyncHandler implements OnInit {
await submit(this.form, async () => {
const item = this._data.item;
this.loading.set(i18n(`${this._name}_SAVING`));
- this._dialog_ref.disableClose = true;
const item_json = item.toJSON ? item.toJSON() : item;
const form_item = (
item.id
@@ -444,7 +443,6 @@ export class BrokerFormComponent extends AsyncHandler implements OnInit {
: addBroker(form_item as unknown as PlaceMQTTBroker)
).catch(async (err) => {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}_SAVE_ERROR`, {
error: await readError(err),
@@ -453,7 +451,6 @@ export class BrokerFormComponent extends AsyncHandler implements OnInit {
return null;
});
if (!result) return;
- this._dialog_ref.disableClose = false;
this.event.emit({ reason: 'done', metadata: { item: result } });
notifySuccess(i18n(`${this._name}_SAVE_SUCCESS`));
this._dialog_ref.close();
diff --git a/src/app/admin/brokers.component.ts b/src/app/admin/brokers.component.ts
index 47c11580c..4c7765119 100644
--- a/src/app/admin/brokers.component.ts
+++ b/src/app/admin/brokers.component.ts
@@ -15,6 +15,7 @@ import {
import { AsyncHandler } from '../common/async-handler.class';
import { describeError } from '../common/errors';
+import { escapeHtml } from '../common/general';
import { notifyError, notifySuccess } from '../common/notifications';
import { FormModalComponent } from '../common/types';
import { openConfirmModal } from '../overlays/confirm-modal.component';
@@ -247,8 +248,9 @@ export class AdminBrokersComponent extends AsyncHandler implements OnInit {
const details = await openConfirmModal(
{
title: `Delete MQTT Broker`,
- content: `Are you sure you want delete this boker?
The broker will be deleted immediately.
`,
+ content: `Delete the broker ${escapeHtml(item.name)} ?
The broker is deleted immediately .
`,
icon: { type: 'icon', content: 'delete' },
+ destructive: true,
},
this._dialog,
);
diff --git a/src/app/admin/cluster-details/cluster-task-list.component.ts b/src/app/admin/cluster-details/cluster-task-list.component.ts
index 5b4085291..8acb75c7e 100644
--- a/src/app/admin/cluster-details/cluster-task-list.component.ts
+++ b/src/app/admin/cluster-details/cluster-task-list.component.ts
@@ -24,6 +24,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { AsyncHandler } from '../../common/async-handler.class';
import { describeError } from '../../common/errors';
+import { reportLoadError } from '../../common/load-error';
import { i18n } from '../../common/locale.service';
import { notifyError } from '../../common/notifications';
import { signalFromSubscribable } from '../../common/signals';
@@ -44,7 +45,12 @@ const task_details = {};
-
+
arrow_back
@@ -316,13 +322,26 @@ export class PlaceClusterTaskListComponent
this.updateProcessList();
}
+ /** Whether the last process list load failed */
+ private _load_failed = false;
+
public async updateProcessList() {
const cluster = this.cluster();
if (!cluster) return;
this.loading.set(true);
+ // This polls, so only report the first failure in a row
const list = await queryProcesses(cluster.id, {
include_status: true,
- }).catch(() => []);
+ })
+ .then((list) => {
+ this._load_failed = false;
+ return list;
+ })
+ .catch((error) => {
+ if (this._load_failed) return [];
+ this._load_failed = true;
+ return reportLoadError([])(error);
+ });
// The page moved to another cluster while this was loading
if (cluster !== this.cluster()) return;
this.process_list.set(
diff --git a/src/app/admin/database-details.component.ts b/src/app/admin/database-details.component.ts
index 0974d2e1a..cff0d2193 100644
--- a/src/app/admin/database-details.component.ts
+++ b/src/app/admin/database-details.component.ts
@@ -452,6 +452,17 @@ export class PlaceDatabaseDetailsComponent
let level = pending.filter(
(zone) => !zone.parent_id || !file_ids.has(zone.parent_id),
);
+ const details = await openConfirmModal(
+ {
+ title: 'Import Zone Tree',
+ content: `Create ${pending.length} new zones? ${level.length} of them have no parent in the file, so they go in at the top level. Zones are not removed if the import fails.`,
+ icon: { type: 'icon', content: 'account_tree' },
+ confirm_text: 'Import',
+ },
+ this._dialog,
+ );
+ if (details.reason !== 'done') return;
+ details.close();
const id_map = new Map();
let created = 0;
try {
diff --git a/src/app/admin/edge-modal.component.ts b/src/app/admin/edge-modal.component.ts
index 5be45a2b7..31e61337a 100644
--- a/src/app/admin/edge-modal.component.ts
+++ b/src/app/admin/edge-modal.component.ts
@@ -104,13 +104,11 @@ export class EdgeModalComponent implements OnInit {
public async save() {
await submit(this.form, async () => undefined);
if (this.form().invalid()) return;
- this._dialog_ref.disableClose = true;
this.loading.set('Saving edge node...');
const edge = { ...this.edge, ...this.formModel() };
const method = edge.id ? updateEdge(edge.id, edge) : addEdge(edge);
const new_edge = await method.catch(() => null);
this.loading.set('');
- this._dialog_ref.disableClose = false;
if (!new_edge) return notifyError(i18n('ADMIN.EDGE_ERROR'));
if (edge.id) {
notifySuccess(i18n('ADMIN.EDGE_NEW_SUCCESS'));
diff --git a/src/app/admin/edge.component.ts b/src/app/admin/edge.component.ts
index b6409e251..3a485fddb 100644
--- a/src/app/admin/edge.component.ts
+++ b/src/app/admin/edge.component.ts
@@ -202,8 +202,14 @@ export class PlaceEdgeComponent implements OnInit {
ref.afterClosed().subscribe((_) => {
// Cancel keeps the last saved edge and its one-time API key
if (!_) return;
- sessionStorage.setItem('BACKOFFICE.last_edge', JSON.stringify(_));
- this.last_change.set(_);
+ // Only creates return a key; edits must not hide the last one
+ if (_.x_api_key) {
+ sessionStorage.setItem(
+ 'BACKOFFICE.last_edge',
+ JSON.stringify(_),
+ );
+ this.last_change.set(_);
+ }
this.loadEdges();
});
}
diff --git a/src/app/admin/extension-modal/extension-modal.component.ts b/src/app/admin/extension-modal/extension-modal.component.ts
index 2fa9dc62b..3b0b563f6 100644
--- a/src/app/admin/extension-modal/extension-modal.component.ts
+++ b/src/app/admin/extension-modal/extension-modal.component.ts
@@ -173,6 +173,7 @@ import { BackofficeExtension } from '../extensions.component';
matRipple
class="border-error text-error h-12 w-12 rounded-sm border"
(click)="removeCondition(condition)"
+ [attr.aria-label]="'COMMON.DELETE' | translate"
>
delete
diff --git a/src/app/admin/resource-imports.component.ts b/src/app/admin/resource-imports.component.ts
index 81ff49dd2..d7190d390 100644
--- a/src/app/admin/resource-imports.component.ts
+++ b/src/app/admin/resource-imports.component.ts
@@ -164,18 +164,27 @@ export interface ExternalResource {
>
publish
-
- visibility
-
+ @if (row.system_id) {
+
+ visibility
+
+ } @else {
+
+ visibility
+
+ }
diff --git a/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts
index 788d0108a..a332210fa 100644
--- a/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts
+++ b/src/app/admin/signage-ai/signage-ai-provider-modal.component.ts
@@ -233,7 +233,8 @@ export class SignageAIProviderModalComponent {
provider: this._data.item?.provider || 'OPENAI',
endpoint: this._data.item?.endpoint || '',
location: this._data.item?.location || '',
- default_model: this._data.item?.default_model || 'gpt-image-2.5-sunburst',
+ default_model:
+ this._data.item?.default_model || 'gpt-image-2.5-sunburst',
enabled: this._data.item?.enabled ?? true,
is_default: this._data.item?.is_default ?? false,
user_per_day: this._data.item?.quotas?.user_per_day ?? 60,
@@ -277,7 +278,6 @@ export class SignageAIProviderModalComponent {
}
this.loading.set(i18n('ADMIN.AI_PROVIDER_SAVING'));
- this._dialog_ref.disableClose = true;
const body: Partial & {
credentials?: Record;
@@ -309,7 +309,6 @@ export class SignageAIProviderModalComponent {
);
} finally {
this.loading.set('');
- this._dialog_ref.disableClose = false;
}
notifySuccess(i18n('ADMIN.AI_PROVIDER_SAVE_SUCCESS'));
this._dialog_ref.close(true);
diff --git a/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts b/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts
index e7fa54a7c..59bbd9b8a 100644
--- a/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts
+++ b/src/app/admin/signage-plugins/signage-plugin-import-modal.component.ts
@@ -56,7 +56,12 @@ interface PluginProbe {
{{ 'ADMIN.SIGNAGE_PLUGINS_IMPORT' | translate }}
-
+
close
diff --git a/src/app/admin/signage-plugins/signage-plugin-modal.component.ts b/src/app/admin/signage-plugins/signage-plugin-modal.component.ts
index 4926687da..74f4ca219 100644
--- a/src/app/admin/signage-plugins/signage-plugin-modal.component.ts
+++ b/src/app/admin/signage-plugins/signage-plugin-modal.component.ts
@@ -355,7 +355,6 @@ export class SignagePluginModalComponent
}
await submit(this.form, async () => {
this.loading.set(i18n('ADMIN.SIGNAGE_PLUGINS_SAVING'));
- this._dialog_ref.disableClose = true;
const schema = this.schema();
const values = this.formModel();
const payload: Partial = {
@@ -376,7 +375,6 @@ export class SignagePluginModalComponent
};
try {
const result = await this._data.save(payload);
- this._dialog_ref.disableClose = false;
this.event.emit({
reason: 'done',
metadata: { item: result },
@@ -388,7 +386,6 @@ export class SignagePluginModalComponent
});
} catch (err) {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n('ADMIN.SIGNAGE_PLUGINS_SAVE_ERROR', {
error: await readError(err),
diff --git a/src/app/admin/signage-plugins/signage-plugin-test-modal.component.ts b/src/app/admin/signage-plugins/signage-plugin-test-modal.component.ts
index c72b0dd9f..fc5c235a3 100644
--- a/src/app/admin/signage-plugins/signage-plugin-test-modal.component.ts
+++ b/src/app/admin/signage-plugins/signage-plugin-test-modal.component.ts
@@ -188,7 +188,12 @@ export interface SignagePluginTestModalData {
{{ 'ADMIN.SIGNAGE_PLUGINS_TEST_HEADING' | translate }}
— {{ plugin().name }}
-
+
close
diff --git a/src/app/admin/staff-api.component.ts b/src/app/admin/staff-api.component.ts
index 9747c9039..a7e8bf743 100644
--- a/src/app/admin/staff-api.component.ts
+++ b/src/app/admin/staff-api.component.ts
@@ -8,8 +8,9 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
import { del, get, PlaceDomain } from '@placeos/ts-client';
-import { escapeHtml } from '../common/general';
import { readError } from '../common/errors';
+import { escapeHtml } from '../common/general';
+import { reportLoadError } from '../common/load-error';
import { notifyError, notifySuccess } from '../common/notifications';
import { HashMap } from '../common/types';
import { openConfirmModal } from '../overlays/confirm-modal.component';
@@ -18,8 +19,8 @@ import { SimpleTableComponent } from '../ui/simple-table.component';
import { TranslatePipe } from '../ui/translate.pipe';
import { AdminDataService } from './admin-data.service';
import { BookingLimitsModalComponent } from './booking-limits-modal.component';
-import { StaffTenantModalComponent } from './staff-tenant-modal.component';
import { isSecretExpired, isSecretExpiring } from './staff-api.utilities';
+import { StaffTenantModalComponent } from './staff-tenant-modal.component';
export interface PlaceTenant {
id: string;
@@ -278,7 +279,9 @@ export class PlaceStaffAPIComponent implements OnInit {
return;
}
this.loading.set('Loading tenants for domain...');
- const tenants = await get('/api/staff/v1/tenants').catch(() => []);
+ const tenants = await get('/api/staff/v1/tenants').catch(
+ reportLoadError([]),
+ );
this.tenants.set(
(tenants || []).filter((t) => t.domain === this.domain()?.domain),
);
diff --git a/src/app/admin/staff-tenant-modal.component.ts b/src/app/admin/staff-tenant-modal.component.ts
index 3e37fb6e0..5662c1446 100644
--- a/src/app/admin/staff-tenant-modal.component.ts
+++ b/src/app/admin/staff-tenant-modal.component.ts
@@ -525,7 +525,7 @@ export class StaffTenantModalComponent {
const invalid_credentials = this.credential_fields()
.filter((field) => field.required)
.filter((field) => !this.formModel().credentials[field.key])
- .map((field) => field.key);
+ .map((field) => FIELD_NAME_MAPPING[field.key] || field.key);
if (this.form().invalid() || invalid_credentials.length) {
return notifyError(
i18n('COMMON.INVALID_FIELDS', {
@@ -536,7 +536,6 @@ export class StaffTenantModalComponent {
}),
);
}
- this._dialog_ref.disableClose = true;
this.loading.set('Saving staff API tenant...');
const model = this.formModel();
const limits = model.booking_limits || [];
@@ -590,7 +589,6 @@ export class StaffTenantModalComponent {
: post('/api/staff/v1/tenants', data);
const tenant = await call.catch((__) => null);
this.loading.set('');
- this._dialog_ref.disableClose = false;
if (!tenant) return notifyError(i18n('ADMIN.TENANTS_SAVE_ERROR'));
notifySuccess(i18n('ADMIN.TENANTS_SAVE_SUCCESS'));
this._dialog_ref.close();
diff --git a/src/app/admin/storage/storage-provider-modal.component.ts b/src/app/admin/storage/storage-provider-modal.component.ts
index c04441c72..70f4a070b 100644
--- a/src/app/admin/storage/storage-provider-modal.component.ts
+++ b/src/app/admin/storage/storage-provider-modal.component.ts
@@ -257,7 +257,6 @@ export class StorageProviderModalComponent {
await submit(this.form, async () => undefined);
if (this.form().invalid()) return;
this.loading.set('Saving storage provider...');
- this._dialog_ref.disableClose = true;
const details = { ...this.formModel() } as PlaceStorage;
if (details.id && !details.access_secret) {
delete (details as PlaceStorage & { access_secret?: unknown })
@@ -271,7 +270,6 @@ export class StorageProviderModalComponent {
);
} finally {
this.loading.set('');
- this._dialog_ref.disableClose = false;
}
notifySuccess(i18n('ADMIN.STORAGE_SAVE_SUCCESS'));
this._dialog_ref.close();
diff --git a/src/app/admin/storage/storage.component.ts b/src/app/admin/storage/storage.component.ts
index a383613be..ac54395ad 100644
--- a/src/app/admin/storage/storage.component.ts
+++ b/src/app/admin/storage/storage.component.ts
@@ -6,8 +6,9 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
-import { escapeHtml } from '../../common/general';
import { describeError } from '../../common/errors';
+import { escapeHtml } from '../../common/general';
+import { reportLoadError } from '../../common/load-error';
import { i18n } from '../../common/locale.service';
import { notifyError } from '../../common/notifications';
import { openConfirmModal } from '../../overlays/confirm-modal.component';
@@ -248,7 +249,7 @@ export class StorageComponent implements OnInit {
this.loading.set('Loading storage...');
const { data } = await queryStorage({
auth_id: this.domain()?.id,
- }).catch(() => ({ data: [] }));
+ }).catch(reportLoadError({ data: [] }));
this.store_list.set(data);
this.loading.set('');
}
diff --git a/src/app/admin/upload-library.component.ts b/src/app/admin/upload-library.component.ts
index b482b42e3..a6c8f1543 100644
--- a/src/app/admin/upload-library.component.ts
+++ b/src/app/admin/upload-library.component.ts
@@ -500,7 +500,7 @@ export class UploadLibraryComponent extends AsyncHandler implements OnInit {
if (failed) {
notifyError('Failed to upload files.');
} else if (success && finished) {
- notifySuccess('Succesfully uploaded files.');
+ notifySuccess('Successfully uploaded files.');
}
this.clearTimeout('upload_list');
this.loading.set(false);
diff --git a/src/app/admin/view-upload-modal.component.ts b/src/app/admin/view-upload-modal.component.ts
index 3c36bf892..9da5f5a4c 100644
--- a/src/app/admin/view-upload-modal.component.ts
+++ b/src/app/admin/view-upload-modal.component.ts
@@ -9,10 +9,10 @@ import { UploadInfo } from './upload-library.component';
selector: 'view-upload-modal',
template: `
diff --git a/src/app/admin/zone-tree-export-modal.component.ts b/src/app/admin/zone-tree-export-modal.component.ts
index 43849e161..4bee61d05 100644
--- a/src/app/admin/zone-tree-export-modal.component.ts
+++ b/src/app/admin/zone-tree-export-modal.component.ts
@@ -16,7 +16,7 @@ import { IconComponent } from '../ui/icon.component';
template: `
Export Zone Tree
-
+
close
@@ -59,6 +59,7 @@ import { IconComponent } from '../ui/icon.component';
icon
matRipple
(click)="removeSelected(zone)"
+ aria-label="Close"
>
close
diff --git a/src/app/common/forms.ts b/src/app/common/forms.ts
index 2e333cc6d..7c2fa29d1 100644
--- a/src/app/common/forms.ts
+++ b/src/app/common/forms.ts
@@ -17,12 +17,21 @@ export function removeSignalChipItem(items: T[], item: T): T[] {
return items.filter((existing) => existing !== item);
}
+/** Readable name for a form field key, e.g. `service_account` to "Service account" */
+export function fieldLabel(key: string): string {
+ const words = key.replace(/_/g, ' ').trim();
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+/** Readable names of the invalid top-level fields, for error messages */
export function getInvalidSignalFields(
form: FieldTree,
): string[] {
const value = form().value() as object;
- return Object.keys(value).filter((key) => {
- const field = form[key as keyof typeof form];
- return typeof field === 'function' && field().invalid();
- });
+ return Object.keys(value)
+ .filter((key) => {
+ const field = form[key as keyof typeof form];
+ return typeof field === 'function' && field().invalid();
+ })
+ .map(fieldLabel);
}
diff --git a/src/app/common/hotkeys.service.ts b/src/app/common/hotkeys.service.ts
index 82fc3b3de..24e6f87a8 100644
--- a/src/app/common/hotkeys.service.ts
+++ b/src/app/common/hotkeys.service.ts
@@ -20,6 +20,17 @@ const INVALID_STANDALONE_KEYS: string[] = [
/** Modifier keys whose held state is read from the key event flags */
const MODIFIER_KEYS: string[] = ['control', 'shift', 'alt', 'meta'];
+/** Focused controls that handle letter and arrow keys themselves */
+const KEYBOARD_WIDGETS = [
+ '[role="combobox"]',
+ '[role="listbox"]',
+ '[role="option"]',
+ '[role="menu"]',
+ '[role="menuitem"]',
+ '[role="radiogroup"]',
+ '[role="slider"]',
+ '[role="spinbutton"]',
+].join(', ');
@Service()
export class HotkeysService {
@@ -149,6 +160,11 @@ export class HotkeysService {
return true;
}
+ // Selects, menus and similar controls use letters and arrow keys
+ if (active.closest(KEYBOARD_WIDGETS)) {
+ return true;
+ }
+
return false;
}
diff --git a/src/app/common/item.service.ts b/src/app/common/item.service.ts
index 30e3a878b..ac96e7f81 100644
--- a/src/app/common/item.service.ts
+++ b/src/app/common/item.service.ts
@@ -409,6 +409,7 @@ export class ActiveItemService extends AsyncHandler {
]
: undefined,
icon: { type: 'icon', content: 'delete' },
+ destructive: true,
},
},
);
diff --git a/src/app/common/load-error.ts b/src/app/common/load-error.ts
new file mode 100644
index 000000000..e4206d511
--- /dev/null
+++ b/src/app/common/load-error.ts
@@ -0,0 +1,17 @@
+import { readError } from './errors';
+import { i18n } from './locale.service';
+import { notifyError } from './notifications';
+
+/**
+ * Catch handler for list loads. Tells the user the load failed, then returns
+ * `empty` so the page still renders. Without it a failed load looks like a
+ * list with nothing in it.
+ */
+export function reportLoadError(empty: T) {
+ return async (error: unknown): Promise => {
+ notifyError(
+ i18n('COMMON.LOAD_ERROR', { error: await readError(error) }),
+ );
+ return empty;
+ };
+}
diff --git a/src/app/common/notifications.ts b/src/app/common/notifications.ts
index b6e6709c4..1df181cd0 100644
--- a/src/app/common/notifications.ts
+++ b/src/app/common/notifications.ts
@@ -58,7 +58,8 @@ function openNotification(
) {
const snackbar_ref = service.open(message, action, {
panelClass: [type],
- duration: 5000,
+ // Errors stay until dismissed so they can be read and acted on
+ duration: type === 'error' ? undefined : 5000,
});
if (action) {
on_action = on_action || (() => snackbar_ref.dismiss());
diff --git a/src/app/domains/application-form.component.ts b/src/app/domains/application-form.component.ts
index b67eff3b5..ab1f1529b 100644
--- a/src/app/domains/application-form.component.ts
+++ b/src/app/domains/application-form.component.ts
@@ -299,7 +299,6 @@ export class ApplicationFormComponent extends AsyncHandler implements OnInit {
await submit(this.form, async () => {
const item = this._data.item as unknown as PlaceResource;
this.loading.set(i18n(`${this._name}_SAVING`));
- this._dialog_ref.disableClose = true;
const item_json = item.toJSON ? item.toJSON() : item;
const form_item = (
item.id
@@ -317,13 +316,11 @@ export class ApplicationFormComponent extends AsyncHandler implements OnInit {
save_item as unknown as PlaceApplication,
)
: addApplication(save_item as unknown as PlaceApplication));
- this._dialog_ref.disableClose = false;
this.event.emit({ reason: 'done', metadata: { item: _item } });
notifySuccess(i18n(`${this._name}_SAVE_SUCCESS`));
this._dialog_ref.close();
} catch (err) {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}_SAVE_ERROR`, {
error: await readError(err),
diff --git a/src/app/domains/domain-form.component.ts b/src/app/domains/domain-form.component.ts
index c0e0cab14..4bb56f570 100644
--- a/src/app/domains/domain-form.component.ts
+++ b/src/app/domains/domain-form.component.ts
@@ -331,7 +331,6 @@ export class DomainFormComponent extends AsyncHandler implements OnInit {
await submit(this.form, async () => {
const item = this._data.item;
this.loading.set(i18n(`${this._name}.SAVING`));
- this._dialog_ref.disableClose = true;
const item_json = item.toJSON ? item.toJSON() : item;
const form_item = (
item.id
@@ -347,13 +346,11 @@ export class DomainFormComponent extends AsyncHandler implements OnInit {
form_item as unknown as PlaceDomain,
)
: addDomain(form_item as unknown as PlaceDomain));
- this._dialog_ref.disableClose = false;
this.event.emit({ reason: 'done', metadata: { item: _item } });
notifySuccess(i18n(`${this._name}.SAVE_SUCCESS`));
this._dialog_ref.close();
} catch (err) {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}.SAVE_ERROR`, {
error: await readError(err),
diff --git a/src/app/domains/domain-state.service.ts b/src/app/domains/domain-state.service.ts
index 5bf3636d1..9776dc1d6 100644
--- a/src/app/domains/domain-state.service.ts
+++ b/src/app/domains/domain-state.service.ts
@@ -34,6 +34,7 @@ import { filter, map } from 'rxjs';
import { describeError } from '../common/errors';
import { escapeHtml } from '../common/general';
import { ActiveItemService } from '../common/item.service';
+import { reportLoadError } from '../common/load-error';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
import { waitForEvent } from '../common/signals';
@@ -116,7 +117,7 @@ export class DomainStateService {
const response = await queryUsers({
authority_id: params.id,
limit: params.full ? 1000 : 1,
- }).catch(() => ({ data: [] as PlaceUser[], total: 0 }));
+ }).catch(reportLoadError({ data: [] as PlaceUser[], total: 0 }));
return {
...response,
data: [...response.data].sort((a, b) =>
@@ -179,7 +180,9 @@ export class DomainStateService {
const response = await queryApplications({
authority_id: params.id,
...(params.full ? {} : { limit: 1 }),
- }).catch(() => ({ data: [] as PlaceApplication[], total: 0 }));
+ }).catch(
+ reportLoadError({ data: [] as PlaceApplication[], total: 0 }),
+ );
return {
...response,
data: [...response.data].sort((a, b) =>
diff --git a/src/app/domains/domains.component.ts b/src/app/domains/domains.component.ts
index caad62351..e3819e064 100644
--- a/src/app/domains/domains.component.ts
+++ b/src/app/domains/domains.component.ts
@@ -24,13 +24,13 @@ import { DomainStateService } from './domain-state.service';
>
@@ -39,6 +39,7 @@ import { DomainStateService } from './domain-state.service';
icon
class="mr-2 sm:hidden"
(click)="open_menu = true"
+ [attr.aria-label]="'COMMON.MENU' | translate"
>
menu
@@ -69,7 +70,7 @@ import { DomainStateService } from './domain-state.service';
}
{
const item = this._data.item;
this.saving.set(i18n(`${this._name}.SAVING`));
- this._dialog_ref.disableClose = true;
const item_json = item.toJSON ? item.toJSON() : item;
const form_value = { ...this.formModel() };
if (item.id) {
@@ -499,7 +498,6 @@ export class DriverFormComponent extends AsyncHandler implements OnInit {
form_item as unknown as PlaceDriver,
)
: addDriver(form_item as unknown as PlaceDriver));
- this._dialog_ref.disableClose = false;
this.event.emit({
reason: 'done',
metadata: { item: _item as unknown as Identity },
@@ -514,7 +512,6 @@ export class DriverFormComponent extends AsyncHandler implements OnInit {
this._dialog_ref.close();
} catch (err) {
this.saving.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}.SAVE_ERROR`, {
error: await readError(err),
diff --git a/src/app/drivers/driver-state.service.ts b/src/app/drivers/driver-state.service.ts
index 1545fa4bd..ae04af08b 100644
--- a/src/app/drivers/driver-state.service.ts
+++ b/src/app/drivers/driver-state.service.ts
@@ -13,7 +13,9 @@ import {
} from '@placeos/ts-client';
import { describeError } from '../common/errors';
import { ActiveItemService } from '../common/item.service';
+import { reportLoadError } from '../common/load-error';
import { notifyError, notifySuccess } from '../common/notifications';
+import { collectPages } from '../common/support-access';
import { HashMap, Identity } from '../common/types';
import { openConfirmModal } from '../overlays/confirm-modal.component';
import { ViewResponseModalComponent } from '../overlays/view-response-modal.component';
@@ -58,10 +60,11 @@ export class DriverStateService {
loader: async ({ params: { item } }) => {
if (!(item instanceof PlaceDriver))
return { data: [] as PlaceModule[], total: 0 };
- const response = await queryModules({
- driver_id: item.id,
- }).catch(() => ({ data: [] as PlaceModule[], total: 0 }));
- return { data: response.data, total: response.total };
+ // Load every page so the list matches the tab count
+ const data = await collectPages(
+ queryModules({ driver_id: item.id, limit: 500 }),
+ ).catch(reportLoadError([] as PlaceModule[]));
+ return { data, total: data.length };
},
});
diff --git a/src/app/drivers/driver-update-list-modal.component.ts b/src/app/drivers/driver-update-list-modal.component.ts
index be7e392a6..1badf8ef5 100644
--- a/src/app/drivers/driver-update-list-modal.component.ts
+++ b/src/app/drivers/driver-update-list-modal.component.ts
@@ -8,6 +8,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { queryDrivers, updateDriver } from '@placeos/ts-client';
import { describeError } from '../common/errors';
+import { reportLoadError } from '../common/load-error';
+import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
import { IconComponent } from '../ui/icon.component';
import { DateFromPipe } from '../ui/pipes/date-from.pipe';
@@ -35,7 +37,12 @@ import { TranslatePipe } from '../ui/translate.pipe';
}
@if (!loading()) {
-
+
close
}
@@ -121,7 +128,7 @@ import { TranslatePipe } from '../ui/translate.pipe';
class="flex h-48 w-[20rem] flex-col items-center justify-center space-y-2"
>
- {{ 'DRIVERS.LOADING' | translate }}
+ {{ loading() }}
}
@if (!loading()) {
@@ -165,16 +172,16 @@ export class DriverUpdateListModalComponent {
private _dialog_ref =
inject>(MatDialogRef);
- public readonly loading = signal('Loading drivers...');
+ public readonly loading = signal(i18n('DRIVERS.LOADING'));
private readonly _change = signal(0);
private readonly _drivers_with_updates = resource({
params: () => this._change(),
loader: async () => {
- this.loading.set('Loading drivers...');
+ this.loading.set(i18n('DRIVERS.LOADING'));
const response = await queryDrivers({
update_available: true,
limit: 1000,
- }).catch(() => ({ data: [], total: 0 }));
+ }).catch(reportLoadError({ data: [], total: 0 }));
response.data = response.data.filter(
(_) => _.commit !== _.update_info.commit,
);
@@ -183,7 +190,8 @@ export class DriverUpdateListModalComponent {
);
// Keep total in sync with the filtered list for select all
response.total = response.data.length;
- this.selected_drivers.set(response.data.map((d) => d.id));
+ // Updates reload live modules, so the user picks them on purpose
+ this.selected_drivers.set([]);
this.loading.set('');
return response;
},
@@ -220,7 +228,7 @@ export class DriverUpdateListModalComponent {
}
public async updateDrivers() {
- this.loading.set('Updating drivers...');
+ this.loading.set(i18n('DRIVERS.UPDATING'));
this._dialog_ref.disableClose = true;
const drivers = this.drivers_with_updates();
const selected = drivers.data.filter((_) =>
diff --git a/src/app/drivers/drivers.component.ts b/src/app/drivers/drivers.component.ts
index d5ea5c212..883ce94a7 100644
--- a/src/app/drivers/drivers.component.ts
+++ b/src/app/drivers/drivers.component.ts
@@ -27,13 +27,13 @@ import { DriverStateService } from './driver-state.service';
@@ -42,6 +42,7 @@ import { DriverStateService } from './driver-state.service';
icon
class="mr-2 sm:hidden"
(click)="open_menu.set(true)"
+ [attr.aria-label]="'COMMON.MENU' | translate"
>
menu
@@ -72,7 +73,7 @@ import { DriverStateService } from './driver-state.service';
@if (updates_available()) {
}
{
{{ title | translate }}
-
+
close
@@ -66,7 +71,9 @@ export interface GroupBulkAddResult {
}
-
+
@if (!show_permissions || tab() === 0) {
{
})
export class GroupBulkAddModalComponent {
private _dialog_ref =
- inject, GroupBulkAddResult>>(
- MatDialogRef,
- );
+ inject<
+ MatDialogRef, GroupBulkAddResult>
+ >(MatDialogRef);
private _data = inject>(MAT_DIALOG_DATA);
public readonly selected = signal([]);
diff --git a/src/app/groups/group-form.component.ts b/src/app/groups/group-form.component.ts
index aa7562054..51688bef9 100644
--- a/src/app/groups/group-form.component.ts
+++ b/src/app/groups/group-form.component.ts
@@ -364,7 +364,6 @@ export class GroupFormComponent extends AsyncHandler implements OnInit {
await submit(this.form, async () => {
const item = this._data.item;
this.loading.set(i18n(`${this._name}.SAVING`));
- this._dialog_ref.disableClose = true;
const form_value = this.formModel();
const form_item = cleanObject(
{
@@ -381,13 +380,11 @@ export class GroupFormComponent extends AsyncHandler implements OnInit {
form_item as unknown as PlaceGroup,
)
: addGroup(form_item as unknown as PlaceGroup));
- this._dialog_ref.disableClose = false;
this.event.emit({ reason: 'done', metadata: { item: _item } });
notifySuccess(i18n(`${this._name}.SAVE_SUCCESS`));
this._dialog_ref.close();
} catch (err) {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}.SAVE_ERROR`, {
error: await readError(err),
diff --git a/src/app/groups/groups.component.ts b/src/app/groups/groups.component.ts
index ad37cf112..13d1277bc 100644
--- a/src/app/groups/groups.component.ts
+++ b/src/app/groups/groups.component.ts
@@ -26,13 +26,13 @@ import { GroupStateService } from './group-state.service';
>
@@ -41,6 +41,7 @@ import { GroupStateService } from './group-state.service';
icon
class="mr-2 sm:hidden"
(click)="open_menu = true"
+ [attr.aria-label]="'COMMON.MENU' | translate"
>
menu
@@ -70,7 +71,7 @@ import { GroupStateService } from './group-state.service';
}
{
const item = this._data.item;
this.loading.set(i18n(`${this._name}.SAVING`));
- this._dialog_ref.disableClose = true;
const item_json = item.toJSON ? item.toJSON() : item;
const form_value: Partial = {
...this.formModel(),
@@ -530,13 +529,11 @@ export class ModuleFormComponent extends AsyncHandler implements OnInit {
)
: addModule(form_item as unknown as PlaceModule));
clearSupportModuleCache();
- this._dialog_ref.disableClose = false;
this.event.emit({ reason: 'done', metadata: { item: _item } });
notifySuccess(i18n(`${this._name}.SAVE_SUCCESS`));
this._dialog_ref.close();
} catch (err) {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}.SAVE_ERROR`, {
error: await readError(err),
diff --git a/src/app/modules/module-state.service.ts b/src/app/modules/module-state.service.ts
index f7d9ebf11..b7e1af1c9 100644
--- a/src/app/modules/module-state.service.ts
+++ b/src/app/modules/module-state.service.ts
@@ -11,6 +11,7 @@ import {
stopModule,
} from '@placeos/ts-client';
import { ActiveItemService } from '../common/item.service';
+import { reportLoadError } from '../common/load-error';
import { notifyError, notifySuccess } from '../common/notifications';
import { querySupportSystems as querySystems } from '../common/support-access';
import { ViewResponseModalComponent } from '../overlays/view-response-modal.component';
@@ -79,7 +80,9 @@ export class ModuleStateService {
try {
const response = await querySystems({
module_id: item.id,
- }).catch(() => ({ data: [] as PlaceSystem[], total: 0 }));
+ }).catch(
+ reportLoadError({ data: [] as PlaceSystem[], total: 0 }),
+ );
return { data: response.data, total: response.total };
} finally {
this._loading.set(false);
@@ -123,9 +126,7 @@ export class ModuleStateService {
}
return;
}
- notifySuccess(
- `Module successfully ${running ? 'stopped' : 'started'}`,
- );
+ notifySuccess(`Module successfully ${running ? 'stopped' : 'started'}`);
item.running = !running;
}
diff --git a/src/app/modules/modules.component.ts b/src/app/modules/modules.component.ts
index 7cfe0511d..2674b5b84 100644
--- a/src/app/modules/modules.component.ts
+++ b/src/app/modules/modules.component.ts
@@ -29,13 +29,13 @@ import { ModuleStateService } from './module-state.service';
@@ -44,6 +44,7 @@ import { ModuleStateService } from './module-state.service';
matRipple
class="mr-2 sm:hidden"
(click)="open_menu.set(true)"
+ [attr.aria-label]="'COMMON.MENU' | translate"
>
menu
@@ -73,7 +74,7 @@ import { ModuleStateService } from './module-state.service';
}
> {
@if (!loading) {
-
+
close
}
diff --git a/src/app/overlays/bulk-item-modal/list.component.ts b/src/app/overlays/bulk-item-modal/list.component.ts
index 728e6056e..cabd416cf 100644
--- a/src/app/overlays/bulk-item-modal/list.component.ts
+++ b/src/app/overlays/bulk-item-modal/list.component.ts
@@ -167,6 +167,7 @@ import { TranslatePipe } from '../../ui/translate.pipe';
class="text-error mx-auto"
title="Remove row"
(click)="removeItem(index); $event.stopPropagation()"
+ [attr.aria-label]="'COMMON.DELETE' | translate"
>
delete
diff --git a/src/app/overlays/confirm-modal.component.ts b/src/app/overlays/confirm-modal.component.ts
index 52deaa594..5116fe456 100644
--- a/src/app/overlays/confirm-modal.component.ts
+++ b/src/app/overlays/confirm-modal.component.ts
@@ -97,6 +97,8 @@ export interface ConfirmModalData {
options?: ConfirmModalOption[];
/** Text displaed on the confirmation button */
confirm_text?: string;
+ /** Whether confirming deletes or loses data. Styles the button as an error */
+ destructive?: boolean;
/** Text displaed on the confirmation button */
cancel_text?: string;
/** Icon to display on the modal */
@@ -386,6 +388,7 @@ export async function openConfirmModal(
matRipple
name="accept"
class="flex-1"
+ [class.error]="destructive"
[disabled]="resolving() || blocked()"
(click)="onConfirm()"
>
@@ -426,9 +429,12 @@ export class ConfirmModalComponent implements OnInit {
public readonly content: string = this._data.content || 'Are you sure?';
/** Body of the confirm modal */
public readonly extra: [string, string] = this._data.extra || ['', ''];
+ /** Whether the confirm button deletes or loses data */
+ public readonly destructive = !!this._data.destructive;
/** Display text on the confirm button */
public readonly confirm_text: string =
- this._data.confirm_text || 'COMMON.ACCEPT';
+ this._data.confirm_text ||
+ (this.destructive ? 'COMMON.DELETE' : 'COMMON.ACCEPT');
/** Display text on the cancel button */
public readonly cancel_text: string =
this._data.cancel_text || 'COMMON.CANCEL';
diff --git a/src/app/overlays/metadata-details-modal.component.ts b/src/app/overlays/metadata-details-modal.component.ts
index af5b1b12b..52eeb2ade 100644
--- a/src/app/overlays/metadata-details-modal.component.ts
+++ b/src/app/overlays/metadata-details-modal.component.ts
@@ -1,7 +1,13 @@
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
-import { Component, OnInit, inject, signal } from '@angular/core';
+import { Component, inject, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { form, FormField, required, submit, validate } from '@angular/forms/signals';
+import {
+ form,
+ FormField,
+ required,
+ submit,
+ validate,
+} from '@angular/forms/signals';
import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
import { MatRippleModule } from '@angular/material/core';
import {
@@ -14,6 +20,7 @@ import { MatInputModule } from '@angular/material/input';
import { addSignalChipItem, removeSignalChipItem } from '../common/forms';
import { IconComponent } from '../ui/icon.component';
import type { MetadataFormModel } from '../ui/metadata-display.component';
+import { TranslatePipe } from '../ui/translate.pipe';
export interface MetadataDetailsModalData {
value: MetadataFormModel;
@@ -29,7 +36,12 @@ export interface MetadataDetailsModalData {
class="border-base-100 bg-base-200 z-10 mx-auto my-2 flex w-[calc(100%-1rem)] items-center justify-between rounded-sm border px-4 py-2"
>
Update metadata details
-
+
close
@@ -115,6 +127,7 @@ export interface MetadataDetailsModalData {
`,
styles: [``],
imports: [
+ TranslatePipe,
MatDialogModule,
MatRippleModule,
MatFormFieldModule,
diff --git a/src/app/overlays/metadata-history-modal.component.ts b/src/app/overlays/metadata-history-modal.component.ts
index 444f5b7fe..b12281b83 100644
--- a/src/app/overlays/metadata-history-modal.component.ts
+++ b/src/app/overlays/metadata-history-modal.component.ts
@@ -30,7 +30,12 @@ import { TranslatePipe } from '../ui/translate.pipe';
{{ 'COMMON.METADATA_HISTORY' | translate }}
-
+
close
diff --git a/src/app/overlays/select-item-modal.component.ts b/src/app/overlays/select-item-modal.component.ts
index 955996f76..ccde63e53 100644
--- a/src/app/overlays/select-item-modal.component.ts
+++ b/src/app/overlays/select-item-modal.component.ts
@@ -25,7 +25,12 @@ export interface SelectItemModalData {
{{ 'COMMON.ITEM_ADD' | translate: { item: name } }}
@if (!loading) {
-
+
close
}
diff --git a/src/app/overlays/view-module-state.component.ts b/src/app/overlays/view-module-state.component.ts
index 9283f2c33..6cffc099f 100644
--- a/src/app/overlays/view-module-state.component.ts
+++ b/src/app/overlays/view-module-state.component.ts
@@ -35,7 +35,12 @@ export interface ModuleStateModalData {
{{ 'MODULES.STATE' | translate }}
-
+
close
diff --git a/src/app/repositories/repositories.component.ts b/src/app/repositories/repositories.component.ts
index 03cea0ab8..fbb7a0b38 100644
--- a/src/app/repositories/repositories.component.ts
+++ b/src/app/repositories/repositories.component.ts
@@ -24,13 +24,13 @@ import { RepositoriesStateService } from './repositories-state.service';
>
@@ -39,6 +39,7 @@ import { RepositoriesStateService } from './repositories-state.service';
matRipple
class="mr-2 sm:hidden"
(click)="open_menu.set(true)"
+ [attr.aria-label]="'COMMON.MENU' | translate"
>
menu
@@ -68,7 +69,7 @@ import { RepositoriesStateService } from './repositories-state.service';
}
{
const item = this._data.item as unknown as PlaceResource;
this.loading.set(i18n(`${this._name}.SAVING`));
- this._dialog_ref.disableClose = true;
const item_json = item.toJSON ? item.toJSON() : item;
// `zone` is UI-only; the backend uses `zones`, which the effect
// keeps in sync. Strip it so we never POST `zone: null`.
@@ -728,13 +727,11 @@ export class SystemFormComponent extends AsyncHandler implements OnInit {
processed_item as unknown as PlaceSystem,
)
: addSystem(processed_item as unknown as PlaceSystem));
- this._dialog_ref.disableClose = false;
this.event.emit({ reason: 'done', metadata: { item: _item } });
notifySuccess(i18n(`${this._name}.SAVE_SUCCESS`));
this._dialog_ref.close();
} catch (err) {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}.SAVE_ERROR`, {
error: await readError(err),
diff --git a/src/app/systems/system-modules.component.ts b/src/app/systems/system-modules.component.ts
index a14a502f1..9595125f7 100644
--- a/src/app/systems/system-modules.component.ts
+++ b/src/app/systems/system-modules.component.ts
@@ -307,6 +307,7 @@ import { SystemStateService } from './system-state.service';
default
matRipple
(click)="editModule(row)"
+ [attr.aria-label]="'COMMON.EDIT' | translate"
>
edit
@@ -316,6 +317,9 @@ import { SystemStateService } from './system-state.service';
matRipple
(click)="setActive(i)"
[matMenuTriggerFor]="context_menu"
+ [attr.aria-label]="
+ 'COMMON.MORE_ACTIONS' | translate
+ "
>
more_vert
diff --git a/src/app/systems/system-state.service.ts b/src/app/systems/system-state.service.ts
index 36c0bd45e..f3c81b3c9 100644
--- a/src/app/systems/system-state.service.ts
+++ b/src/app/systems/system-state.service.ts
@@ -39,6 +39,7 @@ import {
import { describeError, readError } from '../common/errors';
import { ActiveItemService } from '../common/item.service';
+import { reportLoadError } from '../common/load-error';
import { notifyError, notifySuccess } from '../common/notifications';
import { waitForEvent } from '../common/signals';
import { DialogEvent, HashMap, Identity } from '../common/types';
@@ -161,7 +162,9 @@ export class SystemStateService extends AsyncHandler {
control_system_id: item.id,
complete: true,
limit: 200,
- } as Record
).catch(() => ({ data: [] }));
+ } as Record).catch(
+ reportLoadError({ data: [] }),
+ );
// A newer load or another system took over. Leave its state alone.
if (abortSignal.aborted || this.item()?.id !== item.id) {
return [] as PlaceModule[];
@@ -244,9 +247,9 @@ export class SystemStateService extends AsyncHandler {
)
).filter((zone) => !!zone),
}
- : await listSystemZones(item.id).catch(() => ({
- data: [],
- }));
+ : await listSystemZones(item.id).catch(
+ reportLoadError({ data: [] }),
+ );
const zones = [...response.data];
zones.sort(
(a, b) =>
@@ -276,7 +279,7 @@ export class SystemStateService extends AsyncHandler {
const response = await listSystemTriggers(
item.id,
full ? {} : { limit: 1 },
- ).catch(() => empty);
+ ).catch(full ? reportLoadError(empty) : () => empty);
return full ? response : { ...empty, total: response.total };
} finally {
this.setLoading('triggers', false);
@@ -303,8 +306,9 @@ export class SystemStateService extends AsyncHandler {
public async startSystem() {
const details = await this.confirm({
title: 'Start system?',
- content: `Are you sure you want to start this system? All stopped modules within the system will boot up.`,
+ content: `Start ${escapeHtml(this.active_item.name)} ? All stopped modules within the system will boot up.`,
icon: { type: 'icon', class: 'backoffice-controller-play' },
+ confirm_text: 'Start system',
});
if (details.reason !== 'done') return;
details.loading('Starting system...');
@@ -327,8 +331,10 @@ export class SystemStateService extends AsyncHandler {
public async stopSystem() {
const details = await this.confirm({
title: 'Stop system?',
- content: `Are you sure you want to stop this system? All modules will be immediately stopped regardless of any other systems they may be in.`,
+ content: `Stop ${escapeHtml(this.active_item.name)} ? All modules will be immediately stopped regardless of any other systems they may be in.`,
icon: { type: 'icon', class: 'backoffice-controller-stop' },
+ confirm_text: 'Stop system',
+ destructive: true,
});
if (details.reason !== 'done') return;
details.loading('Stopping system...');
diff --git a/src/app/systems/systems.component.ts b/src/app/systems/systems.component.ts
index 66f150323..e988df778 100644
--- a/src/app/systems/systems.component.ts
+++ b/src/app/systems/systems.component.ts
@@ -29,19 +29,20 @@ import { SystemStateService } from './system-state.service';
menu
@@ -71,7 +72,7 @@ import { SystemStateService } from './system-state.service';
}
add
edit
@@ -191,6 +192,7 @@ import { TriggerStateService } from './trigger-state.service';
error
matRipple
(click)="removeCondition(row)"
+ [attr.aria-label]="'COMMON.DELETE' | translate"
>
delete
@@ -281,6 +283,7 @@ import { TriggerStateService } from './trigger-state.service';
matRipple
[disabled]="!template_system()"
(click)="editAction(row)"
+ [attr.aria-label]="'COMMON.EDIT' | translate"
>
edit
@@ -290,6 +293,7 @@ import { TriggerStateService } from './trigger-state.service';
error
matRipple
(click)="removeAction(row)"
+ [attr.aria-label]="'COMMON.DELETE' | translate"
>
delete
diff --git a/src/app/triggers/trigger-form.component.ts b/src/app/triggers/trigger-form.component.ts
index 8deb34690..56c5232e3 100644
--- a/src/app/triggers/trigger-form.component.ts
+++ b/src/app/triggers/trigger-form.component.ts
@@ -215,7 +215,6 @@ export class TriggerFormComponent extends AsyncHandler implements OnInit {
}
const item = this._data.item;
this.loading.set(i18n(`${this._name}.SAVING`));
- this._dialog_ref.disableClose = true;
const item_json = item.toJSON ? item.toJSON() : item;
const form_item = (
item.id
@@ -228,13 +227,11 @@ export class TriggerFormComponent extends AsyncHandler implements OnInit {
const _item = await (form_item.id
? updateTrigger(form_item.id, form_item)
: addTrigger(form_item));
- this._dialog_ref.disableClose = false;
this.event.emit({ reason: 'done', metadata: { item: _item } });
notifySuccess(i18n(`${this._name}.SAVE_SUCCESS`));
this._dialog_ref.close();
} catch (err) {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}.SAVE_ERROR`, {
error: await readError(err),
diff --git a/src/app/triggers/trigger-state.service.ts b/src/app/triggers/trigger-state.service.ts
index 8f303d6a5..2181e1a73 100644
--- a/src/app/triggers/trigger-state.service.ts
+++ b/src/app/triggers/trigger-state.service.ts
@@ -18,6 +18,7 @@ import {
import { describeError } from '../common/errors';
import { escapeHtml } from '../common/general';
import { ActiveItemService } from '../common/item.service';
+import { reportLoadError } from '../common/load-error';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
import { waitForEvent } from '../common/signals';
@@ -49,7 +50,9 @@ export class TriggerStateService {
loader: async ({ params }) => {
const { item } = params;
if (!(item instanceof PlaceTrigger)) return [] as PlaceTrigger[];
- return listTriggerInstances(item.id).catch(() => []);
+ return listTriggerInstances(item.id).catch(
+ reportLoadError([] as PlaceTrigger[]),
+ );
},
});
diff --git a/src/app/triggers/triggers.component.ts b/src/app/triggers/triggers.component.ts
index aa7f6eedd..05f270f34 100644
--- a/src/app/triggers/triggers.component.ts
+++ b/src/app/triggers/triggers.component.ts
@@ -30,13 +30,13 @@ import { TriggerStateService } from './trigger-state.service';
@@ -45,6 +45,7 @@ import { TriggerStateService } from './trigger-state.service';
icon
class="mr-2 sm:hidden"
(click)="open_menu = true"
+ [attr.aria-label]="'COMMON.MENU' | translate"
>
menu
@@ -74,7 +75,7 @@ import { TriggerStateService } from './trigger-state.service';
}
remove
@@ -58,6 +60,7 @@ import { IconComponent } from './icon.component';
class="border-secondary text-secondary z-10 h-12 w-12 rounded-l-none rounded-r border"
[disabled]="disabled() || value() === max()"
(click)="add()"
+ [attr.aria-label]="'COMMON.INCREASE' | translate"
>
add
@@ -72,7 +75,7 @@ import { IconComponent } from './icon.component';
multi: true,
},
],
- imports: [IconComponent, MatRippleModule, FormsModule],
+ imports: [TranslatePipe, IconComponent, MatRippleModule, FormsModule],
})
export class CounterComponent implements ControlValueAccessor {
/** Size of a single step */
diff --git a/src/app/ui/custom-fields/image-list-field.component.ts b/src/app/ui/custom-fields/image-list-field.component.ts
index 2c01b3b27..ec77ec003 100644
--- a/src/app/ui/custom-fields/image-list-field.component.ts
+++ b/src/app/ui/custom-fields/image-list-field.component.ts
@@ -75,10 +75,22 @@ import { TranslatePipe } from '../translate.pipe';
actions
class="absolute top-0 right-0 left-0 flex items-center justify-center space-x-2 opacity-0"
>
-
+
link
-
+
visibility
close
@@ -101,6 +114,9 @@ import { TranslatePipe } from '../translate.pipe';
[style.transform]="'translate(-' + offset() + '00%)'"
[matTooltip]="item.error"
(click)="retryUpload(item)"
+ [attr.aria-label]="
+ item.error ? ('COMMON.RETRY' | translate) : null
+ "
>
@if (!item.error) {
delete
diff --git a/src/app/ui/custom-tooltip.component.ts b/src/app/ui/custom-tooltip.component.ts
index 7696b8ed8..2a7287c15 100644
--- a/src/app/ui/custom-tooltip.component.ts
+++ b/src/app/ui/custom-tooltip.component.ts
@@ -18,6 +18,8 @@ import {
viewChild,
WritableSignal,
} from '@angular/core';
+import { NavigationEnd, Router } from '@angular/router';
+import { filter } from 'rxjs';
import { AsyncHandler } from '../common/async-handler.class';
import { SanitizePipe } from './pipes/sanitise.pipe';
@@ -64,6 +66,7 @@ export class CustomTooltipComponent
private _element = inject>(ElementRef);
private _overlay = inject(Overlay);
private _injector = inject(Injector);
+ private _router = inject(Router, { optional: true });
/** Horizontal position of the rendered overlay */
public readonly xPosition = input<'start' | 'center' | 'end'>('end');
@@ -155,6 +158,22 @@ export class CustomTooltipComponent
]),
});
this._overlay_ref.attach(_portal);
+ // Close like a menu: on Escape and when the page changes
+ this.subscription(
+ 'escape',
+ this._overlay_ref
+ .keydownEvents()
+ .pipe(filter((e) => e.key === 'Escape'))
+ .subscribe(() => this.close()),
+ );
+ if (this._router) {
+ this.subscription(
+ 'navigation',
+ this._router.events
+ .pipe(filter((e) => e instanceof NavigationEnd))
+ .subscribe(() => this.close()),
+ );
+ }
if (this.backdrop()) {
this.subscription(
'backdrop',
@@ -170,6 +189,8 @@ export class CustomTooltipComponent
public close() {
this.clearTimeout('open');
+ this.unsub('escape');
+ this.unsub('navigation');
if (this._overlay_ref) {
this._overlay_ref.dispose();
this._overlay_ref = null;
diff --git a/src/app/ui/debug-info.component.ts b/src/app/ui/debug-info.component.ts
index 29fb5d840..2e25d5f3a 100644
--- a/src/app/ui/debug-info.component.ts
+++ b/src/app/ui/debug-info.component.ts
@@ -79,7 +79,13 @@ import { TranslatePipe } from './translate.pipe';
}
@if (compact()) {
-
+
more_horiz
diff --git a/src/app/ui/forms/settings-form.component.ts b/src/app/ui/forms/settings-form.component.ts
index d6a24f833..58b1a4c68 100644
--- a/src/app/ui/forms/settings-form.component.ts
+++ b/src/app/ui/forms/settings-form.component.ts
@@ -8,6 +8,7 @@ import {
model,
OnInit,
signal,
+ untracked,
WritableSignal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
@@ -247,6 +248,8 @@ export class SettingsFormComponent extends AsyncHandler implements OnInit {
settings4: '',
});
public readonly dirty = signal>({});
+ /** Item the form was last loaded for */
+ private _form_id?: string;
/** Whether a setting is being saved */
public readonly saving = signal<[boolean, boolean, boolean, boolean]>([
false,
@@ -376,12 +379,13 @@ export class SettingsFormComponent extends AsyncHandler implements OnInit {
});
effect(() => {
if (this.merge_settings() !== undefined) {
- this.timeout('update_merge', () => this.clearChanges(), 50);
+ this.timeout('update_merge', () => this._reloadForm(), 50);
}
});
effect(() => {
if (this.settings()) {
- this.clearChanges();
+ // Only new settings should re-run this, not the form it writes
+ untracked(() => this._reloadForm());
}
});
}
@@ -453,13 +457,13 @@ export class SettingsFormComponent extends AsyncHandler implements OnInit {
).then(
(new_settings: PlaceSettings) => {
this._setSaving(level, false);
+ this._clearDirty([level]);
this._setSettings(level, new_settings);
notifySuccess(
i18n('COMMON.SETTINGS_SAVE_SUCCESS', {
type: this.type(level),
}),
);
- this.clearChanges();
},
async (err) => {
this._setSaving(level, false);
@@ -499,12 +503,12 @@ export class SettingsFormComponent extends AsyncHandler implements OnInit {
if (promises.length) {
Promise.all(promises).then(
(results: PlaceSettings[]) => {
+ this._clearDirty(saved_levels);
results.forEach((result, index) => {
this._setSaving(saved_levels[index], false);
this._setSettings(saved_levels[index], result);
});
notifySuccess(i18n('COMMON.SETTINGS_SAVE_SUCCESS_ALL'));
- this.clearChanges();
},
async (err) => {
for (const level of saved_levels) {
@@ -560,24 +564,40 @@ export class SettingsFormComponent extends AsyncHandler implements OnInit {
});
}
+ /** Discard all unsaved changes */
public clearChanges() {
- this.used_settings.set(this._processSettings(this.settings() || []));
- this._initForm();
+ this.dirty.set({});
+ this._reloadForm();
}
- private _initForm() {
+ /**
+ * Reload the form from the saved settings. Unsaved edits are kept, unless
+ * the settings now belong to a different item.
+ */
+ private _reloadForm() {
+ if (this._form_id !== this.id()) this.dirty.set({});
+ this._form_id = this.id();
+ this.used_settings.set(this._processSettings(this.settings() || []));
const used = this.used_settings();
- this.form.set({
- settings0: used[0].settings_string,
- settings1: used[1].settings_string,
- settings2: used[2].settings_string,
- settings3: used[3].settings_string,
- settings4: used[4].settings_string,
- });
- this.dirty.set({});
+ const form = this.form();
+ const dirty = this.dirty();
+ const next: HashMap = {};
+ for (let i = 0; i <= EncryptionLevel.NeverDisplay + 1; i++) {
+ const key = `settings${i}`;
+ next[key] = dirty[key] ? form[key] : used[i]?.settings_string;
+ }
+ this.form.set(next);
this.changed.set(Date.now());
}
+ private _clearDirty(levels: number[]) {
+ this.dirty.update((dirty) => {
+ const next = { ...dirty };
+ for (const level of levels) delete next[`settings${level}`];
+ return next;
+ });
+ }
+
private _processSettings(settings: PlaceSettings[]): PlaceSettings[] {
if (!settings.length) return [];
const processed_settings = [];
diff --git a/src/app/ui/fullscreen-modal-shell.component.ts b/src/app/ui/fullscreen-modal-shell.component.ts
index a23c5e7b0..73a92aa91 100644
--- a/src/app/ui/fullscreen-modal-shell.component.ts
+++ b/src/app/ui/fullscreen-modal-shell.component.ts
@@ -1,10 +1,33 @@
-import { Component, input, output } from '@angular/core';
+import {
+ Component,
+ DestroyRef,
+ ElementRef,
+ inject,
+ input,
+ output,
+} from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatRippleModule } from '@angular/material/core';
-import { MatDialogModule } from '@angular/material/dialog';
+import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { filter, merge } from 'rxjs';
+import { i18n } from '../common/locale.service';
+import { openConfirmModal } from '../overlays/confirm-modal.component';
import { IconComponent } from './icon.component';
import { TranslatePipe } from './translate.pipe';
+/** Events in the form that may change a value */
+const EDIT_EVENTS = ['input', 'change', 'click', 'keydown'];
+/** Keys that move around the form without changing it */
+const NAVIGATION_KEYS = new Set([
+ 'Tab',
+ 'Escape',
+ 'Shift',
+ 'Control',
+ 'Alt',
+ 'Meta',
+]);
+
@Component({
selector: 'fullscreen-modal-shell,[fs-modal-shell]',
template: `
@@ -21,7 +44,12 @@ import { TranslatePipe } from './translate.pipe';
{{ heading() }}
@if (!loading()) {
-
+
close
}
@@ -60,18 +88,17 @@ import { TranslatePipe } from './translate.pipe';
}
+ @if (loading()) {
+
+ }
-
-
-
`,
imports: [
- MatDialogModule,
MatProgressSpinnerModule,
TranslatePipe,
IconComponent,
@@ -79,6 +106,31 @@ import { TranslatePipe } from './translate.pipe';
],
})
export class FullscreenModalShellComponent {
+ private _dialog_ref = inject(MatDialogRef, { optional: true });
+ private _dialog = inject(MatDialog);
+ private _confirming = false;
+
+ /** Set once the user edits a field, so closing asks before it drops them */
+ public touched = false;
+
+ /**
+ * Whether a form event may have changed a value. Selects, chips and +/-
+ * counters change values without input events, so clicks on buttons or
+ * selects and non-navigation keys count as edits too.
+ */
+ private _isEdit(event: Event) {
+ const target = event.target as Element | null;
+ if (!target?.closest?.('main')) return false;
+ if (event instanceof KeyboardEvent) {
+ return !NAVIGATION_KEYS.has(event.key);
+ }
+ if (event.type === 'click') {
+ return !!target.closest('button, [role="combobox"]');
+ }
+ return true;
+ }
+
+ /** Text shown over the form while it saves. Closing is blocked meanwhile */
public readonly loading = input('');
public readonly heading = input('Fullscreen Modal');
public readonly confirm_text = input('');
@@ -86,4 +138,51 @@ export class FullscreenModalShellComponent {
/** Disable the save button, e.g. until required choices are made */
public readonly disable_confirm = input(false);
public readonly save = output();
+
+ constructor() {
+ // Capture phase, as Material stops some events (e.g. chip remove)
+ const host: HTMLElement = inject(ElementRef).nativeElement;
+ const mark = (event: Event) => {
+ if (this._isEdit(event)) this.touched = true;
+ };
+ for (const type of EDIT_EVENTS) {
+ host.addEventListener(type, mark, true);
+ }
+ inject(DestroyRef).onDestroy(() => {
+ for (const type of EDIT_EVENTS) {
+ host.removeEventListener(type, mark, true);
+ }
+ });
+ const ref = this._dialog_ref;
+ if (!ref) return;
+ // The shell owns closing so Escape and backdrop clicks can be guarded
+ ref.disableClose = true;
+ merge(
+ ref.backdropClick(),
+ ref.keydownEvents().pipe(filter((e) => e.key === 'Escape')),
+ )
+ .pipe(takeUntilDestroyed())
+ .subscribe(() => this.close());
+ }
+
+ /** Close the modal, asking first if there are unsaved changes */
+ public async close() {
+ if (!this._dialog_ref || this.loading() || this._confirming) return;
+ if (this.touched) {
+ this._confirming = true;
+ const details = await openConfirmModal(
+ {
+ title: i18n('COMMON.DISCARD_CHANGES'),
+ content: i18n('COMMON.DISCARD_CHANGES_MSG'),
+ icon: { type: 'icon', content: 'edit_off' },
+ confirm_text: i18n('COMMON.DISCARD'),
+ },
+ this._dialog,
+ );
+ this._confirming = false;
+ if (details.reason !== 'done') return;
+ details.close();
+ }
+ this._dialog_ref.close();
+ }
}
diff --git a/src/app/ui/global-banner.component.ts b/src/app/ui/global-banner.component.ts
index 334e6dc91..2379ae4b1 100644
--- a/src/app/ui/global-banner.component.ts
+++ b/src/app/ui/global-banner.component.ts
@@ -2,6 +2,7 @@ import { Component, inject } from '@angular/core';
import { MatRipple } from '@angular/material/core';
import { SettingsService } from '../common/settings.service';
import { IconComponent } from './icon.component';
+import { TranslatePipe } from './translate.pipe';
export interface BannerDetails {
id: string;
@@ -25,7 +26,12 @@ export interface BannerDetails {
[class.text-error-content]="banner.type === 'error'"
>
{{ banner?.content }}
-
+
close
@@ -39,7 +45,7 @@ export interface BannerDetails {
}
`,
],
- imports: [IconComponent, MatRipple],
+ imports: [TranslatePipe, IconComponent, MatRipple],
})
export class GlobalBannerComponent {
private _settings = inject(SettingsService);
diff --git a/src/app/ui/icon.component.ts b/src/app/ui/icon.component.ts
index 83ad4a704..0cb0ee668 100644
--- a/src/app/ui/icon.component.ts
+++ b/src/app/ui/icon.component.ts
@@ -1,7 +1,24 @@
-import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import {
+ afterEveryRender,
+ afterNextRender,
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ inject,
+ Injector,
+ input,
+} from '@angular/core';
+import { MatTooltip } from '@angular/material/tooltip';
import { ApplicationIcon } from '../common/types';
import { SafePipe } from './pipes/safe.pipe';
+/** Text of an element, leaving out the names of any icons inside it */
+function textWithoutIcons(element: HTMLElement) {
+ const copy = element.cloneNode(true) as HTMLElement;
+ for (const icon of copy.querySelectorAll('icon, app-icon')) icon.remove();
+ return copy.textContent.trim();
+}
+
@Component({
selector: 'app-icon,icon',
template: `
@@ -35,4 +52,55 @@ export class IconComponent {
public readonly className = input('material-symbols-rounded');
/** Icon details */
public readonly icon = input
(undefined);
+
+ private readonly _element = inject>(ElementRef);
+ private readonly _tooltip = inject(MatTooltip, { optional: true });
+ private readonly _injector = inject(Injector);
+
+ constructor() {
+ afterNextRender(() => this._labelControl());
+ }
+
+ /**
+ * Icon fonts render names like "more_vert", which screen readers read
+ * aloud. Inside a button or link, hide the icon when the control has its
+ * own text or label. For icon-only controls with a tooltip, use the
+ * tooltip as the control's label.
+ */
+ private _labelControl() {
+ const host = this._element.nativeElement;
+ const control = host.parentElement?.closest('button, a');
+ if (!control) return;
+ const has_text = !!textWithoutIcons(control);
+ if (control.hasAttribute('aria-label') || has_text) {
+ host.setAttribute('aria-hidden', 'true');
+ return;
+ }
+ // The tooltip must be on the control itself, not an outer element
+ const tooltip = this._tooltip;
+ if (
+ !tooltip ||
+ !control.classList.contains('mat-mdc-tooltip-trigger')
+ ) {
+ return;
+ }
+ // Tooltip text can change, e.g. "Enable" and "Disable"
+ afterEveryRender(
+ () => {
+ const label = `${tooltip.message || ''}`.trim();
+ if (control.getAttribute('aria-label') === (label || null)) {
+ return;
+ }
+ // Without tooltip text the icon name is the only label left
+ if (label) {
+ control.setAttribute('aria-label', label);
+ host.setAttribute('aria-hidden', 'true');
+ } else {
+ control.removeAttribute('aria-label');
+ host.removeAttribute('aria-hidden');
+ }
+ },
+ { injector: this._injector },
+ );
+ }
}
diff --git a/src/app/ui/item-details.component.ts b/src/app/ui/item-details.component.ts
index 3bfd1cc1b..23a8c6eb1 100644
--- a/src/app/ui/item-details.component.ts
+++ b/src/app/ui/item-details.component.ts
@@ -101,7 +101,13 @@ export interface DisplayItem {
}
-
+
more_vert
diff --git a/src/app/ui/item-selection.component.ts b/src/app/ui/item-selection.component.ts
index bd67e72d2..21d43fafc 100644
--- a/src/app/ui/item-selection.component.ts
+++ b/src/app/ui/item-selection.component.ts
@@ -55,7 +55,7 @@ import { VirtualScrollComponent } from './virtual-scroll.component';
(click)="close()"
>
- @if (loading()) {
+ @if (loading() || group_hierarchy_loading()) {
filter_list
@@ -224,7 +226,7 @@ interface GroupTreeItem {
{{ 'COMMON.END_OF_LIST' | translate }}
- } @else {
+ } @else if (!group_hierarchy_loading()) {
@@ -322,7 +324,8 @@ interface GroupTreeItem {
}
- } @else {
+ } @else if (!loading()) {
+
@@ -387,6 +390,8 @@ export class ItemSidebarComponent
public readonly filter_options = input
([]);
public readonly show = signal(true);
public readonly group_hierarchy = signal([]);
+ /** Whether the group tree is loading */
+ public readonly group_hierarchy_loading = signal(false);
public readonly expanded_groups = signal>({});
public readonly include_deleted = this._service.include_deleted;
@@ -614,6 +619,7 @@ export class ItemSidebarComponent
}
private async loadGroupHierarchy() {
+ this.group_hierarchy_loading.set(true);
const response = await queryGroups({
limit: 2500,
fields: [
@@ -624,11 +630,12 @@ export class ItemSidebarComponent
'parent_id',
'children_count',
].join(','),
- }).catch(() => ({ data: [] }));
+ }).catch(reportLoadError({ data: [] }));
const groups = response.data.sort((a, b) =>
this.displayName(a).localeCompare(this.displayName(b)),
);
this.group_hierarchy.set(groups);
+ this.group_hierarchy_loading.set(false);
}
private expandGroupPath(group_id: string) {
diff --git a/src/app/ui/metadata-display.component.ts b/src/app/ui/metadata-display.component.ts
index 6e83618a7..cae34f47f 100644
--- a/src/app/ui/metadata-display.component.ts
+++ b/src/app/ui/metadata-display.component.ts
@@ -125,7 +125,10 @@ function replaceDescTag(inputString, newContent) {
icon
matRipple
(click)="saveMetadata(item)"
- [disabled]="!edited()[item.name]"
+ [disabled]="
+ !edited()[item.name] ||
+ loading()[item.name]
+ "
[matTooltip]="'COMMON.SAVE' | translate"
>
save
@@ -336,8 +339,11 @@ export class MetadataDisplayComponent
this._dialog,
);
if (result.reason !== 'done') return;
+ const is_new = !!this.metadata().find((prop) => prop?.name === field)
+ ?.new;
try {
- await removeMetadata(this.item().id, { name: field });
+ // Blocks that were never saved only exist locally
+ if (!is_new) await removeMetadata(this.item().id, { name: field });
} catch (err) {
return notifyError(
`Error removing old "${field}" metadata. Error: ${await readError(
@@ -442,10 +448,21 @@ export class MetadataDisplayComponent
});
}
- private generateForms() {
+ /** Build a form for each block. Unsaved edits are kept unless `reset` is set */
+ private generateForms(reset = false) {
+ const forms = this.form_map();
+ const edited = reset ? {} : this.edited();
this.form_map.set({});
this.edited.set({});
this.metadata().forEach((group) => {
+ if (edited[group.name] && forms[group.name]) {
+ this.form_map.update((m) => {
+ m[group.name] = forms[group.name];
+ return m;
+ });
+ this.edited.update((m) => ({ ...m, [group.name]: true }));
+ return;
+ }
const details =
typeof group.details === 'string'
? JSON.parse(group.details)
@@ -514,7 +531,7 @@ export class MetadataDisplayComponent
.sort((a, b) => a.name.localeCompare(b.name))
.filter((m) => !!m),
);
- this.generateForms();
+ this.generateForms(true);
})
.catch((err) =>
notifyError(
diff --git a/src/app/ui/module-runtime-errors-modal.component.ts b/src/app/ui/module-runtime-errors-modal.component.ts
index ed5243403..b0b85c31a 100644
--- a/src/app/ui/module-runtime-errors-modal.component.ts
+++ b/src/app/ui/module-runtime-errors-modal.component.ts
@@ -25,7 +25,12 @@ import { TranslatePipe } from './translate.pipe';
@if (!loading()) {
-
+
close
}
diff --git a/src/app/ui/sidebar-menu.component.ts b/src/app/ui/sidebar-menu.component.ts
index d38e3f727..8092e556a 100644
--- a/src/app/ui/sidebar-menu.component.ts
+++ b/src/app/ui/sidebar-menu.component.ts
@@ -80,6 +80,7 @@ import { UserMenuTooltipComponent } from './user-menu-tooltip.component';
compact() ? (link.name | translate) : ''
"
matTooltipPosition="right"
+ [attr.aria-label]="link.name | translate"
>
+ [attr.aria-label]="'COMMON.CLOSE' | translate">
close
@@ -164,6 +166,7 @@ import { UserMenuTooltipComponent } from './user-menu-tooltip.component';
[content]="application_picker"
[matTooltip]="compact() ? 'Applications' : ''"
matTooltipPosition="right"
+ aria-label="Applications"
yPosition="bottom"
>
@@ -301,7 +308,7 @@ export class SidebarMenuComponent extends AsyncHandler implements OnInit {
{
name: 'COMMON.ZONES',
route: '/zones',
- icon: 'meeting_room',
+ icon: 'layers',
show_on: () => canAccessSection('zones'),
},
{
diff --git a/src/app/ui/simple-table.component.ts b/src/app/ui/simple-table.component.ts
index 36f6c0b56..fe8da7861 100644
--- a/src/app/ui/simple-table.component.ts
+++ b/src/app/ui/simple-table.component.ts
@@ -187,6 +187,7 @@ function sortKey(value: unknown): string {
icon
matRipple
class="h-full w-full rounded-none"
+ aria-label="Drag to reorder"
cdkDragHandle
>
unfold_more
diff --git a/src/app/ui/upload-list.component.ts b/src/app/ui/upload-list.component.ts
index cf31aa750..07bbd5d4e 100644
--- a/src/app/ui/upload-list.component.ts
+++ b/src/app/ui/upload-list.component.ts
@@ -42,7 +42,12 @@ import { TranslatePipe } from './translate.pipe';
>
clear_all
-
+
close
diff --git a/src/app/ui/upload-permissions-modal.component.ts b/src/app/ui/upload-permissions-modal.component.ts
index 9a5d28a1f..961c51098 100644
--- a/src/app/ui/upload-permissions-modal.component.ts
+++ b/src/app/ui/upload-permissions-modal.component.ts
@@ -23,7 +23,12 @@ import { TranslatePipe } from './translate.pipe';
{{ 'COMMON.UPLOAD_FILE' | translate }}
-
+
close
diff --git a/src/app/ui/user-menu-tooltip.component.ts b/src/app/ui/user-menu-tooltip.component.ts
index cdc1af651..a3278f566 100644
--- a/src/app/ui/user-menu-tooltip.component.ts
+++ b/src/app/ui/user-menu-tooltip.component.ts
@@ -37,16 +37,6 @@ import { TranslatePipe } from './translate.pipe';
-
- logout
- {{ 'COMMON.LOGOUT' | translate }}
-
{{ 'COMMON.REPORT_ISSUE' | translate }}
+
+ logout
+ {{ 'COMMON.LOGOUT' | translate }}
+
`,
styles: [
diff --git a/src/app/users/user-form.component.ts b/src/app/users/user-form.component.ts
index d35dc1600..a89bd48e5 100644
--- a/src/app/users/user-form.component.ts
+++ b/src/app/users/user-form.component.ts
@@ -483,7 +483,6 @@ export class UserFormComponent extends AsyncHandler implements OnInit {
await submit(this.form, async () => {
const item = this._data.item;
this.loading.set(i18n(`${this._name}.SAVING`));
- this._dialog_ref.disableClose = true;
const item_json = item.toJSON ? item.toJSON() : item;
const form_item = (
item.id
@@ -511,13 +510,11 @@ export class UserFormComponent extends AsyncHandler implements OnInit {
form_item as unknown as PlaceUser,
)
: addUser(form_item as unknown as PlaceUser));
- this._dialog_ref.disableClose = false;
this.event.emit({ reason: 'done', metadata: { item: _item } });
notifySuccess(i18n(`${this._name}.SAVE_SUCCESS`));
this._dialog_ref.close();
} catch (err) {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}.SAVE_ERROR`, {
error: await readError(err),
diff --git a/src/app/users/users.component.ts b/src/app/users/users.component.ts
index e46fbcf77..d5e02b5b5 100644
--- a/src/app/users/users.component.ts
+++ b/src/app/users/users.component.ts
@@ -27,13 +27,13 @@ import { UsersStateService } from './users-state.service';
>
@@ -42,6 +42,7 @@ import { UsersStateService } from './users-state.service';
icon
class="mr-2 sm:hidden"
(click)="open_menu = true"
+ [attr.aria-label]="'COMMON.MENU' | translate"
>
menu
@@ -72,7 +73,7 @@ import { UsersStateService } from './users-state.service';
}
add
{
const item: PlaceZone = this._data.item;
this.loading.set(i18n(`${this._name}.SAVING`));
- this._dialog_ref.disableClose = true;
const item_json = item.toJSON ? item.toJSON() : item;
// `parent_zone` is UI-only; the backend uses `parent_id`, which the
// effect keeps in sync. Strip it so we never POST `parent_zone: null`.
@@ -442,13 +441,11 @@ export class ZoneFormComponent extends AsyncHandler implements OnInit {
form_item as unknown as PlaceZone,
)
: addZoneRequest(form_item as unknown as PlaceZone));
- this._dialog_ref.disableClose = false;
this.event.emit({ reason: 'done', metadata: { item: _item } });
notifySuccess(i18n(`${this._name}.SAVE_SUCCESS`));
this._dialog_ref.close();
} catch (err) {
this.loading.set(null);
- this._dialog_ref.disableClose = false;
notifyError(
i18n(`${this._name}.SAVE_ERROR`, {
error: await readError(err),
diff --git a/src/app/zones/zones-state.service.ts b/src/app/zones/zones-state.service.ts
index a78e4d5e0..078275799 100644
--- a/src/app/zones/zones-state.service.ts
+++ b/src/app/zones/zones-state.service.ts
@@ -20,6 +20,7 @@ import {
import { describeError, readError } from '../common/errors';
import { escapeHtml, unique } from '../common/general';
import { ActiveItemService } from '../common/item.service';
+import { reportLoadError } from '../common/load-error';
import { i18n } from '../common/locale.service';
import { notifyError, notifySuccess } from '../common/notifications';
import { openedTabs } from '../common/opened-tabs';
@@ -31,7 +32,11 @@ import {
querySupportZones as queryZones,
} from '../common/support-access';
import { DialogEvent, Identity } from '../common/types';
-import { GroupBulkAddModalComponent } from '../groups/group-bulk-add-modal.component';
+import {
+ GroupBulkAddModalComponent,
+ GroupBulkAddModalData,
+ GroupBulkAddResult,
+} from '../groups/group-bulk-add-modal.component';
import { GroupPermissionsModalComponent } from '../groups/group-permissions-modal.component';
import { openConfirmModal } from '../overlays/confirm-modal.component';
import {
@@ -111,7 +116,7 @@ export class ZonesStateService {
// Load every page so the list matches the tab count
const data = await collectPages(
querySystems({ zone_id: item.id, limit: 500 }),
- ).catch(() => [] as PlaceSystem[]);
+ ).catch(reportLoadError([] as PlaceSystem[]));
return { data, total: data.length };
},
});
@@ -124,7 +129,7 @@ export class ZonesStateService {
const { item } = params;
const empty = { data: [] as PlaceTrigger[], total: 0 };
if (!(item instanceof PlaceZone) || isSubsystemUser()) return empty;
- return listZoneTriggers(item.id).catch(() => empty);
+ return listZoneTriggers(item.id).catch(reportLoadError(empty));
},
});
@@ -162,7 +167,7 @@ export class ZonesStateService {
// Load every page so the list matches the tab count
const data = await collectPages(
queryZones({ parent_id: item.id, limit: 500 }),
- ).catch(() => [] as PlaceZone[]);
+ ).catch(reportLoadError([] as PlaceZone[]));
return { data, total: data.length };
},
});
@@ -184,7 +189,7 @@ export class ZonesStateService {
const response = await queryGroupZones({
zone_id: item.id,
limit: full ? 1000 : 1,
- }).catch(() => empty);
+ }).catch(reportLoadError(empty));
if (!full) return { ...empty, total: response.total };
return {
total: response.total,
@@ -306,9 +311,13 @@ export class ZonesStateService {
}
public async bulkAddGroups(existing_groups: PlaceGroupZone[] = []) {
- const groups = await waitForEvent(
+ const result = await waitForEvent(
this._dialog
- .open(GroupBulkAddModalComponent, {
+ .open<
+ GroupBulkAddModalComponent,
+ GroupBulkAddModalData,
+ GroupBulkAddResult
+ >(GroupBulkAddModalComponent, {
data: {
title: 'ZONES.GROUPS_BULK',
placeholder: 'GROUPS.SEARCH',
@@ -339,6 +348,7 @@ export class ZonesStateService {
})
.afterClosed(),
);
+ const groups = result?.items;
if (!groups?.length) return;
this._saving.set(true);
const results = await Promise.allSettled(
diff --git a/src/app/zones/zones.component.ts b/src/app/zones/zones.component.ts
index 4f216120d..1144fc81a 100644
--- a/src/app/zones/zones.component.ts
+++ b/src/app/zones/zones.component.ts
@@ -31,14 +31,14 @@ import { ZonesStateService } from './zones-state.service';
@@ -47,6 +47,7 @@ import { ZonesStateService } from './zones-state.service';
matRipple
class="mr-2 sm:hidden"
(click)="open_menu.set(true)"
+ [attr.aria-label]="'COMMON.MENU' | translate"
>
menu
@@ -76,7 +77,7 @@ import { ZonesStateService } from './zones-state.service';
}
add
label,
@@ -699,10 +695,11 @@ section > label {
min-width: 12rem;
}
+ /* Dimmed text colour, so it stays readable in light and dark themes */
::placeholder,
.mat-form-field-label,
.mat-mdc-select-placeholder {
- color: var(--neutral);
+ color: color-mix(in srgb, var(--base-content) 60%, transparent);
}
input {
diff --git a/src/tests/admin/signage-ai-provider-modal.component.spec.ts b/src/tests/admin/signage-ai-provider-modal.component.spec.ts
index d00b82160..dcea8274f 100644
--- a/src/tests/admin/signage-ai-provider-modal.component.spec.ts
+++ b/src/tests/admin/signage-ai-provider-modal.component.spec.ts
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { EMPTY } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SignageAIProviderModalComponent } from '../../app/admin/signage-ai/signage-ai-provider-modal.component';
@@ -20,7 +21,12 @@ describe('SignageAIProviderModalComponent', () => {
{ provide: MAT_DIALOG_DATA, useValue: {} },
{
provide: MatDialogRef,
- useValue: { close: vi.fn(), disableClose: false },
+ useValue: {
+ close: vi.fn(),
+ disableClose: false,
+ backdropClick: () => EMPTY,
+ keydownEvents: () => EMPTY,
+ },
},
],
})
diff --git a/src/tests/admin/signage-plugin-modal.component.spec.ts b/src/tests/admin/signage-plugin-modal.component.spec.ts
index ee624266d..cc45f3e9e 100644
--- a/src/tests/admin/signage-plugin-modal.component.spec.ts
+++ b/src/tests/admin/signage-plugin-modal.component.spec.ts
@@ -1,6 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { SignagePlugin } from '@placeos/ts-client';
+import { EMPTY } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SignagePluginModalComponent,
@@ -47,7 +48,12 @@ describe('SignagePluginModalComponent', () => {
},
{
provide: MatDialogRef,
- useValue: { close: vi.fn(), disableClose: false },
+ useValue: {
+ close: vi.fn(),
+ disableClose: false,
+ backdropClick: () => EMPTY,
+ keydownEvents: () => EMPTY,
+ },
},
{
provide: HotkeysService,
diff --git a/src/tests/admin/staff-tenant-modal.component.spec.ts b/src/tests/admin/staff-tenant-modal.component.spec.ts
index 4ec77c167..042852b2e 100644
--- a/src/tests/admin/staff-tenant-modal.component.spec.ts
+++ b/src/tests/admin/staff-tenant-modal.component.spec.ts
@@ -1,6 +1,7 @@
import { TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { post, put } from '@placeos/ts-client';
+import { EMPTY } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
StaffTenantModalComponent,
@@ -38,7 +39,12 @@ describe('Staff tenant advance booking limits', () => {
{ provide: MAT_DIALOG_DATA, useValue: data },
{
provide: MatDialogRef,
- useValue: { close: vi.fn(), disableClose: false },
+ useValue: {
+ close: vi.fn(),
+ disableClose: false,
+ backdropClick: () => EMPTY,
+ keydownEvents: () => EMPTY,
+ },
},
],
})
diff --git a/src/tests/common/forms.spec.ts b/src/tests/common/forms.spec.ts
new file mode 100644
index 000000000..14b321f6d
--- /dev/null
+++ b/src/tests/common/forms.spec.ts
@@ -0,0 +1,9 @@
+import { describe, expect, it } from 'vitest';
+import { fieldLabel } from '../../app/common/forms';
+
+describe('fieldLabel', () => {
+ it('turns form keys into readable names', () => {
+ expect(fieldLabel('zone')).toBe('Zone');
+ expect(fieldLabel('service_account')).toBe('Service account');
+ });
+});
diff --git a/src/tests/common/hotkeys.service.spec.ts b/src/tests/common/hotkeys.service.spec.ts
index b79baa4fc..fcf010055 100644
--- a/src/tests/common/hotkeys.service.spec.ts
+++ b/src/tests/common/hotkeys.service.spec.ts
@@ -159,6 +159,23 @@ describe('HotkeysService', () => {
});
});
+ describe('focused controls', () => {
+ it('ignores keys while a select has focus', () => {
+ const callback = vi.fn();
+ const subscription = service.listen(['ArrowRight'], callback);
+ const select = document.createElement('div');
+ select.setAttribute('role', 'combobox');
+ select.tabIndex = 0;
+ document.body.appendChild(select);
+ select.focus();
+ keyEvent('keydown', 'ArrowRight');
+ keyEvent('keyup', 'ArrowRight');
+ expect(callback).not.toHaveBeenCalled();
+ select.remove();
+ subscription?.unsubscribe();
+ });
+ });
+
describe('modifier keys', () => {
it('ignores a single key combination while Control or Meta is held', () => {
const callback = vi.fn();
diff --git a/src/tests/common/notifications.spec.ts b/src/tests/common/notifications.spec.ts
index 53ec32850..d88771612 100644
--- a/src/tests/common/notifications.spec.ts
+++ b/src/tests/common/notifications.spec.ts
@@ -181,6 +181,14 @@ describe('notifications.ts', () => {
);
});
+ it('should stay open until dismissed', () => {
+ notifyError('Error message');
+
+ expect(
+ mock_snackbar.open.mock.calls[0][2].duration,
+ ).toBeUndefined();
+ });
+
it('should log to console.error', () => {
notifyError('Error message');
diff --git a/src/tests/modules/module-form.component.spec.ts b/src/tests/modules/module-form.component.spec.ts
index 3ecba6e38..0668d135a 100644
--- a/src/tests/modules/module-form.component.spec.ts
+++ b/src/tests/modules/module-form.component.spec.ts
@@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { PlaceDriverRole, type PlaceDriver } from '@placeos/ts-client';
+import { EMPTY } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ModuleFormComponent } from '../../app/modules/module-form.component';
@@ -60,7 +61,14 @@ describe('ModuleFormComponent', () => {
providers: [
provideZonelessChangeDetection(),
provideNoopAnimations(),
- { provide: MatDialogRef, useValue: { close: vi.fn() } },
+ {
+ provide: MatDialogRef,
+ useValue: {
+ close: vi.fn(),
+ backdropClick: () => EMPTY,
+ keydownEvents: () => EMPTY,
+ },
+ },
{ provide: MAT_DIALOG_DATA, useValue: { item } },
],
}).compileComponents();
@@ -137,7 +145,14 @@ describe('creating a module', () => {
providers: [
provideZonelessChangeDetection(),
provideNoopAnimations(),
- { provide: MatDialogRef, useValue: { close: vi.fn() } },
+ {
+ provide: MatDialogRef,
+ useValue: {
+ close: vi.fn(),
+ backdropClick: () => EMPTY,
+ keydownEvents: () => EMPTY,
+ },
+ },
{ provide: MAT_DIALOG_DATA, useValue: { item } },
],
}).compileComponents();
diff --git a/src/tests/overlays/confirm-modal.component.spec.ts b/src/tests/overlays/confirm-modal.component.spec.ts
index d97dfe75d..a37f7667d 100644
--- a/src/tests/overlays/confirm-modal.component.spec.ts
+++ b/src/tests/overlays/confirm-modal.component.spec.ts
@@ -8,7 +8,7 @@ import {
} from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Subject } from 'rxjs';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock the ts-client - factory must be self-contained
vi.mock('@placeos/ts-client', () => {
@@ -109,6 +109,43 @@ describe('ConfirmModalComponent', () => {
});
});
+ describe('destructive confirmations', () => {
+ it('label the confirm button Delete and style it as an error', async () => {
+ await TestBed.resetTestingModule();
+ await TestBed.configureTestingModule({
+ imports: [
+ ConfirmModalComponent,
+ MatDialogModule,
+ NoopAnimationsModule,
+ ],
+ providers: [
+ { provide: MatDialogRef, useValue: dialog_ref_mock },
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: { ...default_data, destructive: true },
+ },
+ ],
+ })
+ .overrideComponent(ConfirmModalComponent, {
+ remove: { imports: [IconComponent] },
+ add: { imports: [MockComponent(IconComponent)] },
+ })
+ .compileComponents();
+ const destructive_fixture = TestBed.createComponent(
+ ConfirmModalComponent,
+ );
+ destructive_fixture.detectChanges();
+ expect(destructive_fixture.componentInstance.confirm_text).toBe(
+ 'COMMON.DELETE',
+ );
+ const accept: HTMLElement =
+ destructive_fixture.nativeElement.querySelector(
+ 'button[name="accept"]',
+ );
+ expect(accept.classList).toContain('error');
+ });
+ });
+
describe('with default values when data is missing', () => {
let minimal_component: ConfirmModalComponent;
diff --git a/src/tests/repositories/repository-form.component.spec.ts b/src/tests/repositories/repository-form.component.spec.ts
index d6fca41cb..d2fb76657 100644
--- a/src/tests/repositories/repository-form.component.spec.ts
+++ b/src/tests/repositories/repository-form.component.spec.ts
@@ -7,6 +7,7 @@ import {
listRepositoryBranches,
updateRepository,
} from '@placeos/ts-client';
+import { EMPTY } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { notifyError } from '../../app/common/notifications';
import { RepositoryFormComponent } from '../../app/repositories/repository-form.component';
@@ -44,7 +45,14 @@ describe('RepositoryFormComponent', () => {
providers: [
provideZonelessChangeDetection(),
provideNoopAnimations(),
- { provide: MatDialogRef, useValue: { close: vi.fn() } },
+ {
+ provide: MatDialogRef,
+ useValue: {
+ close: vi.fn(),
+ backdropClick: () => EMPTY,
+ keydownEvents: () => EMPTY,
+ },
+ },
{
provide: MAT_DIALOG_DATA,
useValue: {
diff --git a/src/tests/systems/system-trigger-form.component.spec.ts b/src/tests/systems/system-trigger-form.component.spec.ts
index ccc1f9570..3e2954918 100644
--- a/src/tests/systems/system-trigger-form.component.spec.ts
+++ b/src/tests/systems/system-trigger-form.component.spec.ts
@@ -2,6 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { PlaceTrigger, SignagePlaylist } from '@placeos/ts-client';
+import { EMPTY } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SystemTriggerFormComponent } from '../../app/systems/system-trigger-form.component';
@@ -37,7 +38,14 @@ describe('system trigger playlists', () => {
imports: [SystemTriggerFormComponent],
providers: [
provideZonelessChangeDetection(),
- { provide: MatDialogRef, useValue: { close: vi.fn() } },
+ {
+ provide: MatDialogRef,
+ useValue: {
+ close: vi.fn(),
+ backdropClick: () => EMPTY,
+ keydownEvents: () => EMPTY,
+ },
+ },
{
provide: MAT_DIALOG_DATA,
useValue: {
diff --git a/src/tests/ui/components/custom-tooltip.component.spec.ts b/src/tests/ui/components/custom-tooltip.component.spec.ts
index 89e7f77cc..4d2d6fe1e 100644
--- a/src/tests/ui/components/custom-tooltip.component.spec.ts
+++ b/src/tests/ui/components/custom-tooltip.component.spec.ts
@@ -1,9 +1,6 @@
-import { Component, TemplateRef, viewChild } from '@angular/core';
-import {
- ComponentFixture,
- TestBed,
-} from '@angular/core/testing';
import { OverlayContainer } from '@angular/cdk/overlay';
+import { Component, TemplateRef, viewChild } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CustomTooltipComponent } from '../../../app/ui/custom-tooltip.component';
@@ -85,6 +82,21 @@ describe('CustomTooltipComponent', () => {
expect(overlay_element.textContent).not.toContain('Tooltip content');
});
+ it('closes on Escape', async () => {
+ fixture.detectChanges();
+ fixture.nativeElement.querySelector('button').click();
+ await waitForTooltipDelay();
+ fixture.detectChanges();
+ expect(overlay_element.textContent).toContain('Tooltip content');
+
+ document.body.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
+ );
+ fixture.detectChanges();
+
+ expect(overlay_element.textContent).not.toContain('Tooltip content');
+ });
+
it('opens on mouse enter when hover is enabled', async () => {
fixture.componentInstance.backdrop = false;
fixture.componentInstance.hover = true;
diff --git a/src/tests/ui/components/fullscreen-modal-shell.component.spec.ts b/src/tests/ui/components/fullscreen-modal-shell.component.spec.ts
new file mode 100644
index 000000000..9944d8e23
--- /dev/null
+++ b/src/tests/ui/components/fullscreen-modal-shell.component.spec.ts
@@ -0,0 +1,151 @@
+import {
+ Component,
+ Pipe,
+ PipeTransform,
+ provideZonelessChangeDetection,
+ signal,
+} from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { MatChipsModule } from '@angular/material/chips';
+import { MatDialog, MatDialogRef } from '@angular/material/dialog';
+import { MatSelectModule } from '@angular/material/select';
+import { provideNoopAnimations } from '@angular/platform-browser/animations';
+import { Subject } from 'rxjs';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { openConfirmModal } from '../../../app/overlays/confirm-modal.component';
+import { FullscreenModalShellComponent } from '../../../app/ui/fullscreen-modal-shell.component';
+import { TranslatePipe } from '../../../app/ui/translate.pipe';
+
+vi.mock('@placeos/ts-client', () =>
+ vi.importActual('@placeos/ts-client/dist/index.es.js'),
+);
+vi.mock('../../../app/overlays/confirm-modal.component', () => ({
+ openConfirmModal: vi.fn(),
+}));
+
+@Pipe({ name: 'translate' })
+class TestTranslatePipe implements PipeTransform {
+ transform(value: string) {
+ return value;
+ }
+}
+
+@Component({
+ imports: [FullscreenModalShellComponent, MatChipsModule, MatSelectModule],
+ template: `
+
+
+
+ Tag
+ x
+
+
+
+ A
+ B
+
+
+ `,
+})
+class TestFormComponent {
+ public readonly loading = signal('');
+}
+
+describe('FullscreenModalShellComponent', () => {
+ const escape = new Subject();
+ const ref = {
+ close: vi.fn(),
+ disableClose: false,
+ backdropClick: () => new Subject(),
+ keydownEvents: () => escape,
+ };
+
+ function createForm() {
+ TestBed.configureTestingModule({
+ imports: [TestFormComponent],
+ providers: [
+ provideZonelessChangeDetection(),
+ provideNoopAnimations(),
+ { provide: MatDialogRef, useValue: ref },
+ { provide: MatDialog, useValue: {} },
+ ],
+ }).overrideComponent(FullscreenModalShellComponent, {
+ remove: { imports: [TranslatePipe] },
+ add: { imports: [TestTranslatePipe] },
+ });
+ const fixture = TestBed.createComponent(TestFormComponent);
+ fixture.detectChanges();
+ const element: HTMLElement = fixture.nativeElement;
+ const shell = fixture.debugElement.children[0]
+ .componentInstance as FullscreenModalShellComponent;
+ return { fixture, element, shell };
+ }
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ vi.mocked(openConfirmModal).mockResolvedValue({
+ reason: undefined,
+ loading: vi.fn(),
+ close: vi.fn(),
+ });
+ });
+
+ it('closes on Escape when nothing was edited', async () => {
+ createForm();
+ expect(ref.disableClose).toBe(true);
+ escape.next(new KeyboardEvent('keydown', { key: 'Escape' }));
+ await Promise.resolve();
+ expect(openConfirmModal).not.toHaveBeenCalled();
+ expect(ref.close).toHaveBeenCalled();
+ });
+
+ it('asks before closing once a field was typed in', async () => {
+ const { element } = createForm();
+ element
+ .querySelector('main')
+ .dispatchEvent(new Event('input', { bubbles: true }));
+ escape.next(new KeyboardEvent('keydown', { key: 'Escape' }));
+ await Promise.resolve();
+ expect(openConfirmModal).toHaveBeenCalled();
+ expect(ref.close).not.toHaveBeenCalled();
+ });
+
+ it('asks before closing after a chip was removed', async () => {
+ const { element, shell } = createForm();
+ element.querySelector('[matChipRemove]').click();
+ await shell.close();
+ expect(openConfirmModal).toHaveBeenCalled();
+ expect(ref.close).not.toHaveBeenCalled();
+ });
+
+ it('asks before closing after a select was changed by keyboard', async () => {
+ const { element, shell } = createForm();
+ const select = element.querySelector('mat-select');
+ select.focus();
+ select.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
+ );
+ await shell.close();
+ expect(openConfirmModal).toHaveBeenCalled();
+ });
+
+ it('does not count moving between fields as an edit', async () => {
+ const { element, shell } = createForm();
+ element
+ .querySelector('mat-select')
+ .dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }),
+ );
+ await shell.close();
+ expect(openConfirmModal).not.toHaveBeenCalled();
+ expect(ref.close).toHaveBeenCalled();
+ });
+
+ it('does not close while saving', async () => {
+ const { fixture, shell } = createForm();
+ fixture.componentInstance.loading.set('Saving...');
+ await fixture.whenStable();
+ await shell.close();
+ expect(ref.close).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/tests/ui/components/icon.component.spec.ts b/src/tests/ui/components/icon.component.spec.ts
index a2b102aa2..c996a91cd 100644
--- a/src/tests/ui/components/icon.component.spec.ts
+++ b/src/tests/ui/components/icon.component.spec.ts
@@ -1,4 +1,6 @@
+import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatTooltip } from '@angular/material/tooltip';
import { beforeEach, describe, expect, it } from 'vitest';
import { ApplicationIcon } from '../../../app/common/types';
import { IconComponent } from '../../../app/ui/icon.component';
@@ -123,4 +125,38 @@ describe('IconComponent', () => {
expect(wrapper.className).toContain('justify-center');
});
});
+
+ describe('inside buttons', () => {
+ @Component({
+ imports: [IconComponent, MatTooltip],
+ template: `
+
+ more_vert
+
+ save Save
+ warning refresh
+ `,
+ })
+ class TestButtonsComponent {}
+
+ it('labels icon-only buttons from their tooltip and hides icon names', async () => {
+ const host = TestBed.createComponent(TestButtonsComponent);
+ host.detectChanges();
+ await host.whenStable();
+ const [icon_only, with_text, two_icons] =
+ host.nativeElement.querySelectorAll('button');
+ expect(icon_only.getAttribute('aria-label')).toBe('More actions');
+ expect(
+ icon_only.querySelector('icon').getAttribute('aria-hidden'),
+ ).toBe('true');
+ // Other icons are not text, so these stay readable
+ expect(
+ two_icons.querySelector('icon').hasAttribute('aria-hidden'),
+ ).toBe(false);
+ expect(with_text.hasAttribute('aria-label')).toBe(false);
+ expect(
+ with_text.querySelector('icon').getAttribute('aria-hidden'),
+ ).toBe('true');
+ });
+ });
});
diff --git a/src/tests/ui/components/settings-form.component.spec.ts b/src/tests/ui/components/settings-form.component.spec.ts
index 37a8545bc..3b5edae87 100644
--- a/src/tests/ui/components/settings-form.component.spec.ts
+++ b/src/tests/ui/components/settings-form.component.spec.ts
@@ -98,6 +98,18 @@ describe('SettingsFormComponent', () => {
);
});
+ it('keeps unsaved edits on other levels after saving one', async () => {
+ const component = createForm();
+ component.updateSetting(0, 'level0: changed');
+ component.updateSetting(1, 'level1: changed');
+ component.save(0);
+ await Promise.resolve();
+ TestBed.tick();
+ expect(component.settingDirty(0)).toBe(false);
+ expect(component.settingValue(1)).toBe('level1: changed');
+ expect(component.settingDirty(1)).toBe(true);
+ });
+
it('masks admin settings for support users', () => {
mocks.user = { sys_admin: false, support: true };
const component = createForm();
diff --git a/src/tests/zones/zone-form.component.spec.ts b/src/tests/zones/zone-form.component.spec.ts
index 6aabc4bf5..f8cfc1bad 100644
--- a/src/tests/zones/zone-form.component.spec.ts
+++ b/src/tests/zones/zone-form.component.spec.ts
@@ -2,6 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { PlaceZone } from '@placeos/ts-client';
+import { EMPTY } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ZoneFormComponent } from '../../app/zones/zone-form.component';
@@ -32,7 +33,12 @@ vi.mock('../../app/common/notifications', () => ({
describe('saving zones', () => {
let fixture: ComponentFixture;
- const dialog = { close: vi.fn(), disableClose: false };
+ const dialog = {
+ close: vi.fn(),
+ disableClose: false,
+ backdropClick: () => EMPTY,
+ keydownEvents: () => EMPTY,
+ };
beforeEach(async () => {
vi.resetAllMocks();
@@ -112,7 +118,8 @@ describe('saving zones', () => {
'Renamed floor',
);
expect(fixture.componentInstance.loading()).toBeNull();
- expect(dialog.disableClose).toBe(false);
+ // The modal shell keeps guarding Escape and backdrop clicks
+ expect(dialog.disableClose).toBe(true);
expect(dialog.close).not.toHaveBeenCalled();
expect(mocks.notifyError).toHaveBeenCalled();
expect(mocks.notifySuccess).not.toHaveBeenCalled();
@@ -123,7 +130,12 @@ describe('saving zones', () => {
});
describe('saving zones with a stored parent', () => {
- const dialog = { close: vi.fn(), disableClose: false };
+ const dialog = {
+ close: vi.fn(),
+ disableClose: false,
+ backdropClick: () => EMPTY,
+ keydownEvents: () => EMPTY,
+ };
async function createForm() {
TestBed.configureTestingModule({
diff --git a/src/tests/zones/zones-state.service.spec.ts b/src/tests/zones/zones-state.service.spec.ts
new file mode 100644
index 000000000..282c7d8a2
--- /dev/null
+++ b/src/tests/zones/zones-state.service.spec.ts
@@ -0,0 +1,87 @@
+import { provideZonelessChangeDetection, signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { MatDialog } from '@angular/material/dialog';
+import { NavigationEnd, Router } from '@angular/router';
+import { PlaceGroup, PlaceZone } from '@placeos/ts-client';
+import { of, Subject } from 'rxjs';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ActiveItemService } from '../../app/common/item.service';
+import { ZonesStateService } from '../../app/zones/zones-state.service';
+
+const mocks = vi.hoisted(() => ({
+ addGroupZone: vi.fn(),
+ open: vi.fn(),
+ notifyError: vi.fn(),
+ notifySuccess: vi.fn(),
+}));
+vi.mock('@placeos/ts-client', async () => ({
+ ...(await vi.importActual(
+ '@placeos/ts-client/dist/index.es.js',
+ )),
+ addGroupZone: mocks.addGroupZone,
+}));
+vi.mock('../../app/common/item.service', () => ({
+ ActiveItemService: class {},
+}));
+vi.mock('../../app/common/locale.service', () => ({
+ i18n: (key: string, params?: { count?: number }) =>
+ params?.count === undefined ? key : `${key}:${params.count}`,
+}));
+vi.mock('../../app/common/notifications', () => ({
+ notifyError: mocks.notifyError,
+ notifySuccess: mocks.notifySuccess,
+}));
+vi.mock('../../app/groups/group-bulk-add-modal.component', () => ({
+ GroupBulkAddModalComponent: class {},
+}));
+
+describe('zone group bulk add', () => {
+ const zone = new PlaceZone({ id: 'zone-1' });
+ let service: ZonesStateService;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ mocks.addGroupZone.mockResolvedValue({});
+ TestBed.configureTestingModule({
+ providers: [
+ provideZonelessChangeDetection(),
+ ZonesStateService,
+ {
+ provide: ActiveItemService,
+ useValue: { item: signal(null), active_item: zone },
+ },
+ { provide: MatDialog, useValue: { open: mocks.open } },
+ {
+ provide: Router,
+ useValue: {
+ url: '/zones/zone-1/about',
+ events: new Subject(),
+ },
+ },
+ ],
+ });
+ service = TestBed.inject(ZonesStateService);
+ });
+
+ it('adds each group picked in the bulk modal', async () => {
+ mocks.open.mockReturnValue({
+ afterClosed: () =>
+ of({
+ items: [
+ new PlaceGroup({ id: 'group-1' }),
+ new PlaceGroup({ id: 'group-2' }),
+ ],
+ permissions: 0,
+ }),
+ });
+ await service.bulkAddGroups();
+ expect(mocks.addGroupZone).toHaveBeenCalledTimes(2);
+ expect(mocks.addGroupZone).toHaveBeenCalledWith({
+ group_id: 'group-2',
+ zone_id: 'zone-1',
+ });
+ expect(mocks.notifySuccess).toHaveBeenCalledExactlyOnceWith(
+ 'ZONES.GROUPS_BULK_SUCCESS:2',
+ );
+ });
+});