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/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', + ]); + }); +}); 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(''); + }); +});