Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions config/proxy.conf.js
Original file line number Diff line number Diff line change
@@ -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 = {};
Expand Down
Binary file added docs/ppt-1203/1-option-off.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/ppt-1203/2-zone-plan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/ppt-1203/3-domain-plan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/ppt-1203/4-shared-org-zone.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/ppt-1203/5-receipt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
190 changes: 190 additions & 0 deletions e2e/src/cascade-delete.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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.waitForSelector('confirm-modal [result-items]', {
timeout: 60000,
});
await page.locator('confirm-modal button[name="close"]').click();

// 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('lists what was removed, with ids, once it has run', async ({
page,
}) => {
await openZone(page, 'zone-Kl0E0HmCJ3');
await zonesPage.openDeleteConfirmation();
await zonesPage.enableCascade();

const summary = await zonesPage.cascadeSummary.innerText();
const systems = Number(summary.match(/(\d+) systems? left/)?.[1] || 0);

await zonesPage.acceptButton.click();
await page.waitForSelector('confirm-modal [result-items]', {
timeout: 60000,
});

// one row per system, plus the zone itself
const rows = page.locator('confirm-modal [result-items] li');
await expect(rows).toHaveCount(systems + 1);

const receipt = await page
.locator('confirm-modal [result-items]')
.innerText();
expect(receipt).toContain('zone-Kl0E0HmCJ3');
expect(receipt).toContain('Place Technology');
// every row carries an id
for (const row of await rows.all()) {
expect(await row.innerText()).toMatch(/(sys|zone)-\S+/);
}

// the confirmation buttons are replaced by a single close
await expect(zonesPage.acceptButton).toHaveCount(0);
await expect(
page.locator('confirm-modal button[name="close"]'),
).toBeVisible();
});

test('shows no receipt for a delete without the option', async ({
page,
}) => {
await openZone(page, 'zone-lmhh_hVfz0');
await zonesPage.openDeleteConfirmation();
await zonesPage.acceptButton.click();

// unchanged behaviour: the dialog closes itself, no receipt
await page.waitForURL(/#\/zones\/-/, { timeout: 30000 });
await expect(page.locator('confirm-modal')).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);
});
});
51 changes: 51 additions & 0 deletions e2e/src/pages/base.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
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)
*/
Expand Down
1 change: 0 additions & 1 deletion public/assets/locale/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,6 @@
"ADD": "إضافة منطقة",
"EDIT": "تحرير منطقة",
"DELETE": "حذف منطقة",
"DELETE_MSG": "<p>هل أنت متأكد أنك تريد حذف هذه المنطقة؟</p><p>سيؤدي حذف هذه المنطقة إلى <strong>الإزالة الفورية</strong> للأنظمة دون منطقة أخرى</p>",
"DELETE_LOADING": "جاري حذف المنطقة...",
"DELETE_SUCCESS": "تم حذف المنطقة بنجاح.",
"DELETE_ERROR": "فشل حذف المنطقة. الخطأ: {{ error }}",
Expand Down
57 changes: 53 additions & 4 deletions public/assets/locale/en-AU.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"NOTES": "Notes",
"NONE": "None",
"CANCEL": "Cancel",
"CLOSE": "Close",
"CONFIRM": "Confirm",
"SAVE": "Save",
"SAVE_ALL": "Save All",
Expand Down Expand Up @@ -379,7 +380,7 @@
"ADD": "Add zone",
"EDIT": "Edit zone",
"DELETE": "Delete zone",
"DELETE_MSG": "<p>Are you sure you want delete this zone?</p><p>Deleting this zone will <strong>immediately</strong> remove systems without another zone</p>",
"DELETE_MSG": "<p>Are you sure you want delete this zone?</p><p>Zones beneath it, its triggers, metadata and settings are removed <strong>immediately</strong>. Systems in this zone are kept unless you also remove associated resources.</p>",
"DELETE_LOADING": "Deleting zone...",
"DELETE_SUCCESS": "Successfully deleted zone.",
"DELETE_ERROR": "Failed to delete zone. Error: {{ error }}",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -802,7 +805,7 @@
"BULK": "Bulk add domains",
"REMOVE": "Remove domain",
"DELETE": "Delete domain",
"DELETE_MSG": "<p>Are you sure you want delete this domain?</p><p>The domain will be deleted <strong>immediately.</strong></p>",
"DELETE_MSG": "<p>Are you sure you want delete this domain?</p><p>Its users, auth sources and groups are removed <strong>immediately</strong>.</p>",
"DELETE_LOADING": "Deleting domain...",
"DELETE_SUCCESS": "Successfully deleted domain.",
"DELETE_ERROR": "Failed to delete domain. Error: {{ error }}",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
}
Loading
Loading