From 603642f264e183ccbbb4619d464fa6c0709c0d86 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 19 Oct 2026 15:27:27 +1100 Subject: [PATCH 1/2] fix(map-studio): fix editor save model, kiosk live data and other UX issues The UI/UX audit of Map Studio found false save prompts, actions that silently did less than they said, live PlaceOS writes without warning, and a kiosk that always showed random availability. - Editor: autosave with a leave guard, no dirty flag on a plain click, multi-select delete/duplicate, trackpad pan and pinch zoom, safer shortcuts, rotated hit tests, layer and placement feedback, kept-alive panels, validation that matches publish, and accessibility fixes. - Panels: publish stages link changes and confirms before it writes, validated property inputs, and an auto-number preview. - Pages: sync status, sync tombstones, rename and reorder, import wizard fixes, settings test-before-save, and accessible cards. - Kiosk: live data from the level zone, offline and demo banners, error states, touch pan and zoom, tap details, idle reset, kiosk mode, and a non-colour status legend. --- apps/map-studio/src/app/app.config.ts | 45 +- apps/map-studio/src/app/app.routes.ts | 14 +- .../src/app/data/placeos.service.ts | 65 +- apps/map-studio/src/app/data/store.service.ts | 212 ++++- apps/map-studio/src/app/data/types.ts | 2 + .../src/app/editor/asset-picker.component.ts | 6 +- .../editor/availability-panel.component.ts | 2 +- .../src/app/editor/canvas.component.ts | 311 +++++-- .../src/app/editor/editor-state.service.ts | 241 +++++- .../src/app/editor/editor.component.ts | 503 ++++++++--- apps/map-studio/src/app/editor/geometry.ts | 38 + .../app/editor/labelling-panel.component.ts | 75 +- .../src/app/editor/layer-panel.component.ts | 47 +- .../src/app/editor/minimap.component.ts | 192 +++-- .../app/editor/object-list-panel.component.ts | 12 +- .../app/editor/properties-panel.component.ts | 265 ++++-- .../src/app/editor/publish-panel.component.ts | 638 +++++++++++--- apps/map-studio/src/app/editor/svg-export.ts | 17 + .../src/app/editor/toast.service.ts | 26 +- .../app/editor/validation-panel.component.ts | 72 +- .../app/kiosk/availability-polling.service.ts | 133 ++- .../src/app/kiosk/floor-plan-2d.component.ts | 802 +++++++++++++----- .../src/app/kiosk/kiosk.component.ts | 565 +++++++++--- .../src/app/pages/import.component.ts | 196 ++++- .../src/app/pages/project-detail.component.ts | 434 ++++++++-- .../src/app/pages/projects.component.ts | 187 ++-- .../src/app/pages/settings.component.ts | 93 +- .../src/tests/availability-polling.spec.ts | 110 +++ .../src/tests/editor-state.service.spec.ts | 146 ++++ apps/map-studio/src/tests/geometry.spec.ts | 24 + .../src/tests/placeos.service.spec.ts | 19 + .../src/tests/store.service.spec.ts | 78 ++ apps/map-studio/src/tests/svg-export.spec.ts | 19 + 33 files changed, 4334 insertions(+), 1255 deletions(-) create mode 100644 apps/map-studio/src/tests/availability-polling.spec.ts diff --git a/apps/map-studio/src/app/app.config.ts b/apps/map-studio/src/app/app.config.ts index a20d5645433..3524f97b8d1 100644 --- a/apps/map-studio/src/app/app.config.ts +++ b/apps/map-studio/src/app/app.config.ts @@ -9,12 +9,9 @@ import { provideServiceWorker } from '@angular/service-worker'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; -import { PlaceOSService, PlaceOSZone } from './data/placeos.service'; +import { PlaceOSService } from './data/placeos.service'; import { StoreService } from './data/store.service'; -/** Level requests in flight at once during zone sync */ -const SYNC_CONCURRENCY = 6; - async function initialisePlaceOS() { const placeos = inject(PlaceOSService); const store = inject(StoreService); @@ -24,43 +21,9 @@ async function initialisePlaceOS() { if (window.location.hash.startsWith('#/kiosk')) return; // Zone import can take a while on large deployments. It must not hold - // Angular bootstrap open because the editor can render without it. - void syncPlaceOSZones(placeos, store); -} - -async function syncPlaceOSZones( - placeos: PlaceOSService, - store: StoreService, -): Promise { - try { - const organisation = await placeos.getActiveOrganisation(); - if (!organisation) return; - const regions = await placeos.getZones('region', organisation.id); - const building_lists = await Promise.all( - [organisation, ...regions].map((parent) => - placeos.getZones('building', parent.id), - ), - ); - const zones_by_id = new Map( - building_lists.flat().map((zone) => [zone.id, zone]), - ); - const zones = [...zones_by_id.values()]; - const buildings: { zone: PlaceOSZone; levels: PlaceOSZone[] }[] = []; - // A few buildings at a time, so large deployments do not flood the API - for (let i = 0; i < zones.length; i += SYNC_CONCURRENCY) { - buildings.push( - ...(await Promise.all( - zones.slice(i, i + SYNC_CONCURRENCY).map(async (zone) => ({ - zone, - levels: await placeos.getZones('level', zone.id), - })), - )), - ); - } - await store.syncPlaceOSZones(buildings); - } catch (error) { - console.warn('Failed to create projects from PlaceOS zones', error); - } + // Angular bootstrap open because the editor can render without it. The + // projects page shows its progress through `store.placeos_sync`. + void store.syncFromPlaceOS(); } export const appConfig: ApplicationConfig = { diff --git a/apps/map-studio/src/app/app.routes.ts b/apps/map-studio/src/app/app.routes.ts index 40fbec50882..ac34759d47c 100644 --- a/apps/map-studio/src/app/app.routes.ts +++ b/apps/map-studio/src/app/app.routes.ts @@ -1,6 +1,13 @@ import { inject } from '@angular/core'; -import { ActivatedRouteSnapshot, Router, Routes } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanDeactivateFn, + Router, + Routes, +} from '@angular/router'; + import { StoreService } from './data/store.service'; +import type { EditorComponent } from './editor/editor.component'; /** Opens the floorplan for a PlaceOS level, which is how Concierge links here. */ async function openLevel(route: ActivatedRouteSnapshot) { @@ -16,6 +23,10 @@ async function openLevel(route: ActivatedRouteSnapshot) { } } +/** Saves pending editor changes before any navigation away, Back included */ +const saveBeforeLeaving: CanDeactivateFn = (editor) => + editor.canLeave(); + export const routes: Routes = [ { path: 'level/:level_id', @@ -26,6 +37,7 @@ export const routes: Routes = [ path: 'editor/:floorplan_id', loadComponent: () => import('./editor/editor.component').then((m) => m.EditorComponent), + canDeactivate: [saveBeforeLeaving], }, { path: 'kiosk/:project_id', diff --git a/apps/map-studio/src/app/data/placeos.service.ts b/apps/map-studio/src/app/data/placeos.service.ts index 7527a075fdb..b2a4f514e96 100644 --- a/apps/map-studio/src/app/data/placeos.service.ts +++ b/apps/map-studio/src/app/data/placeos.service.ts @@ -59,6 +59,34 @@ export interface PlaceOSSettings { api_key: string; } +/** Engine API response with a non-2xx status */ +export class PlaceOSRequestError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + } +} + +/** Plain message for a failed connection test */ +function connectionMessage(error: unknown): string { + if (error instanceof PlaceOSRequestError) { + if (error.status === 401 || error.status === 403) { + return 'PlaceOS rejected the API key. Check the key and its permissions.'; + } + if (error.status === 404) { + return 'No PlaceOS API was found at this domain. Check the domain.'; + } + return `PlaceOS returned an error (${error.status}). Try again later.`; + } + // fetch rejects with a TypeError when the host cannot be reached + if (error instanceof TypeError) { + return 'Cannot reach the PlaceOS domain. Check the address and your network connection.'; + } + return error instanceof Error ? error.message : 'Connection failed'; +} + /** * `domain` when the app is served by a PlaceOS domain and the user is signed * in through it, `manual` when a domain and API key have to be entered. @@ -234,14 +262,31 @@ export class PlaceOSService { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } - /** Checks the stored credentials by fetching the current user */ - public async testConnection(): Promise<{ + /** Forgets the stored domain and API key */ + public clearConfig(): void { + this._settings.set({ domain: '', api_key: '' }); + localStorage.removeItem(SETTINGS_KEY); + } + + /** + * Checks credentials by fetching the current user. Tests `settings` when + * given, without storing them, and the stored credentials otherwise. + */ + public async testConnection(settings?: PlaceOSSettings): Promise<{ ok: boolean; user?: PlaceOSUser; error?: string; }> { try { - const user = await this._request('/users/current'); + const user = await this._request( + '/users/current', + {}, + {}, + settings && { + ...settings, + domain: settings.domain.replace(/\/$/, ''), + }, + ); return { ok: true, user: { @@ -251,13 +296,7 @@ export class PlaceOSService { }, }; } catch (error) { - return { - ok: false, - error: - error instanceof Error - ? error.message - : 'Connection failed', - }; + return { ok: false, error: connectionMessage(error) }; } } @@ -507,8 +546,9 @@ export class PlaceOSService { path: string, params: Record = {}, options: RequestInit = {}, + settings: PlaceOSSettings = this._settings(), ): Promise { - const { domain, api_key } = this._settings(); + const { domain, api_key } = settings; const on_domain = this._mode() === 'domain'; if (!on_domain && !api_key) throw new Error('PlaceOS not configured'); const query = new URLSearchParams(params).toString(); @@ -527,7 +567,8 @@ export class PlaceOSService { async (response) => { if (!response.ok) { const detail = await response.text().catch(() => ''); - throw new Error( + throw new PlaceOSRequestError( + response.status, `PlaceOS ${response.status}: ${detail || response.statusText}`, ); } diff --git a/apps/map-studio/src/app/data/store.service.ts b/apps/map-studio/src/app/data/store.service.ts index b813f375de5..f52c4797e45 100644 --- a/apps/map-studio/src/app/data/store.service.ts +++ b/apps/map-studio/src/app/data/store.service.ts @@ -19,9 +19,12 @@ import { type ZoneReference = Pick; -/** Server metadata requests in flight at once during zone sync */ +/** PlaceOS requests in flight at once during zone sync */ const SYNC_CONCURRENCY = 6; +/** Progress of the background import of PlaceOS buildings and levels */ +export type PlaceOSSyncState = 'idle' | 'syncing' | 'done' | 'error'; + function localFloorplanData(metadata: Partial = {}) { const saved = { ...metadata }; delete saved.id; @@ -46,6 +49,9 @@ const key = { versions: (floorplan_id: string) => `version:${floorplan_id}:`, image: (floorplan_id: string) => `image:${floorplan_id}`, thumbnail: (floorplan_id: string) => `thumbnail:${floorplan_id}`, + /** PlaceOS zone whose local project or floorplan the user deleted */ + deleted_zone: (zone_id: string) => `deleted-zone:${zone_id}`, + deleted_zones: () => 'deleted-zone:', }; /** Records that store `floorplan`: its summary and, apart, its canvas */ @@ -66,6 +72,14 @@ function floorplanRecords(floorplan: Floorplan): [string, unknown][] { const now = () => new Date().toISOString(); +/** Thumbnail key to delete when an imported floor's SVG plan changes */ +function staleThumbnail(before: Floorplan, after: Floorplan): string[] { + return before.svg_source !== after.svg_source || + before.svg_output !== after.svg_output + ? [key.thumbnail(after.id)] + : []; +} + function serverSaveMessage(error: unknown): string { const detail = error instanceof Error ? error.message : String(error ?? ''); const status = (error as { status?: number } | null)?.status; @@ -141,6 +155,10 @@ export class StoreService { /** Why the last floorplan save reached this browser but not PlaceOS; empty once one does. */ public readonly server_save_error = signal(''); private _zone_sync: Promise = Promise.resolve(); + private readonly _placeos_sync = signal('idle'); + + /** State of the background PlaceOS zone sync started at boot */ + public readonly placeos_sync = this._placeos_sync.asReadonly(); // ── Projects ──────────────────────────────────────────────────────────── @@ -202,13 +220,21 @@ export class StoreService { }); } - /** Removes the project along with every floorplan and object beneath it */ + /** + * Removes the project along with every floorplan and object beneath it. A + * project made from a PlaceOS building is remembered so that the zone + * sync does not create it again. + */ public async deleteProject(id: string): Promise { + const project = await this._db.get(key.project(id)); const floorplans = ( await this._db.list('floorplan:') ).filter((f) => f.project_id === id); await Promise.all(floorplans.map((f) => this.deleteFloorplan(f.id))); await this._db.delete(key.project(id)); + if (project?.building_zone_id) { + await this._forgetZone(project.building_zone_id); + } } // ── Floorplans ────────────────────────────────────────────────────────── @@ -220,12 +246,16 @@ export class StoreService { level_zone_id?: string, metadata?: Partial, ): Promise { - // Only an unplaced floor needs the sibling count + // Only an unplaced floor needs the siblings. It goes after the last + // one, so a gap left by a delete does not give two floors one index. const index = floor_index ?? - (await this._db.list('floorplan:')).filter( - (f) => f.project_id === project_id, - ).length; + Math.max( + -1, + ...(await this._db.list('floorplan:')) + .filter((f) => f.project_id === project_id) + .map((f) => f.floor_index), + ) + 1; const floorplan: Floorplan = { id: uuid(), project_id, @@ -285,17 +315,78 @@ export class StoreService { const level = await this._placeos.getZone(level_id); const building = await this._placeos.getZone(level.parent_id); const levels = await this._placeos.getZones('level', building.id); + // Opening a level on purpose brings it back if it was deleted here + await this._db.delete(key.deleted_zone(level.id)); + await this._db.delete(key.deleted_zone(building.id)); await this.syncPlaceOSZones([{ zone: building, levels }]); const added = await find(); if (!added) throw new Error('This level was not found in PlaceOS.'); return added; } + /** + * Loads the building and level zones under the active PlaceOS + * organisation and creates the missing projects. Sets `placeos_sync` while + * it runs. Call it again to retry after an error. + */ + public async syncFromPlaceOS(): Promise { + if (this._placeos_sync() === 'syncing') return; + this._placeos_sync.set('syncing'); + try { + const organisation = await this._placeos.getActiveOrganisation(); + if (organisation) { + const regions = await this._placeos.getZones( + 'region', + organisation.id, + ); + const building_lists = await Promise.all( + [organisation, ...regions].map((parent) => + this._placeos.getZones('building', parent.id), + ), + ); + const zones_by_id = new Map( + building_lists.flat().map((zone) => [zone.id, zone]), + ); + const zones = [...zones_by_id.values()]; + const buildings: { + zone: ZoneReference; + levels: ZoneReference[]; + }[] = []; + // A few buildings at a time, so large deployments do not flood the API + for (let i = 0; i < zones.length; i += SYNC_CONCURRENCY) { + buildings.push( + ...(await Promise.all( + zones + .slice(i, i + SYNC_CONCURRENCY) + .map(async (zone) => ({ + zone, + levels: await this._placeos.getZones( + 'level', + zone.id, + ), + })), + )), + ); + } + await this.syncPlaceOSZones(buildings); + } + this._placeos_sync.set('done'); + } catch (error) { + console.warn('Failed to create projects from PlaceOS zones', error); + this._placeos_sync.set('error'); + } + } + + /** Creates the missing projects and floorplans. Zones the user deleted locally are skipped. */ private async _syncZones( buildings: { zone: ZoneReference; levels: ZoneReference[] }[], ): Promise { const projects = await this.listProjects(); + const deleted = new Set( + await this._db.list(key.deleted_zones()), + ); for (const { zone, levels } of buildings) { + if (deleted.has(zone.id)) continue; let project = projects.find( (item) => item.building_zone_id === zone.id, ); @@ -313,7 +404,8 @@ export class StoreService { project.floorplans?.map((floor) => floor.level_zone_id), ); const missing = [...levels.entries()].filter( - ([, level]) => !existing_levels.has(level.id), + ([, level]) => + !existing_levels.has(level.id) && !deleted.has(level.id), ); // Fetch metadata a few levels at a time rather than one by one for (let i = 0; i < missing.length; i += SYNC_CONCURRENCY) { @@ -384,6 +476,7 @@ export class StoreService { id, }; await this._db.write({ + delete: staleThumbnail(floorplan, updated), ...(updated.canvas_state?.objects ? { clear: key.objects(id), @@ -406,7 +499,10 @@ export class StoreService { ): Promise { const floorplan = await this.getFloorplan(id); const updated = { ...floorplan, ...updates, id, updated_at: now() }; - await this._db.write({ put: floorplanRecords(updated) }); + await this._db.write({ + put: floorplanRecords(updated), + delete: staleThumbnail(floorplan, updated), + }); if (updated.level_zone_id && this._placeos.mode() === 'domain') { const server_updated_at = await this._placeos .saveFloorplanMetadata(updated.level_zone_id, updated) @@ -429,7 +525,15 @@ export class StoreService { return updated; } + /** + * Removes the floorplan, its objects, versions and image. A floorplan made + * from a PlaceOS level is remembered so that the zone sync does not create + * it again. + */ public async deleteFloorplan(id: string): Promise { + const floorplan = await this._db.get( + key.floorplan(id), + ); await Promise.all([ this._db.deleteWhere(key.objects(id)), this._db.deleteWhere(key.versions(id)), @@ -442,6 +546,14 @@ export class StoreService { ], }), ]); + if (floorplan?.level_zone_id) { + await this._forgetZone(floorplan.level_zone_id); + } + } + + /** Records a deleted PlaceOS zone so `syncPlaceOSZones` skips it */ + private async _forgetZone(zone_id: string): Promise { + await this._db.set(key.deleted_zone(zone_id), zone_id); } public async saveCanvasState( @@ -535,14 +647,15 @@ export class StoreService { } /** - * Object URL for a small copy of the background image, made and stored on + * Object URL for a small copy of the background image or imported SVG plan, made and stored on * first use. Previews use this so they never decode a full-size plan. The * caller owns the URL and must revoke it when done. */ public async getSourceThumbnailUrl(id: string): Promise { let thumbnail = await this._db.get(key.thumbnail(id)); if (!thumbnail) { - const image = await this.getSourceImage(id); + const image = + (await this.getSourceImage(id)) ?? (await this._sourceSvg(id)); if (!image) return null; thumbnail = await makeThumbnail(image); await this._db.set(key.thumbnail(id), thumbnail); @@ -550,6 +663,13 @@ export class StoreService { return URL.createObjectURL(thumbnail); } + /** Imported SVG floors keep their plan as markup, not as a stored image */ + private async _sourceSvg(id: string): Promise { + const canvas = await this._db.get(key.canvas(id)); + const svg = canvas?.svg_source || canvas?.svg_output; + return svg ? new Blob([svg], { type: 'image/svg+xml' }) : null; + } + /** Raw source image used by local processing such as AI analysis. */ public async getSourceImage(id: string): Promise { return (await this._db.get(key.image(id))) ?? null; @@ -593,51 +713,35 @@ export class StoreService { // ── SVG import ────────────────────────────────────────────────────────── /** - * Creates a project, floorplan and objects from an analysed SVG. Replaces - * the reference application's `POST /import/svg/confirm`. + * Creates a floorplan and objects from an analysed SVG. Adds the floorplan + * to `project_id` when given, or else to a new project named + * `project_name`. Nothing is left behind if a step fails. Replaces the + * reference application's `POST /import/svg/confirm`. */ public async importSvg(options: { svg_content: string; analysis: SvgAnalysis; - project_name: string; + /** Existing project to add the floor to */ + project_id?: string; + /** Name of the new project, used when `project_id` is not set */ + project_name?: string; floor_name: string; /** SVG ids to import, with the type and label the user settled on */ mappings: { svgId: string; objectType: string; label?: string }[]; outline_points?: { x: number; y: number }[]; }): Promise<{ project_id: string; floorplan_id: string; objects: number }> { - const { - svg_content, - analysis, - project_name, - floor_name, - mappings, - outline_points, - } = options; + const { svg_content, analysis, floor_name, mappings, outline_points } = + options; if (!analysis.sourceSvg) throw new Error('The file is not a valid SVG map.'); - const project = await this.createProject(project_name); - const floorplan = await this.addFloorplan(project.id, floor_name, 0); - - const has_outline = (outline_points?.length ?? 0) >= 3; - await this.updateFloorplan(floorplan.id, { - svg_source: analysis.sourceSvg, - svg_source_ids: analysis.objects.map((object) => object.svgId), - svg_output: analysis.sourceSvg || svg_content, - canvas_width: analysis.width, - canvas_height: analysis.height, - source_type: 'svg-import', - }); - const chosen = new Map(mappings.map((m) => [m.svgId, m])); const objects = this._objectsFromSvg(analysis, chosen); - await this.updateFloorplan(floorplan.id, { - svg_source_ids: objects.flatMap((object) => - object.svg_id ? [object.svg_id] : [], - ), - }); - + const source_ids = objects.flatMap((object) => + object.svg_id ? [object.svg_id] : [], + ); + const has_outline = (outline_points?.length ?? 0) >= 3; if (has_outline) { const points = outline_points as { x: number; y: number }[]; const xs = points.map((p) => p.x); @@ -661,12 +765,28 @@ export class StoreService { }); } - await this.bulkUpsertObjects(floorplan.id, objects); - return { - project_id: project.id, - floorplan_id: floorplan.id, - objects: objects.length, - }; + const project_id = + options.project_id ?? + (await this.createProject(options.project_name || floor_name)).id; + let floorplan_id = ''; + try { + const floorplan = await this.addFloorplan(project_id, floor_name); + floorplan_id = floorplan.id; + await this.updateFloorplan(floorplan_id, { + svg_source: analysis.sourceSvg, + svg_source_ids: source_ids, + svg_output: analysis.sourceSvg || svg_content, + canvas_width: analysis.width, + canvas_height: analysis.height, + source_type: 'svg-import', + }); + await this.bulkUpsertObjects(floorplan_id, objects); + } catch (error) { + if (!options.project_id) await this.deleteProject(project_id); + else if (floorplan_id) await this.deleteFloorplan(floorplan_id); + throw error; + } + return { project_id, floorplan_id, objects: objects.length }; } private _objectsFromSvg( diff --git a/apps/map-studio/src/app/data/types.ts b/apps/map-studio/src/app/data/types.ts index 8623cf30476..d8614b4020b 100644 --- a/apps/map-studio/src/app/data/types.ts +++ b/apps/map-studio/src/app/data/types.ts @@ -121,6 +121,8 @@ export interface EditorState { viewport: { x: number; y: number; zoom: number }; selectedIds: string[]; layers: EditorLayer[]; + /** Format of `layers`; see LAYERS_VERSION in the editor state service */ + layersVersion?: number; gridEnabled: boolean; snapEnabled: boolean; gridSize: number; diff --git a/apps/map-studio/src/app/editor/asset-picker.component.ts b/apps/map-studio/src/app/editor/asset-picker.component.ts index 65186e3a02e..e3e3f0c598f 100644 --- a/apps/map-studio/src/app/editor/asset-picker.component.ts +++ b/apps/map-studio/src/app/editor/asset-picker.component.ts @@ -32,9 +32,11 @@ const ICONS: Record = { [class]="buttonClass(state.placing()?.kind === kind)" [title]="'Place ' + labelFor(kind)" > - {{ iconFor(kind) }} + {{ labelFor(kind) }} - expand_more + } diff --git a/apps/map-studio/src/app/editor/availability-panel.component.ts b/apps/map-studio/src/app/editor/availability-panel.component.ts index d8e97f3c61d..79fbfc6cf81 100644 --- a/apps/map-studio/src/app/editor/availability-panel.component.ts +++ b/apps/map-studio/src/app/editor/availability-panel.component.ts @@ -11,7 +11,7 @@ import { EditorStateService } from './editor-state.service';
- Availability Preview + Preview
`, - imports: [FormsModule, IconComponent, MatFormField, MatInput], + imports: [FormsModule, IconComponent, MatFormField, MatInput, RouterLink], }) export class ImportComponent { private readonly _store = inject(StoreService); private readonly _router = inject(Router); + /** Existing project to add the floor to, from `?project=` */ + private readonly _project_id = + inject(ActivatedRoute).snapshot.queryParamMap.get('project'); public readonly steps = STEPS; public readonly object_types = OBJECT_TYPES; @@ -512,8 +580,18 @@ export class ImportComponent { public readonly mappings = signal([]); public readonly project_name = signal(''); public readonly floor_name = signal(''); + public readonly target_project = signal(null); + public readonly cancel_link = this._project_id + ? ['/project', this._project_id] + : ['/']; private _svg_content = ''; + /** Layers whose state was last carried through to `mappings` */ + private _applied_layers = new Set(); + + constructor() { + if (this._project_id) this._loadProject(this._project_id); + } public readonly stepIndex = computed(() => STEPS.findIndex((s) => s.key === this.step()), @@ -541,6 +619,16 @@ export class ImportComponent { return [...counts.entries()].map(([type, count]) => ({ type, count })); }); + /** Included objects tallied by type, without decorative shapes */ + public readonly spaceCounts = computed(() => + this.typeCounts().filter((entry) => entry.type !== 'decorative'), + ); + + public readonly decorativeCount = computed( + () => + this.included().filter((m) => m.objectType === 'decorative').length, + ); + public readonly spaceLabel = (type: string) => SPACE_LABELS[type] ?? type; public readonly asValue = (event: Event) => @@ -567,7 +655,10 @@ export class ImportComponent { } public onFileInput(event: Event) { - const file = (event.target as HTMLInputElement).files?.[0]; + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + // Clear so that picking the same file again still fires a change + input.value = ''; if (file) this.handleFile(file); } @@ -587,8 +678,15 @@ export class ImportComponent { ); const analysis = analyzeSvg(this._svg_content); + if (!analysis.objects.length) { + throw new Error( + 'No importable shapes were found in this SVG. Each room, desk or zone shape needs an id attribute.', + ); + } this.analysis.set(analysis); - this.enabled_layers.set(new Set(analysis.layers.map((l) => l.id))); + const layers = new Set(analysis.layers.map((l) => l.id)); + this.enabled_layers.set(layers); + this._applied_layers = new Set(layers); this.mappings.set( analysis.objects.map((object) => ({ svgId: object.svgId, @@ -600,6 +698,8 @@ export class ImportComponent { ); this.step.set('review'); } catch (error) { + this.analysis.set(null); + this.mappings.set([]); this.error.set(this._message(error, 'Failed to analyse SVG')); } finally { this.analyzing.set(false); @@ -615,15 +715,22 @@ export class ImportComponent { }); } - /** Carry the layer checkboxes through to the per-object exclusions */ + /** + * Carry the layer checkboxes through to the per-object exclusions. Only + * rows on a layer that changed since the last pass are touched, so + * choices made on the Map step survive going Back and forward. + */ public applyLayerFilter() { const enabled = this.enabled_layers(); + const applied = this._applied_layers; this.mappings.update((mappings) => - mappings.map((mapping) => ({ - ...mapping, - excluded: !enabled.has(mapping.layer), - })), + mappings.map((mapping) => + enabled.has(mapping.layer) === applied.has(mapping.layer) + ? mapping + : { ...mapping, excluded: !enabled.has(mapping.layer) }, + ), ); + this._applied_layers = new Set(enabled); this.step.set('map'); } @@ -643,22 +750,29 @@ export class ImportComponent { public excludeDecorative() { this.mappings.update((mappings) => - mappings.map((mapping) => ({ - ...mapping, - excluded: mapping.objectType === 'decorative', - })), + mappings.map((mapping) => + mapping.objectType === 'decorative' + ? { ...mapping, excluded: true } + : mapping, + ), ); } public async runImport() { const analysis = this.analysis(); if (!analysis) return; + // A missing target project must not fall back to creating a new one + if (this._project_id && !this.target_project()) { + this.error.set('The project to import into was not found.'); + return; + } this.importing.set(true); this.error.set(''); try { const result = await this._store.importSvg({ svg_content: this._svg_content, analysis, + project_id: this.target_project()?.id, project_name: this.project_name().trim(), floor_name: this.floor_name().trim(), mappings: this.included().map((mapping) => ({ @@ -675,6 +789,14 @@ export class ImportComponent { } } + private async _loadProject(id: string) { + try { + this.target_project.set(await this._store.getProject(id)); + } catch (error) { + this.error.set(this._message(error, 'Project not found')); + } + } + private _message(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } diff --git a/apps/map-studio/src/app/pages/project-detail.component.ts b/apps/map-studio/src/app/pages/project-detail.component.ts index dd8efb06b26..9328b453f9b 100644 --- a/apps/map-studio/src/app/pages/project-detail.component.ts +++ b/apps/map-studio/src/app/pages/project-detail.component.ts @@ -1,6 +1,16 @@ -import { Component, DestroyRef, inject, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { + Component, + computed, + DestroyRef, + effect, + ElementRef, + inject, + signal, + viewChild, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatFormField } from '@angular/material/form-field'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { IconComponent } from '@placeos/components'; @@ -14,9 +24,25 @@ const BADGE_CLASSES: Record = { archived: 'bg-warning-light text-warning', }; +/** Inline rename target: the project name, building name, or a floor id */ +type RenameTarget = 'name' | 'building' | { floor: string }; + @Component({ selector: 'map-studio-project-detail', template: ` + + + + @if (loading()) {
= {
@@ -43,48 +70,113 @@ const BADGE_CLASSES: Record = { class="clear mb-4 w-44 justify-start! gap-1 px-0! text-sm" routerLink="/" > - arrow_back + Back to Projects -
-
-

- {{ detail.name }} -

- @if (detail.building_name) { -

- {{ detail.building_name }} -

+
+
+ @if (isEditing('name')) { +
+ +
+ } @else { +
+

+ {{ detail.name }} +

+ +
+ } + @if (isEditing('building')) { +
+ +
+ } @else { +
+

+ {{ + detail.building_name || + 'No building name' + }} +

+ +
}
-
+
- View Kiosk + + Import SVG - - {{ detail.status }} - + @if (floorplans().length) { + + View Kiosk + + }
-
+

- Floorplans ({{ floorplans().length }}) + Floors ({{ floorplans().length }})

+ @if (!show_form()) { + + }
} @else {
- @for (floor of floorplans(); track floor.id) { + @for ( + floor of floorplans(); + track floor.id; + let first = $first, last = $last + ) {
-
-

- {{ floor.floor_name }} -

+ @if (isEditing({ floor: floor.id })) { +
+ +
+ } @else { +
+

+ {{ floor.floor_name }} +

+ +
+ } = {
- - v{{ floor.version }} - -
+
+ + + v{{ floor.version }} + +
+
@@ -231,11 +408,23 @@ const BADGE_CLASSES: Record = {
} `, - imports: [FormsModule, IconComponent, MatFormField, MatInput, RouterLink], + imports: [ + FormsModule, + IconComponent, + MatFormField, + MatInput, + MatLabel, + NgTemplateOutlet, + RouterLink, + ], }) export class ProjectDetailComponent { private readonly _store = inject(StoreService); private readonly _route = inject(ActivatedRoute); + private readonly _floor_input = + viewChild>('floor_input'); + private readonly _edit_input = + viewChild>('edit_input'); private readonly _id = this._route.snapshot.paramMap.get('id') ?? ''; @@ -244,7 +433,11 @@ export class ProjectDetailComponent { public readonly error = signal(''); public readonly show_form = signal(false); public readonly creating = signal(false); + public readonly reordering = signal(false); public readonly floor_name = signal(''); + /** Field being renamed inline, and its draft value */ + public readonly editing = signal(null); + public readonly draft = signal(''); /** Object URLs for stored background images, keyed by floorplan id */ public readonly previews = signal>({}); @@ -256,19 +449,32 @@ export class ProjectDetailComponent { this._destroyed = true; this._revokePreviews(this.previews()); }); + // Focus the floor name field each time the form opens + effect(() => this._floor_input()?.nativeElement.focus()); + // Focus and select the rename field when it appears + effect(() => this._edit_input()?.nativeElement.select()); } public readonly badgeClass = (status: string) => BADGE_CLASSES[status] ?? BADGE_CLASSES['draft']; - public readonly floorplans = () => + public readonly floorplans = computed(() => [...(this.project()?.floorplans ?? [])].sort( (a, b) => a.floor_index - b.floor_index, - ); + ), + ); public readonly previewFor = (floor: FloorplanSummary) => this.previews()[floor.id] ?? null; + public readonly isEditing = (target: RenameTarget) => { + const editing = this.editing(); + if (typeof target === 'string' || typeof editing === 'string') { + return target === editing; + } + return editing?.floor === target.floor; + }; + public async load() { if (!this._id) return; this.loading.set(true); @@ -284,40 +490,120 @@ export class ProjectDetailComponent { } } + public startRename(target: RenameTarget, value: string) { + this.draft.set(value); + this.editing.set(target); + } + + /** Saves the inline rename. Enter and blur both call this. */ + public async saveRename() { + const target = this.editing(); + const project = this.project(); + if (!target || !project) return; + this.editing.set(null); + const value = this.draft().trim(); + try { + if (target === 'name') { + if (!value || value === project.name) return; + await this._store.updateProject(project.id, { name: value }); + this._patchProject({ name: value }); + } else if (target === 'building') { + const building_name = value || null; + if (building_name === project.building_name) return; + await this._store.updateProject(project.id, { building_name }); + this._patchProject({ building_name }); + } else { + const floor = this.floorplans().find( + (f) => f.id === target.floor, + ); + if (!floor || !value || value === floor.floor_name) return; + this._replaceFloor( + await this._store.updateFloorplan(floor.id, { + floor_name: value, + }), + ); + } + } catch (error) { + this.error.set(this._message(error, 'Failed to rename')); + } + } + + /** Moves a floor one place up (-1) or down (1) and renumbers `floor_index` */ + public async moveFloor(floor: FloorplanSummary, offset: -1 | 1) { + const order = [...this.floorplans()]; + const from = order.findIndex((f) => f.id === floor.id); + const to = from + offset; + if (from < 0 || to < 0 || to >= order.length) return; + [order[from], order[to]] = [order[to], order[from]]; + this.reordering.set(true); + try { + const changed = order + .map((f, floor_index) => ({ f, floor_index })) + .filter(({ f, floor_index }) => f.floor_index !== floor_index); + const updated = await Promise.all( + changed.map(({ f, floor_index }) => + this._store.updateFloorplan(f.id, { floor_index }), + ), + ); + updated.forEach((f) => this._replaceFloor(f)); + } catch (error) { + this.error.set(this._message(error, 'Failed to reorder floors')); + } finally { + this.reordering.set(false); + } + } + public async addFloor(event: Event) { event.preventDefault(); const name = this.floor_name().trim(); if (!name) return; this.creating.set(true); try { - await this._store.addFloorplan( - this._id, - name, - this.floorplans().length, - ); + const floor = await this._store.addFloorplan(this._id, name); + this._patchProject({ + floorplans: [...this.floorplans(), floor], + }); this.floor_name.set(''); this.show_form.set(false); - await this.load(); } catch (error) { - this.error.set(this._message(error, 'Failed to add floorplan')); + this.error.set(this._message(error, 'Failed to add floor')); } finally { this.creating.set(false); } } public async removeFloor(floor: FloorplanSummary) { + const synced = floor.level_zone_id + ? ' It will not be imported from PlaceOS again.' + : ''; const confirmed = confirm( - `Delete floor "${floor.floor_name}"? This cannot be undone.`, + `Delete floor "${floor.floor_name}" and its floor plan? This cannot be undone.${synced}`, ); if (!confirmed) return; try { await this._store.deleteFloorplan(floor.id); - await this.load(); + this._patchProject({ + floorplans: this.floorplans().filter((f) => f.id !== floor.id), + }); } catch (error) { this.error.set(this._message(error, 'Failed to delete floor')); } } + private _patchProject(data: Partial) { + this.project.update((project) => + project ? { ...project, ...data } : project, + ); + } + + private _replaceFloor(floor: FloorplanSummary) { + this._patchProject({ + floorplans: this.floorplans().map((f) => + f.id === floor.id ? floor : f, + ), + }); + } + private async _loadPreviews(floorplans: FloorplanSummary[]) { const entries = await Promise.all( floorplans.map( diff --git a/apps/map-studio/src/app/pages/projects.component.ts b/apps/map-studio/src/app/pages/projects.component.ts index 696c337ba99..eef73eb07eb 100644 --- a/apps/map-studio/src/app/pages/projects.component.ts +++ b/apps/map-studio/src/app/pages/projects.component.ts @@ -1,6 +1,14 @@ -import { Component, effect, inject, signal } from '@angular/core'; +import { + Component, + effect, + ElementRef, + inject, + signal, + untracked, + viewChild, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatFormField } from '@angular/material/form-field'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { Router, RouterLink } from '@angular/router'; import { IconComponent } from '@placeos/components'; @@ -15,17 +23,11 @@ const formatDate = (iso: string) => year: 'numeric', }); -const BADGE_CLASSES: Record = { - draft: 'border-base-300 text-base-content/60 border', - published: 'bg-success-light text-success', - archived: 'bg-warning-light text-warning', -}; - @Component({ selector: 'map-studio-projects', template: `
-
+

Projects

@@ -34,7 +36,9 @@ const BADGE_CLASSES: Record = {

- upload_file + Import SVG
+ @switch (placeos_sync()) { + @case ('syncing') { +
+
+ Syncing buildings from PlaceOS… +
+ } + @case ('error') { + + } + } + @if (error(); as message) { @@ -68,11 +102,13 @@ const BADGE_CLASSES: Record = { appearance="outline" class="no-subscript w-full" > + Project name @@ -81,10 +117,11 @@ const BADGE_CLASSES: Record = { appearance="outline" class="no-subscript w-full" > + Building name @@ -120,52 +157,44 @@ const BADGE_CLASSES: Record = { Create your first project to start designing floor plans.

- + @if (!show_form()) { + + }
} @else {
@for (project of projects(); track project.id) { +
-
-
-

- - {{ project.name }} - -

- @if (project.building_name) { -

- {{ project.building_name }} -

- } -
- +

- {{ project.status }} - + + {{ project.name }} + +

+ @if (project.building_name) { +

+ {{ project.building_name }} +

+ }
= {
@@ -201,11 +232,20 @@ const BADGE_CLASSES: Record = { }
`, - imports: [FormsModule, IconComponent, MatFormField, MatInput, RouterLink], + imports: [ + FormsModule, + IconComponent, + MatFormField, + MatInput, + MatLabel, + RouterLink, + ], }) export class ProjectsComponent { private readonly _store = inject(StoreService); private readonly _router = inject(Router); + private readonly _name_input = + viewChild>('name_input'); public readonly projects = signal([]); public readonly loading = signal(true); @@ -214,11 +254,15 @@ export class ProjectsComponent { public readonly creating = signal(false); public readonly form_name = signal(''); public readonly form_building = signal(''); + public readonly placeos_sync = this._store.placeos_sync; constructor() { + // Focus the name field each time the form opens + effect(() => this._name_input()?.nativeElement.focus()); + // Load now, then reload quietly after each zone sync so new buildings appear effect(() => { - this._store.zone_sync_version(); - void this.load(); + const version = this._store.zone_sync_version(); + untracked(() => void this.load(version > 0)); }); } @@ -228,11 +272,9 @@ export class ProjectsComponent { public readonly updatedOn = (project: Project) => formatDate(project.updated_at); - public readonly badgeClass = (project: Project) => - BADGE_CLASSES[project.status] ?? BADGE_CLASSES.draft; - - public async load() { - this.loading.set(true); + /** Reads the project list. `quiet` keeps the current list on screen. */ + public async load(quiet = false) { + if (!quiet) this.loading.set(true); this.error.set(''); try { this.projects.set(await this._store.listProjects()); @@ -243,20 +285,21 @@ export class ProjectsComponent { } } + public retrySync() { + this._store.syncFromPlaceOS(); + } + public async create(event: Event) { event.preventDefault(); const name = this.form_name().trim(); if (!name) return; this.creating.set(true); try { - await this._store.createProject( + const project = await this._store.createProject( name, this.form_building().trim() || undefined, ); - this.form_name.set(''); - this.form_building.set(''); - this.show_form.set(false); - await this.load(); + await this._router.navigate(['/project', project.id]); } catch (error) { this.error.set(this._message(error, 'Failed to create project')); } finally { @@ -264,14 +307,12 @@ export class ProjectsComponent { } } - public open(project: Project) { - this._router.navigate(['/project', project.id]); - } - - public async remove(event: Event, project: Project) { - event.stopPropagation(); + public async remove(project: Project) { + const synced = project.building_zone_id + ? ' It will not be imported from PlaceOS again.' + : ''; const confirmed = confirm( - `Delete project "${project.name}"? This cannot be undone.`, + `Delete project "${project.name}" and all of its floors? This cannot be undone.${synced}`, ); if (!confirmed) return; try { diff --git a/apps/map-studio/src/app/pages/settings.component.ts b/apps/map-studio/src/app/pages/settings.component.ts index 164a2ee70c4..812c8a8fcf3 100644 --- a/apps/map-studio/src/app/pages/settings.component.ts +++ b/apps/map-studio/src/app/pages/settings.component.ts @@ -1,11 +1,15 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatFormField } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { AsyncHandler } from '@placeos/common'; -import { PlaceOSService, PlaceOSUser } from '../data/placeos.service'; +import { + PlaceOSService, + PlaceOSSettings, + PlaceOSUser, +} from '../data/placeos.service'; type ConnectionStatus = 'idle' | 'loading' | 'connected' | 'error'; @@ -48,6 +52,12 @@ type ConnectionStatus = 'idle' | 'loading' | 'connected' | 'error';
} +

+ A PlaceOS connection lets you publish floor plans to + building levels and show live space availability in the + kiosk. +

+ @if (mode() === 'domain') {

Signed in through @@ -85,22 +95,38 @@ type ConnectionStatus = 'idle' | 'loading' | 'connected' | 'error'; [ngModelOptions]="{ standalone: true }" /> - +

+ + @if (has_saved_key()) { + + } +
@if (error(); as message) { -

{{ message }}

+ } @if (saved()) {

@@ -129,28 +155,43 @@ export class SettingsComponent extends AsyncHandler { public readonly user = signal(null); public readonly error = signal(''); public readonly saved = signal(false); + /** True when a domain and API key are stored */ + public readonly has_saved_key = computed( + () => !!this._placeos.settings().api_key, + ); constructor() { super(); if (this._placeos.config.configured) this._test(); } + /** Tests the entered credentials and stores them only if they work */ public async save() { if (!this.api_key()) return; - this.status.set('loading'); this.error.set(''); this.saved.set(false); - this._placeos.setConfig(this.domain(), this.api_key()); - const connected = await this._test(); - if (connected) { - this.saved.set(true); - this.timeout('saved', () => this.saved.set(false), 3000); - } + const settings = { domain: this.domain(), api_key: this.api_key() }; + const connected = await this._test(settings); + if (!connected) return; + this._placeos.setConfig(settings.domain, settings.api_key); + this.saved.set(true); + this.timeout('saved', () => this.saved.set(false), 3000); + } + + /** Forgets the stored credentials */ + public disconnect() { + this._placeos.clearConfig(); + this.domain.set(''); + this.api_key.set(''); + this.user.set(null); + this.error.set(''); + this.saved.set(false); + this.status.set('idle'); } - private async _test(): Promise { + private async _test(settings?: PlaceOSSettings): Promise { this.status.set('loading'); - const result = await this._placeos.testConnection(); + const result = await this._placeos.testConnection(settings); if (result.ok && result.user) { this.user.set(result.user); this.status.set('connected'); diff --git a/apps/map-studio/src/tests/availability-polling.spec.ts b/apps/map-studio/src/tests/availability-polling.spec.ts new file mode 100644 index 00000000000..d75f47fbb08 --- /dev/null +++ b/apps/map-studio/src/tests/availability-polling.spec.ts @@ -0,0 +1,110 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { PlaceOSService } from '../app/data/placeos.service'; +import { MapObject } from '../app/data/types'; +import { AvailabilityPollingService } from '../app/kiosk/availability-polling.service'; + +const object = ( + id: string, + object_type: MapObject['object_type'] = 'room', +): MapObject => ({ + id, + floorplan_id: 'fp', + object_type, + svg_id: id, + label: id, + geometry: { type: 'rect', x: 0, y: 0, width: 40, height: 40 }, + entity_type: null, + entity_id: null, + layer: 'rooms', + fill_color: null, + stroke_color: null, + opacity: 1, + capacity: null, + amenities: null, + tags: null, + metadata: null, + group_id: null, + z_index: 0, + locked: false, + visible: true, +}); + +describe('AvailabilityPollingService', () => { + const getSystems = vi.fn(); + let service: AvailabilityPollingService; + + beforeEach(() => { + vi.useFakeTimers(); + getSystems.mockReset(); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + AvailabilityPollingService, + { + provide: PlaceOSService, + useValue: { config: { configured: true }, getSystems }, + }, + ], + }); + service = TestBed.inject(AvailabilityPollingService); + }); + + afterEach(() => { + service.stop(); + vi.useRealTimers(); + }); + + it('simulates only when the floor has no level zone', async () => { + service.start([object('room-1'), object('desk-1', 'desk')]); + expect(service.simulated()).toBe(true); + expect(service.states()).toEqual({}); + + await vi.advanceTimersByTimeAsync(2_000); + expect(getSystems).not.toHaveBeenCalled(); + expect(Object.keys(service.states())).toEqual(['room-1', 'desk-1']); + }); + + it('reads live states when a level zone is given', async () => { + getSystems.mockResolvedValue([{ map_id: 'room-1', bookable: false }]); + service.start([object('room-1')], 'zone-1'); + expect(service.simulated()).toBe(false); + + await vi.advanceTimersByTimeAsync(2_000); + expect(getSystems).toHaveBeenCalledWith('zone-1'); + expect(service.states()).toEqual({ 'room-1': 'out-of-service' }); + expect(service.last_updated()).not.toBeNull(); + }); + + it('keeps the last good states when a poll fails', async () => { + getSystems.mockResolvedValueOnce([ + { map_id: 'room-1', bookable: true }, + ]); + service.start([object('room-1')], 'zone-1'); + await vi.advanceTimersByTimeAsync(2_000); + const updated = service.last_updated(); + + getSystems.mockRejectedValue(new Error('offline')); + await vi.advanceTimersByTimeAsync(30_000 * 2); + expect(service.states()).toEqual({ 'room-1': 'free' }); + expect(service.simulated()).toBe(false); + expect(service.last_updated()).toBe(updated); + expect(service.offline()).toBe(false); + + await vi.advanceTimersByTimeAsync(30_000); + expect(service.offline()).toBe(true); + }); + + it('ignores a poll that lands after the floor changed', async () => { + let resolve: (systems: unknown[]) => void = () => undefined; + getSystems.mockReturnValueOnce(new Promise((done) => (resolve = done))); + service.start([object('room-1')], 'zone-1'); + await vi.advanceTimersByTimeAsync(2_000); + + service.start([object('room-2')], 'zone-2'); + resolve([{ map_id: 'room-1', bookable: false }]); + await vi.advanceTimersByTimeAsync(0); + expect(service.states()).toEqual({}); + }); +}); diff --git a/apps/map-studio/src/tests/editor-state.service.spec.ts b/apps/map-studio/src/tests/editor-state.service.spec.ts index 1680b3d0719..37b8a6d1d84 100644 --- a/apps/map-studio/src/tests/editor-state.service.spec.ts +++ b/apps/map-studio/src/tests/editor-state.service.spec.ts @@ -4,6 +4,7 @@ import { TestBed } from '@angular/core/testing'; import { DbChanges, DbService } from '../app/data/db'; import { PlaceOSService } from '../app/data/placeos.service'; import { StoreService } from '../app/data/store.service'; +import { CanvasComponent } from '../app/editor/canvas.component'; import { EditorStateService } from '../app/editor/editor-state.service'; /** jsdom has no IndexedDB — back the store with a plain Map instead */ @@ -393,6 +394,76 @@ describe('EditorStateService', () => { expect(after).toHaveLength(before.length); }); + it('deletes a multi-selection as one undo step', async () => { + const room = editor.objects()[0]; + const desk = await editor.createObject({ + object_type: 'desk', + label: 'Desk 1', + layer: 'desks', + geometry: { type: 'rect', x: 10, y: 10, width: 20, height: 20 }, + }); + + await editor.deleteObjects([room.id, desk.id]); + expect(editor.objects()).toHaveLength(0); + + editor.undo(); + expect(editor.objects()).toHaveLength(2); + expect(await store.listObjects(floorplan_id)).toHaveLength(2); + }); + + it('duplicates a multi-selection as one undo step', async () => { + const second = await editor.createObject({ + object_type: 'room', + label: 'Room 2', + layer: 'rooms', + geometry: { type: 'rect', x: 60, y: 0, width: 50, height: 50 }, + }); + + const copies = await editor.duplicateObjects([ + editor.objects()[0].id, + second.id, + ]); + expect(copies).toHaveLength(2); + expect(editor.objects()).toHaveLength(4); + expect(editor.multi_select()).toEqual(copies.map((c) => c.id)); + + editor.undo(); + expect(editor.objects()).toHaveLength(2); + }); + + it('places a desk group as one undo step', async () => { + editor.startPlacing('desk-layout', 'row-4'); + await editor.placeAt(200, 200); + expect(editor.objects()).toHaveLength(5); + + editor.undo(); + expect(editor.objects()).toHaveLength(1); + }); + + it('deletes a layer with its objects, and undo brings both back', async () => { + await editor.deleteLayer('rooms'); + expect(editor.objects()).toHaveLength(0); + expect(editor.layers().some((l) => l.id === 'rooms')).toBe(false); + + editor.undo(); + expect(editor.objects()).toHaveLength(1); + expect(editor.visible_objects()).toHaveLength(1); + }); + + it('keeps the plan dirty and shows the error when a save fails', async () => { + await editor.updateObject(editor.objects()[0].id, { label: 'Moved' }); + vi.spyOn(store, 'saveCanvasState').mockRejectedValueOnce( + new Error('Disk full'), + ); + + expect(await editor.flush()).toBe(false); + expect(editor.dirty()).toBe(true); + expect(editor.error()).toBe('Disk full'); + + expect(await editor.flush()).toBe(true); + expect(editor.error()).toBe(''); + }); + it('hands the active layer on when the current one is deleted', () => { editor.setActiveLayer('zones'); editor.deleteLayer('zones'); @@ -428,6 +499,27 @@ describe('EditorStateService', () => { expect(editor.multi_select()).toEqual([room.id, secondRoom.id]); }); + it('extends a plain click selection with Shift-click', async () => { + const room = editor.objects()[0]; + const secondRoom = await editor.createObject({ + object_type: 'room', + label: 'Room 2', + layer: 'rooms', + geometry: { type: 'rect', x: 60, y: 0, width: 50, height: 50 }, + }); + + editor.select(room.id); + editor.toggleMultiSelect(secondRoom.id); + expect(editor.multi_select()).toEqual([room.id, secondRoom.id]); + }); + + it('keeps a deleted default layer deleted after reopening', async () => { + await editor.deleteLayer('zones'); + await editor.save(); + await editor.load(floorplan_id); + expect(editor.layers().some((l) => l.id === 'zones')).toBe(false); + }); + it('saves the canvas state back to the floorplan', async () => { editor.setZoom(2); await editor.save(); @@ -443,3 +535,57 @@ describe('EditorStateService', () => { expect(editor.zoom()).toBe(0.8); }); }); + +describe('CanvasComponent pointer presses', () => { + let editor: EditorStateService; + let svg: SVGSVGElement; + + /** jsdom lays nothing out, so every client point maps to canvas (0, 0) */ + const press = (type: string, clientX = 0) => + svg.dispatchEvent( + new MouseEvent(type, { button: 0, clientX, bubbles: true }), + ); + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: DbService, useClass: MapDbService }, + EditorStateService, + ], + }); + const store = TestBed.inject(StoreService); + editor = TestBed.inject(EditorStateService); + const floorplan = await store.addFloorplan('p1', 'Level 1'); + await store.createObject(floorplan.id, { + label: 'Room 1', + object_type: 'room', + layer: 'rooms', + geometry: { type: 'rect', x: 0, y: 0, width: 50, height: 50 }, + }); + await editor.load(floorplan.id); + + const fixture = TestBed.createComponent(CanvasComponent); + await fixture.whenStable(); + svg = fixture.nativeElement.querySelector('svg'); + }); + + it('selects without marking the plan dirty when the pointer does not travel', () => { + press('mousedown'); + press('mousemove', 3); + press('mouseup', 3); + + expect(editor.selected_id()).toBe(editor.objects()[0].id); + expect(editor.dirty()).toBe(false); + expect(editor.can_undo()).toBe(false); + }); + + it('commits a drag once the pointer passes the threshold', () => { + press('mousedown'); + press('mousemove', 20); + press('mouseup', 20); + + expect(editor.dirty()).toBe(true); + expect(editor.can_undo()).toBe(true); + }); +}); diff --git a/apps/map-studio/src/tests/geometry.spec.ts b/apps/map-studio/src/tests/geometry.spec.ts index 409de8aaa27..666a9dde986 100644 --- a/apps/map-studio/src/tests/geometry.spec.ts +++ b/apps/map-studio/src/tests/geometry.spec.ts @@ -5,6 +5,8 @@ import { computeSnap, getHandlePos, pointInPolygon, + pointInRotatedRect, + rotatePoint, } from '../app/editor/geometry'; const SQUARE = [ @@ -90,6 +92,28 @@ describe('getHandlePos', () => { }); }); +describe('pointInRotatedRect', () => { + // A long thin bar, 100x10, centred on (50, 5) + const bar = { x: 0, y: 0, width: 100, height: 10 }; + + it('follows the box once it is rotated', () => { + // Rotated 90 degrees the bar stands upright through its centre + expect(pointInRotatedRect({ x: 50, y: 40 }, bar, 90)).toBe(true); + expect(pointInRotatedRect({ x: 90, y: 5 }, bar, 90)).toBe(false); + expect(pointInRotatedRect({ x: 90, y: 5 }, bar)).toBe(true); + }); + + it('round-trips a point through rotatePoint', () => { + const center = { x: 10, y: 10 }; + const turned = rotatePoint({ x: 20, y: 10 }, center, 90); + expect(turned.x).toBeCloseTo(10); + expect(turned.y).toBeCloseTo(20); + const back = rotatePoint(turned, center, -90); + expect(back.x).toBeCloseTo(20); + expect(back.y).toBeCloseTo(10); + }); +}); + describe('boundsOf', () => { it('returns the bounding box in geometry field order', () => { expect( diff --git a/apps/map-studio/src/tests/placeos.service.spec.ts b/apps/map-studio/src/tests/placeos.service.spec.ts index 7b3eef0ade3..c0abe15b418 100644 --- a/apps/map-studio/src/tests/placeos.service.spec.ts +++ b/apps/map-studio/src/tests/placeos.service.spec.ts @@ -352,6 +352,25 @@ describe('PlaceOSService', () => { expect(details()).toMatchObject({ svg_output: '' }); }); + it('tests unsaved credentials and reports a rejected key plainly', async () => { + stubFetch(false); + await service.init(); + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => '{"error":"unauthorized"}', + } as Response); + + const result = await service.testConnection({ + domain: 'https://other.example.com', + api_key: 'wrong', + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain('rejected the API key'); + expect(service.config.configured).toBe(false); + }); + it('falls back to a domain and API key otherwise', async () => { const calls = stubFetch(false); await service.init(); diff --git a/apps/map-studio/src/tests/store.service.spec.ts b/apps/map-studio/src/tests/store.service.spec.ts index 10492653eb2..3dc8771333d 100644 --- a/apps/map-studio/src/tests/store.service.spec.ts +++ b/apps/map-studio/src/tests/store.service.spec.ts @@ -4,6 +4,7 @@ import { TestBed } from '@angular/core/testing'; import { DbChanges, DbService } from '../app/data/db'; import { PlaceOSService } from '../app/data/placeos.service'; import { StoreService } from '../app/data/store.service'; +import { analyzeSvg } from '../app/data/svg-import'; /** * jsdom has no IndexedDB, so the persistence layer is backed by a plain Map. @@ -168,6 +169,83 @@ describe('StoreService', () => { expect(saved.updated_at).toBe('2099-01-01T00:00:00.000Z'); }); + it('does not recreate synced zones the user deleted', async () => { + const zone = (id: string) => ({ id, name: id, display_name: id }); + const buildings = [ + { + zone: zone('building-1'), + levels: [zone('level-1'), zone('level-2')], + }, + { zone: zone('building-2'), levels: [zone('level-3')] }, + ]; + await store.syncPlaceOSZones(buildings); + const projects = await store.listProjects(); + const first = projects.find((p) => p.building_zone_id === 'building-1'); + const second = projects.find( + (p) => p.building_zone_id === 'building-2', + ); + await store.deleteFloorplan(first!.floorplans![1].id); + await store.deleteProject(second!.id); + + await store.syncPlaceOSZones(buildings); + + const [project, ...rest] = await store.listProjects(); + expect(rest).toHaveLength(0); + expect(project.floorplans?.map((f) => f.level_zone_id)).toEqual([ + 'level-1', + ]); + }); + + it('appends new floors after the highest floor index', async () => { + await store.addFloorplan('p1', 'Level 1', 0); + await store.addFloorplan('p1', 'Level 5', 4); + const added = await store.addFloorplan('p1', 'Level 6'); + expect(added.floor_index).toBe(5); + }); + + it('imports an SVG into an existing project', async () => { + const project = await store.createProject('Sydney HQ'); + await store.addFloorplan(project.id, 'Level 1'); + const svg_content = + ''; + + const result = await store.importSvg({ + svg_content, + analysis: analyzeSvg(svg_content), + project_id: project.id, + floor_name: 'Level 2', + mappings: [{ svgId: 'room-1', objectType: 'room' }], + }); + + expect(result.project_id).toBe(project.id); + const floorplan = await store.getFloorplan(result.floorplan_id); + expect(floorplan).toMatchObject({ + floor_index: 1, + source_type: 'svg-import', + svg_source_ids: ['room-1'], + }); + expect(floorplan.svg_source).toContain('room-1'); + expect(await store.listProjects()).toHaveLength(1); + }); + + it('removes the new project when an SVG import fails', async () => { + const svg_content = ''; + vi.spyOn(store, 'bulkUpsertObjects').mockRejectedValueOnce( + new Error('disk full'), + ); + + await expect( + store.importSvg({ + svg_content, + analysis: analyzeSvg(svg_content), + project_name: 'Broken', + floor_name: 'Level 1', + mappings: [], + }), + ).rejects.toThrow('disk full'); + expect(await store.listProjects()).toHaveLength(0); + }); + it('detects and applies a newer server floorplan', async () => { const floorplan = await store.addFloorplan( 'project-1', diff --git a/apps/map-studio/src/tests/svg-export.spec.ts b/apps/map-studio/src/tests/svg-export.spec.ts index df66aa2a76b..9a46fcd07e6 100644 --- a/apps/map-studio/src/tests/svg-export.spec.ts +++ b/apps/map-studio/src/tests/svg-export.spec.ts @@ -3,6 +3,7 @@ import { bookableObjects, buildSvg, preflightChecks, + sequentialIdRange, } from '../app/editor/svg-export'; const object = (partial: Partial): MapObject => ({ @@ -326,3 +327,21 @@ describe('buildSvg', () => { ); }); }); + +describe('sequentialIdRange', () => { + it('shows the first and last id with zero padding', () => { + expect(sequentialIdRange('room-', 1, 42)).toBe('room-001 … room-042'); + }); + + it('shows a single id when there is one object', () => { + expect(sequentialIdRange('desk-', 7, 1)).toBe('desk-007'); + }); + + it('stops padding past three digits', () => { + expect(sequentialIdRange('d', 998, 3)).toBe('d998 … d1000'); + }); + + it('is empty when there is nothing to number', () => { + expect(sequentialIdRange('room-', 1, 0)).toBe(''); + }); +}); From fe209ee5102074ad162f7609d76a80f567836d9f Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 19 Oct 2026 17:25:37 +1100 Subject: [PATCH 2/2] fix(map-studio): let imported kiosk rooms and desks take taps Rooms and desks from an imported SVG are drawn inside the source picture. The kiosk now adds invisible tap targets for them in one back-to-front draw order: zones, imported rooms, drawn rooms, imported desks, drawn desks, then other objects. --- .../src/app/kiosk/floor-plan-2d.component.ts | 266 ++++++++++++------ .../src/tests/floor-plan-2d.spec.ts | 72 +++++ 2 files changed, 247 insertions(+), 91 deletions(-) create mode 100644 apps/map-studio/src/tests/floor-plan-2d.spec.ts diff --git a/apps/map-studio/src/app/kiosk/floor-plan-2d.component.ts b/apps/map-studio/src/app/kiosk/floor-plan-2d.component.ts index 00007b4cf12..83fd66135b0 100644 --- a/apps/map-studio/src/app/kiosk/floor-plan-2d.component.ts +++ b/apps/map-studio/src/app/kiosk/floor-plan-2d.component.ts @@ -215,100 +215,153 @@ function objectIdOf(target: EventTarget | null) { } } - @for (object of shapes(); track object.id) { - - @switch (object.geometry.type) { - @case ('rect') { - - } - @case ('polygon') { - - } - @case ('circle') { - - } - } - @if (layouts().get(object.id); as layout) { - @if (layout.label; as text) { - - {{ text.text }} - + + @for (item of drawOrder(); track item.object.id) { + @let object = item.object; + @if (item.target) { + + @switch (object.geometry.type) { + @case ('polygon') { + + } + @case ('circle') { + + } + @case ('path') { + + } + @default { + + } } - @if (layout.status; as text) { - - {{ text.text }} - + + } @else { + + @switch (object.geometry.type) { + @case ('rect') { + + } + @case ('polygon') { + + } + @case ('circle') { + + } } - @if (layout.mark; as text) { - - {{ text.text }} - + @if (layouts().get(object.id); as layout) { + @if (layout.label; as text) { + + {{ text.text }} + + } + @if (layout.status; as text) { + + {{ text.text }} + + } + @if (layout.mark; as text) { + + {{ text.text }} + + } } - } - + + } } @@ -571,6 +624,37 @@ export class FloorPlan2dComponent { return [...zones, ...rooms, ...desks, ...other]; }); + /** + * What takes taps, back to front: zones, rooms, desks, then the rest. + * Imported rooms and desks live in the source image, so they come as + * invisible targets under the drawn shapes of their kind. + */ + public readonly drawOrder = computed(() => { + const drawn = this.shapes(); + const imported = this.floorplan().svg_source + ? this.objects().filter((o) => o.visible && importedObject(o)) + : []; + const ofType = (list: MapObject[], ...types: string[]) => + list.filter((o) => types.includes(o.object_type)); + const shape = (object: MapObject) => ({ object, target: false }); + const target = (object: MapObject) => ({ object, target: true }); + return [ + ...ofType(drawn, 'zone', 'area').map(shape), + ...ofType(imported, 'room').map(target), + ...ofType(drawn, 'room').map(shape), + ...ofType(imported, 'desk').map(target), + ...ofType(drawn, 'desk').map(shape), + ...drawn + .filter( + (o) => + !['zone', 'area', 'room', 'desk'].includes( + o.object_type, + ), + ) + .map(shape), + ]; + }); + /** Text for each shape, sized for the current zoom */ public readonly layouts = computed(() => { const states = this.availability(); diff --git a/apps/map-studio/src/tests/floor-plan-2d.spec.ts b/apps/map-studio/src/tests/floor-plan-2d.spec.ts new file mode 100644 index 00000000000..e5415d99f26 --- /dev/null +++ b/apps/map-studio/src/tests/floor-plan-2d.spec.ts @@ -0,0 +1,72 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { Floorplan, MapObject } from '../app/data/types'; +import { FloorPlan2dComponent } from '../app/kiosk/floor-plan-2d.component'; + +const object = ( + id: string, + object_type: MapObject['object_type'], + imported = false, +): MapObject => ({ + id, + floorplan_id: 'fp', + object_type, + svg_id: id, + label: id, + geometry: { type: 'rect', x: 0, y: 0, width: 40, height: 40 }, + entity_type: null, + entity_id: null, + layer: 'rooms', + fill_color: null, + stroke_color: null, + opacity: 1, + capacity: null, + amenities: null, + tags: null, + metadata: imported ? { source: 'svg-import' } : null, + group_id: null, + z_index: 0, + locked: false, + visible: true, +}); + +describe('FloorPlan2dComponent draw order', () => { + it('keeps imported targets above zones and below drawn shapes of their kind', () => { + // jsdom has no ResizeObserver; the component only uses it for sizing + vi.stubGlobal( + 'ResizeObserver', + class { + public observe() {} + public disconnect() {} + }, + ); + TestBed.configureTestingModule({ + providers: [provideZonelessChangeDetection()], + }); + const fixture = TestBed.createComponent(FloorPlan2dComponent); + fixture.componentRef.setInput('floorplan', { + id: 'fp', + svg_source: '', + } as Floorplan); + fixture.componentRef.setInput('objects', [ + object('drawn-desk', 'desk'), + object('imported-desk', 'desk', true), + object('drawn-room', 'room'), + object('imported-room', 'room', true), + object('zone', 'zone'), + ]); + fixture.componentRef.setInput('availability', {}); + + const order = fixture.componentInstance + .drawOrder() + .map(({ object, target }) => `${object.id}${target ? '*' : ''}`); + expect(order).toEqual([ + 'zone', + 'imported-room*', + 'drawn-room', + 'imported-desk*', + 'drawn-desk', + ]); + }); +});