From 7ab4e09f1232be79d1412ce9f1cb22ab98b2894d Mon Sep 17 00:00:00 2001
From: Cameron Reeves
Date: Tue, 28 Jul 2026 14:49:15 +1000
Subject: [PATCH 1/9] feat(delete): optionally remove resources associated with
a zone or domain (PPT-1203)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Deleting a zone has never removed the systems inside it. `Zone#destroy`
cascades child zones, trigger instances, metadata, settings and group links,
but `sys.zones` is a text array with no foreign key, so the zone id was simply
stripped and any system left with no zones became an orphan (PROJ-845 — the
dev install currently carries six such systems). The confirmation copy already
claimed otherwise: "Deleting this zone will immediately remove systems without
another zone".
The delete confirmation now offers "Also delete associated resources",
**off by default** — deleting an item without touching what hangs off it stays
the default behaviour. Switching it on resolves a plan first and shows exactly
what would go before anything is confirmed.
Zones remove the systems whose every zone falls inside the subtree, so a system
shared with a zone outside it is kept, as it is today. The backend takes their
modules, triggers, metadata and settings.
Domains remove their OAuth applications (`oauth_applications.owner_id` has no
foreign key, so these orphan today) and their staff API tenant, matched on
domain name the same way the admin screen does. Zones are only reachable from a
domain through the `authority.config.org_zone` convention, which is not
exclusive — three domains share one org zone on the dev install — so the zone
tree is only included when no other domain references it, and the dialog names
the domains that caused it to be skipped.
Because the removals are destructive, the system index (Elasticsearch, which
can lag) only nominates candidates; each one is re-read through `showSystem`
and re-checked against the database before it makes the list.
Also here:
- `ConfirmModalComponent` gains optional checkboxes with lazily resolved
detail, reported back on the confirmation event. Existing callers pass no
options and are unaffected.
- The zone mock filtered on a `parent` query param the API has never sent;
it now honours `parent_id`, including comma separated lists and `root`.
Mock zones gained the `parent_id` hierarchy their `zones` arrays imply.
- `config/proxy.conf.js` accepts `PLACEOS_DOMAIN` so the dev server can point
at a local stack.
- `ZONES.DELETE_MSG` now describes what actually happens. The stale
translation was dropped from the non-English locales so they fall back to
the corrected source string rather than repeating the old claim.
Verified against a local PlaceOS stack: an org > building > two levels tree
with three systems (two enclosed, one straddling an outside zone) and their
modules. With the option on, the tree, both enclosed systems and both modules
were removed while the straddling system survived holding only its outside
zone; with it off, the zone went and the system was left orphaned exactly as
before. A domain delete removed its org zone tree, orphaned system, OAuth
application and staff API tenant; a domain sharing its org zone with another
left the zone, its system and the other domain untouched.
---
config/proxy.conf.js | 6 +-
e2e/src/cascade-delete.spec.ts | 139 ++++++
e2e/src/pages/base.page.ts | 51 ++
public/assets/locale/ar.json | 1 -
public/assets/locale/en-AU.json | 42 +-
public/assets/locale/en-GB.json | 42 +-
public/assets/locale/en-US.json | 42 +-
public/assets/locale/es.json | 1 -
public/assets/locale/fr.json | 1 -
public/assets/locale/jp.json | 1 -
src/app/common/actions.ts | 30 ++
src/app/common/cascade-delete.ts | 401 ++++++++++++++++
src/app/common/item.service.ts | 135 ++++--
src/app/mocks/backend/zones.mock.ts | 12 +-
src/app/mocks/data/zones.ts | 7 +
src/app/overlays/confirm-modal.component.ts | 182 ++++++-
src/tests/common/cascade-delete.spec.ts | 454 ++++++++++++++++++
src/tests/common/cascade-locale.spec.ts | 80 +++
.../overlays/confirm-modal.component.spec.ts | 149 ++++++
19 files changed, 1715 insertions(+), 61 deletions(-)
create mode 100644 e2e/src/cascade-delete.spec.ts
create mode 100644 src/app/common/cascade-delete.ts
create mode 100644 src/tests/common/cascade-delete.spec.ts
create mode 100644 src/tests/common/cascade-locale.spec.ts
diff --git a/config/proxy.conf.js b/config/proxy.conf.js
index 44c634f24..22855ba21 100644
--- a/config/proxy.conf.js
+++ b/config/proxy.conf.js
@@ -1,5 +1,7 @@
-const domain = 'placeos-dev.aca.im';
-const secure = true;
+// Override to develop against another environment, e.g. the local PlaceOS
+// stack: `PLACEOS_DOMAIN=localhost:8443 bun run start`
+const domain = process.env.PLACEOS_DOMAIN || 'placeos-dev.aca.im';
+const secure = process.env.PLACEOS_INSECURE !== 'true';
const valid_ssl = false;
const PROXY_CONFIG = {};
diff --git a/e2e/src/cascade-delete.spec.ts b/e2e/src/cascade-delete.spec.ts
new file mode 100644
index 000000000..7f2b63084
--- /dev/null
+++ b/e2e/src/cascade-delete.spec.ts
@@ -0,0 +1,139 @@
+import { expect, test } from '@playwright/test';
+import { ZonesPage } from './pages';
+
+/**
+ * Optional cascade delete (PPT-1203)
+ *
+ * Deleting a zone has never removed the systems inside it — the zone id was
+ * just stripped from `sys.zones`, leaving systems orphaned (PROJ-845). The
+ * delete confirmation now offers to remove them, off by default.
+ *
+ * The mock zone tree these tests rely on:
+ *
+ * Place Technology (org)
+ * └── Tower 2 (building)
+ * ├── Level 30
+ * │ └── L30 Activity Spaces
+ * └── Level 31
+ * ├── L31 Activity Spaces
+ * ├── L31 Multifunction: 31.22
+ * └── L31 R7 Activity Space
+ * New Zone (root, no children, no systems)
+ */
+test.describe('Cascade delete', () => {
+ let zonesPage: ZonesPage;
+
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript(() => {
+ localStorage.setItem('BACKOFFICE.mock', 'true');
+ });
+ zonesPage = new ZonesPage(page);
+ });
+
+ const openZone = async (page, zone_id: string) => {
+ await page.goto(`/?mock=true#/zones/${zone_id}/about`);
+ await zonesPage.waitForLoad();
+ await page.waitForSelector('item-details', { timeout: 20000 });
+ };
+
+ test('offers the option, disabled by default', async ({ page }) => {
+ await openZone(page, 'zone-lmhh_hVfz0');
+ await zonesPage.openDeleteConfirmation();
+
+ await expect(zonesPage.cascadeCheckbox).toHaveCount(1);
+ await expect(zonesPage.cascadeCheckbox).not.toBeChecked();
+ // Nothing is resolved until the option is switched on
+ await expect(zonesPage.cascadeSummary).toHaveCount(0);
+ });
+
+ test('no longer claims that systems are removed by default', async ({
+ page,
+ }) => {
+ await openZone(page, 'zone-lmhh_hVfz0');
+ await zonesPage.openDeleteConfirmation();
+
+ const content = await page
+ .locator('confirm-modal [content]')
+ .innerText();
+ expect(content).toContain('are kept unless you also remove');
+ });
+
+ test('reports nothing to remove for an empty zone', async ({ page }) => {
+ await openZone(page, 'zone-lmhh_hVfz0');
+ await zonesPage.openDeleteConfirmation();
+ await zonesPage.enableCascade();
+
+ await expect(zonesPage.cascadeEmpty).toBeVisible();
+ await expect(zonesPage.cascadeSummary).toHaveCount(0);
+ });
+
+ test('lists the systems and modules that would be removed', async ({
+ page,
+ }) => {
+ await openZone(page, 'zone-Kl0E0HmCJ3'); // Place Technology (org)
+ await zonesPage.openDeleteConfirmation();
+ await zonesPage.enableCascade();
+
+ const summary = await zonesPage.cascadeSummary.innerText();
+ expect(summary).toMatch(/\d+ systems? left without a zone/);
+ expect(summary).toMatch(/\d+ modules? in those systems/);
+
+ const scope = await page.locator('confirm-modal').innerText();
+ expect(scope).toMatch(/Scope: this zone and \d+ zones? beneath it/);
+ });
+
+ test('keeps systems that also belong to a zone outside the subtree', async ({
+ page,
+ }) => {
+ // Level 30's systems are also in Tower 2, which is above it — they
+ // survive, so the cascade must remove nothing and say so.
+ await openZone(page, 'zone-LEHeo501Er');
+ await zonesPage.openDeleteConfirmation();
+ await zonesPage.enableCascade();
+
+ await expect(zonesPage.cascadeEmpty).toBeVisible();
+ const warning = await zonesPage.cascadeWarnings.innerText();
+ expect(warning).toMatch(
+ /systems? also belongs? to (a )?zones? outside this one and will be kept/,
+ );
+ });
+
+ test('removes the orphaned systems when confirmed', async ({ page }) => {
+ await openZone(page, 'zone-Kl0E0HmCJ3');
+ await zonesPage.openDeleteConfirmation();
+ await zonesPage.enableCascade();
+
+ const summary = await zonesPage.cascadeSummary.innerText();
+ const expected = Number(summary.match(/(\d+) systems? left/)?.[1] || 0);
+ expect(expected).toBeGreaterThan(0);
+
+ await zonesPage.acceptButton.click();
+ await page.waitForURL(/#\/zones\/-/, { timeout: 30000 });
+
+ // Every system lived inside this org zone, so the systems list empties
+ await page.goto('/?mock=true#/systems');
+ await zonesPage.waitForLoad();
+ await page.waitForTimeout(1000);
+ await expect(zonesPage.sidebarItems).toHaveCount(0);
+ });
+
+ test('leaves systems alone when the option is left off', async ({
+ page,
+ }) => {
+ await page.goto('/?mock=true#/systems');
+ await zonesPage.waitForLoad();
+ await page.waitForTimeout(1000);
+ const before = await zonesPage.sidebarItems.count();
+ expect(before).toBeGreaterThan(0);
+
+ await openZone(page, 'zone-Kl0E0HmCJ3');
+ await zonesPage.openDeleteConfirmation();
+ await zonesPage.acceptButton.click();
+ await page.waitForURL(/#\/zones\/-/, { timeout: 30000 });
+
+ await page.goto('/?mock=true#/systems');
+ await zonesPage.waitForLoad();
+ await page.waitForTimeout(1000);
+ await expect(zonesPage.sidebarItems).toHaveCount(before);
+ });
+});
diff --git a/e2e/src/pages/base.page.ts b/e2e/src/pages/base.page.ts
index ec185f940..4e587642d 100644
--- a/e2e/src/pages/base.page.ts
+++ b/e2e/src/pages/base.page.ts
@@ -233,6 +233,57 @@ export abstract class BasePage {
await this.dialog.waitFor({ timeout: 5000 });
}
+ /**
+ * Get the "also delete associated resources" checkbox on the delete
+ * confirmation
+ */
+ get cascadeCheckbox(): Locator {
+ return this.page.locator(
+ 'confirm-modal [confirm-option] input[type="checkbox"]',
+ );
+ }
+
+ /** Get the resolved list of what the cascade would remove */
+ get cascadeSummary(): Locator {
+ return this.page.locator('confirm-modal [details-summary]');
+ }
+
+ /** Get the "nothing else to remove" message */
+ get cascadeEmpty(): Locator {
+ return this.page.locator('confirm-modal [details-empty]');
+ }
+
+ /** Get the lines describing what the cascade will leave alone */
+ get cascadeWarnings(): Locator {
+ return this.page.locator('confirm-modal [details-warning]');
+ }
+
+ /** Get the confirmation dialog's accept button */
+ get acceptButton(): Locator {
+ return this.page.locator('confirm-modal button[name="accept"]');
+ }
+
+ /**
+ * Open the delete confirmation without confirming it
+ */
+ async openDeleteConfirmation(): Promise {
+ await this.page.waitForSelector('item-details', { timeout: 10000 });
+ await this.openActionMenu();
+ await this.deleteButton.click();
+ await this.page.waitForSelector('confirm-modal', { timeout: 5000 });
+ }
+
+ /**
+ * Enable the cascade option and wait for its breakdown to resolve
+ */
+ async enableCascade(): Promise {
+ await this.cascadeCheckbox.click();
+ await this.page.waitForSelector(
+ 'confirm-modal [details-summary], confirm-modal [details-empty]',
+ { timeout: 20000 },
+ );
+ }
+
/**
* Click delete and confirm (requires opening action menu first)
*/
diff --git a/public/assets/locale/ar.json b/public/assets/locale/ar.json
index 1b833b091..11e34574a 100644
--- a/public/assets/locale/ar.json
+++ b/public/assets/locale/ar.json
@@ -340,7 +340,6 @@
"ADD": "إضافة منطقة",
"EDIT": "تحرير منطقة",
"DELETE": "حذف منطقة",
- "DELETE_MSG": "
هل أنت متأكد أنك تريد حذف هذه المنطقة؟
سيؤدي حذف هذه المنطقة إلى الإزالة الفورية للأنظمة دون منطقة أخرى
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 +436,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 +804,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 +915,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 +1204,35 @@
"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 }})",
+ "SUCCESS": "Removed {{ count }} associated resources.",
+ "SUCCESS_1": "Removed {{ count }} associated resource.",
+ "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 }}"
}
}
diff --git a/public/assets/locale/en-GB.json b/public/assets/locale/en-GB.json
index 17eab3696..e26ad9a77 100644
--- a/public/assets/locale/en-GB.json
+++ b/public/assets/locale/en-GB.json
@@ -346,7 +346,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 +385,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 +647,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 +752,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 +994,35 @@
"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 }})",
+ "SUCCESS": "Removed {{ count }} associated resources.",
+ "SUCCESS_1": "Removed {{ count }} associated resource.",
+ "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 }}"
}
}
diff --git a/public/assets/locale/en-US.json b/public/assets/locale/en-US.json
index 35030d5f6..d6d6047fd 100644
--- a/public/assets/locale/en-US.json
+++ b/public/assets/locale/en-US.json
@@ -343,7 +343,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 +382,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 +644,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 +749,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 +991,35 @@
"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 }})",
+ "SUCCESS": "Removed {{ count }} associated resources.",
+ "SUCCESS_1": "Removed {{ count }} associated resource.",
+ "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 }}"
}
}
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..9d837f0ca 100644
--- a/src/app/common/actions.ts
+++ b/src/app/common/actions.ts
@@ -58,6 +58,11 @@ import {
updateUser,
updateZone,
} from '@placeos/ts-client';
+import {
+ CascadePlan,
+ 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 +73,20 @@ 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;
+ /** Resolves what would be removed alongside the item */
+ plan: (_: T) => Promise;
+}
+
export interface ItemActions {
query: (_?: string) => QueryResponse;
show: (_: string) => Promise;
@@ -77,6 +96,7 @@ export interface ItemActions {
modalComponent: Type;
delete_message: string;
delete_extra?: (_: T) => Promise<[string, string]>;
+ cascade?: ItemCascade;
name: string;
}
@@ -92,6 +112,11 @@ const domains: ItemActions = {
itemConstructor: PlaceDomain,
modalComponent: DomainFormComponent,
delete_message: ``,
+ cascade: {
+ label: 'DOMAINS.DELETE_CASCADE',
+ description: 'DOMAINS.DELETE_CASCADE_DESC',
+ plan: (item) => planDomainCascade(item),
+ },
name: 'DOMAINS',
};
@@ -319,6 +344,11 @@ const zones: ItemActions = {
itemConstructor: PlaceZone,
modalComponent: ZoneFormComponent,
delete_message: ``,
+ cascade: {
+ label: 'ZONES.DELETE_CASCADE',
+ description: 'ZONES.DELETE_CASCADE_DESC',
+ 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..50f81d26c
--- /dev/null
+++ b/src/app/common/cascade-delete.ts
@@ -0,0 +1,401 @@
+import {
+ del,
+ get,
+ PlaceApplication,
+ PlaceDomain,
+ PlaceSystem,
+ PlaceZone,
+ QueryResponse,
+ queryApplications,
+ queryDomains,
+ 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;
+
+/** A single removal performed as part of a cascade. */
+export interface CascadeStep {
+ /** Progress message shown while the step runs */
+ label: string;
+ /** 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 {
+ /** Number of steps that completed */
+ removed: number;
+ /** Steps that threw, kept so the caller can report them */
+ failures: { label: string; error: unknown }[];
+}
+
+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 && pages < MAX_PAGES) {
+ 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 && found.length < MAX_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);
+ const inside = (system: PlaceSystem) =>
+ (system.zones || []).every((id) => subtree.has(id));
+
+ const found = new Map();
+ const pages = await mapLimit(zone_ids, READ_CONCURRENCY, (zone_id) =>
+ collectPages(querySystems({ zone_id, limit: PAGE_SIZE })).catch(
+ () => [] as PlaceSystem[],
+ ),
+ );
+ 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): Promise {
+ const plan = emptyPlan();
+ const zone_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) => ({
+ label: i18n('CASCADE.REMOVING_SYSTEM', { name: system.name }),
+ run: () => removeSystem(system.id),
+ }));
+ return plan;
+}
+
+/** Tenants configured in the staff API against `domain`. */
+async function domainTenants(domain: string): Promise {
+ if (!domain) return [];
+ const tenants = (await get('/api/staff/v1/tenants').catch(
+ () => [],
+ )) 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);
+ const [applications, tenants, all_domains] = await Promise.all([
+ collectPages(
+ queryApplications({ authority_id: domain.id, limit: PAGE_SIZE }),
+ ).catch(() => [] as PlaceApplication[]),
+ domainTenants(domain.domain),
+ org_zone_id
+ ? collectPages(queryDomains({ limit: PAGE_SIZE })).catch(
+ () => [] as PlaceDomain[],
+ )
+ : 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) => ({
+ label: i18n('CASCADE.REMOVING_APPLICATION', {
+ 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) => ({
+ label: i18n('CASCADE.REMOVING_TENANT', {
+ 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;
+ }
+
+ const sharing = all_domains.filter(
+ (other) => other.id !== domain.id && orgZoneId(other) === org_zone_id,
+ );
+ 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;
+ }
+
+ const zone_plan = await planZoneCascade(org_zone_id);
+ plan.scope.push(
+ i18n('CASCADE.SCOPE_ORG_ZONE', { name: org_zone.name }),
+ ...zone_plan.scope,
+ );
+ plan.summary.push(...zone_plan.summary, i18n('CASCADE.REMOVE_ORG_ZONE'));
+ plan.warnings.push(...zone_plan.warnings);
+ plan.steps.push(...zone_plan.steps, {
+ label: i18n('CASCADE.REMOVING_ZONE', { 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.
+ */
+export async function runCascade(
+ plan: CascadePlan,
+ progress: (message: string) => void = () => undefined,
+): Promise {
+ const outcome: CascadeOutcome = { removed: 0, failures: [] };
+ const total = plan.steps.length;
+ for (const [index, step] of plan.steps.entries()) {
+ progress(
+ i18n('CASCADE.PROGRESS', {
+ step: step.label,
+ index: index + 1,
+ total,
+ }),
+ );
+ try {
+ await step.run();
+ outcome.removed += 1;
+ } catch (error) {
+ outcome.failures.push({ label: step.label, error });
+ }
+ }
+ return outcome;
+}
diff --git a/src/app/common/item.service.ts b/src/app/common/item.service.ts
index a6aa56782..878e2b957 100644
--- a/src/app/common/item.service.ts
+++ b/src/app/common/item.service.ts
@@ -26,11 +26,15 @@ 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 { CascadePlan, 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';
+
export type ResourceType =
| 'domains'
| 'drivers'
@@ -246,57 +250,108 @@ 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 () => {
+ },
+ );
+ waitForEvent(
+ ref.componentInstance.event,
+ (e: DialogEvent) => e.reason === 'done',
+ ).then(async (event: DialogEvent<{ options?: HashMap }>) => {
+ ref.componentInstance.loading.set(
+ i18n(`${actions.name}.DELETE_LOADING`),
+ );
+ if (event.metadata?.options?.[CASCADE_OPTION] && plan) {
+ const outcome = await runCascade(plan, (message) =>
+ ref.componentInstance.loading.set(message),
+ );
+ if (outcome.failures.length) {
+ ref.componentInstance.loading.set('');
+ return notifyError(
+ i18n(
+ 'CASCADE.FAILED',
+ {
+ count: outcome.failures.length,
+ error:
+ (outcome.failures[0].error as Error)
+ ?.message || outcome.failures[0].label,
+ },
+ outcome.failures.length,
+ ),
+ );
+ }
+ if (outcome.removed) {
+ notifySuccess(
+ i18n(
+ 'CASCADE.SUCCESS',
+ { count: outcome.removed },
+ outcome.removed,
+ ),
+ );
+ }
ref.componentInstance.loading.set(
- i18n(`${this.actions.name}.DELETE_LOADING`),
+ i18n(`${actions.name}.DELETE_LOADING`),
);
- await this.actions
- .remove(item)
- .then(() => {
- notifySuccess(
- i18n(`${this.actions.name}.DELETE_SUCCESS`, {
- name: item.name,
- }),
- );
- 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,
- ),
- }),
- );
- });
- });
- }
+ }
+ await actions
+ .remove(item)
+ .then(() => {
+ notifySuccess(
+ i18n(`${actions.name}.DELETE_SUCCESS`, {
+ name: item.name,
+ }),
+ );
+ this._active_item.set(null);
+ this.removeItem(item);
+ this._router.navigate([`/${this._type}`, '-', 'about']);
+ ref.close();
+ })
+ .catch((err) => {
+ ref.componentInstance.loading.set('');
+ notifyError(
+ i18n(`${actions.name}.DELETE_ERROR`, {
+ error: JSON.stringify(
+ err.response || err.message || err,
+ ),
+ }),
+ );
+ });
+ });
}
public duplicate() {
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..f51091f01 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,6 +14,7 @@ import {
MatDialogRef,
} from '@angular/material/dialog';
+import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatRippleModule } from '@angular/material/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { lastValueFrom } from 'rxjs';
@@ -22,6 +24,37 @@ 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;
+
export interface ConfirmModalData {
/** Title of the modal */
title: string;
@@ -29,6 +62,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 */
@@ -45,6 +80,7 @@ export const CONFIRM_METADATA = {
export interface ConfirmRepsonse {
reason: 'done' | '' | null;
+ metadata?: { options?: ConfirmModalSelection };
loading: (_: string) => void;
close: () => void;
}
@@ -92,6 +128,81 @@ export async function openConfirmModal(
[class]="'text-' + extra[0] + ' text-center text-sm'"
[innerHTML]="extra[1]"
>