Deleting this zone will immediately remove systems without another 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_LOADING": "Deleting zone...",
"DELETE_SUCCESS": "Successfully deleted zone.",
"DELETE_ERROR": "Failed to delete zone. Error: {{ error }}",
@@ -436,7 +437,9 @@
"PARENT_ZONE": "Parent Zone",
"NAME_REQUIRED": "A unique zone name is required",
"DISPLAY_NAME": "Display name",
- "MISCONFIGURED": "Tags in zone require a parent zone"
+ "MISCONFIGURED": "Tags in zone require a parent zone",
+ "DELETE_CASCADE": "Also delete associated resources",
+ "DELETE_CASCADE_DESC": "Removes systems that would be left without any zone, along with the modules, triggers and settings belonging to them."
},
"DRIVERS": {
"SINGULAR": "Driver",
@@ -802,7 +805,7 @@
"BULK": "Bulk add domains",
"REMOVE": "Remove domain",
"DELETE": "Delete domain",
- "DELETE_MSG": "
Are you sure you want delete this domain?
The domain will be deleted immediately.
",
+ "DELETE_MSG": "
Are you sure you want delete this domain?
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 }}",
@@ -913,7 +916,9 @@
"APP_SCOPES": "Access Scopes",
"APP_SUBSYSTEMS": "Subsystems",
"APP_REDIRECT_URL": "Redirect URL",
- "APP_REDIRECT_URL_REQUIRED": "A valid URL is required"
+ "APP_REDIRECT_URL_REQUIRED": "A valid URL is required",
+ "DELETE_CASCADE": "Also delete associated resources",
+ "DELETE_CASCADE_DESC": "Removes this domain's OAuth applications and staff API tenant, plus its org zone tree when no other domain uses it."
},
"ADMIN": {
"TITLE": "Admin",
@@ -1200,5 +1205,49 @@
"BUILD_LIST_REMOVE_LOADING": "Cancelling build job...",
"BUILD_LIST_REMOVE_ERROR": "Failed to cancel build job. Error: {{ error }}",
"BUILD_LIST_REMOVE_SUCCESS": "Successfully cancelled build job."
+ },
+ "CASCADE": {
+ "RESOLVING": "Working out what would be removed...",
+ "NOTHING": "Nothing else to remove.",
+ "SCOPE_ZONES": "Scope: this zone and {{ count }} zones beneath it.",
+ "SCOPE_ZONES_1": "Scope: this zone and {{ count }} zone beneath it.",
+ "SCOPE_ORG_ZONE": "Scope: org zone \"{{ name }}\" and everything beneath it.",
+ "REMOVE_SYSTEMS": "{{ count }} systems left without a zone",
+ "REMOVE_SYSTEMS_1": "{{ count }} system left without a zone",
+ "REMOVE_MODULES": "{{ count }} modules in those systems (any also used by a system being kept will remain)",
+ "REMOVE_MODULES_1": "{{ count }} module in those systems (kept if another system also uses it)",
+ "REMOVE_APPLICATIONS": "{{ count }} OAuth applications",
+ "REMOVE_APPLICATIONS_1": "{{ count }} OAuth application",
+ "REMOVE_TENANTS": "{{ count }} staff API tenants, with their bookings, guests and survey data",
+ "REMOVE_TENANTS_1": "{{ count }} staff API tenant, with its bookings, guests and survey data",
+ "REMOVE_ORG_ZONE": "the org zone and every zone beneath it",
+ "KEEP_SYSTEMS": "{{ count }} systems also belong to zones outside this one and will be kept.",
+ "KEEP_SYSTEMS_1": "{{ count }} system also belongs to a zone outside this one and will be kept.",
+ "NO_ORG_ZONE": "This domain has no \"org_zone\" configured, so no zones can be matched to it. Delete its zones from the Zones page instead.",
+ "ORG_ZONE_SHARED": "The org zone is also used by {{ names }}, so it will be left alone. Delete it from the Zones page if that is what you want.",
+ "ORG_ZONE_MISSING": "The configured org zone ({{ id }}) no longer exists, so no zones will be removed.",
+ "REMOVING_SYSTEM": "Removing system \"{{ name }}\"",
+ "REMOVING_APPLICATION": "Removing application \"{{ name }}\"",
+ "REMOVING_TENANT": "Removing staff API tenant \"{{ name }}\"",
+ "REMOVING_ZONE": "Removing zone \"{{ name }}\"",
+ "PROGRESS": "{{ step }} ({{ index }} of {{ total }})",
+ "FAILED": "Failed to remove {{ count }} associated resources, so nothing further was deleted. Error: {{ error }}",
+ "FAILED_1": "Failed to remove {{ count }} associated resource, so nothing further was deleted. Error: {{ error }}",
+ "TYPE_SYSTEM": "System",
+ "TYPE_ZONE": "Zone",
+ "TYPE_APPLICATION": "Application",
+ "TYPE_TENANT": "Tenant",
+ "TYPE_DOMAIN": "Domain",
+ "REMOVING_DOMAIN": "Removing domain \"{{ name }}\"",
+ "RECEIPT_TITLE": "Removed",
+ "RECEIPT_NOTE": "Modules, triggers, metadata and settings belonging to these resources were removed with them by the server, so they are not listed individually.",
+ "RECEIPT_PARTIAL_TITLE": "Partly removed",
+ "RECEIPT_PARTIAL_NOTE": "\"{{ name }}\" was left in place because not everything associated with it could be removed. Resolve the failures above and try again.",
+ "RECEIPT_FAILED": "Could not be removed",
+ "RECEIPT_SKIPPED": "Not attempted",
+ "PLAN_UNAVAILABLE": "Could not work out what else would be removed, so nothing was deleted. Try again in a moment.",
+ "RECEIPT_COPY": "Copy list",
+ "RECEIPT_COPIED": "Copied {{ count }} rows to the clipboard.",
+ "RECEIPT_COPIED_1": "Copied {{ count }} row to the clipboard."
}
}
diff --git a/public/assets/locale/en-GB.json b/public/assets/locale/en-GB.json
index 17eab3696..a56a1f765 100644
--- a/public/assets/locale/en-GB.json
+++ b/public/assets/locale/en-GB.json
@@ -42,6 +42,7 @@
"NOTES": "Notes",
"NONE": "None",
"CANCEL": "Cancel",
+ "CLOSE": "Close",
"CONFIRM": "Confirm",
"SAVE": "Save",
"SAVE_ALL": "Save All",
@@ -346,7 +347,7 @@
"ADD": "Add zone",
"EDIT": "Edit zone",
"DELETE": "Delete zone",
- "DELETE_MSG": "
Are you sure you want delete this zone?
Deleting this zone will immediately remove systems without another 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_LOADING": "Deleting zone...",
"DELETE_SUCCESS": "Successfully deleted zone.",
"DELETE_ERROR": "Failed to delete zone. Error: {{ error }}",
@@ -385,7 +386,9 @@
"TRIGGERS_EMPTY": "No triggers for selected zone",
"PARENT_ZONE": "Parent Zone",
"NAME_REQUIRED": "A unique zone name is required",
- "DISPLAY_NAME": "Display name"
+ "DISPLAY_NAME": "Display name",
+ "DELETE_CASCADE": "Also delete associated resources",
+ "DELETE_CASCADE_DESC": "Removes systems that would be left without any zone, along with the modules, triggers and settings belonging to them."
},
"DRIVERS": {
"SINGULAR": "Driver",
@@ -645,7 +648,7 @@
"BULK": "Bulk add domains",
"REMOVE": "Remove domain",
"DELETE": "Delete domain",
- "DELETE_MSG": "
Are you sure you want delete this domain?
The domain will be deleted immediately.
",
+ "DELETE_MSG": "
Are you sure you want delete this domain?
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 }}",
@@ -750,7 +753,9 @@
"APP_SCOPES": "Access Scopes",
"APP_SUBSYSTEMS": "Subsystems",
"APP_REDIRECT_URL": "Redirect URL",
- "APP_REDIRECT_URL_REQUIRED": "A valid URL is required"
+ "APP_REDIRECT_URL_REQUIRED": "A valid URL is required",
+ "DELETE_CASCADE": "Also delete associated resources",
+ "DELETE_CASCADE_DESC": "Removes this domain's OAuth applications and staff API tenant, plus its org zone tree when no other domain uses it."
},
"ADMIN": {
"TITLE": "Admin",
@@ -990,5 +995,49 @@
"UPLOADS_LIB_FIELD_TYPE": "File Type",
"UPLOADS_LIB_FIELD_SIZE": "Size",
"UPLOADS_LIB_LIST_EMPTY": "No uploads for the selected domain"
+ },
+ "CASCADE": {
+ "RESOLVING": "Working out what would be removed...",
+ "NOTHING": "Nothing else to remove.",
+ "SCOPE_ZONES": "Scope: this zone and {{ count }} zones beneath it.",
+ "SCOPE_ZONES_1": "Scope: this zone and {{ count }} zone beneath it.",
+ "SCOPE_ORG_ZONE": "Scope: org zone \"{{ name }}\" and everything beneath it.",
+ "REMOVE_SYSTEMS": "{{ count }} systems left without a zone",
+ "REMOVE_SYSTEMS_1": "{{ count }} system left without a zone",
+ "REMOVE_MODULES": "{{ count }} modules in those systems (any also used by a system being kept will remain)",
+ "REMOVE_MODULES_1": "{{ count }} module in those systems (kept if another system also uses it)",
+ "REMOVE_APPLICATIONS": "{{ count }} OAuth applications",
+ "REMOVE_APPLICATIONS_1": "{{ count }} OAuth application",
+ "REMOVE_TENANTS": "{{ count }} staff API tenants, with their bookings, guests and survey data",
+ "REMOVE_TENANTS_1": "{{ count }} staff API tenant, with its bookings, guests and survey data",
+ "REMOVE_ORG_ZONE": "the org zone and every zone beneath it",
+ "KEEP_SYSTEMS": "{{ count }} systems also belong to zones outside this one and will be kept.",
+ "KEEP_SYSTEMS_1": "{{ count }} system also belongs to a zone outside this one and will be kept.",
+ "NO_ORG_ZONE": "This domain has no \"org_zone\" configured, so no zones can be matched to it. Delete its zones from the Zones page instead.",
+ "ORG_ZONE_SHARED": "The org zone is also used by {{ names }}, so it will be left alone. Delete it from the Zones page if that is what you want.",
+ "ORG_ZONE_MISSING": "The configured org zone ({{ id }}) no longer exists, so no zones will be removed.",
+ "REMOVING_SYSTEM": "Removing system \"{{ name }}\"",
+ "REMOVING_APPLICATION": "Removing application \"{{ name }}\"",
+ "REMOVING_TENANT": "Removing staff API tenant \"{{ name }}\"",
+ "REMOVING_ZONE": "Removing zone \"{{ name }}\"",
+ "PROGRESS": "{{ step }} ({{ index }} of {{ total }})",
+ "FAILED": "Failed to remove {{ count }} associated resources, so nothing further was deleted. Error: {{ error }}",
+ "FAILED_1": "Failed to remove {{ count }} associated resource, so nothing further was deleted. Error: {{ error }}",
+ "TYPE_SYSTEM": "System",
+ "TYPE_ZONE": "Zone",
+ "TYPE_APPLICATION": "Application",
+ "TYPE_TENANT": "Tenant",
+ "TYPE_DOMAIN": "Domain",
+ "REMOVING_DOMAIN": "Removing domain \"{{ name }}\"",
+ "RECEIPT_TITLE": "Removed",
+ "RECEIPT_NOTE": "Modules, triggers, metadata and settings belonging to these resources were removed with them by the server, so they are not listed individually.",
+ "RECEIPT_PARTIAL_TITLE": "Partly removed",
+ "RECEIPT_PARTIAL_NOTE": "\"{{ name }}\" was left in place because not everything associated with it could be removed. Resolve the failures above and try again.",
+ "RECEIPT_FAILED": "Could not be removed",
+ "RECEIPT_SKIPPED": "Not attempted",
+ "PLAN_UNAVAILABLE": "Could not work out what else would be removed, so nothing was deleted. Try again in a moment.",
+ "RECEIPT_COPY": "Copy list",
+ "RECEIPT_COPIED": "Copied {{ count }} rows to the clipboard.",
+ "RECEIPT_COPIED_1": "Copied {{ count }} row to the clipboard."
}
}
diff --git a/public/assets/locale/en-US.json b/public/assets/locale/en-US.json
index 35030d5f6..ae82c659e 100644
--- a/public/assets/locale/en-US.json
+++ b/public/assets/locale/en-US.json
@@ -38,6 +38,7 @@
"NOTES": "Notes",
"NONE": "None",
"CANCEL": "Cancel",
+ "CLOSE": "Close",
"CONFIRM": "Confirm",
"SAVE": "Save",
"SAVE_ALL": "Save All",
@@ -343,7 +344,7 @@
"ADD": "Add zone",
"EDIT": "Edit zone",
"DELETE": "Delete zone",
- "DELETE_MSG": "
Are you sure you want delete this zone?
Deleting this zone will immediately remove systems without another 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_LOADING": "Deleting zone...",
"DELETE_SUCCESS": "Successfully deleted zone.",
"DELETE_ERROR": "Failed to delete zone. Error: {{ error }}",
@@ -382,7 +383,9 @@
"TRIGGERS_EMPTY": "No triggers for selected zone",
"PARENT_ZONE": "Parent Zone",
"NAME_REQUIRED": "A unique zone name is required",
- "DISPLAY_NAME": "Display name"
+ "DISPLAY_NAME": "Display name",
+ "DELETE_CASCADE": "Also delete associated resources",
+ "DELETE_CASCADE_DESC": "Removes systems that would be left without any zone, along with the modules, triggers and settings belonging to them."
},
"DRIVERS": {
"SINGULAR": "Driver",
@@ -642,7 +645,7 @@
"BULK": "Bulk add domains",
"REMOVE": "Remove domain",
"DELETE": "Delete domain",
- "DELETE_MSG": "
Are you sure you want delete this domain?
The domain will be deleted immediately.
",
+ "DELETE_MSG": "
Are you sure you want delete this domain?
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 }}",
@@ -747,7 +750,9 @@
"APP_SCOPES": "Access Scopes",
"APP_SUBSYSTEMS": "Subsystems",
"APP_REDIRECT_URL": "Redirect URL",
- "APP_REDIRECT_URL_REQUIRED": "A valid URL is required"
+ "APP_REDIRECT_URL_REQUIRED": "A valid URL is required",
+ "DELETE_CASCADE": "Also delete associated resources",
+ "DELETE_CASCADE_DESC": "Removes this domain's OAuth applications and staff API tenant, plus its org zone tree when no other domain uses it."
},
"ADMIN": {
"TITLE": "Admin",
@@ -987,5 +992,49 @@
"UPLOADS_LIB_FIELD_TYPE": "File Type",
"UPLOADS_LIB_FIELD_SIZE": "Size",
"UPLOADS_LIB_LIST_EMPTY": "No uploads for the selected domain"
+ },
+ "CASCADE": {
+ "RESOLVING": "Working out what would be removed...",
+ "NOTHING": "Nothing else to remove.",
+ "SCOPE_ZONES": "Scope: this zone and {{ count }} zones beneath it.",
+ "SCOPE_ZONES_1": "Scope: this zone and {{ count }} zone beneath it.",
+ "SCOPE_ORG_ZONE": "Scope: org zone \"{{ name }}\" and everything beneath it.",
+ "REMOVE_SYSTEMS": "{{ count }} systems left without a zone",
+ "REMOVE_SYSTEMS_1": "{{ count }} system left without a zone",
+ "REMOVE_MODULES": "{{ count }} modules in those systems (any also used by a system being kept will remain)",
+ "REMOVE_MODULES_1": "{{ count }} module in those systems (kept if another system also uses it)",
+ "REMOVE_APPLICATIONS": "{{ count }} OAuth applications",
+ "REMOVE_APPLICATIONS_1": "{{ count }} OAuth application",
+ "REMOVE_TENANTS": "{{ count }} staff API tenants, with their bookings, guests and survey data",
+ "REMOVE_TENANTS_1": "{{ count }} staff API tenant, with its bookings, guests and survey data",
+ "REMOVE_ORG_ZONE": "the org zone and every zone beneath it",
+ "KEEP_SYSTEMS": "{{ count }} systems also belong to zones outside this one and will be kept.",
+ "KEEP_SYSTEMS_1": "{{ count }} system also belongs to a zone outside this one and will be kept.",
+ "NO_ORG_ZONE": "This domain has no \"org_zone\" configured, so no zones can be matched to it. Delete its zones from the Zones page instead.",
+ "ORG_ZONE_SHARED": "The org zone is also used by {{ names }}, so it will be left alone. Delete it from the Zones page if that is what you want.",
+ "ORG_ZONE_MISSING": "The configured org zone ({{ id }}) no longer exists, so no zones will be removed.",
+ "REMOVING_SYSTEM": "Removing system \"{{ name }}\"",
+ "REMOVING_APPLICATION": "Removing application \"{{ name }}\"",
+ "REMOVING_TENANT": "Removing staff API tenant \"{{ name }}\"",
+ "REMOVING_ZONE": "Removing zone \"{{ name }}\"",
+ "PROGRESS": "{{ step }} ({{ index }} of {{ total }})",
+ "FAILED": "Failed to remove {{ count }} associated resources, so nothing further was deleted. Error: {{ error }}",
+ "FAILED_1": "Failed to remove {{ count }} associated resource, so nothing further was deleted. Error: {{ error }}",
+ "TYPE_SYSTEM": "System",
+ "TYPE_ZONE": "Zone",
+ "TYPE_APPLICATION": "Application",
+ "TYPE_TENANT": "Tenant",
+ "TYPE_DOMAIN": "Domain",
+ "REMOVING_DOMAIN": "Removing domain \"{{ name }}\"",
+ "RECEIPT_TITLE": "Removed",
+ "RECEIPT_NOTE": "Modules, triggers, metadata and settings belonging to these resources were removed with them by the server, so they are not listed individually.",
+ "RECEIPT_PARTIAL_TITLE": "Partly removed",
+ "RECEIPT_PARTIAL_NOTE": "\"{{ name }}\" was left in place because not everything associated with it could be removed. Resolve the failures above and try again.",
+ "RECEIPT_FAILED": "Could not be removed",
+ "RECEIPT_SKIPPED": "Not attempted",
+ "PLAN_UNAVAILABLE": "Could not work out what else would be removed, so nothing was deleted. Try again in a moment.",
+ "RECEIPT_COPY": "Copy list",
+ "RECEIPT_COPIED": "Copied {{ count }} rows to the clipboard.",
+ "RECEIPT_COPIED_1": "Copied {{ count }} row to the clipboard."
}
}
diff --git a/public/assets/locale/es.json b/public/assets/locale/es.json
index 955948eb3..64c6825dd 100644
--- a/public/assets/locale/es.json
+++ b/public/assets/locale/es.json
@@ -340,7 +340,6 @@
"ADD": "Agregar zona",
"EDIT": "Editar zona",
"DELETE": "Eliminar zona",
- "DELETE_MSG": "
¿Estás seguro de que deseas eliminar esta zona?
Al eliminar esta zona, se eliminarán inmediatamente los sistemas que no tengan otra zona
",
"DELETE_LOADING": "Eliminando zona...",
"DELETE_SUCCESS": "Zona eliminada correctamente.",
"DELETE_ERROR": "No se pudo eliminar la zona. Error: {{ error }}",
diff --git a/public/assets/locale/fr.json b/public/assets/locale/fr.json
index 414d7bd1a..707e21852 100644
--- a/public/assets/locale/fr.json
+++ b/public/assets/locale/fr.json
@@ -340,7 +340,6 @@
"ADD": "Ajouter une zone",
"EDIT": "Modifier la zone",
"DELETE": "Supprimer la zone",
- "DELETE_MSG": "
Êtes-vous sûr de vouloir supprimer cette zone ?
La suppression de cette zone va immédiatement retirer les systèmes sans une autre zone
",
"DELETE_LOADING": "Suppression de la zone...",
"DELETE_SUCCESS": "Zone supprimée avec succès.",
"DELETE_ERROR": "Échec de la suppression de la zone. Erreur : {{ error }}",
diff --git a/public/assets/locale/jp.json b/public/assets/locale/jp.json
index 276c492bd..a15a06a5e 100644
--- a/public/assets/locale/jp.json
+++ b/public/assets/locale/jp.json
@@ -342,7 +342,6 @@
"ADD": "ゾーンを追加",
"EDIT": "ゾーンを編集",
"DELETE": "ゾーンを削除",
- "DELETE_MSG": "
このゾーンを削除してよろしいですか?
このゾーンを削除すると、他のゾーンに属さないシステムは即時に削除されます
",
"DELETE_LOADING": "ゾーンを削除中...",
"DELETE_SUCCESS": "ゾーンを正常に削除しました。",
"DELETE_ERROR": "ゾーンの削除に失敗しました。エラー:{{ error }}",
diff --git a/src/app/common/actions.ts b/src/app/common/actions.ts
index 9909051af..ca92ba218 100644
--- a/src/app/common/actions.ts
+++ b/src/app/common/actions.ts
@@ -58,6 +58,12 @@ import {
updateUser,
updateZone,
} from '@placeos/ts-client';
+import {
+ CascadePlan,
+ CascadeResourceType,
+ planDomainCascade,
+ planZoneCascade,
+} from './cascade-delete';
import { DomainFormComponent } from '../domains/domain-form.component';
import { DriverFormComponent } from '../drivers/driver-form.component';
import { GroupFormComponent } from '../groups/group-form.component';
@@ -68,6 +74,22 @@ import { TriggerFormComponent } from '../triggers/trigger-form.component';
import { UserFormComponent } from '../users/user-form.component';
import { ZoneFormComponent } from '../zones/zone-form.component';
+/**
+ * Optional "also remove the things associated with this item" behaviour,
+ * surfaced as a checkbox on the delete confirmation. Off by default — deleting
+ * an item without touching its associated resources stays the default.
+ */
+export interface ItemCascade {
+ /** i18n key for the checkbox label */
+ label: string;
+ /** i18n key for the text shown under the checkbox */
+ description: string;
+ /** Type of the item itself, so it can be labelled on the receipt */
+ resource_type: CascadeResourceType;
+ /** Resolves what would be removed alongside the item */
+ plan: (_: T) => Promise;
+}
+
export interface ItemActions {
query: (_?: string) => QueryResponse;
show: (_: string) => Promise;
@@ -77,6 +99,7 @@ export interface ItemActions {
modalComponent: Type;
delete_message: string;
delete_extra?: (_: T) => Promise<[string, string]>;
+ cascade?: ItemCascade;
name: string;
}
@@ -92,6 +115,12 @@ const domains: ItemActions = {
itemConstructor: PlaceDomain,
modalComponent: DomainFormComponent,
delete_message: ``,
+ cascade: {
+ label: 'DOMAINS.DELETE_CASCADE',
+ description: 'DOMAINS.DELETE_CASCADE_DESC',
+ resource_type: 'domain',
+ plan: (item) => planDomainCascade(item),
+ },
name: 'DOMAINS',
};
@@ -319,6 +348,12 @@ const zones: ItemActions = {
itemConstructor: PlaceZone,
modalComponent: ZoneFormComponent,
delete_message: ``,
+ cascade: {
+ label: 'ZONES.DELETE_CASCADE',
+ description: 'ZONES.DELETE_CASCADE_DESC',
+ resource_type: 'zone',
+ plan: (item) => planZoneCascade(item.id),
+ },
name: 'ZONES',
};
diff --git a/src/app/common/cascade-delete.ts b/src/app/common/cascade-delete.ts
new file mode 100644
index 000000000..feb6da2b5
--- /dev/null
+++ b/src/app/common/cascade-delete.ts
@@ -0,0 +1,501 @@
+import {
+ del,
+ get,
+ PlaceDomain,
+ PlaceSystem,
+ PlaceZone,
+ queryApplications,
+ queryDomains,
+ QueryResponse,
+ querySystems,
+ queryZones,
+ removeApplication,
+ removeSystem,
+ removeZone,
+ showSystem,
+ showZone,
+} from '@placeos/ts-client';
+import type { PlaceTenant } from '../admin/staff-api.component';
+import { i18n } from './locale.service';
+
+/**
+ * Resolution and execution of "delete the things associated with this item".
+ *
+ * PlaceOS already cascades most relationships server side — deleting a zone
+ * takes its child zones, trigger instances, metadata, settings and group links;
+ * deleting a system takes any module used by only that system. The one
+ * relationship that is *not* cascaded is `sys.zones`, a text array, so a system
+ * whose only zones are deleted is left orphaned with `zones: []`. Everything
+ * here exists to close that gap, plus the handful of authority-scoped records
+ * that have no foreign key back to `authority`.
+ *
+ * See tasks/PPT-1203 for the full relationship audit.
+ */
+
+/** Page size used when walking collections. Server caps a page at 10000. */
+const PAGE_SIZE = 500;
+/** Ceiling on zones walked in one subtree, guards against a cyclic `parent_id`. */
+const MAX_ZONES = 5000;
+/** Ceiling on pages followed for a single query. */
+const MAX_PAGES = 100;
+/** Concurrent requests issued while resolving a plan. */
+const READ_CONCURRENCY = 8;
+
+/** Kinds of resource a cascade can remove directly. */
+export type CascadeResourceType =
+ | 'system'
+ | 'zone'
+ | 'application'
+ | 'tenant'
+ | 'domain';
+
+/** A resource a cascade removed, or is about to. */
+export interface CascadeResource {
+ type: CascadeResourceType;
+ id: string;
+ name: string;
+}
+
+/** i18n key for the progress message of each resource type */
+const REMOVING_KEY: Record = {
+ system: 'CASCADE.REMOVING_SYSTEM',
+ zone: 'CASCADE.REMOVING_ZONE',
+ application: 'CASCADE.REMOVING_APPLICATION',
+ tenant: 'CASCADE.REMOVING_TENANT',
+ domain: 'CASCADE.REMOVING_DOMAIN',
+};
+
+/** Progress message shown while `resource` is being removed. */
+export const removingLabel = (resource: CascadeResource) =>
+ i18n(REMOVING_KEY[resource.type], { name: resource.name });
+
+/** A single removal performed as part of a cascade. */
+export interface CascadeStep {
+ /** What the step removes, reported back once it has run */
+ resource: CascadeResource;
+ /** Performs the removal. Rejects on failure. */
+ run: () => Promise;
+}
+
+/** What a cascade would do, resolved before the user confirms it. */
+export interface CascadePlan {
+ /** Lines describing the scope the cascade was resolved over */
+ scope: string[];
+ /** Lines describing what the cascade will remove */
+ summary: string[];
+ /** Lines describing what the cascade deliberately leaves alone */
+ warnings: string[];
+ /** Removals, in the order they must happen */
+ steps: CascadeStep[];
+}
+
+/** Result of executing a `CascadePlan`. */
+export interface CascadeOutcome {
+ /** Resources that were removed, in the order they went */
+ removed: CascadeResource[];
+ /** Steps that threw, kept so the caller can report them */
+ failures: { resource: CascadeResource; error: unknown }[];
+ /** Steps never attempted because an earlier one failed */
+ skipped: CascadeResource[];
+}
+
+const emptyPlan = (): CascadePlan => ({
+ scope: [],
+ summary: [],
+ warnings: [],
+ steps: [],
+});
+
+/** Runs `fn` over `list` with at most `limit` requests in flight. */
+async function mapLimit(
+ list: T[],
+ limit: number,
+ fn: (item: T) => Promise,
+): Promise {
+ const results: R[] = new Array(list.length);
+ let next_index = 0;
+ const worker = async () => {
+ while (next_index < list.length) {
+ const index = next_index++;
+ results[index] = await fn(list[index]);
+ }
+ };
+ const size = Math.min(limit, list.length);
+ await Promise.all(new Array(size).fill(0).map(() => worker()));
+ return results;
+}
+
+/** Collects every page of a paginated query. */
+async function collectPages(request: QueryResponse): Promise {
+ const items: T[] = [];
+ let page = await request;
+ items.push(...page.data);
+ let pages = 1;
+ while (page.next) {
+ // A truncated list would be indistinguishable from a complete one, and
+ // every caller uses this to decide what a delete will take with it. So
+ // running out of pages has to be an error rather than a short answer.
+ if (pages >= MAX_PAGES) {
+ throw new Error(
+ `Listing did not complete within ${MAX_PAGES} pages of ${PAGE_SIZE}`,
+ );
+ }
+ const next_page = page.next();
+ if (!next_page) break;
+ page = await next_page;
+ if (!page.data.length) break;
+ items.push(...page.data);
+ pages += 1;
+ }
+ return items;
+}
+
+/**
+ * IDs of `zone_id` and every zone beneath it, walked breadth first. `parent_id`
+ * accepts a comma separated list so this costs one request per level of the
+ * tree rather than one per zone.
+ */
+export async function zoneSubtreeIds(zone_id: string): Promise {
+ if (!zone_id) return [];
+ const found = [zone_id];
+ const seen = new Set(found);
+ let level = [zone_id];
+ while (level.length) {
+ // Truncating here would understate the tree, which decides both what
+ // gets deleted and whether another domain shares it. Same reasoning as
+ // `collectPages`: an incomplete answer must not look like a complete
+ // one.
+ if (found.length >= MAX_ZONES) {
+ throw new Error(
+ `Zone tree beneath ${zone_id} exceeds ${MAX_ZONES} zones`,
+ );
+ }
+ const children = await collectPages(
+ queryZones({ parent_id: level.join(','), limit: PAGE_SIZE }),
+ );
+ level = [];
+ for (const zone of children) {
+ if (!zone?.id || seen.has(zone.id)) continue;
+ seen.add(zone.id);
+ found.push(zone.id);
+ level.push(zone.id);
+ }
+ }
+ return found;
+}
+
+/** Systems in a zone subtree, split by whether they survive its removal. */
+export interface ZoneSystemSplit {
+ /** Systems whose every zone is inside the subtree — these would be orphaned */
+ orphaned: PlaceSystem[];
+ /** Systems that also belong to a zone outside the subtree — these are kept */
+ retained: PlaceSystem[];
+}
+
+/**
+ * Resolves the systems attached to a zone subtree.
+ *
+ * `GET /systems?zone_id=` ANDs its zone list server side, so "in any of these
+ * zones" needs one query per zone, deduplicated by system id.
+ *
+ * The index is Elasticsearch backed and can lag the database. Since a stale
+ * `zones` array here would mean deleting a system that still belongs
+ * somewhere, every removal candidate is re-read through `showSystem` (which
+ * reads the database) and re-checked before it makes the list.
+ */
+export async function splitZoneSystems(
+ zone_ids: string[],
+): Promise {
+ const subtree = new Set(zone_ids);
+ // A system with no zones at all is not "entirely inside" the subtree — it
+ // has no relationship to it. `[].every()` is `true`, so without the length
+ // check a system whose zones were just cleared, and which the search index
+ // still lists under this zone, would be treated as orphaned and deleted.
+ const inside = (system: PlaceSystem) => {
+ const zones = system.zones || [];
+ return zones.length > 0 && zones.every((id) => subtree.has(id));
+ };
+
+ const found = new Map();
+ // Not caught, for the same reason as the domain listings: a zone whose
+ // systems cannot be listed would contribute nothing to the plan, and the
+ // zone would then be deleted with those systems still inside it.
+ const pages = await mapLimit(zone_ids, READ_CONCURRENCY, (zone_id) =>
+ collectPages(querySystems({ zone_id, limit: PAGE_SIZE })),
+ );
+ for (const list of pages) {
+ for (const system of list) if (system?.id) found.set(system.id, system);
+ }
+
+ const candidates: PlaceSystem[] = [];
+ const retained: PlaceSystem[] = [];
+ for (const system of found.values()) {
+ (inside(system) ? candidates : retained).push(system);
+ }
+
+ const confirmed = await mapLimit(candidates, READ_CONCURRENCY, (system) =>
+ showSystem(system.id).catch(() => null),
+ );
+ const orphaned: PlaceSystem[] = [];
+ confirmed.forEach((current) => {
+ // A system that has since been deleted, or that has picked up a zone
+ // outside the subtree, is left alone.
+ if (!current) return;
+ (inside(current) ? orphaned : retained).push(current);
+ });
+ return { orphaned, retained };
+}
+
+/**
+ * Resolves the removals needed so that deleting `zone_id` does not leave
+ * orphaned systems behind. Does **not** include removal of the zone itself —
+ * for a zone delete that is the caller's existing `remove` action, and for a
+ * domain delete `planDomainCascade` appends it.
+ */
+export async function planZoneCascade(
+ zone_id: string,
+ /** Subtree ids, when the caller has already walked them. */
+ subtree_ids?: string[],
+): Promise {
+ const plan = emptyPlan();
+ const zone_ids = subtree_ids ?? (await zoneSubtreeIds(zone_id));
+ if (!zone_ids.length) return plan;
+ const { orphaned, retained } = await splitZoneSystems(zone_ids);
+ const module_count = new Set(
+ orphaned.flatMap((system) => [...(system.modules || [])]),
+ ).size;
+
+ const child_count = zone_ids.length - 1;
+ if (child_count) {
+ plan.scope.push(
+ i18n('CASCADE.SCOPE_ZONES', { count: child_count }, child_count),
+ );
+ }
+ if (orphaned.length) {
+ plan.summary.push(
+ i18n(
+ 'CASCADE.REMOVE_SYSTEMS',
+ { count: orphaned.length },
+ orphaned.length,
+ ),
+ );
+ if (module_count) {
+ plan.summary.push(
+ i18n(
+ 'CASCADE.REMOVE_MODULES',
+ { count: module_count },
+ module_count,
+ ),
+ );
+ }
+ }
+ if (retained.length) {
+ plan.warnings.push(
+ i18n(
+ 'CASCADE.KEEP_SYSTEMS',
+ { count: retained.length },
+ retained.length,
+ ),
+ );
+ }
+ plan.steps = orphaned.map((system) => ({
+ resource: {
+ type: 'system' as const,
+ id: system.id,
+ name: system.name,
+ },
+ run: () => removeSystem(system.id),
+ }));
+ return plan;
+}
+
+/**
+ * Tenants configured in the staff API against `domain`.
+ *
+ * Not caught. This listing crosses a service boundary — staff API is separate
+ * and its tenants index is admin gated — so it fails for reasons that have
+ * nothing to do with the domain having no tenant: a restart, an ingress 502, a
+ * token that PlaceOS accepts but staff API does not. Returning an empty list
+ * for those made the plan say "Nothing else to remove", which is a positive
+ * claim of absence, and the tenant would then survive the domain that owned it
+ * still holding its encrypted calendar credentials.
+ */
+async function domainTenants(domain: string): Promise {
+ if (!domain) return [];
+ const tenants = (await get('/api/staff/v1/tenants')) as PlaceTenant[];
+ return (tenants || []).filter((tenant) => tenant?.domain === domain);
+}
+
+/** The `org_zone` a domain points at, if it declares one. */
+function orgZoneId(domain: PlaceDomain): string {
+ return `${domain?.config?.org_zone || ''}`;
+}
+
+/**
+ * Resolves the removals associated with a domain.
+ *
+ * Users, auth sources, groups, playlists, signage plugins, shorteners, pending
+ * mail, asset categories and alert dashboards already cascade when the domain
+ * is deleted (model callbacks and DB foreign keys), so they are not listed
+ * here. What does not cascade — and so is handled here — is OAuth applications
+ * (`oauth_applications.owner_id` has no foreign key) and the staff API tenant
+ * (a separate service, linked only by matching domain name).
+ *
+ * Zones are only reachable through the `authority.config.org_zone` convention.
+ * That convention is not exclusive — multiple domains can and do point at the
+ * same org zone — so the zone tree is only included when no other domain
+ * references it.
+ */
+export async function planDomainCascade(
+ domain: PlaceDomain,
+): Promise {
+ const plan = emptyPlan();
+ const org_zone_id = orgZoneId(domain);
+ // None of these are caught. A failed or truncated listing used to read as
+ // "nothing found", which is the dangerous direction: an unavailable
+ // ownership list makes a shared org zone look unshared, and this function
+ // would then plan to delete another domain's zone tree. Letting the
+ // rejection through means the caller cannot offer a cascade it has not
+ // been able to size.
+ const [applications, tenants, all_domains] = await Promise.all([
+ collectPages(
+ queryApplications({ authority_id: domain.id, limit: PAGE_SIZE }),
+ ),
+ domainTenants(domain.domain),
+ org_zone_id
+ ? collectPages(queryDomains({ limit: PAGE_SIZE }))
+ : Promise.resolve([] as PlaceDomain[]),
+ ]);
+
+ if (applications.length) {
+ plan.summary.push(
+ i18n(
+ 'CASCADE.REMOVE_APPLICATIONS',
+ { count: applications.length },
+ applications.length,
+ ),
+ );
+ plan.steps.push(
+ ...applications.map((application) => ({
+ resource: {
+ type: 'application' as const,
+ id: `${application.id}`,
+ name: application.name,
+ },
+ run: () => removeApplication(application.id),
+ })),
+ );
+ }
+
+ if (tenants.length) {
+ plan.summary.push(
+ i18n(
+ 'CASCADE.REMOVE_TENANTS',
+ { count: tenants.length },
+ tenants.length,
+ ),
+ );
+ plan.steps.push(
+ ...tenants.map((tenant) => ({
+ resource: {
+ type: 'tenant' as const,
+ id: `${tenant.id}`,
+ name: tenant.name || tenant.domain,
+ },
+ run: () => del(`/api/staff/v1/tenants/${tenant.id}`),
+ })),
+ );
+ }
+
+ if (!org_zone_id) {
+ plan.warnings.push(i18n('CASCADE.NO_ORG_ZONE'));
+ return plan;
+ }
+
+ // Deleting the org zone destroys every zone beneath it as well — `Zone`
+ // declares its children `dependent: :destroy`. So another domain is
+ // affected not only when it points at this exact zone, but when it points
+ // at anything inside the tree that is about to go. Comparing ids alone let
+ // a domain whose org zone was a *child* of this one be silently destroyed.
+ const org_subtree_ids = await zoneSubtreeIds(org_zone_id);
+ const org_subtree = new Set(org_subtree_ids);
+ const sharing = all_domains.filter((other) => {
+ if (other.id === domain.id) return false;
+ const other_zone = orgZoneId(other);
+ return !!other_zone && org_subtree.has(other_zone);
+ });
+ if (sharing.length) {
+ plan.warnings.push(
+ i18n('CASCADE.ORG_ZONE_SHARED', {
+ names: sharing.map((other) => other.name).join(', '),
+ }),
+ );
+ return plan;
+ }
+
+ const org_zone: PlaceZone | null = await showZone(org_zone_id).catch(
+ () => null,
+ );
+ if (!org_zone) {
+ plan.warnings.push(
+ i18n('CASCADE.ORG_ZONE_MISSING', { id: org_zone_id }),
+ );
+ return plan;
+ }
+
+ // Reuse the walk done for the sharing check rather than repeating it.
+ const zone_plan = await planZoneCascade(org_zone_id, org_subtree_ids);
+ // The org zone line already says "and everything beneath it", so the zone
+ // plan's own scope line would just repeat it.
+ plan.scope.push(i18n('CASCADE.SCOPE_ORG_ZONE', { name: org_zone.name }));
+ plan.summary.push(...zone_plan.summary, i18n('CASCADE.REMOVE_ORG_ZONE'));
+ plan.warnings.push(...zone_plan.warnings);
+ plan.steps.push(...zone_plan.steps, {
+ resource: {
+ type: 'zone' as const,
+ id: org_zone_id,
+ name: org_zone.name,
+ },
+ run: () => removeZone(org_zone_id),
+ });
+ return plan;
+}
+
+/**
+ * Executes a plan's steps in order. Steps run sequentially — each system
+ * removal cascades work on the server, and sequential execution gives honest
+ * progress and lets a partial failure be reported precisely.
+ *
+ * The run stops at the first failure. Steps are ordered so that later ones
+ * depend on earlier ones having gone: `planDomainCascade` appends the org zone
+ * after the systems inside it, so carrying on past a failed system removal
+ * would delete the zone out from under it. Everything after the failure is
+ * reported as skipped rather than attempted.
+ */
+export async function runCascade(
+ plan: CascadePlan,
+ progress: (message: string) => void = () => undefined,
+): Promise {
+ const outcome: CascadeOutcome = { removed: [], failures: [], skipped: [] };
+ const total = plan.steps.length;
+ for (const [index, step] of plan.steps.entries()) {
+ if (outcome.failures.length) {
+ outcome.skipped.push(step.resource);
+ continue;
+ }
+ progress(
+ i18n('CASCADE.PROGRESS', {
+ step: removingLabel(step.resource),
+ index: index + 1,
+ total,
+ }),
+ );
+ try {
+ await step.run();
+ outcome.removed.push(step.resource);
+ } catch (error) {
+ outcome.failures.push({ resource: step.resource, error });
+ }
+ }
+ return outcome;
+}
diff --git a/src/app/common/item.service.ts b/src/app/common/item.service.ts
index a6aa56782..ef6360f92 100644
--- a/src/app/common/item.service.ts
+++ b/src/app/common/item.service.ts
@@ -21,16 +21,34 @@ import {
CONFIRM_METADATA,
ConfirmModalComponent,
ConfirmModalData,
+ describeError,
} from '../overlays/confirm-modal.component';
import { DuplicateModalComponent } from '../overlays/duplicate-modal.component';
import { BackofficeUsersService } from '../users/users.service';
import { ACTIONS, ItemActions } from './actions';
import { AsyncHandler } from './async-handler.class';
+import {
+ CascadeOutcome,
+ CascadePlan,
+ CascadeResource,
+ runCascade,
+} from './cascade-delete';
import { log } from './general';
import { i18n } from './locale.service';
import { notifyError, notifySuccess } from './notifications';
import { waitForEvent } from './signals';
+/** Id the "also delete associated resources" toggle is reported under */
+const CASCADE_OPTION = 'cascade';
+
+/** Turns removed resources into the receipt rows the confirm modal renders. */
+const receiptItems = (resources: CascadeResource[]) =>
+ resources.map((resource) => ({
+ type: i18n(`CASCADE.TYPE_${resource.type.toUpperCase()}`),
+ id: resource.id,
+ name: resource.name,
+ }));
+
export type ResourceType =
| 'domains'
| 'drivers'
@@ -246,57 +264,203 @@ export class ActiveItemService extends AsyncHandler {
public async delete() {
if (!this._user.current().sys_admin) return;
const item = this._active_item();
- if (item) {
- const ref = this._dialog.open<
- ConfirmModalComponent,
- ConfirmModalData
- >(ConfirmModalComponent, {
+ if (!item) return;
+ const actions = this.actions;
+ const cascade = actions.cascade;
+ // Resolved lazily, only if the user enables the option — a cascade
+ // plan walks the whole zone subtree, which is not free.
+ let plan: CascadePlan | null = null;
+ const ref = this._dialog.open(
+ ConfirmModalComponent,
+ {
...CONFIRM_METADATA,
data: {
- title: i18n(`${this.actions.name}.DELETE`),
- content: i18n(`${this.actions.name}.DELETE_MSG`, {
+ title: i18n(`${actions.name}.DELETE`),
+ content: i18n(`${actions.name}.DELETE_MSG`, {
name:
(item as PlaceResource & { display_name?: string })
.display_name || item.name,
}),
- extra: this.actions.delete_extra
- ? await this.actions.delete_extra(item)
+ extra: actions.delete_extra
+ ? await actions.delete_extra(item)
: null,
+ options: cascade
+ ? [
+ {
+ id: CASCADE_OPTION,
+ label: i18n(cascade.label),
+ description: i18n(cascade.description),
+ details: async () => {
+ plan = await cascade.plan(item);
+ const { scope, summary, warnings } = plan;
+ return { scope, summary, warnings };
+ },
+ },
+ ]
+ : undefined,
icon: { type: 'icon', content: 'delete' },
},
- });
- waitForEvent(
- ref.componentInstance.event,
- (e: DialogEvent) => e.reason === 'done',
- ).then(async () => {
- ref.componentInstance.loading.set(
- i18n(`${this.actions.name}.DELETE_LOADING`),
- );
- await this.actions
- .remove(item)
- .then(() => {
- notifySuccess(
- i18n(`${this.actions.name}.DELETE_SUCCESS`, {
- name: item.name,
- }),
+ },
+ );
+ waitForEvent(
+ ref.componentInstance.event,
+ (e: DialogEvent) => e.reason === 'done',
+ )
+ .then(
+ async (event: DialogEvent<{ options?: HashMap }>) => {
+ ref.componentInstance?.loading.set(
+ i18n(`${actions.name}.DELETE_LOADING`),
+ );
+ let outcome: CascadeOutcome | null = null;
+ const cascade_selected =
+ !!event.metadata?.options?.[CASCADE_OPTION];
+ if (cascade_selected && !plan) {
+ // The breakdown never resolved, so there is nothing to run.
+ // Falling through here would delete the item on its own and
+ // orphan everything the cascade existed to take with it. The
+ // modal blocks this too; this is the second line of defence.
+ ref.componentInstance?.loading.set('');
+ return notifyError(i18n('CASCADE.PLAN_UNAVAILABLE'));
+ }
+ if (cascade_selected && plan) {
+ // Each step is its own request, so a large cascade runs for
+ // tens of seconds with only a spinner on screen — the footer
+ // is hidden while loading, which leaves Escape and the
+ // backdrop as the only things to press. Dismissing mid-run
+ // tears down the component this callback writes to and
+ // abandons the rest of an irreversible operation with no
+ // record of what already went.
+ ref.componentInstance?.disableClose();
+ outcome = await runCascade(plan, (message) =>
+ ref.componentInstance?.loading.set(message),
);
- this._active_item.set(null);
- this.removeItem(item);
- this._router.navigate([`/${this._type}`, '-', 'about']);
- ref.close();
- })
- .catch((err) => {
- ref.componentInstance.loading.set('');
- notifyError(
- i18n(`${this.actions.name}.DELETE_ERROR`, {
- error: JSON.stringify(
- err.response || err.message || err,
+ ref.componentInstance?.enableClose();
+ if (outcome.failures.length) {
+ // Something is still referencing the item, so leave it in
+ // place and show what did and did not go.
+ ref.componentInstance?.loading.set('');
+ ref.componentInstance?.result.set({
+ title: i18n('CASCADE.RECEIPT_PARTIAL_TITLE'),
+ items: receiptItems(outcome.removed),
+ failed: receiptItems(
+ outcome.failures.map((_) => _.resource),
+ ),
+ skipped: receiptItems(outcome.skipped),
+ note: i18n('CASCADE.RECEIPT_PARTIAL_NOTE', {
+ name: item.name,
+ }),
+ });
+ return notifyError(
+ i18n(
+ 'CASCADE.FAILED',
+ {
+ count: outcome.failures.length,
+ // The status is what tells an admin
+ // why an irreversible run stopped.
+ // ts-client rejects with a raw
+ // Response, which has no `.message`,
+ // so the old fallback printed the
+ // resource name as if it were the
+ // reason — and the receipt already
+ // names the resource anyway.
+ error: describeError(
+ outcome.failures[0].error,
+ ),
+ },
+ outcome.failures.length,
),
- }),
+ );
+ }
+ ref.componentInstance?.loading.set(
+ i18n(`${actions.name}.DELETE_LOADING`),
);
- });
+ }
+ await actions
+ .remove(item)
+ .then(() => {
+ this._active_item.set(null);
+ this.removeItem(item);
+ this._router.navigate([
+ `/${this._type}`,
+ '-',
+ 'about',
+ ]);
+ if (outcome) {
+ // A cascade removed more than the item itself, so show
+ // the receipt rather than closing on a notification.
+ ref.componentInstance?.result.set({
+ title: i18n('CASCADE.RECEIPT_TITLE'),
+ items: receiptItems([
+ ...outcome.removed,
+ {
+ type: cascade.resource_type,
+ id: `${item.id}`,
+ name: item.name,
+ },
+ ]),
+ note: i18n('CASCADE.RECEIPT_NOTE'),
+ });
+ return;
+ }
+ notifySuccess(
+ i18n(`${actions.name}.DELETE_SUCCESS`, {
+ name: item.name,
+ }),
+ );
+ ref.close();
+ })
+ .catch((err) => {
+ ref.componentInstance?.loading.set('');
+ if (outcome?.removed.length) {
+ // The cascade succeeded and only the item itself
+ // failed. Its removals are irreversible and this
+ // receipt is the only record of them, so show it
+ // rather than closing on a toast that reads as though
+ // nothing happened.
+ ref.componentInstance?.result.set({
+ title: i18n(
+ 'CASCADE.RECEIPT_PARTIAL_TITLE',
+ ),
+ items: receiptItems(outcome.removed),
+ failed: receiptItems([
+ {
+ type: cascade.resource_type,
+ id: `${item.id}`,
+ name: item.name,
+ },
+ ]),
+ note: i18n('CASCADE.RECEIPT_PARTIAL_NOTE', {
+ name: item.name,
+ }),
+ });
+ }
+ notifyError(
+ i18n(`${actions.name}.DELETE_ERROR`, {
+ error: JSON.stringify(
+ err.response || err.message || err,
+ ),
+ }),
+ );
+ });
+ },
+ )
+ .catch((err: unknown) => {
+ // Nothing above may fail silently. This chain runs an irreversible
+ // operation, and an unhandled rejection here would leave the user
+ // with a spinner and no idea whether anything happened.
+ log(
+ 'ITEM',
+ 'Delete flow failed',
+ [describeError(err)],
+ 'error',
+ );
+ ref.componentInstance?.loading.set('');
+ notifyError(
+ i18n(`${actions.name}.DELETE_ERROR`, {
+ error: describeError(err),
+ }),
+ );
});
- }
}
public duplicate() {
@@ -386,7 +550,12 @@ export class ActiveItemService extends AsyncHandler {
this._list.set([]);
}
const resp = await next().catch((err) => {
- log('Service', `Error loading ${type} list.`, [err], 'warn');
+ log(
+ 'Service',
+ `Error loading ${type} list.`,
+ [err],
+ 'warn',
+ );
return null;
});
if (!resp) {
diff --git a/src/app/mocks/backend/zones.mock.ts b/src/app/mocks/backend/zones.mock.ts
index 00b71bd05..4a674b4ad 100644
--- a/src/app/mocks/backend/zones.mock.ts
+++ b/src/app/mocks/backend/zones.mock.ts
@@ -14,8 +14,16 @@ const FILTER_FN = (item: Record, q: HashMap) => {
.toLowerCase()
.indexOf(((q.q as string) || '').toLowerCase()) >= 0;
}
- if (q.parent) {
- match = match && item.parent_id === q.parent;
+ if (q.parent_id) {
+ // Matches the API: a comma separated list of parents, plus the
+ // special `root` value for zones without one.
+ const parents = `${q.parent_id}`.split(',').filter((_) => !!_);
+ const parent_id = `${item.parent_id || ''}`;
+ match =
+ match &&
+ parents.some((parent) =>
+ parent === 'root' ? !parent_id : parent === parent_id,
+ );
}
if (q.control_system_id) {
const system = endpointData(`${API}/systems`).find(
diff --git a/src/app/mocks/data/zones.ts b/src/app/mocks/data/zones.ts
index 36eed244c..b9bf5d45a 100644
--- a/src/app/mocks/data/zones.ts
+++ b/src/app/mocks/data/zones.ts
@@ -305,6 +305,7 @@ export const ZONES = [
triggers: [],
created_at: 1543374809,
id: 'zone-iIdF20naW0',
+ parent_id: 'zone-LEHeo501Er',
},
{
name: 'L31 Activity Spaces',
@@ -389,6 +390,7 @@ export const ZONES = [
triggers: ['trigger-WzXonXrB4G'],
created_at: 1519368108,
id: 'zone-WjDE_sLQy8',
+ parent_id: 'zone-QjLXbYUxuC',
},
{
name: 'L31 Multifunction: 31.22',
@@ -446,6 +448,7 @@ export const ZONES = [
triggers: [],
created_at: 1529567548,
id: 'zone-beI-19FMdl',
+ parent_id: 'zone-QjLXbYUxuC',
},
{
name: 'L31 R7 Activity Space',
@@ -530,6 +533,7 @@ export const ZONES = [
triggers: [],
created_at: 1547438444,
id: 'zone-kG8cn_fkH9',
+ parent_id: 'zone-QjLXbYUxuC',
},
{
name: 'Level 30',
@@ -539,6 +543,7 @@ export const ZONES = [
triggers: [],
created_at: 1495599360,
id: 'zone-LEHeo501Er',
+ parent_id: 'zone-Kl0HN~nDwc',
},
{
name: 'Level 31',
@@ -548,6 +553,7 @@ export const ZONES = [
triggers: [],
created_at: 1506945022,
id: 'zone-QjLXbYUxuC',
+ parent_id: 'zone-Kl0HN~nDwc',
},
{
name: 'New Zone',
@@ -811,5 +817,6 @@ export const ZONES = [
triggers: [],
created_at: 1494571187,
id: 'zone-Kl0HN~nDwc',
+ parent_id: 'zone-Kl0E0HmCJ3',
},
];
diff --git a/src/app/overlays/confirm-modal.component.ts b/src/app/overlays/confirm-modal.component.ts
index f872e87ba..66f169fd8 100644
--- a/src/app/overlays/confirm-modal.component.ts
+++ b/src/app/overlays/confirm-modal.component.ts
@@ -3,6 +3,7 @@ import {
EventEmitter,
OnInit,
Output,
+ computed,
inject,
signal,
} from '@angular/core';
@@ -13,15 +14,77 @@ import {
MatDialogRef,
} from '@angular/material/dialog';
+import { Clipboard } from '@angular/cdk/clipboard';
+import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatRippleModule } from '@angular/material/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { lastValueFrom } from 'rxjs';
import { AsyncHandler } from '../common/async-handler.class';
+import { i18n } from '../common/locale.service';
+import { notifyInfo } from '../common/notifications';
import { waitForEvent } from '../common/signals';
import { ApplicationIcon, DialogEvent } from '../common/types';
import { IconComponent } from '../ui/icon.component';
import { TranslatePipe } from '../ui/translate.pipe';
+/** Breakdown of what enabling a `ConfirmModalOption` would do */
+export interface ConfirmModalOptionDetails {
+ /** Lines describing the scope the option was resolved over */
+ scope?: string[];
+ /** Lines describing what will additionally be removed */
+ summary?: string[];
+ /** Lines describing what will deliberately be left alone */
+ warnings?: string[];
+}
+
+/** Opt-in toggle offered alongside the confirmation */
+export interface ConfirmModalOption {
+ /** Identifier the selection is reported under */
+ id: string;
+ /** Label displayed beside the checkbox */
+ label: string;
+ /** Explanatory text displayed under the checkbox */
+ description?: string;
+ /** Whether the option starts enabled. Defaults to `false` */
+ enabled?: boolean;
+ /**
+ * Resolves a breakdown of the option's effect. Run the first time the
+ * option is enabled so the cost is only paid when the user asks for it.
+ * Confirmation is blocked until it settles.
+ */
+ details?: () => Promise;
+}
+
+/** Options selected on confirmation, keyed by `ConfirmModalOption.id` */
+export type ConfirmModalSelection = Record;
+
+/** A resource listed in the post-action receipt */
+export interface ConfirmModalResultItem {
+ /** Short type label, e.g. "System" */
+ type: string;
+ /** Resource id, so the removal can be traced afterwards */
+ id: string;
+ /** Resource name at the time it was removed */
+ name: string;
+}
+
+/**
+ * Replaces the modal body once the action has run, so the user gets a receipt
+ * of what actually happened rather than a transient notification.
+ */
+export interface ConfirmModalResult {
+ /** Heading above the list */
+ title: string;
+ /** Resources that were removed */
+ items: ConfirmModalResultItem[];
+ /** Resources that could not be removed */
+ failed?: ConfirmModalResultItem[];
+ /** Resources never attempted, because an earlier step failed */
+ skipped?: ConfirmModalResultItem[];
+ /** Explanatory line under the list */
+ note?: string;
+}
+
export interface ConfirmModalData {
/** Title of the modal */
title: string;
@@ -29,6 +92,8 @@ export interface ConfirmModalData {
content: string;
/** Contents of the modal */
extra?: [string, string];
+ /** Opt-in toggles offered alongside the confirmation */
+ options?: ConfirmModalOption[];
/** Text displaed on the confirmation button */
confirm_text?: string;
/** Text displaed on the confirmation button */
@@ -39,12 +104,61 @@ export interface ConfirmModalData {
close_delay?: number;
}
+/**
+ * Renders a receipt as tab separated rows, for pasting into a ticket or
+ * spreadsheet. Failed rows are marked so a partial run is not mistaken for a
+ * complete one.
+ */
+/**
+ * Readable text for whatever an option's `details()` rejected with.
+ *
+ * ts-client throws the raw `Response` for any non-OK status, and interpolating
+ * that yields "[object Response]" — which is what the user would otherwise be
+ * shown as the reason they cannot continue.
+ */
+export function describeError(error: unknown): string {
+ if (!error) return 'Unknown error';
+ if (typeof error === 'string') return error;
+ if (typeof Response !== 'undefined' && error instanceof Response) {
+ return `${error.status} ${error.statusText || 'request failed'}`.trim();
+ }
+ const message = (error as Error)?.message;
+ if (message) return message;
+ const status = (error as { status?: number; statusText?: string })?.status;
+ if (status) {
+ return `${status} ${
+ (error as { statusText?: string }).statusText || 'request failed'
+ }`.trim();
+ }
+ return 'Unknown error';
+}
+
+export function receiptToTsv(result: ConfirmModalResult): string {
+ const rows = [
+ ...result.items.map((item) => [item.type, item.name, item.id]),
+ ...(result.failed || []).map((item) => [
+ item.type,
+ item.name,
+ item.id,
+ 'FAILED',
+ ]),
+ ...(result.skipped || []).map((item) => [
+ item.type,
+ item.name,
+ item.id,
+ 'SKIPPED',
+ ]),
+ ];
+ return rows.map((row) => row.join('\t')).join('\n');
+}
+
export const CONFIRM_METADATA = {
height: 'auto',
};
export interface ConfirmRepsonse {
reason: 'done' | '' | null;
+ metadata?: { options?: ConfirmModalSelection };
loading: (_: string) => void;
close: () => void;
}
@@ -79,9 +193,98 @@ export async function openConfirmModal(
-