From aa8a93794eca5be85c84313637da571ac0df3a73 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:57 +1100 Subject: [PATCH 01/10] fix(apps): fix bugs in signage, stagehand, assistant panel and small apps - Signage: the animation time setting now reaches the CSS transition. - Stagehand: failed loads clear the progress bar and show an error. The editors do not save over another dashboard or alert after a failed load, and leaving during a load does not redirect. - Stagehand: missing translation keys and the post-save route are fixed. - Assistant panel: the chat binds to the system chosen at setup. - Chat: sending reopens a closed chat; the info toggle works. - Enrolment: a failed check-in no longer leaves the spinner forever. - Caterer UI: the landing tile images are in shared assets. - App loader: the app grid layout is back. - Redirect: the ellipsis animation is only on the redirect message. --- apps/app-loader/src/app/app.component.ts | 12 ++- .../src/app/panel-view.component.ts | 5 +- .../src/tests/panel-view.component.spec.ts | 16 ++++ apps/caterer-ui/src/app/catering.component.ts | 8 +- .../src/app/catering/catering.component.ts | 4 +- .../src/app/enrolment-error.component.ts | 8 ++ .../src/app/enrolment-state.service.ts | 23 +++-- .../src/tests/enrolment-state.service.spec.ts | 10 +++ apps/redirect/src/app/app.component.ts | 4 +- apps/signage/src/styles.css | 6 +- .../src/tests/media-player.component.spec.ts | 19 ++++ .../dashboard-alert-manage.component.ts | 29 ++++-- .../dashboards/dashboard-mange.component.ts | 17 ++-- .../src/app/dashboards/dashboards.service.ts | 85 +++++++++++++----- .../src/tests/dashboards.service.spec.ts | 11 +++ .../dashboard-alert-manage.component.spec.ts | 72 ++++++++++++++- .../dashboard-mange.component.spec.ts | 17 +++- .../components/src/lib/chat/chat.component.ts | 13 +-- libs/components/src/lib/chat/chat.service.ts | 9 ++ .../src/tests/chat.component.spec.ts | 26 +++--- .../components/src/tests/chat.service.spec.ts | 10 +++ .../assets => shared/assets/img}/menus.jpg | Bin .../assets => shared/assets/img}/orders.jpg | Bin 23 files changed, 322 insertions(+), 82 deletions(-) rename {apps/concierge/src/assets => shared/assets/img}/menus.jpg (100%) rename {apps/concierge/src/assets => shared/assets/img}/orders.jpg (100%) diff --git a/apps/app-loader/src/app/app.component.ts b/apps/app-loader/src/app/app.component.ts index 0f13ee79da1..0dbda15e664 100644 --- a/apps/app-loader/src/app/app.component.ts +++ b/apps/app-loader/src/app/app.component.ts @@ -51,7 +51,9 @@ import {

No applications configured for this domain.

} @else { -
+
@for (app of applications(); track app.id) { {{ appInitial(app.name) }} }
-
{{ app.name }}
+
+ {{ app.name }} +
}
@@ -80,7 +86,7 @@ import { `, - styles: [], + host: { class: 'block min-h-full' }, providers: [SettingsService], }) export class AppComponent implements OnInit { diff --git a/apps/assistant-panel/src/app/panel-view.component.ts b/apps/assistant-panel/src/app/panel-view.component.ts index a55f4cd6c78..59266c5375c 100644 --- a/apps/assistant-panel/src/app/panel-view.component.ts +++ b/apps/assistant-panel/src/app/panel-view.component.ts @@ -360,6 +360,10 @@ export class PanelViewComponent } public async ngOnInit() { + // Chat with the system chosen at setup, not the building default + this._chat.setBinding( + this._route.snapshot.paramMap.get('system_id') || '', + ); await this._org.waitUntilInitialised(); const start_voice = () => { void this._setupVoiceRecognition(); @@ -662,7 +666,6 @@ export class PanelViewComponent const last_text = this.last_text(); if (last_text.length <= 3) return; if (last_text === this._previous_message) return; - this._chat.startChat(); this._chat.sendMessage(last_text); this._previous_message = last_text; this.last_text.set(''); diff --git a/apps/assistant-panel/src/tests/panel-view.component.spec.ts b/apps/assistant-panel/src/tests/panel-view.component.spec.ts index b2c13e3f356..078e353ad8b 100644 --- a/apps/assistant-panel/src/tests/panel-view.component.spec.ts +++ b/apps/assistant-panel/src/tests/panel-view.component.spec.ts @@ -45,6 +45,7 @@ describe('PanelViewComponent', () => { let chat: { messages: ReturnType>; progress: ReturnType>; + setBinding: ReturnType; startChat: ReturnType; sendMessage: ReturnType; close: ReturnType; @@ -97,12 +98,14 @@ describe('PanelViewComponent', () => { chat = { messages: signal([]), progress: signal(null), + setBinding: vi.fn(), startChat: vi.fn(), sendMessage: vi.fn(), close: vi.fn(), }; spectator = create_component({ providers: [{ provide: ChatService, useValue: chat }], + params: { system_id: 'sys-7' }, }); }); @@ -110,6 +113,19 @@ describe('PanelViewComponent', () => { expect(spectator.component).toBeTruthy(); }); + it('should chat with the routed system', async () => { + vi.useFakeTimers(); + spectator.detectChanges(); + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + + expect(chat.setBinding).toHaveBeenCalledWith('sys-7'); + expect(chat.startChat).toHaveBeenCalled(); + expect(chat.setBinding.mock.invocationCallOrder[0]).toBeLessThan( + chat.startChat.mock.invocationCallOrder[0], + ); + }); + it('should reveal a message timestamp when the message is tapped', async () => { chat.messages.set([make_message({})]); spectator.detectChanges(); diff --git a/apps/caterer-ui/src/app/catering.component.ts b/apps/caterer-ui/src/app/catering.component.ts index 620f6c67bf7..32dd15530b1 100644 --- a/apps/caterer-ui/src/app/catering.component.ts +++ b/apps/caterer-ui/src/app/catering.component.ts @@ -68,8 +68,8 @@ import { CateringTopbarComponent } from './catering-topbar.component'; [routerLink]="['/menu']" >
diff --git a/apps/enrolment/src/app/enrolment-state.service.ts b/apps/enrolment/src/app/enrolment-state.service.ts index 568c5005731..1e2150d0549 100644 --- a/apps/enrolment/src/app/enrolment-state.service.ts +++ b/apps/enrolment/src/app/enrolment-state.service.ts @@ -111,21 +111,34 @@ export class EnrolmentStateService { this._loading.set(''); } + /** Saves edited details, then checks the guest in. Failures show the error view. */ public async checkin() { if (this.form().dirty()) await this.updateGuest(); + if (this._error()) return; this._loading.set('Checking you in...'); - await checkinEventGuest(this._event_id(), this._guest_id(), true); - this._view.set('complete'); - this._loading.set(''); + try { + await checkinEventGuest(this._event_id(), this._guest_id(), true); + this._view.set('complete'); + } catch { + this.setError('checkin'); + } finally { + this._loading.set(''); + } } + /** Saves the guest's edited details. A failure shows the error view. */ public async updateGuest() { this.form().markAsTouched(); if (!this.form().valid()) return; this._loading.set('Updating your details...'); const details = this._guest.value(); - await updateGuest(details.id, { ...details, ...this.model() }); - this._loading.set(''); + try { + await updateGuest(details.id, { ...details, ...this.model() }); + } catch { + this.setError('update'); + } finally { + this._loading.set(''); + } } public handleUserToken(token: string) { diff --git a/apps/enrolment/src/tests/enrolment-state.service.spec.ts b/apps/enrolment/src/tests/enrolment-state.service.spec.ts index fc4f49da24a..15678faff26 100644 --- a/apps/enrolment/src/tests/enrolment-state.service.spec.ts +++ b/apps/enrolment/src/tests/enrolment-state.service.spec.ts @@ -323,5 +323,15 @@ describe('EnrolmentStateService', () => { await spectator.service.checkin(); expect(ts_client.patch).not.toHaveBeenCalled(); }); + + it('should show the error view and stop loading when check-in fails', async () => { + (ts_client.post as any).mockRejectedValue(new Error('offline')); + + await spectator.service.checkin(); + + expect(spectator.service.error()).toBe('checkin'); + expect(spectator.service.view()).toBe('error'); + expect(spectator.service.loading()).toBe(''); + }); }); }); diff --git a/apps/redirect/src/app/app.component.ts b/apps/redirect/src/app/app.component.ts index 46d4f794997..c64f2a92426 100644 --- a/apps/redirect/src/app/app.component.ts +++ b/apps/redirect/src/app/app.component.ts @@ -24,7 +24,7 @@ import {

{{ initialisation_error() }}

} @else { -

Redirecting

+

Redirecting

}
@@ -48,7 +48,7 @@ import { font-family: Arial, sans-serif; } - p:after { + .redirecting:after { display: inline-block; content: ''; vertical-align: bottom; diff --git a/apps/signage/src/styles.css b/apps/signage/src/styles.css index dbf2056282d..761d496132b 100644 --- a/apps/signage/src/styles.css +++ b/apps/signage/src/styles.css @@ -16,11 +16,11 @@ body { :root { --bg: #212121; - --transition-delay: 1000ms; + --transition-duration: 3000ms; } .player-animate { transition: - transform var(--transition-delay) ease-out, - opacity var(--transition-delay) ease-out; + transform var(--transition-duration) ease-out, + opacity var(--transition-duration) ease-out; } diff --git a/apps/signage/src/tests/media-player.component.spec.ts b/apps/signage/src/tests/media-player.component.spec.ts index 8ce432a54d4..76bbbd97927 100644 --- a/apps/signage/src/tests/media-player.component.spec.ts +++ b/apps/signage/src/tests/media-player.component.spec.ts @@ -1,3 +1,6 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + import { createComponentFactory, createHostFactory, @@ -2393,4 +2396,20 @@ describe('MediaPlayerComponent in a host', () => { ).toBe(true); } }); + + it('should set the transition duration that the global styles read', () => { + create_host(``); + const styles = readFileSync( + join(process.cwd(), 'apps/signage/src/styles.css'), + 'utf8', + ); + const name = styles.match( + /transition:\s*transform var\((--[\w-]+)\)/, + )?.[1]; + + expect(name).toBeTruthy(); + expect( + document.documentElement.style.getPropertyValue(name ?? ''), + ).toBe('1500ms'); + }); }); diff --git a/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts b/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts index 780ca484410..78fc293f845 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { Component, + DestroyRef, inject, OnInit, signal, @@ -344,6 +345,7 @@ export class DashboardAlertManageComponent implements OnInit { private _service = inject(DashboardsService); + private readonly _destroy_ref = inject(DestroyRef); private _dialog = inject(MatDialog); private _route = inject(ActivatedRoute); private _router = inject(Router); @@ -375,23 +377,29 @@ export class DashboardAlertManageComponent required(p.name); }); - public ngOnInit() { + public async ngOnInit() { const id = this._route.snapshot.paramMap.get('id'); const alert_id = this._route.snapshot.paramMap.get('alert_id'); + if (alert_id) this.setAlert(alert_id); if (id) { - this._service.setDashboard(id); this.model.update((m) => ({ ...m, alert_dashboard_id: id })); + await this._service.setDashboard(id); + } + // The user can leave while the dashboard loads + if (this._destroy_ref.destroyed) return; + if (!id || this.dashboard()?.id !== id) { + this._router.navigate(['/dashboards']); } - if (alert_id) this.setAlert(alert_id); - this.timeout('redirect', () => { - if (!this.dashboard()) this._router.navigate(['/dashboards']); - }); } + /** Set when the alert did not load, so save cannot change another one */ + private readonly _load_failed = signal(false); + public async setAlert(id: string) { - await this._service.setAlert(id); + const loaded = await this._service.setAlert(id); + this._load_failed.set(!loaded); const dash = this._service.alert(); - if (dash) this.model.update((m) => ({ ...m, ...dash })); + if (loaded && dash) this.model.update((m) => ({ ...m, ...dash })); } public removeCondition( @@ -436,6 +444,9 @@ export class DashboardAlertManageComponent public async save() { if (this.loading()) return; + if (this._load_failed()) { + return notifyError('The alert did not load. Reload the page.'); + } this.form().markAsTouched(); if (!this.form().valid()) { return notifyError( @@ -449,7 +460,7 @@ export class DashboardAlertManageComponent } const dashboard = this._service.dashboard(); if (!dashboard) return; - this.loading.set(i18n('STAGEHAND.DASHBOARD_ALERTS_LOADING')); + this.loading.set(i18n('COMMON.SAVING')); // The model holds the loaded alert, so a new alert takes nothing // from one opened before it const new_alert = { diff --git a/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts b/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts index 80614e15609..c8d50f7f079 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts @@ -45,7 +45,7 @@ import { DashboardsService } from './dashboards.service'; aria-label="Close dashboard editor" [routerLink]=" dashboard() - ? ['/dashboards', dashboard().id] + ? ['/dashboards', dashboard().id, 'alerts'] : ['/dashboards'] " > @@ -134,14 +134,21 @@ export class DashboardManageComponent extends AsyncHandler implements OnInit { if (id) this.setDashboard(id); } + /** Set when the dashboard did not load, so save cannot change another one */ + private readonly _load_failed = signal(false); + public async setDashboard(id: string) { - await this._service.setDashboard(id); + const loaded = await this._service.setDashboard(id); + this._load_failed.set(!loaded); const dash = this._service.dashboard(); - if (dash) this.model.update((m) => ({ ...m, ...dash })); + if (loaded && dash) this.model.update((m) => ({ ...m, ...dash })); } public async save() { if (this.loading()) return; + if (this._load_failed()) { + return notifyError('The dashboard did not load. Reload the page.'); + } this.form().markAsTouched(); if (!this.form().valid()) { return notifyError( @@ -153,7 +160,7 @@ export class DashboardManageComponent extends AsyncHandler implements OnInit { }), ); } - this.loading.set(i18n('STAGEHAND.DASHBOARD_LOADING')); + this.loading.set(i18n('COMMON.SAVING')); // The model holds the loaded dashboard, so a new dashboard takes // nothing from one opened before it const new_dash = this.model(); @@ -166,6 +173,6 @@ export class DashboardManageComponent extends AsyncHandler implements OnInit { return notifyError('Failed to save the dashboard. Try again.'); } this._service.loadDashboards(); - this._router.navigate(['/dashboards', result.id]); + this._router.navigate(['/dashboards', result.id, 'alerts']); } } diff --git a/apps/stagehand/src/app/dashboards/dashboards.service.ts b/apps/stagehand/src/app/dashboards/dashboards.service.ts index ee456a9763a..35777758dc1 100644 --- a/apps/stagehand/src/app/dashboards/dashboards.service.ts +++ b/apps/stagehand/src/app/dashboards/dashboards.service.ts @@ -175,31 +175,64 @@ export class DashboardsService extends AsyncHandler { } } + /** + * Shows `key` in `loading` while `task` runs. A failed task is logged + * and reported, and the key is always removed. + * Resolves true when the task succeeded. + */ + private async _whileLoading( + key: string, + error: string, + task: () => Promise, + ): Promise { + this.loading.update((l) => [...l, key]); + try { + await task(); + return true; + } catch (e) { + log('ALERTS', error, e, 'warn'); + notifyError(error); + return false; + } finally { + this.loading.update((l) => l.filter((i) => i !== key)); + } + } + + /** Load the alert. Resolves true when it loaded. */ public async setAlert(id: string) { - this.loading.update((l) => [...l, 'ALERT']); - const alert = await showAlert(id); - this.alert.set(alert); - this.loading.update((l) => l.filter((i) => i !== 'ALERT')); + return this._whileLoading( + 'ALERT', + 'Failed to load the alert.', + async () => this.alert.set(await showAlert(id)), + ); } + /** Load the dashboard. Resolves true when it loaded or was cleared. */ public async setDashboard(id: string) { if (!id) { this.dashboard.set(null); this.alerts_list.set([]); - return; + return true; } - this.loading.update((l) => [...l, 'DASHBOARD']); - const dashboard = await showAlertDashboard(id); - this.dashboard.set(dashboard); - this.loadDashboardAlerts(); - this.loading.update((l) => l.filter((i) => i !== 'DASHBOARD')); + return this._whileLoading( + 'DASHBOARD', + 'Failed to load the dashboard.', + async () => { + this.dashboard.set(await showAlertDashboard(id)); + this.loadDashboardAlerts(); + }, + ); } public async loadDashboards() { - this.loading.update((l) => [...l, 'DASHBOARD_LIST']); - const alerts = await queryAlertDashboards({ limit: 100 }); - this.dashboard_list.set(alerts.data); - this.loading.update((l) => l.filter((i) => i !== 'DASHBOARD_LIST')); + await this._whileLoading( + 'DASHBOARD_LIST', + 'Failed to load the dashboards.', + async () => { + const alerts = await queryAlertDashboards({ limit: 100 }); + this.dashboard_list.set(alerts.data); + }, + ); } public async removeDashboard(dashboard: PlaceAlertDashboard) { @@ -259,16 +292,20 @@ export class DashboardsService extends AsyncHandler { async loadDashboardAlerts(id = '') { const dashboard = this.dashboard(); if (!dashboard && !id) return; - this.loading.update((l) => [...l, 'ALERT_LIST']); - const alerts = await listDashboardAlerts(id || dashboard.id); - if (!id) this.alerts_list.set(alerts.data); - else { - this.dashboard_alert_map.update((m) => ({ - ...m, - [id]: alerts.data, - })); - } - this.loading.update((l) => l.filter((i) => i !== 'ALERT_LIST')); + await this._whileLoading( + 'ALERT_LIST', + 'Failed to load the dashboard alerts.', + async () => { + const alerts = await listDashboardAlerts(id || dashboard.id); + if (!id) this.alerts_list.set(alerts.data); + else { + this.dashboard_alert_map.update((m) => ({ + ...m, + [id]: alerts.data, + })); + } + }, + ); } public async listenForDashboardAlerts(force = false) { diff --git a/apps/stagehand/src/tests/dashboards.service.spec.ts b/apps/stagehand/src/tests/dashboards.service.spec.ts index 9d1ec3b7385..b5cb2da50c3 100644 --- a/apps/stagehand/src/tests/dashboards.service.spec.ts +++ b/apps/stagehand/src/tests/dashboards.service.spec.ts @@ -207,6 +207,17 @@ describe('DashboardsService', () => { ); expect(spectator.service.alerts_list()).toEqual([{ id: 'a1' }]); }); + + it('should clear the loading flag and report a failed load', async () => { + vi.mocked(ts_client.showAlertDashboard).mockRejectedValue( + new Error('offline'), + ); + + await spectator.service.setDashboard('dash-1'); + + expect(spectator.service.loading()).not.toContain('DASHBOARD'); + expect(notify_open).toHaveBeenCalled(); + }); }); describe('loadDashboards', () => { diff --git a/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts index 73abfd4230b..dd69b883a11 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts @@ -21,6 +21,8 @@ import { SystemSearchFieldComponent } from '../../app/dashboards/system-search-f vi.mock('@placeos/ts-client', { spy: true }); +import * as ts_client from '@placeos/ts-client'; + describe('DashboardAlertManageComponent', () => { let spectator: SpectatorRouting; let service: any; @@ -69,8 +71,8 @@ describe('DashboardAlertManageComponent', () => { service = { dashboard: signal({ id: 'dash-1', name: 'Lobby' }), alert: signal(undefined), - setDashboard: vi.fn(), - setAlert: vi.fn(), + setDashboard: vi.fn(async () => true), + setAlert: vi.fn(async () => true), }; dialog = { open: vi.fn() }; }); @@ -233,4 +235,70 @@ describe('DashboardAlertManageComponent', () => { expect(service.setAlert).toHaveBeenCalledWith('alert-3'); expect(spectator.component.model().name).toBe('Loaded'); }); + + it('should not save over another alert when the load fails', async () => { + service.alert.set({ id: 'alert-1', name: 'Previous' }); + service.setAlert = vi.fn(async () => false); + build({ params: { id: 'dash-1', alert_id: 'alert-2' } }); + + await spectator.component.setAlert('alert-2'); + spectator.component.model.update((m) => ({ ...m, name: 'Edited' })); + await spectator.component.save(); + + expect(spectator.component.model().id).not.toBe('alert-1'); + expect(ts_client.updateAlert).not.toHaveBeenCalled(); + expect(ts_client.addAlert).not.toHaveBeenCalled(); + }); + + it('should not leave the page when destroyed during the dashboard load', async () => { + service.dashboard.set({ id: 'dash-0' }); + let finish: (loaded: boolean) => void = () => undefined; + service.setDashboard = vi.fn( + () => new Promise((resolve) => (finish = resolve)), + ); + build({ params: { id: 'dash-1' } }); + const navigate = spectator.inject(Router).navigate; + + const init = spectator.component.ngOnInit(); + spectator.fixture.destroy(); + finish(false); + await init; + // Let any timer the old code set run + await new Promise((resolve) => setTimeout(resolve)); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('should wait for a slow dashboard load before checking it', async () => { + vi.useFakeTimers(); + service.dashboard.set(undefined); + service.setDashboard = vi.fn( + () => + new Promise((resolve) => + setTimeout(() => { + service.dashboard.set({ id: 'dash-1' }); + resolve(); + }, 1000), + ), + ); + build({ params: { id: 'dash-1' } }); + const navigate = spectator.inject(Router).navigate; + + spectator.component.ngOnInit(); + await vi.advanceTimersByTimeAsync(1500); + vi.useRealTimers(); + + expect(navigate).not.toHaveBeenCalled(); + }); + + it('should leave when the routed dashboard does not load', async () => { + service.dashboard.set({ id: 'dash-0' }); + build({ params: { id: 'dash-1' } }); + const navigate = spectator.inject(Router).navigate; + + await spectator.component.ngOnInit(); + await new Promise((resolve) => setTimeout(resolve)); + + expect(navigate).toHaveBeenCalledWith(['/dashboards']); + }); }); diff --git a/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts index 2afe466e383..51dca841ff4 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts @@ -49,7 +49,7 @@ describe('DashboardManageComponent', () => { setNotifyOutlet({ open: notify_open } as any, true); service = { dashboard: signal(undefined), - setDashboard: vi.fn(), + setDashboard: vi.fn(async () => true), loadDashboards: vi.fn(), }; }); @@ -92,6 +92,7 @@ describe('DashboardManageComponent', () => { expect(spectator.inject(Router).navigate).toHaveBeenCalledWith([ '/dashboards', 'new-1', + 'alerts', ]); }); @@ -150,6 +151,20 @@ describe('DashboardManageComponent', () => { expect(spectator.component.model().name).toBe('Loaded'); }); + it('should not save over another dashboard when the load fails', async () => { + service.dashboard.set({ id: 'dash-1', name: 'Previous' }); + service.setDashboard = vi.fn(async () => false); + build({ params: { id: 'dash-2' } }); + + await spectator.component.setDashboard('dash-2'); + spectator.component.model.update((m) => ({ ...m, name: 'Edited' })); + await spectator.component.save(); + + expect(spectator.component.model().id).not.toBe('dash-1'); + expect(updateAlertDashboard).not.toHaveBeenCalled(); + expect(addAlertDashboard).not.toHaveBeenCalled(); + }); + it('should not load a dashboard when the route has no id', () => { build(); diff --git a/libs/components/src/lib/chat/chat.component.ts b/libs/components/src/lib/chat/chat.component.ts index 790e983c515..42b2828244e 100644 --- a/libs/components/src/lib/chat/chat.component.ts +++ b/libs/components/src/lib/chat/chat.component.ts @@ -82,10 +82,6 @@ import { ChatService } from './chat.service'; [class.items-end]=" message.user_id === user().id " - (click)=" - show_time()[message.id] = - !show_time()[message.id] - " [class.waiting-margin]="waiting()" >
@@ -119,7 +115,7 @@ import { ChatService } from './chat.service';
diff --git a/apps/signage-manager/src/app/playlists/playlist-header.component.ts b/apps/signage-manager/src/app/playlists/playlist-header.component.ts index 034c220f771..524580e3975 100644 --- a/apps/signage-manager/src/app/playlists/playlist-header.component.ts +++ b/apps/signage-manager/src/app/playlists/playlist-header.component.ts @@ -20,7 +20,9 @@ import { SignagePlaylistService } from './signage-playlist.service';
{{ 'COMMON.ITEM_COUNT' - | translate: { count: total_count() } + | translate + : { count: total_count() } + : total_count() }}
diff --git a/apps/signage-manager/src/app/shared/media-select-modal.component.ts b/apps/signage-manager/src/app/shared/media-select-modal.component.ts index f43bdd820c9..7d68ebb1b14 100644 --- a/apps/signage-manager/src/app/shared/media-select-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-select-modal.component.ts @@ -4,7 +4,11 @@ import { MatRippleModule } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + IconComponent, + LoadErrorComponent, + TranslatePipe, +} from '@placeos/components'; import { SignageMedia } from '@placeos/ts-client'; import { SignageMediaService } from '../media/signage-media.service'; import { IntersectDirective } from './intersect.directive'; @@ -87,6 +91,8 @@ export interface MediaSelectModalData { intersect (intersect)="list.loadMore()" >
+ } @else if (list.error()) { + } } @else if (list.loading()) {
+ } @else if (list.error()) { + } @else {
+ } @else if (list.error()) { + } } @else if (list.loading()) {
+ } @else if (list.error()) { + } @else {
{{ 'COMMON.ITEM_COUNT' - | translate: { count: total_count() } + | translate + : { count: total_count() } + : total_count() }}
diff --git a/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts b/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts index f3e82b58b98..cadd839d03a 100644 --- a/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts +++ b/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts @@ -1,5 +1,7 @@ -import { signal } from '@angular/core'; +import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { LocaleService } from '@placeos/common'; +import { TranslatePipe } from '@placeos/components'; import { PlaylistHeaderComponent } from '../../app/playlists/playlist-header.component'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { SignageContextService } from '../../app/signage-context.service'; @@ -42,6 +44,33 @@ describe('PlaylistHeaderComponent', () => { expect(component.total_count()).toBe(450); }); + it('passes the count as the plural so one playlist reads "1 item"', async () => { + playlists_total.set(1); + // Plural 1 picks the ITEM_COUNT_1 form, as the locale service does + const locale = { + get: (key: string, args: { count?: number }, plural?: number) => + key === 'COMMON.ITEM_COUNT' + ? `${args.count} ${plural === 1 ? 'item' : 'items'}` + : key, + }; + await TestBed.configureTestingModule({ + imports: [PlaylistHeaderComponent], + providers: [ + { provide: LocaleService, useValue: locale }, + { provide: SignageContextService, useValue: context_stub }, + { provide: SignagePlaylistService, useValue: playlist_stub }, + ], + }) + .overrideComponent(PlaylistHeaderComponent, { + set: { imports: [TranslatePipe], schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); + const fixture = TestBed.createComponent(PlaylistHeaderComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('1 item'); + expect(fixture.nativeElement.textContent).not.toContain('1 items'); + }); + it('mirrors the service create permission', async () => { can_create.set(true); const component = await make(); diff --git a/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts index f357fe3ba76..10bb6bd4392 100644 --- a/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts @@ -44,3 +44,52 @@ describe('PlaylistSelectModalComponent', () => { ); }); }); + +// The real template, so a failed search shows an error and not "no playlists" +describe('PlaylistSelectModalComponent errors', () => { + const flush = () => new Promise((resolve) => setTimeout(resolve)); + const queryPlaylists = vi.fn(); + + beforeEach(async () => { + vi.clearAllMocks(); + await TestBed.configureTestingModule({ + imports: [PlaylistSelectModalComponent], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: {} }, + { + provide: SignagePlaylistService, + useValue: { queryPlaylists }, + }, + ], + }).compileComponents(); + }); + + it('shows a load error with a retry that queries again', async () => { + queryPlaylists + .mockReturnValueOnce(Promise.reject(new Error('offline'))) + .mockReturnValueOnce( + Promise.resolve({ + data: [{ id: 'p1', name: 'Morning Loop' }], + total: 1, + next: null, + }), + ); + const fixture = TestBed.createComponent(PlaylistSelectModalComponent); + fixture.detectChanges(); + await flush(); + fixture.detectChanges(); + + const element: HTMLElement = fixture.nativeElement; + const retry = + element.querySelector('load-error button'); + expect(retry).toBeTruthy(); + + retry?.click(); + await flush(); + fixture.detectChanges(); + + expect(queryPlaylists).toHaveBeenCalledTimes(2); + expect(element.querySelector('load-error')).toBeNull(); + expect(element.textContent).toContain('Morning Loop'); + }); +}); From 64733a48d140158f1e12eedb8348eac20d23b708 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:57 +1100 Subject: [PATCH 03/10] refactor(apps): remove unused code from the remaining apps - Outlook add-in: remove the legacy booking flow. The manifest only opens the calendar pane. This also removes the imports of workplace source. - Stagehand: remove time conditions (users could not add them), unused dashboard components and routes, and dead members. - Map Studio: remove unused version history, CSV import/export, editor mode and other members. - Signage Manager: remove unused translation keys and a dead fallback. - Remove small dead members in signage, enrolment, survey, redirect, assistant panel and caterer UI. --- .../src/app/panel-view.component.ts | 11 +- .../src/app/catering-topbar.component.ts | 2 - apps/enrolment/src/app/app.component.ts | 4 - apps/map-studio/SRS.md | 47 -- .../src/app/data/placeos.service.ts | 4 - apps/map-studio/src/app/data/store.service.ts | 195 +----- apps/map-studio/src/app/data/types.ts | 12 - .../src/app/editor/canvas.component.ts | 3 +- apps/map-studio/src/app/editor/constants.ts | 61 +- .../src/app/editor/editor-state.service.ts | 34 - .../src/tests/placeos.service.spec.ts | 8 +- .../src/tests/store.service.spec.ts | 101 --- apps/outlook-addin/src/app/app.component.ts | 4 - apps/outlook-addin/src/app/app.routes.ts | 80 --- .../src/app/desks/desk-booking.component.ts | 64 -- .../src/app/desks/desk-form.component.ts | 206 ------ .../src/app/desks/desk-success.component.ts | 60 -- .../app/meetings/meeting-booking.component.ts | 91 --- .../app/meetings/meeting-form.component.ts | 469 ------------- .../app/meetings/meeting-success.component.ts | 64 -- .../src/app/not-found.component.ts | 53 -- .../app/rooms/booking-confirmed.component.ts | 98 --- .../app/rooms/existing-bookings.service.ts | 31 - .../src/app/rooms/features-filter.service.ts | 74 -- .../src/app/rooms/filter-space.component.ts | 254 ------- .../app/rooms/find-space-item.component.ts | 59 -- .../src/app/rooms/find-space.component.ts | 635 ------------------ .../src/app/rooms/map.service.ts | 119 ---- .../src/app/rooms/room-booking.component.ts | 189 ------ .../src/app/rooms/room-confirm.component.ts | 200 ------ .../src/app/rooms/room-confirm.service.ts | 95 --- .../src/app/rooms/room-details.component.ts | 189 ------ .../src/app/rooms/room-tile.component.ts | 111 --- .../app/rooms/upcoming-bookings.component.ts | 94 --- apps/outlook-addin/src/assets/not-found.svg | 26 - apps/outlook-addin/src/assets/template.csv | 2 - apps/outlook-addin/src/assets/tick.svg | 3 - .../outlook-addin/src/assets/tick_success.svg | 31 - .../src/environments/settings.ts | 9 - apps/outlook-addin/src/styles.css | 14 - .../src/tests/features-filter.service.spec.ts | 63 -- .../src/tests/map.service.spec.ts | 141 ---- .../src/tests/room-booking.component.spec.ts | 161 ----- .../src/tests/room-confirm.service.spec.ts | 59 -- apps/outlook-addin/src/tests/test-mocks.ts | 262 -------- .../tests/upcoming-bookings.component.spec.ts | 61 -- apps/redirect/src/app/app.component.ts | 5 - .../shared/playlist-edit-modal.component.ts | 17 +- .../signage/src/app/media-player.component.ts | 5 - apps/signage/src/app/signage.component.ts | 2 - apps/stagehand/USER_STORIES.md | 14 - apps/stagehand/src/app/alerts.component.ts | 63 -- apps/stagehand/src/app/analytics.component.ts | 9 +- apps/stagehand/src/app/app.routes.ts | 7 - apps/stagehand/src/app/dashboard.component.ts | 24 - .../alert-condition-time-form.component.ts | 529 --------------- .../alert-conditions-modal.component.ts | 169 +---- .../dashboards/cron-input-field.component.ts | 173 ----- .../dashboard-alert-manage.component.ts | 50 +- .../dashboards/dashboard-view.component.ts | 102 --- .../app/dashboards/dashboards.component.ts | 30 +- .../system-search-field.component.ts | 96 +-- .../src/app/push-notification.service.ts | 6 - .../src/app/recorder-view.component.ts | 2 - .../src/app/remote-support.component.ts | 79 --- .../src/app/support-groups.service.ts | 16 - ...lert-condition-time-form.component.spec.ts | 244 ------- .../src/tests/alerts.component.spec.ts | 1 - .../tests/cron-input-field.component.spec.ts | 116 ---- .../src/tests/dashboard.component.spec.ts | 32 - .../alert-conditions-modal.component.spec.ts | 43 +- .../dashboard-view.component.spec.ts | 94 --- .../dashboards/dashboards.component.spec.ts | 16 - .../tests/push-notification.service.spec.ts | 20 +- .../tests/remote-support.component.spec.ts | 1 - .../system-search-field.component.spec.ts | 25 - apps/survey/src/app/app.component.ts | 2 - apps/survey/src/app/survey.component.ts | 4 +- docs/settings/outlook-addin.md | 59 +- libs/events/src/lib/event-form.service.ts | 9 +- shared/assets/locale/ar.json | 4 - shared/assets/locale/en-AU.json | 29 - shared/assets/locale/en-GB.json | 8 - shared/assets/locale/en-US.json | 8 - shared/assets/locale/es.json | 4 - shared/assets/locale/fr-CA.json | 4 - shared/assets/locale/fr.json | 4 - shared/assets/locale/it.json | 4 - shared/assets/locale/ja-JP.json | 4 - shared/assets/locale/pt.json | 4 - shared/assets/locale/zh.json | 4 - 91 files changed, 73 insertions(+), 6621 deletions(-) delete mode 100644 apps/map-studio/SRS.md delete mode 100644 apps/outlook-addin/src/app/desks/desk-booking.component.ts delete mode 100644 apps/outlook-addin/src/app/desks/desk-form.component.ts delete mode 100644 apps/outlook-addin/src/app/desks/desk-success.component.ts delete mode 100644 apps/outlook-addin/src/app/meetings/meeting-booking.component.ts delete mode 100644 apps/outlook-addin/src/app/meetings/meeting-form.component.ts delete mode 100644 apps/outlook-addin/src/app/meetings/meeting-success.component.ts delete mode 100644 apps/outlook-addin/src/app/not-found.component.ts delete mode 100644 apps/outlook-addin/src/app/rooms/booking-confirmed.component.ts delete mode 100644 apps/outlook-addin/src/app/rooms/existing-bookings.service.ts delete mode 100644 apps/outlook-addin/src/app/rooms/features-filter.service.ts delete mode 100644 apps/outlook-addin/src/app/rooms/filter-space.component.ts delete mode 100644 apps/outlook-addin/src/app/rooms/find-space-item.component.ts delete mode 100644 apps/outlook-addin/src/app/rooms/find-space.component.ts delete mode 100644 apps/outlook-addin/src/app/rooms/map.service.ts delete mode 100644 apps/outlook-addin/src/app/rooms/room-booking.component.ts delete mode 100644 apps/outlook-addin/src/app/rooms/room-confirm.component.ts delete mode 100644 apps/outlook-addin/src/app/rooms/room-confirm.service.ts delete mode 100644 apps/outlook-addin/src/app/rooms/room-details.component.ts delete mode 100644 apps/outlook-addin/src/app/rooms/room-tile.component.ts delete mode 100644 apps/outlook-addin/src/app/rooms/upcoming-bookings.component.ts delete mode 100644 apps/outlook-addin/src/assets/not-found.svg delete mode 100644 apps/outlook-addin/src/assets/template.csv delete mode 100644 apps/outlook-addin/src/assets/tick.svg delete mode 100644 apps/outlook-addin/src/assets/tick_success.svg delete mode 100644 apps/outlook-addin/src/tests/features-filter.service.spec.ts delete mode 100644 apps/outlook-addin/src/tests/map.service.spec.ts delete mode 100644 apps/outlook-addin/src/tests/room-booking.component.spec.ts delete mode 100644 apps/outlook-addin/src/tests/room-confirm.service.spec.ts delete mode 100644 apps/outlook-addin/src/tests/test-mocks.ts delete mode 100644 apps/outlook-addin/src/tests/upcoming-bookings.component.spec.ts delete mode 100644 apps/stagehand/src/app/dashboard.component.ts delete mode 100644 apps/stagehand/src/app/dashboards/alert-condition-time-form.component.ts delete mode 100644 apps/stagehand/src/app/dashboards/cron-input-field.component.ts delete mode 100644 apps/stagehand/src/app/dashboards/dashboard-view.component.ts delete mode 100644 apps/stagehand/src/tests/alert-condition-time-form.component.spec.ts delete mode 100644 apps/stagehand/src/tests/cron-input-field.component.spec.ts delete mode 100644 apps/stagehand/src/tests/dashboard.component.spec.ts delete mode 100644 apps/stagehand/src/tests/dashboards/dashboard-view.component.spec.ts diff --git a/apps/assistant-panel/src/app/panel-view.component.ts b/apps/assistant-panel/src/app/panel-view.component.ts index 59266c5375c..0d2dec1f7a4 100644 --- a/apps/assistant-panel/src/app/panel-view.component.ts +++ b/apps/assistant-panel/src/app/panel-view.component.ts @@ -75,7 +75,6 @@ type AssistantWindow = Window & {
-
@if (error().speech_recognition || error().speech_synthesis) {
- {{ - message.timestamp + offset - | dateFrom - }} + {{ message.timestamp | dateFrom }}
}
@@ -284,7 +280,6 @@ export class PanelViewComponent private _chat = inject(ChatService); private _org = inject(OrganisationService); - public scale = 1; public readonly current_text = signal(''); public readonly last_text = signal(''); public readonly listening = signal(false); @@ -294,8 +289,6 @@ export class PanelViewComponent public readonly error = signal>({}); public readonly show_time = signal>({}); public readonly show_info = signal(false); - public offset = 0; - private _time = 0; private _last_message = ''; private _previous_message = ''; private _context: CanvasRenderingContext2D; @@ -661,8 +654,6 @@ export class PanelViewComponent if (!this.setup()) return; this.last_text.set(this.current_text()); this.current_text.set(''); - this.clearInterval('scale'); - this.scale = 1; const last_text = this.last_text(); if (last_text.length <= 3) return; if (last_text === this._previous_message) return; diff --git a/apps/caterer-ui/src/app/catering-topbar.component.ts b/apps/caterer-ui/src/app/catering-topbar.component.ts index 484c232805b..15d800b3db0 100644 --- a/apps/caterer-ui/src/app/catering-topbar.component.ts +++ b/apps/caterer-ui/src/app/catering-topbar.component.ts @@ -181,7 +181,6 @@ import { DateOptionsComponent } from '@placeos/form-fields'; @if (page() !== 'menu') {
} - @if (page() !== 'menu') { } @@ -365,7 +364,6 @@ export class CateringTopbarComponent extends AsyncHandler { const params = this._query_param_map(); if (params.has('zone_ids')) { const zones = params.get('zone_ids').split(','); - if (!zones.length) return; const level = this._org.levelWithID(zones); this.zones.set(zones); if (!level) return; diff --git a/apps/enrolment/src/app/app.component.ts b/apps/enrolment/src/app/app.component.ts index 0aad1da6e7d..398403e2c9e 100644 --- a/apps/enrolment/src/app/app.component.ts +++ b/apps/enrolment/src/app/app.component.ts @@ -1,4 +1,3 @@ -import { Clipboard } from '@angular/cdk/clipboard'; import { Component, ErrorHandler, inject, OnInit } from '@angular/core'; import { SwUpdate } from '@angular/service-worker'; import { SettingsDebugPanelLauncherComponent } from '@placeos/components/settings-debug'; @@ -13,7 +12,6 @@ import { lazySnackbar, log, markInitialisationComplete, - OrganisationService, retryInitialisation, setAppName, setNotifyOutlet, @@ -80,10 +78,8 @@ import { PlaceAuthority, token } from '@placeos/ts-client'; }) export class AppComponent extends AsyncHandler implements OnInit { private _settings = inject(SettingsService); - private _org = inject(OrganisationService); private _cache = inject(SwUpdate); private _snackbar = lazySnackbar(); - private _clipboard = inject(Clipboard); /** Reports startup failures to the console and Sentry */ private _error_handler = inject(ErrorHandler); public readonly initialisation_error = initialisationFailure(); diff --git a/apps/map-studio/SRS.md b/apps/map-studio/SRS.md deleted file mode 100644 index e116efa8690..00000000000 --- a/apps/map-studio/SRS.md +++ /dev/null @@ -1,47 +0,0 @@ -# PlaceOS Map Studio - Software Requirements Specification - -## 1. Introduction - -### 1.1 Purpose - -To easily allow 3rd parties to create and edit maps for use with PlaceOS. - -### 1.2 Scope - -### 1.3 Definition, acronyms, and abbreviations - -### 1.4 References - -### 1.5 Overview - -#### Standard - -In line with international and systematic standards and recommendations guidelines, this SRS will follow IEEE/ANSI 830-1998, the definitive and most widely known SRS standard (Sommerville 2008) which is detailed in the following document, documents which further explicate this standard includes: Generating Software Requirements Specification (IEEE­ Std. 830­ 1998) document with Use Cases. Section 3 - Specific Requirements of the SRS can be organised in a number of ways as detailed in the Annex, this SRS will follow the A.5 Template of SRS Section 3: organized by feature. More information can be found here. - -## 2. Overall Description - -### 2.1 Product perspective - -### 2.2 Software interfaces - -### 2.3 Application Architecture - -### 2.4 Product functions - -### 2.5 User characteristics - -None, should be able to be used by anyone with the URL. - -### 2.6 Constraints - -### Assumptions and dependencies - -## 3. System Features and Requirements - -### 3.1 Functional Requirements - -### 3.2 External Interface Requirements - -### 3.3 System Features - -### 3.4 Nonfunctional Requirements diff --git a/apps/map-studio/src/app/data/placeos.service.ts b/apps/map-studio/src/app/data/placeos.service.ts index 1ebecc9bda4..26a6b642157 100644 --- a/apps/map-studio/src/app/data/placeos.service.ts +++ b/apps/map-studio/src/app/data/placeos.service.ts @@ -395,10 +395,6 @@ export class PlaceOSService { : details.updated_at; } - public async getLevelSvg(level_id: string): Promise { - return (await this.getLevelMap(level_id))?.svg ?? null; - } - /** The level's current `map_id` and its SVG; null when the level has no map. */ public async getLevelMap( level_id: string, diff --git a/apps/map-studio/src/app/data/store.service.ts b/apps/map-studio/src/app/data/store.service.ts index 65aade9e602..d0e4da54a06 100644 --- a/apps/map-studio/src/app/data/store.service.ts +++ b/apps/map-studio/src/app/data/store.service.ts @@ -10,7 +10,6 @@ import { Floorplan, FloorplanCanvas, FloorplanSummary, - FloorplanVersion, MapObject, MapObjectType, Project, @@ -43,10 +42,6 @@ const key = { object: (floorplan_id: string, id: string) => `object:${floorplan_id}:${id}`, objects: (floorplan_id: string) => `object:${floorplan_id}:`, - /** Zero-padded so a prefix scan comes back in version order */ - version: (floorplan_id: string, version: number) => - `version:${floorplan_id}:${`${version}`.padStart(8, '0')}`, - 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 */ @@ -97,29 +92,6 @@ function serverSaveMessage(error: unknown): string { } const uuid = () => crypto.randomUUID(); -const CSV_COLUMNS = [ - 'id', - 'floorplan_id', - 'object_type', - 'svg_id', - 'label', - 'geometry', - 'entity_type', - 'entity_id', - 'layer', - 'fill_color', - 'stroke_color', - 'opacity', - 'capacity', - 'amenities', - 'tags', - 'metadata', - 'group_id', - 'z_index', - 'locked', - 'visible', -] as const; - /** Split a CSV line, honouring quoted fields containing commas or quotes */ export function parseCsvLine(line: string): string[] { const fields: string[] = []; @@ -151,7 +123,7 @@ const CSV_FORMULA_RE = /^[=+\-@\t\r']/; /** * One CSV cell. Labels can come from shared PlaceOS data, so text that a - * spreadsheet would run gets a leading `'`, which `importObjectsCsv` removes. + * spreadsheet would run gets a leading `'`, which `fromCsvField` removes. */ export function toCsvField(value: unknown): string { if (value === null || value === undefined) return ''; @@ -564,7 +536,7 @@ export class StoreService { } /** - * Removes the floorplan, its objects, versions and image. A floorplan made + * Removes the floorplan, its objects and image. A floorplan made * from a PlaceOS level is remembered so that the zone sync does not create * it again. */ @@ -574,7 +546,6 @@ export class StoreService { ); await Promise.all([ this._db.deleteWhere(key.objects(id)), - this._db.deleteWhere(key.versions(id)), this._db.write({ delete: [ key.image(id), @@ -601,62 +572,6 @@ export class StoreService { await this.updateFloorplan(id, { canvas_state }); } - /** Snapshots the current canvas as a new version and marks it published */ - public async publishFloorplan(id: string): Promise { - const floorplan = await this.getFloorplan(id); - if (!floorplan.canvas_state) - throw new Error('No canvas state to publish'); - const version = (floorplan.version ?? 1) + 1; - await this._writeVersion(floorplan, version, 'Published'); - await this.updateFloorplan(id, { status: 'published', version }); - } - - public async listVersions(id: string): Promise { - const versions = await this._db.list( - key.versions(id), - ); - return versions.reverse(); - } - - public async createVersion(id: string, note?: string): Promise { - const floorplan = await this.getFloorplan(id); - const version = (floorplan.version ?? 1) + 1; - await this._writeVersion(floorplan, version, note ?? null); - await this.updateFloorplan(id, { version }); - } - - public async restoreVersion(id: string, version_id: string): Promise { - const version = (await this.listVersions(id)).find( - (v) => v.id === version_id, - ); - if (!version) throw new Error('Version not found'); - await this.updateFloorplan(id, { - canvas_state: version.canvas_state, - svg_source: version.svg_source ?? null, - svg_source_ids: version.svg_source_ids, - svg_output: version.svg_output, - }); - } - - private async _writeVersion( - floorplan: Floorplan, - version: number, - note: string | null, - ): Promise { - const snapshot: FloorplanVersion = { - id: uuid(), - floorplan_id: floorplan.id, - version, - canvas_state: floorplan.canvas_state as EditorState, - svg_source: floorplan.svg_source, - svg_source_ids: floorplan.svg_source_ids, - svg_output: floorplan.svg_output, - note, - created_at: now(), - }; - await this._db.set(key.version(floorplan.id, version), snapshot); - } - // ── Source images ─────────────────────────────────────────────────────── /** @@ -766,10 +681,8 @@ export class StoreService { 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, floor_name, mappings, outline_points } = - options; + const { svg_content, analysis, floor_name, mappings } = options; if (!analysis.sourceSvg) throw new Error('The file is not a valid SVG map.'); @@ -779,29 +692,6 @@ export class StoreService { 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); - const ys = points.map((p) => p.y); - objects.push({ - object_type: 'area', - svg_id: 'floor-outline', - label: 'Floor Outline', - geometry: { - type: 'polygon', - points, - x: Math.min(...xs), - y: Math.min(...ys), - width: Math.max(...xs) - Math.min(...xs), - height: Math.max(...ys) - Math.min(...ys), - }, - layer: 'background', - fill_color: 'rgba(107,114,128,0.15)', - stroke_color: '#6b7280', - z_index: 0, - }); - } const project_id = options.project_id ?? @@ -962,10 +852,6 @@ export class StoreService { }); } - public async deleteObject(floorplan_id: string, id: string): Promise { - await this._db.delete(key.object(floorplan_id, id)); - } - /** * Writes `objects` and removes `remove_ids` in one transaction. Returns the * objects as stored, with defaults filled in. @@ -983,81 +869,6 @@ export class StoreService { return records.map(([, object]) => object); } - /** Replaces the stored objects for a floorplan with exactly `objects` */ - public async replaceObjects( - floorplan_id: string, - objects: Partial[], - ): Promise { - await this._db.write({ - clear: key.objects(floorplan_id), - put: this._objectRecords(floorplan_id, objects), - }); - } - - // ── CSV ───────────────────────────────────────────────────────────────── - - public async exportObjectsCsv(floorplan_id: string): Promise { - const objects = await this.listObjects(floorplan_id); - const rows = objects.map((obj) => - CSV_COLUMNS.map((column) => - toCsvField((obj as unknown as Record)[column]), - ).join(','), - ); - return [CSV_COLUMNS.join(','), ...rows].join('\n'); - } - - /** - * Merges a CSV export back in. Rows matching an existing `svg_id` update - * that object; everything else is created. - */ - public async importObjectsCsv( - floorplan_id: string, - csv: string, - ): Promise<{ imported: number; updated: number }> { - const lines = csv.trim().split('\n'); - if (lines.length < 2) - throw new Error( - 'CSV must have a header row and at least one data row', - ); - - const headers = parseCsvLine(lines[0]); - const existing = new Map( - (await this.listObjects(floorplan_id)) - .filter((o) => o.svg_id) - .map((o) => [o.svg_id as string, o]), - ); - - let imported = 0; - let updated = 0; - const changed: Partial[] = []; - for (const line of lines.slice(1)) { - const values = parseCsvLine(line); - const row: Record = {}; - headers.forEach((header, index) => { - const value = fromCsvField(values[index] ?? ''); - if (value !== '') row[header] = value; - }); - if (row['geometry']) - row['geometry'] = JSON.parse(row['geometry'] as string); - - const match = existing.get(row['svg_id'] as string); - if (match) { - delete row['id']; - delete row['floorplan_id']; - const merged = { ...match, ...row, id: match.id }; - // A later row for the same svg_id builds on this one - existing.set(match.svg_id as string, merged); - changed.push(merged); - updated++; - } else { - changed.push({ ...row, floorplan_id } as Partial); - imported++; - } - } - await this.bulkUpsertObjects(floorplan_id, changed); - return { imported, updated }; - } - // ── Validation ────────────────────────────────────────────────────────── public async validateFloorplan( diff --git a/apps/map-studio/src/app/data/types.ts b/apps/map-studio/src/app/data/types.ts index d8614b4020b..1fb8e98d451 100644 --- a/apps/map-studio/src/app/data/types.ts +++ b/apps/map-studio/src/app/data/types.ts @@ -46,18 +46,6 @@ export type FloorplanCanvas = Pick< /** Floorplan without its canvas, as listings load it */ export type FloorplanSummary = Omit; -export interface FloorplanVersion { - id: string; - floorplan_id: string; - version: number; - canvas_state: EditorState; - svg_source?: string | null; - svg_source_ids?: string[]; - svg_output: string | null; - note: string | null; - created_at: string; -} - export type MapObjectType = | 'room' | 'desk' diff --git a/apps/map-studio/src/app/editor/canvas.component.ts b/apps/map-studio/src/app/editor/canvas.component.ts index eb43a965d50..786543da424 100644 --- a/apps/map-studio/src/app/editor/canvas.component.ts +++ b/apps/map-studio/src/app/editor/canvas.component.ts @@ -1207,8 +1207,7 @@ export class CanvasComponent { public isPolygonEditable(object: MapObject) { return ( object.id === this.state.selected_id() && - this.state.active_tool() === 'select' && - this.state.mode() === 'design' + this.state.active_tool() === 'select' ); } diff --git a/apps/map-studio/src/app/editor/constants.ts b/apps/map-studio/src/app/editor/constants.ts index e41e360cfc8..eb232ad52b0 100644 --- a/apps/map-studio/src/app/editor/constants.ts +++ b/apps/map-studio/src/app/editor/constants.ts @@ -3,11 +3,10 @@ import { MapObjectType } from '../data/types'; /** * Editor constants and artwork, ported from the reference application. * - * The icon and furniture tables are verbatim — they are data, not logic. + * The icon and furniture artwork is verbatim. It is data, not logic. */ export type Tool = 'select' | 'rect' | 'polygon' | 'pen' | 'wall'; -export type EditorMode = 'design' | 'label' | 'preview'; export type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; export const HANDLES: Handle[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']; @@ -73,14 +72,12 @@ export function layerToObjectType(layer_id: string): MapObjectType { export interface AmenityIconAsset { id: string; label: string; - emoji: string; svg: string; } export interface FurnitureAsset { id: string; label: string; - icon: string; svg: string; w: number; h: number; @@ -101,151 +98,126 @@ export const AMENITY_ICONS: AmenityIconAsset[] = [ { id: 'male-restroom', label: 'Male Restroom', - emoji: '🚹', svg: '', }, { id: 'female-restroom', label: 'Female Restroom', - emoji: '🚺', svg: '', }, { id: 'accessible-restroom', label: 'Accessible', - emoji: '♿', svg: '', }, { id: 'staircase', label: 'Staircase', - emoji: '🪜', svg: '', }, { id: 'elevator', label: 'Elevator', - emoji: '🛗', svg: '', }, { id: 'fire-exit', label: 'Fire Exit', - emoji: '🚪', svg: '', }, { id: 'cafe', label: 'Cafe', - emoji: '☕', svg: '', }, { id: 'reception', label: 'Reception', - emoji: '🛎️', svg: '', }, { id: 'aed', label: 'AED', - emoji: '💚', svg: '', }, { id: 'first-aid', label: 'First Aid', - emoji: '🏥', svg: '', }, { id: 'lockers', label: 'Lockers', - emoji: '🔐', svg: '', }, { id: 'presentation', label: 'Presentation', - emoji: '📽️', svg: '', }, { id: 'door', label: 'Door', - emoji: '🚪', svg: '', }, { id: 'window', label: 'Window', - emoji: '🪟', svg: '', }, { id: 'water-fountain', label: 'Water Fountain', - emoji: '🚰', svg: '', }, { id: 'kitchen', label: 'Kitchen', - emoji: '🍳', svg: '', }, { id: 'vending', label: 'Vending Machine', - emoji: '🥤', svg: '', }, { id: 'wifi', label: 'WiFi Access Point', - emoji: '📶', svg: '', }, { id: 'security-camera', label: 'Security Camera', - emoji: '📹', svg: '', }, { id: 'parking-spot', label: 'Parking Spot', - emoji: '🅿️', svg: '', }, { id: 'bike-rack', label: 'Bike Rack', - emoji: '🚲', svg: '', }, { id: 'charging', label: 'Charging Station', - emoji: '🔌', svg: '', }, { id: 'mail', label: 'Mailroom', - emoji: '📬', svg: '', }, { id: 'shower', label: 'Shower', - emoji: '🚿', svg: '', }, { id: 'smoking', label: 'Smoking Area', - emoji: '🚬', svg: '', }, ]; @@ -255,7 +227,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'desk-single', label: 'Single Desk', - icon: 'D', svg: '', w: 30, h: 20, @@ -264,7 +235,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'desk-pair', label: 'Desk Pair', - icon: 'DD', svg: '', w: 50, h: 20, @@ -273,7 +243,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'desk-pod', label: 'Desk Pod (4)', - icon: '4D', svg: '', w: 50, h: 40, @@ -282,7 +251,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'table-small', label: 'Small Table', - icon: 'T', svg: '', w: 30, h: 30, @@ -291,7 +259,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'table-medium', label: 'Medium Table', - icon: 'T', svg: '', w: 50, h: 30, @@ -300,7 +267,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'table-large', label: 'Large Table', - icon: 'T', svg: '', w: 80, h: 40, @@ -309,7 +275,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'table-round', label: 'Round Table', - icon: 'O', svg: '', w: 30, h: 30, @@ -318,7 +283,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'standing-desk', label: 'Standing Desk', - icon: 'SD', svg: '', w: 30, h: 15, @@ -328,7 +292,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'bench', label: 'Bench', - icon: 'B', svg: '', w: 50, h: 12, @@ -337,7 +300,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'lounge-chair', label: 'Lounge Chair', - icon: 'LC', svg: '', w: 25, h: 25, @@ -346,7 +308,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'sofa', label: 'Sofa', - icon: 'So', svg: '', w: 55, h: 22, @@ -355,7 +316,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'phone-booth', label: 'Phone Booth', - icon: 'PB', svg: '', w: 25, h: 25, @@ -365,7 +325,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'lockers', label: 'Lockers', - icon: 'Lk', svg: '', w: 40, h: 15, @@ -374,7 +333,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'filing-cabinet', label: 'Filing Cabinet', - icon: 'FC', svg: '', w: 15, h: 20, @@ -383,7 +341,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'bookshelf', label: 'Bookshelf', - icon: 'BS', svg: '', w: 40, h: 12, @@ -392,7 +349,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'printer', label: 'Printer', - icon: 'Pr', svg: '', w: 20, h: 20, @@ -402,7 +358,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'plant', label: 'Plant', - icon: 'Pl', svg: '', w: 12, h: 12, @@ -411,7 +366,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'plant-large', label: 'Large Plant', - icon: 'PL', svg: '', w: 18, h: 18, @@ -420,7 +374,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'partition', label: 'Partition Wall', - icon: '||', svg: '', w: 60, h: 4, @@ -429,7 +382,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'whiteboard', label: 'Whiteboard', - icon: 'WB', svg: '', w: 40, h: 5, @@ -438,7 +390,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'tv-screen', label: 'TV/Screen', - icon: 'TV', svg: '', w: 35, h: 5, @@ -447,7 +398,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'bin', label: 'Waste Bin', - icon: 'Bn', svg: '', w: 8, h: 8, @@ -457,7 +407,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'conference-table', label: 'Conference Table', - icon: 'CT', svg: '', w: 80, h: 50, @@ -466,7 +415,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'monitor-arm', label: 'Monitor Arm', - icon: 'MA', svg: '', w: 15, h: 10, @@ -475,7 +423,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'chair-office', label: 'Office Chair', - icon: 'Ch', svg: '', w: 12, h: 12, @@ -484,7 +431,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'recycling', label: 'Recycling Bin', - icon: 'Rc', svg: '', w: 8, h: 8, @@ -493,7 +439,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'coat-rack', label: 'Coat Rack', - icon: 'CR', svg: '', w: 8, h: 8, @@ -502,7 +447,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'umbrella-stand', label: 'Umbrella Stand', - icon: 'US', svg: '', w: 8, h: 8, @@ -511,7 +455,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'water-cooler', label: 'Water Cooler', - icon: 'WC', svg: '', w: 10, h: 10, @@ -520,7 +463,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'fire-extinguisher', label: 'Fire Extinguisher', - icon: 'FE', svg: '', w: 6, h: 6, @@ -529,7 +471,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'locker-unit', label: 'Locker Unit', - icon: 'LU', svg: '', w: 45, h: 20, diff --git a/apps/map-studio/src/app/editor/editor-state.service.ts b/apps/map-studio/src/app/editor/editor-state.service.ts index 15903fa930b..1f247c1796b 100644 --- a/apps/map-studio/src/app/editor/editor-state.service.ts +++ b/apps/map-studio/src/app/editor/editor-state.service.ts @@ -17,7 +17,6 @@ import { cycleState, defaultStateFor, getStatesForType } from './availability'; import { AMENITY_ICONS, DESK_LAYOUTS, - EditorMode, FURNITURE_ASSETS, TYPE_COLORS, Tool, @@ -146,14 +145,12 @@ export class EditorStateService extends AsyncHandler { private readonly _selected_id = signal(null); private readonly _active_tool = signal('select'); private readonly _active_layer_id = signal('rooms'); - private readonly _mode = signal('design'); private readonly _zoom = signal(1); private readonly _grid_enabled = signal(true); private readonly _snap_enabled = signal(true); private readonly _grid_size = signal(20); private readonly _dirty = signal(false); private readonly _saving = signal(false); - private readonly _last_saved = signal(null); private readonly _loading = signal(true); private readonly _error = signal(''); private readonly _project_name = signal(''); @@ -191,21 +188,18 @@ export class EditorStateService extends AsyncHandler { public readonly selected_id = this._selected_id.asReadonly(); public readonly active_tool = this._active_tool.asReadonly(); public readonly active_layer_id = this._active_layer_id.asReadonly(); - public readonly mode = this._mode.asReadonly(); public readonly zoom = this._zoom.asReadonly(); public readonly grid_enabled = this._grid_enabled.asReadonly(); public readonly snap_enabled = this._snap_enabled.asReadonly(); public readonly grid_size = this._grid_size.asReadonly(); public readonly dirty = this._dirty.asReadonly(); public readonly saving = this._saving.asReadonly(); - public readonly last_saved = this._last_saved.asReadonly(); public readonly loading = this._loading.asReadonly(); public readonly error = this._error.asReadonly(); public readonly server_save_error = this._store.server_save_error.asReadonly(); public readonly project_name = this._project_name.asReadonly(); public readonly image_url = this._image_url.asReadonly(); - public readonly image_dims = this._image_dims.asReadonly(); public readonly place_size = this._place_size.asReadonly(); public readonly can_undo = this._can_undo.asReadonly(); public readonly can_redo = this._can_redo.asReadonly(); @@ -540,20 +534,12 @@ export class EditorStateService extends AsyncHandler { this._active_tool.set(tool); } - public setMode(mode: EditorMode) { - this._mode.set(mode); - } - public setActiveLayer(id: string) { this._active_layer_id.set(id); const size = PLACE_SIZES[id]; if (size) this._place_size.set(size); } - public setPlaceSize(width: number, height: number) { - this._place_size.set({ width, height }); - } - public select(id: string | null) { this._selected_id.set(id); } @@ -1305,7 +1291,6 @@ export class EditorStateService extends AsyncHandler { await this._store.saveCanvasState(this._floorplan_id, state); // Edits made while writing stay pending for the next save if (this._change_count === change) this._dirty.set(false); - this._last_saved.set(new Date()); this._error.set(''); } catch (error) { this._error.set(this._message(error, 'Failed to save')); @@ -1314,25 +1299,6 @@ export class EditorStateService extends AsyncHandler { } } - public async exportCsv() { - const csv = await this._store.exportObjectsCsv(this._floorplan_id); - const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv' })); - const link = document.createElement('a'); - link.href = url; - link.download = `objects-${this._floorplan_id}.csv`; - link.click(); - URL.revokeObjectURL(url); - } - - public async importCsv(file: File) { - await this._store.importObjectsCsv( - this._floorplan_id, - await file.text(), - ); - this._objects.set(await this._store.listObjects(this._floorplan_id)); - this.markDirty(); - } - private _message(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } diff --git a/apps/map-studio/src/tests/placeos.service.spec.ts b/apps/map-studio/src/tests/placeos.service.spec.ts index 7ca4ea9e725..38960ccca03 100644 --- a/apps/map-studio/src/tests/placeos.service.spec.ts +++ b/apps/map-studio/src/tests/placeos.service.spec.ts @@ -286,7 +286,9 @@ describe('PlaceOSService', () => { await expect(service.getActiveOrganisation()).resolves.toEqual({ id: 'org-2', }); - await expect(service.getLevelSvg('level-1')).resolves.toContain(' url.endsWith('/maps/level-1.svg'), @@ -300,7 +302,9 @@ describe('PlaceOSService', () => { const calls = stubFetch(true); await service.init(); - await expect(service.getLevelSvg('level-2')).resolves.toContain(' { ]); }); - it('round-trips objects through CSV, escaping and re-parsing fields', async () => { - const floorplan = await store.addFloorplan('p1', 'Level 1'); - await store.createObject(floorplan.id, { - svg_id: 'room-1', - label: 'Board, Room "A"', - geometry: { type: 'rect', x: 10, y: 20, width: 30, height: 40 }, - }); - - const csv = await store.exportObjectsCsv(floorplan.id); - const target = await store.addFloorplan('p1', 'Level 2'); - const result = await store.importObjectsCsv(target.id, csv); - - expect(result).toEqual({ imported: 1, updated: 0 }); - const [imported] = await store.listObjects(target.id); - expect(imported.label).toBe('Board, Room "A"'); - expect(imported.geometry).toEqual({ - type: 'rect', - x: 10, - y: 20, - width: 30, - height: 40, - }); - }); - - it('exports formula-like labels as text and imports them unchanged', async () => { - const floorplan = await store.addFloorplan('p1', 'Level 1'); - const labels = [ - '=HYPERLINK("https://example.com","Open")', - '\r=1+1', - '\t=1+1', - "'=1+1", - ]; - for (const [index, label] of labels.entries()) { - await store.createObject(floorplan.id, { - svg_id: `room-${index}`, - label, - geometry: { type: 'rect', x: 0, y: 0, width: 1, height: 1 }, - }); - } - - const csv = await store.exportObjectsCsv(floorplan.id); - expect(csv).toContain(`"'=HYPERLINK(`); - expect(csv).toContain(`"'\r=1+1"`); - - const target = await store.addFloorplan('p1', 'Level 2'); - await store.importObjectsCsv(target.id, csv); - const imported = await store.listObjects(target.id); - expect(imported.map((object) => object.label).sort()).toEqual( - [...labels].sort(), - ); - }); - - it('updates rather than duplicates when a CSV row matches an svg_id', async () => { - const floorplan = await store.addFloorplan('p1', 'Level 1'); - await store.createObject(floorplan.id, { - svg_id: 'room-1', - label: 'Old Name', - geometry: { type: 'rect', x: 0, y: 0, width: 1, height: 1 }, - }); - - const csv = (await store.exportObjectsCsv(floorplan.id)).replace( - 'Old Name', - 'New Name', - ); - const result = await store.importObjectsCsv(floorplan.id, csv); - - expect(result).toEqual({ imported: 0, updated: 1 }); - const objects = await store.listObjects(floorplan.id); - expect(objects).toHaveLength(1); - expect(objects[0].label).toBe('New Name'); - }); - it('reports missing labels, empty geometry and duplicate svg ids', async () => { const floorplan = await store.addFloorplan('p1', 'Level 1'); await store.createObject(floorplan.id, { @@ -511,33 +439,4 @@ describe('StoreService', () => { types.filter((m) => m.includes('Duplicate svg_id')), ).toHaveLength(2); }); - - it('snapshots a version on publish and restores it', async () => { - const floorplan = await store.addFloorplan('p1', 'Level 1'); - const state = { - objects: [], - viewport: { x: 0, y: 0, zoom: 1 }, - selectedIds: [], - layers: [], - gridEnabled: true, - snapEnabled: true, - gridSize: 10, - }; - await store.saveCanvasState(floorplan.id, state); - await store.publishFloorplan(floorplan.id); - - const published = await store.getFloorplan(floorplan.id); - expect(published.status).toBe('published'); - expect(published.version).toBe(2); - - const [version] = await store.listVersions(floorplan.id); - await store.saveCanvasState(floorplan.id, { - ...state, - gridSize: 99, - }); - await store.restoreVersion(floorplan.id, version.id); - - const restored = await store.getFloorplan(floorplan.id); - expect(restored.canvas_state?.gridSize).toBe(10); - }); }); diff --git a/apps/outlook-addin/src/app/app.component.ts b/apps/outlook-addin/src/app/app.component.ts index 878c8f11197..bd82244c9e6 100644 --- a/apps/outlook-addin/src/app/app.component.ts +++ b/apps/outlook-addin/src/app/app.component.ts @@ -18,7 +18,6 @@ import { setTranslationService, setupCache, setupPlace, - UploadsService, userSignal, withTimeout, } from '@placeos/common'; @@ -62,7 +61,6 @@ export class AppComponent extends AsyncHandler implements OnInit { private _cache = inject(SwUpdate); private _snackbar = lazySnackbar(); private _locales = inject(LocaleService); - private _uploads = inject(UploadsService); private _current_user = userSignal(); private _internal_user_domain = computed(() => { const email = this._current_user()?.email || ''; @@ -73,7 +71,6 @@ export class AppComponent extends AsyncHandler implements OnInit { ); }); - public readonly title = 'outlook-addin'; private _mocks_registered = false; /** Read before startup, so later URL changes cannot drop it */ private _naa_client_id = naaClientId(); @@ -119,7 +116,6 @@ export class AppComponent extends AsyncHandler implements OnInit { } } await this._finishInitialise(); - if (this._settings.get('app.has_uploads')) this._uploads.init(); } private async _initialiseAuth(local = true): Promise { diff --git a/apps/outlook-addin/src/app/app.routes.ts b/apps/outlook-addin/src/app/app.routes.ts index bb8923822b4..b53088170d8 100644 --- a/apps/outlook-addin/src/app/app.routes.ts +++ b/apps/outlook-addin/src/app/app.routes.ts @@ -5,18 +5,6 @@ import { UnauthorisedComponent } from '@placeos/components'; export const routes: Routes = [ { path: 'unauthorised', component: UnauthorisedComponent }, - { - path: '404', - loadComponent: () => - import('./not-found.component').then((m) => m.NotFoundComponent), - }, - { - path: 'find', - loadComponent: () => - import('./rooms/find-space.component').then( - (m) => m.FindSpaceComponent, - ), - }, { path: '', canActivate: [AuthorisedUserGuard], @@ -29,74 +17,6 @@ export const routes: Routes = [ (m) => m.CalendarPaneComponent, ), }, - { - path: 'book', - children: [ - { - path: 'spaces', - loadComponent: () => - import('./rooms/room-booking.component').then( - (m) => m.RoomBookingComponent, - ), - }, - { - path: 'spaces/success', - loadComponent: () => - import('./rooms/booking-confirmed.component').then( - (m) => m.BookingConfirmedComponent, - ), - }, - { - path: 'meeting', - loadComponent: () => - import('./meetings/meeting-booking.component').then( - (m) => m.MeetingBookingComponent, - ), - }, - { - path: 'meeting/success', - loadComponent: () => - import('./meetings/meeting-success.component').then( - (m) => m.MeetingBookingSuccessComponent, - ), - }, - { - path: 'desks', - loadComponent: () => - import('./desks/desk-booking.component').then( - (m) => m.DeskBookingComponent, - ), - }, - { - path: 'desks/success', - loadComponent: () => - import('./desks/desk-success.component').then( - (m) => m.DeskBookingSuccessComponent, - ), - }, - ], - }, - { - path: 'schedule/view', - loadComponent: () => - import('./rooms/find-space.component').then( - (m) => m.FindSpaceComponent, - ), - }, - { - path: 'confirm/success', - loadComponent: () => - import('./rooms/booking-confirmed.component').then( - (m) => m.BookingConfirmedComponent, - ), - }, - { - path: 'upcoming', - loadComponent: () => - import('./rooms/upcoming-bookings.component').then( - (m) => m.UpcomingBookingsComponent, - ), - }, { path: '**', redirectTo: 'calendar' }, ], }, diff --git a/apps/outlook-addin/src/app/desks/desk-booking.component.ts b/apps/outlook-addin/src/app/desks/desk-booking.component.ts deleted file mode 100644 index a121ef9df37..00000000000 --- a/apps/outlook-addin/src/app/desks/desk-booking.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - Component, - computed, - inject, -} from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { Router } from '@angular/router'; -import { BookingFormService } from '@placeos/bookings'; -import { DeskBookingFormComponent } from './desk-form.component'; - -@Component({ - selector: 'desk-booking', - template: ` -
-
-
-

Book Desk

-
- -
- - -
-
-
- `, - styles: [``], - imports: [DeskBookingFormComponent, MatRippleModule], -}) -export class DeskBookingComponent { - private _service = inject(BookingFormService); - private _router = inject(Router); - - public readonly form = this._service.form; - public readonly model = this._service.model; - public readonly can_book = computed(() => !!this.model().asset_id); - public readonly clearForm = () => this._service.clearForm(); - - public async makeBooking() { - await this._service.confirmPost(); - this._router.navigate(['/book', 'desks', 'success']); - } -} diff --git a/apps/outlook-addin/src/app/desks/desk-form.component.ts b/apps/outlook-addin/src/app/desks/desk-form.component.ts deleted file mode 100644 index 4d8ef21e47b..00000000000 --- a/apps/outlook-addin/src/app/desks/desk-form.component.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Component, computed, inject, Injector, OnInit } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { FormField } from '@angular/forms/signals'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { AssetListFieldComponent } from '@placeos/assets'; -import { - BookingFormService, - DeskListFieldComponent, - requiresLocker, -} from '@placeos/bookings'; -import { - AsyncHandler, - Desk, - onFieldChange, - settingSignal, -} from '@placeos/common'; -import { - DateFieldComponent, - DurationFieldComponent, - TimeFieldComponent, -} from '@placeos/form-fields'; - -@Component({ - selector: 'desk-booking-form', - template: ` - @if (form) { -
-
-

-
- 1 -
-
Details
-

-
-
- - - - Booking title is required. - -
-
- - - Date and time must be in the future - -
-
-
-
- - -
-
- - - - @if (allow_all_day()) { - - All Day - - } -
-
- @if (can_book_lockers()) { -
-
- - Require locker - -
-
- } -
-
-

-
- 2 -
-
Desk
-

- -
- @if (allow_assets()) { -
-

-
- 3 -
-
Request Asset
-

- -
- } -
- } - `, - styles: [``], - imports: [ - AssetListFieldComponent, - DeskListFieldComponent, - MatCheckboxModule, - FormsModule, - FormField, - DurationFieldComponent, - TimeFieldComponent, - DateFieldComponent, - MatFormFieldModule, - MatInputModule, - ], -}) -export class DeskBookingFormComponent extends AsyncHandler implements OnInit { - private _service = inject(BookingFormService); - private _injector = inject(Injector); - - public readonly form = this._service.form; - public readonly model = this._service.model; - public readonly allow_assets = settingSignal('desks.allow_assets', false); - public readonly allow_all_day = settingSignal('desks.allow_all_day', false); - public readonly can_book_lockers = settingSignal( - 'desks.can_book_lockers', - false, - ); - public readonly requires_locker = computed(() => - requiresLocker(this.model().secondary_resource), - ); - - public ngOnInit() { - this._service.setOptions({ type: 'desk' }); - onFieldChange( - this._service.model, - (m) => m.resources, - (list) => (list?.length ? this.setBookingAsset(list[0]) : ''), - this._injector, - ); - } - - private setBookingAsset(desk: Desk) { - if (!desk) return; - this._service.model.update((m) => ({ - ...m, - asset_id: desk?.id, - asset_name: desk.name, - map_id: desk?.map_id || desk?.id, - description: desk.name, - booking_type: 'desk', - zones: desk.zone ? [desk.zone?.parent_id, desk.zone?.id] : [], - booking_asset: desk, - })); - } -} diff --git a/apps/outlook-addin/src/app/desks/desk-success.component.ts b/apps/outlook-addin/src/app/desks/desk-success.component.ts deleted file mode 100644 index 170e3c298b2..00000000000 --- a/apps/outlook-addin/src/app/desks/desk-success.component.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - computed, - inject, - signal, -} from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { RouterModule } from '@angular/router'; -import { BookingFormService } from '@placeos/bookings'; - -@Component({ - selector: 'desk-booking-success', - template: ` -
-

Booking Confirmed!

- -

- Your desk booking for - {{ - last_success()?.asset_name || last_success()?.asset_id - }} - has been successfully booked for the - {{ last_success()?.date | date: 'dd MMMM yyyy' }} - at - {{ last_success()?.date | date: 'shortTime' }} - - - {{ end_time() | date: 'shortTime' }} -

- @if (true) { -

- Please allow for some time for your booking to be approved. -

- } - - New Booking - -
- `, - styles: [ - ` - :host { - height: 100%; - width: 100%; - } - `, - ], - imports: [MatRippleModule, RouterModule, CommonModule], -}) -export class DeskBookingSuccessComponent { - private _service = inject(BookingFormService); - - public readonly last_success = signal(this._service.last_success); - public readonly end_time = computed(() => { - const booking = this.last_success(); - return booking?.date + booking?.duration * 60 * 1000; - }); -} diff --git a/apps/outlook-addin/src/app/meetings/meeting-booking.component.ts b/apps/outlook-addin/src/app/meetings/meeting-booking.component.ts deleted file mode 100644 index b1cbf97fdd6..00000000000 --- a/apps/outlook-addin/src/app/meetings/meeting-booking.component.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - Component, - inject, - signal, -} from '@angular/core'; -import { - MatBottomSheet, - MatBottomSheetRef, -} from '@angular/material/bottom-sheet'; -import { MatRippleModule } from '@angular/material/core'; -import { Router } from '@angular/router'; -import { - currentUser, - getInvalidSignalFields, - notifyError, -} from '@placeos/common'; -import { EventFormService } from '@placeos/events'; -import { MeetingFlowConfirmComponent } from 'apps/workplace/src/app/book/meeting-flow/meeting-flow-confirm.component'; -import { MeetingBookingFormComponent } from './meeting-form.component'; - -@Component({ - selector: 'meeting-booking', - template: ` -
-
-
-

Book Meeting

-
- -
- - -
-
-
- `, - styles: [``], - imports: [MatRippleModule, MeetingBookingFormComponent], -}) -export class MeetingBookingComponent { - private _service = inject(EventFormService); - private _router = inject(Router); - private _bottom_sheet = inject(MatBottomSheet); - - public readonly form = this._service.form; - public readonly model = this._service.model; - public readonly sheet_ref = signal | null>(null); - - public readonly clearForm = () => this._service.clearForm(); - - public makeBooking() { - if (!this.model().host) - this.model.update((m) => ({ ...m, host: currentUser()?.email })); - if (!this.form().valid()) - return notifyError( - `Some fields are invalid. [${getInvalidSignalFields( - this.form, - this.model, - ).join(', ')}]`, - ); - const sheet_ref = this._bottom_sheet.open(MeetingFlowConfirmComponent); - this.sheet_ref.set(sheet_ref); - sheet_ref.instance.show_close.set(true); - sheet_ref.afterDismissed().subscribe((value) => { - if (value) { - this._router.navigate(['/book', 'meeting', 'success']); - this._service.setView('success'); - } - }); - } -} diff --git a/apps/outlook-addin/src/app/meetings/meeting-form.component.ts b/apps/outlook-addin/src/app/meetings/meeting-form.component.ts deleted file mode 100644 index 8d589fe7589..00000000000 --- a/apps/outlook-addin/src/app/meetings/meeting-form.component.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { Component, computed, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { FormField } from '@angular/forms/signals'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { AssetListFieldComponent } from '@placeos/assets'; -import { CateringListFieldComponent } from '@placeos/catering'; -import { - AsyncHandler, - Building, - currentUser, - OrganisationService, - settingSignal, -} from '@placeos/common'; -import { IconComponent } from '@placeos/components'; -import { EventFormService } from '@placeos/events'; -import { - DateFieldComponent, - DurationFieldComponent, - HostSelectFieldComponent, - RichTextInputComponent, - SpaceListFieldComponent, - TimeFieldComponent, - UserListFieldComponent, -} from '@placeos/form-fields'; -import { FindAvailabilityModalComponent } from '@placeos/users'; - -@Component({ - selector: 'meeting-booking-form', - template: ` - @if (form) { -
-
-

-
- 1 -
-
Details
-
- -

-
- @if (buildings().length > 1) { -
- - - - @for (bld of buildings(); track bld) { - - {{ - bld.display_name || bld.name - }} - - } - - -
- } -
-
- - - - Meeting title is required. - -
-
- - - Date and time must be in the future - - @if (allow_all_day()) { - All Day - } -
-
- @if (!model().all_day) { -
-
- - -
-
- - - -
-
- } - @if (can_book_for_others()) { -
- - -
- } -
-
- @if (!hide_attendees()) { -
-

-
- 2 -
-
Attendees
-
- - -

-
- -
-
- } -
-

-
- 3 -
-
Room
-
- -

-
- -
-
- @if (has_catering()) { -
-

-
- 4 -
-
Catering
-
- -

-
- -
-
- } - @if (allow_assets()) { -
-

-
- {{ !has_catering() ? '4' : '5' }} -
-
Assets
-
- -

-
- -
-
- } - @if (!hide_notes()) { -
-

-
- {{ notes_section() }} -
-
Notes
-

-
- - -
-
- } -
- } - `, - styles: [``], - imports: [ - MatRippleModule, - FormsModule, - FormField, - RichTextInputComponent, - AssetListFieldComponent, - CateringListFieldComponent, - SpaceListFieldComponent, - IconComponent, - UserListFieldComponent, - DateFieldComponent, - TimeFieldComponent, - DurationFieldComponent, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatCheckboxModule, - HostSelectFieldComponent, - ], -}) -export class MeetingBookingFormComponent extends AsyncHandler { - private _service = inject(EventFormService); - private _dialog = inject(MatDialog); - private _org = inject(OrganisationService); - - public readonly form = this._service.form; - public readonly model = this._service.model; - - public readonly hide_block = signal>({}); - - public readonly building = this._org.active_building; - public readonly buildings = this._org.building_list; - - private readonly _catering_enabled = settingSignal( - 'events.catering_enabled', - false, - ); - private readonly _has_catering_enabled = settingSignal( - 'events.has_catering', - false, - ); - public readonly has_catering = computed( - () => !!this._catering_enabled() || !!this._has_catering_enabled(), - ); - public readonly hide_notes = settingSignal('events.hide_notes', false); - public readonly hide_attendees = settingSignal( - 'events.hide_attendees', - false, - ); - public readonly max_duration = settingSignal('events.max_duration', 480); - public readonly min_duration = settingSignal('events.min_duration', 30); - public readonly bookable_hours = settingSignal( - 'events.bookable_hours', - null, - ); - public readonly custom_duration_options = settingSignal( - 'events.custom_duration_options', - [], - ); - public readonly effective_min_duration = computed(() => - Math.min(this.min_duration(), ...this.custom_duration_options()), - ); - public readonly can_book_for_others = settingSignal( - 'events.can_book_for_others', - false, - ); - public readonly allow_all_day = settingSignal( - 'events.allow_all_day', - false, - ); - public readonly allow_assets = settingSignal('events.allow_assets', false); - public readonly notes_section = computed( - () => 4 + Number(this.has_catering()) + Number(this.allow_assets()), - ); - - public findAvailableTime() { - const { attendees, organiser, date, duration } = this.model(); - const ref = this._dialog.open(FindAvailabilityModalComponent, { - data: { - users: attendees, - host: organiser || currentUser(), - date, - duration, - }, - }); - ref.afterClosed().subscribe((d) => { - if (!d) return; - this.model.update((m) => ({ - ...m, - date: ref.componentInstance.date(), - attendees: ref.componentInstance.users(), - duration: ref.componentInstance.duration(), - })); - }); - } - - public setBuilding(bld: Building) { - this._org.building = bld; - this._org.setBuilding(bld, true); - } -} diff --git a/apps/outlook-addin/src/app/meetings/meeting-success.component.ts b/apps/outlook-addin/src/app/meetings/meeting-success.component.ts deleted file mode 100644 index f7036e8431f..00000000000 --- a/apps/outlook-addin/src/app/meetings/meeting-success.component.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - computed, - inject, -} from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { RouterModule } from '@angular/router'; -import { EventFormService, SpacePipe } from '@placeos/events'; - -@Component({ - selector: 'meeting-booking-success', - template: ` -
- @let booking = last_success(); -

Booking Confirmed!

- - @let room = room_email() | space | async; -

- Your meeting booking for - {{ - booking_location() || room?.display_name || room?.name - }} - has been successfully booked for the - {{ booking?.date | date: 'dd MMMM yyyy' }} - at - {{ booking?.date | date: 'shortTime' }} - - - {{ booking_end() | date: 'shortTime' }} -

- - New Booking - -
- `, - styles: [ - ` - :host { - height: 100%; - width: 100%; - } - `, - ], - imports: [CommonModule, SpacePipe, MatRippleModule, RouterModule], -}) -export class MeetingBookingSuccessComponent { - private _service = inject(EventFormService); - - public readonly last_success = this._service.last_success; - public readonly room_email = computed( - () => this.last_success()?.system?.email || '', - ); - public readonly booking_location = computed( - () => this.last_success()?.location || '', - ); - public readonly booking_end = computed(() => { - const booking = this.last_success(); - return booking - ? booking.date + booking.duration * 60 * 1000 - : undefined; - }); -} diff --git a/apps/outlook-addin/src/app/not-found.component.ts b/apps/outlook-addin/src/app/not-found.component.ts deleted file mode 100644 index 0e8eddd2e6a..00000000000 --- a/apps/outlook-addin/src/app/not-found.component.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Component, inject } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { NavigationEnd, Router } from '@angular/router'; -import { filter, map } from 'rxjs/operators'; - -@Component({ - selector: 'placeos-not-found', - template: ` -
-
-
- graphic of magnifying glass -
-
-
- Page not found -
-
-
- - The page - {{ url() }} - was not found. - -
-
-
- `, - styles: [``], - imports: [], -}) -export class NotFoundComponent { - private _router = inject(Router); - - public readonly url = toSignal( - this._router.events.pipe( - filter((event) => event instanceof NavigationEnd), - map(() => this._router.url), - ), - { initialValue: this._router.url }, - ); -} diff --git a/apps/outlook-addin/src/app/rooms/booking-confirmed.component.ts b/apps/outlook-addin/src/app/rooms/booking-confirmed.component.ts deleted file mode 100644 index 3a0def275f6..00000000000 --- a/apps/outlook-addin/src/app/rooms/booking-confirmed.component.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - computed, - inject, -} from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { Router } from '@angular/router'; -import { CalendarEvent } from '@placeos/common'; -import { EventFormService } from '@placeos/events'; - -@Component({ - selector: 'booking-confirmed', - template: ` -
-
-
-
- - Booking Confirmed! -
-
- -
- -
- Your room booking for - {{ location() }} - has been successfully booked for the - {{ date() | date: 'dd MMMM yyyy' }} at - {{ start_time() }} - - {{ end_time() }} - -
-
- -
-
-
-
- `, - styles: [``], - imports: [CommonModule, MatRippleModule], -}) -export class BookingConfirmedComponent { - private _state = inject(EventFormService); - private _router = inject(Router); - - public readonly form = this._state.form; - public readonly booking = computed(() => this._state.last_success()); - public readonly location = computed(() => { - const booking = this.booking(); - return booking?.location; - }); - public readonly date = computed(() => { - const booking = this.booking(); - return booking?.date; - }); - public readonly duration = computed(() => { - const booking = this.booking(); - return booking?.duration; - }); - public readonly start_time = computed(() => { - const booking = this.booking(); - return this.format_time(booking?.date); - }); - public readonly end_time = computed(() => { - const booking = this.booking(); - return this.format_time(booking?.date + this.duration() * 60 * 1000); - }); - - private format_time(time: CalendarEvent['date']) { - return new Date(time).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: 'numeric', - hour12: true, - }); - } - - newBooking() { - this._router.navigate(['book/spaces']); - } -} diff --git a/apps/outlook-addin/src/app/rooms/existing-bookings.service.ts b/apps/outlook-addin/src/app/rooms/existing-bookings.service.ts deleted file mode 100644 index 7cfcefbebfd..00000000000 --- a/apps/outlook-addin/src/app/rooms/existing-bookings.service.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Injectable, effect, signal } from '@angular/core'; -import { AsyncHandler, CalendarEvent } from '@placeos/common'; -import { queryEvents } from '@placeos/events'; -import { endOfDay, getUnixTime, startOfDay } from 'date-fns'; - -@Injectable({ - providedIn: 'root', -}) -export class ExistingBookingsService extends AsyncHandler { - public readonly loading = signal(false); - public readonly date = signal(Date.now()); - public readonly events = signal([]); - - constructor() { - super(); - effect(() => { - this.date(); - this.getBookings(); - }); - } - - async getBookings() { - this.loading.set(true); - const events = await queryEvents({ - period_start: getUnixTime(startOfDay(this.date())), - period_end: getUnixTime(endOfDay(this.date())), - }); - this.events.set(events || []); - this.loading.set(false); - } -} diff --git a/apps/outlook-addin/src/app/rooms/features-filter.service.ts b/apps/outlook-addin/src/app/rooms/features-filter.service.ts deleted file mode 100644 index 864a9e40edd..00000000000 --- a/apps/outlook-addin/src/app/rooms/features-filter.service.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Injectable, computed, inject, signal } from '@angular/core'; -import { Space } from '@placeos/common'; -import { EventFormService } from '@placeos/events'; - -interface RoomFeatureFilter { - readonly id: string; - readonly name: string; - value: boolean; -} - -@Injectable({ - providedIn: 'root', -}) -export class FeaturesFilterService { - private _state = inject(EventFormService); - - public readonly spaces = this._state.available_spaces; - public readonly selected_features = signal([]); - public readonly show_favourites = signal(false); - - private readonly _applied_features = signal([]); - private readonly _applied_show_favourites = signal(false); - - public readonly room_features: RoomFeatureFilter[] = [ - { name: 'Video Conference (VC)', id: 'VidConf', value: false }, - { name: 'Conference Phone', id: 'ConfPhone', value: false }, - { name: 'Wireless Content Sharing', id: 'Wireless', value: false }, - { name: 'Video Wall', id: 'VidWall', value: false }, - { name: 'Whiteboard', id: 'Whiteboard', value: false }, - { name: 'Jamboard', id: 'Jamboard', value: false }, - { name: 'Projector', id: 'Projector', value: false }, - { name: 'Views', id: 'Views', value: false }, - ]; - - public readonly features = signal(this.room_features); - public readonly filtered_spaces = computed(() => { - const selected_features = this._applied_features(); - const favourite_spaces = this._applied_show_favourites() - ? this._state.favorite_spaces - : null; - return this.spaces().filter((space: Space) => { - const features = space.feature_list?.length - ? space.feature_list - : space.features || []; - return ( - (!favourite_spaces || favourite_spaces.includes(space.id)) && - selected_features.every((feature) => features.includes(feature)) - ); - }); - }); - - getSelectedFeatures() { - this.selected_features.set( - this.features().filter((item) => item.value), - ); - } - - applyFilter() { - this.getSelectedFeatures(); - this._applied_features.set( - this.selected_features().map((item) => item.id), - ); - this._applied_show_favourites.set(this.show_favourites()); - } - - clearFilter() { - this.selected_features.set([]); - this.show_favourites.set(false); - this._applied_features.set([]); - this._applied_show_favourites.set(false); - this.room_features.forEach((feature) => (feature.value = false)); - this.features.set([...this.room_features]); - } -} diff --git a/apps/outlook-addin/src/app/rooms/filter-space.component.ts b/apps/outlook-addin/src/app/rooms/filter-space.component.ts deleted file mode 100644 index 653d43e3f30..00000000000 --- a/apps/outlook-addin/src/app/rooms/filter-space.component.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { Component, computed, inject } from '@angular/core'; - -import { FormsModule } from '@angular/forms'; -import { FormField } from '@angular/forms/signals'; -import { - MAT_BOTTOM_SHEET_DATA, - MatBottomSheetRef, -} from '@angular/material/bottom-sheet'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatRippleModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { OrganisationService } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; -import { EventFormService } from '@placeos/events'; -import { - DateFieldComponent, - DurationFieldComponent, - TimeFieldComponent, -} from '@placeos/form-fields'; -import { FeaturesFilterService } from './features-filter.service'; - -@Component({ - selector: '[filter-space]', - template: ` -
- @if (form) { -
-
-
-
- - - Space Filters -
-
-
-
-
-
Details
-
- @if (has_multiple_buildings()) { - - } - @if (has_multiple_buildings()) { - - - @for ( - bld of buildings(); - track bld - ) { - - {{ - bld.display_name || - bld.name - }} - - } - - - } -
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
-
Favourites
-
- Only show favourite rooms - -
-
-
-
-
-
Features
- @for (feature of features(); track feature) { -
-
- {{ feature.name }} - -
-
- } -
-
-
- -
-
- } -
- `, - styles: [``], - imports: [ - MatRippleModule, - MatCheckboxModule, - FormsModule, - FormField, - DateFieldComponent, - TimeFieldComponent, - DurationFieldComponent, - MatFormFieldModule, - MatSelectModule, - IconComponent, - ], -}) -export class FilterSpaceComponent { - data = inject<{ - data; - }>(MAT_BOTTOM_SHEET_DATA); - private _bottomsheetRef = - inject>(MatBottomSheetRef); - private _featuresFilterService = inject(FeaturesFilterService); - private _state = inject(EventFormService); - private _org = inject(OrganisationService); - - readonly buildings = this._org.building_list; - readonly building = this._org.active_building; - readonly features = this._featuresFilterService.features; - readonly show_favourites = this._featuresFilterService.show_favourites; - readonly has_multiple_buildings = computed( - () => this.buildings().length > 1, - ); - readonly form = this._state.form; - readonly model = this._state.model; - minDate = Date.now(); - - public readonly setBuilding = (b) => (this._org.building = b); - - applyFilters() { - this._featuresFilterService.applyFilter(); - this._bottomsheetRef.dismiss(true); - } - - getSelectedFeatures() { - this._featuresFilterService.getSelectedFeatures(); - } - - closeModal() { - this._bottomsheetRef.dismiss(); - } -} diff --git a/apps/outlook-addin/src/app/rooms/find-space-item.component.ts b/apps/outlook-addin/src/app/rooms/find-space-item.component.ts deleted file mode 100644 index 6542934a222..00000000000 --- a/apps/outlook-addin/src/app/rooms/find-space-item.component.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - Component, - computed, - inject, - input, - output, -} from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { OrganisationService, Space } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; - -@Component({ - selector: 'find-space-item', - template: ` - - `, - styles: [ - ` - :host { - padding: 0 0.5rem; - } - `, - ], - imports: [MatRippleModule, IconComponent], -}) -export class FindSpaceItemComponent { - private readonly _org = inject(OrganisationService); - - public readonly space = input(undefined); - public readonly selected = input(false); - public readonly selectedChange = output(); - public readonly level_name = computed(() => { - const level = this._org.levelWithID(this.space()?.zones || []); - return level?.display_name || level?.name || ''; - }); - public readonly space_name = computed( - () => this.space()?.display_name || this.space()?.name || '', - ); - public readonly capacity = computed(() => this.space()?.capacity || 0); - - readonly toggleSelected = () => this.selectedChange.emit(!this.selected()); -} diff --git a/apps/outlook-addin/src/app/rooms/find-space.component.ts b/apps/outlook-addin/src/app/rooms/find-space.component.ts deleted file mode 100644 index f342c1dc264..00000000000 --- a/apps/outlook-addin/src/app/rooms/find-space.component.ts +++ /dev/null @@ -1,635 +0,0 @@ -import { - Component, - Injector, - OnInit, - computed, - effect, - inject, - signal, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { - MatBottomSheet, - MatBottomSheetRef, -} from '@angular/material/bottom-sheet'; -import { MatButtonToggleModule } from '@angular/material/button-toggle'; -import { MatRippleModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Router } from '@angular/router'; -import { - AsyncHandler, - HashMap, - OrganisationService, - Space, - ViewerFeature, - ViewerStyles, - firstValueWhere, -} from '@placeos/common'; -import { - IconComponent, - InteractiveMapComponent, - TranslatePipe, -} from '@placeos/components'; -import { EventFormService, SpacesService } from '@placeos/events'; -import { FeaturesFilterService } from './features-filter.service'; -import { FilterSpaceComponent } from './filter-space.component'; -import { FindSpaceItemComponent } from './find-space-item.component'; -import { MapService, MapsList } from './map.service'; -import { RoomConfirmService } from './room-confirm.service'; - -@Component({ - selector: 'find-space', - template: ` -
-
-
-
-

Find Space

- -
-
- -
- - -
-
-
-
- @if (!loading()) { - @if (spaces().length > 0) { - @if (view() === 'list') { -
- @for (space of spaces(); track space) { - - - } -
- } @else { - - - - {{ 'COMMON.LEVEL_ALL' | translate }} - - @for (map of maps_list(); track map) { - {{ - map.level - }} - } - - - } - } @else { -
-
-

No spaces

-
-
- } - } @else { -
- -

{{ loading() }}

-
- } -
-
-
- -
-
-
-
- - - - Find Space -
- -
-
- -
-
- - -
-
- -
- -
- -
-
- Results - - {{ spaces().length || 0 }} results found -
-
- -
- @if (!loading()) { - @if (spaces().length > 0) { - @if (view() === 'list') { -
- @for (space of spaces(); track space) { - - - } -
- } - @if ( - view() === 'map' && - map_features().length > 0 - ) { -
- @if (maps_list().length > 1) { -
- - - - {{ - 'COMMON.LEVEL_ALL' - | translate - }} - - @for ( - map of maps_list(); - track map - ) { - {{ - map.level - }} - } - - -
- } - @if (selected_level()) { -
- - @if (selected_all_levels()) { -
- @for ( - map of selected_level_maps(); - track map - ) { -
- - -
- } -
- } - - @if (!selected_all_levels()) { -
- - -
- } -
- } -
- } -

- End of available spaces list -

- } @else { -
-

- No available spaces for selected time, - capacity or level(s) -

-
- } - } @else { -
- -

Retrieving available spaces...

-
- } -
-
-
- @if (show_room_details()) { -
- -
- } -
-
-
- `, - styles: [ - ` - .mat-button-toggle-appearance-standard { - height: 2.25rem; - } - .mat-button-toggle-appearance-standard - .mat-button-toggle-label-content { - line-height: 2.25rem; - font-size: 0.875rem; - } - .mat-button-toggle-label-content { - font-size: 0.875rem; - } - .mat-button-toggle-checked { - border: 1px solid var(--secondary); - border-radius: 5px; - box-shadow: none; - } - .mat-focus-indicator { - border: none; - } - `, - ], - imports: [ - MatRippleModule, - MatProgressSpinnerModule, - InteractiveMapComponent, - MatFormFieldModule, - MatSelectModule, - FindSpaceItemComponent, - MatButtonToggleModule, - FormsModule, - IconComponent, - TranslatePipe, - MatTooltipModule, - ], -}) -export class FindSpaceComponent extends AsyncHandler implements OnInit { - private _bottomSheet = inject(MatBottomSheet); - private _org = inject(OrganisationService); - private _spaces = inject(SpacesService); - private _state = inject(EventFormService); - private _featuresFilterService = inject(FeaturesFilterService); - private _mapService = inject(MapService); - private _roomConfirmService = inject(RoomConfirmService); - private _router = inject(Router); - private _injector = inject(Injector); - - public readonly show_room_details = signal(false); - public readonly view = signal<'list' | 'map'>('list'); - private bottomSheetRef: MatBottomSheetRef; - - public readonly selected_features = - this._featuresFilterService.selected_features; - public readonly selected_feature_count = computed( - () => this.selected_features()?.length || 0, - ); - public readonly loading = this._state.loading; - public readonly spaces = this._featuresFilterService.filtered_spaces; - public readonly maps_list = this._mapService.maps_list; - public readonly map_features = signal([]); - public readonly map_actions = this._mapService.map_actions; - public readonly map_styles = signal(null); - public readonly selected_level = signal(null); - public readonly selected_all_levels = computed(() => - Array.isArray(this.selected_level()), - ); - public readonly selected_level_maps = computed(() => { - const selected_level = this.selected_level(); - return Array.isArray(selected_level) ? selected_level : []; - }); - public readonly selected_map = computed(() => { - const selected_level = this.selected_level(); - return !Array.isArray(selected_level) ? selected_level : null; - }); - - public get form() { - return this._state.form; - } - - public get model() { - return this._state.model; - } - - public readonly book_space = signal>({}); - - public readonly buildings = this._org.building_list; - - public readonly setBuilding = (b) => (this._org.building = b); - - constructor() { - super(); - effect(() => { - const maps = this.maps_list(); - if (maps?.length && !this.selected_level()) { - this.selected_level.set(maps); - } - }); - } - - public async ngOnInit() { - this.view.set('list'); - this._state.setView('find'); - - await this._org.waitUntilInitialised(); - await firstValueWhere( - this._spaces.initialised, - (_) => !!_, - this._injector, - ); - await this._state.listAvailableSpaces(); - - this.setBuilding(this._org.building); - this.book_space.set({}); - - await this._mapService.locateSpaces(this.spaces()); - - await firstValueWhere( - this._mapService.features_loaded, - (_) => !!_, - this._injector, - ); - - this.applyMapDecorations(); - - this.map_features.set(this._mapService.map_features()); - } - - public handleBookEvent(space: Space, book = true) { - this.book_space.set(book ? { [space.id]: true } : {}); - this._roomConfirmService.book_space = this.book_space(); - this._roomConfirmService.handleBookEvent(space, book); - this.show_room_details.set(book); - this._roomConfirmService.updateSelectedSpace(book ? space : null); - } - - openFilter() { - this.bottomSheetRef = this._bottomSheet.open(FilterSpaceComponent, { - data: this.buildings(), - }); - this.subscription( - 'filter-sheet', - this.bottomSheetRef.afterDismissed().subscribe((applied) => { - if (!applied) return; - void this.refreshMap(); - }), - ); - } - - private async refreshMap() { - await this._mapService.locateSpaces(this.spaces()); - this._mapService.processFeature(); - this.selected_level.set(this.maps_list()); - this.processStyles(); - this.map_features.set(this._mapService.map_features()); - } - - openRoomDetails() { - this._roomConfirmService.openRoomDetail(); - } - - updateSelectedLevel(e) { - this.selected_level.set(e); - if (!Array.isArray(this.selected_level())) { - this.applyMapDecorations(); - } - } - - applyMapDecorations() { - this.timeout( - 'init', - () => { - this.processFeature(); - this.processStyles(); - }, - 1500, - ); - } - - processFeature() { - this.map_features.set(this._mapService.map_features()); - } - - processStyles() { - this.map_styles.set(this._mapService.style_map); - } - - closeModal() { - this._router.navigate(['/book/spaces']); - this._featuresFilterService.clearFilter(); - } -} diff --git a/apps/outlook-addin/src/app/rooms/map.service.ts b/apps/outlook-addin/src/app/rooms/map.service.ts deleted file mode 100644 index cab1922d6d2..00000000000 --- a/apps/outlook-addin/src/app/rooms/map.service.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Injectable, inject, signal } from '@angular/core'; -import { MatBottomSheet } from '@angular/material/bottom-sheet'; -import { AsyncHandler, BuildingLevel, Space } from '@placeos/common'; -import { MapPinComponent } from '@placeos/components'; -import { ViewAction, ViewerFeature, ViewerStyles } from '@placeos/common'; -import { RoomConfirmService } from './room-confirm.service'; -import { RoomTileComponent } from './room-tile.component'; - -export interface Locatable { - id: string; - name: string; - map_id: string; - level: BuildingLevel; - zones?: string[]; -} - -export interface MapsList { - map_id: string; - level: string; -} - -@Injectable({ - providedIn: 'root', -}) -export class MapService extends AsyncHandler { - private _bottomSheet = inject(MatBottomSheet); - private _roomConfirmService = inject(RoomConfirmService); - - public level: BuildingLevel; - public style_map: ViewerStyles = {}; - public item: Locatable; - - public readonly map_features = signal([]); - public readonly map_actions = signal([]); - public readonly map_loaded = signal(false); - public readonly features_loaded = signal(false); - - public readonly selected_space = this._roomConfirmService.selected_space; - - //Store of Locatable Spaces - public readonly locatable_spaces = signal([]); - - //Store of map_id urls & level names for available_spaces - public readonly maps_list = signal([]); - - async locateSpaces(available_spaces: Space[]) { - const spaces = available_spaces || []; - this.locatable_spaces.set( - spaces.map((space) => ({ - id: space.id, - name: space.name, - map_id: space.map_id, - level: space.level, - })), - ); - await this.loadMap(); - this.timeout( - 'init', - () => { - this.processFeature(); - }, - 1000, - ); - this.processStyles(); - this.map_actions.set( - spaces.map( - (space: Space) => - ({ - id: space.map_id, - action: 'click', - callback: () => { - this.openRoomTile(space); - }, - }) as ViewAction, - ), - ); - } - - async loadMap() { - this.map_loaded.set(false); - const maps_list = this.locatable_spaces().map((space: Locatable) => ({ - map_id: space.level.map_id, - level: space.level.name, - })); - this.maps_list.set([ - ...new Map(maps_list.map((v) => [v.map_id, v])).values(), - ]); - this.map_loaded.set(true); - } - - public processFeature(): void { - this.features_loaded.set(false); - const focus = this.locatable_spaces().map((space) => ({ - location: space.map_id, - content: MapPinComponent, - data: { name: space.name }, - z_index: 99, - zoom: 100, - })); - this.map_features.set(focus); - this.features_loaded.set(true); - } - - public processStyles(): void { - const styles: ViewerStyles = {}; - styles[`#zones`] = { display: 'none' }; - styles[`#Zones`] = { display: 'none' }; - this.style_map = styles; - } - - openRoomTile(space: Space) { - this._bottomSheet.open(RoomTileComponent, { - panelClass: 'bottom-sheet-transparent', - data: space, - }); - - this._roomConfirmService.handleBookEvent(space, true); - } -} diff --git a/apps/outlook-addin/src/app/rooms/room-booking.component.ts b/apps/outlook-addin/src/app/rooms/room-booking.component.ts deleted file mode 100644 index dee3fc9b10c..00000000000 --- a/apps/outlook-addin/src/app/rooms/room-booking.component.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { Component, DOCUMENT, OnInit, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { FormField } from '@angular/forms/signals'; -import { MatRippleModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { Router } from '@angular/router'; -import { currentUser } from '@placeos/common'; -import { EventFormService } from '@placeos/events'; -import { - DateFieldComponent, - DurationFieldComponent, - TimeFieldComponent, - UserListFieldComponent, -} from '@placeos/form-fields'; -import { FeaturesFilterService } from './features-filter.service'; - -@Component({ - selector: 'room-booking', - template: ` -
-
-
-

Book Room

-
- @if (form(); as form) { -
-
-
-
- 1 -
-
Details
-
-
-
- - - - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
-
-
- 2 -
-
Attendees
-
-
- - -
-
-
- - -
-
- } -
-
- `, - styles: [``], - imports: [ - MatRippleModule, - UserListFieldComponent, - DurationFieldComponent, - TimeFieldComponent, - DateFieldComponent, - MatFormFieldModule, - MatInputModule, - FormField, - FormsModule, - ], -}) -export class RoomBookingComponent implements OnInit { - private _document = inject(DOCUMENT); - private router = inject(Router); - private _state = inject(EventFormService); - private _featuresFilterService = inject(FeaturesFilterService); - - min_date = Date.now(); - public readonly form = signal(this._state.form); - public readonly model = this._state.model; - - public readonly clearForm = () => { - this._state.clearForm(); - }; - - ngOnInit(): void { - this._state.newForm(); - this.form.set(this._state.form); - this._featuresFilterService.clearFilter(); - } - - async findSpace() { - const form = this.form() || this._state.form; - if (form && !this.form()) this.form.set(form); - if (!form) return; - form().markAsTouched(); - if (!this.model().host) - this.model.update((m) => ({ ...m, host: currentUser()?.email })); - if (!form().valid()) return; - await this._state.storeForm(); - this.router.navigate(['/schedule/view']); - } - - downloadTemplate() { - window.open('assets/template.csv'); - } -} diff --git a/apps/outlook-addin/src/app/rooms/room-confirm.component.ts b/apps/outlook-addin/src/app/rooms/room-confirm.component.ts deleted file mode 100644 index d5638b38def..00000000000 --- a/apps/outlook-addin/src/app/rooms/room-confirm.component.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, inject, signal } from '@angular/core'; -import { - MAT_BOTTOM_SHEET_DATA, - MatBottomSheetRef, -} from '@angular/material/bottom-sheet'; -import { MatRippleModule } from '@angular/material/core'; -import { CalendarEvent, Space, User } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; -import { EventFormService } from '@placeos/events'; -import { RoomConfirmService } from './room-confirm.service'; - -@Component({ - selector: 'room-confirm', - template: ` -
-
-
- - - Confirm Room Booking -
-
- -
-
- - -
-
- {{ title() }} - - -
- calendar_today - - {{ unix_time() | date: 'dd MMMM yyyy' }} - -
-
- schedule - {{ start_time() }} -{{ end_time() }} -
-
-
- -
-
- - -
- -
- Attendees - - - @for (attendee of attendees(); track attendee) { -
-
- people -
-
- {{ - attendee?.email - }} -
-
- } - -
-
- people -
-
- {{ creator() }} -
-
-
-
- -
-
- - -
-
- Rooms - - -
- meeting_room - - {{ space().name }} - -
-
- room - {{ space().level?.name }} -
-
-
- -
- -
-
- `, - styles: [``], - imports: [CommonModule, MatRippleModule, IconComponent], -}) -export class RoomConfirmComponent { - data = inject(MAT_BOTTOM_SHEET_DATA); - private _bottomSheetRef = - inject>(MatBottomSheetRef); - private _state = inject(EventFormService); - private _roomConfirmService = inject(RoomConfirmService); - - public readonly form = this._state.form; - public readonly model = this._state.model; - public loading = this._state.loading; - public readonly show_submit_button = signal(true); - public readonly space = signal(this.data as Space); - - private readonly _form_value = this.model; - - public readonly unix_time = computed( - () => this._form_value()?.date as number, - ); - public readonly start_time = computed(() => - new Date(this.unix_time()).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: 'numeric', - hour12: true, - }), - ); - public readonly end_time = computed(() => { - const duration_minutes = this._form_value()?.duration as number; - const end_time = this.unix_time() + duration_minutes * 60 * 1000; - return new Date(end_time).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: 'numeric', - hour12: true, - }); - }); - public readonly attendees = computed( - () => (this._form_value()?.attendees as User[]) || [], - ); - public readonly creator = computed( - () => (this._form_value()?.creator as string) || '', - ); - public readonly title = computed( - () => this._form_value()?.title as CalendarEvent['title'], - ); - - closeModal() { - this._bottomSheetRef.dismiss('cancel'); - } - - async confirmBooking() { - this.show_submit_button.set(false); - const booked = await this._roomConfirmService.bookRoom(this.space()); - if (booked) this._bottomSheetRef.dismiss(true); - else this.show_submit_button.set(true); - } -} diff --git a/apps/outlook-addin/src/app/rooms/room-confirm.service.ts b/apps/outlook-addin/src/app/rooms/room-confirm.service.ts deleted file mode 100644 index 6a35667b19b..00000000000 --- a/apps/outlook-addin/src/app/rooms/room-confirm.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { inject, Injectable, signal } from '@angular/core'; -import { MatBottomSheet } from '@angular/material/bottom-sheet'; -import { Router } from '@angular/router'; -import { errorMessage, HashMap, notifyError, Space } from '@placeos/common'; -import { EventFormService, SpacePipe, SpacesService } from '@placeos/events'; -import { RoomConfirmComponent } from './room-confirm.component'; -import { RoomDetailsComponent } from './room-details.component'; - -@Injectable({ - providedIn: 'root', -}) -export class RoomConfirmService { - private _bottomSheet = inject(MatBottomSheet); - private _router = inject(Router); - private _state = inject(EventFormService); - private _spaces = inject(SpacesService); - private _space_pipe = new SpacePipe(); - - space: Space; - public book_space: HashMap = {}; - public space_list: Space[] = []; - - public readonly selected_space = signal(null); - - public get form() { - return this._state.form; - } - - public get model() { - return this._state.model; - } - - constructor() { - this.book_space = {}; - const resources = this._state.model().resources || []; - resources.forEach((_) => (this.book_space[_.id] = true)); - this.space_list = this._spaces.filter((s) => this.book_space[s.id]); - } - - openRoomDetail(space = this.selected_space()) { - const room_details_ref = this._bottomSheet.open(RoomDetailsComponent, { - data: space, - }); - - room_details_ref.afterDismissed().subscribe((selected_space) => { - if (selected_space) this.openRoomConfirm(selected_space); - }); - } - - openRoomConfirm(space?: Space) { - if (space) { - this._bottomSheet.open(RoomConfirmComponent, { - data: space, - }); - } - } - - updateSelectedSpace(space: Space | null) { - this.selected_space.set(space); - } - - handleBookEvent(space: Space, book: boolean = true) { - this.book_space = {}; - this.book_space[space.id] = book; - } - - async bookRoom(space?: Space): Promise { - if (!space) return false; - this.handleBookEvent(space); - const id_list = Object.keys(this.book_space).filter( - (id) => this.book_space[id], - ); - const spaces = await Promise.all( - id_list.map((id) => this._space_pipe.transform(id)), - ); - this.model.update((m) => ({ - ...m, - resources: spaces, - system: spaces[0], - })); - this.space_list = this._spaces.filter((s) => this.book_space[s.id]); - return this.postForm(); - } - - async postForm(): Promise { - try { - await this._state.postForm(); - await this._router.navigate(['/confirm/success']); - return true; - } catch (error) { - notifyError(errorMessage(error) || 'Unable to book the room.'); - return false; - } - } -} diff --git a/apps/outlook-addin/src/app/rooms/room-details.component.ts b/apps/outlook-addin/src/app/rooms/room-details.component.ts deleted file mode 100644 index 31682851b54..00000000000 --- a/apps/outlook-addin/src/app/rooms/room-details.component.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { - Component, - inject, - signal, -} from '@angular/core'; -import { - MAT_BOTTOM_SHEET_DATA, - MatBottomSheetRef, -} from '@angular/material/bottom-sheet'; -import { MatRippleModule } from '@angular/material/core'; -import { Space } from '@placeos/common'; -import { - AuthenticatedImageDirective, - IconComponent, -} from '@placeos/components'; - -@Component({ - selector: 'placeos-room-details', - template: ` -
- @if (space()?.images?.length > 0) { -
- -
- } -
- {{ space()?.name }} - -
- @if (!room_added()) { - - } - - @if (room_added()) { - - } -
-
-
- Details -
- people - - {{ space()?.capacity }} People -
-
- room - - {{ space()?.level?.name }}, - {{ space()?.level?.parent_id }} -
-
- @if (space()?.feature_list.length > 0) { -
- Room Features - @for (facility of space()?.feature_list; track facility) { -
-
- @switch (facility) { - @case ('Views') { -
- panorama -
- } - @case ('Projector') { -
- video_camera_front -
- } - @case ('VidConf') { -
- contact_phone -
- } - @case ('Whiteboard') { -
- drive_file_rename_outline -
- } - @case ('Jamboard') { -
- draw -
- } - @case ('Wifi') { -
- nest_remote_comfort_sensor -
- } - } -
- - {{ facility }} - -
- } -
- } -
- @if (!room_added()) { - - } - @if (room_added()) { - - } -
-
- `, - styles: [``], - imports: [MatRippleModule, IconComponent, AuthenticatedImageDirective], -}) -export class RoomDetailsComponent { - data = inject(MAT_BOTTOM_SHEET_DATA); - private _bottomSheetRef = - inject>(MatBottomSheetRef); - - public readonly space = signal(this.data); - public readonly room_added = signal(false); - - selectRoom() { - this.room_added.update((room_added) => !room_added); - } - - back() { - if (this.room_added()) { - this._bottomSheetRef.dismiss(this.space()); - } else { - this._bottomSheetRef.dismiss(null); - } - } -} diff --git a/apps/outlook-addin/src/app/rooms/room-tile.component.ts b/apps/outlook-addin/src/app/rooms/room-tile.component.ts deleted file mode 100644 index 668ecf6d2bd..00000000000 --- a/apps/outlook-addin/src/app/rooms/room-tile.component.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { - Component, - inject, - signal, -} from '@angular/core'; -import { - MAT_BOTTOM_SHEET_DATA, - MatBottomSheetRef, -} from '@angular/material/bottom-sheet'; -import { MatRippleModule } from '@angular/material/core'; -import { Space } from '@placeos/common'; -import { - AuthenticatedImageDirective, - IconComponent, -} from '@placeos/components'; -import { RoomConfirmService } from './room-confirm.service'; - -@Component({ - selector: 'placeos-room-tile', - template: ` -
-
-
-
- @if (space()?.images?.length > 0) { - image of building - } - - @if (space()?.images?.length == 0) { -
- image -
- } -
-
- - {{ space()?.name }} - -
- room - - {{ space()?.level?.name }}, - {{ space()?.level?.parent_id }} -
- -
- people - - {{ space()?.capacity }} -
-
-
-
-
-
- -
- `, - styles: [``], - imports: [MatRippleModule, IconComponent, AuthenticatedImageDirective], -}) -export class RoomTileComponent { - readonly data = inject(MAT_BOTTOM_SHEET_DATA); - private _bottomSheetRef = - inject>(MatBottomSheetRef); - private _roomConfirmService = inject(RoomConfirmService); - - readonly space = signal(this.data); - - openRoomDetail() { - this._roomConfirmService.openRoomDetail(this.space()); - } - - cancel() { - this._bottomSheetRef.dismiss(null); - } -} diff --git a/apps/outlook-addin/src/app/rooms/upcoming-bookings.component.ts b/apps/outlook-addin/src/app/rooms/upcoming-bookings.component.ts deleted file mode 100644 index da67a6ab68d..00000000000 --- a/apps/outlook-addin/src/app/rooms/upcoming-bookings.component.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - OnInit, - inject, - signal, -} from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { BookingCardComponent } from '@placeos/bookings'; -import { AsyncHandler, CalendarEvent, currentUser } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { EventCardComponent } from '@placeos/events'; - -import { ScheduleStateService } from 'apps/workplace/src/app/schedule/schedule-state.service'; -import { startOfMinute } from 'date-fns'; - -@Component({ - selector: 'placeos-upcoming-bookings', - template: ` -
-
-
-

- {{ 'APP.WORKPLACE.UPCOMING' | translate }} -

-
-

- {{ now() | date: 'EEE dd LLL yyyy' }} -

-
- @let event_list = events(); - @if (event_list?.length) { - @for (item of event_list; track item.id) { - @if (isEvent(item)) { - - } @else { - - } - } - } @else { -
-

- {{ 'APP.WORKPLACE.UPCOMING_EMPTY' | translate }} -

-
- } -
-
- -

Retrieving bookings...

-
-
-
- `, - styles: [``], - imports: [ - CommonModule, - TranslatePipe, - MatProgressSpinnerModule, - EventCardComponent, - BookingCardComponent, - ], -}) -export class UpcomingBookingsComponent extends AsyncHandler implements OnInit { - private _schedule = inject(ScheduleStateService); - - public user = currentUser(); - public readonly loading = this._schedule.loading; - public readonly events = this._schedule.filtered_bookings; - public readonly now = signal(startOfMinute(Date.now())); - - public ngOnInit(): void { - this._schedule.toggleType('parking', true); - this.subscription('poll', this._schedule.startPolling()); - } - - public isEvent(item: unknown) { - return item instanceof CalendarEvent; - } -} diff --git a/apps/outlook-addin/src/assets/not-found.svg b/apps/outlook-addin/src/assets/not-found.svg deleted file mode 100644 index 6cf5e4680b9..00000000000 --- a/apps/outlook-addin/src/assets/not-found.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/outlook-addin/src/assets/template.csv b/apps/outlook-addin/src/assets/template.csv deleted file mode 100644 index e2ae5ccfc49..00000000000 --- a/apps/outlook-addin/src/assets/template.csv +++ /dev/null @@ -1,2 +0,0 @@ -Organisation,First Name,Last Name,Email,Phone,Assistance Required,Visit Expected -Fake Org,John,Smith,john.smith@example.com,01234567898,false,true \ No newline at end of file diff --git a/apps/outlook-addin/src/assets/tick.svg b/apps/outlook-addin/src/assets/tick.svg deleted file mode 100644 index 0e64d85396d..00000000000 --- a/apps/outlook-addin/src/assets/tick.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/outlook-addin/src/assets/tick_success.svg b/apps/outlook-addin/src/assets/tick_success.svg deleted file mode 100644 index 696b9c6cfa1..00000000000 --- a/apps/outlook-addin/src/assets/tick_success.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/outlook-addin/src/environments/settings.ts b/apps/outlook-addin/src/environments/settings.ts index c666c440935..7c757c7481f 100644 --- a/apps/outlook-addin/src/environments/settings.ts +++ b/apps/outlook-addin/src/environments/settings.ts @@ -1,11 +1,3 @@ -/** - * BOOKING FLOW SETTINGS - */ -const events: any = { - multiple_spaces: false, - can_book_for_others: false, - has_catering: true, -}; /*===========================*\ || SPACE LISTING SETTINGS || \*===========================*/ @@ -50,7 +42,6 @@ const app: any = { 'help', 'schedule', ], - events, space_display, explore, desks: { diff --git a/apps/outlook-addin/src/styles.css b/apps/outlook-addin/src/styles.css index 9d0e76d20b8..b575481cd57 100644 --- a/apps/outlook-addin/src/styles.css +++ b/apps/outlook-addin/src/styles.css @@ -18,20 +18,6 @@ body { font-family: Roboto, 'Helvetica Neue', sans-serif; } -.mat-bottom-sheet-container { - min-width: 400px; -} - -.bottom-sheet-transparent { - background-color: transparent; -} - -.top-box-shadow { - -webkit-box-shadow: 0px -4px 10px rgba(200, 200, 200, 0.75); - -moz-box-shadow: 0px -4px 10px rgba(200, 200, 200, 0.75); - box-shadow: 0px -4px 10px rgba(200, 200, 200, 0.75); -} - .material-symbols-rounded.blue-icon { color: var(--blueIcon); display: flex; diff --git a/apps/outlook-addin/src/tests/features-filter.service.spec.ts b/apps/outlook-addin/src/tests/features-filter.service.spec.ts deleted file mode 100644 index 4833fad5efb..00000000000 --- a/apps/outlook-addin/src/tests/features-filter.service.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { signal } from '@angular/core'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; -import { Space } from '@placeos/common'; -import { EventFormService } from '@placeos/events'; - -import { FeaturesFilterService } from '../app/rooms/features-filter.service'; - -describe('FeaturesFilterService', () => { - let spectator: SpectatorService; - const spaces = signal([ - new Space({ id: 'one', features: ['Projector', 'Whiteboard'] }), - new Space({ id: 'two', features: ['Projector'] }), - new Space({ id: 'three', features: ['Whiteboard'] }), - ]); - - const createService = createServiceFactory({ - service: FeaturesFilterService, - providers: [ - { - provide: EventFormService, - useValue: { - available_spaces: spaces, - favorite_spaces: ['two'], - }, - }, - ], - }); - - beforeEach(() => { - spectator = createService(); - spectator.service.clearFilter(); - }); - - it('applies every selected room feature', () => { - const features = spectator.service.features(); - features.find(({ id }) => id === 'Projector').value = true; - features.find(({ id }) => id === 'Whiteboard').value = true; - - spectator.service.getSelectedFeatures(); - spectator.service.applyFilter(); - - expect( - spectator.service.filtered_spaces().map(({ id }) => id), - ).toEqual(['one']); - }); - - it('shows only favourite rooms when requested', () => { - spectator.service.show_favourites.set(true); - spectator.service.applyFilter(); - - expect( - spectator.service.filtered_spaces().map(({ id }) => id), - ).toEqual(['two']); - }); - - it('restores the full available list when filters are cleared', () => { - spectator.service.show_favourites.set(true); - spectator.service.applyFilter(); - spectator.service.clearFilter(); - - expect(spectator.service.filtered_spaces()).toEqual(spaces()); - }); -}); diff --git a/apps/outlook-addin/src/tests/map.service.spec.ts b/apps/outlook-addin/src/tests/map.service.spec.ts deleted file mode 100644 index 74889c5b7c1..00000000000 --- a/apps/outlook-addin/src/tests/map.service.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { signal } from '@angular/core'; -import { CommonModule } from '@angular/common'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { - MatBottomSheet, - MatBottomSheetModule, -} from '@angular/material/bottom-sheet'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { BrowserModule } from '@angular/platform-browser'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; -import { ngMocks } from 'ng-mocks'; -import { MapService } from '../app/rooms/map.service'; - -import { mockComponent } from '@placeos/common/tests'; -import { MapPinComponent } from '@placeos/components'; -import { RoomConfirmService } from '../app/rooms/room-confirm.service'; -import { RoomTileComponent } from '../app/rooms/room-tile.component'; -import { mockSpace } from './test-mocks'; - -describe('MapService', () => { - let spectator: SpectatorService; - - const createService = createServiceFactory({ - service: MapService, - imports: [ - ReactiveFormsModule, - FormsModule, - MatFormFieldModule, - CommonModule, - BrowserModule, - BrowserAnimationsModule, - MatBottomSheetModule, - mockComponent(RoomTileComponent), - mockComponent(MapPinComponent), - ], - providers: [ - { - provide: MatBottomSheet, - useValue: { - open: vi.fn((_RoomTileComponent) => {}), - afterDismissed: vi.fn(), - }, - }, - { - provide: RoomConfirmService, - useValue: { - selected_space: signal(mockSpace), - openRoomDetail: vi.fn((_param) => {}), - handleBookEvent: vi.fn((_space, _flat) => {}), - }, - }, - ], - }); - - beforeEach(() => { - spectator = createService(); - }); - - afterEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - ngMocks.reset(); - }); - - it('should create service', () => { - expect(spectator.service).toBeTruthy(); - }); - - it('should return a selected space', () => { - expect(spectator.service.selected_space()).toBe(mockSpace); - }); - - it('should return a list of map IDs with no duplicates', async () => { - const available_spaces = [mockSpace, mockSpace, mockSpace, mockSpace]; - const map_list_spy = vi.spyOn(spectator.service, 'loadMap'); - await spectator.service.locateSpaces(available_spaces); - const maps_list = spectator.service.maps_list(); - - expect(maps_list.length).toBe(1); //4 spaces with the same map_ID were passed through, only 1 should be returned - expect(map_list_spy).toHaveBeenCalled(); - }); - - it('should have a flag indicating whether the map has loaded', async () => { - expect(spectator.service.map_loaded()).toBe(false); - - await spectator.service.loadMap(); - expect(spectator.service.map_loaded()).toBe(true); - }); - - it('should create map actions for all available spaces', async () => { - const available_spaces = [mockSpace, mockSpace, mockSpace, mockSpace]; - await spectator.service.locateSpaces(available_spaces); - const map_actions = spectator.service.map_actions(); - - expect(map_actions.length).toBe(available_spaces.length); - - expect(map_actions.every((map) => map.action == 'click')).toBeTruthy(); - - map_actions.forEach((map) => { - expect(map.callback).toBeInstanceOf(Function); - }); - }); - - it('should contain a method to open a room tile when clicked', async () => { - const mat_bottom_sheet = spectator.inject(MatBottomSheet); - (mat_bottom_sheet as any).open.mockImplementation( - () => {}, - ); - const room_confirm_service = spectator.inject(RoomConfirmService); - - const room_confirm_service_spy = vi.spyOn( - room_confirm_service, - 'handleBookEvent', - ); - - await spectator.service.openRoomTile(mockSpace); - expect(room_confirm_service_spy).toHaveBeenCalled(); - expect(mat_bottom_sheet.open).toHaveBeenCalled(); - }); - - it('should only process map features after the map has been loaded', async () => { - const map_load_spy = vi.spyOn(spectator.service, 'loadMap'); - - const process_features_spy = vi.spyOn( - spectator.service, - 'processFeature', - ); - - const available_spaces = [mockSpace, mockSpace]; - - await spectator.service.locateSpaces(available_spaces); - expect(map_load_spy).toHaveBeenCalled(); - - expect(process_features_spy).not.toHaveBeenCalled(); - - setTimeout(() => { - expect(process_features_spy).toHaveBeenCalled(); - }, 2000); - }); -}); diff --git a/apps/outlook-addin/src/tests/room-booking.component.spec.ts b/apps/outlook-addin/src/tests/room-booking.component.spec.ts deleted file mode 100644 index 2506dfb20a0..00000000000 --- a/apps/outlook-addin/src/tests/room-booking.component.spec.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Location } from '@angular/common'; -import { Injector, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { NativeDateModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { CalendarEvent, SettingsService } from '@placeos/common'; -import { - EventFormService, - eventFormValue, - generateEventForm, -} from '@placeos/events'; -import { MockProvider } from 'ng-mocks'; -import { RoomBookingComponent } from '../app/rooms/room-booking.component'; -import { UpcomingBookingsComponent } from '../app/rooms/upcoming-bookings.component'; - -describe('RoomBookingComponent', () => { - let spectator: SpectatorRouting; - // A real signal-forms ref backs the mocked service so the component - // captures stable `model`/`form` references. Built in `beforeEach` so the - // generator runs inside an Angular injection context. - let form_ref: ReturnType; - - // Signal forms only create child field nodes for properties present in the - // model, so seed every bound field (title/date/duration/attendees). - const baseEvent = () => - new CalendarEvent({ - title: '', - date: Date.now(), - duration: 30, - }); - - const createComponent = createRoutingFactory({ - component: RoomBookingComponent, - imports: [FormsModule, MatFormFieldModule, NativeDateModule], - providers: [ - { - provide: EventFormService, - // Built inside an injection context so `form()` can `inject()`. - useFactory: (injector: Injector) => { - form_ref = generateEventForm( - baseEvent(), - undefined, - injector, - ); - return { - setView: vi.fn(() => {}), - newForm: vi.fn(() => {}), - clearForm: vi.fn(), - storeForm: vi.fn(() => {}), - loadForm: vi.fn(), - postForm: vi.fn(), - view: '', - model: form_ref.model, - form: form_ref.form, - }; - }, - deps: [Injector], - }, - MockProvider(SettingsService, { - get: vi.fn(), - signal: (_name: string, default_value?: unknown) => - signal(default_value), - } as Partial), - ], - stubsEnabled: false, - routes: [ - { - path: '', - component: RoomBookingComponent, - }, - { - path: 'schedule/view', - component: UpcomingBookingsComponent, - }, - ], - }); - - beforeEach(() => { - spectator = createComponent(); - const event_service: any = spectator.inject(EventFormService); - event_service.setView.mockImplementation((_) => { - event_service.view = _; - spectator.detectChanges(); - }); - event_service.newForm.mockImplementation(() => { - form_ref.model.set(eventFormValue(baseEvent())); - form_ref.form().reset(); - }); - event_service.storeForm.mockImplementation(() => { - const value = form_ref.model(); - if ( - value.title === undefined || - value.date === undefined || - value.duration === undefined - ) - return null; - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - it('should create a new form on component init', () => { - const event_service = spectator.inject(EventFormService); - const spy = vi.spyOn(spectator.component, 'ngOnInit'); - const new_form_spy = vi.spyOn(event_service, 'newForm'); - spectator.component.ngOnInit(); - expect(spy).toHaveBeenCalled(); - expect(new_form_spy).toHaveBeenCalled(); - // `toExist` is a jQuery DOM matcher; passing the form signal-function - // makes jQuery treat it as a `$(fn)` ready-callback and leak a timer. - expect(event_service.form).toBeTruthy(); - }); - - it('should not navigate away if the booking is in the past', async () => { - // Settle the routing factory's initial navigation so `Location.path()` - // reports the root route. - await spectator.fixture.whenStable(); - const event_service = spectator.inject(EventFormService); - await event_service.newForm(); - - form_ref.model.update((m) => ({ - ...m, - host: 'host@test.com', - title: 'test', - date: Date.now() - 60 * 60 * 1000, - duration: 30, - })); - - // Read validity synchronously before the form's bookable-hours effect - // flushes and corrects the past booking to a valid future slot. - spectator.component.findSpace(); - expect(form_ref.form.duration().invalid()).toBeTruthy(); - expect(form_ref.form().valid()).toBeFalsy(); - expect(spectator.inject(Location).path()).toBe('/'); - }); - - it('should navigate if the required form fields are valid', async () => { - await spectator.component.ngOnInit(); - - form_ref.model.update((m) => ({ - ...m, - host: 'host@test.com', - date: Date.now() + 60 * 60 * 1000, - duration: 120, - attendees: [], - title: 'test', - })); - - expect(form_ref.form().valid()).toBeTruthy(); - await spectator.component.findSpace(); - await spectator.fixture.whenStable(); - expect(spectator.inject(Location).path()).toBe('/schedule/view'); - }); -}); diff --git a/apps/outlook-addin/src/tests/room-confirm.service.spec.ts b/apps/outlook-addin/src/tests/room-confirm.service.spec.ts deleted file mode 100644 index ee739f1795b..00000000000 --- a/apps/outlook-addin/src/tests/room-confirm.service.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { signal } from '@angular/core'; -import { MatBottomSheet } from '@angular/material/bottom-sheet'; -import { Router } from '@angular/router'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; -import { EventFormService, SpacesService } from '@placeos/events'; - -import { RoomConfirmService } from '../app/rooms/room-confirm.service'; - -describe('RoomConfirmService', () => { - let spectator: SpectatorService; - const post_form = vi.fn(); - const navigate = vi.fn(); - - const createService = createServiceFactory({ - service: RoomConfirmService, - providers: [ - { - provide: EventFormService, - useValue: { - model: signal({ resources: [] }), - postForm: post_form, - }, - }, - { - provide: SpacesService, - useValue: { filter: vi.fn(() => []) }, - }, - { - provide: MatBottomSheet, - useValue: { open: vi.fn() }, - }, - { - provide: Router, - useValue: { navigate }, - }, - ], - }); - - beforeEach(() => { - post_form.mockReset(); - navigate.mockReset(); - navigate.mockResolvedValue(true); - spectator = createService(); - }); - - it('navigates to confirmation after the booking succeeds', async () => { - post_form.mockResolvedValue({ id: 'event-1' }); - - await expect(spectator.service.postForm()).resolves.toBe(true); - expect(navigate).toHaveBeenCalledWith(['/confirm/success']); - }); - - it('stays on the confirmation screen when the booking fails', async () => { - post_form.mockRejectedValue(new Error('Unavailable')); - - await expect(spectator.service.postForm()).resolves.toBe(false); - expect(navigate).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/outlook-addin/src/tests/test-mocks.ts b/apps/outlook-addin/src/tests/test-mocks.ts deleted file mode 100644 index d70cb412ff8..00000000000 --- a/apps/outlook-addin/src/tests/test-mocks.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { Validators } from '@angular/forms'; -import { Space } from '@placeos/common'; - -export const mockForm = { - id: 1, - host: ['host@test.com', Validators.required], - organiser: ['organiser@test.com', Validators.required], - creator: ['creator@test.com', Validators.required], - title: ['', Validators.required], - date: [0, Validators.required], - duration: 0, - attendees: '' as any, - markAllAsTouched: vi.fn(() => {}), -}; - -export const mockGenerateEventForm = (_event: unknown) => {}; - -export const mockBuilding = { - id: 1, - name: 'test-building', - zone_id: 'zone-1', -}; - -export const mockUser: any = { - id: '123', - name: 'testUser', - email: 'user@test.com', - first_name: 'user', - last_name: 'user', - username: '', - phone: '', - organisation: '', - notes: '', - photo: '', - required: true, - organizer: true, - response_status: '' as any, - visit_expected: true, - checked_in: false, - groups: [''], - extension_data: ' ' as any, - is_external: true, - assistance_required: false, -}; - -export const mockStaffUser: any = { - id: '123', - name: 'PlaceOS - Demo User', - email: 'user@test.com', - first_name: 'user', - last_name: 'user', - username: '', - phone: '', - organisation: '', - notes: '', - photo: '', - required: true, - organizer: true, - response_status: '' as any, - visit_expected: true, - checked_in: false, - groups: [''], - extension_data: ' ' as any, - is_external: false, - assistance_required: false, - card_number: '123', - staff_id: '123', - is_logged_in: true, - location: {} as any, -}; - -export const mockExternalUser: any = { - id: '123', - name: 'ExternalUser', - email: 'external-user@test.com', - first_name: 'External', - last_name: 'user', - username: '', - phone: '', - organisation: '', - notes: '', - photo: '', - required: true, - organizer: true, - response_status: '' as any, - visit_expected: true, - checked_in: false, - groups: [''], - extension_data: ' ' as any, - is_external: true, - assistance_required: false, -}; - -export const mockSpace: Space = { - id: '1', - name: 'test-space', - display_name: '', - email: '', - capacity: 2, - feature_list: [''], - support_url: '', - map_id: 'map-id-1', - bookable: true, - zones: [''], - images: [], - response_status: '' as any, - approval: false, - level: { - id: '123', - parent_id: '', - name: 'Building-1', - display_name: '', - capacity: 40, - number: 'url', - map_id: 'map-id-1', - tags: ['tag-1'], - settings: {}, - location: '', - code: '', - locations: [] as any, - images: [], - }, - features: [''], - availability: [ - { - duration: 12345, - date: 23456, - status: '', - }, - ], - inUseAt: () => false, -} as any; - -export const mockSpaceWithViews: Space = { - id: '1', - name: 'test-space', - display_name: '', - email: '', - capacity: 2, - feature_list: ['Views'], - support_url: '', - map_id: 'map-id-1', - bookable: true, - zones: [''], - images: [], - response_status: '' as any, - approval: false, - level: { - id: '123', - parent_id: '', - name: 'Building-1', - display_name: '', - capacity: 40, - number: 'url', - map_id: 'map-id-1', - tags: ['tag-1'], - settings: {}, - location: '', - code: '', - locations: [], - images: [], - }, - features: [''], - availability: [ - { - duration: 12345, - date: 23456, - status: '', - }, - ], - inUseAt: () => false, -} as any; - -export const mockCalendarEvent: any = { - id: '123', - status: 'approved', - host: 'host@test.com', - calendar: 'calendar-id', - creator: 'creator@test.com', - attendees: [mockUser], - resources: [mockSpace], - title: 'title', - body: '', - event_start: 12345, - event_end: 23456, - all_day: false, - date: 12345, - duration: 22222, - timezone: '', - location: '', - meeting_url: '', - meeting_id: '', - meeting_provider: '', - recurring: false, - recurrence: '' as any, - recurring_master_id: '', - private: false, - attachments: '' as any, - extension_data: '' as any, - system: '' as any, - old_system: '' as any, - organiser: mockStaffUser, - type: 'external', - is_today: true, - space: mockSpace, - guests: [] as any, - ext: '' as any, - toJSON: () => { - return ''; - }, - state: 'upcoming', - can_check_in: true, -}; - -export const mockExternalCalendarEvent: any = { - id: '123', - status: 'approved', - host: 'host@test.com', - calendar: 'calendar-id', - creator: 'creator@test.com', - attendees: [mockExternalUser], - resources: [mockSpace], - title: 'title', - body: '', - event_start: 12345, - event_end: 23456, - all_day: false, - date: 12345, - duration: 22222, - timezone: '', - location: '', - meeting_url: '', - meeting_id: '', - meeting_provider: '', - recurring: false, - recurrence: '' as any, - recurring_master_id: '', - private: false, - attachments: '' as any, - extension_data: '' as any, - system: '' as any, - old_system: '' as any, - organiser: mockExternalUser, - type: 'external', - is_today: true, - space: mockSpace, - guests: [] as any, - ext: '' as any, - toJSON: () => { - return ''; - }, - state: 'upcoming', - can_check_in: true, -}; -export const mockEventFlowOptions = { - calendar_id: '123', - zone_id: ['zone-1'], -}; - -export function mockGetStaffUser() { - return mockStaffUser; -} diff --git a/apps/outlook-addin/src/tests/upcoming-bookings.component.spec.ts b/apps/outlook-addin/src/tests/upcoming-bookings.component.spec.ts deleted file mode 100644 index 63e7bc169c7..00000000000 --- a/apps/outlook-addin/src/tests/upcoming-bookings.component.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { signal, WritableSignal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { BookingCardComponent } from '@placeos/bookings'; -import { CalendarEvent } from '@placeos/common'; -import { mockComponent } from '@placeos/common/tests'; -import { EventCardComponent } from '@placeos/events'; -import { UpcomingBookingsComponent } from '../app/rooms/upcoming-bookings.component'; - -import { ScheduleStateService } from 'apps/workplace/src/app/schedule/schedule-state.service'; - -describe('UpcomingBookingsComponent', () => { - let spectator: Spectator; - let loading: WritableSignal; - let filtered_bookings: WritableSignal; - - const createComponent = createComponentFactory({ - component: UpcomingBookingsComponent, - imports: [MatProgressSpinnerModule], - providers: [ - { - provide: ScheduleStateService, - useFactory: () => { - loading = signal(false); - filtered_bookings = signal([]); - return { - loading, - filtered_bookings, - toggleType: vi.fn(), - startPolling: vi.fn(() => () => {}), - }; - }, - }, - ], - declarations: [ - mockComponent(EventCardComponent), - mockComponent(BookingCardComponent), - ], - }); - - beforeEach(() => (spectator = createComponent())); - - afterEach(() => { - loading.set(false); - filtered_bookings.set([]); - }); - - it('should show empty state', () => { - expect('[empty]').toExist(); - filtered_bookings.set([new CalendarEvent()]); - spectator.detectChanges(); - expect('[empty]').not.toExist(); - }); - - it("should show user's events", () => { - expect('event-card').not.toExist(); - filtered_bookings.set([new CalendarEvent()]); - spectator.detectChanges(); - expect('event-card').toExist(); - }); -}); diff --git a/apps/redirect/src/app/app.component.ts b/apps/redirect/src/app/app.component.ts index c64f2a92426..234e576f60e 100644 --- a/apps/redirect/src/app/app.component.ts +++ b/apps/redirect/src/app/app.component.ts @@ -80,11 +80,6 @@ import { background-color: #424242; border-color: #888; } - - input { - border-color: #666; - color: #fff; - } } `, ], diff --git a/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts index 7ba2aa5fa97..f8f03e44441 100644 --- a/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts @@ -26,12 +26,7 @@ import { TranslatePipe, } from '@placeos/components'; import { DateFieldComponent } from '@placeos/form-fields'; -import { - addSignagePlaylist, - MediaAnimation, - SignagePlaylist, - updateSignagePlaylist, -} from '@placeos/ts-client'; +import { MediaAnimation, SignagePlaylist } from '@placeos/ts-client'; import { endOfDay, getUnixTime, startOfDay } from 'date-fns'; import { playlistAnimation } from '../signage-playlist.util'; import { @@ -48,7 +43,9 @@ export interface PlaylistEditModalData { playlist: SignagePlaylist; /** Signage group the playlist is being viewed from */ group_id?: string; + /** Saves a new playlist. Set when the modal opens without a playlist ID. */ onAdd?: (data: Partial) => Promise; + /** Saves an existing playlist. Set when the playlist has an ID. */ onEdit?: ( id: string, data: Partial, @@ -485,13 +482,9 @@ export class PlaylistEditModalComponent { try { let result: SignagePlaylist; if (this.playlist.id) { - result = this._data.onEdit - ? await this._data.onEdit(this.playlist.id, data) - : await updateSignagePlaylist(this.playlist.id, data); + result = await this._data.onEdit(this.playlist.id, data); } else { - result = this._data.onAdd - ? await this._data.onAdd(data) - : await addSignagePlaylist(data); + result = await this._data.onAdd(data); } this._dialog_ref.disableClose = false; this._dialog_ref.close(result); diff --git a/apps/signage/src/app/media-player.component.ts b/apps/signage/src/app/media-player.component.ts index f750b50260e..d91ca2e061c 100644 --- a/apps/signage/src/app/media-player.component.ts +++ b/apps/signage/src/app/media-player.component.ts @@ -322,8 +322,6 @@ export class MediaPlayerComponent alias: 'state', }); public readonly state = linkedSignal(this.stateInput); - public readonly stateChange = output(); - public readonly indexChange = output(); public readonly mutedChange = output(); public readonly playing_id = output(); public readonly event = output(); @@ -632,7 +630,6 @@ export class MediaPlayerComponent (_) => _.id === current_item.id, ); this.index.set(index); - this.indexChange.emit(index); } else { const was_playing = this.state() === 'PLAYING'; this._dropItemState(new Set()); @@ -884,7 +881,6 @@ export class MediaPlayerComponent index = this._normalisePlaylistIndex(index); const old_index = this.index(); this.index.set(index); - this.indexChange.emit(index); const item = this.active_item; const old_item = this._item_playlist[old_index]; @@ -1626,7 +1622,6 @@ export class MediaPlayerComponent private _clearActiveItem() { if (this.index() !== -1) { this.index.set(-1); - this.indexChange.emit(-1); } this.playing_id.emit(''); this._item_start = 0; diff --git a/apps/signage/src/app/signage.component.ts b/apps/signage/src/app/signage.component.ts index a2e43d224ab..16dc447af7e 100644 --- a/apps/signage/src/app/signage.component.ts +++ b/apps/signage/src/app/signage.component.ts @@ -8,7 +8,6 @@ import { signal, viewChildren, } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { @@ -214,7 +213,6 @@ function isCoveredByLoadingOverlay() { DatePipe, DebugOverlayComponent, IconComponent, - MatRippleModule, MediaPlayerComponent, MatTooltipModule, ], diff --git a/apps/stagehand/USER_STORIES.md b/apps/stagehand/USER_STORIES.md index 922a4a8878a..7dab00c9fe7 100644 --- a/apps/stagehand/USER_STORIES.md +++ b/apps/stagehand/USER_STORIES.md @@ -241,20 +241,6 @@ Stagehand is an AV systems monitoring and management application for facility ma --- -### US-SM-016: Add Time-Based Condition - -**As a** facility manager -**I want to** add time-based trigger conditions -**So that** I can schedule alerts for specific times - -**Acceptance Criteria:** - -- Support specific time (AT) conditions -- Support CRON expression conditions -- Combine with other conditions using AND/OR logic - ---- - ### US-SM-017: Edit Alert Condition **As a** facility manager diff --git a/apps/stagehand/src/app/alerts.component.ts b/apps/stagehand/src/app/alerts.component.ts index c3e7d7ff4de..de6707d1dcb 100644 --- a/apps/stagehand/src/app/alerts.component.ts +++ b/apps/stagehand/src/app/alerts.component.ts @@ -11,7 +11,6 @@ import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -82,7 +81,6 @@ import { SidebarComponent } from './ui/sidebar.component'; id="stagehand-page-content" class="bg-base-200 w-full flex-1 overflow-auto" > -
@@ -194,30 +192,6 @@ import { SidebarComponent } from './ui/sidebar.component';
-
build -
-
@@ -439,7 +387,6 @@ import { SidebarComponent } from './ui/sidebar.component'; styles: [``], imports: [ CommonModule, - MatMenuModule, MatRippleModule, SimpleTableComponent, IconComponent, @@ -529,12 +476,6 @@ export class AlertsComponent extends AsyncHandler implements OnInit { public readonly dashboard = signal(null); public readonly dashboards = this._dashboards.dashboard_list.asReadonly(); - public readonly info_alerts = computed( - () => - this.alert_list().filter( - (a) => a.severity === 'medium' || a.severity === 'low', - ).length, - ); public readonly critical_alerts = computed( () => this.alert_list().filter((a) => a.severity === 'critical').length, ); @@ -563,10 +504,6 @@ export class AlertsComponent extends AsyncHandler implements OnInit { 'backoffice_link', `${location.origin}/backoffice/`, ); - public readonly service_link = settingSignal( - 'service_link', - `${location.origin}/control/`, - ); public ngOnInit() { // Load filters from query parameters diff --git a/apps/stagehand/src/app/analytics.component.ts b/apps/stagehand/src/app/analytics.component.ts index 261b1b95e61..bc3e2540025 100644 --- a/apps/stagehand/src/app/analytics.component.ts +++ b/apps/stagehand/src/app/analytics.component.ts @@ -1,9 +1,7 @@ -import { Component, inject, linkedSignal } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Component, linkedSignal } from '@angular/core'; import { AsyncHandler, settingSignal } from '@placeos/common'; import { FormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { IconComponent, SafePipe, TranslatePipe } from '@placeos/components'; @@ -77,7 +75,6 @@ interface AnalyticsPage { `, styles: [``], imports: [ - MatRippleModule, TranslatePipe, SafePipe, IconComponent, @@ -85,13 +82,9 @@ interface AnalyticsPage { MatSelectModule, FormsModule, SidebarComponent, - FormsModule, ], }) export class AnalyticsComponent extends AsyncHandler { - private _route = inject(ActivatedRoute); - private _router = inject(Router); - public readonly analytics_pages = settingSignal( 'analytics_pages', [], diff --git a/apps/stagehand/src/app/app.routes.ts b/apps/stagehand/src/app/app.routes.ts index e7fcbdcc4ad..cbff59f90d5 100644 --- a/apps/stagehand/src/app/app.routes.ts +++ b/apps/stagehand/src/app/app.routes.ts @@ -87,13 +87,6 @@ export const routes: Routes = [ (m) => m.DashboardManageComponent, ), }, - { - path: 'view/:id', - loadComponent: () => - import('./dashboards/dashboard-view.component').then( - (m) => m.DashboardViewComponent, - ), - }, { path: '**', redirectTo: 'list' }, ], }, diff --git a/apps/stagehand/src/app/dashboard.component.ts b/apps/stagehand/src/app/dashboard.component.ts deleted file mode 100644 index c7ad17b8fa9..00000000000 --- a/apps/stagehand/src/app/dashboard.component.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Component } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { SidebarComponent } from './ui/sidebar.component'; - -@Component({ - selector: 'app-dashboard', - template: ` -
- -
-
-

AV Systems Monitor

- -
-
-
-
- `, - styles: [], - imports: [MatRippleModule, SidebarComponent], -}) -export class DashboardComponent {} diff --git a/apps/stagehand/src/app/dashboards/alert-condition-time-form.component.ts b/apps/stagehand/src/app/dashboards/alert-condition-time-form.component.ts deleted file mode 100644 index b41e92f2899..00000000000 --- a/apps/stagehand/src/app/dashboards/alert-condition-time-form.component.ts +++ /dev/null @@ -1,529 +0,0 @@ -import { - Component, - input, - OnChanges, - OnInit, - signal, - SimpleChanges, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { FormField } from '@angular/forms/signals'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { AsyncHandler, i18n, Identity } from '@placeos/common'; -import { TIMEZONES_IANA } from '@placeos/common/timezones'; -import { TriggerTimeConditionType } from '@placeos/ts-client'; -import { format, setDay, setMonth } from 'date-fns'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { DateFieldComponent } from 'libs/form-fields/src/lib/date-field.component'; -import { TimeFieldComponent } from 'libs/form-fields/src/lib/time-field.component'; -import { CronInputFieldComponent } from './cron-input-field.component'; - -type CronPeriod = - | 'minute' - | 'hour' - | 'day' - | 'week' - | 'month' - | 'year' - | 'custom'; - -/** - * Calculate the position counter for the given number e.g `1st`, `2nd`, `3rd`, `4th`... - * @param num Number to caculate position for - */ -export function numberToPosition(num: number): string { - const mod_ten = num % 10; - if (num > 10 && num < 20) { - return `${num}th`; - } else if (mod_ten === 1) { - return `${num}st`; - } else if (mod_ten === 2) { - return `${num}nd`; - } else if (mod_ten === 3) { - return `${num}rd`; - } - return `${num}th`; -} - -@Component({ - selector: 'alert-condition-time-form', - template: ` - @if (form()) { -
-
- -
- @if (is_cron()) { -
- - - search - - - - @for (tz of timezones(); track tz) { - {{ tz }} - } - @if (!timezones().length) { - {{ - 'COMMON.TIMEZONE_EMPTY' | translate - }} - } - -
- } - @if (!is_cron()) { -
- @if (form().time) { -
- - -
- } - @if (form().time) { -
- - -
- } -
- } @else { -
- - - - @for (period of repeat_period(); track period) { - - {{ period.name }} - - } - - -
- @if (cron_period() !== 'custom') { -
- @if (cron_period() === 'year') { -
- - - - @for ( - month of months_of_year(); - track month; - let i = $index - ) { - - {{ month }} - - } - - -
- } - @if ( - cron_period() === 'month' || - cron_period() === 'year' - ) { -
- - - - @for ( - period of days_of_month; - track period - ) { - - {{ period.name }} - - } - - -
- } -
- @if (cron_period() === 'week') { -
- - - - @for ( - weekday of days_of_week(); - track weekday; - let i = $index - ) { - - {{ weekday }} - - } - - -
- } -
- @if ( - cron_period() !== 'minute' && - cron_period() !== 'hour' - ) { -
- - - - - {{ pad(cron_hour()) }}:00 - - @for ( - hour of hours_in_day; - track hour - ) { - - {{ pad(hour) }}:00 - - } - - -
- } - @if (cron_period() !== 'minute') { -
- - - - - {{ - pad(cron_hour()) - }}:{{ pad(cron_minute()) }} - - @for ( - minute of minutes_in_hour; - track minute - ) { - - {{ - pad(cron_hour()) - }}:{{ pad(minute) }} - - } - - -
- } -
- } @else { - - } - } -
- } - `, - styles: [], - imports: [ - FormsModule, - CronInputFieldComponent, - MatFormFieldModule, - MatSelectModule, - DateFieldComponent, - TimeFieldComponent, - MatAutocompleteModule, - SettingsToggleComponent, - MatInputModule, - FormField, - TranslatePipe, - IconComponent, - ], -}) -export class AlertConditionTimeFormComponent - extends AsyncHandler - implements OnChanges, OnInit -{ - /** Group of form fields used for creating the system */ - public readonly form = input(undefined); - /** List of available periods for scheduled repetition */ - public readonly repeat_period = signal([]); - /** Whether condition is a cron(recurring) job */ - public readonly is_cron = signal(false); - /** The period which the user selects the recurrence */ - public readonly cron_period = signal('minute'); - - public readonly minutes_in_hour = new Array(12) - .fill(0) - .map((_, idx) => idx * 5); - public readonly hours_in_day = new Array(24).fill(0).map((_, idx) => idx); - public readonly days_of_week = signal([]); - public readonly days_of_month: Identity[] = Array(31) - .fill(0) - .map((_, index) => ({ - id: index + 1, - name: `${numberToPosition(index + 1)}`, - })); - public readonly months_of_year = signal([]); - public readonly cron_string = signal(''); - /** Minute of the hour to recurr on */ - public readonly cron_minute = signal(0); - /** Hour of the day to recurr on */ - public readonly cron_hour = signal(0); - /** Hour of the day to recurr on */ - public cron_hour_period = 'AM'; - /** Hour of the day to recurr on */ - public readonly cron_day = signal(0); - /** Hour of the day to recurr on */ - public readonly cron_date = signal(1); - /** Hour of the day to recurr on */ - public readonly cron_month = signal(1); - - public readonly timezones = signal([]); - - public pad(str: unknown, digits = 2) { - return `${str}`.padStart(digits, '0'); - } - - public ngOnInit() { - this.repeat_period.set([ - { id: 'minute', name: i18n('COMMON.MINUTE') }, - { id: 'hour', name: i18n('COMMON.HOUR') }, - { id: 'day', name: i18n('COMMON.DAY') }, - { id: 'week', name: i18n('COMMON.WEEK') }, - { id: 'month', name: i18n('COMMON.MONTH') }, - { id: 'year', name: i18n('COMMON.YEAR') }, - { id: 'custom', name: i18n('COMMON.CRON_CUSTOM') }, - ]); - this.days_of_week.set( - new Array(7) - .fill(0) - .map((_, index) => - i18n( - `COMMON.${format( - setDay(Date.now(), index), - 'EEEE', - ).toUpperCase()}`, - ), - ), - ); - this.months_of_year.set( - Array(12) - .fill(0) - .map((_, index) => - i18n( - `COMMON.${format( - setMonth(Date.now(), index), - 'MMMM', - ).toUpperCase()}`, - ), - ), - ); - } - - public ngOnChanges(changes: SimpleChanges): void { - const form = this.form(); - if (changes.form && form) { - this.is_cron.set( - form.time_type().value() === TriggerTimeConditionType.CRON, - ); - if (this.is_cron()) { - this.loadCronTab(form.cron().value()); - } - this.updateTimezoneList(form.timezone().value()); - } - } - - public updateTimezoneList(tz: string) { - const tz_lower = (tz || '').toLowerCase(); - this.timezones.set( - TIMEZONES_IANA.filter((_) => _.toLowerCase().includes(tz_lower)), - ); - } - - public toggleCRON(is_cron: boolean) { - this.form().cron().value.set(''); - this.form() - .time_type() - .value.set( - is_cron - ? TriggerTimeConditionType.CRON - : TriggerTimeConditionType.AT, - ); - this.updateCronString(); - } - - public saveCRON(cron_str: string) { - this.timeout( - 'save_cron', - () => this.form().cron().value.set(cron_str), - 1000, - ); - } - - /** - * Update the output CRON string for the selected periods - */ - public updateCronString() { - const form = this.form(); - if (form && form.cron) { - const hour = this.cron_hour(); - const minute = this.cron_minute() % 60; - const day_of_week = this.cron_day(); - const day_of_month = this.cron_date(); - const month = this.cron_month(); - let cron_str = '* * * * *'; - switch (this.cron_period()) { - case 'minute': - cron_str = minute ? `*/${minute} * * * *` : '* * * * *'; - break; - case 'hour': - cron_str = hour - ? `${minute} */${hour} * * *` - : `${minute} * * * *`; - break; - case 'day': - cron_str = `${minute} ${hour} * * *`; - break; - case 'week': - cron_str = `${minute} ${hour} * * ${day_of_week}`; - break; - case 'month': - cron_str = `${minute} ${hour} ${day_of_month} * *`; - break; - case 'year': - cron_str = `${minute} ${hour} ${day_of_month} ${month} *`; - break; - } - form.cron().value.set(cron_str); - } - } - - private loadCronTab(cron_tab: string): void { - this.cron_string.set(cron_tab); - const cron_string = this.cron_string(); - if ( - cron_string.includes('-') || - cron_string.includes('/') || - cron_string.includes(',') - ) { - this.cron_period.set('custom'); - return; - } - const [minute, hour, day, month, weekday] = cron_tab.split(' '); - this.cron_minute.set(+minute || 0); - this.cron_hour.set(+hour || 0); - this.cron_day.set(+weekday || 0); - this.cron_date.set(+hour || 1); - this.cron_month.set(+month || 1); - this.cron_period.set('minute'); - if (month !== '*') { - this.cron_period.set('month'); - } else if (weekday !== '*') { - this.cron_period.set('week'); - } else if (day !== '*') { - this.cron_period.set('day'); - } else if (hour !== '*') { - this.cron_period.set('hour'); - } - this.cron_hour_period = this.cron_hour() > 12 ? 'PM' : 'AM'; - } -} diff --git a/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts b/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts index cc818948d83..f0eed7725ad 100644 --- a/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts +++ b/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts @@ -1,35 +1,17 @@ -import { - Component, - EventEmitter, - OnInit, - Output, - inject, - signal, -} from '@angular/core'; -import { - disabled, - form, - FormField, -} from '@angular/forms/signals'; +import { Component, inject, signal } from '@angular/core'; +import { form } from '@angular/forms/signals'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { AsyncHandler, DialogEvent, Identity } from '@placeos/common'; +import { AsyncHandler } from '@placeos/common'; import { PlaceAlert, PlaceSystem, - TriggerAtTimeCondition, TriggerComparison, TriggerConditionOperator, - TriggerCronTimeCondition, TriggerStatusVariable, - TriggerTimeCondition, - TriggerTimeConditionType, } from '@placeos/ts-client'; import { FullscreenModalShellComponent } from 'libs/components/src/lib/fullscreen-modal-shell.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { AlertConditionComparisonFormComponent } from './alert-condition-compare-form.component'; -import { AlertConditionTimeFormComponent } from './alert-condition-time-form.component'; export interface AlertConditionData { /** Item to add/update the trigger on */ @@ -37,7 +19,7 @@ export interface AlertConditionData { /** Trigger to add/update */ alert: PlaceAlert; /** Trigger Condition to edit */ - condition?: TriggerComparison | TriggerTimeCondition; + condition?: TriggerComparison; } function validateJSONValue(value: any) { @@ -68,46 +50,29 @@ export function validateCompareValue(value: any) { } export interface AlertConditionFormValue { - condition_type: 'compare' | 'time'; left: any; operator: TriggerConditionOperator; right: any; - time_type: TriggerTimeConditionType; - time: number; - cron: string; - timezone: string; } /** - * Generate form controls for creating a trigger condition + * Generate form controls for creating a trigger comparison condition */ export function generateTriggerConditionForm( - condition: TriggerComparison | TriggerTimeCondition = {} as any, + condition: TriggerComparison = {} as any, ) { - const type = (condition as TriggerTimeCondition).type ? 'time' : 'compare'; const left = - typeof (condition as TriggerComparison).left === 'object' - ? { ...((condition as TriggerComparison).left as any) } - : (condition as TriggerComparison).left; + typeof condition.left === 'object' + ? { ...(condition.left as any) } + : condition.left; const right = - typeof (condition as TriggerComparison).right === 'object' - ? { ...((condition as TriggerComparison).right as any) } - : (condition as TriggerComparison).right; + typeof condition.right === 'object' + ? { ...(condition.right as any) } + : condition.right; return { - condition_type: type as 'compare' | 'time', left: left || { mod: '', status: '', keys: [] }, - operator: - (condition as TriggerComparison).operator || - TriggerConditionOperator.EQ, + operator: condition.operator || TriggerConditionOperator.EQ, right: right || '', - time_type: - (condition as TriggerTimeCondition).type || - TriggerTimeConditionType.AT, - time: - (+(condition as TriggerAtTimeCondition).time || 0) * 1000 || - Date.now(), - cron: (condition as TriggerCronTimeCondition).cron || '', - timezone: (condition as TriggerCronTimeCondition).timezone || '', } satisfies AlertConditionFormValue; } @@ -124,41 +89,10 @@ export function generateTriggerConditionForm( > @if (form) {
- @if (form.condition_type) { -
- - - - @for (type of condition_types; track type) { - - {{ - (type.id === 'compare' - ? 'TRIGGERS.CONDITION_COMPARE' - : 'TRIGGERS.CONDITION_TIME' - ) | translate - }} - - } - - -
- } - @if (model().condition_type === 'compare') { - - } @else { - - } +
} @@ -167,41 +101,24 @@ export function generateTriggerConditionForm( imports: [ FullscreenModalShellComponent, AlertConditionComparisonFormComponent, - AlertConditionTimeFormComponent, - MatFormFieldModule, - MatSelectModule, - FormField, TranslatePipe, ], }) -export class AlertConditionModalComponent - extends AsyncHandler - implements OnInit -{ +export class AlertConditionModalComponent extends AsyncHandler { private _dialog = inject>(MatDialogRef); private _data = inject(MAT_DIALOG_DATA); - /** Emitter for events on the modal */ - @Output() public event = new EventEmitter(); /** Whether actions are loading */ public readonly loading = signal(false); /** Form fields for trigger condition */ public readonly model = signal( generateTriggerConditionForm(this._data.condition), ); - public readonly form = form(this.model, (p) => { - disabled(p.condition_type); - }); + public readonly form = form(this.model); /** Store for updated conditions */ public conditions: any; - /** Types of trigger conditions */ - public condition_types: Identity[] = [ - { id: 'compare', name: 'Compare values' }, - { id: 'time', name: 'Particular time' }, - ]; - /** Whether the triggers is new or not */ public get is_new(): boolean { return !this._data.condition; @@ -217,23 +134,18 @@ export class AlertConditionModalComponent return this._data.alert; } - public ngOnInit() {} - public async save() { if (this.loading()) return; this.form().markAsTouched(); if ( - this.model().condition_type === 'compare' && - (validateCompareValue(this.model().left) || - validateCompareValue(this.model().right)) + validateCompareValue(this.model().left) || + validateCompareValue(this.model().right) ) { return; } if (!this.form().valid()) return; this.loading.set(true); - this.model().condition_type === 'compare' - ? this.updateComparisons() - : this.updateTimeDependents(); + this.updateComparisons(); this._dialog.close(this.conditions); } @@ -271,41 +183,4 @@ export class AlertConditionModalComponent }; this.conditions = updated_conditions; } - - /** - * Update the time dependent list by replace an exisiting item or add a new item - */ - private updateTimeDependents() { - const old_values = [...(this.alert.conditions.time_dependents || [])]; - const model = this.model(); - const new_value = { - type: model.time_type, - time: +(model.time / 1000).toFixed(0), - cron: model.cron, - timezone: model.timezone, - }; - if (new_value.type === TriggerTimeConditionType.CRON) { - delete new_value.time; - } else { - delete new_value.cron; - delete new_value.timezone; - } - new_value.cron ? delete new_value.time : delete new_value.cron; - if (this._data.condition) { - const old_value = JSON.stringify(this._data.condition); - const index = old_values.findIndex( - (time) => JSON.stringify(time) === old_value, - ); - if (index >= 0) { - old_values.splice(index, 1, new_value); - } - } else { - old_values.push(new_value); - } - const updated_conditions = { - ...this.alert.conditions, - time_dependents: old_values, - }; - this.conditions = updated_conditions; - } } diff --git a/apps/stagehand/src/app/dashboards/cron-input-field.component.ts b/apps/stagehand/src/app/dashboards/cron-input-field.component.ts deleted file mode 100644 index 22780ffa003..00000000000 --- a/apps/stagehand/src/app/dashboards/cron-input-field.component.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Component, effect, signal } from '@angular/core'; -import { ControlValueAccessor } from '@angular/forms'; -import { form, FormField, pattern } from '@angular/forms/signals'; -import { provideValueAccessor } from '@placeos/common'; - -const VALID_INPUT = [ - '0', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '*', - '/', - '-', - ',', - 'Backspace', - 'ArrowLeft', - 'ArrowRight', - 'ArrowUp', - 'ArrowDown', - 'Delete', -]; -function listPattern(fieldPattern) { - // One instance of `fieldPattern` plus any number of `,fieldPattern` - return `(?:${fieldPattern})(?:,(?:${fieldPattern}))*`; -} - -@Component({ - selector: 'cron-input-field', - template: ` -
- - - - - -
-
- CRON expression is invalid -
- `, - styles: [``], - providers: [ - provideValueAccessor(() => CronInputFieldComponent), - ], - imports: [FormField], -}) -export class CronInputFieldComponent implements ControlValueAccessor { - public cron_string: string; - public readonly model = signal({ - minute: '*', - hour: '*', - day: '*', - month: '*', - day_of_week: '*', - }); - public readonly form = form(this.model, (p) => { - pattern( - p.minute, - new RegExp( - `^${listPattern( - '(?:\\*(?:/\\d{1,2})?|[0-5]?\\d(?:-[0-5]?\\d)?(?:/\\d{1,2})?)', - )}$`, - ), - ); - pattern( - p.hour, - new RegExp( - `^${listPattern( - '(?:\\*(?:/\\d{1,2})?|(?:[01]?\\d|2[0-3])(?:-(?:[01]?\\d|2[0-3]))?(?:/\\d{1,2})?)', - )}$`, - ), - ); - pattern( - p.day, - new RegExp( - `^${listPattern( - '(?:\\*(?:/\\d{1,2})?|(?:[1-9]|[12]\\d|3[01])(?:-(?:[1-9]|[12]d|3[01]))?(?:/\\d{1,2})?)', - )}$`, - ), - ); - pattern( - p.month, - new RegExp( - `^${listPattern( - '(?:\\*(?:/\\d{1,2})?|(?:[1-9]|1[0-2])(?:-(?:[1-9]|1[0-2]))?(?:/\\d{1,2})?)', - )}$`, - ), - ); - pattern( - p.day_of_week, - new RegExp( - `^${listPattern( - '(?:\\*(?:/\\d{1,2})?|[0-6](?:-[0-6])?(?:/\\d{1,2})?)', - )}$`, - ), - ); - }); - private readonly _form_change = effect(() => this.updateValueFromForm()); - - public readonly registerOnChange = (fn) => (this._onChange = fn); - public readonly registerOnTouched = (fn) => (this._onTouch = fn); - - private _onChange: (_: string) => void; - private _onTouch: (_: string) => void; - - public updateValueFromForm(): void { - const value = this.model(); - if (!this.form().valid()) return; - this.setValue( - `${value.minute || '*'} ${value.hour || '*'} ${ - value.day || '*' - } ${value.month || '*'} ${value.day_of_week || '*'}`, - ); - } - - public setValue(value: string): void { - this.cron_string = value; - if (this._onChange) this._onChange(value); - } - - public writeValue(value: string): void { - if (!value) return; - this.cron_string = value; - const parts = value.split(' '); - this.model.set({ - minute: parts[0] || '*', - hour: parts[1] || '*', - day: parts[2] || '*', - month: parts[3] || '*', - day_of_week: parts[4] || '*', - }); - } - - public preventInvalidCharacters(event: KeyboardEvent): void { - if (!VALID_INPUT.includes(event.key)) event.preventDefault(); - } -} diff --git a/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts b/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts index 78fc293f845..90aeb669196 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts @@ -25,7 +25,6 @@ import { addAlert, TriggerComparison, TriggerConditions, - TriggerTimeCondition, updateAlert, } from '@placeos/ts-client'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -221,42 +220,6 @@ export type AlertType = 'threshold' | 'status' | 'custom'; | translate " > - - -
- {{ row.type === 'at' ? 'At time' : 'CRON' }} - {{ - row.type === 'at' ? row.time : row.cron - }} -
-
({ ...m, ...dash })); } - public removeCondition( - condition: TriggerComparison | TriggerTimeCondition = null, - ) { + public removeCondition(condition: TriggerComparison = null) { const old_conditions = this.model().conditions; + // Keep time dependents that saved alerts can still have const new_conditions: TriggerConditions = { + ...old_conditions, comparisons: old_conditions.comparisons.filter( (_) => _ !== condition, ), - time_dependents: old_conditions.time_dependents.filter( - (_) => _ !== condition, - ), }; this.model.update((m) => ({ ...m, conditions: new_conditions })); } - public async editCondition( - condition: TriggerComparison | TriggerTimeCondition = null, - ) { + public async editCondition(condition: TriggerComparison = null) { const system = this.template_system(); if (!system) return; const ref = this._dialog.open(AlertConditionModalComponent, { diff --git a/apps/stagehand/src/app/dashboards/dashboard-view.component.ts b/apps/stagehand/src/app/dashboards/dashboard-view.component.ts deleted file mode 100644 index 7beaf998e2f..00000000000 --- a/apps/stagehand/src/app/dashboards/dashboard-view.component.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - inject, - OnInit, - signal, -} from '@angular/core'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { ActivatedRoute } from '@angular/router'; -import { AsyncHandler } from '@placeos/common'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { DashboardsService } from './dashboards.service'; - -@Component({ - selector: `stagehand-dashboard-view`, - template: ` -
- - - -
{{ date | date }}
-
- -
{{ desc }}
-
- -
- -
-
-
- `, - styles: [``], - imports: [ - CommonModule, - SimpleTableComponent, - MatProgressBarModule, - TranslatePipe, - IconComponent, - ], -}) -export class DashboardViewComponent extends AsyncHandler implements OnInit { - private _route = inject(ActivatedRoute); - private _service = inject(DashboardsService); - - public readonly setDashboard = (id) => this._service.setDashboard(id); - public readonly search_term = signal(''); - public readonly alerts = this._service.dashboard_alerts; - public readonly loading = signal(true); - - public ngOnInit() { - const id = this._route.snapshot.paramMap.get('id'); - if (!id) return; - this.setDashboard(id); - this.timeout('listen', () => - this._service.listenForDashboardAlerts(), - ); - } -} diff --git a/apps/stagehand/src/app/dashboards/dashboards.component.ts b/apps/stagehand/src/app/dashboards/dashboards.component.ts index d99d7e06a43..94189e61665 100644 --- a/apps/stagehand/src/app/dashboards/dashboards.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboards.component.ts @@ -24,7 +24,7 @@ import { DashboardsService } from './dashboards.service';
- @if (page() === 'alerts' || page() === 'view') { + @if (page() === 'alerts') { @@ -56,20 +54,12 @@ import { DashboardsService } from './dashboards.service';
}
- @if (page() !== 'view') { -
{{ - (page() === 'alerts' - ? 'APP.STAGEHAND.DASHBOARD_ALERTS_ADD' - : 'APP.STAGEHAND.DASHBOARD_ADD' - ) | translate - }} - } + {{ + (page() === 'alerts' + ? 'APP.STAGEHAND.DASHBOARD_ALERTS_ADD' + : 'APP.STAGEHAND.DASHBOARD_ADD' + ) | translate + }}
+
@@ -65,45 +56,6 @@ import { IconComponent } from 'libs/components/src/lib/icon.component';
} - @if (display_list()) { - @if (item_list()?.length) { -
- @for (option of item_list(); track option) { - - } -
- } @else { -
-

- {{ - search_str()?.length - ? 'No matching ' + - (name() || 'item') + - ' for search string' - : 'No ' + - (name() || 'items') + - ' available to search' - }} -

-
- } - } @for (option of item_list(); track option.id) { @@ -146,10 +98,6 @@ import { IconComponent } from 'libs/components/src/lib/icon.component'; width: 100%; } - .disabled { - color: rgba(0, 0, 0, 0.35); - } - .name { display: flex; align-items: center; @@ -179,20 +127,13 @@ import { IconComponent } from 'libs/components/src/lib/icon.component'; }) export class SystemSearchFieldComponent extends AsyncHandler - implements OnInit, OnChanges, ControlValueAccessor + implements OnInit, ControlValueAccessor { - private _changed = signal(0); private _last_query = ''; /** Name of the items being query'd */ public readonly name = input(undefined); /** Placeholder to display on the form input */ public readonly placeholder = input(undefined); - /** Limit available options to these */ - public readonly options = input(undefined); - /** Whether the form field should be disabled */ - public readonly disabled = input(false); - public readonly display_list = input(false); - public readonly clear_on_select = input(false); /** Function for filtering out options */ public readonly exclude = input< (_: PlaceSystem, search: string) => boolean @@ -202,10 +143,8 @@ export class SystemSearchFieldComponent (v.email || '').toLowerCase().indexOf(search) < 0 && (v.description || '').toLowerCase().indexOf(search) < 0, ); - /** Minimum number of characters needed to start a server query */ - public readonly min_length = input(0, { alias: 'minLength' }); /** Whether item list is loading */ - public readonly loading = model(false); + public readonly loading = signal(false); private _groups = inject(SupportGroupsService); /** Service used for searching items. By default, the systems of the * selected support group. */ @@ -236,18 +175,11 @@ export class SystemSearchFieldComponent private readonly _input_el = viewChild>('input'); - public get items() { - const options = this.options(); - return options?.length ? options : this.item_list(); - } - /** Map of item names to their IDs */ public readonly item_name = signal>({}); private readonly _search_query = effect(() => { const query = this.search_str(); - this.options(); - this._changed(); this.timeout('search', () => this._loadSearchResults(query), 400); }); @@ -257,22 +189,11 @@ export class SystemSearchFieldComponent }); } - public ngOnChanges(changes: SimpleChanges): void { - if (changes.options) this._changed.set(Date.now()); - } - private async _loadSearchResults(query: string) { - if (query === this._last_query && !this.options()?.length) return; + if (query === this._last_query) return; this._last_query = query; this.loading.set(true); - const options = this.options(); - const min_length = this.min_length(); - const raw_list = - options && options.length > 0 - ? options - : !min_length || query.length >= min_length - ? await this.query_fn()(query).catch(() => []) - : []; + const raw_list = await this.query_fn()(query).catch(() => []); if (query !== this.search_str()) return; const search = this.search_str().toLowerCase(); const exclude = this.exclude(); @@ -291,10 +212,7 @@ export class SystemSearchFieldComponent this.timeout( 'value', () => { - if (this.clear_on_select()) { - this.active_item.set(null); - this.search_str.set(''); - } else if (this.active_item()) { + if (this.active_item()) { this.search_str.set( this.active_item().name || this.search_str(), ); @@ -349,7 +267,7 @@ export class SystemSearchFieldComponent private _updateNameMap() { const map = {}; - const list = this.items || []; + const list = this.item_list(); for (const item of list) { // shown as text, so a name can't add markup to the page map[item.id] = diff --git a/apps/stagehand/src/app/push-notification.service.ts b/apps/stagehand/src/app/push-notification.service.ts index 6575d9b745a..e453b32aab9 100644 --- a/apps/stagehand/src/app/push-notification.service.ts +++ b/apps/stagehand/src/app/push-notification.service.ts @@ -63,12 +63,6 @@ export class AlertNotificationService extends BasePushNotificationService { }); } - /** Update the entire notification config */ - public setConfig(config: AlertNotificationConfig): void { - this.config.set(config); - this._saveConfig(config); - } - /** Check if notifications should be sent for a given severity */ public shouldNotify(severity: AlertSeverity): boolean { const is_enabled = this.enabled(); diff --git a/apps/stagehand/src/app/recorder-view.component.ts b/apps/stagehand/src/app/recorder-view.component.ts index 593efef36ba..2a4763dd257 100644 --- a/apps/stagehand/src/app/recorder-view.component.ts +++ b/apps/stagehand/src/app/recorder-view.component.ts @@ -66,7 +66,6 @@ interface RecorderStreamMetadata { export class RecorderGridViewComponent extends AsyncHandler implements OnInit { private _org = inject(OrganisationService); - public readonly time = signal(Date.now()); public readonly images = signal([]); public domain = ''; public api_path = ''; @@ -108,7 +107,6 @@ export class RecorderGridViewComponent extends AsyncHandler implements OnInit { } public refresh() { - this.time.set(Date.now()); const image_list = []; for (const image of this.image_data) { const { name, ip_address, id } = image; diff --git a/apps/stagehand/src/app/remote-support.component.ts b/apps/stagehand/src/app/remote-support.component.ts index c3ca38820bc..ef80346ef0c 100644 --- a/apps/stagehand/src/app/remote-support.component.ts +++ b/apps/stagehand/src/app/remote-support.component.ts @@ -12,7 +12,6 @@ import { MatRippleModule } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; @@ -32,7 +31,6 @@ import { SimpleTableComponent, ViewportVisibilityComponent, } from '@placeos/components'; -import { startOfMonth } from 'date-fns'; import { CameraSnapshotModalComponent } from './camera-snapshot-modal.component'; import { Alert } from './dashboards/broker-alerts'; import { DashboardsService } from './dashboards/dashboards.service'; @@ -86,20 +84,6 @@ import { SidebarComponent } from './ui/sidebar.component'; {{ room_alerts().length }} -
@@ -282,58 +266,6 @@ import { SidebarComponent } from './ui/sidebar.component'; }
- -
- @for ( - mic of data; - let idx = $index; - track idx - ) { -
- {{ mic.icon || 'mic' }} -
-
- {{ mic.value }}% -
- - @if (mic.value > 75) { - battery_full_alt - } @else if (mic.value > 50) { - battery_horiz_075 - } @else if (mic.value > 25) { - battery_horiz_050 - } @else if (mic.value > 10) { - battery_low - } @else { - battery_very_low - } - -
-
- } -
-
@let feed_url = cameraSnapshotFeed(space); @if (feed_url) { @@ -481,7 +413,6 @@ import { SidebarComponent } from './ui/sidebar.component'; imports: [ CommonModule, MatRippleModule, - MatMenuModule, IconComponent, BindingDirective, SimpleTableComponent, @@ -510,11 +441,6 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { public readonly alerts = this._dashboard.dashboard_alerts; public readonly alert_sort = (a: unknown[], b: unknown[]) => (a?.length || 0) - (b?.length || 0); - public readonly critical_alerts = computed( - () => - this.room_alerts().filter((alert) => alert.severity === 'critical') - .length, - ); public readonly search_term = signal(''); public readonly status = signal>({}); @@ -560,11 +486,6 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { (!bld_id && rm.zones.includes(r_id)), ); }); - public readonly new_rooms = computed(() => - this.room_list().filter( - (rm) => rm.created_at * 1000 > startOfMonth(Date.now()).valueOf(), - ), - ); public readonly room_data = computed(() => { // alerts by room, so each room doesn't scan every alert const issues = new Map(); diff --git a/apps/stagehand/src/app/support-groups.service.ts b/apps/stagehand/src/app/support-groups.service.ts index 87e79253ca9..2447b363464 100644 --- a/apps/stagehand/src/app/support-groups.service.ts +++ b/apps/stagehand/src/app/support-groups.service.ts @@ -18,7 +18,6 @@ import { showZone, } from '@placeos/ts-client'; import { - groupHierarchy, GroupScope, groupScope, queryAll, @@ -108,8 +107,6 @@ export async function loadGroupScope(group_id: string): Promise { providedIn: 'root', }) export class SupportGroupsService { - private readonly _groups_change = signal(0); - public readonly current_user = userSignal(); public readonly is_sys_admin = computed(() => { const user: Partial> = @@ -143,7 +140,6 @@ export class SupportGroupsService { ? { email: this.current_user()?.email || '', view_all: this.can_view_all(), - change: this._groups_change(), } : undefined, loader: async ({ params }) => { @@ -192,13 +188,6 @@ export class SupportGroupsService { ({ group }) => group.id === this.selected_group_id(), ), ); - /** Selected group and its ancestors, root first */ - public readonly selected_hierarchy = computed(() => - groupHierarchy( - this.selected_group()?.group, - this.groups().map(({ group }) => group), - ), - ); /** ID of the selected group, empty for "All groups" */ public readonly group_id = computed( () => this.selected_group()?.group.id || '', @@ -253,11 +242,6 @@ export class SupportGroupsService { effect(() => persistSelectedGroupId(this.selected_group_id())); } - /** Load the groups again */ - public reloadGroups() { - this._groups_change.update((count) => count + 1); - } - /** Switch group. An empty ID picks "All groups", for those allowed it. */ public setSelectedGroup(group_id: string) { const allowed = group_id diff --git a/apps/stagehand/src/tests/alert-condition-time-form.component.spec.ts b/apps/stagehand/src/tests/alert-condition-time-form.component.spec.ts deleted file mode 100644 index 906f646d8f2..00000000000 --- a/apps/stagehand/src/tests/alert-condition-time-form.component.spec.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { signal } from '@angular/core'; -import { ComponentFixtureAutoDetect } from '@angular/core/testing'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { TIMEZONES_IANA } from '@placeos/common/timezones'; -import { TriggerTimeConditionType } from '@placeos/ts-client'; - -import { - AlertConditionTimeFormComponent, - numberToPosition, -} from '../app/dashboards/alert-condition-time-form.component'; - -/** Build a mock signal-form field whose value can be read and set */ -function makeField(initial: unknown) { - const value = signal(initial); - const state = { value }; - return () => state; -} - -/** Build a mock time-condition form tree matching what the component reads */ -function makeTimeForm( - opts: { - time_type?: unknown; - cron?: string; - timezone?: string; - time?: number; - } = {}, -) { - return { - time_type: makeField(opts.time_type ?? TriggerTimeConditionType.AT), - cron: makeField(opts.cron ?? ''), - timezone: makeField(opts.timezone ?? ''), - time: makeField(opts.time ?? Date.now()), - }; -} - -describe('AlertConditionTimeFormComponent', () => { - let spectator: Spectator; - - const create_component = createComponentFactory({ - component: AlertConditionTimeFormComponent, - shallow: true, - // Disable the zoneless auto-tick: these tests assert on component - // signals/methods, not the DOM. Auto change-detection would render the - // experimental signal-forms + mat-select template and emit benign async - // rxjs/material errors that vitest reports as unhandled. - providers: [{ provide: ComponentFixtureAutoDetect, useValue: false }], - }); - - beforeEach(() => { - vi.clearAllMocks(); - spectator = create_component({ detectChanges: false }); - spectator.component.ngOnInit(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - describe('numberToPosition', () => { - it('applies the correct ordinal suffix', () => { - expect(numberToPosition(1)).toBe('1st'); - expect(numberToPosition(2)).toBe('2nd'); - expect(numberToPosition(3)).toBe('3rd'); - expect(numberToPosition(4)).toBe('4th'); - expect(numberToPosition(21)).toBe('21st'); - expect(numberToPosition(22)).toBe('22nd'); - expect(numberToPosition(23)).toBe('23rd'); - }); - - it('uses "th" for the 11-19 range regardless of last digit', () => { - expect(numberToPosition(11)).toBe('11th'); - expect(numberToPosition(12)).toBe('12th'); - expect(numberToPosition(13)).toBe('13th'); - }); - }); - - it('zero-pads values to the requested width', () => { - expect(spectator.component.pad(5)).toBe('05'); - expect(spectator.component.pad(12)).toBe('12'); - expect(spectator.component.pad(5, 3)).toBe('005'); - }); - - it('populates repeat periods, weekdays and months on init', () => { - expect(spectator.component.repeat_period().map((_) => _.id)).toEqual([ - 'minute', - 'hour', - 'day', - 'week', - 'month', - 'year', - 'custom', - ]); - expect(spectator.component.days_of_week()).toHaveLength(7); - expect(spectator.component.months_of_year()).toHaveLength(12); - }); - - describe('updateTimezoneList', () => { - it('filters IANA timezones by a case-insensitive substring', () => { - spectator.component.updateTimezoneList('sydney'); - expect(spectator.component.timezones()).toContain('Australia/Sydney'); - expect( - spectator.component - .timezones() - .every((_) => _.toLowerCase().includes('sydney')), - ).toBe(true); - }); - - it('returns the full list for an empty query', () => { - spectator.component.updateTimezoneList(''); - expect(spectator.component.timezones()).toHaveLength( - TIMEZONES_IANA.length, - ); - }); - }); - - describe('toggleCRON', () => { - it('switches the form to a CRON schedule and writes a cron string', () => { - const form = makeTimeForm(); - spectator.fixture.componentRef.setInput('form', form); - - spectator.component.toggleCRON(true); - - expect(form.time_type().value()).toBe(TriggerTimeConditionType.CRON); - expect(form.cron().value()).toBe('* * * * *'); - }); - - it('switches the form back to a fixed time', () => { - const form = makeTimeForm({ time_type: TriggerTimeConditionType.CRON }); - spectator.fixture.componentRef.setInput('form', form); - - spectator.component.toggleCRON(false); - - expect(form.time_type().value()).toBe(TriggerTimeConditionType.AT); - }); - }); - - describe('updateCronString', () => { - let form: any; - beforeEach(() => { - form = makeTimeForm(); - spectator.fixture.componentRef.setInput('form', form); - spectator.component.cron_minute.set(30); - spectator.component.cron_hour.set(9); - }); - - it('produces an every-N-minutes expression for the minute period', () => { - spectator.component.cron_period.set('minute'); - spectator.component.updateCronString(); - expect(form.cron().value()).toBe('*/30 * * * *'); - - spectator.component.cron_minute.set(0); - spectator.component.updateCronString(); - expect(form.cron().value()).toBe('* * * * *'); - }); - - it('produces an hourly expression', () => { - spectator.component.cron_period.set('hour'); - spectator.component.updateCronString(); - expect(form.cron().value()).toBe('30 */9 * * *'); - }); - - it('produces a daily expression at the chosen time', () => { - spectator.component.cron_period.set('day'); - spectator.component.updateCronString(); - expect(form.cron().value()).toBe('30 9 * * *'); - }); - - it('produces a monthly expression on the chosen date', () => { - spectator.component.cron_period.set('month'); - spectator.component.cron_date.set(15); - spectator.component.updateCronString(); - expect(form.cron().value()).toBe('30 9 15 * *'); - }); - - it('emits the selected weekday for a weekly expression', () => { - spectator.component.cron_period.set('week'); - spectator.component.cron_day.set(3); - spectator.component.updateCronString(); - expect(form.cron().value()).toBe('30 9 * * 3'); - }); - - it('emits the selected month for a yearly expression', () => { - spectator.component.cron_period.set('year'); - spectator.component.cron_date.set(15); - spectator.component.cron_month.set(5); - spectator.component.updateCronString(); - expect(form.cron().value()).toBe('30 9 15 5 *'); - }); - }); - - it('saves a custom cron string to the form after a debounce', () => { - vi.useFakeTimers(); - const form = makeTimeForm(); - spectator.fixture.componentRef.setInput('form', form); - - spectator.component.saveCRON('*/5 * * * *'); - expect(form.cron().value()).toBe(''); - - vi.advanceTimersByTime(1000); - expect(form.cron().value()).toBe('*/5 * * * *'); - }); - - describe('ngOnChanges', () => { - it('parses a simple cron expression into schedule fields', () => { - const form = makeTimeForm({ - time_type: TriggerTimeConditionType.CRON, - cron: '30 9 * * *', - timezone: 'Australia/Sydney', - }); - spectator.fixture.componentRef.setInput('form', form); - - spectator.component.ngOnChanges({ form: { firstChange: true } as any }); - - expect(spectator.component.is_cron()).toBe(true); - expect(spectator.component.cron_minute()).toBe(30); - expect(spectator.component.cron_hour()).toBe(9); - expect(spectator.component.cron_period()).toBe('hour'); - expect(spectator.component.timezones()).toContain('Australia/Sydney'); - }); - - it('detects a custom cron expression', () => { - const form = makeTimeForm({ - time_type: TriggerTimeConditionType.CRON, - cron: '*/15 * * * *', - }); - spectator.fixture.componentRef.setInput('form', form); - - spectator.component.ngOnChanges({ form: { firstChange: true } as any }); - - expect(spectator.component.cron_period()).toBe('custom'); - expect(spectator.component.cron_string()).toBe('*/15 * * * *'); - }); - - it('stays as a fixed-time condition when the form is not CRON', () => { - const form = makeTimeForm({ time_type: TriggerTimeConditionType.AT }); - spectator.fixture.componentRef.setInput('form', form); - - spectator.component.ngOnChanges({ form: { firstChange: true } as any }); - - expect(spectator.component.is_cron()).toBe(false); - }); - }); -}); diff --git a/apps/stagehand/src/tests/alerts.component.spec.ts b/apps/stagehand/src/tests/alerts.component.spec.ts index 9fe075bd072..1b8bc51ac50 100644 --- a/apps/stagehand/src/tests/alerts.component.spec.ts +++ b/apps/stagehand/src/tests/alerts.component.spec.ts @@ -97,7 +97,6 @@ describe('AlertsComponent', () => { ]); expect(spectator.component.critical_alerts()).toBe(1); expect(spectator.component.warning_alerts()).toBe(1); - expect(spectator.component.info_alerts()).toBe(2); expect(spectator.component.open_alerts()).toBe(3); }); diff --git a/apps/stagehand/src/tests/cron-input-field.component.spec.ts b/apps/stagehand/src/tests/cron-input-field.component.spec.ts deleted file mode 100644 index e8ba94b009a..00000000000 --- a/apps/stagehand/src/tests/cron-input-field.component.spec.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; - -import { CronInputFieldComponent } from '../app/dashboards/cron-input-field.component'; - -describe('CronInputFieldComponent', () => { - let spectator: Spectator; - let component: CronInputFieldComponent; - - const create_component = createComponentFactory({ - component: CronInputFieldComponent, - detectChanges: false, - }); - - beforeEach(() => { - spectator = create_component(); - component = spectator.component; - }); - - it('should create the component', () => { - expect(component).toBeTruthy(); - }); - - it('should split an incoming CRON string into its field model', () => { - component.writeValue('5 10 1 2 3'); - expect(component.model()).toEqual({ - minute: '5', - hour: '10', - day: '1', - month: '2', - day_of_week: '3', - }); - expect(component.cron_string).toBe('5 10 1 2 3'); - }); - - it('should ignore an empty value on writeValue', () => { - component.writeValue(''); - expect(component.model()).toEqual({ - minute: '*', - hour: '*', - day: '*', - month: '*', - day_of_week: '*', - }); - }); - - it('should default missing CRON parts to wildcards', () => { - component.writeValue('30'); - expect(component.model()).toEqual({ - minute: '30', - hour: '*', - day: '*', - month: '*', - day_of_week: '*', - }); - }); - - it('should emit the joined CRON string when the form is valid', () => { - const on_change = vi.fn(); - component.registerOnChange(on_change); - component.model.set({ - minute: '5', - hour: '10', - day: '1', - month: '2', - day_of_week: '3', - }); - - component.updateValueFromForm(); - - expect(on_change).toHaveBeenCalledWith('5 10 1 2 3'); - expect(component.cron_string).toBe('5 10 1 2 3'); - }); - - it('should not emit while the form is invalid', () => { - const on_change = vi.fn(); - component.registerOnChange(on_change); - // hour 99 is outside the 0-23 range -> invalid - component.model.set({ - minute: '5', - hour: '99', - day: '1', - month: '2', - day_of_week: '3', - }); - - component.updateValueFromForm(); - - expect(on_change).not.toHaveBeenCalled(); - }); - - it('should call the registered change handler on setValue', () => { - const on_change = vi.fn(); - component.registerOnChange(on_change); - component.setValue('* * * * *'); - expect(on_change).toHaveBeenCalledWith('* * * * *'); - expect(component.cron_string).toBe('* * * * *'); - }); - - it('should block keystrokes that are not valid CRON characters', () => { - const event = { - key: 'a', - preventDefault: vi.fn(), - } as unknown as KeyboardEvent; - component.preventInvalidCharacters(event); - expect(event.preventDefault).toHaveBeenCalled(); - }); - - it('should allow valid CRON characters through', () => { - const event = { - key: '5', - preventDefault: vi.fn(), - } as unknown as KeyboardEvent; - component.preventInvalidCharacters(event); - expect(event.preventDefault).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/stagehand/src/tests/dashboard.component.spec.ts b/apps/stagehand/src/tests/dashboard.component.spec.ts deleted file mode 100644 index 0e713208d6d..00000000000 --- a/apps/stagehand/src/tests/dashboard.component.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; - -import { DashboardComponent } from '../app/dashboard.component'; -import { SidebarComponent } from '../app/ui/sidebar.component'; - -describe('DashboardComponent', () => { - let spectator: Spectator; - - const create_component = createComponentFactory({ - component: DashboardComponent, - declarations: [MockComponent(SidebarComponent)], - }); - - beforeEach(() => { - spectator = create_component(); - }); - - it('should create the component', () => { - expect(spectator.component).toBeTruthy(); - }); - - it('should render the sidebar', () => { - expect(spectator.query('sidebar')).toBeTruthy(); - }); - - it('should render the page heading and report action', () => { - expect(spectator.query('h1')).toHaveText('AV Systems Monitor'); - const button = spectator.query('button[btn]'); - expect(button).toHaveText('Generate report'); - }); -}); diff --git a/apps/stagehand/src/tests/dashboards/alert-conditions-modal.component.spec.ts b/apps/stagehand/src/tests/dashboards/alert-conditions-modal.component.spec.ts index a0c83cc7158..c926cf0e49f 100644 --- a/apps/stagehand/src/tests/dashboards/alert-conditions-modal.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/alert-conditions-modal.component.spec.ts @@ -1,10 +1,7 @@ import { ComponentFixtureAutoDetect } from '@angular/core/testing'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { - TriggerConditionOperator, - TriggerTimeConditionType, -} from '@placeos/ts-client'; +import { TriggerConditionOperator } from '@placeos/ts-client'; import { MockProvider } from 'ng-mocks'; import { @@ -16,30 +13,17 @@ import { describe('generateTriggerConditionForm', () => { it('should default to a compare condition with an empty status variable', () => { const value = generateTriggerConditionForm(); - expect(value.condition_type).toBe('compare'); expect(value.operator).toBe(TriggerConditionOperator.EQ); expect(value.left).toEqual({ mod: '', status: '', keys: [] }); expect(value.right).toBe(''); }); - it('should detect a time condition from the condition type', () => { - const value = generateTriggerConditionForm({ - type: TriggerTimeConditionType.AT, - time: 10, - }); - expect(value.condition_type).toBe('time'); - expect(value.time_type).toBe(TriggerTimeConditionType.AT); - // stored seconds are converted to milliseconds - expect(value.time).toBe(10000); - }); - it('should preserve an existing comparison condition', () => { const value = generateTriggerConditionForm({ left: { mod: 'Display_1', status: 'power', keys: [] }, operator: TriggerConditionOperator.NEQ, right: true, }); - expect(value.condition_type).toBe('compare'); expect(value.left).toEqual({ mod: 'Display_1', status: 'power', @@ -149,7 +133,6 @@ describe('AlertConditionModalComponent', () => { spectator = build({ system: {}, alert }); spectator.component.model.set({ ...spectator.component.model(), - condition_type: 'compare', left: '"connected"', operator: TriggerConditionOperator.EQ, right: 'false', @@ -178,7 +161,6 @@ describe('AlertConditionModalComponent', () => { spectator = build({ system: {}, alert, condition: existing }); spectator.component.model.set({ ...spectator.component.model(), - condition_type: 'compare', left: '"connected"', operator: TriggerConditionOperator.NEQ, right: 'true', @@ -195,29 +177,6 @@ describe('AlertConditionModalComponent', () => { }); }); - it('should build a time-dependent condition on save', async () => { - const alert = base_alert(); - spectator = build({ system: {}, alert }); - spectator.component.model.set({ - ...spectator.component.model(), - condition_type: 'time', - time_type: TriggerTimeConditionType.AT, - time: 5000, - }); - - await spectator.component.save(); - - expect(dialog_close).toHaveBeenCalledTimes(1); - const conditions = dialog_close.mock.calls[0][0]; - expect(conditions.time_dependents).toHaveLength(1); - expect(conditions.time_dependents[0]).toEqual( - expect.objectContaining({ - type: TriggerTimeConditionType.AT, - time: 5, - }), - ); - }); - it('should ignore save while an action is already loading', async () => { spectator = build({ system: {}, alert: base_alert() }); spectator.component.loading.set(true); diff --git a/apps/stagehand/src/tests/dashboards/dashboard-view.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-view.component.spec.ts deleted file mode 100644 index f71e506a19e..00000000000 --- a/apps/stagehand/src/tests/dashboards/dashboard-view.component.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { signal } from '@angular/core'; -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { DashboardViewComponent } from '../../app/dashboards/dashboard-view.component'; -import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; - -describe('DashboardViewComponent', () => { - let spectator: SpectatorRouting; - let service: any; - - const create_component = createRoutingFactory({ - component: DashboardViewComponent, - shallow: true, - declarations: [ - MockComponent(SimpleTableComponent), - MockComponent(IconComponent), - ], - providers: [MockProvider(DashboardsService)], - }); - - function build(route_options = {}) { - spectator = create_component({ - providers: [MockProvider(DashboardsService, service)], - ...route_options, - }); - return spectator; - } - - beforeEach(() => { - service = { - dashboard_alerts: signal([ - { id: 'a-1', name: 'Offline', time: 1710000000 }, - ]), - setDashboard: vi.fn(), - listenForDashboardAlerts: vi.fn(), - }; - }); - - afterEach(() => vi.useRealTimers()); - - it('should select the dashboard from the route id', () => { - build({ params: { id: 'dash-9' } }); - - expect(service.setDashboard).toHaveBeenCalledWith('dash-9'); - }); - - it('should do nothing when there is no dashboard id in the route', () => { - build(); - - expect(service.setDashboard).not.toHaveBeenCalled(); - }); - - it('should start listening for dashboard alerts after init', () => { - vi.useFakeTimers(); - build({ params: { id: 'dash-9' } }); - - expect(service.listenForDashboardAlerts).not.toHaveBeenCalled(); - vi.advanceTimersByTime(300); - - expect(service.listenForDashboardAlerts).toHaveBeenCalled(); - }); - - it('should feed the dashboard alerts into the table', () => { - build({ params: { id: 'dash-9' } }); - const table = spectator.query(SimpleTableComponent); - - expect(table.data).toEqual(service.dashboard_alerts()); - }); - - it('should show the progress bar while the initial load is pending', () => { - build({ params: { id: 'dash-9' } }); - - expect(spectator.component.loading()).toBe(true); - expect(spectator.query('mat-progress-bar')?.classList).not.toContain( - 'opacity-0', - ); - }); - - it('should hide the progress bar once loading completes', () => { - build({ params: { id: 'dash-9' } }); - spectator.component.loading.set(false); - spectator.detectChanges(); - - expect(spectator.query('mat-progress-bar')?.classList).toContain( - 'opacity-0', - ); - }); -}); diff --git a/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts index bc2b5bcea34..b8631c38293 100644 --- a/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts @@ -51,22 +51,6 @@ describe('DashboardsComponent', () => { ).toBeTruthy(); }); - it('should show the back navigation on the view page', () => { - spectator.component.page.set('view'); - spectator.detectChanges(); - - expect( - spectator.query('a[aria-label="Back to dashboards"]'), - ).toBeTruthy(); - }); - - it('should hide the add action while viewing a dashboard', () => { - spectator.component.page.set('view'); - spectator.detectChanges(); - - expect(spectator.query('a[btn]')).toBeFalsy(); - }); - it('should show the add action on the listing and alerts pages', () => { spectator.component.page.set('dashboards'); spectator.detectChanges(); diff --git a/apps/stagehand/src/tests/push-notification.service.spec.ts b/apps/stagehand/src/tests/push-notification.service.spec.ts index f8bf3d3b677..0be08241475 100644 --- a/apps/stagehand/src/tests/push-notification.service.spec.ts +++ b/apps/stagehand/src/tests/push-notification.service.spec.ts @@ -73,20 +73,6 @@ describe('AlertNotificationService', () => { expect(JSON.parse(localStorage.getItem(STORAGE_KEY)).high).toBe(true); }); - it('should replace the whole config via setConfig and persist it', () => { - build(); - const next = { - critical: false, - high: true, - medium: true, - low: true, - }; - service.setConfig(next); - - expect(service.config()).toEqual(next); - expect(JSON.parse(localStorage.getItem(STORAGE_KEY))).toEqual(next); - }); - it('should never notify while notifications are disabled', () => { build(); // permission is not granted -> enabled() is false @@ -97,7 +83,7 @@ describe('AlertNotificationService', () => { it('should notify only for enabled severities once enabled', () => { build(); service.permission.set('granted'); - service.setConfig({ + service.config.set({ critical: true, high: false, medium: false, @@ -112,7 +98,7 @@ describe('AlertNotificationService', () => { build(); service.permission.set('granted'); const notify = vi.spyOn(service, 'notify').mockReturnValue({} as any); - service.setConfig({ + service.config.set({ critical: false, high: false, medium: false, @@ -156,7 +142,7 @@ describe('AlertNotificationService', () => { build(); service.permission.set('granted'); const notify = vi.spyOn(service, 'notify').mockReturnValue({} as any); - service.setConfig({ + service.config.set({ critical: false, high: true, medium: false, diff --git a/apps/stagehand/src/tests/remote-support.component.spec.ts b/apps/stagehand/src/tests/remote-support.component.spec.ts index 3d68a28d2b3..927604398a9 100644 --- a/apps/stagehand/src/tests/remote-support.component.spec.ts +++ b/apps/stagehand/src/tests/remote-support.component.spec.ts @@ -167,7 +167,6 @@ describe('RemoteSupportComponent', () => { 'al-1', 'al-2', ]); - expect(spectator.component.critical_alerts()).toBe(1); }); it('should filter rooms by the selected state', () => { diff --git a/apps/stagehand/src/tests/system-search-field.component.spec.ts b/apps/stagehand/src/tests/system-search-field.component.spec.ts index 3d40cb901ba..391b69e5d20 100644 --- a/apps/stagehand/src/tests/system-search-field.component.spec.ts +++ b/apps/stagehand/src/tests/system-search-field.component.spec.ts @@ -30,19 +30,6 @@ describe('SystemSearchFieldComponent', () => { expect(component).toBeTruthy(); }); - it('should prefer the provided options over the loaded item list', () => { - const options = [makeSystem({ id: 'sys-1', name: 'Provided' })]; - spectator.setInput('options', options); - component.item_list.set([makeSystem({ id: 'sys-2', name: 'Loaded' })]); - expect(component.items).toBe(options); - }); - - it('should fall back to the loaded item list when no options given', () => { - const loaded = [makeSystem({ id: 'sys-2', name: 'Loaded' })]; - component.item_list.set(loaded); - expect(component.items).toBe(loaded); - }); - it('should exclude items that do not match by name, email or description', () => { const exclude = component.exclude(); const match = makeSystem({ id: 's', name: 'Meeting Room', email: '' }); @@ -98,16 +85,4 @@ describe('SystemSearchFieldComponent', () => { expect(name?.textContent).toBe(''); expect(name?.querySelector('img')).toBeNull(); }); - - it('should skip the server query when below the minimum length', async () => { - const query_fn = vi.fn().mockResolvedValue([]); - spectator.setInput('query_fn', query_fn); - spectator.setInput('minLength', 3); - component.search_str.set('me'); - - await (component as any)._loadSearchResults('me'); - - expect(query_fn).not.toHaveBeenCalled(); - expect(component.item_list()).toEqual([]); - }); }); diff --git a/apps/survey/src/app/app.component.ts b/apps/survey/src/app/app.component.ts index 11c7ac83d3c..3ce2a219e89 100644 --- a/apps/survey/src/app/app.component.ts +++ b/apps/survey/src/app/app.component.ts @@ -70,7 +70,6 @@ const START_QUERY = location.search; ], }) export class AppComponent extends AsyncHandler implements OnInit { - protected title = 'survey'; private _org = inject(OrganisationService); private _settings = inject(SettingsService); private _hotkey = inject(HotkeysService); @@ -165,7 +164,6 @@ export class AppComponent extends AsyncHandler implements OnInit { ); return; } - this.clearTimeout('wait_for_user'); this._initLocale(); markInitialisationComplete(); } diff --git a/apps/survey/src/app/survey.component.ts b/apps/survey/src/app/survey.component.ts index 91bcc1dbd9f..ff6fc17ca1f 100644 --- a/apps/survey/src/app/survey.component.ts +++ b/apps/survey/src/app/survey.component.ts @@ -1,5 +1,5 @@ import { Component, inject } from '@angular/core'; -import { Router, RouterModule } from '@angular/router'; +import { Router } from '@angular/router'; import { SurveyOutletComponent } from '@placeos/components'; @@ -11,7 +11,7 @@ import { SurveyOutletComponent } from '@placeos/components';
`, styles: [], - imports: [SurveyOutletComponent, RouterModule], + imports: [SurveyOutletComponent], }) export class SurveyComponent { private _router = inject(Router); diff --git a/docs/settings/outlook-addin.md b/docs/settings/outlook-addin.md index 34027d0c55d..81043cf4481 100644 --- a/docs/settings/outlook-addin.md +++ b/docs/settings/outlook-addin.md @@ -1,15 +1,15 @@ # Outlook Add-in App Settings -The Outlook Add-in brings PlaceOS room booking into Microsoft Outlook. From the add-in pane, staff can find and book meeting rooms for their calendar events, book desks, and view their upcoming bookings — without leaving Outlook. +The Outlook Add-in adds PlaceOS rooms, desks and parking to Microsoft Outlook calendar events. The add-in pane reads the event time from Outlook. Staff add rooms to the event, and book desks and parking for the event, without leaving Outlook. Set settings in Backoffice zone metadata under `outlook-addin_app` for the standard `/outlook-addin/` URL. Use the organisation, region or building zone. See [settings storage and priority](README.md#settings-storage-and-priority). The examples below show the metadata details object, without an `app` wrapper. -All keys below are relative to the metadata details object unless noted otherwise. For example, the key `events.max_duration` means: +All keys below are relative to the metadata details object unless noted otherwise. For example, the key `desks.use_assets` means: ```json { - "events": { - "max_duration": 240 + "desks": { + "use_assets": true } } ``` @@ -41,57 +41,16 @@ These options live at the root of the settings file (alongside `app`, not inside |---------|------|---------|-------------| | `internal_user_domain` | string | – | Email domain used to decide whether a person is internal staff or an external visitor (e.g. `"@company.com"`). Attendees whose email doesn't match this domain are treated as external. When not set, the domain of the signed-in user's email address is used. | -## Meeting Booking Form +## Desks -These settings control the form shown when booking a meeting room for a calendar event (keys under `events`). - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `events.has_catering` | boolean | `true` | Show the Catering step on the meeting form, letting the organiser add catering orders to the booking. | -| `events.catering_enabled` | boolean | – | Alias for `events.has_catering`. The form shows the Catering step if either key is `true`. | -| `events.can_book_for_others` | boolean | `false` | Show a Host field on the meeting form so the person booking can select someone else as the meeting host. | -| `events.allow_all_day` | boolean | – | Show an "All Day" checkbox next to the date field. Defaults to off. | -| `events.hide_attendees` | boolean | – | Hide the Attendees step of the meeting form. Defaults to off (attendees are shown). | -| `events.allow_international` | boolean | – | Show a checkbox next to the attendee list that flags each attendee as international. The flag is stored on the event and shown in the visitor report for synced visitor bookings. | -| `events.allow_attendee_internal_or_external` | boolean | – | Show a control next to the attendee list that marks each attendee as internal (default) or external. The type is stored on the event and shown in the visitor report for synced visitor bookings. | -| `events.hide_notes` | boolean | – | Hide the Notes step of the meeting form. Defaults to off (notes are shown). | -| `events.min_duration` | number | – | Minimum meeting duration in minutes. Defaults to `30`. Also affects the spacing of selectable start times. | -| `events.max_duration` | number | – | Maximum meeting duration in minutes. Defaults to `480` (8 hours). | -| `events.bookable_hours` | object | – | Restrict start and end times to a window of the day, as 24-hour clock hours. When not set, any time of day can be selected. | -| `events.custom_duration_options` | number[] | – | Replace the standard duration dropdown options with your own list of durations, in minutes. A value smaller than `events.min_duration` also lowers the effective minimum. | -| `events.allow_assets` | boolean | – | Show the Assets (equipment request) step on the meeting form. Defaults to off. | -| `events.multiple_spaces` | boolean | `false` | When `true`, users can select more than one room in the meeting form. The event form then uses the timezone of the event, or the browser timezone, and ignores `events.use_building_timezone`. | -| `events.allow_multiple_spaces` | boolean | – | Legacy alias for `events.multiple_spaces`. The setting applies if either key is `true`. | -| `events.use_building_timezone` | boolean | – | Use the timezone of the active building for meeting times. When the selected room is in a different building, the add-in saves the timezone of the room's building with the event. Has no effect when `events.multiple_spaces` is `true`. | - -Example — restrict meetings to office hours with fixed duration choices: - -```json -{ - "events": { - "bookable_hours": { "start": 7, "end": 19 }, - "min_duration": 15, - "max_duration": 240, - "custom_duration_options": [15, 30, 45, 60, 90, 120] - } -} -``` - -## Desk Booking Form - -These settings control the desk booking form (keys under `desks`). +These settings control the Desks tab (keys under `desks`). | Setting | Type | Default | Description | |---------|------|---------|-------------| | `desks.use_assets` | boolean | `false` | Read desk resources through the assets API. When `false`, Outlook Add-in uses the legacy `desks` zone metadata. | -| `desks.allow_all_day` | boolean | – | Show an "All Day" checkbox on the desk booking form. Defaults to off. | -| `desks.can_book_lockers` | boolean | – | Show a "Require locker" checkbox so a locker can be requested along with the desk. Defaults to off. | -| `desks.allow_assets` | boolean | – | Show a Request Asset step on the desk booking form for requesting equipment with the booking. Defaults to off. | | `desks.all_day_period` | object | – | Time window for all-day desk bookings, as hours of the day, e.g. `{ "start": 8, "end": 18 }`. The desk search shows this window for an all-day event. When not set, an all-day booking uses the full day. | | `desks.hide_user` | boolean | – | Hide the desk details on the desk map, such as the name of the person who booked the desk. Defaults to off. | -Desk booking times are otherwise fixed by the app: durations from 1 to 10 hours in 1-hour steps. - The booking form service reads `desks.all_day_period` from these keys in order, and uses the first value that is set: `desk.all_day_period`, `desks.all_day_period`, then `bookings.all_day_period`. Parking settings that the service reads use the same order, with `parking`, `parkings` and `bookings`. ## Parking @@ -111,9 +70,3 @@ The calendar pane shows a Parking tab when `features` turns on parking or parkin | `space_display.show_images` | boolean | `false` | Show the first photo of each space in the room search list. | | `use_region` | boolean | – | List the levels of the whole active region on the room and desk maps, instead of only the active building. The map shows the building name under each level. | | `explore.colors` | object | Status colours for spaces and zones | Map fill colours by status. The room map reads `space-free` and `space-busy`. The desk map reads `desk-free`, `desk-busy` and `desk-not-bookable`. Each map then tries the key without the prefix, such as `free`, and then a built-in colour. | - -## Other Behaviour - -| Setting | Type | Default | Description | -|---------|------|---------|-------------| -| `has_uploads` | boolean | – | Enable the file uploads service on startup (cloud storage for attached files). Leave off unless your PlaceOS instance is configured for uploads. | diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index cf9a1a352bc..3e41d3adf1a 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -83,14 +83,7 @@ import { newCalendarEventFromBooking, } from './utilities'; -const BOOKING_URLS = [ - 'book/rooms', - 'book/spaces', - 'book/meeting', - 'schedule/view', - 'confirm/success', - 'upcoming', -]; +const BOOKING_URLS = ['book/rooms', 'book/spaces', 'book/meeting']; enum Tags { Availability = 'AVAILABILITY', diff --git a/shared/assets/locale/ar.json b/shared/assets/locale/ar.json index 11a73af87f7..4026abecf96 100644 --- a/shared/assets/locale/ar.json +++ b/shared/assets/locale/ar.json @@ -2263,7 +2263,6 @@ "MEDIA_URL_ARIA": "رابط الوسائط", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "الرسالة", - "MINUTES_BETWEEN_PLAYS": "الدقائق بين مرات التشغيل", "MONTHLY": "شهري", "MONTHLY_BY_WEEKDAY": "شهري حسب يوم الأسبوع", "MORE": "المزيد", @@ -2422,8 +2421,6 @@ "SUMMARY_DAILY": "يبدأ كل يوم عند {{ time }} لمدة {{ duration }}.", "SUMMARY_EVERY_HOUR": "يعمل كل {{ interval }} ساعة.", "SUMMARY_EVERY_HOUR_1": "يعمل كل {{ interval }} ساعة.", - "SUMMARY_EVERY_MINUTE": "يعمل كل {{ interval }} دقيقة.", - "SUMMARY_EVERY_MINUTE_1": "يعمل كل {{ interval }} دقيقة.", "SUMMARY_MONTHLY": "يبدأ في {{ days }} من كل شهر عند {{ time }} لمدة {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "يبدأ في {{ weeks }} {{ days }} من كل شهر عند {{ time }} لمدة {{ duration }}.", "SUMMARY_PLAY_ONCE": "يعمل مرة واحدة في {{ datetime }} لمدة {{ duration }}", @@ -2441,7 +2438,6 @@ "SVC_DISPLAY_ADDED_ZONE": "تمت إضافة الشاشة إلى المنطقة", "SVC_DISPLAY_IN_ZONE": "الشاشة معينة بالفعل لهذه المنطقة.", "SVC_DISPLAY_REMOVED_PLAYLIST": "تمت إزالة الشاشة من قائمة التشغيل", - "SVC_DISPLAY_REMOVED_ZONE": "تمت إزالة الشاشة من المنطقة", "SVC_ERR_ADD_USER": "خطأ في إضافة مستخدم المجموعة", "SVC_ERR_ADD_ZONE": "خطأ في إضافة منطقة المجموعة", "SVC_ERR_CONVERT_IMAGE": "تعذر تحويل الصورة", diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index faf93ea4431..61ddf686eab 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -62,11 +62,9 @@ "IMAGE_GEN_NO_ORG_ZONE": "This domain has no organisation zone, so a logo cannot be saved", "IMAGE_GEN_OPTION": "Generated option", "IMAGE_GEN_OPTIONS_COUNT": "Options to generate", - "IMAGE_GEN_PICK_ONE": "Pick one", "IMAGE_GEN_POS_BOTTOM": "Bottom", "IMAGE_GEN_POS_BOTTOM_LEFT": "Bottom left", "IMAGE_GEN_POS_BOTTOM_RIGHT": "Bottom right", - "IMAGE_GEN_POS_CENTRE": "Centre", "IMAGE_GEN_POS_TOP": "Top", "IMAGE_GEN_POS_TOP_LEFT": "Top left", "IMAGE_GEN_POS_TOP_RIGHT": "Top right", @@ -75,7 +73,6 @@ "IMAGE_GEN_REFINE": "Ask for a change", "IMAGE_GEN_REFINE_ACTION": "Refine", "IMAGE_GEN_REFINE_HINT": "Make the background darker", - "IMAGE_GEN_REFINE_NOTE": "Every version stays available above, so you can always go back to one you liked.", "IMAGE_GEN_INCLUDE_IMAGES": "Images to include", "IMAGE_GEN_INCLUDE_IMAGES_HINT": "Attach pictures that should appear in the artwork. They are arranged to look good on their own; name them in your words above as image 1, image 2 and so on for finer control.", "IMAGE_GEN_STYLE_REFERENCE": "Style reference", @@ -101,9 +98,7 @@ "IMAGE_GEN_TEXT_BRAND_FONT": "Default", "IMAGE_GEN_TEXT_DRAG_HINT": "Drag the words on the image to put them where you want them. Arrow keys nudge, hold shift to move further. Enter starts a new line.", "IMAGE_GEN_TEXT_FONT": "Font", - "IMAGE_GEN_TEXT_COLOUR": "Colour", "IMAGE_GEN_TEXT_PANEL": "Shade behind the text", - "IMAGE_GEN_TEXT_POSITION": "Text position", "IMAGE_GEN_TEXT_SIZE": "Size", "IMAGE_GEN_USE_BRANDING": "Use our branding", "IMAGE_GEN_USE_BRANDING_HINT": "The organisation's colours, font and tone shape the image, and its palette is offered for the words. Turn it off for a poster that is not meant to look like us.", @@ -330,7 +325,6 @@ "MEDIA_URL_ARIA": "Media URL", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "Message", - "MINUTES_BETWEEN_PLAYS": "Minutes between plays", "MONTHLY": "Monthly", "MONTHLY_BY_WEEKDAY": "Monthly by weekday", "MORE": "More", @@ -542,7 +536,6 @@ "REQUEST_PLAYLIST_APPROVAL_TOOLTIP": "Request playlist approval", "REQUEST_TEMPLATE_APPROVAL_TOOLTIP": "Request template approval", "SCHEDULE": "Schedule", - "SCHEDULED_TEMPLATE": "Scheduled template", "SCHEDULE_INTERVAL_ARIA": "Recurring schedule interval", "SCHEDULE_NUMBER": "Schedule {{ number }}", "SCHEDULE_TYPES": "Schedule types", @@ -600,8 +593,6 @@ "SUMMARY_DAILY": "Starts every day at {{ time }} for {{ duration }}.", "SUMMARY_EVERY_HOUR": "Plays every {{ interval }} hours.", "SUMMARY_EVERY_HOUR_1": "Plays every {{ interval }} hour.", - "SUMMARY_EVERY_MINUTE": "Plays every {{ interval }} minutes.", - "SUMMARY_EVERY_MINUTE_1": "Plays every {{ interval }} minute.", "SUMMARY_MONTHLY": "Starts on the {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Starts on the {{ weeks }} {{ days }} of each month at {{ time }} for {{ duration }}.", "DISPLAY_LOCAL_TIME": "display local time", @@ -622,7 +613,6 @@ "SVC_DISPLAY_IN_ZONE": "Display already assigned to this zone.", "SVC_DISPLAY_REMOVED": "Display removed", "SVC_DISPLAY_REMOVED_PLAYLIST": "Display removed from playlist", - "SVC_DISPLAY_REMOVED_ZONE": "Display removed from zone", "SVC_DISPLAY_REMOVE_ERROR": "Could not remove the display. Please try again.", "SVC_DISPLAY_SAVED": "Display saved", "SVC_DISPLAY_SAVE_ERROR": "Could not save the display. Please try again.", @@ -800,9 +790,6 @@ "TEMPLATE_NO_PLUGIN": "No plugin", "TEMPLATE_PANEL_HEIGHT": "Height", "TEMPLATE_PANEL_WIDTH": "Width", - "TEMPLATE_PARAMS_INVALID": "Parameters must be valid JSON", - "TEMPLATE_PLAYER_PREVIEW": "Open in player", - "TEMPLATE_PLAYER_PREVIEW_UNAVAILABLE": "Player preview is not available yet", "TEMPLATE_POSITION_BOTTOM": "Footer", "TEMPLATE_POSITION_FLOATING": "Floating", "TEMPLATE_POSITION_LEFT": "Left Sidebar", @@ -935,13 +922,9 @@ "DISABLED": "Disabled", "CODE": "Code", "BOOK": "Book", - "MINUTE": "Minute", - "HOUR": "Hour", "DAY": "Day", "WEEK": "Week", "MONTH": "Month", - "YEAR": "Year", - "CRON_CUSTOM": "Custom", "TODAY": "Today", "MAP": "Map", "LIST": "List", @@ -3550,20 +3533,8 @@ "TRIGGERS": { "CONDITION_NEW": "New Condition", "CONDITION_EDIT": "Edit Condition", - "CONDITION_FIELD_TYPE": "Condition Type", - "CONDITION_COMPARE": "Compare Values", - "CONDITION_TIME": "Particular Time", "CONDITION_COMPARE_EMPTY": "No variable comparision conditions for this alert", - "CONDITION_TIME_EMPTY": "No time dependant conditions for this alert", "FIELD_VAR_COMPARE": "Variable Comparison Condition", - "FIELD_TIME_DEPS": "Time dependant Condition", - "TIME_SCHEDULE": "Recurring Schedule", - "TIME_REPEAT": "Repeat Every", - "TIME_DAY_OF_MONTH": "Day of Month", - "TIME_MONTH_OF_YEAR": "Month of Year", - "TIME_DAY_OF_WEEK": "Day of Week", - "TIME_HOUR_OF_DAY": "Hour of the day", - "TIME_MINUTE_OF_HOUR": "Minute of the hour", "COMPARE_CONSTANT": "Constant Value", "COMPARE_VARIABLE": "Status Variable", "COMPARE_VARIABLE_SELECT": "Select status variable", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index fd295c46ea5..55b6ebe6767 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -2506,7 +2506,6 @@ "MEDIA_URL_ARIA": "Media URL", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "Message", - "MINUTES_BETWEEN_PLAYS": "Minutes between plays", "MONTHLY": "Monthly", "MONTHLY_BY_WEEKDAY": "Monthly by weekday", "MORE": "More", @@ -2703,7 +2702,6 @@ "REQUEST_APPROVAL_SELECTED": "Request approval for selected playlist", "REQUEST_PLAYLIST_APPROVAL_TOOLTIP": "Request playlist approval", "SCHEDULE": "Schedule", - "SCHEDULED_TEMPLATE": "Scheduled template", "EDIT_SCHEDULE": "Edit Schedule", "ITEM_SCHEDULES": "Item Schedules", "NO_SCHEDULES": "No schedules", @@ -2761,8 +2759,6 @@ "SUMMARY_DAILY": "Starts every day at {{ time }} for {{ duration }}.", "SUMMARY_EVERY_HOUR": "Plays every {{ interval }} hours.", "SUMMARY_EVERY_HOUR_1": "Plays every {{ interval }} hour.", - "SUMMARY_EVERY_MINUTE": "Plays every {{ interval }} minutes.", - "SUMMARY_EVERY_MINUTE_1": "Plays every {{ interval }} minute.", "SUMMARY_MONTHLY": "Starts on the {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Starts on the {{ weeks }} {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_PLAY_ONCE": "Plays once on {{ datetime }} for {{ duration }}", @@ -2781,7 +2777,6 @@ "SVC_DISPLAY_ADDED_ZONE": "Display added to zone", "SVC_DISPLAY_IN_ZONE": "Display already assigned to this zone.", "SVC_DISPLAY_REMOVED_PLAYLIST": "Display removed from playlist", - "SVC_DISPLAY_REMOVED_ZONE": "Display removed from zone", "SVC_DISPLAY_REMOVE_ERROR": "Could not remove the display. Please try again.", "SVC_DISPLAY_SAVE_ERROR": "Could not save the display. Please try again.", "SVC_ERR_ADD_PLAYLIST_ITEMS": "Error adding media to the playlist", @@ -2925,9 +2920,6 @@ "TEMPLATE_NO_PLUGIN": "No plugin", "TEMPLATE_PANEL_HEIGHT": "Height", "TEMPLATE_PANEL_WIDTH": "Width", - "TEMPLATE_PARAMS_INVALID": "Parameters must be valid JSON", - "TEMPLATE_PLAYER_PREVIEW": "Open in player", - "TEMPLATE_PLAYER_PREVIEW_UNAVAILABLE": "Player preview is not available yet", "TEMPLATE_POSITION_BOTTOM": "Footer", "TEMPLATE_POSITION_FLOATING": "Floating", "TEMPLATE_POSITION_LEFT": "Left Sidebar", diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index 837b3455bbb..844c93e5ab1 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -2526,7 +2526,6 @@ "MEDIA_URL_ARIA": "Media URL", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "Message", - "MINUTES_BETWEEN_PLAYS": "Minutes between plays", "MONTHLY": "Monthly", "MONTHLY_BY_WEEKDAY": "Monthly by weekday", "MORE": "More", @@ -2732,7 +2731,6 @@ "REQUEST_APPROVAL_SELECTED": "Request approval for selected playlist", "REQUEST_PLAYLIST_APPROVAL_TOOLTIP": "Request playlist approval", "SCHEDULE": "Schedule", - "SCHEDULED_TEMPLATE": "Scheduled template", "EDIT_SCHEDULE": "Edit Schedule", "ITEM_SCHEDULES": "Item Schedules", "NO_SCHEDULES": "No schedules", @@ -2790,8 +2788,6 @@ "SUMMARY_DAILY": "Starts every day at {{ time }} for {{ duration }}.", "SUMMARY_EVERY_HOUR": "Plays every {{ interval }} hours.", "SUMMARY_EVERY_HOUR_1": "Plays every {{ interval }} hour.", - "SUMMARY_EVERY_MINUTE": "Plays every {{ interval }} minutes.", - "SUMMARY_EVERY_MINUTE_1": "Plays every {{ interval }} minute.", "SUMMARY_MONTHLY": "Starts on the {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Starts on the {{ weeks }} {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_PLAY_ONCE": "Plays once on {{ datetime }} for {{ duration }}", @@ -2812,7 +2808,6 @@ "SVC_DISPLAY_REMOVED": "Display removed", "SVC_DISPLAY_REMOVE_ERROR": "Could not remove the display. Please try again.", "SVC_DISPLAY_REMOVED_PLAYLIST": "Display removed from playlist", - "SVC_DISPLAY_REMOVED_ZONE": "Display removed from zone", "SVC_DISPLAY_SAVED": "Display saved", "SVC_DISPLAY_SAVE_ERROR": "Could not save the display. Please try again.", "SVC_ERR_ADD_PLAYLIST_ITEMS": "Error adding media to the playlist", @@ -2975,9 +2970,6 @@ "TEMPLATE_NO_PLUGIN": "No plugin", "TEMPLATE_PANEL_HEIGHT": "Height", "TEMPLATE_PANEL_WIDTH": "Width", - "TEMPLATE_PARAMS_INVALID": "Parameters must be valid JSON", - "TEMPLATE_PLAYER_PREVIEW": "Open in player", - "TEMPLATE_PLAYER_PREVIEW_UNAVAILABLE": "Player preview is not available yet", "TEMPLATE_POSITION_BOTTOM": "Footer", "TEMPLATE_POSITION_FLOATING": "Floating", "TEMPLATE_POSITION_LEFT": "Left Sidebar", diff --git a/shared/assets/locale/es.json b/shared/assets/locale/es.json index c76d35775ce..83bdb338437 100644 --- a/shared/assets/locale/es.json +++ b/shared/assets/locale/es.json @@ -2263,7 +2263,6 @@ "MEDIA_URL_ARIA": "URL del medio", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "Mensaje", - "MINUTES_BETWEEN_PLAYS": "Minutos entre reproducciones", "MONTHLY": "Mensual", "MONTHLY_BY_WEEKDAY": "Mensual por día de la semana", "MORE": "Más", @@ -2422,8 +2421,6 @@ "SUMMARY_DAILY": "Empieza todos los días a las {{ time }} durante {{ duration }}.", "SUMMARY_EVERY_HOUR": "Se reproduce cada {{ interval }} horas.", "SUMMARY_EVERY_HOUR_1": "Se reproduce cada {{ interval }} hora.", - "SUMMARY_EVERY_MINUTE": "Se reproduce cada {{ interval }} minutos.", - "SUMMARY_EVERY_MINUTE_1": "Se reproduce cada {{ interval }} minuto.", "SUMMARY_MONTHLY": "Empieza el día {{ days }} de cada mes a las {{ time }} durante {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Empieza el {{ weeks }} {{ days }} de cada mes a las {{ time }} durante {{ duration }}.", "SUMMARY_PLAY_ONCE": "Se reproduce una vez el {{ datetime }} durante {{ duration }}", @@ -2441,7 +2438,6 @@ "SVC_DISPLAY_ADDED_ZONE": "Pantalla añadida a la zona", "SVC_DISPLAY_IN_ZONE": "La pantalla ya está asignada a esta zona.", "SVC_DISPLAY_REMOVED_PLAYLIST": "Pantalla quitada de la lista de reproducción", - "SVC_DISPLAY_REMOVED_ZONE": "Pantalla quitada de la zona", "SVC_ERR_ADD_USER": "Error al añadir usuario al grupo", "SVC_ERR_ADD_ZONE": "Error al añadir zona al grupo", "SVC_ERR_CONVERT_IMAGE": "No se pudo convertir la imagen", diff --git a/shared/assets/locale/fr-CA.json b/shared/assets/locale/fr-CA.json index e338d096420..946ef331aeb 100644 --- a/shared/assets/locale/fr-CA.json +++ b/shared/assets/locale/fr-CA.json @@ -2263,7 +2263,6 @@ "MEDIA_URL_ARIA": "URL du média", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "Message", - "MINUTES_BETWEEN_PLAYS": "Minutes entre les lectures", "MONTHLY": "Mensuel", "MONTHLY_BY_WEEKDAY": "Mensuel par jour de semaine", "MORE": "Plus", @@ -2422,8 +2421,6 @@ "SUMMARY_DAILY": "Commence tous les jours à {{ time }} pendant {{ duration }}.", "SUMMARY_EVERY_HOUR": "Se lit toutes les {{ interval }} heures.", "SUMMARY_EVERY_HOUR_1": "Se lit toutes les {{ interval }} heure.", - "SUMMARY_EVERY_MINUTE": "Se lit toutes les {{ interval }} minutes.", - "SUMMARY_EVERY_MINUTE_1": "Se lit toutes les {{ interval }} minute.", "SUMMARY_MONTHLY": "Commence le {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Commence le {{ weeks }} {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_PLAY_ONCE": "Se lit une fois le {{ datetime }} pendant {{ duration }}", @@ -2441,7 +2438,6 @@ "SVC_DISPLAY_ADDED_ZONE": "Écran ajouté à la zone", "SVC_DISPLAY_IN_ZONE": "Écran déjà attribué à cette zone.", "SVC_DISPLAY_REMOVED_PLAYLIST": "Écran retiré de la liste de lecture", - "SVC_DISPLAY_REMOVED_ZONE": "Écran retiré de la zone", "SVC_ERR_ADD_USER": "Erreur lors de l'ajout de l'utilisateur au groupe", "SVC_ERR_ADD_ZONE": "Erreur lors de l'ajout de la zone au groupe", "SVC_ERR_CONVERT_IMAGE": "Impossible de convertir l'image", diff --git a/shared/assets/locale/fr.json b/shared/assets/locale/fr.json index b9f06005dbe..3fd1defc4bd 100644 --- a/shared/assets/locale/fr.json +++ b/shared/assets/locale/fr.json @@ -2263,7 +2263,6 @@ "MEDIA_URL_ARIA": "URL du média", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "Message", - "MINUTES_BETWEEN_PLAYS": "Minutes entre les lectures", "MONTHLY": "Mensuel", "MONTHLY_BY_WEEKDAY": "Mensuel par jour de semaine", "MORE": "Plus", @@ -2422,8 +2421,6 @@ "SUMMARY_DAILY": "Commence tous les jours à {{ time }} pendant {{ duration }}.", "SUMMARY_EVERY_HOUR": "Se lit toutes les {{ interval }} heures.", "SUMMARY_EVERY_HOUR_1": "Se lit toutes les {{ interval }} heure.", - "SUMMARY_EVERY_MINUTE": "Se lit toutes les {{ interval }} minutes.", - "SUMMARY_EVERY_MINUTE_1": "Se lit toutes les {{ interval }} minute.", "SUMMARY_MONTHLY": "Commence le {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Commence le {{ weeks }} {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_PLAY_ONCE": "Se lit une fois le {{ datetime }} pendant {{ duration }}", @@ -2441,7 +2438,6 @@ "SVC_DISPLAY_ADDED_ZONE": "Écran ajouté à la zone", "SVC_DISPLAY_IN_ZONE": "Écran déjà attribué à cette zone.", "SVC_DISPLAY_REMOVED_PLAYLIST": "Écran retiré de la liste de lecture", - "SVC_DISPLAY_REMOVED_ZONE": "Écran retiré de la zone", "SVC_ERR_ADD_USER": "Erreur lors de l'ajout de l'utilisateur au groupe", "SVC_ERR_ADD_ZONE": "Erreur lors de l'ajout de la zone au groupe", "SVC_ERR_CONVERT_IMAGE": "Impossible de convertir l'image", diff --git a/shared/assets/locale/it.json b/shared/assets/locale/it.json index 98be53e7e79..d582c479d4e 100644 --- a/shared/assets/locale/it.json +++ b/shared/assets/locale/it.json @@ -2263,7 +2263,6 @@ "MEDIA_URL_ARIA": "URL media", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "Messaggio", - "MINUTES_BETWEEN_PLAYS": "Minuti tra le riproduzioni", "MONTHLY": "Mensile", "MONTHLY_BY_WEEKDAY": "Mensile per giorno della settimana", "MORE": "Altro", @@ -2422,8 +2421,6 @@ "SUMMARY_DAILY": "Inizia ogni giorno alle {{ time }} per {{ duration }}.", "SUMMARY_EVERY_HOUR": "Riproduce ogni {{ interval }} ore.", "SUMMARY_EVERY_HOUR_1": "Riproduce ogni {{ interval }} ora.", - "SUMMARY_EVERY_MINUTE": "Riproduce ogni {{ interval }} minuti.", - "SUMMARY_EVERY_MINUTE_1": "Riproduce ogni {{ interval }} minuto.", "SUMMARY_MONTHLY": "Inizia il {{ days }} di ogni mese alle {{ time }} per {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Inizia il {{ weeks }} {{ days }} di ogni mese alle {{ time }} per {{ duration }}.", "SUMMARY_PLAY_ONCE": "Riproduce una volta il {{ datetime }} per {{ duration }}", @@ -2441,7 +2438,6 @@ "SVC_DISPLAY_ADDED_ZONE": "Schermo aggiunto alla zona", "SVC_DISPLAY_IN_ZONE": "Schermo già assegnato a questa zona.", "SVC_DISPLAY_REMOVED_PLAYLIST": "Schermo rimosso dalla lista di riproduzione", - "SVC_DISPLAY_REMOVED_ZONE": "Schermo rimosso dalla zona", "SVC_ERR_ADD_USER": "Errore durante l'aggiunta dell'utente al gruppo", "SVC_ERR_ADD_ZONE": "Errore durante l'aggiunta della zona al gruppo", "SVC_ERR_CONVERT_IMAGE": "Impossibile convertire l'immagine", diff --git a/shared/assets/locale/ja-JP.json b/shared/assets/locale/ja-JP.json index feb680b8fed..3e2e855fdbd 100644 --- a/shared/assets/locale/ja-JP.json +++ b/shared/assets/locale/ja-JP.json @@ -2263,7 +2263,6 @@ "MEDIA_URL_ARIA": "メディア URL", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "メッセージ", - "MINUTES_BETWEEN_PLAYS": "再生間隔(分)", "MONTHLY": "毎月", "MONTHLY_BY_WEEKDAY": "曜日指定の毎月", "MORE": "その他", @@ -2422,8 +2421,6 @@ "SUMMARY_DAILY": "毎日 {{ time }} に開始し、{{ duration }} 継続します。", "SUMMARY_EVERY_HOUR": "{{ interval }} 時間ごとに再生します。", "SUMMARY_EVERY_HOUR_1": "{{ interval }} 時間ごとに再生します。", - "SUMMARY_EVERY_MINUTE": "{{ interval }} 分ごとに再生します。", - "SUMMARY_EVERY_MINUTE_1": "{{ interval }} 分ごとに再生します。", "SUMMARY_MONTHLY": "毎月 {{ days }} 日の {{ time }} に開始し、{{ duration }} 継続します。", "SUMMARY_MONTHLY_WEEKDAY": "毎月第 {{ weeks }} {{ days }} の {{ time }} に開始し、{{ duration }} 継続します。", "SUMMARY_PLAY_ONCE": "{{ datetime }} に 1 回再生し、{{ duration }} 継続します", @@ -2441,7 +2438,6 @@ "SVC_DISPLAY_ADDED_ZONE": "ディスプレイをゾーンに追加しました", "SVC_DISPLAY_IN_ZONE": "ディスプレイは既にこのゾーンに割り当てられています。", "SVC_DISPLAY_REMOVED_PLAYLIST": "ディスプレイをプレイリストから削除しました", - "SVC_DISPLAY_REMOVED_ZONE": "ディスプレイをゾーンから削除しました", "SVC_ERR_ADD_USER": "グループユーザーの追加中にエラーが発生しました", "SVC_ERR_ADD_ZONE": "グループゾーンの追加中にエラーが発生しました", "SVC_ERR_CONVERT_IMAGE": "画像を変換できません", diff --git a/shared/assets/locale/pt.json b/shared/assets/locale/pt.json index 040004d28f4..0537404462a 100644 --- a/shared/assets/locale/pt.json +++ b/shared/assets/locale/pt.json @@ -2263,7 +2263,6 @@ "MEDIA_URL_ARIA": "URL da multimédia", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "Mensagem", - "MINUTES_BETWEEN_PLAYS": "Minutos entre reproduções", "MONTHLY": "Mensal", "MONTHLY_BY_WEEKDAY": "Mensal por dia da semana", "MORE": "Mais", @@ -2422,8 +2421,6 @@ "SUMMARY_DAILY": "Começa todos os dias às {{ time }} durante {{ duration }}.", "SUMMARY_EVERY_HOUR": "Reproduz a cada {{ interval }} horas.", "SUMMARY_EVERY_HOUR_1": "Reproduz a cada {{ interval }} hora.", - "SUMMARY_EVERY_MINUTE": "Reproduz a cada {{ interval }} minutos.", - "SUMMARY_EVERY_MINUTE_1": "Reproduz a cada {{ interval }} minuto.", "SUMMARY_MONTHLY": "Começa no dia {{ days }} de cada mês às {{ time }} durante {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Começa no {{ weeks }} {{ days }} de cada mês às {{ time }} durante {{ duration }}.", "SUMMARY_PLAY_ONCE": "Reproduz uma vez em {{ datetime }} durante {{ duration }}", @@ -2441,7 +2438,6 @@ "SVC_DISPLAY_ADDED_ZONE": "Ecrã adicionado à zona", "SVC_DISPLAY_IN_ZONE": "O ecrã já está atribuído a esta zona.", "SVC_DISPLAY_REMOVED_PLAYLIST": "Ecrã removido da lista de reprodução", - "SVC_DISPLAY_REMOVED_ZONE": "Ecrã removido da zona", "SVC_ERR_ADD_USER": "Erro ao adicionar utilizador ao grupo", "SVC_ERR_ADD_ZONE": "Erro ao adicionar zona ao grupo", "SVC_ERR_CONVERT_IMAGE": "Não foi possível converter a imagem", diff --git a/shared/assets/locale/zh.json b/shared/assets/locale/zh.json index e6b65a7ca98..c80eee3b668 100644 --- a/shared/assets/locale/zh.json +++ b/shared/assets/locale/zh.json @@ -2264,7 +2264,6 @@ "MEDIA_URL_ARIA": "媒体 URL", "MEDIA_VIEW_ARIA": "Media view mode", "MESSAGE": "消息", - "MINUTES_BETWEEN_PLAYS": "播放间隔分钟数", "MONTHLY": "每月", "MONTHLY_BY_WEEKDAY": "按星期每月重复", "MORE": "更多", @@ -2423,8 +2422,6 @@ "SUMMARY_DAILY": "每天 {{ time }} 开始,持续 {{ duration }}。", "SUMMARY_EVERY_HOUR": "每 {{ interval }} 小时播放一次。", "SUMMARY_EVERY_HOUR_1": "每 {{ interval }} 小时播放一次。", - "SUMMARY_EVERY_MINUTE": "每 {{ interval }} 分钟播放一次。", - "SUMMARY_EVERY_MINUTE_1": "每 {{ interval }} 分钟播放一次。", "SUMMARY_MONTHLY": "每月 {{ days }} 日 {{ time }} 开始,持续 {{ duration }}。", "SUMMARY_MONTHLY_WEEKDAY": "每月第 {{ weeks }} 个 {{ days }} 的 {{ time }} 开始,持续 {{ duration }}。", "SUMMARY_PLAY_ONCE": "在 {{ datetime }} 播放一次,持续 {{ duration }}", @@ -2442,7 +2439,6 @@ "SVC_DISPLAY_ADDED_ZONE": "显示屏已添加到区域", "SVC_DISPLAY_IN_ZONE": "显示屏已分配给此区域。", "SVC_DISPLAY_REMOVED_PLAYLIST": "显示屏已从播放列表移除", - "SVC_DISPLAY_REMOVED_ZONE": "显示屏已从区域移除", "SVC_ERR_ADD_USER": "添加组用户时出错", "SVC_ERR_ADD_ZONE": "添加组区域时出错", "SVC_ERR_CONVERT_IMAGE": "无法转换图片", From 0eebd34e3e2b151631ba1bd4da22a40a9b3df131 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:57 +1100 Subject: [PATCH 04/10] refactor(signage): share player rules with the signage manager The cron rules, schedule mask check, play-once parsing, media animation, template layout and preview message names move to libs/common/src/lib/signage. The signage manager now uses the player's rules, so it no longer calls a schedule playable when the player will not play it. --- .../src/app/media/media-view.util.ts | 19 +- .../app/schedules/signage-schedule.util.ts | 27 ++- .../app/shared/media-edit-modal.component.ts | 2 +- .../shared/media-preview-modal.component.ts | 2 +- .../playlist-schedule-form.component.ts | 41 ++-- .../template-approval-preview.component.ts | 9 +- .../app/shared/template-mappings.component.ts | 12 +- .../src/app/signage-cron.util.ts | 136 +----------- .../src/app/signage-playlist.util.ts | 139 ++++-------- .../src/app/templates/template-layout.util.ts | 133 ++---------- .../templates/template-preview.component.ts | 29 ++- .../displays/display-schedule.util.spec.ts | 7 +- .../src/tests/media/media-view.util.spec.ts | 21 -- .../schedules/signage-schedule.util.spec.ts | 9 +- .../src/tests/signage-cron.util.spec.ts | 61 +----- .../src/tests/signage-playlist.util.spec.ts | 34 +-- .../template-layout-list.component.spec.ts | 4 +- .../templates/template-layout.util.spec.ts | 150 ++----------- apps/signage/src/app/cron-helpers.ts | 204 +++--------------- apps/signage/src/app/media-helpers.ts | 17 -- apps/signage/src/app/signage.service.ts | 52 +---- apps/signage/src/app/template.component.ts | 21 +- apps/signage/src/tests/media-helpers.spec.ts | 17 -- .../signage/src/tests/template-layout.spec.ts | 78 ------- libs/common/src/index.ts | 5 +- libs/common/src/lib/signage/cron.spec.ts | 93 ++++++++ libs/common/src/lib/signage/cron.ts | 133 ++++++++++++ libs/common/src/lib/signage/playlist.spec.ts | 77 +++++++ libs/common/src/lib/signage/playlist.ts | 84 ++++++++ libs/common/src/lib/signage/template.spec.ts | 140 ++++++++++++ .../common/src/lib/signage/template.ts | 57 +++-- 31 files changed, 770 insertions(+), 1043 deletions(-) delete mode 100644 apps/signage/src/tests/template-layout.spec.ts create mode 100644 libs/common/src/lib/signage/cron.spec.ts create mode 100644 libs/common/src/lib/signage/cron.ts create mode 100644 libs/common/src/lib/signage/playlist.spec.ts create mode 100644 libs/common/src/lib/signage/playlist.ts create mode 100644 libs/common/src/lib/signage/template.spec.ts rename apps/signage/src/app/template-layout.ts => libs/common/src/lib/signage/template.ts (52%) diff --git a/apps/signage-manager/src/app/media/media-view.util.ts b/apps/signage-manager/src/app/media/media-view.util.ts index 30a349f656f..a8a0a0d3593 100644 --- a/apps/signage-manager/src/app/media/media-view.util.ts +++ b/apps/signage-manager/src/app/media/media-view.util.ts @@ -1,4 +1,4 @@ -import { MediaAnimation, type SignageMedia } from '@placeos/ts-client'; +import { type SignageMedia } from '@placeos/ts-client'; export const MEDIA_SORTS = ['newest', 'oldest', 'name', 'expiry'] as const; export const MEDIA_TYPE_FILTERS = [ @@ -49,23 +49,6 @@ function mediaTypeGroup(item: Pick) { } } -const MEDIA_ANIMATIONS = Object.values(MediaAnimation); - -/** - * Animation of a media item as the enum. The API takes the animation name, - * but returns the index of the name, such as 2 for cross fade. Anything - * else is the default animation. - */ -export function mediaAnimation(value: unknown): MediaAnimation { - if (typeof value === 'number') { - return MEDIA_ANIMATIONS[value] ?? MediaAnimation.Default; - } - return ( - MEDIA_ANIMATIONS.find((animation) => animation === value) ?? - MediaAnimation.Default - ); -} - /** Whether the options differ from the default newest-first, unfiltered view */ export function isMediaViewActive(options: MediaViewOptions) { return ( diff --git a/apps/signage-manager/src/app/schedules/signage-schedule.util.ts b/apps/signage-manager/src/app/schedules/signage-schedule.util.ts index 7f25cf9e3a9..d90ae56a61e 100644 --- a/apps/signage-manager/src/app/schedules/signage-schedule.util.ts +++ b/apps/signage-manager/src/app/schedules/signage-schedule.util.ts @@ -1,5 +1,11 @@ -import { i18n } from '@placeos/common'; -import { SignagePlaylist } from '@placeos/ts-client'; +import { + cronDaySlots, + cronParts, + doesCronMatchDay, + i18n, + isPlayOnceSchedule, +} from '@placeos/common'; +import { SignagePlaylist, SignagePlaylistSchedule } from '@placeos/ts-client'; import { addDays, differenceInCalendarDays, @@ -7,15 +13,8 @@ import { isSameDay, startOfDay, } from 'date-fns'; -import { - cronDaySlots, - cronParts, - doesCronMatchDay, -} from '../signage-cron.util'; import { createScheduleMaskFilter, - isPlayOnceSchedule, - type PlaylistSchedule, playOnceStart, } from '../signage-playlist.util'; @@ -91,7 +90,7 @@ export interface ScheduleItem { } /** Schedule of a playlist with no schedules */ -const ALL_DAY_SCHEDULE: Partial = { +const ALL_DAY_SCHEDULE: Partial = { play_cron: '0 0 * * *', play_period: MINUTES_PER_DAY, }; @@ -103,7 +102,7 @@ const ALL_DAY_SCHEDULE: Partial = { */ export function playlistSchedules( playlist: Pick, -): Partial[] { +): Partial[] { return playlist.schedules?.length ? playlist.schedules : [ALL_DAY_SCHEDULE]; } @@ -114,7 +113,7 @@ export function hasTakeoverSchedule(playlist: SignagePlaylist) { ); } -function playPeriodMinutes(schedule: Partial) { +function playPeriodMinutes(schedule: Partial) { return Number.isFinite(schedule.play_period) ? Math.max(0, schedule.play_period || 0) : DEFAULT_PLAYLIST_DURATION; @@ -177,7 +176,7 @@ function playedRun( day: Date, starts_at: Date, duration: number, - schedule: Partial, + schedule: Partial, playlist: Pick, ) { const time = starts_at.getTime(); @@ -208,7 +207,7 @@ function playedRun( /** Start times of a schedule on a day, before the dates and mask apply */ function scheduleStarts( - schedule: Partial, + schedule: Partial, slots: readonly number[], parts: readonly string[] | null, day: Date, diff --git a/apps/signage-manager/src/app/shared/media-edit-modal.component.ts b/apps/signage-manager/src/app/shared/media-edit-modal.component.ts index b1d156e6f1c..8b4c6ea6d00 100644 --- a/apps/signage-manager/src/app/shared/media-edit-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-edit-modal.component.ts @@ -28,6 +28,7 @@ import { MatSliderModule } from '@angular/material/slider'; import { HotkeysService, i18n, + mediaAnimation, notifyError, notifySuccess, UploadPermissions, @@ -53,7 +54,6 @@ import { SignagePlugin, } from '@placeos/ts-client'; import { endOfDay, getUnixTime, startOfDay } from 'date-fns'; -import { mediaAnimation } from '../media/media-view.util'; import { getVideoContainer, isSupportedImageFile, diff --git a/apps/signage-manager/src/app/shared/media-preview-modal.component.ts b/apps/signage-manager/src/app/shared/media-preview-modal.component.ts index 8402e005c8a..cf11424ffe6 100644 --- a/apps/signage-manager/src/app/shared/media-preview-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-preview-modal.component.ts @@ -6,6 +6,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltip } from '@angular/material/tooltip'; import { DomSanitizer } from '@angular/platform-browser'; import { RouterLink } from '@angular/router'; +import { mediaAnimation } from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, @@ -22,7 +23,6 @@ import { SignagePlaylist, SignagePlugin, } from '@placeos/ts-client'; -import { mediaAnimation } from '../media/media-view.util'; import { SignageMediaService } from '../media/signage-media.service'; import { playlistMediaThumbnailUrl, diff --git a/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts b/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts index 64cb2bf8dc9..82c5dfc7785 100644 --- a/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts @@ -16,12 +16,24 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelect, MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { + cronDaySlots, + cronParts, + DEFAULT_PLAY_PERIOD_MINUTES, + doesCronMatchDay, formatTimeInTimezone, getTimeInTimezone, getTimezoneOffsetString, + hasPlayableScheduleMask, i18n, + isCronMonthlyWeekday, + isPlayOnceSchedule, + isValidScheduleMask, LOCAL_TIMEZONE, LocaleService, + parseCronNumber, + parseCronWeekdays, + parseCronWeeksOfMonth, + parsePlayAtLocal, setTimeInTimezone, settingSignal, } from '@placeos/common'; @@ -37,33 +49,18 @@ import { DurationFieldComponent, TimeFieldComponent, } from '@placeos/form-fields'; -import { SignagePlaylist } from '@placeos/ts-client'; +import { SignagePlaylist, SignagePlaylistSchedule } from '@placeos/ts-client'; import { endOfDay, fromUnixTime, getUnixTime, startOfDay } from 'date-fns'; import { fromZonedTime, toZonedTime } from 'date-fns-tz'; -import { - cronDaySlots, - cronParts, - doesCronMatchDay, - isCronMonthlyWeekday, - nextCronDates, - parseCronNumber, - parseCronWeekdays, - parseCronWeeksOfMonth, -} from '../signage-cron.util'; +import { nextCronDates } from '../signage-cron.util'; import { createScheduleMaskFilter, - DEFAULT_PLAY_PERIOD_MINUTES, formatPlayAtLocal, formatPlayDateTime, formatPlayDateTimeRange, - hasPlayableScheduleMask, - isPlayOnceSchedule, - isValidScheduleMask, ordinal, parseCronMonthDays, - parsePlayAtLocal, playlistScheduleExpiryLabel, - type PlaylistSchedule, } from '../signage-playlist.util'; export type PlaylistScheduleType = 'play_at' | 'play_cron'; @@ -307,14 +304,14 @@ function buildRecurringCron(value: { return `${minute} ${hour} * * *`; } -function playlistPlayPeriod(schedule: Partial) { +function playlistPlayPeriod(schedule: Partial) { return Number.isFinite(schedule.play_period) ? Math.max(0, schedule.play_period) : DEFAULT_PLAY_PERIOD_MINUTES; } function scheduleTypeFor( - schedule: Partial, + schedule: Partial, ): PlaylistScheduleType { return isPlayOnceSchedule(schedule) ? 'play_at' : 'play_cron'; } @@ -448,7 +445,7 @@ function maskOccurrenceDates( } export function createPlaylistScheduleModel( - schedule?: Partial, + schedule?: Partial, ): PlaylistScheduleFormModel { const source = schedule || {}; const recurring_schedule = parseRecurringCron(source.play_cron); @@ -460,7 +457,7 @@ export function createPlaylistScheduleModel( // Local play times use the browser timezone for the wall-clock value. play_at: source.play_at ? fromUnixTime(source.play_at).getTime() - : (parsePlayAtLocal(source.play_at_local)?.getTime() ?? Date.now()), + : (parsePlayAtLocal(source.play_at_local) ?? Date.now()), play_at_exact: !!source.play_at, play_takeover: !!source.play_takeover, play_cron: source.play_cron || DEFAULT_RECURRING_CRON, @@ -526,7 +523,7 @@ export const playlistScheduleSchema = schema( export function playlistSchedulePayload( value: PlaylistScheduleFormModel, -): PlaylistSchedule { +): SignagePlaylistSchedule { return value.schedule_type === 'play_at' ? { play_at: diff --git a/apps/signage-manager/src/app/shared/template-approval-preview.component.ts b/apps/signage-manager/src/app/shared/template-approval-preview.component.ts index 2887c69c8df..1814c4286ca 100644 --- a/apps/signage-manager/src/app/shared/template-approval-preview.component.ts +++ b/apps/signage-manager/src/app/shared/template-approval-preview.component.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, input } from '@angular/core'; +import { computeTemplateLayout, TemplateLayoutRect } from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, @@ -13,11 +14,9 @@ import { import { SignagePluginService } from '../signage-plugin.service'; import { pluginName } from '../signage-plugin.util'; import { - computeTemplateLayoutRects, layoutAxisPercentage, layoutPositionAxes, layoutPositionLabel, - TemplateLayoutRect, } from '../templates/template-layout.util'; import { signageTemplateFieldChanges, @@ -510,9 +509,9 @@ export class TemplateApprovalPreviewComponent { other: SignageTemplate | null, current: boolean, ): TemplateVersionPreview { - const rects = computeTemplateLayoutRects(template.layouts); - const layouts = template.layouts.map((layout, index) => - this.layoutPreview(layout, index, rects[index], other, current), + const layouts = computeTemplateLayout(template.layouts).items.map( + ({ layout, rect }, index) => + this.layoutPreview(layout, index, rect, other, current), ); const other_tags = other?.tags || []; return { diff --git a/apps/signage-manager/src/app/shared/template-mappings.component.ts b/apps/signage-manager/src/app/shared/template-mappings.component.ts index b627754841a..b9d8e549051 100644 --- a/apps/signage-manager/src/app/shared/template-mappings.component.ts +++ b/apps/signage-manager/src/app/shared/template-mappings.component.ts @@ -3,7 +3,7 @@ import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; -import { i18n } from '@placeos/common'; +import { computeTemplateLayout, i18n } from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, @@ -24,7 +24,6 @@ import { SignageTemplateMappingTarget, } from '../signage-template-mapping'; import { SignageTemplateService } from '../templates/signage-template.service'; -import { computeTemplateLayoutRects } from '../templates/template-layout.util'; import { SignageZoneService } from '../zones/signage-zone.service'; export type TemplateMappingTargetType = 'display' | 'zone'; @@ -372,15 +371,16 @@ export class TemplateMappingsComponent { ]), ); return this.mappings().map((mapping) => { - const layouts = mapping.template_details.layouts || []; - const rects = computeTemplateLayoutRects(layouts); + const { items } = computeTemplateLayout( + mapping.template_details.layouts || [], + ); return { mapping, zone_name: zone_names.get(mapping.zone_id) || mapping.zone_id, - layout_items: layouts.map((layout, index) => ({ + layout_items: items.map(({ layout, rect }, index) => ({ index, plugin_id: layout.plugin_id, - ...rects[index], + ...rect, })), }; }); diff --git a/apps/signage-manager/src/app/signage-cron.util.ts b/apps/signage-manager/src/app/signage-cron.util.ts index 98b32e426de..ed697101649 100644 --- a/apps/signage-manager/src/app/signage-cron.util.ts +++ b/apps/signage-manager/src/app/signage-cron.util.ts @@ -1,18 +1,9 @@ +import { cronDaySlots, cronParts, doesCronMatchDay } from '@placeos/common'; import { formatInTimeZone, fromZonedTime, toZonedTime } from 'date-fns-tz'; /** - * Cron rules for signage schedules. - * - * The signage player (`apps/signage/src/app/cron-helpers.ts`) decides what a - * display plays, so these rules copy the player exactly. The player differs - * from standard cron: - * - `*\/n` matches values that divide by n. Day of month `*\/2` is 2, 4, 6... - * - `a-b/n` matches a, a + n, a + 2n... up to b. - * - `a/n` matches only a, and only when a divides by n. `5/15` never matches. - * - Day of week 7 does not match Sunday. Use 0. - * - When day of month and day of week are both set, a day matches either one. - * Week of month ranges (1-7, 8-14, 15-21, 22-28, 29-31) with weekdays must - * match both, e.g. "first Monday". + * Upcoming start times of signage schedules. The cron rules are shared with + * the signage player in `libs/common/src/lib/signage/cron.ts`. */ /** Days searched for upcoming plays, so a cron that never matches stops. */ @@ -20,127 +11,6 @@ const MAX_CRON_SEARCH_DAYS = 2 * 366; const MS_PER_DAY = 86_400_000; -/** The five cron fields, or null when the cron does not have five fields. */ -export function cronParts(cron: string | null | undefined) { - const parts = (cron || '').trim().split(/\s+/); - return parts.length === 5 ? parts : null; -} - -/** Whether a value matches one cron field. */ -export function matchesCronPart(value: number, cron_part: string): boolean { - if (cron_part === '*') return true; - if (cron_part.includes(',')) { - return cron_part - .split(',') - .some((item) => matchesCronPart(value, item)); - } - if (cron_part.includes('/')) { - const [base, step] = cron_part.split('/'); - const step_value = Number(step); - if (!step_value) return false; - if (base === '*') return value % step_value === 0; - if (base.includes('-')) { - const [start, end] = base.split('-').map(Number); - if (value < start || value > end) return false; - return (value - start) % step_value === 0; - } - return value % step_value === 0 && matchesCronPart(value, base); - } - if (cron_part.includes('-')) { - const [start, end] = cron_part.split('-').map(Number); - return value >= start && value <= end; - } - return Number(cron_part) === value; -} - -/** A whole number from `min` to `max`, or null. */ -export function parseCronNumber(value: string, min: number, max: number) { - if (!/^\d+$/.test(value || '')) return null; - const number_value = Number(value); - return number_value >= min && number_value <= max ? number_value : null; -} - -/** Weekdays (0 is Sunday) of a list such as "1,3,5" or "1-5". Empty when the field is not a plain list. */ -export function parseCronWeekdays(value: string) { - if (!value?.trim() || value === '*') return []; - const days = new Set(); - for (const part of value.split(',')) { - if (part.includes('-')) { - const [start, end] = part - .split('-') - .map((_) => parseCronNumber(_, 0, 6)); - if (start === null || end === null || start > end) return []; - for (let day = start; day <= end; day++) days.add(day); - } else { - const day = parseCronNumber(part, 0, 6); - if (day === null) return []; - days.add(day); - } - } - return [...days].sort((a, b) => a - b); -} - -function parseCronWeekOfMonthRange(value: string) { - const match = /^(\d+)-(\d+)$/.exec(value || ''); - if (!match) return null; - const start = Number(match[1]); - const end = Number(match[2]); - if (start === 29 && end === 31) return 5; - if ((start - 1) % 7 !== 0 || end !== start + 6) return null; - const week = (start - 1) / 7 + 1; - return week >= 1 && week <= 4 ? week : null; -} - -/** Weeks of the month (1 to 5) of day ranges such as "1-7,15-21". Empty when the field has other values. */ -export function parseCronWeeksOfMonth(value: string) { - if (!value?.trim() || value === '*') return []; - const weeks = new Set(); - for (const part of value.split(',')) { - const week = parseCronWeekOfMonthRange(part); - if (week === null) return []; - weeks.add(week); - } - return [...weeks].sort((a, b) => a - b); -} - -/** Whether the day fields mean "these weekdays in these weeks of the month". */ -export function isCronMonthlyWeekday(day_part: string, weekday_part: string) { - return ( - !!parseCronWeeksOfMonth(day_part).length && - !!parseCronWeekdays(weekday_part).length - ); -} - -/** Whether the calendar fields (day, month, weekday) match the local date. */ -export function doesCronMatchDay(parts: readonly string[], date: Date) { - const [, , day_part, month_part, weekday_part] = parts; - if (!matchesCronPart(date.getMonth() + 1, month_part)) return false; - if (day_part === '*' && weekday_part === '*') return true; - const day_matches = matchesCronPart(date.getDate(), day_part); - if (weekday_part === '*') return day_matches; - const weekday_matches = matchesCronPart(date.getDay(), weekday_part); - if (day_part === '*') return weekday_matches; - if (isCronMonthlyWeekday(day_part, weekday_part)) { - return day_matches && weekday_matches; - } - return day_matches || weekday_matches; -} - -/** Whether all five fields match the local date and time. */ - -/** Minutes after midnight that the minute and hour fields match, in order. */ -export function cronDaySlots(parts: readonly string[]) { - const slots: number[] = []; - for (let hour = 0; hour < 24; hour++) { - if (!matchesCronPart(hour, parts[1])) continue; - for (let minute = 0; minute < 60; minute++) { - if (matchesCronPart(minute, parts[0])) - slots.push(hour * 60 + minute); - } - } - return slots; -} - /** * Time in milliseconds of a wall-clock time, or null when the time does not * exist because of a daylight saving change. diff --git a/apps/signage-manager/src/app/signage-playlist.util.ts b/apps/signage-manager/src/app/signage-playlist.util.ts index f3c7d8c57f8..041c31e218b 100644 --- a/apps/signage-manager/src/app/signage-playlist.util.ts +++ b/apps/signage-manager/src/app/signage-playlist.util.ts @@ -1,3 +1,16 @@ +import { + cronDaySlots, + cronParts, + DEFAULT_PLAY_PERIOD_MINUTES, + doesCronMatchDay, + hasPlayableScheduleMask, + isCronMonthlyWeekday, + isPlayOnceSchedule, + mediaAnimation, + parseCronNumber, + parseCronWeekdays, + parsePlayAtLocal, +} from '@placeos/common'; import { MediaAnimation, SignageMedia, @@ -6,59 +19,7 @@ import { } from '@placeos/ts-client'; import { format, formatDistance, fromUnixTime, getUnixTime } from 'date-fns'; import { fromZonedTime, toZonedTime } from 'date-fns-tz'; -import { - cronDaySlots, - cronParts, - doesCronMatchDay, - isCronMonthlyWeekday, - nextCronDates, - parseCronNumber, - parseCronWeekdays, -} from './signage-cron.util'; - -/** - * Schedule fields that are not in the ts-client type yet. - * valid_from uses Unix seconds. Zero or omitted means no start limit. - */ -export type PlaylistSchedule = SignagePlaylistSchedule & { - readonly valid_from?: number; - /** Up to 128 binary characters, first occurrence first. Empty disables the mask. */ - readonly mask?: string; - /** - * One-off play time as a wall-clock time with no offset, e.g. - * "2027-01-01T00:00:00". Each display plays it in its own timezone. - * Do not set it with `play_at`. - */ - readonly play_at_local?: string; -}; - -const PLAY_AT_LOCAL_PATTERN = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/; - -/** Whether the schedule plays once at `play_at` or `play_at_local`. */ -export function isPlayOnceSchedule(schedule: Partial) { - return !!schedule.play_at || !!schedule.play_at_local; -} - -/** - * Parse a `play_at_local` value as a wall-clock time in the viewer's timezone. - * Returns null when the value is not an ISO 8601 date time with no offset. - */ -export function parsePlayAtLocal(value: string | null | undefined) { - const match = PLAY_AT_LOCAL_PATTERN.exec(value || ''); - if (!match) return null; - const [year, month, day, hours, minutes, seconds] = match - .slice(1) - .map(Number); - const date = new Date(year, month - 1, day, hours, minutes, seconds); - // Reject values such as February 30 or 00:60 that roll over. - return date.getDate() === day && - date.getMonth() === month - 1 && - minutes < 60 && - seconds < 60 - ? date - : null; -} +import { nextCronDates } from './signage-cron.util'; /** Format a date as a `play_at_local` value in the viewer's timezone. */ export function formatPlayAtLocal(date: Date | number) { @@ -66,7 +27,7 @@ export function formatPlayAtLocal(date: Date | number) { } /** Play once start time for labels. Local times note the display timezone. */ -function playOnceLabel(schedule: Partial) { +function playOnceLabel(schedule: Partial) { const start = playOnceStart(schedule)?.toLocaleString() ?? ''; return schedule.play_at ? start : `${start} display local time`; } @@ -75,13 +36,12 @@ function playOnceLabel(schedule: Partial) { * Start of a play once schedule, or null for a recurring schedule. * The viewer's timezone resolves `play_at_local` values. */ -export function playOnceStart(schedule: Partial) { +export function playOnceStart(schedule: Partial) { if (schedule.play_at) return fromUnixTime(schedule.play_at); - return parsePlayAtLocal(schedule.play_at_local); + const local_time = parsePlayAtLocal(schedule.play_at_local); + return local_time === null ? null : new Date(local_time); } -/** Play period of a schedule that does not set one: the whole day */ -export const DEFAULT_PLAY_PERIOD_MINUTES = 24 * 60; const WEEKDAY_NAMES = [ 'Sunday', 'Monday', @@ -92,22 +52,6 @@ const WEEKDAY_NAMES = [ 'Saturday', ]; -/** - * Animation as a `MediaAnimation` value. The API accepts animation names - * but stores and returns the index of the name in `MediaAnimation`, e.g. - * 2 for `cross_fade`. Names are kept as they are. - * @returns The default animation for an index out of range or no value - */ -export function mediaAnimation( - value: MediaAnimation | number | null | undefined, -): MediaAnimation { - if (typeof value !== 'number') return value || MediaAnimation.Default; - const animations = Object.values(MediaAnimation); - return Number.isInteger(value) && value >= 0 && value < animations.length - ? animations[value] - : MediaAnimation.Default; -} - /** * Default animation of a playlist that ts-client loaded. The API returns * the index of the animation, and `SignagePlaylist` replaces a falsy value @@ -345,7 +289,7 @@ function humanizeCronSchedule(cron: string, duration_minutes: number) { return `Custom schedule (${cron})`; } -function schedulePeriod(schedule: Partial) { +function schedulePeriod(schedule: Partial) { return Number.isFinite(schedule.play_period) ? schedule.play_period || 0 : DEFAULT_PLAY_PERIOD_MINUTES; @@ -377,7 +321,7 @@ export function playlistLoopDuration( } export function playlistScheduleExpiryLabel( - schedule: Partial, + schedule: Partial, now = Date.now(), ) { if (!schedule.valid_until) return ''; @@ -389,7 +333,7 @@ export function playlistScheduleExpiryLabel( } export function playlistScheduleExpiryTooltip( - schedule: Partial, + schedule: Partial, ) { return schedule.valid_until ? fromUnixTime(schedule.valid_until).toLocaleString() @@ -403,7 +347,7 @@ export function playlistScheduleExpiryTooltip( export function playlistExpiredAt( playlist: { valid_until?: number; - schedules?: readonly Partial[]; + schedules?: readonly Partial[]; }, now = Date.now(), ) { @@ -421,7 +365,11 @@ export function playlistExpiredAt( /** Status badge of a playlist in a list. Null when it needs no badge. */ export type PlaylistStatus = - 'expired' | 'pending' | 'awaiting_approval' | 'awaiting_review' | null; + | 'expired' + | 'pending' + | 'awaiting_approval' + | 'awaiting_review' + | null; /** * Status of a playlist for list badges. Expiry uses `playlistExpiredAt`, so @@ -435,7 +383,7 @@ export function playlistStatus( id: string; valid_from?: number; valid_until?: number; - schedules?: readonly Partial[]; + schedules?: readonly Partial[]; }, approvals: Record, requests: Record, @@ -449,7 +397,9 @@ export function playlistStatus( return requests[playlist.id] ? 'awaiting_review' : 'awaiting_approval'; } -export function playlistScheduleLabel(schedule: Partial) { +export function playlistScheduleLabel( + schedule: Partial, +) { const period = schedulePeriod(schedule); const expiry = playlistScheduleExpiryLabel(schedule); const suffix = [ @@ -471,29 +421,13 @@ export function playlistScheduleLabel(schedule: Partial) { }`; } -/** Validate an active mask without changing leading zeros or whitespace. */ -export function isValidScheduleMask(mask: string) { - return mask.length > 0 && mask.length <= 128 && !/[^01]/.test(mask); -} - -/** Whether the mask can select any instances in its repeat cycle. */ -export function hasPlayableScheduleMask(schedule: Partial) { - const mask = schedule.mask || ''; - return ( - !mask || - (isValidScheduleMask(mask) && - !!schedule.valid_from && - mask.includes('1')) - ); -} - /** * Count cron instances from valid_from, before applying the repeating mask. * Cache complete days so successive preview times do not recount the past. * Call this filter only for timestamps that match the schedule. */ export function createScheduleMaskFilter( - schedule: Partial, + schedule: Partial, timezone?: string, ): (date: Date) => boolean { const mask = schedule.mask || ''; @@ -501,7 +435,6 @@ export function createScheduleMaskFilter( if (!size) return () => true; if (!hasPlayableScheduleMask(schedule)) return () => false; const anchor = schedule.valid_from * 1000; - if (!Number.isFinite(new Date(anchor).getTime())) return () => false; if (isPlayOnceSchedule(schedule)) return (date) => date.getTime() >= anchor && mask[0] === '1'; const parts = cronParts(schedule.play_cron || '0 0 * * *'); @@ -631,7 +564,7 @@ interface PlaySession { /** Next plays of one schedule, inside its validity window and mask. */ function nextSchedulePlays( - schedule: Partial, + schedule: Partial, count: number, now: number, ): PlaySession[] { @@ -664,7 +597,7 @@ function nextSchedulePlays( * is under way plays at `now`. Null when no play is left. */ export function playlistNextPlayTime( - schedules: Partial[], + schedules: Partial[], now = Date.now(), ): number | null { let next = Infinity; @@ -682,7 +615,7 @@ export function playlistNextPlayTime( /** Time ranges of the next plays of all the schedules, earliest first. */ export function playlistNextPlayLabels( - schedules: Partial[], + schedules: Partial[], count = 5, now = Date.now(), ) { @@ -695,7 +628,7 @@ export function playlistNextPlayLabels( /** Time ranges of the next plays of one schedule. */ export function playlistScheduleNextPlayLabels( - schedule: Partial, + schedule: Partial, count = 5, ) { return playlistNextPlayLabels([schedule], count); diff --git a/apps/signage-manager/src/app/templates/template-layout.util.ts b/apps/signage-manager/src/app/templates/template-layout.util.ts index 432f969a053..89c66eb8d0a 100644 --- a/apps/signage-manager/src/app/templates/template-layout.util.ts +++ b/apps/signage-manager/src/app/templates/template-layout.util.ts @@ -1,31 +1,13 @@ +import { + TEMPLATE_EDGE_BAR_HEIGHT_PC, + TEMPLATE_FLOATING_POSITION_PC, + TEMPLATE_SIDEBAR_WIDTH_PC, +} from '@placeos/common'; import { SignageTemplateLayout, SignageTemplateLayoutPosition, } from '@placeos/ts-client'; -/** Rectangle within the preview frame, all values are percentages (0-100) */ -export interface TemplateLayoutRect { - left: number; - top: number; - width: number; - height: number; -} - -/** - * The API stores `x_pos`/`y_pos` as ratios from 0 to 1. For edge panels they - * are the panel's size on the relevant axis. For floating panels they are the - * top-left corner, with the panel filling the frame from there. - * - * These defaults must stay in step with the player - * (`apps/signage/src/app/template-layout.ts`), so the preview shows what - * screens show and saving does not move a panel. - */ -export const EDGE_BAR_HEIGHT_PC = 15; -export const SIDEBAR_WIDTH_PC = 20; -// A floating panel with no position fills the frame -export const FLOATING_DEFAULT_X_PC = 0; -export const FLOATING_DEFAULT_Y_PC = 0; - export const LAYOUT_POSITIONS: SignageTemplateLayoutPosition[] = [ 'top', 'bottom', @@ -58,6 +40,12 @@ export function layoutPositionLabel(position: SignageTemplateLayoutPosition) { return POSITION_LABELS[position] || position; } +/* + * The player shares the layout defaults and maths in + * `libs/common/src/lib/signage/template.ts`, so the preview shows what + * screens show and saving does not move a panel. + */ + const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), Math.max(min, max)); @@ -78,10 +66,10 @@ export function layoutAxisPercentage( ) { const percentage = layoutRatioToPercentage(layout[axis]); if (percentage !== null) return Math.round(percentage * 100) / 100; - if (layout.position === 'floating') { - return axis === 'x_pos' ? FLOATING_DEFAULT_X_PC : FLOATING_DEFAULT_Y_PC; - } - return axis === 'x_pos' ? SIDEBAR_WIDTH_PC : EDGE_BAR_HEIGHT_PC; + if (layout.position === 'floating') return TEMPLATE_FLOATING_POSITION_PC; + return axis === 'x_pos' + ? TEMPLATE_SIDEBAR_WIDTH_PC + : TEMPLATE_EDGE_BAR_HEIGHT_PC; } /** Position axes that have an effect for the given layout position. */ @@ -119,24 +107,26 @@ export function applyLayoutPositionDefaults( return { ...layout, y_pos: - layout.y_pos ?? layoutPercentageToRatio(EDGE_BAR_HEIGHT_PC), + layout.y_pos ?? + layoutPercentageToRatio(TEMPLATE_EDGE_BAR_HEIGHT_PC), }; case 'left': case 'right': return { ...layout, x_pos: - layout.x_pos ?? layoutPercentageToRatio(SIDEBAR_WIDTH_PC), + layout.x_pos ?? + layoutPercentageToRatio(TEMPLATE_SIDEBAR_WIDTH_PC), }; case 'floating': return { ...layout, x_pos: layout.x_pos ?? - layoutPercentageToRatio(FLOATING_DEFAULT_X_PC), + layoutPercentageToRatio(TEMPLATE_FLOATING_POSITION_PC), y_pos: layout.y_pos ?? - layoutPercentageToRatio(FLOATING_DEFAULT_Y_PC), + layoutPercentageToRatio(TEMPLATE_FLOATING_POSITION_PC), }; } } @@ -154,84 +144,3 @@ export function layoutPositionValid(layout: SignageTemplateLayout) { (value) => value === undefined || (value > 0 && value < 1), ); } - -/** - * Resolve each layout item to a rectangle in the preview frame. Items are - * placed in array order, each edge panel consuming space from the remaining - * unclaimed area — e.g. a bottom panel inserted first spans the full frame - * width, while one inserted after a sidebar spans the width left over by it. - * Edge panels read `x_pos`/`y_pos` as their size on the axis they consume. - * Floating panels read them as their top-left corner, overlay the frame from - * there to the bottom-right, and don't consume space from following items. - */ -export function computeTemplateLayoutRects( - layouts: SignageTemplateLayout[], -): TemplateLayoutRect[] { - const rem = { left: 0, top: 0, width: 100, height: 100 }; - return layouts.map((layout) => { - switch (layout.position) { - case 'top': { - const height = Math.min( - layoutRatioToPercentage(layout.y_pos) ?? EDGE_BAR_HEIGHT_PC, - rem.height, - ); - const rect = { ...rem, height }; - rem.top += height; - rem.height -= height; - return rect; - } - case 'bottom': { - const height = Math.min( - layoutRatioToPercentage(layout.y_pos) ?? EDGE_BAR_HEIGHT_PC, - rem.height, - ); - const rect = { - ...rem, - top: rem.top + rem.height - height, - height, - }; - rem.height -= height; - return rect; - } - case 'left': { - const width = Math.min( - layoutRatioToPercentage(layout.x_pos) ?? SIDEBAR_WIDTH_PC, - rem.width, - ); - const rect = { ...rem, width }; - rem.left += width; - rem.width -= width; - return rect; - } - case 'right': { - const width = Math.min( - layoutRatioToPercentage(layout.x_pos) ?? SIDEBAR_WIDTH_PC, - rem.width, - ); - const rect = { - ...rem, - left: rem.left + rem.width - width, - width, - }; - rem.width -= width; - return rect; - } - case 'floating': - default: { - const left = clamp( - layoutRatioToPercentage(layout.x_pos) ?? - FLOATING_DEFAULT_X_PC, - 0, - 100, - ); - const top = clamp( - layoutRatioToPercentage(layout.y_pos) ?? - FLOATING_DEFAULT_Y_PC, - 0, - 100, - ); - return { left, top, width: 100 - left, height: 100 - top }; - } - } - }); -} diff --git a/apps/signage-manager/src/app/templates/template-preview.component.ts b/apps/signage-manager/src/app/templates/template-preview.component.ts index 57c7f2e0d7c..b7a49a9f613 100644 --- a/apps/signage-manager/src/app/templates/template-preview.component.ts +++ b/apps/signage-manager/src/app/templates/template-preview.component.ts @@ -12,7 +12,13 @@ import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; -import { parseWebUrl, settingSignal } from '@placeos/common'; +import { + computeTemplateLayout, + parseWebUrl, + settingSignal, + SIGNAGE_PREVIEW_LAYOUTS_MESSAGE, + SIGNAGE_PREVIEW_READY_MESSAGE, +} from '@placeos/common'; import { AuthenticatedImageDirective, SafePipe, @@ -26,7 +32,6 @@ import { pluginName } from '../signage-plugin.util'; import { SignageTemplateService } from './signage-template.service'; import { applyLayoutPositionDefaults, - computeTemplateLayoutRects, layoutPositionLabel, } from './template-layout.util'; @@ -36,11 +41,6 @@ interface AspectRatioOption { ratio: number; } -/** Message type the signage player accepts to preview unsaved layouts */ -const PREVIEW_LAYOUTS_MESSAGE = 'signage:template-layouts'; -/** Message type the signage player posts to request the unsaved layouts */ -const PREVIEW_READY_MESSAGE = 'signage:template-preview-ready'; - const ASPECT_RATIOS: AspectRatioOption[] = [ { id: '16:9', label: '16:9', ratio: 16 / 9 }, { id: '9:16', label: '9:16', ratio: 9 / 16 }, @@ -292,7 +292,7 @@ export class TemplatePreviewComponent { private readonly _preview_ready_handler = (event: MessageEvent) => { const frame = this._live_frame()?.nativeElement; if ( - event.data?.type !== PREVIEW_READY_MESSAGE || + event.data?.type !== SIGNAGE_PREVIEW_READY_MESSAGE || !frame?.contentWindow || event.source !== frame.contentWindow ) @@ -307,14 +307,9 @@ export class TemplatePreviewComponent { ); } - public readonly layout_rects = computed(() => { - const layouts = this._layouts(); - const rects = computeTemplateLayoutRects(layouts); - return layouts.map((layout, index) => ({ - layout, - rect: rects[index], - })); - }); + public readonly layout_rects = computed( + () => computeTemplateLayout(this._layouts()).items, + ); public readonly background_url = computed(() => { const background_id = @@ -356,7 +351,7 @@ export class TemplatePreviewComponent { if (!frame?.contentWindow || !target) return; frame.contentWindow.postMessage( { - type: PREVIEW_LAYOUTS_MESSAGE, + type: SIGNAGE_PREVIEW_LAYOUTS_MESSAGE, layouts: this._layouts().map(applyLayoutPositionDefaults), }, target.origin, diff --git a/apps/signage-manager/src/tests/displays/display-schedule.util.spec.ts b/apps/signage-manager/src/tests/displays/display-schedule.util.spec.ts index dfb508346e0..cd56719c199 100644 --- a/apps/signage-manager/src/tests/displays/display-schedule.util.spec.ts +++ b/apps/signage-manager/src/tests/displays/display-schedule.util.spec.ts @@ -1,16 +1,15 @@ -import { SignagePlaylist } from '@placeos/ts-client'; +import { SignagePlaylist, SignagePlaylistSchedule } from '@placeos/ts-client'; import { buildDisplayScheduleDays } from '../../app/displays/display-schedule.util'; import { buildScheduleBlocks } from '../../app/schedules/signage-schedule.util'; -import { type PlaylistSchedule } from '../../app/signage-playlist.util'; import { HydratedSignageTemplateMapping } from '../../app/signage-template-mapping'; const days = [new Date(2026, 8, 7)]; -const schedule = (hour: number, duration: number): PlaylistSchedule => ({ +const schedule = (hour: number, duration: number): SignagePlaylistSchedule => ({ play_cron: `0 ${hour} * * *`, play_period: duration, play_takeover: false, }); -const mapping = (id: string, timing: PlaylistSchedule | null = null) => +const mapping = (id: string, timing: SignagePlaylistSchedule | null = null) => new HydratedSignageTemplateMapping({ id, template_id: `template-${id}`, diff --git a/apps/signage-manager/src/tests/media/media-view.util.spec.ts b/apps/signage-manager/src/tests/media/media-view.util.spec.ts index 294a9bbba6d..7c0255404e9 100644 --- a/apps/signage-manager/src/tests/media/media-view.util.spec.ts +++ b/apps/signage-manager/src/tests/media/media-view.util.spec.ts @@ -1,9 +1,7 @@ -import { MediaAnimation } from '@placeos/ts-client'; import { applyMediaView, DEFAULT_MEDIA_VIEW, isMediaViewActive, - mediaAnimation, } from '../../app/media/media-view.util'; const DAY_S = 24 * 60 * 60; @@ -68,22 +66,3 @@ describe('media view util', () => { ).toBe(true); }); }); - -describe('mediaAnimation', () => { - // The API stores the name it is sent, but returns its index - it('maps an index from the API to the animation name', () => { - expect(mediaAnimation(2)).toBe(MediaAnimation.CrossFade); - expect(mediaAnimation(4)).toBe(MediaAnimation.SlideLeft); - }); - - it('keeps an animation name', () => { - expect(mediaAnimation('cross_fade')).toBe(MediaAnimation.CrossFade); - }); - - it.each([-1, 7, 1.5, 'spin', null, undefined])( - 'uses the default animation for %s', - (value) => { - expect(mediaAnimation(value)).toBe(MediaAnimation.Default); - }, - ); -}); diff --git a/apps/signage-manager/src/tests/schedules/signage-schedule.util.spec.ts b/apps/signage-manager/src/tests/schedules/signage-schedule.util.spec.ts index 05880f0b41f..61bc38e9ba2 100644 --- a/apps/signage-manager/src/tests/schedules/signage-schedule.util.spec.ts +++ b/apps/signage-manager/src/tests/schedules/signage-schedule.util.spec.ts @@ -1,4 +1,4 @@ -import { SignagePlaylist } from '@placeos/ts-client'; +import { SignagePlaylist, SignagePlaylistSchedule } from '@placeos/ts-client'; import { getUnixTime } from 'date-fns'; import { buildDayTimelineBlocks, @@ -7,7 +7,6 @@ import { buildZoneScheduleAssignments, hasTakeoverSchedule, } from '../../app/schedules/signage-schedule.util'; -import { type PlaylistSchedule } from '../../app/signage-playlist.util'; describe('signage-schedule.util', () => { it('applies the repeating mask before adding timeline blocks', () => { @@ -28,7 +27,7 @@ describe('signage-schedule.util', () => { play_takeover: false, valid_from: days[0].getTime() / 1000, mask: '10', - } as PlaylistSchedule, + } as SignagePlaylistSchedule, ], }), }, @@ -140,7 +139,7 @@ describe('signage-schedule.util', () => { { play_cron: '0 9 * * *' }, { play_at: timestamp }, ]) { - const schedule: PlaylistSchedule = { + const schedule: SignagePlaylistSchedule = { play_cron: '0 9 * * *', ...timing, play_period: 30, @@ -286,7 +285,7 @@ describe('signage-schedule.util', () => { const day = new Date(2026, 2, 2); const at = (hours: number) => getUnixTime(new Date(2026, 2, 2, hours)); const blocksOf = ( - schedule: Partial, + schedule: Partial, dates: { valid_from?: number; valid_until?: number } = {}, ) => buildScheduleBlocks( diff --git a/apps/signage-manager/src/tests/signage-cron.util.spec.ts b/apps/signage-manager/src/tests/signage-cron.util.spec.ts index d4ece103b5e..ea812b50e8a 100644 --- a/apps/signage-manager/src/tests/signage-cron.util.spec.ts +++ b/apps/signage-manager/src/tests/signage-cron.util.spec.ts @@ -1,64 +1,11 @@ -import { - cronParts, - doesCronMatchDay, - matchesCronPart, - nextCronDates, -} from '../app/signage-cron.util'; +import { nextCronDates } from '../app/signage-cron.util'; -/** Fields of a cron that is known to be valid */ -const parts = (cron: string) => cronParts(cron) || []; - -/** Values from `min` to `max` that match the cron field */ -function matches(part: string, min: number, max: number) { - return Array.from({ length: max - min + 1 }, (_, i) => min + i).filter( - (value) => matchesCronPart(value, part), - ); -} - -// Expected values follow apps/signage/src/app/cron-helpers.ts, which decides -// what displays play. Some differ from standard cron on purpose. -describe('signage cron rules', () => { - it('matches */n on values that divide by n', () => { - expect(matches('*/2', 1, 9)).toEqual([2, 4, 6, 8]); - expect(matches('*/15', 0, 59)).toEqual([0, 15, 30, 45]); - }); - - it('steps a range from its start', () => { - expect(matches('9-17/2', 0, 23)).toEqual([9, 11, 13, 15, 17]); - expect(matches('10-17/3', 0, 23)).toEqual([10, 13, 16]); - }); - - it('matches a/n only when a divides by n', () => { - expect(matches('5/15', 0, 59)).toEqual([]); - expect(matches('0/15', 0, 59)).toEqual([0]); - }); - - it('does not treat day of week 7 as Sunday', () => { - const sunday = new Date(2026, 9, 4, 12); - expect(doesCronMatchDay(parts('0 9 * * 7'), sunday)).toBe(false); - expect(doesCronMatchDay(parts('0 9 * * 0'), sunday)).toBe(true); - }); - - it('needs both day fields only for week of month ranges', () => { - const first_monday = new Date(2026, 9, 5, 12); - const second_monday = new Date(2026, 9, 12, 12); - const monthly_weekday = parts('0 9 1-7 * 1'); - expect(doesCronMatchDay(monthly_weekday, first_monday)).toBe(true); - expect(doesCronMatchDay(monthly_weekday, second_monday)).toBe(false); - // Not a week range, so either field is enough - expect(doesCronMatchDay(parts('0 9 1-10 * 1'), second_monday)).toBe( - true, - ); - }); +describe('nextCronDates', () => { + const from = Date.UTC(2026, 9, 1); - it('rejects crons without five fields', () => { - expect(cronParts('0 9 * *')).toBeNull(); + it('finds no starts for a cron without five fields', () => { expect(nextCronDates('0 9 * *', { from: 0, count: 5 })).toEqual([]); }); -}); - -describe('nextCronDates', () => { - const from = Date.UTC(2026, 9, 1); it('lists starts in the timezone from and until the limits, included', () => { const dates = nextCronDates('0 9 * * *', { diff --git a/apps/signage-manager/src/tests/signage-playlist.util.spec.ts b/apps/signage-manager/src/tests/signage-playlist.util.spec.ts index 8690bb1d4e7..00552481b41 100644 --- a/apps/signage-manager/src/tests/signage-playlist.util.spec.ts +++ b/apps/signage-manager/src/tests/signage-playlist.util.spec.ts @@ -2,7 +2,6 @@ import { MediaAnimation } from '@placeos/ts-client'; import { getUnixTime } from 'date-fns'; import { createScheduleMaskFilter, - mediaAnimation, playEndTime, playlistAnimation, playlistItemScheduleMap, @@ -105,18 +104,13 @@ describe('signage playlist util', () => { ); }); - it('reads play_at_local only as a date time with no offset', () => { + it('starts a local play once schedule at its date time', () => { expect(playOnceStart({ play_at_local: '2027-01-01T00:00:00' })).toEqual( new Date(2027, 0, 1), ); - for (const value of [ - '2027-01-01T00:00:00Z', - '2027-01-01T00:00', - '2027-02-30T00:00:00', - '2027-01-01T00:60:00', - ]) { - expect(playOnceStart({ play_at_local: value })).toBeNull(); - } + expect( + playOnceStart({ play_at_local: '2027-01-01T00:00:00Z' }), + ).toBeNull(); }); it('labels cron schedules like playlist details', () => { @@ -398,26 +392,6 @@ describe('playlist status', () => { }); }); -describe('media animation', () => { - it('maps a saved index to its animation', () => { - expect(mediaAnimation(0)).toBe(MediaAnimation.Default); - expect(mediaAnimation(2)).toBe(MediaAnimation.CrossFade); - expect(mediaAnimation(6)).toBe(MediaAnimation.SlideBottom); - }); - - it('keeps animation names', () => { - expect(mediaAnimation(MediaAnimation.SlideTop)).toBe( - MediaAnimation.SlideTop, - ); - }); - - it('uses the default for an index out of range or no value', () => { - for (const value of [-1, 7, 1.5, Number.NaN, null, undefined]) { - expect(mediaAnimation(value)).toBe(MediaAnimation.Default); - } - }); -}); - describe('playlist animation', () => { it('reads the cut that ts-client puts in place of index 0 as the default', () => { expect( diff --git a/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts b/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts index b8ac5be5d32..025d0e57d80 100644 --- a/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts +++ b/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts @@ -1,11 +1,11 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { TEMPLATE_SIDEBAR_WIDTH_PC } from '@placeos/common'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignageContextService } from '../../app/signage-context.service'; import { SignagePluginService } from '../../app/signage-plugin.service'; import { SignageTemplateService } from '../../app/templates/signage-template.service'; import { TemplateLayoutListComponent } from '../../app/templates/template-layout-list.component'; -import { SIDEBAR_WIDTH_PC } from '../../app/templates/template-layout.util'; import { SignageZoneService } from '../../app/zones/signage-zone.service'; describe('TemplateLayoutListComponent', () => { @@ -308,7 +308,7 @@ describe('TemplateLayoutListComponent', () => { component.setAxis(0, 'x_pos', null); expect(draft()[0].x_pos).toBeUndefined(); expect(component.axisPercentage(draft()[0], 'x_pos')).toBe( - SIDEBAR_WIDTH_PC, + TEMPLATE_SIDEBAR_WIDTH_PC, ); }); diff --git a/apps/signage-manager/src/tests/templates/template-layout.util.spec.ts b/apps/signage-manager/src/tests/templates/template-layout.util.spec.ts index 651b04d2c55..778c9f72739 100644 --- a/apps/signage-manager/src/tests/templates/template-layout.util.spec.ts +++ b/apps/signage-manager/src/tests/templates/template-layout.util.spec.ts @@ -1,12 +1,10 @@ -import { SignageTemplateLayout } from '@placeos/ts-client'; import { - applyLayoutPositionDefaults, - computeTemplateLayoutRects, - EDGE_BAR_HEIGHT_PC, - FLOATING_DEFAULT_X_PC, - FLOATING_DEFAULT_Y_PC, - SIDEBAR_WIDTH_PC, -} from '../../app/templates/template-layout.util'; + TEMPLATE_EDGE_BAR_HEIGHT_PC, + TEMPLATE_FLOATING_POSITION_PC, + TEMPLATE_SIDEBAR_WIDTH_PC, +} from '@placeos/common'; +import { SignageTemplateLayout } from '@placeos/ts-client'; +import { applyLayoutPositionDefaults } from '../../app/templates/template-layout.util'; const layout = ( position: SignageTemplateLayout['position'], @@ -16,20 +14,20 @@ const layout = ( describe('applyLayoutPositionDefaults', () => { it('adds the displayed default for each layout position', () => { expect(applyLayoutPositionDefaults(layout('top'))).toMatchObject({ - y_pos: EDGE_BAR_HEIGHT_PC / 100, + y_pos: TEMPLATE_EDGE_BAR_HEIGHT_PC / 100, }); expect(applyLayoutPositionDefaults(layout('bottom'))).toMatchObject({ - y_pos: EDGE_BAR_HEIGHT_PC / 100, + y_pos: TEMPLATE_EDGE_BAR_HEIGHT_PC / 100, }); expect(applyLayoutPositionDefaults(layout('left'))).toMatchObject({ - x_pos: SIDEBAR_WIDTH_PC / 100, + x_pos: TEMPLATE_SIDEBAR_WIDTH_PC / 100, }); expect(applyLayoutPositionDefaults(layout('right'))).toMatchObject({ - x_pos: SIDEBAR_WIDTH_PC / 100, + x_pos: TEMPLATE_SIDEBAR_WIDTH_PC / 100, }); expect(applyLayoutPositionDefaults(layout('floating'))).toMatchObject({ - x_pos: FLOATING_DEFAULT_X_PC / 100, - y_pos: FLOATING_DEFAULT_Y_PC / 100, + x_pos: TEMPLATE_FLOATING_POSITION_PC / 100, + y_pos: TEMPLATE_FLOATING_POSITION_PC / 100, }); }); @@ -41,127 +39,3 @@ describe('applyLayoutPositionDefaults', () => { ).toMatchObject({ x_pos: 0, y_pos: 0.8 }); }); }); - -describe('computeTemplateLayoutRects', () => { - it('gives a footer inserted first the full frame width', () => { - const [rect] = computeTemplateLayoutRects([layout('bottom')]); - expect(rect).toEqual({ - left: 0, - top: 100 - EDGE_BAR_HEIGHT_PC, - width: 100, - height: EDGE_BAR_HEIGHT_PC, - }); - }); - - it('gives a footer inserted after a sidebar the remaining width', () => { - const [sidebar, footer] = computeTemplateLayoutRects([ - layout('left'), - layout('bottom'), - ]); - expect(sidebar).toEqual({ - left: 0, - top: 0, - width: SIDEBAR_WIDTH_PC, - height: 100, - }); - expect(footer).toEqual({ - left: SIDEBAR_WIDTH_PC, - top: 100 - EDGE_BAR_HEIGHT_PC, - width: 100 - SIDEBAR_WIDTH_PC, - height: EDGE_BAR_HEIGHT_PC, - }); - }); - - it('gives a sidebar inserted after a header the remaining height', () => { - const [header, sidebar] = computeTemplateLayoutRects([ - layout('top'), - layout('right'), - ]); - expect(header.top).toBe(0); - expect(header.width).toBe(100); - expect(sidebar).toEqual({ - left: 100 - SIDEBAR_WIDTH_PC, - top: EDGE_BAR_HEIGHT_PC, - width: SIDEBAR_WIDTH_PC, - height: 100 - EDGE_BAR_HEIGHT_PC, - }); - }); - - it('consumes space in insertion order for every edge panel', () => { - const [top, left, bottom] = computeTemplateLayoutRects([ - layout('top'), - layout('left'), - layout('bottom'), - ]); - expect(top.width).toBe(100); - expect(left.top).toBe(EDGE_BAR_HEIGHT_PC); - expect(left.height).toBe(100 - EDGE_BAR_HEIGHT_PC); - expect(bottom.left).toBe(SIDEBAR_WIDTH_PC); - expect(bottom.width).toBe(100 - SIDEBAR_WIDTH_PC); - }); - - it('sizes edge panels from x_pos/y_pos', () => { - const [header, sidebar] = computeTemplateLayoutRects([ - layout('top', { y_pos: 0.3 }), - layout('left', { x_pos: 0.4 }), - ]); - expect(header.height).toBe(30); - expect(sidebar.width).toBe(40); - expect(sidebar.height).toBe(70); - }); - - it('fills floating panels from their top-left corner without consuming space', () => { - const [float_rect, footer] = computeTemplateLayoutRects([ - layout('floating', { x_pos: 0.1, y_pos: 0.2 }), - layout('bottom'), - ]); - expect(float_rect).toEqual({ - left: 10, - top: 20, - width: 90, - height: 80, - }); - expect(footer.width).toBe(100); - }); - - it('fills the frame with a floating panel that has no position, as the player does', () => { - // apps/signage/src/app/template-layout.ts FLOATING_POSITION - const [rect] = computeTemplateLayoutRects([layout('floating')]); - expect(rect).toEqual({ left: 0, top: 0, width: 100, height: 100 }); - expect(applyLayoutPositionDefaults(layout('floating'))).toMatchObject({ - x_pos: 0, - y_pos: 0, - }); - }); - - it('uses the default corner for floating panels without a position', () => { - const [rect] = computeTemplateLayoutRects([layout('floating')]); - expect(rect).toEqual({ - left: FLOATING_DEFAULT_X_PC, - top: FLOATING_DEFAULT_Y_PC, - width: 100 - FLOATING_DEFAULT_X_PC, - height: 100 - FLOATING_DEFAULT_Y_PC, - }); - }); - - it('clamps values to the frame', () => { - const [top, float_rect] = computeTemplateLayoutRects([ - layout('top', { y_pos: 2.5 }), - layout('floating', { x_pos: -0.2, y_pos: 5 }), - ]); - expect(top.height).toBe(100); - expect(float_rect.left).toBe(0); - expect(float_rect.width).toBe(100); - expect(float_rect.top).toBe(100); - expect(float_rect.height).toBe(0); - }); - - it('never lets edge panels exceed the remaining space', () => { - const rects = computeTemplateLayoutRects( - Array.from({ length: 10 }, () => layout('top')), - ); - const total = rects.reduce((sum, rect) => sum + rect.height, 0); - expect(total).toBeLessThanOrEqual(100); - for (const rect of rects) expect(rect.height).toBeGreaterThanOrEqual(0); - }); -}); diff --git a/apps/signage/src/app/cron-helpers.ts b/apps/signage/src/app/cron-helpers.ts index 2e85c8ff3ed..c7346e3ef91 100644 --- a/apps/signage/src/app/cron-helpers.ts +++ b/apps/signage/src/app/cron-helpers.ts @@ -1,148 +1,31 @@ -function matchesCronPart(value: number, cron_part: string): boolean { - if (cron_part === '*') return true; - if (cron_part.includes(',')) { - return cron_part - .split(',') - .some((item) => matchesCronPart(value, item)); - } - if (cron_part.includes('/')) { - const [base, step] = cron_part.split('/'); - const step_value = Number(step); - if (!step_value) return false; - if (base === '*') return value % step_value === 0; - if (base.includes('-')) { - const [start, end] = base.split('-').map(Number); - if (value < start || value > end) return false; - return (value - start) % step_value === 0; - } - return value % step_value === 0 && matchesCronPart(value, base); - } - if (cron_part.includes('-')) { - const [start, end] = cron_part.split('-').map(Number); - return value >= start && value <= end; - } - return Number(cron_part) === value; -} - -function cronNumber(value: string, min: number, max: number) { - if (!/^\d+$/.test(value || '')) return null; - const number_value = Number(value); - return number_value >= min && number_value <= max ? number_value : null; -} - -function parseCronWeekdays(value: string) { - if (!value?.trim() || value === '*') return []; - const days = new Set(); - for (const part of value.split(',')) { - if (part.includes('-')) { - const [start, end] = part - .split('-') - .map((_) => cronNumber(_, 0, 6)); - if (start === null || end === null || start > end) return []; - for (let day = start; day <= end; day++) days.add(day); - } else { - const day = cronNumber(part, 0, 6); - if (day === null) return []; - days.add(day); - } - } - return [...days]; -} +import { + cronDaySlots, + cronParts, + doesCronMatchDay, + hasPlayableScheduleMask, + matchesCronPart, + type ScheduleMask, +} from '@placeos/common'; -function parseCronWeekOfMonthRange(value: string) { - const match = /^(\d+)-(\d+)$/.exec(value || ''); - if (!match) return null; - const start = Number(match[1]); - const end = Number(match[2]); - if (start === 29 && end === 31) return 5; - if ((start - 1) % 7 !== 0 || end !== start + 6) return null; - const week = (start - 1) / 7 + 1; - return week >= 1 && week <= 4 ? week : null; -} - -function parseCronWeeksOfMonth(value: string) { - if (!value?.trim() || value === '*') return []; - const weeks = new Set(); - for (const part of value.split(',')) { - const week = parseCronWeekOfMonthRange(part); - if (week === null) return []; - weeks.add(week); - } - return [...weeks]; -} - -function isCronMonthlyWeekday( - day_of_month_part: string, - day_of_week_part: string, -) { +/** Whether all five cron fields match the local date and time */ +function doesCronMatchDate(parts: readonly string[], date: Date) { return ( - !!parseCronWeeksOfMonth(day_of_month_part).length && - !!parseCronWeekdays(day_of_week_part).length + matchesCronPart(date.getMinutes(), parts[0]) && + matchesCronPart(date.getHours(), parts[1]) && + doesCronMatchDay(parts, date) ); } -function doesCronMatchDate(cron_parts: string[], date: Date) { - const [ - minute_part, - hour_part, - day_of_month_part, - month_part, - day_of_week_part, - ] = cron_parts; - if (!matchesCronPart(date.getMinutes(), minute_part)) return false; - if (!matchesCronPart(date.getHours(), hour_part)) return false; - if (!matchesCronPart(date.getMonth() + 1, month_part)) return false; - const day_of_month_matches = matchesCronPart( - date.getDate(), - day_of_month_part, - ); - const day_of_week_matches = matchesCronPart( - date.getDay(), - day_of_week_part, - ); - if (day_of_month_part === '*' && day_of_week_part === '*') return true; - if (day_of_month_part !== '*' && day_of_week_part === '*') { - return day_of_month_matches; - } - if (day_of_month_part === '*' && day_of_week_part !== '*') { - return day_of_week_matches; - } - if (isCronMonthlyWeekday(day_of_month_part, day_of_week_part)) { - return day_of_month_matches && day_of_week_matches; - } - return day_of_month_matches || day_of_week_matches; -} - -function cronParts(cron_string: string) { - const parts = cron_string.trim().split(/\s+/); - if (parts.length !== 5) { - throw new Error('Invalid CRON string: Must have 5 parts.'); - } +/** + * The five fields of a cron. + * @throws An error when the cron does not have five fields + */ +function requireCronParts(cron_string: string) { + const parts = cronParts(cron_string); + if (!parts) throw new Error('Invalid CRON string: Must have 5 parts.'); return parts; } -/** Schedule start is in Unix seconds; mask characters repeat by cron occurrence. */ -export interface ScheduleMask { - readonly valid_from?: number; - readonly mask?: string; -} - -/** Whether a schedule can have an enabled occurrence. */ -export function hasPlayableScheduleMask({ - mask = '', - valid_from, -}: ScheduleMask) { - return ( - !mask || - (mask.length <= 128 && - !/[^01]/.test(mask) && - mask.includes('1') && - Number.isFinite(valid_from) && - valid_from > 0 && - Number.isFinite(new Date(valid_from * 1000).getTime())) - ); -} - const MASK_FILTER_CACHE = new Map boolean>(); /** Calculate the candidate occurrence index from cached calendar totals. */ @@ -154,22 +37,14 @@ export function createScheduleMaskFilter(cron: string, schedule: ScheduleMask) { const key = JSON.stringify([cron, mask, valid_from]); const cached = MASK_FILTER_CACHE.get(key); if (cached) return cached; - const parts = cronParts(cron); - const slots: number[] = []; - for (let hour = 0; hour < 24; hour++) { - if (!matchesCronPart(hour, parts[1])) continue; - for (let minute = 0; minute < 60; minute++) { - if (matchesCronPart(minute, parts[0])) - slots.push(hour * 60 + minute); - } - } - const calendar_parts = cronDayParts(parts); + const parts = requireCronParts(cron); + const slots = cronDaySlots(parts); const first_day = new Date(anchor); first_day.setHours(0, 0, 0, 0); const month_totals = new Map(); const countBefore = (day: Date, before: number) => { if (!slots.length || day.getTime() + 2 * 86_400_000 < anchor) return 0; - if (!doesCronMatchDate(calendar_parts, day)) return 0; + if (!doesCronMatchDay(parts, day)) return 0; const next_day = new Date(day); next_day.setDate(next_day.getDate() + 1); if (next_day.getTime() <= anchor) return 0; @@ -265,26 +140,10 @@ function isFirstLocalOccurrence(date: Date) { } /** Whether a cron runs at `date`, a whole minute, in local time */ -function isCronRun(cron_parts: string[], date: Date) { +function isCronRun(cron_parts: readonly string[], date: Date) { return doesCronMatchDate(cron_parts, date) && isFirstLocalOccurrence(date); } -/** Whether a cron can run at some time of day; `61 * * * *` never can */ -function hasCronTimeOfDay([minute_part, hour_part]: string[]) { - for (let hour = 0; hour < 24; hour++) { - if (!matchesCronPart(hour, hour_part)) continue; - for (let minute = 0; minute < 60; minute++) { - if (matchesCronPart(minute, minute_part)) return true; - } - } - return false; -} - -/** Cron fields that match any time on the days the cron runs */ -function cronDayParts(cron_parts: string[]) { - return ['*', '*', ...cron_parts.slice(2)]; -} - /** Start of the local day `offset_days` from the day of `date` */ function localDayStart(date: Date, offset_days = 0) { return new Date( @@ -351,24 +210,24 @@ export function getNextCronRunTimestampInRange( now = Date.now(), schedule: ScheduleMask = {}, ): number | null { - const parts = cronParts(cron_string); + const parts = requireCronParts(cron_string); if (!hasPlayableScheduleMask(schedule)) return null; const allows = createScheduleMaskFilter(cron_string, schedule); const mask_key = JSON.stringify([schedule.valid_from, schedule.mask]); const key = `next|${cron_string}|${search_limit_in_seconds}|${mask_key}`; return cachedCronLookup(key, now, search_limit_in_seconds, () => { + // `61 * * * *` never runs at any time of day + if (!cronDaySlots(parts).length) return null; // Steps in UTC, not local time: a local step resolves the hour that // repeats when the clocks go back to its first occurrence, which // would move the search an hour into the past. - if (!hasCronTimeOfDay(parts)) return null; - const day_parts = cronDayParts(parts); const limit = now + searchLimitMs(search_limit_in_seconds); const start = Math.floor(now / MINUTE_MS) * MINUTE_MS + MINUTE_MS; const current_date = new Date(start); // Every step moves forwards, by a minute or to the next day for (let time = start; time <= limit; ) { current_date.setTime(time); - if (!doesCronMatchDate(day_parts, current_date)) { + if (!doesCronMatchDay(parts, current_date)) { time = localDayStart(current_date, 1); continue; } @@ -403,22 +262,21 @@ export function getLastCronRunTimestampInRange( now = Date.now(), schedule: ScheduleMask = {}, ): number | null { - const parts = cronParts(cron_string); + const parts = requireCronParts(cron_string); if (!hasPlayableScheduleMask(schedule)) return null; const allows = createScheduleMaskFilter(cron_string, schedule); const mask_key = JSON.stringify([schedule.valid_from, schedule.mask]); const key = `last|${cron_string}|${search_limit_in_seconds}|${mask_key}`; return cachedCronLookup(key, now, search_limit_in_seconds, () => { + if (!cronDaySlots(parts).length) return null; // Steps in UTC for the same reason as the forwards search. - if (!hasCronTimeOfDay(parts)) return null; - const day_parts = cronDayParts(parts); const limit = now - searchLimitMs(search_limit_in_seconds); const start = Math.floor(now / MINUTE_MS) * MINUTE_MS; const current_date = new Date(start); // Every step moves backwards, by a minute or to the day before for (let time = start; time >= limit; ) { current_date.setTime(time); - if (!doesCronMatchDate(day_parts, current_date)) { + if (!doesCronMatchDay(parts, current_date)) { time = localDayStart(current_date) - MINUTE_MS; continue; } diff --git a/apps/signage/src/app/media-helpers.ts b/apps/signage/src/app/media-helpers.ts index bf5df2fb94f..fedb1b94681 100644 --- a/apps/signage/src/app/media-helpers.ts +++ b/apps/signage/src/app/media-helpers.ts @@ -1,22 +1,5 @@ -import { MediaAnimation } from '@placeos/ts-client'; import { MediaPlayerItem } from './types'; -/** The API returns animation indexes as well as names. */ -export function mediaAnimation(value: unknown): MediaAnimation { - const animations = Object.values(MediaAnimation); - if (typeof value === 'number') { - return Number.isInteger(value) && - value >= 0 && - value < animations.length - ? animations[value] - : MediaAnimation.Default; - } - return ( - animations.find((animation) => animation === value) || - MediaAnimation.Default - ); -} - let _time_override = 0; let _time_anchor: number | null = null; let _time_speed = 0; diff --git a/apps/signage/src/app/signage.service.ts b/apps/signage/src/app/signage.service.ts index 079901593bb..00c91f225e0 100644 --- a/apps/signage/src/app/signage.service.ts +++ b/apps/signage/src/app/signage.service.ts @@ -1,7 +1,12 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import { AsyncHandler, + DEFAULT_PLAY_PERIOD_MINUTES, + hasPlayableScheduleMask, + isPlayOnceSchedule, + mediaAnimation, MINUTES, + parsePlayAtLocal, randomInt, scoped_log, SECONDS, @@ -17,18 +22,17 @@ import { showSignage, SignageMedia, SignagePlaylist, + SignagePlaylistSchedule, SignagePlugin, SignageTemplateMapping, } from '@placeos/ts-client'; import { getLastCronRunTimestampInRange, getNextCronRunTimestampInRange, - hasPlayableScheduleMask, } from './cron-helpers'; import { MediaCacheService } from './media-cache.service'; import { isMediaValid, - mediaAnimation, mockTimeState, time, validateMedia, @@ -68,17 +72,8 @@ interface SignageMetrics { */ type ScheduleKind = 'background' | 'takeover' | 'single-pass'; -interface PlaylistSchedule { - readonly play_cron?: string; - readonly play_period?: number; - readonly play_at?: number; - /** Wall-clock play once time with no offset, e.g. "2027-01-01T00:00:00" */ - readonly play_at_local?: string; - readonly play_takeover?: boolean; - readonly valid_until?: number; - readonly valid_from?: number; - readonly mask?: string; -} +/** A playlist schedule from the API, which can omit any field */ +type PlaylistSchedule = Partial; interface ActivePlaylistSchedule { playlist: SignagePlaylist; @@ -127,7 +122,6 @@ const METRIC_TYPES: readonly (keyof SignageMetrics)[] = [ 'media_counts', ]; -const DEFAULT_PLAY_PERIOD_MINUTES = 24 * 60; const SINGLE_PASS_TRIGGER_WINDOW_MS = 30 * 1000; /** How far ahead media for a not-yet-active scheduled playlist is downloaded */ const MEDIA_CACHE_LOOK_AHEAD_SECONDS = 24 * 60 * 60; @@ -231,38 +225,14 @@ function parsePlayAtTimestamp(value: number) { return value * 1000; } -const PLAY_AT_LOCAL_PATTERN = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/; - /** - * Parse a `play_at_local` value in the display's timezone. Returns 0 when - * the value is not an ISO 8601 date time with no offset. + * Start of a play once schedule in milliseconds; 0 when not valid. The + * display's timezone resolves `play_at_local` values. */ -function parsePlayAtLocalTimestamp(value = '') { - const match = PLAY_AT_LOCAL_PATTERN.exec(value); - if (!match) return 0; - const [year, month, day, hours, minutes, seconds] = match - .slice(1) - .map(Number); - const date = new Date(year, month - 1, day, hours, minutes, seconds); - // Reject values such as February 30 or 00:60 that roll over. - return date.getDate() === day && - date.getMonth() === month - 1 && - minutes < 60 && - seconds < 60 - ? date.getTime() - : 0; -} - -function isPlayOnceSchedule(schedule: PlaylistSchedule) { - return !!schedule.play_at || !!schedule.play_at_local; -} - -/** Start of a play once schedule in milliseconds; 0 when not valid */ function playOnceTimestamp(schedule: PlaylistSchedule) { return schedule.play_at ? parsePlayAtTimestamp(schedule.play_at) - : parsePlayAtLocalTimestamp(schedule.play_at_local); + : (parsePlayAtLocal(schedule.play_at_local) ?? 0); } function parseValidUntilTimestamp(value: number | undefined) { diff --git a/apps/signage/src/app/template.component.ts b/apps/signage/src/app/template.component.ts index 69c8578affa..c0d53747f19 100644 --- a/apps/signage/src/app/template.component.ts +++ b/apps/signage/src/app/template.component.ts @@ -1,7 +1,15 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { toObservable } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { AsyncHandler, log } from '@placeos/common'; +import { + AsyncHandler, + computeTemplateLayout, + log, + mediaAnimation, + SIGNAGE_PREVIEW_LAYOUTS_MESSAGE, + SIGNAGE_PREVIEW_READY_MESSAGE, + TemplateLayoutRect, +} from '@placeos/common'; import { PluginConfigPayload, PluginEmbedComponent } from '@placeos/components'; import { MediaAnimation, @@ -16,18 +24,12 @@ import { } from '@placeos/ts-client'; import { isDebugEnabled } from './debug-state'; import { MediaCacheService } from './media-cache.service'; -import { mediaAnimation } from './media-helpers'; import { MediaPlayerComponent } from './media-player.component'; import { SignagePanelComponent } from './signage.component'; import { SignageService } from './signage.service'; -import { computeTemplateLayout, TemplateLayoutRect } from './template-layout'; import { MediaPlayerItem } from './types'; const STORE_DISPLAY_KEY = 'PlaceOS.SIGNAGE.display'; -/** Message type the signage manager posts with unsaved layouts to preview */ -const PREVIEW_LAYOUTS_MESSAGE = 'signage:template-layouts'; -/** Message type posted to the signage manager to request its unsaved layouts */ -const PREVIEW_READY_MESSAGE = 'signage:template-preview-ready'; /** First delay before a failed template load is tried again */ const RETRY_BASE_MS = 15_000; /** Longest delay between template load retries */ @@ -261,7 +263,8 @@ export class SignageTemplateComponent extends AsyncHandler implements OnInit { private readonly _preview_message_handler = (event: MessageEvent) => { if (window.parent === window || event?.source !== window.parent) return; const data = event?.data; - if (!this.debug() || data?.type !== PREVIEW_LAYOUTS_MESSAGE) return; + if (!this.debug() || data?.type !== SIGNAGE_PREVIEW_LAYOUTS_MESSAGE) + return; this._preview_layouts.set( Array.isArray(data.layouts) ? data.layouts : null, ); @@ -275,7 +278,7 @@ export class SignageTemplateComponent extends AsyncHandler implements OnInit { */ private _requestPreviewLayouts() { if (!this.debug() || window.parent === window) return; - window.parent.postMessage({ type: PREVIEW_READY_MESSAGE }, '*'); + window.parent.postMessage({ type: SIGNAGE_PREVIEW_READY_MESSAGE }, '*'); } private _bootstrapTemplate(template_id: string) { diff --git a/apps/signage/src/tests/media-helpers.spec.ts b/apps/signage/src/tests/media-helpers.spec.ts index 7a46d2b9aa7..8ca700c8ad8 100644 --- a/apps/signage/src/tests/media-helpers.spec.ts +++ b/apps/signage/src/tests/media-helpers.spec.ts @@ -2,7 +2,6 @@ import { MediaAnimation } from '@placeos/ts-client'; import { findValidPlaylistIndex, isMediaValid, - mediaAnimation, mockTimeState, setMockTime, time, @@ -28,22 +27,6 @@ const create_item = ( getURL: async () => '', }); -describe('API media animations', () => { - it.each([ - [1, MediaAnimation.Cut], - [2, MediaAnimation.CrossFade], - [0, MediaAnimation.Default], - [MediaAnimation.Cut, MediaAnimation.Cut], - [MediaAnimation.CrossFade, MediaAnimation.CrossFade], - [-1, MediaAnimation.Default], - [1.5, MediaAnimation.Default], - [999, MediaAnimation.Default], - [undefined, MediaAnimation.Default], - ])('converts %s to %s', (value, expected) => { - expect(mediaAnimation(value)).toBe(expected); - }); -}); - function formatTestTime(date: number) { return new Intl.DateTimeFormat(undefined, { day: 'numeric', diff --git a/apps/signage/src/tests/template-layout.spec.ts b/apps/signage/src/tests/template-layout.spec.ts deleted file mode 100644 index 27b9f6375c3..00000000000 --- a/apps/signage/src/tests/template-layout.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { SignageTemplateLayout } from '@placeos/ts-client'; - -import { computeTemplateLayout } from '../app/template-layout'; - -const layout = ( - position: SignageTemplateLayout['position'], - values: Partial = {}, -): SignageTemplateLayout => ({ position, plugin_params: {}, ...values }); - -describe('computeTemplateLayout', () => { - it('places edge items in order and leaves the rest for the player', () => { - const result = computeTemplateLayout([ - layout('top', { y_pos: 0.1 }), - layout('left', { x_pos: 0.25 }), - layout('bottom', { y_pos: 0.2 }), - ]); - - expect(result.items.map((item) => item.rect)).toEqual([ - { left: 0, top: 0, width: 100, height: 10 }, - { left: 0, top: 10, width: 25, height: 90 }, - { left: 25, top: 80, width: 75, height: 20 }, - ]); - expect(result.player).toEqual({ - left: 25, - top: 10, - width: 75, - height: 70, - }); - }); - - it('does not remove floating items from the player space', () => { - const result = computeTemplateLayout([ - layout('floating', { x_pos: 0.2, y_pos: 0.3 }), - ]); - - expect(result.items[0].rect).toEqual({ - left: 20, - top: 30, - width: 80, - height: 70, - }); - expect(result.player).toEqual({ - left: 0, - top: 0, - width: 100, - height: 100, - }); - }); - - it('fills the frame with a floating item that has no position', () => { - const result = computeTemplateLayout([layout('floating')]); - - expect(result.items[0].rect).toEqual({ - left: 0, - top: 0, - width: 100, - height: 100, - }); - }); - - it('uses defaults and constrains item dimensions to the available space', () => { - const result = computeTemplateLayout([ - layout('right'), - layout('top', { y_pos: 2 }), - ]); - - expect(result.items.map((item) => item.rect)).toEqual([ - { left: 80, top: 0, width: 20, height: 100 }, - { left: 0, top: 0, width: 80, height: 100 }, - ]); - expect(result.player).toEqual({ - left: 0, - top: 100, - width: 80, - height: 0, - }); - }); -}); diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 2b58b3a49ea..559c347e9a3 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -20,14 +20,17 @@ export * from './lib/sentry'; export * from './lib/settings-title-strategy.service'; export * from './lib/settings.service'; export * from './lib/shorten.fn'; +export * from './lib/signage/cron'; +export * from './lib/signage/playlist'; +export * from './lib/signage/template'; export * from './lib/teams-host'; export * from './lib/timezone-helpers'; export * from './lib/token-handoff'; export * from './lib/uploads.service'; export * from './lib/user-group-sync'; export * from './lib/user-idle-time.service'; -export * from './lib/value-accessor'; export { userSignal } from './lib/user-state'; +export * from './lib/value-accessor'; export * from './lib/types/asset-request.class'; export * from './lib/types/asset.class'; diff --git a/libs/common/src/lib/signage/cron.spec.ts b/libs/common/src/lib/signage/cron.spec.ts new file mode 100644 index 00000000000..e663dcda6d8 --- /dev/null +++ b/libs/common/src/lib/signage/cron.spec.ts @@ -0,0 +1,93 @@ +import { + cronDaySlots, + cronParts, + doesCronMatchDay, + isCronMonthlyWeekday, + matchesCronPart, + parseCronWeekdays, + parseCronWeeksOfMonth, +} from './cron'; + +/** Fields of a cron that is known to be valid */ +const parts = (cron: string) => cronParts(cron) || []; + +/** Values from `min` to `max` that match the cron field */ +function matches(part: string, min: number, max: number) { + return Array.from({ length: max - min + 1 }, (_, i) => min + i).filter( + (value) => matchesCronPart(value, part), + ); +} + +// Some rules differ from standard cron on purpose. Displays already play +// schedules by these rules. +describe('signage cron rules', () => { + it('matches */n on values that divide by n', () => { + expect(matches('*/2', 1, 9)).toEqual([2, 4, 6, 8]); + expect(matches('*/15', 0, 59)).toEqual([0, 15, 30, 45]); + }); + + it('steps a range from its start', () => { + expect(matches('9-17/2', 0, 23)).toEqual([9, 11, 13, 15, 17]); + expect(matches('10-17/3', 0, 23)).toEqual([10, 13, 16]); + }); + + it('matches a/n only when a divides by n', () => { + expect(matches('5/15', 0, 59)).toEqual([]); + expect(matches('0/15', 0, 59)).toEqual([0]); + }); + + it('does not treat day of week 7 as Sunday', () => { + const sunday = new Date(2026, 9, 4, 12); + expect(doesCronMatchDay(parts('0 9 * * 7'), sunday)).toBe(false); + expect(doesCronMatchDay(parts('0 9 * * 0'), sunday)).toBe(true); + }); + + it('needs both day fields only for week of month ranges', () => { + const first_monday = new Date(2026, 9, 5, 12); + const second_monday = new Date(2026, 9, 12, 12); + const monthly_weekday = parts('0 9 1-7 * 1'); + expect(doesCronMatchDay(monthly_weekday, first_monday)).toBe(true); + expect(doesCronMatchDay(monthly_weekday, second_monday)).toBe(false); + // Not a week range, so either field is enough + expect(doesCronMatchDay(parts('0 9 1-10 * 1'), second_monday)).toBe( + true, + ); + }); + + it('splits five fields and rejects other crons', () => { + expect(cronParts(' 0 9 * * 1-5 ')).toEqual([ + '0', + '9', + '*', + '*', + '1-5', + ]); + expect(cronParts('0 9 * *')).toBeNull(); + expect(cronParts(undefined)).toBeNull(); + }); + + it('lists the minutes of the day that a cron runs at, in order', () => { + expect(cronDaySlots(parts('30 9-10 * * *'))).toEqual([570, 630]); + expect(cronDaySlots(parts('61 * * * *'))).toEqual([]); + }); +}); + +describe('monthly weekday fields', () => { + it('reads weekday lists in order and rejects other values', () => { + expect(parseCronWeekdays('5,1-3')).toEqual([1, 2, 3, 5]); + expect(parseCronWeekdays('1,7')).toEqual([]); + expect(parseCronWeekdays('*')).toEqual([]); + }); + + it('reads week of month ranges in order and rejects other days', () => { + expect(parseCronWeeksOfMonth('15-21,1-7')).toEqual([1, 3]); + expect(parseCronWeeksOfMonth('29-31')).toEqual([5]); + expect(parseCronWeeksOfMonth('1-10')).toEqual([]); + }); + + it('needs week ranges and weekdays for a monthly weekday', () => { + expect(isCronMonthlyWeekday('1-7', '1')).toBe(true); + expect(isCronMonthlyWeekday('1-7', '*')).toBe(false); + expect(isCronMonthlyWeekday('1,15', '1')).toBe(false); + }); +}); diff --git a/libs/common/src/lib/signage/cron.ts b/libs/common/src/lib/signage/cron.ts new file mode 100644 index 00000000000..4c95df63a99 --- /dev/null +++ b/libs/common/src/lib/signage/cron.ts @@ -0,0 +1,133 @@ +/** + * Cron rules for signage schedules. + * + * The signage player decides what a display plays, and Signage Manager shows + * when it plays. Both use these rules. They differ from standard cron: + * - `*\/n` matches values that divide by n. Day of month `*\/2` is 2, 4, 6... + * - `a-b/n` matches a, a + n, a + 2n... up to b. + * - `a/n` matches only a, and only when a divides by n. `5/15` never matches. + * - Day of week 7 does not match Sunday. Use 0. + * - When day of month and day of week are both set, a day matches either one. + * Week of month ranges (1-7, 8-14, 15-21, 22-28, 29-31) with weekdays must + * match both, e.g. "first Monday". + */ + +/** The five cron fields, or null when the cron does not have five fields. */ +export function cronParts(cron: string | null | undefined) { + const parts = (cron || '').trim().split(/\s+/); + return parts.length === 5 ? parts : null; +} + +/** Whether a value matches one cron field. */ +export function matchesCronPart(value: number, cron_part: string): boolean { + if (cron_part === '*') return true; + if (cron_part.includes(',')) { + return cron_part + .split(',') + .some((item) => matchesCronPart(value, item)); + } + if (cron_part.includes('/')) { + const [base, step] = cron_part.split('/'); + const step_value = Number(step); + if (!step_value) return false; + if (base === '*') return value % step_value === 0; + if (base.includes('-')) { + const [start, end] = base.split('-').map(Number); + if (value < start || value > end) return false; + return (value - start) % step_value === 0; + } + return value % step_value === 0 && matchesCronPart(value, base); + } + if (cron_part.includes('-')) { + const [start, end] = cron_part.split('-').map(Number); + return value >= start && value <= end; + } + return Number(cron_part) === value; +} + +/** A whole number from `min` to `max`, or null. */ +export function parseCronNumber(value: string, min: number, max: number) { + if (!/^\d+$/.test(value || '')) return null; + const number_value = Number(value); + return number_value >= min && number_value <= max ? number_value : null; +} + +/** Weekdays (0 is Sunday) of a list such as "1,3,5" or "1-5", in order. Empty when the field is not a plain list. */ +export function parseCronWeekdays(value: string) { + if (!value?.trim() || value === '*') return []; + const days = new Set(); + for (const part of value.split(',')) { + if (part.includes('-')) { + const [start, end] = part + .split('-') + .map((_) => parseCronNumber(_, 0, 6)); + if (start === null || end === null || start > end) return []; + for (let day = start; day <= end; day++) days.add(day); + } else { + const day = parseCronNumber(part, 0, 6); + if (day === null) return []; + days.add(day); + } + } + return [...days].sort((a, b) => a - b); +} + +/** Week of the month (1 to 5) of a day range such as "8-14", or null. */ +function parseCronWeekOfMonthRange(value: string) { + const match = /^(\d+)-(\d+)$/.exec(value || ''); + if (!match) return null; + const start = Number(match[1]); + const end = Number(match[2]); + if (start === 29 && end === 31) return 5; + if ((start - 1) % 7 !== 0 || end !== start + 6) return null; + const week = (start - 1) / 7 + 1; + return week >= 1 && week <= 4 ? week : null; +} + +/** Weeks of the month (1 to 5) of day ranges such as "1-7,15-21", in order. Empty when the field has other values. */ +export function parseCronWeeksOfMonth(value: string) { + if (!value?.trim() || value === '*') return []; + const weeks = new Set(); + for (const part of value.split(',')) { + const week = parseCronWeekOfMonthRange(part); + if (week === null) return []; + weeks.add(week); + } + return [...weeks].sort((a, b) => a - b); +} + +/** Whether the day fields mean "these weekdays in these weeks of the month". */ +export function isCronMonthlyWeekday(day_part: string, weekday_part: string) { + return ( + !!parseCronWeeksOfMonth(day_part).length && + !!parseCronWeekdays(weekday_part).length + ); +} + +/** Whether the calendar fields (day, month, weekday) match the local date. */ +export function doesCronMatchDay(parts: readonly string[], date: Date) { + const [, , day_part, month_part, weekday_part] = parts; + if (!matchesCronPart(date.getMonth() + 1, month_part)) return false; + if (day_part === '*' && weekday_part === '*') return true; + const day_matches = matchesCronPart(date.getDate(), day_part); + if (weekday_part === '*') return day_matches; + const weekday_matches = matchesCronPart(date.getDay(), weekday_part); + if (day_part === '*') return weekday_matches; + if (isCronMonthlyWeekday(day_part, weekday_part)) { + return day_matches && weekday_matches; + } + return day_matches || weekday_matches; +} + +/** Minutes after midnight that the minute and hour fields match, in order. */ +export function cronDaySlots(parts: readonly string[]) { + const slots: number[] = []; + for (let hour = 0; hour < 24; hour++) { + if (!matchesCronPart(hour, parts[1])) continue; + for (let minute = 0; minute < 60; minute++) { + if (matchesCronPart(minute, parts[0])) + slots.push(hour * 60 + minute); + } + } + return slots; +} diff --git a/libs/common/src/lib/signage/playlist.spec.ts b/libs/common/src/lib/signage/playlist.spec.ts new file mode 100644 index 00000000000..ceeeb08351f --- /dev/null +++ b/libs/common/src/lib/signage/playlist.spec.ts @@ -0,0 +1,77 @@ +import { MediaAnimation } from '@placeos/ts-client'; +import { + hasPlayableScheduleMask, + mediaAnimation, + parsePlayAtLocal, +} from './playlist'; + +describe('hasPlayableScheduleMask', () => { + const valid_from = new Date(2026, 2, 2).getTime() / 1000; + + it('plays a schedule with no mask', () => { + expect(hasPlayableScheduleMask({})).toBe(true); + expect(hasPlayableScheduleMask({ mask: '' })).toBe(true); + }); + + it('needs a valid mask with at least one occurrence', () => { + expect(hasPlayableScheduleMask({ mask: '01', valid_from })).toBe(true); + for (const mask of ['000', '10x', '1'.repeat(129), ' 1']) { + expect(hasPlayableScheduleMask({ mask, valid_from })).toBe(false); + } + }); + + // The player cannot count occurrences without a valid start date + it.each([undefined, 0, -60, Number.NaN, 9e15])( + 'does not play a mask that starts at %s', + (value) => { + expect( + hasPlayableScheduleMask({ mask: '1', valid_from: value }), + ).toBe(false); + }, + ); +}); + +describe('parsePlayAtLocal', () => { + it('reads a date time with no offset in the local timezone', () => { + expect(parsePlayAtLocal('2027-01-01T00:00:00')).toBe( + new Date(2027, 0, 1).getTime(), + ); + }); + + it.each([ + '2027-01-01T00:00:00Z', + '2027-01-01T00:00', + '2027-02-30T00:00:00', + '2027-01-01T00:60:00', + '', + null, + undefined, + ])('rejects %s', (value) => { + expect(parsePlayAtLocal(value)).toBeNull(); + }); +}); + +describe('mediaAnimation', () => { + // The API stores the name it is sent, but returns its index + it.each([ + [0, MediaAnimation.Default], + [1, MediaAnimation.Cut], + [2, MediaAnimation.CrossFade], + [4, MediaAnimation.SlideLeft], + [6, MediaAnimation.SlideBottom], + ])('maps index %s to %s', (value, expected) => { + expect(mediaAnimation(value)).toBe(expected); + }); + + it('keeps an animation name', () => { + expect(mediaAnimation(MediaAnimation.Cut)).toBe(MediaAnimation.Cut); + expect(mediaAnimation('cross_fade')).toBe(MediaAnimation.CrossFade); + }); + + it.each([-1, 7, 999, 1.5, Number.NaN, 'spin', null, undefined])( + 'uses the default animation for %s', + (value) => { + expect(mediaAnimation(value)).toBe(MediaAnimation.Default); + }, + ); +}); diff --git a/libs/common/src/lib/signage/playlist.ts b/libs/common/src/lib/signage/playlist.ts new file mode 100644 index 00000000000..f8f62509390 --- /dev/null +++ b/libs/common/src/lib/signage/playlist.ts @@ -0,0 +1,84 @@ +import { MediaAnimation, SignagePlaylistSchedule } from '@placeos/ts-client'; + +/** Play period of a schedule that does not set one: the whole day */ +export const DEFAULT_PLAY_PERIOD_MINUTES = 24 * 60; + +/** Schedule start in Unix seconds and the mask that repeats by occurrence. */ +export type ScheduleMask = Pick; + +/** Whether a mask is valid: 1 to 128 characters of `0` and `1`. */ +export function isValidScheduleMask(mask: string) { + return mask.length > 0 && mask.length <= 128 && !/[^01]/.test(mask); +} + +/** + * Whether a schedule can have an enabled occurrence. A mask needs at least + * one `1` and a `valid_from` that is a valid date after 1970 to count from. + */ +export function hasPlayableScheduleMask({ + mask = '', + valid_from, +}: ScheduleMask) { + return ( + !mask || + (isValidScheduleMask(mask) && + mask.includes('1') && + Number.isFinite(valid_from) && + valid_from > 0 && + Number.isFinite(new Date(valid_from * 1000).getTime())) + ); +} + +/** Whether the schedule plays once at `play_at` or `play_at_local`. */ +export function isPlayOnceSchedule( + schedule: Pick, +) { + return !!schedule.play_at || !!schedule.play_at_local; +} + +const PLAY_AT_LOCAL_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/; + +/** + * Parse a `play_at_local` value as a wall-clock time in the local timezone. + * @returns The time in milliseconds, or null when the value is not an + * ISO 8601 date time with no offset + */ +export function parsePlayAtLocal(value: string | null | undefined) { + const match = PLAY_AT_LOCAL_PATTERN.exec(value || ''); + if (!match) return null; + const [year, month, day, hours, minutes, seconds] = match + .slice(1) + .map(Number); + const date = new Date(year, month - 1, day, hours, minutes, seconds); + // Reject values such as February 30 or 00:60 that roll over. + return date.getDate() === day && + date.getMonth() === month - 1 && + minutes < 60 && + seconds < 60 + ? date.getTime() + : null; +} + +const MEDIA_ANIMATIONS = Object.values(MediaAnimation); + +/** + * Animation as a `MediaAnimation` value. The API accepts animation names + * but stores and returns the index of the name in `MediaAnimation`, e.g. + * 2 for `cross_fade`. + * @returns The default animation for an unknown name, an index out of range + * or no value + */ +export function mediaAnimation(value: unknown): MediaAnimation { + if (typeof value === 'number') { + return Number.isInteger(value) && + value >= 0 && + value < MEDIA_ANIMATIONS.length + ? MEDIA_ANIMATIONS[value] + : MediaAnimation.Default; + } + return ( + MEDIA_ANIMATIONS.find((animation) => animation === value) || + MediaAnimation.Default + ); +} diff --git a/libs/common/src/lib/signage/template.spec.ts b/libs/common/src/lib/signage/template.spec.ts new file mode 100644 index 00000000000..8698412149f --- /dev/null +++ b/libs/common/src/lib/signage/template.spec.ts @@ -0,0 +1,140 @@ +import { SignageTemplateLayout } from '@placeos/ts-client'; +import { + computeTemplateLayout, + TEMPLATE_EDGE_BAR_HEIGHT_PC, + TEMPLATE_SIDEBAR_WIDTH_PC, +} from './template'; + +const layout = ( + position: SignageTemplateLayout['position'], + values: Partial = {}, +): SignageTemplateLayout => ({ position, plugin_params: {}, ...values }); + +/** Rectangles of the layouts, in order */ +const rects = (layouts: SignageTemplateLayout[]) => + computeTemplateLayout(layouts).items.map((item) => item.rect); + +describe('computeTemplateLayout', () => { + it('places edge items in order and leaves the rest for the player', () => { + const result = computeTemplateLayout([ + layout('top', { y_pos: 0.1 }), + layout('left', { x_pos: 0.25 }), + layout('bottom', { y_pos: 0.2 }), + ]); + + expect(result.items.map((item) => item.rect)).toEqual([ + { left: 0, top: 0, width: 100, height: 10 }, + { left: 0, top: 10, width: 25, height: 90 }, + { left: 25, top: 80, width: 75, height: 20 }, + ]); + expect(result.player).toEqual({ + left: 25, + top: 10, + width: 75, + height: 70, + }); + }); + + it('gives a footer inserted first the full frame width', () => { + expect(rects([layout('bottom')])).toEqual([ + { + left: 0, + top: 100 - TEMPLATE_EDGE_BAR_HEIGHT_PC, + width: 100, + height: TEMPLATE_EDGE_BAR_HEIGHT_PC, + }, + ]); + }); + + it('gives a footer inserted after a sidebar the remaining width', () => { + expect(rects([layout('left'), layout('bottom')])).toEqual([ + { left: 0, top: 0, width: TEMPLATE_SIDEBAR_WIDTH_PC, height: 100 }, + { + left: TEMPLATE_SIDEBAR_WIDTH_PC, + top: 100 - TEMPLATE_EDGE_BAR_HEIGHT_PC, + width: 100 - TEMPLATE_SIDEBAR_WIDTH_PC, + height: TEMPLATE_EDGE_BAR_HEIGHT_PC, + }, + ]); + }); + + it('gives a sidebar inserted after a header the remaining height', () => { + const [header, sidebar] = rects([layout('top'), layout('right')]); + expect(header.top).toBe(0); + expect(header.width).toBe(100); + expect(sidebar).toEqual({ + left: 100 - TEMPLATE_SIDEBAR_WIDTH_PC, + top: TEMPLATE_EDGE_BAR_HEIGHT_PC, + width: TEMPLATE_SIDEBAR_WIDTH_PC, + height: 100 - TEMPLATE_EDGE_BAR_HEIGHT_PC, + }); + }); + + it('sizes edge panels from x_pos/y_pos', () => { + const [header, sidebar] = rects([ + layout('top', { y_pos: 0.3 }), + layout('left', { x_pos: 0.4 }), + ]); + expect(header.height).toBe(30); + expect(sidebar.width).toBe(40); + expect(sidebar.height).toBe(70); + }); + + it('fills floating items from their top-left corner without taking player space', () => { + const result = computeTemplateLayout([ + layout('floating', { x_pos: 0.2, y_pos: 0.3 }), + layout('bottom'), + ]); + + expect(result.items[0].rect).toEqual({ + left: 20, + top: 30, + width: 80, + height: 70, + }); + expect(result.items[1].rect.width).toBe(100); + expect(result.player).toEqual({ + left: 0, + top: 0, + width: 100, + height: 100 - TEMPLATE_EDGE_BAR_HEIGHT_PC, + }); + }); + + it('fills the frame with a floating item that has no position', () => { + expect(rects([layout('floating')])).toEqual([ + { left: 0, top: 0, width: 100, height: 100 }, + ]); + }); + + it('uses defaults and constrains item dimensions to the available space', () => { + const result = computeTemplateLayout([ + layout('right'), + layout('top', { y_pos: 2 }), + ]); + + expect(result.items.map((item) => item.rect)).toEqual([ + { left: 80, top: 0, width: 20, height: 100 }, + { left: 0, top: 0, width: 80, height: 100 }, + ]); + expect(result.player).toEqual({ + left: 0, + top: 100, + width: 80, + height: 0, + }); + }); + + it('clamps floating corners to the frame', () => { + const [rect] = rects([layout('floating', { x_pos: -0.2, y_pos: 5 })]); + expect(rect).toEqual({ left: 0, top: 100, width: 100, height: 0 }); + }); + + it('never lets edge panels exceed the remaining space', () => { + const result = rects(Array.from({ length: 10 }, () => layout('top'))); + const total = result.reduce((sum, rect) => sum + rect.height, 0); + expect(total).toBeLessThanOrEqual(100); + for (const rect of result) + expect(rect.height).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/apps/signage/src/app/template-layout.ts b/libs/common/src/lib/signage/template.ts similarity index 52% rename from apps/signage/src/app/template-layout.ts rename to libs/common/src/lib/signage/template.ts index 6bf91ae81b4..66e6bf68cf7 100644 --- a/apps/signage/src/app/template-layout.ts +++ b/libs/common/src/lib/signage/template.ts @@ -1,5 +1,23 @@ -import { SignageTemplateLayout } from '@placeos/ts-client'; +import type { SignageTemplateLayout } from '@placeos/ts-client'; +/** Message type Signage Manager posts with unsaved layouts to preview */ +export const SIGNAGE_PREVIEW_LAYOUTS_MESSAGE = 'signage:template-layouts'; +/** Message type the player posts to ask Signage Manager for unsaved layouts */ +export const SIGNAGE_PREVIEW_READY_MESSAGE = 'signage:template-preview-ready'; + +/** + * Size of a top or bottom panel with no `y_pos`, as a percentage of the frame. + * The API stores `x_pos`/`y_pos` as ratios from 0 to 1. For edge panels they + * are the panel's size on the relevant axis. For floating panels they are the + * top-left corner, with the panel filling the frame from there. + */ +export const TEMPLATE_EDGE_BAR_HEIGHT_PC = 15; +/** Width of a left or right panel with no `x_pos`, as a percentage */ +export const TEMPLATE_SIDEBAR_WIDTH_PC = 20; +/** Top-left corner of a floating panel with no position, so it fills the frame */ +export const TEMPLATE_FLOATING_POSITION_PC = 0; + +/** Rectangle within the frame, all values are percentages (0-100) */ export interface TemplateLayoutRect { left: number; top: number; @@ -7,25 +25,19 @@ export interface TemplateLayoutRect { height: number; } +/** A template item and where it shows in the frame */ export interface TemplateLayoutItem { layout: SignageTemplateLayout; rect: TemplateLayoutRect; } +/** Template items placed in the frame */ export interface ComputedTemplateLayout { items: TemplateLayoutItem[]; + /** Space left for the media player */ player: TemplateLayoutRect; } -const EDGE_BAR_SIZE = 15; -const SIDE_BAR_SIZE = 20; -/** - * Top-left corner of a floating item with no position, so it fills the frame. - * Keep in step with the manager preview - * (`apps/signage-manager/src/app/templates/template-layout.util.ts`). - */ -const FLOATING_POSITION = 0; - const clamp = (value: number, max = 100) => Math.min(Math.max(value, 0), Math.max(max, 0)); @@ -34,8 +46,11 @@ function percentage(value: number | undefined, fallback: number) { } /** - * Place template items in order and return the rectangle left for the player. - * Floating items do not consume player space. + * Place template items in array order and return the rectangle left for the + * player. Each edge panel takes space from the area that is left, e.g. a + * bottom panel placed first spans the full width, while one placed after a + * sidebar spans the width that the sidebar leaves. Floating items overlay the + * frame from their top-left corner and do not take player space. */ export function computeTemplateLayout( layouts: SignageTemplateLayout[], @@ -46,7 +61,7 @@ export function computeTemplateLayout( switch (layout.position) { case 'top': { const height = clamp( - percentage(layout.y_pos, EDGE_BAR_SIZE), + percentage(layout.y_pos, TEMPLATE_EDGE_BAR_HEIGHT_PC), player.height, ); rect = { ...player, height }; @@ -56,7 +71,7 @@ export function computeTemplateLayout( } case 'bottom': { const height = clamp( - percentage(layout.y_pos, EDGE_BAR_SIZE), + percentage(layout.y_pos, TEMPLATE_EDGE_BAR_HEIGHT_PC), player.height, ); rect = { @@ -69,7 +84,7 @@ export function computeTemplateLayout( } case 'left': { const width = clamp( - percentage(layout.x_pos, SIDE_BAR_SIZE), + percentage(layout.x_pos, TEMPLATE_SIDEBAR_WIDTH_PC), player.width, ); rect = { ...player, width }; @@ -79,7 +94,7 @@ export function computeTemplateLayout( } case 'right': { const width = clamp( - percentage(layout.x_pos, SIDE_BAR_SIZE), + percentage(layout.x_pos, TEMPLATE_SIDEBAR_WIDTH_PC), player.width, ); rect = { @@ -92,8 +107,14 @@ export function computeTemplateLayout( } case 'floating': default: { - const left = percentage(layout.x_pos, FLOATING_POSITION); - const top = percentage(layout.y_pos, FLOATING_POSITION); + const left = percentage( + layout.x_pos, + TEMPLATE_FLOATING_POSITION_PC, + ); + const top = percentage( + layout.y_pos, + TEMPLATE_FLOATING_POSITION_PC, + ); rect = { left, top, width: 100 - left, height: 100 - top }; break; } From d5469daf8e1f794521fc8a07b86ed0093829ffc6 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:57 +1100 Subject: [PATCH 05/10] refactor(apps): share app setup, test setup and service worker config - Assistant panel uses the shared system setup screen. The shared screen waits for the organisation before it searches. - 13 apps share one test setup. - 8 more apps use shared/ngsw-config.json, which adds the S3 cache to enrolment, outlook add-in, survey and timetable. - 13 apps reload on chunk load errors. - Small config fixes in outlook add-in, survey, signage, caterer UI, app loader, enrolment and timetable. --- apps/app-loader/project.json | 2 +- apps/app-loader/src/styles.css | 126 --------- apps/app-loader/tsconfig.spec.json | 2 +- apps/assistant-panel/ngsw-config.json | 43 --- apps/assistant-panel/project.json | 7 +- apps/assistant-panel/src/app/app.component.ts | 4 +- apps/assistant-panel/src/app/app.config.ts | 17 +- .../src/app/bootstrap.component.ts | 249 +----------------- apps/assistant-panel/src/test-setup.ts | 34 --- .../src/tests/bootstrap.component.spec.ts | 176 ++----------- apps/assistant-panel/tsconfig.spec.json | 2 +- apps/booking-panel/project.json | 3 +- apps/booking-panel/src/app/app.config.ts | 13 +- apps/booking-panel/src/test-setup.ts | 34 --- apps/booking-panel/tsconfig.spec.json | 2 +- apps/caterer-ui/ngsw-config.json | 43 --- apps/caterer-ui/project.json | 7 +- apps/caterer-ui/src/app/app.component.ts | 6 +- apps/caterer-ui/src/app/app.config.ts | 17 +- apps/caterer-ui/src/test-setup.ts | 34 --- apps/caterer-ui/tsconfig.spec.json | 2 +- apps/control/project.json | 3 +- apps/control/src/main.ts | 18 +- apps/control/src/test-setup.ts | 34 --- apps/control/tsconfig.spec.json | 2 +- apps/enrolment/ngsw-config.json | 30 --- apps/enrolment/project.json | 10 +- apps/enrolment/src/app/app.component.ts | 3 - apps/map-kiosk/project.json | 3 +- apps/map-kiosk/src/app/app.config.ts | 17 +- apps/map-kiosk/src/test-setup.ts | 34 --- apps/map-kiosk/tsconfig.spec.json | 2 +- apps/map-studio/src/app/app.config.ts | 13 +- apps/outlook-addin/ngsw-config.json | 30 --- apps/outlook-addin/project.json | 7 +- apps/outlook-addin/src/app/app.config.ts | 16 +- apps/outlook-addin/src/test-setup.ts | 34 --- apps/outlook-addin/tsconfig.spec.json | 2 +- apps/public/project.json | 3 +- apps/public/src/app/app.config.ts | 14 +- apps/public/src/test-setup.ts | 34 --- apps/public/tsconfig.app.json | 3 +- apps/public/tsconfig.spec.json | 2 +- apps/redirect/project.json | 2 +- apps/redirect/src/test-setup.ts | 28 -- apps/redirect/tsconfig.spec.json | 2 +- apps/signage-manager/ngsw-config.json | 43 --- apps/signage-manager/project.json | 4 +- apps/signage-manager/src/app/app.config.ts | 3 + apps/signage/src/app/app.config.ts | 34 +-- apps/stagehand/ngsw-config.json | 43 --- apps/stagehand/project.json | 7 +- apps/stagehand/src/app/app.config.ts | 17 +- apps/stagehand/src/test-setup.ts | 34 --- apps/stagehand/tsconfig.spec.json | 2 +- apps/survey/ngsw-config.json | 30 --- apps/survey/project.json | 5 +- apps/survey/src/app/app.config.ts | 24 +- apps/survey/src/test-setup.ts | 34 --- apps/survey/tsconfig.spec.json | 2 +- apps/timetable/ngsw-config.json | 30 --- apps/timetable/project.json | 9 +- apps/timetable/src/app/app.config.ts | 18 +- apps/timetable/src/test-setup.ts | 34 --- apps/timetable/tsconfig.spec.json | 2 +- apps/visitor-kiosk/project.json | 3 +- apps/visitor-kiosk/src/test-setup.ts | 35 --- apps/visitor-kiosk/tsconfig.spec.json | 2 +- .../src/lib/system-bootstrap.component.ts | 4 +- shared/ngsw-config.json | 2 +- .../test-setup-base.ts | 6 +- shared/test-setup.ts | 10 + 72 files changed, 290 insertions(+), 1316 deletions(-) delete mode 100644 apps/assistant-panel/ngsw-config.json delete mode 100644 apps/assistant-panel/src/test-setup.ts delete mode 100644 apps/booking-panel/src/test-setup.ts delete mode 100644 apps/caterer-ui/ngsw-config.json delete mode 100644 apps/caterer-ui/src/test-setup.ts delete mode 100644 apps/control/src/test-setup.ts delete mode 100644 apps/enrolment/ngsw-config.json delete mode 100644 apps/map-kiosk/src/test-setup.ts delete mode 100644 apps/outlook-addin/ngsw-config.json delete mode 100644 apps/outlook-addin/src/test-setup.ts delete mode 100644 apps/public/src/test-setup.ts delete mode 100644 apps/redirect/src/test-setup.ts delete mode 100644 apps/signage-manager/ngsw-config.json delete mode 100644 apps/stagehand/ngsw-config.json delete mode 100644 apps/stagehand/src/test-setup.ts delete mode 100644 apps/survey/ngsw-config.json delete mode 100644 apps/survey/src/test-setup.ts delete mode 100644 apps/timetable/ngsw-config.json delete mode 100644 apps/timetable/src/test-setup.ts delete mode 100644 apps/visitor-kiosk/src/test-setup.ts rename apps/app-loader/src/test-setup.ts => shared/test-setup-base.ts (77%) create mode 100644 shared/test-setup.ts diff --git a/apps/app-loader/project.json b/apps/app-loader/project.json index 497647f2fe4..42cc9920ffd 100644 --- a/apps/app-loader/project.json +++ b/apps/app-loader/project.json @@ -93,7 +93,7 @@ "tsConfig": "apps/app-loader/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/app-loader/src/test-setup.ts" + "shared/test-setup-base.ts" ], "isolate": true, "watch": false, diff --git a/apps/app-loader/src/styles.css b/apps/app-loader/src/styles.css index abd4d999b88..2b4c9829102 100644 --- a/apps/app-loader/src/styles.css +++ b/apps/app-loader/src/styles.css @@ -2,132 +2,6 @@ /* The app renders only its own templates, so scan only app sources. */ @source './'; -:root { - /* Brand */ - --brand-100: hsl(336, 57%, 48%); - --brand-200: hsl(217, 91%, 60%); - --brand-300: hsl(200, 15%, 94%); - --brand-400: hsl(336, 57%, 48%); - --brand-500: hsl(336, 57%, 48%); - --brand-600: hsl(336, 57%, 48%); - --brand-content: hsl(0, 0%, 100%); - - /* Primary */ - --primary: hsl(336, 57%, 48%); - --primary-focus: hsl(336, 57%, 38%); - --primary-content: hsl(0, 0%, 100%); - - /* Secondary */ - --secondary: hsl(237, 37%, 20%); - --secondary-focus: hsl(237, 37%, 10%); - --secondary-content: hsl(0, 0%, 100%); - - /* Ternary */ - --ternary: hsl(237, 37%, 20%); - --ternary-focus: hsl(237, 37%, 10%); - --ternary-content: hsl(0, 0%, 100%); - - /* Accent */ - --accent: hsl(156, 57%, 48%); - --accent-focus: hsl(156, 57%, 38%); - --accent-content: hsl(0, 0%, 0%); - - /* Neutral */ - --neutral: hsl(0, 0%, 50%); - --neutral-focus: hsl(0, 0%, 40%); - --neutral-content: hsl(0, 0%, 100%); - - /* Base */ - --base-100: hsl(0, 0%, 98.5%); - --base-200: hsl(49, 2%, 92%); - --base-300: hsl(56, 5%, 71%); - --base-400: hsl(58, 7%, 55%); - --base-content: hsl(0, 0%, 0%); - - /* Info */ - --info: hsl(217, 91%, 60%); - --info-light: hsl(207, 89%, 94%); - --info-content: hsl(214, 100%, 97%); - - /* Warning */ - --warn: #ffa000; - --warn-light: hsl(60, 100%, 95%); - --warn-content: hsl(0, 0%, 0%); - - /* Success */ - --success: hsl(118, 50%, 43%); - --success-light: hsl(120, 39%, 94%); - --success-content: hsl(152, 81%, 96%); - - /* Error */ - --error: hsl(354, 75%, 53%); - --error-light: hsl(350, 100%, 96%); - --error-content: hsl(0, 0%, 100%); -} - -.theme-dark { - /* Brand */ - --brand-100: hsl(336, 57%, 48%); - --brand-200: hsl(217, 91%, 60%); - --brand-300: hsl(232, 50%, 48%); - --brand-400: hsl(336, 57%, 48%); - --brand-500: hsl(336, 57%, 48%); - --brand-600: hsl(336, 57%, 48%); - --brand-content: hsl(0, 0%, 100%); - - /* Primary */ - --primary: hsl(336, 57%, 48%); - --primary-focus: hsl(336, 57%, 38%); - --primary-content: hsl(0, 0%, 100%); - - /* Secondary */ - --secondary: hsl(336, 57%, 48%); - --secondary-focus: hsl(336, 57%, 38%); - --secondary-content: hsl(0, 0%, 100%); - - /* Ternary */ - --ternary: hsl(206, 23%, 30%); - --ternary-focus: hsl(206, 23%, 24%); - --ternary-content: hsl(0, 0%, 80%); - - /* Accent */ - --accent: hsl(156, 57%, 28%); - --accent-focus: hsl(158, 17%, 9%); - --accent-content: hsl(0, 0%, 80%); - - /* Neutral */ - --neutral: hsl(0, 0%, 20%); - --neutral-focus: hsl(0, 0%, 15%); - --neutral-content: hsl(0, 0%, 80%); - - /* Base */ - --base-100: hsl(0, 0%, 10%); - --base-200: hsl(0, 0%, 15%); - --base-300: hsl(0, 0%, 20%); - --base-400: hsl(0, 0%, 30%); - --base-content: hsl(0, 0%, 80%); - - /* Info */ - --info: hsl(211, 80%, 42%); - --info-light: hsl(206, 90%, 61%); - --info-content: hsl(0, 0%, 100%); - - /* Warning */ - --warn: hsl(34, 100%, 50%); - --warn-light: hsl(44, 100%, 65%); - --warn-content: hsl(0, 0%, 0%); - - /* Success */ - --success: hsl(122, 46%, 34%); - --success-light: hsl(122, 39%, 57%); - --success-content: hsl(0, 0%, 100%); - - /* Error */ - --error: hsl(0, 66%, 47%); - --error-light: hsl(4, 83%, 63%); - --error-content: hsl(0, 0%, 100%); -} - html, body { height: 100%; diff --git a/apps/app-loader/tsconfig.spec.json b/apps/app-loader/tsconfig.spec.json index 94be5ce7ec1..fd24a856df9 100644 --- a/apps/app-loader/tsconfig.spec.json +++ b/apps/app-loader/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/assistant-panel/ngsw-config.json b/apps/assistant-panel/ngsw-config.json deleted file mode 100644 index 3b65a2681c1..00000000000 --- a/apps/assistant-panel/ngsw-config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ] - } - } - ], - "dataGroups": [ - { - "name": "s3", - "urls": [ - "https://*.amazonaws.com/**/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ], - "cacheConfig": { - "strategy": "performance", - "maxSize": 200, - "maxAge": "7d" - } - } - ] -} diff --git a/apps/assistant-panel/project.json b/apps/assistant-panel/project.json index be989945112..4b679439619 100644 --- a/apps/assistant-panel/project.json +++ b/apps/assistant-panel/project.json @@ -71,7 +71,7 @@ "with": "apps/assistant-panel/src/environments/settings.ts" } ], - "serviceWorker": "apps/assistant-panel/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -109,7 +109,7 @@ "maximumError": "10kb" } ], - "serviceWorker": "apps/assistant-panel/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" } } }, @@ -138,7 +138,8 @@ "tsConfig": "apps/assistant-panel/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/assistant-panel/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/assistant-panel/src/app/app.component.ts b/apps/assistant-panel/src/app/app.component.ts index 4a9fffd4a82..fc9e894c69a 100644 --- a/apps/assistant-panel/src/app/app.component.ts +++ b/apps/assistant-panel/src/app/app.component.ts @@ -1,12 +1,13 @@ import { Component, inject, OnInit } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import { PlaceOS_Service, SettingsService } from '@placeos/common'; +import { PlaceOS_Service, setMocks, SettingsService } from '@placeos/common'; import { GlobalBannerComponent, GlobalLoadingComponent, } from '@placeos/components'; import { ChatComponent } from '@placeos/components/chat'; import { SettingsDebugPanelLauncherComponent } from '@placeos/components/settings-debug'; +import { mocksInit } from '@placeos/mocks'; @Component({ selector: 'app-root', @@ -53,6 +54,7 @@ export class AppComponent implements OnInit { public readonly has_chat = this._settings.signal('chat.enabled', false); public ngOnInit(): void { + setMocks(mocksInit); this._placeos.init(); } } diff --git a/apps/assistant-panel/src/app/app.config.ts b/apps/assistant-panel/src/app/app.config.ts index 935f2ccb4c7..956a184f8fc 100644 --- a/apps/assistant-panel/src/app/app.config.ts +++ b/apps/assistant-panel/src/app/app.config.ts @@ -8,17 +8,28 @@ import { ErrorHandler, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { + provideRouter, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; -import { LazySentryErrorHandler } from '@placeos/common'; +import { + LazySentryErrorHandler, + reloadOnChunkLoadError, +} from '@placeos/common'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: environment.production, }), diff --git a/apps/assistant-panel/src/app/bootstrap.component.ts b/apps/assistant-panel/src/app/bootstrap.component.ts index d28fe684b4f..314b5ce5d6b 100644 --- a/apps/assistant-panel/src/app/bootstrap.component.ts +++ b/apps/assistant-panel/src/app/bootstrap.component.ts @@ -1,243 +1,22 @@ -import { CommonModule } from '@angular/common'; -import { - Component, - OnInit, - computed, - debounced, - inject, - resource, - signal, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { MatRippleModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { ActivatedRoute, Router } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - Space, - VERSION, -} from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { querySystems } from '@placeos/ts-client'; +import { Component } from '@angular/core'; -const SYS_ID_KEY = 'PLACEOS.ASSISTANT.system'; +import { SystemBootstrapComponent } from '@placeos/components'; +/** Setup screen that binds the assistant panel to a system */ @Component({ selector: 'app-bootstrap', template: ` -
-
-
-
{{ 'COMMON.BOOTSTRAP_ASSISTANT' | translate }}
-
- {{ 'COMMON.BOOTSTRAP_SETUP' | translate }} -
-
- @if (!loading()) { -
- - - - @if (searching()) { - - } - - {{ 'COMMON.BOOTSTRAP_ASSISTANT_INFO' | translate }} - - - - @for (option of space_list(); track option) { - -
-
-
- {{ - option.display_name || - option.name - }} -
- @if ( - option.display_name && - option.display_name !== option.name - ) { -
- {{ option.name }} -
- } -
-
- {{ option.id }} -
-
-
- } - @if (system_id().length < 2 && !space_list().length) { - - {{ - 'COMMON.BOOTSTRAP_INPUT_PLACEHOLDER' - | translate - }} - - } -
-
- } @else { -
- -

{{ 'COMMON.BOOTSTRAP_LOADING' | translate }}

-
- } - @if (!loading()) { -
- -
- } -
-
-
- {{ 'COMMON.CONTROLS_VERSION' | translate }}: {{ version.hash }} -
-
- {{ version.time | date: 'longDate' }} - ({{ version.time | date: 'shortTime' }}) -
-
+ `, - styles: [], - imports: [ - MatAutocompleteModule, - MatFormFieldModule, - MatInputModule, - MatProgressSpinnerModule, - MatRippleModule, - CommonModule, - FormsModule, - TranslatePipe, - ], + imports: [SystemBootstrapComponent], }) -export class BootstrapComponent extends AsyncHandler implements OnInit { - private _org = inject(OrganisationService); - private _router = inject(Router); - private _route = inject(ActivatedRoute); - - public get version() { - return VERSION; - } - - /** Current loading state of the bootstrap process */ - public readonly loading = signal(''); - /** ID of the system to bootstrap */ - public readonly system_id = signal(''); - - private readonly _search = debounced(this.system_id, 300); - private readonly _spaces = resource({ - params: () => ({ - q: this._search.value(), - ready: this._org.initialised(), - }), - loader: async ({ params }) => { - if (!params.ready || params.q.length < 2) return []; - const { data } = await querySystems({ - q: params.q, - limit: 20, - fields: ['id', 'name', 'display_name', 'email'].join(','), - zone_id: this._org.organisation.id, - }); - return data.map((_) => new Space(_ as any)); - }, - }); - /** Spaces matching the current search */ - public readonly space_list = computed(() => this._spaces.value() ?? []); - /** Whether a space search is currently in flight */ - public readonly searching = this._spaces.isLoading; - - public async ngOnInit() { - this.subscription( - 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('clear') && !!params.get('clear')) { - this.clearBootstrap(); - } - if (params.has('system_id') || params.has('sys_id')) { - this.system_id.set( - params.get('system_id') || params.get('sys_id') || '', - ); - this.bootstrap(); - } - }), - ); - this.checkBootstrapped(); - } - - /** - * Save the bootstrapped ID and redirect to the panel for that ID - * @param system_id System to bootstrap - */ - private configure(system_id: string): void { - this.loading.set('Setup'); - if (localStorage) { - localStorage.setItem(SYS_ID_KEY, system_id); - localStorage.setItem('trust', 'true'); - localStorage.setItem('fixed_device', 'true'); - } - this._router.navigate(['/panel', system_id], { - queryParamsHandling: 'preserve', - }); - this.loading.set(''); - } - /* Setup the default system for the application to bind to */ - public readonly bootstrap = () => this.configure(this.system_id()); - - /* Check if the application has previously been bootstrapped */ - private checkBootstrapped(): void { - this.loading.set('Checks'); - if (localStorage) { - const system_id = localStorage.getItem(SYS_ID_KEY); - if (system_id) { - this._router.navigate(['/panel', system_id], { - queryParamsHandling: 'preserve', - }); - return; - } - } - this.loading.set(''); - } - - /* Remove any previously set bootstrapping details */ - private readonly clearBootstrap = () => localStorage.removeItem(SYS_ID_KEY); +export class BootstrapComponent { + /** Open the assistant panel for the system */ + public readonly systemRoute = (system_id: string) => ['/panel', system_id]; } diff --git a/apps/assistant-panel/src/test-setup.ts b/apps/assistant-panel/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/assistant-panel/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/assistant-panel/src/tests/bootstrap.component.spec.ts b/apps/assistant-panel/src/tests/bootstrap.component.spec.ts index 5938ae8f4d1..2898cf9a17d 100644 --- a/apps/assistant-panel/src/tests/bootstrap.component.spec.ts +++ b/apps/assistant-panel/src/tests/bootstrap.component.spec.ts @@ -1,173 +1,49 @@ -import { ApplicationRef, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Router } from '@angular/router'; import { createRoutingFactory, SpectatorRouting, } from '@ngneat/spectator/vitest'; -import { OrganisationService } from '@placeos/common'; -import * as ts_client from '@placeos/ts-client'; -import { MockProvider } from 'ng-mocks'; +import { MockModule, MockProvider } from 'ng-mocks'; +import { SettingsService } from '@placeos/common'; +import { SystemBootstrapComponent } from '@placeos/components'; import { BootstrapComponent } from '../app/bootstrap.component'; -vi.mock('@placeos/ts-client', { spy: true }); - -const SYS_ID_KEY = 'PLACEOS.ASSISTANT.system'; - describe('BootstrapComponent', () => { let spectator: SpectatorRouting; - let org_service: unknown; - - const create_component = createRoutingFactory({ + const createComponent = createRoutingFactory({ component: BootstrapComponent, - shallow: true, - providers: [MockProvider(OrganisationService)], + providers: [MockProvider(SettingsService, { get: vi.fn() })], + imports: [ + FormsModule, + MockModule(MatAutocompleteModule), + MockModule(MatFormFieldModule), + MockModule(MatInputModule), + MockModule(MatProgressSpinnerModule), + ], }); beforeEach(() => { - vi.clearAllMocks(); - localStorage.clear(); - sessionStorage.clear(); - org_service = { - initialised: signal(true), - organisation: { id: 'org-1' }, - waitUntilInitialised: vi.fn(() => Promise.resolve()), - }; - vi.mocked(ts_client.querySystems).mockResolvedValue({ - data: [ - { - id: 'sys-1', - name: 'Panel 1', - display_name: 'Panel One', - }, - ], - } as any); + spectator = createComponent(); }); afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - function build_component() { - spectator = create_component({ - providers: [MockProvider(OrganisationService, org_service)], - }); - return spectator; - } - - it('should create the component', () => { - expect(build_component().component).toBeTruthy(); - }); - - it('should associate the system label with its input', () => { - build_component(); - spectator.detectChanges(); - - const label = spectator.query( - 'label[for="system-id"]', - ); - const input = spectator.query('#system-id'); - - expect(label?.htmlFor).toBe(input?.id); - }); - - it('should query systems matching the search term', async () => { - build_component(); - const app_ref = spectator.inject(ApplicationRef); - spectator.component.system_id.set('pan'); - spectator.detectChanges(); - // The 300ms debounce is a real timer scheduled by an effect, so wait - // real time for it to fire, then let the resource query settle. - await new Promise((r) => setTimeout(r, 350)); - await app_ref.whenStable(); - spectator.detectChanges(); - await app_ref.whenStable(); - spectator.detectChanges(); - - expect(ts_client.querySystems).toHaveBeenCalledWith( - expect.objectContaining({ - q: 'pan', - zone_id: 'org-1', - }), - ); - expect(spectator.component.space_list().map((_) => _.id)).toEqual([ - 'sys-1', - ]); - }); - - it('should not query systems for short search terms', async () => { - build_component(); - const app_ref = spectator.inject(ApplicationRef); - spectator.component.system_id.set('p'); - spectator.detectChanges(); - await new Promise((r) => setTimeout(r, 350)); - await app_ref.whenStable(); - - expect(ts_client.querySystems).not.toHaveBeenCalled(); - expect(spectator.component.space_list()).toEqual([]); + localStorage.clear(); }); - it('should store the selected system and navigate to the panel', () => { - build_component(); - const router = spectator.inject(Router); - const set_item_spy = vi.spyOn(Storage.prototype, 'setItem'); - spectator.component.system_id.set('sys-1'); - - spectator.component.bootstrap(); - - expect(set_item_spy).toHaveBeenCalledWith(SYS_ID_KEY, 'sys-1'); - expect(localStorage.getItem('trust')).toBe('true'); - expect(localStorage.getItem('fixed_device')).toBe('true'); - expect(router.navigate).toHaveBeenCalledWith( + it('should save the system and route to /panel on submit', () => { + const bootstrap = spectator.query(SystemBootstrapComponent); + bootstrap.system_id.set('sys-1'); + bootstrap.bootstrap(); + expect(localStorage.getItem('PLACEOS.ASSISTANT.system')).toBe('sys-1'); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith( ['/panel', 'sys-1'], - expect.objectContaining({ queryParamsHandling: 'preserve' }), - ); - expect(spectator.component.loading()).toBe(''); - }); - - it('should redirect immediately when already bootstrapped', () => { - localStorage.setItem(SYS_ID_KEY, 'sys-existing'); - build_component(); - const router = spectator.inject(Router); - - spectator.detectChanges(); - - expect(router.navigate).toHaveBeenCalledWith( - ['/panel', 'sys-existing'], - expect.objectContaining({ queryParamsHandling: 'preserve' }), + { queryParamsHandling: 'preserve' }, ); }); - - it('should bootstrap from the system_id query parameter', () => { - build_component(); - const bootstrap_spy = vi.spyOn(spectator.component, 'bootstrap'); - - spectator.setRouteQueryParam('system_id', 'sys-2'); - spectator.detectChanges(); - - expect(spectator.component.system_id()).toBe('sys-2'); - expect(bootstrap_spy).toHaveBeenCalled(); - }); - - it('should bootstrap from the legacy sys_id query parameter', () => { - build_component(); - const bootstrap_spy = vi.spyOn(spectator.component, 'bootstrap'); - - spectator.setRouteQueryParam('sys_id', 'sys-3'); - spectator.detectChanges(); - - expect(spectator.component.system_id()).toBe('sys-3'); - expect(bootstrap_spy).toHaveBeenCalled(); - }); - - it('should clear stored bootstrap details from the query string', () => { - localStorage.setItem(SYS_ID_KEY, 'sys-old'); - build_component(); - - spectator.setRouteQueryParam('clear', 'true'); - spectator.detectChanges(); - - expect(localStorage.getItem(SYS_ID_KEY)).toBeNull(); - }); }); diff --git a/apps/assistant-panel/tsconfig.spec.json b/apps/assistant-panel/tsconfig.spec.json index 94be5ce7ec1..65191cc4526 100644 --- a/apps/assistant-panel/tsconfig.spec.json +++ b/apps/assistant-panel/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/booking-panel/project.json b/apps/booking-panel/project.json index 27170dfa94f..4e1b03700d5 100644 --- a/apps/booking-panel/project.json +++ b/apps/booking-panel/project.json @@ -157,7 +157,8 @@ "tsConfig": "apps/booking-panel/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/booking-panel/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/booking-panel/src/app/app.config.ts b/apps/booking-panel/src/app/app.config.ts index 25842e1dd1b..b6950f07bdc 100644 --- a/apps/booking-panel/src/app/app.config.ts +++ b/apps/booking-panel/src/app/app.config.ts @@ -6,13 +6,18 @@ import { provideAppInitializer, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { + provideRouter, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; import { LazySentryErrorHandler, LocaleService, registerActiveLocale, + reloadOnChunkLoadError, } from '@placeos/common'; import { environment } from '../environments/environment'; @@ -24,7 +29,11 @@ export const appConfig: ApplicationConfig = { provideAppInitializer(() => registerActiveLocale(inject(LocaleService).locale), ), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: environment.production && environment.service_worker, }), diff --git a/apps/booking-panel/src/test-setup.ts b/apps/booking-panel/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/booking-panel/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/booking-panel/tsconfig.spec.json b/apps/booking-panel/tsconfig.spec.json index 94be5ce7ec1..65191cc4526 100644 --- a/apps/booking-panel/tsconfig.spec.json +++ b/apps/booking-panel/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/caterer-ui/ngsw-config.json b/apps/caterer-ui/ngsw-config.json deleted file mode 100644 index 3b65a2681c1..00000000000 --- a/apps/caterer-ui/ngsw-config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ] - } - } - ], - "dataGroups": [ - { - "name": "s3", - "urls": [ - "https://*.amazonaws.com/**/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ], - "cacheConfig": { - "strategy": "performance", - "maxSize": 200, - "maxAge": "7d" - } - } - ] -} diff --git a/apps/caterer-ui/project.json b/apps/caterer-ui/project.json index e96674d1161..4b4b08a9b85 100644 --- a/apps/caterer-ui/project.json +++ b/apps/caterer-ui/project.json @@ -60,7 +60,7 @@ "with": "apps/caterer-ui/src/environments/settings.ts" } ], - "serviceWorker": "apps/caterer-ui/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -98,7 +98,7 @@ "maximumError": "10kb" } ], - "serviceWorker": "apps/caterer-ui/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" } } }, @@ -123,7 +123,8 @@ "tsConfig": "apps/caterer-ui/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/caterer-ui/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/caterer-ui/src/app/app.component.ts b/apps/caterer-ui/src/app/app.component.ts index 56de5bdcecb..2be349cf188 100644 --- a/apps/caterer-ui/src/app/app.component.ts +++ b/apps/caterer-ui/src/app/app.component.ts @@ -30,7 +30,11 @@ import { mocksInit } from '@placeos/mocks'; } - + @defer (on idle) { + + } `, styles: [ ` diff --git a/apps/caterer-ui/src/app/app.config.ts b/apps/caterer-ui/src/app/app.config.ts index 238011f9911..d371d1a1f29 100644 --- a/apps/caterer-ui/src/app/app.config.ts +++ b/apps/caterer-ui/src/app/app.config.ts @@ -3,17 +3,28 @@ import { ErrorHandler, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { + provideRouter, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; -import { LazySentryErrorHandler } from '@placeos/common'; +import { + LazySentryErrorHandler, + reloadOnChunkLoadError, +} from '@placeos/common'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: environment.production, }), diff --git a/apps/caterer-ui/src/test-setup.ts b/apps/caterer-ui/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/caterer-ui/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/caterer-ui/tsconfig.spec.json b/apps/caterer-ui/tsconfig.spec.json index 94be5ce7ec1..65191cc4526 100644 --- a/apps/caterer-ui/tsconfig.spec.json +++ b/apps/caterer-ui/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/control/project.json b/apps/control/project.json index b4ac29a986b..26cf2c3269a 100644 --- a/apps/control/project.json +++ b/apps/control/project.json @@ -164,7 +164,8 @@ "tsConfig": "apps/control/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/control/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/control/src/main.ts b/apps/control/src/main.ts index c34d2c5b0aa..affa03085a1 100644 --- a/apps/control/src/main.ts +++ b/apps/control/src/main.ts @@ -5,9 +5,17 @@ import { provideZonelessChangeDetection, } from '@angular/core'; import { bootstrapApplication } from '@angular/platform-browser'; -import { provideRouter, Routes, withHashLocation } from '@angular/router'; +import { + provideRouter, + Routes, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; -import { LazySentryErrorHandler } from '@placeos/common'; +import { + LazySentryErrorHandler, + reloadOnChunkLoadError, +} from '@placeos/common'; import { AuthorisedUserGuard, @@ -62,7 +70,11 @@ const routes: Routes = [ bootstrapApplication(AppComponent, { providers: [ provideZonelessChangeDetection(), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode() && environment.service_worker, registrationStrategy: 'registerWhenStable:30000', diff --git a/apps/control/src/test-setup.ts b/apps/control/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/control/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/control/tsconfig.spec.json b/apps/control/tsconfig.spec.json index 94be5ce7ec1..65191cc4526 100644 --- a/apps/control/tsconfig.spec.json +++ b/apps/control/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/enrolment/ngsw-config.json b/apps/enrolment/ngsw-config.json deleted file mode 100644 index 0ec7b103706..00000000000 --- a/apps/enrolment/ngsw-config.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" - ] - } - } - ] -} diff --git a/apps/enrolment/project.json b/apps/enrolment/project.json index 195394bb6b2..0b3356f0fd3 100644 --- a/apps/enrolment/project.json +++ b/apps/enrolment/project.json @@ -8,7 +8,7 @@ "targets": { "build": { "executor": "@angular/build:application", - "outputs": ["{options.outputPath.base}"], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "outputPath": "dist/apps/enrolment", @@ -43,7 +43,7 @@ } ], "scripts": [], - "serviceWorker": "apps/enrolment/ngsw-config.json", + "serviceWorker": "shared/ngsw-config.json", "browser": "apps/enrolment/src/main.ts" }, "configurations": { @@ -60,7 +60,7 @@ "with": "apps/enrolment/src/environments/settings.ts" } ], - "serviceWorker": "apps/enrolment/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "sourceMap": { @@ -81,10 +81,6 @@ } ], "fileReplacements": [ - { - "replace": "libs/mocks/src/index.ts", - "with": "libs/mocks/src/empty.ts" - }, { "replace": "apps/enrolment/src/environments/environment.ts", "with": "apps/enrolment/src/environments/environment.prod.ts" diff --git a/apps/enrolment/src/app/app.component.ts b/apps/enrolment/src/app/app.component.ts index 398403e2c9e..0984092cbe3 100644 --- a/apps/enrolment/src/app/app.component.ts +++ b/apps/enrolment/src/app/app.component.ts @@ -10,7 +10,6 @@ import { initialisationFailure, initSentryFromSettings, lazySnackbar, - log, markInitialisationComplete, retryInitialisation, setAppName, @@ -27,7 +26,6 @@ import { ServiceWorkerUpdateCardComponent, } from '@placeos/components'; -import * as MOCKS from '@placeos/mocks'; import { PlaceAuthority, token } from '@placeos/ts-client'; @Component({ selector: 'app-root', @@ -95,7 +93,6 @@ export class AppComponent extends AsyncHandler implements OnInit { } public async ngOnInit() { - log('APP', 'MOCKS:', MOCKS); setNotifyOutlet(this._snackbar); // Listen for service worker events before any async setup so update // notifications emitted during initialisation are not missed. diff --git a/apps/map-kiosk/project.json b/apps/map-kiosk/project.json index 5e41ee3c8ce..74215ea63f1 100644 --- a/apps/map-kiosk/project.json +++ b/apps/map-kiosk/project.json @@ -123,7 +123,8 @@ "tsConfig": "apps/map-kiosk/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/map-kiosk/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/map-kiosk/src/app/app.config.ts b/apps/map-kiosk/src/app/app.config.ts index 238011f9911..d371d1a1f29 100644 --- a/apps/map-kiosk/src/app/app.config.ts +++ b/apps/map-kiosk/src/app/app.config.ts @@ -3,17 +3,28 @@ import { ErrorHandler, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { + provideRouter, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; -import { LazySentryErrorHandler } from '@placeos/common'; +import { + LazySentryErrorHandler, + reloadOnChunkLoadError, +} from '@placeos/common'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: environment.production, }), diff --git a/apps/map-kiosk/src/test-setup.ts b/apps/map-kiosk/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/map-kiosk/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/map-kiosk/tsconfig.spec.json b/apps/map-kiosk/tsconfig.spec.json index 94be5ce7ec1..65191cc4526 100644 --- a/apps/map-kiosk/tsconfig.spec.json +++ b/apps/map-kiosk/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/map-studio/src/app/app.config.ts b/apps/map-studio/src/app/app.config.ts index 3524f97b8d1..7d5e1c8627b 100644 --- a/apps/map-studio/src/app/app.config.ts +++ b/apps/map-studio/src/app/app.config.ts @@ -4,9 +4,14 @@ import { provideAppInitializer, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { + provideRouter, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; +import { reloadOnChunkLoadError } from '@placeos/common'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; import { PlaceOSService } from './data/placeos.service'; @@ -30,7 +35,11 @@ export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), provideAppInitializer(initialisePlaceOS), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: environment.production, }), diff --git a/apps/outlook-addin/ngsw-config.json b/apps/outlook-addin/ngsw-config.json deleted file mode 100644 index 0ec7b103706..00000000000 --- a/apps/outlook-addin/ngsw-config.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" - ] - } - } - ] -} diff --git a/apps/outlook-addin/project.json b/apps/outlook-addin/project.json index e1bc6c22c0c..cce5ad83caa 100644 --- a/apps/outlook-addin/project.json +++ b/apps/outlook-addin/project.json @@ -62,7 +62,7 @@ "with": "apps/outlook-addin/src/environments/settings.ts" } ], - "serviceWorker": "apps/outlook-addin/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -98,7 +98,7 @@ "maximumError": "10kb" } ], - "serviceWorker": "apps/outlook-addin/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" } } }, @@ -127,7 +127,8 @@ "tsConfig": "apps/outlook-addin/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/outlook-addin/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/outlook-addin/src/app/app.config.ts b/apps/outlook-addin/src/app/app.config.ts index c68f356f37f..83653c90b22 100644 --- a/apps/outlook-addin/src/app/app.config.ts +++ b/apps/outlook-addin/src/app/app.config.ts @@ -5,13 +5,16 @@ import { } from '@angular/common/http'; import { ApplicationConfig, - importProvidersFrom, provideZonelessChangeDetection, } from '@angular/core'; -import { NativeDateModule } from '@angular/material/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { + provideRouter, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; +import { reloadOnChunkLoadError } from '@placeos/common'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; @@ -19,10 +22,13 @@ export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), provideHttpClient(withXhr(), withInterceptorsFromDi()), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: environment.production, }), - importProvidersFrom(NativeDateModule), ], }; diff --git a/apps/outlook-addin/src/test-setup.ts b/apps/outlook-addin/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/outlook-addin/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/outlook-addin/tsconfig.spec.json b/apps/outlook-addin/tsconfig.spec.json index 0715cfe7a52..4dc00530688 100644 --- a/apps/outlook-addin/tsconfig.spec.json +++ b/apps/outlook-addin/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/public/project.json b/apps/public/project.json index 608eea8ca3d..6464da14381 100644 --- a/apps/public/project.json +++ b/apps/public/project.json @@ -77,7 +77,8 @@ "tsConfig": "apps/public/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/public/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/public/src/app/app.config.ts b/apps/public/src/app/app.config.ts index 7457e004bb0..1677a0bf0d2 100644 --- a/apps/public/src/app/app.config.ts +++ b/apps/public/src/app/app.config.ts @@ -3,13 +3,23 @@ import { provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { + provideRouter, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; + +import { reloadOnChunkLoadError } from '@placeos/common'; import { appRoutes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZonelessChangeDetection(), - provideRouter(appRoutes, withHashLocation()), + provideRouter( + appRoutes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), ], }; diff --git a/apps/public/src/test-setup.ts b/apps/public/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/public/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/public/tsconfig.app.json b/apps/public/tsconfig.app.json index dc5d1bbf990..3b4bd466e47 100644 --- a/apps/public/tsconfig.app.json +++ b/apps/public/tsconfig.app.json @@ -9,8 +9,7 @@ "src/**/*.spec.ts", "src/**/*.test.ts", "jest.config.ts", - "jest.config.cts", - "src/test-setup.ts" + "jest.config.cts" ], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/public/tsconfig.spec.json b/apps/public/tsconfig.spec.json index 026c8ea3ddb..acba446ea12 100644 --- a/apps/public/tsconfig.spec.json +++ b/apps/public/tsconfig.spec.json @@ -12,7 +12,7 @@ "noImplicitReturns": false, "noFallthroughCasesInSwitch": false }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/redirect/project.json b/apps/redirect/project.json index e9f1517dbdf..0d402419801 100644 --- a/apps/redirect/project.json +++ b/apps/redirect/project.json @@ -84,7 +84,7 @@ "tsConfig": "apps/redirect/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/redirect/src/test-setup.ts" + "shared/test-setup-base.ts" ], "isolate": true, "watch": false, diff --git a/apps/redirect/src/test-setup.ts b/apps/redirect/src/test-setup.ts deleted file mode 100644 index 8d870db56db..00000000000 --- a/apps/redirect/src/test-setup.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); diff --git a/apps/redirect/tsconfig.spec.json b/apps/redirect/tsconfig.spec.json index 94be5ce7ec1..fd24a856df9 100644 --- a/apps/redirect/tsconfig.spec.json +++ b/apps/redirect/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/signage-manager/ngsw-config.json b/apps/signage-manager/ngsw-config.json deleted file mode 100644 index 3b65a2681c1..00000000000 --- a/apps/signage-manager/ngsw-config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ] - } - } - ], - "dataGroups": [ - { - "name": "s3", - "urls": [ - "https://*.amazonaws.com/**/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ], - "cacheConfig": { - "strategy": "performance", - "maxSize": 200, - "maxAge": "7d" - } - } - ] -} diff --git a/apps/signage-manager/project.json b/apps/signage-manager/project.json index cea0e754cf4..84c47ee2c7c 100644 --- a/apps/signage-manager/project.json +++ b/apps/signage-manager/project.json @@ -65,7 +65,7 @@ "with": "apps/signage-manager/src/environments/settings.ts" } ], - "serviceWorker": "apps/signage-manager/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -103,7 +103,7 @@ "maximumError": "10kb" } ], - "serviceWorker": "apps/signage-manager/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" } } }, diff --git a/apps/signage-manager/src/app/app.config.ts b/apps/signage-manager/src/app/app.config.ts index dbd65a682cf..6d4a69788c2 100644 --- a/apps/signage-manager/src/app/app.config.ts +++ b/apps/signage-manager/src/app/app.config.ts @@ -12,12 +12,14 @@ import { Routes, withComponentInputBinding, withHashLocation, + withNavigationErrorHandler, } from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; import { LazySentryErrorHandler, LocaleService, registerActiveLocale, + reloadOnChunkLoadError, } from '@placeos/common'; import { AuthorisedUserGuard, @@ -165,6 +167,7 @@ export const APP_CONFIG: ApplicationConfig = { APP_ROUTES, withHashLocation(), withComponentInputBinding(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), ), { provide: ErrorHandler, diff --git a/apps/signage/src/app/app.config.ts b/apps/signage/src/app/app.config.ts index a1b0d8bebf2..f7e1fb98934 100644 --- a/apps/signage/src/app/app.config.ts +++ b/apps/signage/src/app/app.config.ts @@ -6,43 +6,35 @@ import { provideAppInitializer, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { + provideRouter, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; import { + errorMessage, LazySentryErrorHandler, LocaleService, registerActiveLocale, + reloadOnChunkLoadError, } from '@placeos/common'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; import { recordFatalError } from './watchdog'; -/** - * Readable text for a thrown value. Errors are not always `Error` instances - - * HTTP failures and API client rejections are plain objects - so any string - * `message` is used before falling back to the value itself. - */ -function errorMessage(error: unknown): string { - if ( - typeof error === 'object' && - error !== null && - 'message' in error && - typeof error.message === 'string' && - error.message - ) { - return error.message; - } - return String(error); -} - export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), provideAppInitializer(() => registerActiveLocale(inject(LocaleService).locale), ), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: environment.production, }), @@ -54,7 +46,7 @@ export const appConfig: ApplicationConfig = { const handler = new LazySentryErrorHandler(); return { handleError: (error: unknown) => { - recordFatalError(errorMessage(error)); + recordFatalError(errorMessage(error) || String(error)); handler.handleError(error); }, }; diff --git a/apps/stagehand/ngsw-config.json b/apps/stagehand/ngsw-config.json deleted file mode 100644 index 3b65a2681c1..00000000000 --- a/apps/stagehand/ngsw-config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ] - } - } - ], - "dataGroups": [ - { - "name": "s3", - "urls": [ - "https://*.amazonaws.com/**/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" - ], - "cacheConfig": { - "strategy": "performance", - "maxSize": 200, - "maxAge": "7d" - } - } - ] -} diff --git a/apps/stagehand/project.json b/apps/stagehand/project.json index a639a07a77d..596ab9e082a 100644 --- a/apps/stagehand/project.json +++ b/apps/stagehand/project.json @@ -61,7 +61,7 @@ "with": "apps/stagehand/src/environments/settings.ts" } ], - "serviceWorker": "apps/stagehand/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "sourceMap": { @@ -96,7 +96,7 @@ } ], "outputHashing": "all", - "serviceWorker": "apps/stagehand/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "development": { "optimization": false, @@ -135,7 +135,8 @@ "tsConfig": "apps/stagehand/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/stagehand/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/stagehand/src/app/app.config.ts b/apps/stagehand/src/app/app.config.ts index 238011f9911..d371d1a1f29 100644 --- a/apps/stagehand/src/app/app.config.ts +++ b/apps/stagehand/src/app/app.config.ts @@ -3,17 +3,28 @@ import { ErrorHandler, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, withHashLocation } from '@angular/router'; +import { + provideRouter, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; -import { LazySentryErrorHandler } from '@placeos/common'; +import { + LazySentryErrorHandler, + reloadOnChunkLoadError, +} from '@placeos/common'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: environment.production, }), diff --git a/apps/stagehand/src/test-setup.ts b/apps/stagehand/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/stagehand/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/stagehand/tsconfig.spec.json b/apps/stagehand/tsconfig.spec.json index 94be5ce7ec1..65191cc4526 100644 --- a/apps/stagehand/tsconfig.spec.json +++ b/apps/stagehand/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/survey/ngsw-config.json b/apps/survey/ngsw-config.json deleted file mode 100644 index 0ec7b103706..00000000000 --- a/apps/survey/ngsw-config.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" - ] - } - } - ] -} diff --git a/apps/survey/project.json b/apps/survey/project.json index 6a068ee866d..b14db86ec32 100644 --- a/apps/survey/project.json +++ b/apps/survey/project.json @@ -37,7 +37,7 @@ "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", "shared/styles/application.css" ], - "serviceWorker": "apps/survey/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "configurations": { "production": { @@ -105,7 +105,8 @@ "tsConfig": "apps/survey/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/survey/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/survey/src/app/app.config.ts b/apps/survey/src/app/app.config.ts index 22d8c07c777..3d7d1c1b491 100644 --- a/apps/survey/src/app/app.config.ts +++ b/apps/survey/src/app/app.config.ts @@ -1,13 +1,24 @@ import { ApplicationConfig, + inject, LOCALE_ID, + provideAppInitializer, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, Route, withHashLocation } from '@angular/router'; +import { + provideRouter, + Route, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; -import { LocaleService } from '@placeos/common'; +import { + LocaleService, + registerActiveLocale, + reloadOnChunkLoadError, +} from '@placeos/common'; import { environment } from '../environments/environment'; import { NotFoundComponent } from './not-found.component'; @@ -41,7 +52,14 @@ export const appConfig: ApplicationConfig = { enabled: environment.production, }), provideZonelessChangeDetection(), - provideRouter(appRoutes, withHashLocation()), + provideAppInitializer(() => + registerActiveLocale(inject(LocaleService).locale), + ), + provideRouter( + appRoutes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), { provide: LOCALE_ID, deps: [LocaleService], diff --git a/apps/survey/src/test-setup.ts b/apps/survey/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/survey/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/survey/tsconfig.spec.json b/apps/survey/tsconfig.spec.json index 0b21b5bafc3..5f4de2e36bd 100644 --- a/apps/survey/tsconfig.spec.json +++ b/apps/survey/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/timetable/ngsw-config.json b/apps/timetable/ngsw-config.json deleted file mode 100644 index 0ec7b103706..00000000000 --- a/apps/timetable/ngsw-config.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "../../node_modules/@angular/service-worker/config/schema.json", - "index": "/index.html", - "assetGroups": [ - { - "name": "app", - "installMode": "prefetch", - "resources": { - "files": [ - "/favicon.ico", - "/index.html", - "/manifest.webmanifest", - "/*.css", - "/*.js" - ] - } - }, - { - "name": "assets", - "installMode": "lazy", - "updateMode": "prefetch", - "resources": { - "files": [ - "/assets/**", - "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" - ] - } - } - ] -} diff --git a/apps/timetable/project.json b/apps/timetable/project.json index 7fb92d67880..34fc489e4f8 100644 --- a/apps/timetable/project.json +++ b/apps/timetable/project.json @@ -8,7 +8,7 @@ "targets": { "build": { "executor": "@angular/build:application", - "outputs": ["{options.outputPath.base}"], + "outputs": ["{options.outputPath}"], "defaultConfiguration": "production", "options": { "outputPath": "dist/apps/timetable", @@ -43,7 +43,7 @@ "shared/styles/application.css" ], "scripts": [], - "serviceWorker": "apps/timetable/ngsw-config.json", + "serviceWorker": "shared/ngsw-config.json", "browser": "apps/timetable/src/main.ts" }, "configurations": { @@ -60,7 +60,7 @@ "with": "apps/timetable/src/environments/settings.ts" } ], - "serviceWorker": "apps/timetable/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "sourceMap": { @@ -128,7 +128,8 @@ "tsConfig": "apps/timetable/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/timetable/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/timetable/src/app/app.config.ts b/apps/timetable/src/app/app.config.ts index ad6ed04a57e..6d87d6c4650 100644 --- a/apps/timetable/src/app/app.config.ts +++ b/apps/timetable/src/app/app.config.ts @@ -3,7 +3,12 @@ import { ErrorHandler, provideZonelessChangeDetection, } from '@angular/core'; -import { provideRouter, Routes, withHashLocation } from '@angular/router'; +import { + provideRouter, + Routes, + withHashLocation, + withNavigationErrorHandler, +} from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; import { @@ -11,7 +16,10 @@ import { UnauthorisedComponent, } from '@placeos/components'; -import { LazySentryErrorHandler } from '@placeos/common'; +import { + LazySentryErrorHandler, + reloadOnChunkLoadError, +} from '@placeos/common'; import { environment } from '../environments/environment'; const routes: Routes = [ @@ -30,7 +38,11 @@ const routes: Routes = [ export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), - provideRouter(routes, withHashLocation()), + provideRouter( + routes, + withHashLocation(), + withNavigationErrorHandler((e) => reloadOnChunkLoadError(e.error)), + ), provideServiceWorker('ngsw-worker.js', { enabled: environment.production, // Register the ServiceWorker as soon as the app is stable diff --git a/apps/timetable/src/test-setup.ts b/apps/timetable/src/test-setup.ts deleted file mode 100644 index cfd12bbcfff..00000000000 --- a/apps/timetable/src/test-setup.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { MockPipe } from 'ng-mocks'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/timetable/tsconfig.spec.json b/apps/timetable/tsconfig.spec.json index 0b21b5bafc3..5f4de2e36bd 100644 --- a/apps/timetable/tsconfig.spec.json +++ b/apps/timetable/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/apps/visitor-kiosk/project.json b/apps/visitor-kiosk/project.json index 3c4911a6fdc..49f0cfe0093 100644 --- a/apps/visitor-kiosk/project.json +++ b/apps/visitor-kiosk/project.json @@ -123,7 +123,8 @@ "tsConfig": "apps/visitor-kiosk/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/visitor-kiosk/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/visitor-kiosk/src/test-setup.ts b/apps/visitor-kiosk/src/test-setup.ts deleted file mode 100644 index 9b9323f5926..00000000000 --- a/apps/visitor-kiosk/src/test-setup.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; -import { defineGlobalsInjections, Spectator } from '@ngneat/spectator'; -import { MockPipe } from 'ng-mocks'; - -import { setNotifyOutlet } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { TextDecoder, TextEncoder } from 'util'; - -global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder; -installTestStorage(); - -// Under the zoneless unit-test builder, `fixture.detectChanges()` only -// refreshes views that were explicitly marked dirty. Plain host-property -// changes (`setHostInput`), overridden getters and reassigned signals do not -// mark the root view, so the main CD pass skips them while dev-mode -// `checkNoChanges` still catches the new value -> NG0100. Emulate zone.js's -// full dirty-check by marking the fixture root for check before every -// `detectChanges()`. -const _dom_spectator_proto: any = Object.getPrototypeOf(Spectator.prototype); -const _original_detect_changes = _dom_spectator_proto.detectChanges; -_dom_spectator_proto.detectChanges = function () { - try { - this.fixture?.componentRef?.changeDetectorRef?.markForCheck(); - } catch { - // ignore - fixture may not expose a componentRef - } - return _original_detect_changes.apply(this, arguments); -}; - -setNotifyOutlet(null, true); - -defineGlobalsInjections({ - declarations: [MockPipe(TranslatePipe)], -}); diff --git a/apps/visitor-kiosk/tsconfig.spec.json b/apps/visitor-kiosk/tsconfig.spec.json index 94be5ce7ec1..65191cc4526 100644 --- a/apps/visitor-kiosk/tsconfig.spec.json +++ b/apps/visitor-kiosk/tsconfig.spec.json @@ -7,7 +7,7 @@ "moduleResolution": "bundler", "isolatedModules": true }, - "files": ["src/test-setup.ts"], + "files": ["../../shared/test-setup-base.ts", "../../shared/test-setup.ts"], "include": ["**/*.spec.ts", "**/*.d.ts"], "angularCompilerOptions": { "extendedDiagnostics": { diff --git a/libs/components/src/lib/system-bootstrap.component.ts b/libs/components/src/lib/system-bootstrap.component.ts index cede8a597a6..da682f0881a 100644 --- a/libs/components/src/lib/system-bootstrap.component.ts +++ b/libs/components/src/lib/system-bootstrap.component.ts @@ -75,7 +75,7 @@ import { TranslatePipe } from './translate.pipe'; @if (!loading() || loading() === 'search') {

- {{ 'COMMON.BOOTSTRAP_DESCRIPTION' | translate }} + {{ description() | translate }}

{{ @@ -203,6 +203,8 @@ export class SystemBootstrapComponent implements OnInit { public readonly store_key = input.required(); /** Translation key of the application title in the header */ public readonly heading = input.required(); + /** Translation key of the text above the search box */ + public readonly description = input('COMMON.BOOTSTRAP_DESCRIPTION'); /** Translation key of the submit button label */ public readonly submit_label = input('COMMON.BOOTSTRAP_SUBMIT'); /** Router commands to open the application for a system ID */ diff --git a/shared/ngsw-config.json b/shared/ngsw-config.json index 787020ba5da..49a140e49ca 100644 --- a/shared/ngsw-config.json +++ b/shared/ngsw-config.json @@ -22,7 +22,7 @@ "resources": { "files": [ "/assets/**", - "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" + "/*.(eot|svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2|ani)" ] } } diff --git a/apps/app-loader/src/test-setup.ts b/shared/test-setup-base.ts similarity index 77% rename from apps/app-loader/src/test-setup.ts rename to shared/test-setup-base.ts index 8d870db56db..86364de6ffe 100644 --- a/apps/app-loader/src/test-setup.ts +++ b/shared/test-setup-base.ts @@ -1,7 +1,11 @@ -import { installTestStorage } from 'libs/common/src/test-storage'; +// Unit test setup shared by the apps. Apps that use `@placeos/components` +// also load `test-setup.ts` after this file. Add both as `setupFiles` (not +// imports): the root package marks modules as free of side effects, so the +// bundler drops side-effect-only imports. import { Spectator } from '@ngneat/spectator'; import { setNotifyOutlet } from '@placeos/common'; import { TextDecoder, TextEncoder } from 'util'; +import { installTestStorage } from '../libs/common/src/test-storage'; global.TextEncoder = TextEncoder; global.TextDecoder = TextDecoder; diff --git a/shared/test-setup.ts b/shared/test-setup.ts new file mode 100644 index 00000000000..01a789b00ee --- /dev/null +++ b/shared/test-setup.ts @@ -0,0 +1,10 @@ +// Unit test setup for apps that use `@placeos/components`. +// Load it after `test-setup-base.ts` in the `setupFiles` of the test target. +// Adds a mock translate pipe to every Spectator test. +import { defineGlobalsInjections } from '@ngneat/spectator'; +import { TranslatePipe } from '@placeos/components'; +import { MockPipe } from 'ng-mocks'; + +defineGlobalsInjections({ + declarations: [MockPipe(TranslatePipe)], +}); From 7f4784c7a9fe5a3bb7a0987d59fcef32b0208628 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:57 +1100 Subject: [PATCH 06/10] refactor(apps): use lib helpers in place of app copies Use themedLogo, withTimeout, errorMessage, mapWithLimit, parseJWT and firstTruthyValueFrom from the libs. The catering availability and charge code dialogs move to CateringStateService for caterer UI and concierge. --- apps/app-loader/src/app/app.component.ts | 8 +- .../src/app/catering-topbar.component.ts | 85 ++++------------- .../tests/catering-topbar.component.spec.ts | 65 ++----------- .../app/catering/catering-topbar.component.ts | 43 +-------- .../src/app/enrolment-state.service.ts | 16 +--- apps/enrolment/src/app/enrolment.component.ts | 29 +++--- .../src/tests/enrolment-state.service.spec.ts | 21 +--- .../src/tests/enrolment.component.spec.ts | 29 ++---- .../src/app/data/placeos.service.ts | 26 +++-- apps/map-studio/src/app/data/store.service.ts | 95 ++++++++----------- .../src/app/editor/editor-state.service.ts | 14 +-- .../src/app/editor/editor.component.ts | 9 +- .../src/app/editor/publish-panel.component.ts | 9 +- .../app/editor/validation-panel.component.ts | 5 +- .../src/app/pages/import.component.ts | 11 +-- .../src/app/pages/project-detail.component.ts | 15 ++- .../src/app/pages/projects.component.ts | 11 +-- .../src/app/image-gen/image-gen.service.ts | 3 +- .../src/app/image-gen/image-gen.util.ts | 15 --- .../src/app/media/signage-media.service.ts | 37 +++----- .../src/app/shared/nav-sidebar.component.ts | 19 ++-- .../src/app/signage-context.service.ts | 12 ++- .../src/app/signage-features.ts | 3 +- .../src/app/signage-inventory.service.ts | 40 +++----- .../src/app/signage-media-tags.util.ts | 15 +-- .../tests/image-gen/image-gen.util.spec.ts | 7 -- .../shared/nav-sidebar.component.spec.ts | 48 ++++++---- .../src/tests/signage-media-tags.util.spec.ts | 24 ++--- apps/signage/src/app/media-cache.service.ts | 29 +----- apps/signage/src/app/signage.service.ts | 27 +----- apps/timetable/src/app/timetable.component.ts | 18 +--- .../app/components/footer-menu.component.ts | 10 +- .../components/footer-menu.component.spec.ts | 13 ++- .../src/lib/catering-state.service.ts | 36 +++++++ .../src/tests/catering-state.service.spec.ts | 77 ++++++++++++++- libs/common/src/lib/api.ts | 15 +++ libs/common/src/lib/general.ts | 12 ++- libs/common/src/tests/api.spec.ts | 16 +++- libs/common/src/tests/general.spec.ts | 42 ++++++++ libs/map-editor/src/lib/ai/api.ts | 12 +-- libs/map-editor/src/lib/ai/thread.ts | 2 +- libs/map-editor/src/tests/ai-api.spec.ts | 6 +- 42 files changed, 448 insertions(+), 581 deletions(-) diff --git a/apps/app-loader/src/app/app.component.ts b/apps/app-loader/src/app/app.component.ts index 0dbda15e664..fd7dbe8e291 100644 --- a/apps/app-loader/src/app/app.component.ts +++ b/apps/app-loader/src/app/app.component.ts @@ -2,6 +2,7 @@ import { Component, inject, OnInit, signal } from '@angular/core'; import { current_user, currentUser, + firstTruthyValueFrom, markInitialisationComplete, SettingsService, setupPlace, @@ -9,7 +10,6 @@ import { withTimeout, } from '@placeos/common'; import { authority, queryApplications, setAPI_Key } from '@placeos/ts-client'; -import { first, lastValueFrom } from 'rxjs'; import { appInitial, @@ -103,16 +103,14 @@ export class AppComponent implements OnInit { const api_key = params.get('x-api-key'); if (api_key) setAPI_Key(api_key); try { - await lastValueFrom( - this._settings.initialised.pipe(first((_) => _)), - ); + await firstTruthyValueFrom(this._settings.initialised); const settings = { ...(this._settings.get('composer') || {}) }; settings.mock = !!this._settings.get('mock') || location.origin.includes('demo.place.tech'); await setupPlace(settings).finally(storeApiKeyFromUrl); await withTimeout( - lastValueFrom(current_user.pipe(first((_) => !!_))), + firstTruthyValueFrom(current_user), 30_000, 'Current user loading timed out.', ); diff --git a/apps/caterer-ui/src/app/catering-topbar.component.ts b/apps/caterer-ui/src/app/catering-topbar.component.ts index 15d800b3db0..fadd2bbc2aa 100644 --- a/apps/caterer-ui/src/app/catering-topbar.component.ts +++ b/apps/caterer-ui/src/app/catering-topbar.component.ts @@ -2,22 +2,18 @@ import { Component, computed, effect, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Params, Router, RouterModule } from '@angular/router'; -import { MatDialog } from '@angular/material/dialog'; import { CateringDocketsService, CateringOrderAlertsService, CateringOrderFilters, CateringOrdersService, CateringStateService, - ChargeCodeListModalComponent, ordersToCsvRows, } from '@placeos/catering'; import { AsyncHandler, downloadFile, jsonToCsv, - notifyError, - notifySuccess, settingSignal, } from '@placeos/common'; import { format } from 'date-fns'; @@ -30,7 +26,6 @@ import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { OrganisationService } from '@placeos/common'; import { - AvailableRoomsStateModalComponent, BuildingPipe, IconComponent, TranslatePipe, @@ -117,24 +112,19 @@ import { DateOptionsComponent } from '@placeos/form-fields'; } +
@if (page() === 'menu') { -
- } - @if ( - page() === 'menu' && - (!zones()[0] || zones()[0] === building()?.id) - ) { - - } - @if (page() === 'menu') { + @if (!zones()[0] || zones()[0] === building()?.id) { + + } - } - @if (page() === 'menu') { - } - @if (page() === 'menu') { - } - @if (page() === 'menu') { - } - @if (page() !== 'menu') { -
- } - @if (page() !== 'menu') { + } @else { } @if (page() === 'orders') { @@ -277,7 +257,6 @@ export class CateringTopbarComponent extends AsyncHandler { private _org = inject(OrganisationService); private _route = inject(ActivatedRoute); private _router = inject(Router); - private _dialog = inject(MatDialog); private _dockets = inject(CateringDocketsService); private _alerts = inject(CateringOrderAlertsService); @@ -293,12 +272,7 @@ export class CateringTopbarComponent extends AsyncHandler { /** List of selected levels */ public readonly zones = signal([]); /** Currently active page */ - public readonly page = computed( - () => - (this._param_map().has('view') - ? this._param_map().get('view') - : '') || '', - ); + public readonly page = computed(() => this._param_map().get('view') || ''); public readonly filters = this._orders.order_filters; /** Number of orders in the list */ public readonly order_count = computed( @@ -343,6 +317,9 @@ export class CateringTopbarComponent extends AsyncHandler { public readonly addItem = () => this._catering.addItem(); public readonly editConfig = () => this._catering.editConfig(); public readonly importMenu = () => this._catering.importMenu(); + public readonly setRoomAvailability = () => + this._catering.setRoomAvailability(); + public readonly setChargeCodes = () => this._catering.setChargeCodes(); public readonly setCaterer = (caterer: string) => { this._orders.filters = { ...this._orders.filters, caterer }; this._setQueryParams({ caterer: caterer || null }); @@ -380,34 +357,6 @@ export class CateringTopbarComponent extends AsyncHandler { }); } - public async setRoomAvailability() { - const ref = this._dialog.open(AvailableRoomsStateModalComponent, { - data: { - type: 'Catering', - disabled_rooms: this._catering.availability(), - }, - }); - this.subscription( - 'room-availability', - ref.componentInstance.change.subscribe(async (list) => { - try { - await this._catering.saveSettings({ - disabled_rooms: list, - }); - notifySuccess('Room availability settings saved'); - } catch { - notifyError('Unable to save room availability settings'); - } finally { - ref.componentInstance.loading.set(false); - } - }), - ); - } - - public setChargeCodes() { - this._dialog.open(ChargeCodeListModalComponent); - } - public toggleSummary() { this._setQueryParams({ summary: this.show_summary() ? null : 'true' }); } diff --git a/apps/caterer-ui/src/tests/catering-topbar.component.spec.ts b/apps/caterer-ui/src/tests/catering-topbar.component.spec.ts index c92a331f825..f91c78337cd 100644 --- a/apps/caterer-ui/src/tests/catering-topbar.component.spec.ts +++ b/apps/caterer-ui/src/tests/catering-topbar.component.spec.ts @@ -1,6 +1,5 @@ import { signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { Router } from '@angular/router'; @@ -13,12 +12,9 @@ import { CateringOrderAlertsService, CateringOrdersService, CateringStateService, - ChargeCodeListModalComponent, } from '@placeos/catering'; import { OrganisationService, settingSignal } from '@placeos/common'; -import { AvailableRoomsStateModalComponent } from '@placeos/components'; import { MockComponent } from 'ng-mocks'; -import { Subject } from 'rxjs'; import { DateOptionsComponent } from '@placeos/form-fields'; import { CateringTopbarComponent } from '../app/catering-topbar.component'; @@ -37,12 +33,12 @@ class OrdersServiceStub { class StateServiceStub { public readonly caterers = signal([]); - public readonly availability = signal(['room-1']); public zone = ''; public addItem = vi.fn(); public editConfig = vi.fn(); public importMenu = vi.fn(); - public saveSettings = vi.fn().mockResolvedValue(undefined); + public setRoomAvailability = vi.fn(); + public setChargeCodes = vi.fn(); } class OrgServiceStub { @@ -61,8 +57,6 @@ describe('CateringTopbarComponent', () => { let orders: OrdersServiceStub; let state: StateServiceStub; let org: OrgServiceStub; - let dialog: { open: ReturnType }; - let dialog_ref: any; const create_component = createRoutingFactory({ component: CateringTopbarComponent, @@ -77,20 +71,12 @@ describe('CateringTopbarComponent', () => { useValue: { enabled: signal(false), setEnabled: vi.fn() }, }, { provide: CateringDocketsService, useValue: { print: vi.fn() } }, - { provide: MatDialog, useFactory: () => dialog }, ], }); beforeEach(() => { vi.clearAllMocks(); settingSignal('use_region', false).set(false); - dialog_ref = { - componentInstance: { - change: new Subject(), - loading: signal(true), - }, - }; - dialog = { open: vi.fn().mockReturnValue(dialog_ref) }; spectator = create_component(); orders = spectator.inject(CateringOrdersService) as any; state = spectator.inject(CateringStateService) as any; @@ -188,10 +174,14 @@ describe('CateringTopbarComponent', () => { spectator.component.addItem(); spectator.component.editConfig(); spectator.component.importMenu(); + spectator.component.setRoomAvailability(); + spectator.component.setChargeCodes(); expect(state.addItem).toHaveBeenCalledTimes(1); expect(state.editConfig).toHaveBeenCalledTimes(1); expect(state.importMenu).toHaveBeenCalledTimes(1); + expect(state.setRoomAvailability).toHaveBeenCalledTimes(1); + expect(state.setChargeCodes).toHaveBeenCalledTimes(1); }); it('should choose levels for the building when not using regions', () => { @@ -209,48 +199,6 @@ describe('CateringTopbarComponent', () => { expect(org.levelsForRegion).toHaveBeenCalledWith(org.active_region()); }); - it('should open the charge codes modal', () => { - spectator.component.setChargeCodes(); - expect(dialog.open).toHaveBeenCalledWith(ChargeCodeListModalComponent); - }); - - it('should open the room availability modal and save selected rooms', async () => { - await spectator.component.setRoomAvailability(); - - expect(dialog.open).toHaveBeenCalledWith( - AvailableRoomsStateModalComponent, - expect.objectContaining({ - data: expect.objectContaining({ - type: 'Catering', - disabled_rooms: ['room-1'], - }), - }), - ); - - dialog_ref.componentInstance.change.next(['room-2']); - // The change handler awaits saveSettings().catch() before clearing the - // loading flag, so flush all pending microtasks before asserting. - await new Promise((r) => setTimeout(r, 0)); - - expect(state.saveSettings).toHaveBeenCalledWith({ - disabled_rooms: ['room-2'], - }); - expect(dialog_ref.componentInstance.loading()).toBe(false); - }); - - it('should stop loading when room availability cannot be saved', async () => { - state.saveSettings.mockRejectedValueOnce(new Error('save failed')); - await spectator.component.setRoomAvailability(); - - dialog_ref.componentInstance.change.next(['room-2']); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(state.saveSettings).toHaveBeenCalledWith({ - disabled_rooms: ['room-2'], - }); - expect(dialog_ref.componentInstance.loading()).toBe(false); - }); - it('should render a back-to-home link and the page title', () => { spectator.setRouteParam('view', 'orders'); spectator.detectChanges(); @@ -307,7 +255,6 @@ describe('CateringTopbarComponent with filters in the URL', () => { useValue: { enabled: signal(false), setEnabled: vi.fn() }, }, { provide: CateringDocketsService, useValue: { print: vi.fn() } }, - { provide: MatDialog, useValue: {} }, ], }); diff --git a/apps/concierge/src/app/catering/catering-topbar.component.ts b/apps/concierge/src/app/catering/catering-topbar.component.ts index 1e9e0653781..ff6dec45369 100644 --- a/apps/concierge/src/app/catering/catering-topbar.component.ts +++ b/apps/concierge/src/app/catering/catering-topbar.component.ts @@ -3,30 +3,21 @@ import { ActivatedRoute, Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { - CateringOrdersService, - CateringStateService, - ChargeCodeListModalComponent, -} from '@placeos/catering'; +import { CateringOrdersService, CateringStateService } from '@placeos/catering'; import { AsyncHandler, - notifyError, - notifySuccess, OrganisationService, SettingsService, } from '@placeos/common'; import { - AvailableRoomsStateModalComponent, BuildingPipe, IconComponent, TranslatePipe, } from '@placeos/components'; import { DateOptionsComponent } from '@placeos/form-fields'; -import { errorText } from '../ui/modal-actions'; import { SearchbarComponent } from '../ui/searchbar.component'; import { loadPersistedZones, @@ -208,7 +199,6 @@ export class CateringTopbarComponent extends AsyncHandler implements OnInit { private _org = inject(OrganisationService); private _route = inject(ActivatedRoute); private _router = inject(Router); - private _dialog = inject(MatDialog); private _settings = inject(SettingsService); /** List of selected levels */ @@ -254,6 +244,9 @@ export class CateringTopbarComponent extends AsyncHandler implements OnInit { public readonly addItem = () => this._catering.addItem(); public readonly editConfig = () => this._catering.editConfig(); public readonly importMenu = () => this._catering.importMenu(); + public readonly setRoomAvailability = () => + this._catering.setRoomAvailability(); + public readonly setChargeCodes = () => this._catering.setChargeCodes(); public get building() { return this._org.building; @@ -315,32 +308,4 @@ export class CateringTopbarComponent extends AsyncHandler implements OnInit { this._catering.zone = (this.filters()?.zones || [])[0] || this._org.building?.id; } - - public async setRoomAvailability() { - const ref = this._dialog.open(AvailableRoomsStateModalComponent, { - data: { - type: 'Catering', - disabled_rooms: this._catering.availability(), - }, - }); - this.subscription( - 'room-availability', - ref.componentInstance.change.subscribe(async (list) => { - try { - await this._catering.saveSettings({ disabled_rooms: list }); - notifySuccess('Room availability settings saved'); - } catch (e) { - notifyError( - `Failed to save room availability. ${errorText(e)}`, - ); - } finally { - ref.componentInstance.loading.set(false); - } - }), - ); - } - - public setChargeCodes() { - this._dialog.open(ChargeCodeListModalComponent); - } } diff --git a/apps/enrolment/src/app/enrolment-state.service.ts b/apps/enrolment/src/app/enrolment-state.service.ts index 1e2150d0549..a37a8e1387c 100644 --- a/apps/enrolment/src/app/enrolment-state.service.ts +++ b/apps/enrolment/src/app/enrolment-state.service.ts @@ -1,6 +1,6 @@ import { Injectable, computed, resource, signal } from '@angular/core'; import { email, form, required, validate } from '@angular/forms/signals'; -import { CalendarEvent, patchSignalModel } from '@placeos/common'; +import { CalendarEvent, parseJWT, patchSignalModel } from '@placeos/common'; import { checkinEventGuest, showEvent } from '@placeos/events'; import { setToken } from '@placeos/ts-client'; import { showGuest, updateGuest } from '@placeos/users'; @@ -20,20 +20,6 @@ export interface EnrolmentFormValue { accepted_terms_conditions: boolean; } -export function parseJWT(token: string) { - const base64Url = token.split('.')[1]; - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - const jsonPayload = decodeURIComponent( - atob(base64) - .split('') - .map((c) => { - return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); - }) - .join(''), - ); - return JSON.parse(jsonPayload); -} - @Injectable({ providedIn: 'root', }) diff --git a/apps/enrolment/src/app/enrolment.component.ts b/apps/enrolment/src/app/enrolment.component.ts index 989172d5059..dffc3a10250 100644 --- a/apps/enrolment/src/app/enrolment.component.ts +++ b/apps/enrolment/src/app/enrolment.component.ts @@ -2,11 +2,7 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, OnInit } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; +import { AsyncHandler, settingSignal, SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, @@ -23,7 +19,7 @@ import { EnrolmentStateService } from './enrolment-state.service';
- @let logo_path = logo()?.src || logo(); + @let logo_path = logo(); @@ -97,20 +94,22 @@ export class EnrolmentComponent extends AsyncHandler implements OnInit { private _state = inject(EnrolmentStateService); private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); public readonly loading = this._state.loading; public readonly view = this._state.view; public readonly event = this._state.event; + /** + * Image URL of the header logo. The header uses the opposite logo to the + * theme: `logo_dark` with the light theme and `logo_light` with the dark theme. + */ public readonly logo = computed(() => { - // Recompute when the active building changes - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_light') - : this._settings.get('app.logo_dark')) || {} - ); + const logo = settingSignal( + this._settings.theme_signal() === 'dark' + ? 'logo_light' + : 'logo_dark', + )(); + return typeof logo === 'string' ? logo : logo?.src || ''; }); public ngOnInit() { diff --git a/apps/enrolment/src/tests/enrolment-state.service.spec.ts b/apps/enrolment/src/tests/enrolment-state.service.spec.ts index 15678faff26..717eb52d649 100644 --- a/apps/enrolment/src/tests/enrolment-state.service.spec.ts +++ b/apps/enrolment/src/tests/enrolment-state.service.spec.ts @@ -6,10 +6,7 @@ import { import { getUnixTime } from 'date-fns'; import * as ts_client from '@placeos/ts-client'; -import { - EnrolmentStateService, - parseJWT, -} from '../app/enrolment-state.service'; +import { EnrolmentStateService } from '../app/enrolment-state.service'; // Assert at the `@placeos/ts-client` boundary. `showGuest`/`showEvent`/ // `checkinEventGuest`/`updateGuest` are workspace functions that run for real @@ -100,22 +97,6 @@ describe('EnrolmentStateService', () => { expect(ts_client.get).not.toHaveBeenCalled(); }); - describe('parseJWT', () => { - it('should decode the payload segment of a token', () => { - expect( - parseJWT(userToken({ exp: 42, u: { e: 'a@b.com' } })), - ).toEqual({ exp: 42, u: { e: 'a@b.com' } }); - }); - - it('should decode base64url-specific characters', () => { - // '>>>' and '???' encode to base64 containing '+' and '/', which the - // base64url variant replaces with '-' and '_' - const payload = { note: '>>>???' }; - expect(userToken(payload)).toMatch(/[-_]/); - expect(parseJWT(userToken(payload))).toEqual(payload); - }); - }); - describe('handleUserToken', () => { it('should authenticate and load the guest named in the token', async () => { spectator.service.handleUserToken(validToken()); diff --git a/apps/enrolment/src/tests/enrolment.component.spec.ts b/apps/enrolment/src/tests/enrolment.component.spec.ts index b4d9d627fa6..eab93b37525 100644 --- a/apps/enrolment/src/tests/enrolment.component.spec.ts +++ b/apps/enrolment/src/tests/enrolment.component.spec.ts @@ -4,8 +4,8 @@ import { DeferBlockState, } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; -import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { settingSignal, SettingsService } from '@placeos/common'; import { getUnixTime } from 'date-fns'; import { MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; @@ -31,8 +31,7 @@ function userToken() { function param_map(params: Record): unknown { return { - has: (key: string) => - Object.prototype.hasOwnProperty.call(params, key), + has: (key: string) => Object.prototype.hasOwnProperty.call(params, key), get: (key: string) => (key in params ? params[key] : null), getAll: (key: string) => (key in params ? [params[key]] : []), get keys() { @@ -46,21 +45,14 @@ describe('EnrolmentComponent', () => { let state: EnrolmentStateService; let params: BehaviorSubject; let query_params: BehaviorSubject; - let settings_map: Record; - let theme: string; + const theme = signal('light'); const createComponent = createComponentFactory({ component: EnrolmentComponent, providers: [ MockProvider(SettingsService, { - get: vi.fn((key: string) => settings_map[key]), signal: vi.fn(() => signal(false)), - get theme() { - return theme; - }, - } as any), - MockProvider(OrganisationService, { - active_building: signal({ id: 'bld-1' }), + theme_signal: theme, } as any), { provide: ActivatedRoute, @@ -83,11 +75,9 @@ describe('EnrolmentComponent', () => { beforeEach(() => { vi.useFakeTimers(); - theme = 'light'; - settings_map = { - 'app.logo_dark': { src: 'assets/logo-dark.svg' }, - 'app.logo_light': { src: 'assets/logo-light.svg' }, - }; + theme.set('light'); + settingSignal('logo_dark').set({ src: 'assets/logo-dark.svg' }); + settingSignal('logo_light').set('assets/logo-light.svg'); params = new BehaviorSubject(param_map({})); query_params = new BehaviorSubject(param_map({})); (ts_client.get as any).mockReset(); @@ -182,8 +172,7 @@ describe('EnrolmentComponent', () => { }); it('should use the light logo for the dark theme', () => { - theme = 'dark'; - spectator = createComponent(); + theme.set('dark'); spectator.detectChanges(); expect(spectator.query('img')?.getAttribute('src')).toBe( diff --git a/apps/map-studio/src/app/data/placeos.service.ts b/apps/map-studio/src/app/data/placeos.service.ts index 26a6b642157..32d40f1fac2 100644 --- a/apps/map-studio/src/app/data/placeos.service.ts +++ b/apps/map-studio/src/app/data/placeos.service.ts @@ -21,6 +21,24 @@ const HANDOFF_MIN_DELAY_MS = 30 * 1000; const PAGE_SIZE = 100; const PAGE_LIMIT = 50; +/** + * Copy of `floorplan` without the fields that only this browser keeps: IDs, + * the PlaceOS level link, the source file details and the creation time. + * Used for floorplan metadata sent to or read from PlaceOS. + */ +export function withoutLocalFields( + floorplan: Partial = {}, +): Partial { + const data = { ...floorplan }; + delete data.id; + delete data.project_id; + delete data.level_zone_id; + delete data.source_image_path; + delete data.source_type; + delete data.created_at; + return data; +} + function withDeadline(promise: Promise, message: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout( @@ -372,13 +390,7 @@ export class PlaceOSService { level_id: string, floorplan: Floorplan, ): Promise { - const details: Partial = { ...floorplan }; - delete details.id; - delete details.project_id; - delete details.level_zone_id; - delete details.source_image_path; - delete details.source_type; - delete details.created_at; + const details = withoutLocalFields(floorplan); // Imports keep the same SVG, often megabytes, in both fields. Send it // once; reimport only reads svg_output when there is no svg_source. if (details.svg_output === details.svg_source) diff --git a/apps/map-studio/src/app/data/store.service.ts b/apps/map-studio/src/app/data/store.service.ts index d0e4da54a06..b50f5daa872 100644 --- a/apps/map-studio/src/app/data/store.service.ts +++ b/apps/map-studio/src/app/data/store.service.ts @@ -1,8 +1,9 @@ import { inject, Injectable, signal } from '@angular/core'; +import { mapWithLimit } from '@placeos/common'; import { DbService } from './db'; import type { PlaceOSZone } from './placeos.service'; -import { PlaceOSService } from './placeos.service'; +import { PlaceOSService, withoutLocalFields } from './placeos.service'; import { analyzeSvg, SvgAnalysis } from './svg-import'; import { makeThumbnail } from './thumbnail'; import { @@ -24,17 +25,6 @@ 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; - delete saved.project_id; - delete saved.level_zone_id; - delete saved.source_image_path; - delete saved.source_type; - delete saved.created_at; - return saved; -} - const key = { project: (id: string) => `project:${id}`, floorplan: (id: string) => `floorplan:${id}`, @@ -282,7 +272,7 @@ export class StoreService { version: 1, created_at: now(), updated_at: now(), - ...localFloorplanData(metadata), + ...withoutLocalFields(metadata), }; await this._db.write({ put: [ @@ -354,26 +344,23 @@ export class StoreService { 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, - ), - })), - )), - ); - } + // A few buildings at a time, so large deployments do not flood + // the API. A failed level query fails the sync once the + // other queries end. + const failures: unknown[] = []; + const buildings = await mapWithLimit( + zones, + SYNC_CONCURRENCY, + (zone) => + this._placeos.getZones('level', zone.id).then( + (levels) => ({ zone, levels }), + (error: unknown) => { + failures.push(error); + return { zone, levels: [] }; + }, + ), + ); + if (failures.length) throw failures[0]; await this.syncPlaceOSZones(buildings); } this._placeos_sync.set('done'); @@ -413,28 +400,26 @@ export class StoreService { ([, 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) { - const batch = missing.slice(i, i + SYNC_CONCURRENCY); - const metadata = await Promise.all( - batch.map(([, level]) => - this._placeos - .getFloorplanMetadata(level.id) - .catch(() => null), - ), + // Fetch metadata a few levels at a time, then add the floors in order + const metadata = await mapWithLimit( + missing, + SYNC_CONCURRENCY, + ([, level]) => + this._placeos + .getFloorplanMetadata(level.id) + .catch(() => null), + ); + for (const [index, [floor_index, level]] of missing.entries()) { + if (existing_levels.has(level.id)) continue; + const floorplan = await this.addFloorplan( + project.id, + level.display_name || level.name || level.id, + floor_index, + level.id, + metadata[index] ?? undefined, ); - for (const [index, [floor_index, level]] of batch.entries()) { - if (existing_levels.has(level.id)) continue; - const floorplan = await this.addFloorplan( - project.id, - level.display_name || level.name || level.id, - floor_index, - level.id, - metadata[index] ?? undefined, - ); - project.floorplans?.push(floorplan); - existing_levels.add(level.id); - } + project.floorplans?.push(floorplan); + existing_levels.add(level.id); } } this.zone_sync_version.update((version) => version + 1); @@ -478,7 +463,7 @@ export class StoreService { const floorplan = await this.getFloorplan(id); const updated = { ...floorplan, - ...localFloorplanData(metadata), + ...withoutLocalFields(metadata), id, }; await this._db.write({ diff --git a/apps/map-studio/src/app/editor/editor-state.service.ts b/apps/map-studio/src/app/editor/editor-state.service.ts index 1f247c1796b..7ea136a84bc 100644 --- a/apps/map-studio/src/app/editor/editor-state.service.ts +++ b/apps/map-studio/src/app/editor/editor-state.service.ts @@ -1,6 +1,6 @@ import { Injectable, computed, inject, signal } from '@angular/core'; -import { AsyncHandler } from '@placeos/common'; +import { AsyncHandler, errorMessage } from '@placeos/common'; import type { DetectedRoom } from '../data/floorplan-ai.service'; import { PlaceOSService } from '../data/placeos.service'; @@ -309,7 +309,7 @@ export class EditorStateService extends AsyncHandler { await this.load(this._floorplan_id); await this.save(); } catch (error) { - this._error.set(this._message(error, 'Failed to reimport SVG')); + this._error.set(errorMessage(error) || 'Failed to reimport SVG'); } finally { this._loading.set(false); } @@ -449,7 +449,7 @@ export class EditorStateService extends AsyncHandler { await this._loadImage(floorplan_id); if (imported_svg) await this.save(); } catch (error) { - this._error.set(this._message(error, 'Failed to load floorplan')); + this._error.set(errorMessage(error) || 'Failed to load floorplan'); } finally { this._loading.set(false); } @@ -524,7 +524,7 @@ export class EditorStateService extends AsyncHandler { ); await this._loadImage(this._floorplan_id); } catch (error) { - this._error.set(this._message(error, 'Upload failed')); + this._error.set(errorMessage(error) || 'Upload failed'); } } @@ -1293,13 +1293,9 @@ export class EditorStateService extends AsyncHandler { if (this._change_count === change) this._dirty.set(false); this._error.set(''); } catch (error) { - this._error.set(this._message(error, 'Failed to save')); + this._error.set(errorMessage(error) || 'Failed to save'); } finally { this._saving.set(false); } } - - private _message(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; - } } diff --git a/apps/map-studio/src/app/editor/editor.component.ts b/apps/map-studio/src/app/editor/editor.component.ts index f637dc34ec0..1354a1cca84 100644 --- a/apps/map-studio/src/app/editor/editor.component.ts +++ b/apps/map-studio/src/app/editor/editor.component.ts @@ -10,6 +10,7 @@ import { viewChild, } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; +import { errorMessage } from '@placeos/common'; import { IconComponent } from '@placeos/components'; import type { DetectedRoom } from '../data/floorplan-ai.service'; @@ -1222,7 +1223,7 @@ export class EditorComponent { ); } catch (error) { this._toasts.show( - this._errorMessage(error, 'Outline detection failed'), + errorMessage(error) || 'Outline detection failed', 'error', ); } finally { @@ -1255,7 +1256,7 @@ export class EditorComponent { ); } catch (error) { this._toasts.show( - this._errorMessage(error, 'Room detection failed'), + errorMessage(error) || 'Room detection failed', 'error', ); } finally { @@ -1302,10 +1303,6 @@ export class EditorComponent { ); } - private _errorMessage(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; - } - /** The multi-selection when it holds the selected object, else the selected object */ private _selectionIds(): string[] { const multi = this.state.multi_select(); diff --git a/apps/map-studio/src/app/editor/publish-panel.component.ts b/apps/map-studio/src/app/editor/publish-panel.component.ts index b0e4650d6a1..5333510720e 100644 --- a/apps/map-studio/src/app/editor/publish-panel.component.ts +++ b/apps/map-studio/src/app/editor/publish-panel.component.ts @@ -5,6 +5,7 @@ import { signal, WritableSignal, } from '@angular/core'; +import { errorMessage } from '@placeos/common'; import { IconComponent } from '@placeos/components'; import { @@ -595,7 +596,7 @@ export class PublishPanelComponent { if (isCurrent()) target.set({ state: 'ready', items, error: '' }); } catch (error) { if (!isCurrent()) return; - const message = this._message(error, 'Request failed'); + const message = errorMessage(error) || 'Request failed'; target.set({ state: 'error', items: [], error: message }); } } @@ -678,7 +679,7 @@ export class PublishPanelComponent { failed_links ? 'error' : 'success', ); } catch (error) { - const message = this._message(error, 'Publish failed'); + const message = errorMessage(error) || 'Publish failed'; this.status.set(message); this._toasts.show(message, 'error'); } finally { @@ -705,8 +706,4 @@ export class PublishPanelComponent { } return failed; } - - private _message(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; - } } diff --git a/apps/map-studio/src/app/editor/validation-panel.component.ts b/apps/map-studio/src/app/editor/validation-panel.component.ts index 1df299af5a2..4303caca9c3 100644 --- a/apps/map-studio/src/app/editor/validation-panel.component.ts +++ b/apps/map-studio/src/app/editor/validation-panel.component.ts @@ -1,4 +1,5 @@ import { Component, computed, inject, output, signal } from '@angular/core'; +import { errorMessage } from '@placeos/common'; import { IconComponent } from '@placeos/components'; import { StoreService } from '../data/store.service'; @@ -173,9 +174,7 @@ export class ValidationPanelComponent { this._checked.set(objects); this.has_run.set(true); } catch (error) { - this.error.set( - error instanceof Error ? error.message : 'Validation failed', - ); + this.error.set(errorMessage(error) || 'Validation failed'); } finally { this.running.set(false); } diff --git a/apps/map-studio/src/app/pages/import.component.ts b/apps/map-studio/src/app/pages/import.component.ts index 171153c2747..797dda219ef 100644 --- a/apps/map-studio/src/app/pages/import.component.ts +++ b/apps/map-studio/src/app/pages/import.component.ts @@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms'; import { MatFormField } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { errorMessage } from '@placeos/common'; import { IconComponent } from '@placeos/components'; import { StoreService } from '../data/store.service'; @@ -737,7 +738,7 @@ export class ImportComponent { } catch (error) { this.analysis.set(null); this.mappings.set([]); - this.error.set(this._message(error, 'Failed to analyse SVG')); + this.error.set(errorMessage(error) || 'Failed to analyse SVG'); } finally { this.analyzing.set(false); } @@ -820,7 +821,7 @@ export class ImportComponent { }); this._router.navigate(['/editor', result.floorplan_id]); } catch (error) { - this.error.set(this._message(error, 'Import failed')); + this.error.set(errorMessage(error) || 'Import failed'); } finally { this.importing.set(false); } @@ -831,11 +832,7 @@ export class ImportComponent { this.target_project.set(await this._store.getProject(id)); } catch (error) { this.project_missing.set(true); - this.error.set(this._message(error, 'Project not found')); + this.error.set(errorMessage(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 c2775a76952..c1b74a58ed2 100644 --- a/apps/map-studio/src/app/pages/project-detail.component.ts +++ b/apps/map-studio/src/app/pages/project-detail.component.ts @@ -13,6 +13,7 @@ import { FormsModule } from '@angular/forms'; import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { errorMessage } from '@placeos/common'; import { IconComponent } from '@placeos/components'; import { StoreService } from '../data/store.service'; @@ -495,7 +496,7 @@ export class ProjectDetailComponent { this.project.set(project); await this._loadPreviews(project.floorplans ?? []); } catch (error) { - this.error.set(this._message(error, 'Failed to load project')); + this.error.set(errorMessage(error) || 'Failed to load project'); } finally { this.loading.set(false); } @@ -537,7 +538,7 @@ export class ProjectDetailComponent { if (save_error) this.error.set(save_error); } } catch (error) { - this.error.set(this._message(error, 'Failed to rename')); + this.error.set(errorMessage(error) || 'Failed to rename'); } } @@ -566,7 +567,7 @@ export class ProjectDetailComponent { } if (save_error) this.error.set(save_error); } catch (error) { - this.error.set(this._message(error, 'Failed to reorder floors')); + this.error.set(errorMessage(error) || 'Failed to reorder floors'); } finally { this.reordering.set(false); } @@ -586,7 +587,7 @@ export class ProjectDetailComponent { this.floor_name.set(''); this.show_form.set(false); } catch (error) { - this.error.set(this._message(error, 'Failed to add floor')); + this.error.set(errorMessage(error) || 'Failed to add floor'); } finally { this.creating.set(false); } @@ -607,7 +608,7 @@ export class ProjectDetailComponent { floorplans: this.floorplans().filter((f) => f.id !== floor.id), }); } catch (error) { - this.error.set(this._message(error, 'Failed to delete floor')); + this.error.set(errorMessage(error) || 'Failed to delete floor'); } } @@ -654,8 +655,4 @@ export class ProjectDetailComponent { private _revokePreviews(previews: Record) { for (const url of Object.values(previews)) URL.revokeObjectURL(url); } - - private _message(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; - } } diff --git a/apps/map-studio/src/app/pages/projects.component.ts b/apps/map-studio/src/app/pages/projects.component.ts index a3e5cb976d5..fafc53f7c94 100644 --- a/apps/map-studio/src/app/pages/projects.component.ts +++ b/apps/map-studio/src/app/pages/projects.component.ts @@ -11,6 +11,7 @@ import { FormsModule } from '@angular/forms'; import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { Router, RouterLink } from '@angular/router'; +import { errorMessage } from '@placeos/common'; import { IconComponent } from '@placeos/components'; import { lastUpdated, StoreService } from '../data/store.service'; @@ -282,7 +283,7 @@ export class ProjectsComponent { try { this.projects.set(await this._store.listProjects()); } catch (error) { - this.error.set(this._message(error, 'Failed to load projects')); + this.error.set(errorMessage(error) || 'Failed to load projects'); } finally { this.loading.set(false); } @@ -305,7 +306,7 @@ export class ProjectsComponent { ); await this._router.navigate(['/project', project.id]); } catch (error) { - this.error.set(this._message(error, 'Failed to create project')); + this.error.set(errorMessage(error) || 'Failed to create project'); } finally { this.creating.set(false); } @@ -326,11 +327,7 @@ export class ProjectsComponent { list.filter((p) => p.id !== project.id), ); } catch (error) { - this.error.set(this._message(error, 'Failed to delete project')); + this.error.set(errorMessage(error) || 'Failed to delete project'); } } - - private _message(error: unknown, fallback: string) { - return error instanceof Error ? error.message : fallback; - } } diff --git a/apps/signage-manager/src/app/image-gen/image-gen.service.ts b/apps/signage-manager/src/app/image-gen/image-gen.service.ts index 0c69e991a7a..5022eda8afb 100644 --- a/apps/signage-manager/src/app/image-gen/image-gen.service.ts +++ b/apps/signage-manager/src/app/image-gen/image-gen.service.ts @@ -1,6 +1,7 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import { AsyncHandler, + errorStatus, i18n, notifyError, notifyInfo, @@ -28,7 +29,7 @@ import { ImageGenJob, ImageGenLogoSlot, } from './image-gen.types'; -import { errorStatus, UserFacingError } from './image-gen.util'; +import { UserFacingError } from './image-gen.util'; const FINAL_STATES = ['done', 'failed', 'cancelled']; diff --git a/apps/signage-manager/src/app/image-gen/image-gen.util.ts b/apps/signage-manager/src/app/image-gen/image-gen.util.ts index a0812d66979..aa0c5772b5e 100644 --- a/apps/signage-manager/src/app/image-gen/image-gen.util.ts +++ b/apps/signage-manager/src/app/image-gen/image-gen.util.ts @@ -16,21 +16,6 @@ export function actionError(error: unknown, fallback: string): string { return fallback; } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -/** Return an HTTP status from either the error or its wrapped API response. */ -export function errorStatus(error: unknown): number | undefined { - if (!isRecord(error)) return undefined; - const status = error['status']; - if (typeof status === 'number') return status; - const nested = error['error']; - if (!isRecord(nested)) return undefined; - const nested_status = nested['status']; - return typeof nested_status === 'number' ? nested_status : undefined; -} - /** Perceived sRGB brightness on a 0 to 255 scale. */ export function perceivedLightness( red: number, diff --git a/apps/signage-manager/src/app/media/signage-media.service.ts b/apps/signage-manager/src/app/media/signage-media.service.ts index 65216cc1516..8e5c8f66c8f 100644 --- a/apps/signage-manager/src/app/media/signage-media.service.ts +++ b/apps/signage-manager/src/app/media/signage-media.service.ts @@ -11,6 +11,7 @@ import { import { MatDialog } from '@angular/material/dialog'; import { i18n, + mapWithLimit, notifyError, notifyInfo, notifySuccess, @@ -88,26 +89,18 @@ const MEDIA_CONCURRENCY = 4; * `Promise.allSettled`, one failure does not stop the others, and the results * keep the order of `items`. */ -async function settleEach(items: T[], task: (item: T) => Promise) { - const results: PromiseSettledResult[] = new Array(items.length); - let next = 0; - const worker = async () => { - // Each pass takes a new index, so this ends after `items.length` passes - while (next < items.length) { - const index = next++; +function settleEach(items: T[], task: (item: T) => Promise) { + return mapWithLimit( + items, + MEDIA_CONCURRENCY, + async (item): Promise> => { try { - results[index] = { - status: 'fulfilled', - value: await task(items[index]), - }; + return { status: 'fulfilled', value: await task(item) }; } catch (reason) { - results[index] = { status: 'rejected', reason }; + return { status: 'rejected', reason }; } - } - }; - const workers = Math.min(MEDIA_CONCURRENCY, items.length); - await Promise.all(Array.from({ length: workers }, worker)); - return results; + }, + ); } /** First three names, then a count of the others, such as "a, b, c +2" */ @@ -1082,10 +1075,8 @@ export class SignageMediaService { ); if (result.reason !== 'done') return false; result.loading(i18n('SIGNAGE_MANAGER.SVC_MEDIA_REMOVING')); - const results = await Promise.allSettled( - media_ids.map((id) => - removeSignageMedia(id, this._context.groupQueryParams({})), - ), + const results = await settleEach(media_ids, (id) => + removeSignageMedia(id, this._context.groupQueryParams({})), ); const removed_ids = media_ids.filter( (_, index) => results[index].status === 'fulfilled', @@ -1134,8 +1125,8 @@ export class SignageMediaService { id: item.id, tags: [...new Set([...(item.tags || []), ...tags])], })); - const results = await Promise.allSettled( - changes.map(({ id, tags }) => updateSignageMedia(id, { tags })), + const results = await settleEach(changes, ({ id, tags }) => + updateSignageMedia(id, { tags }), ); // Show the new tags on the items that saved, in place const saved = new Map( diff --git a/apps/signage-manager/src/app/shared/nav-sidebar.component.ts b/apps/signage-manager/src/app/shared/nav-sidebar.component.ts index 4fa0d38a18b..d427923b275 100644 --- a/apps/signage-manager/src/app/shared/nav-sidebar.component.ts +++ b/apps/signage-manager/src/app/shared/nav-sidebar.component.ts @@ -13,7 +13,12 @@ import { MatRippleModule } from '@angular/material/core'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; -import { i18n, LocaleService, SettingsService } from '@placeos/common'; +import { + i18n, + LocaleService, + SettingsService, + themedLogo, +} from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, @@ -47,7 +52,7 @@ class NavScrollState { class="bg-base-100/80 mx-auto flex h-20 w-20 shrink-0 items-center justify-center rounded-2xl shadow-xl" [routerLink]="['/']" > - @if (logo_src; as logo) { + @if (logo_src(); as logo) { >('scroller'); private readonly _scroll_content = viewChild>('scroll_content'); @@ -356,13 +362,4 @@ export class NavSidebarComponent { localStorage.setItem('PLACEOS.locale', code); setTimeout(() => location.reload(), 300); } - - public get logo_src(): string { - const logo = this._settings.get( - this._settings.theme === 'dark' - ? 'app.logo_dark' - : 'app.logo_light', - ); - return typeof logo === 'string' ? logo : logo?.src || ''; - } } diff --git a/apps/signage-manager/src/app/signage-context.service.ts b/apps/signage-manager/src/app/signage-context.service.ts index 65341bc3ac5..1ada2695b58 100644 --- a/apps/signage-manager/src/app/signage-context.service.ts +++ b/apps/signage-manager/src/app/signage-context.service.ts @@ -11,7 +11,9 @@ import { } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { + errorStatus, i18n, + mapWithLimit, notifyError, notifySuccess, notifyWarn, @@ -32,7 +34,6 @@ import { shareSignageTemplates, showGroupFeatures, } from '@placeos/ts-client'; -import { errorStatus } from './image-gen/image-gen.util'; import { decodeEntityNames } from './shared/decode-entity-names.util'; import { byName } from './shared/paged-search'; import { @@ -83,6 +84,8 @@ const SIGNAGE_GROUP_FIELDS = [ // Upper bound on group index pages, 10,000 groups at 200 per page. const MAX_GROUP_PAGES = 50; +/** Most group list queries `groupsHolding` sends at the same time */ +const GROUP_QUERY_CONCURRENCY = 4; // Flags that allow no feature and no plugin const NO_GROUP_FEATURES: SignageGroupFeatures = { @@ -694,14 +697,15 @@ export class SignageContextService { ({ group }) => group.id === this.api_group_id(), ); if (selected_group) return [selected_group]; - const matches = await Promise.all( - groups.map(({ group }) => + const matches = await mapWithLimit( + groups, + GROUP_QUERY_CONCURRENCY, + ({ group }) => query(group.id).then( (result) => (result.data || []).some(({ id }) => id === item_id), () => false, ), - ), ); return groups.filter((_, index) => matches[index]); } diff --git a/apps/signage-manager/src/app/signage-features.ts b/apps/signage-manager/src/app/signage-features.ts index ff578b19d1e..120abfaefb6 100644 --- a/apps/signage-manager/src/app/signage-features.ts +++ b/apps/signage-manager/src/app/signage-features.ts @@ -1,5 +1,4 @@ -import { i18n } from '@placeos/common'; -import { errorStatus } from './image-gen/image-gen.util'; +import { errorStatus, i18n } from '@placeos/common'; /** * Features the `app.features` setting can turn on. Groups can only narrow diff --git a/apps/signage-manager/src/app/signage-inventory.service.ts b/apps/signage-manager/src/app/signage-inventory.service.ts index 70f0482b042..9e49dd32824 100644 --- a/apps/signage-manager/src/app/signage-inventory.service.ts +++ b/apps/signage-manager/src/app/signage-inventory.service.ts @@ -1,4 +1,5 @@ import { computed, inject, Injectable } from '@angular/core'; +import { mapWithLimit } from '@placeos/common'; import { PlaceSystem, PlaceZone, @@ -177,10 +178,8 @@ export class SignageInventoryService { * @returns The items in use and the number of expired items not checked */ private async _expiredMediaInPlaylists(now: number) { - const usage: { media: SignageMedia; playlists: SignagePlaylist[] }[] = - []; if (!this._context.canQueryLists()) { - return { items: usage, unchecked: 0 }; + return { items: [], unchecked: 0 }; } const media = await queryAll( querySignageMedia( @@ -192,32 +191,19 @@ export class SignageInventoryService { ); const expired = all_expired.slice(0, MAX_EXPIRED_MEDIA_CHECKS); const query_params = this._context.groupQueryParams({}); - let next = 0; - const check = async () => { - // Each pass takes one item, so the loop ends after the list - while (next < expired.length) { - const index = next++; - const item = expired[index]; - try { - const detail = await showSignageMedia( - item.id, - query_params, - ); - // Keep the media order, whichever request ends first - usage[index] = { - media: item, - playlists: detail.playlists || [], - }; - } catch { - // Leave out media that cannot be read - } - } - }; - await Promise.all( - Array.from({ length: EXPIRED_MEDIA_CONCURRENCY }, check), + // Results keep the media order, whichever request ends first + const usage = await mapWithLimit( + expired, + EXPIRED_MEDIA_CONCURRENCY, + (media) => + showSignageMedia(media.id, query_params).then( + (detail) => ({ media, playlists: detail.playlists || [] }), + // Media that cannot be read is left out below + () => ({ media, playlists: [] as SignagePlaylist[] }), + ), ); return { - items: usage.filter((item) => item?.playlists.length), + items: usage.filter((item) => item.playlists.length), unchecked: all_expired.length - expired.length, }; } diff --git a/apps/signage-manager/src/app/signage-media-tags.util.ts b/apps/signage-manager/src/app/signage-media-tags.util.ts index e8f243b6655..3ed75739dcf 100644 --- a/apps/signage-manager/src/app/signage-media-tags.util.ts +++ b/apps/signage-manager/src/app/signage-media-tags.util.ts @@ -1,4 +1,7 @@ -import { apiEndpoint, get, listSignageMediaTags } from '@placeos/ts-client'; +import { + listSignageMediaTags, + listSignageMediaTagCounts as queryTagCounts, +} from '@placeos/ts-client'; export interface SignageMediaTagCounts { /** Distinct tags in use, sorted by name */ @@ -19,14 +22,12 @@ function sortTagNames(tags: string[]) { export async function listSignageMediaTagCounts( query_params: Record = {}, ): Promise { - const params = Object.entries(query_params).filter( - (entry): entry is [string, string] => !!entry[1], + // The API client sends empty strings, so leave out empty parameters + const params = Object.fromEntries( + Object.entries(query_params).filter(([, value]) => !!value), ); - const query = new URLSearchParams(params).toString(); try { - const response = await get( - `${apiEndpoint()}/signage/media/tag_counts${query ? `?${query}` : ''}`, - ); + const response = await queryTagCounts(params); const counts: Record = {}; for (const [tag, count] of Object.entries(response || {})) { counts[tag] = Number(count) || 0; diff --git a/apps/signage-manager/src/tests/image-gen/image-gen.util.spec.ts b/apps/signage-manager/src/tests/image-gen/image-gen.util.spec.ts index e0c2910af7d..ca2f4587406 100644 --- a/apps/signage-manager/src/tests/image-gen/image-gen.util.spec.ts +++ b/apps/signage-manager/src/tests/image-gen/image-gen.util.spec.ts @@ -1,6 +1,5 @@ import { actionError, - errorStatus, hexColour, orientationOf, perceivedLightness, @@ -23,12 +22,6 @@ describe('image generation utilities', () => { ).toBe('This domain has no organisation zone'); }); - it('reads direct and wrapped HTTP status codes', () => { - expect(errorStatus({ status: 404 })).toBe(404); - expect(errorStatus({ error: { status: 403 } })).toBe(403); - expect(errorStatus(new Error('offline'))).toBeUndefined(); - }); - it('uses one luminance calculation for black and white', () => { expect(perceivedLightness(0, 0, 0)).toBe(0); expect(perceivedLightness(255, 255, 255)).toBe(255); diff --git a/apps/signage-manager/src/tests/shared/nav-sidebar.component.spec.ts b/apps/signage-manager/src/tests/shared/nav-sidebar.component.spec.ts index 1d20d1c555e..83dee4ff83d 100644 --- a/apps/signage-manager/src/tests/shared/nav-sidebar.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/nav-sidebar.component.spec.ts @@ -1,6 +1,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { LocaleService, SettingsService } from '@placeos/common'; +import { LocaleService, settingSignal, SettingsService } from '@placeos/common'; import { NavSidebarComponent } from '../../app/shared/nav-sidebar.component'; import { SignageContextService } from '../../app/signage-context.service'; @@ -14,7 +14,6 @@ describe('NavSidebarComponent', () => { key === 'locales' ? locales_signal : show_locale_signal, ), get: vi.fn(), - theme: 'light', }; const locale = { locale: 'en', @@ -77,7 +76,6 @@ describe('NavSidebarComponent', () => { can_manage_all_groups.set(false); can_manage_groups.set(false); templates_enabled.set(false); - settings.theme = 'light'; settings.get.mockReset(); locale.locale = 'en'; TestBed.resetTestingModule(); @@ -168,23 +166,39 @@ describe('NavSidebarComponent', () => { set_item.mockRestore(); }); - it('reads the light logo from settings by default', async () => { - settings.get.mockImplementation((key: string) => - key === 'app.logo_light' ? 'light.png' : 'dark.png', - ); - const component = await createComponent(); + describe('logo', () => { + beforeEach(() => { + settingSignal('logo_light').set('light.png'); + settingSignal('logo_dark').set({ src: 'dark.png' }); + }); - expect(component.logo_src).toBe('light.png'); - }); + afterEach(() => { + settingSignal('logo_light').set(undefined); + settingSignal('logo_dark').set(undefined); + settingSignal('allow_dark_mode').set(false); + settingSignal('theme', 'light', true).set('light'); + }); - it('reads the dark logo when the theme is dark', async () => { - settings.theme = 'dark'; - settings.get.mockImplementation((key: string) => - key === 'app.logo_dark' ? { src: 'dark.png' } : 'light.png', - ); - const component = await createComponent(); + it('reads the light logo from settings by default', async () => { + const component = await createComponent(); - expect(component.logo_src).toBe('dark.png'); + expect(component.logo_src()).toBe('light.png'); + }); + + it('reads the dark logo when the theme is dark', async () => { + settingSignal('allow_dark_mode').set(true); + settingSignal('theme', 'light', true).set('dark'); + const component = await createComponent(); + + expect(component.logo_src()).toBe('dark.png'); + }); + + it('keeps the light logo when the app does not allow dark mode', async () => { + settingSignal('theme', 'light', true).set('dark'); + const component = await createComponent(); + + expect(component.logo_src()).toBe('light.png'); + }); }); describe('nav list overflow', () => { diff --git a/apps/signage-manager/src/tests/signage-media-tags.util.spec.ts b/apps/signage-manager/src/tests/signage-media-tags.util.spec.ts index d7b2f41183f..efeb47f3f43 100644 --- a/apps/signage-manager/src/tests/signage-media-tags.util.spec.ts +++ b/apps/signage-manager/src/tests/signage-media-tags.util.spec.ts @@ -1,40 +1,36 @@ -import { apiEndpoint, get, listSignageMediaTags } from '@placeos/ts-client'; +import { + listSignageMediaTags, + listSignageMediaTagCounts as queryTagCounts, +} from '@placeos/ts-client'; import { listSignageMediaTagCounts } from '../app/signage-media-tags.util'; vi.mock('@placeos/ts-client', { spy: true }); describe('signage media tags util', () => { - beforeEach(() => { - vi.clearAllMocks(); - (apiEndpoint as any).mockReturnValue('/api/engine/v2'); - }); + beforeEach(() => vi.clearAllMocks()); it('lists tags with the media count the backend reports', async () => { - (get as any).mockResolvedValue({ news: 12, lobby: '3' }); + (queryTagCounts as any).mockResolvedValue({ news: 12, lobby: '3' }); const result = await listSignageMediaTagCounts({ group_id: 'grp-1' }); - expect(get).toHaveBeenCalledWith( - '/api/engine/v2/signage/media/tag_counts?group_id=grp-1', - ); + expect(queryTagCounts).toHaveBeenCalledWith({ group_id: 'grp-1' }); // Tags are sorted by name so the folder list keeps a stable order. expect(result.tags).toEqual(['lobby', 'news']); expect(result.counts).toEqual({ news: 12, lobby: 3 }); }); it('omits empty query parameters', async () => { - (get as any).mockResolvedValue({}); + (queryTagCounts as any).mockResolvedValue({}); await listSignageMediaTagCounts({ group_id: '', zone_id: 'zone-1' }); - expect(get).toHaveBeenCalledWith( - '/api/engine/v2/signage/media/tag_counts?zone_id=zone-1', - ); + expect(queryTagCounts).toHaveBeenCalledWith({ zone_id: 'zone-1' }); }); it('falls back to the tags endpoint when counts are unavailable', async () => { - (get as any).mockRejectedValue({ status: 404 }); + (queryTagCounts as any).mockRejectedValue({ status: 404 }); (listSignageMediaTags as any).mockResolvedValue(['news', 'lobby']); const result = await listSignageMediaTagCounts({ group_id: 'grp-1' }); diff --git a/apps/signage/src/app/media-cache.service.ts b/apps/signage/src/app/media-cache.service.ts index df4c29e3b35..4d5a463e70b 100644 --- a/apps/signage/src/app/media-cache.service.ts +++ b/apps/signage/src/app/media-cache.service.ts @@ -5,6 +5,7 @@ import { randomString, scoped_log, SECONDS, + withTimeout, } from '@placeos/common'; import { apiKey, token } from '@placeos/ts-client'; // `Subject`/`firstValueFrom` are kept for `on_change`: awaiting a cache item's @@ -182,34 +183,6 @@ function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } -/** - * Rejects if `promise` has not settled within `timeout_ms`. `on_timeout` lets - * the caller abandon whatever the promise was waiting on. - */ -function withTimeout( - promise: Promise, - timeout_ms: number, - message: string, - on_timeout?: () => void, -) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - on_timeout?.(); - reject(new Error(message)); - }, timeout_ms); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error) => { - clearTimeout(timer); - reject(error); - }, - ); - }); -} - /** * Read a response body into a Blob. Fails if no data arrives for * `DOWNLOAD_STALL_MS`, or once the body grows past `max_bytes`. Chunks pass diff --git a/apps/signage/src/app/signage.service.ts b/apps/signage/src/app/signage.service.ts index 00c91f225e0..e8a6c8d3aae 100644 --- a/apps/signage/src/app/signage.service.ts +++ b/apps/signage/src/app/signage.service.ts @@ -11,6 +11,7 @@ import { scoped_log, SECONDS, shuffleArray, + withTimeout, } from '@placeos/common'; import { apiEndpoint, @@ -648,26 +649,6 @@ export class SignageService extends AsyncHandler { this._poll(); } - /** Rejects if `promise` has not settled within `timeout_ms` */ - private _withTimeout(promise: Promise, timeout_ms: number) { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error('Display request timed out')), - timeout_ms, - ); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error) => { - clearTimeout(timer); - reject(error); - }, - ); - }); - } - /** * Re-fetch the active display details and refresh derived player state. * Returns whether the backend answered. @@ -692,9 +673,10 @@ export class SignageService extends AsyncHandler { // full would otherwise rewrite the same payload once a minute. if (payload.id === id) this._saveOfflineDisplay(id, payload_json); const display = this._parseDisplay(payload); - display.plugins = await this._withTimeout( + display.plugins = await withTimeout( this._resolveDisplayPlugins(display), DISPLAY_FETCH_TIMEOUT_MS, + 'Display plugin request timed out', ).catch((e) => { log.warn('Failed to resolve display plugins.', e); return display.plugins || []; @@ -757,9 +739,10 @@ export class SignageService extends AsyncHandler { let payload: DisplayPayload | null = null; let validators: DisplayValidators | null = null; try { - const response = await this._withTimeout( + const response = await withTimeout( showSignage(id, query_params, request_options), DISPLAY_FETCH_TIMEOUT_MS, + 'Display request timed out', ); if (isDisplayPayload(response)) { payload = response; diff --git a/apps/timetable/src/app/timetable.component.ts b/apps/timetable/src/app/timetable.component.ts index 2b83db2b67e..0cdb3a65e68 100644 --- a/apps/timetable/src/app/timetable.component.ts +++ b/apps/timetable/src/app/timetable.component.ts @@ -19,6 +19,7 @@ import { OrganisationService, SettingsService, Space, + themedLogo, } from '@placeos/common'; import { AuthenticatedImageDirective, @@ -75,12 +76,7 @@ export interface TimetableColumn { topbar class="border-base-300 bg-base-100 relative z-20 flex h-16 w-full items-center border-b p-2 shadow-sm" > - Logo + Logo
@if (page_count() > 1) {
@@ -370,15 +366,7 @@ export class AppTimetableComponent extends AsyncHandler implements OnInit { return ((current_hour - this.offset()) / this.length()) * 100; }); - public readonly logo = computed(() => { - // Recompute the logo whenever the active building changes. - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); + public readonly logo = themedLogo(); constructor() { super(); diff --git a/apps/workplace/src/app/components/footer-menu.component.ts b/apps/workplace/src/app/components/footer-menu.component.ts index f0825b49aea..8769ca0e4b7 100644 --- a/apps/workplace/src/app/components/footer-menu.component.ts +++ b/apps/workplace/src/app/components/footer-menu.component.ts @@ -221,13 +221,9 @@ export class FooterMenuComponent extends AsyncHandler { // Derive settings-based state as computeds so they stay consistent within a // change detection pass. Writing these from an effect created the menu // views late, throwing NG0100 (ExpressionChangedAfterItHasBeenChecked). - public readonly dark_mode = computed(() => { - this._org.active_building(); - return ( - this._settings.get('app.allow_dark_mode') && - this._settings.theme === 'dark' - ); - }); + public readonly dark_mode = computed( + () => this._settings.theme_signal() === 'dark', + ); /** Features of the user, limited by `app.feature_groups` like the top menu */ public readonly features = landingFeatures(); public readonly default_page = computed(() => { diff --git a/apps/workplace/src/tests/components/footer-menu.component.spec.ts b/apps/workplace/src/tests/components/footer-menu.component.spec.ts index 1740e76369d..f0891f6a398 100644 --- a/apps/workplace/src/tests/components/footer-menu.component.spec.ts +++ b/apps/workplace/src/tests/components/footer-menu.component.spec.ts @@ -18,13 +18,14 @@ import { MockPipe, MockProvider } from 'ng-mocks'; import { FooterMenuComponent } from '../../app/components/footer-menu.component'; describe('FooterMenuComponent', () => { + const theme = signal('light'); let spectator: SpectatorRouting; const createComponent = createRoutingFactory({ component: FooterMenuComponent, providers: [ MockProvider(SettingsService, { get: vi.fn(), - theme: 'light', + theme_signal: theme, }), MockProvider(OrganisationService, { active_building: signal(new Building()), @@ -51,6 +52,7 @@ describe('FooterMenuComponent', () => { beforeEach(() => { features.set([]); feature_groups.set({}); + theme.set('light'); setCurrentUser(new StaffUser({ id: 'me', groups: ['staff'] })); spectator = createComponent(); setSettings({}); @@ -119,16 +121,13 @@ describe('FooterMenuComponent', () => { expect('[name="footer-nav-explore"]').toExist(); }); - it('resolves dark mode from the allow_dark_mode setting and active theme', () => { - setSettings({ 'app.allow_dark_mode': true }); - (spectator.inject(SettingsService) as any).theme = 'dark'; + it('resolves dark mode from the active theme', () => { + theme.set('dark'); expect(spectator.component.dark_mode()).toBe(true); }); it('reports light mode when the theme is not dark', () => { - setSettings({ 'app.allow_dark_mode': true }); - (spectator.inject(SettingsService) as any).theme = 'light'; - expect(spectator.component.dark_mode()).toBeFalsy(); + expect(spectator.component.dark_mode()).toBe(false); }); it('falls back to the landing route when no default route is configured', () => { diff --git a/libs/catering/src/lib/catering-state.service.ts b/libs/catering/src/lib/catering-state.service.ts index 01c7f5e4cd7..9bf8b777486 100644 --- a/libs/catering/src/lib/catering-state.service.ts +++ b/libs/catering/src/lib/catering-state.service.ts @@ -20,6 +20,7 @@ import { i18n, notifyError, notifySuccess, + responseErrorMessage, unique, user_group_names, } from '@placeos/common'; @@ -33,12 +34,14 @@ import { CateringItemOptionModalData, } from './catering-option-modal.component'; import { CateringOption } from './catering.interfaces'; +import { ChargeCodeListModalComponent } from './charge-code-list-modal.component'; import { AttachedResourceConfigModalComponent, AttachedResourceConfigModalData, AttachedResourceRuleset, } from 'libs/components/src/lib/attached-resource-config-modal.component'; +import { AvailableRoomsStateModalComponent } from 'libs/components/src/lib/available-rooms-state-modal.component'; import { openConfirmModal } from 'libs/components/src/lib/confirm-modal.component'; export interface CateringSettings { @@ -413,6 +416,39 @@ export class CateringStateService extends AsyncHandler { ref.close(); } + /** + * Open the room availability modal. Each change the user makes saves + * the rooms that cannot order catering to the catering settings. + */ + public setRoomAvailability() { + const ref = this._dialog.open(AvailableRoomsStateModalComponent, { + data: { + type: 'Catering', + disabled_rooms: this.availability(), + }, + }); + this.subscription( + 'room-availability', + ref.componentInstance.change.subscribe(async (list) => { + try { + await this.saveSettings({ disabled_rooms: list }); + notifySuccess('Room availability settings saved'); + } catch (e) { + const detail = + (await responseErrorMessage(e)) || 'Unknown error'; + notifyError(`Failed to save room availability. ${detail}`); + } finally { + ref.componentInstance.loading.set(false); + } + }), + ); + } + + /** Open the modal that edits the charge codes of the building */ + public setChargeCodes() { + this._dialog.open(ChargeCodeListModalComponent); + } + public async saveSettings(settings: CateringSettings) { const old_settings = this.settings(); const result = await updateMetadata(this._org.building.id, { diff --git a/libs/catering/src/tests/catering-state.service.spec.ts b/libs/catering/src/tests/catering-state.service.spec.ts index f010f2d211a..613beb7de74 100644 --- a/libs/catering/src/tests/catering-state.service.spec.ts +++ b/libs/catering/src/tests/catering-state.service.spec.ts @@ -2,13 +2,16 @@ import { signal } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { createServiceFactory, SpectatorService } from '@ngneat/spectator'; import { MockProvider } from 'ng-mocks'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { delay } from 'rxjs/operators'; import { Building, CateringItem, OrganisationService } from '@placeos/common'; +import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; import { SettingsService } from 'libs/common/src/lib/settings.service'; +import { AvailableRoomsStateModalComponent } from 'libs/components/src/lib/available-rooms-state-modal.component'; import { CateringStateService } from '../lib/catering-state.service'; +import { ChargeCodeListModalComponent } from '../lib/charge-code-list-modal.component'; // The `@placeos/assets` catering fns run for real; only the ts-client API // layer beneath them (asset/type/category queries + mutations) is stubbed. @@ -173,6 +176,78 @@ describe('CateringStateService', () => { }); }); + it('should open the charge codes modal', () => { + spectator.service.setChargeCodes(); + expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( + ChargeCodeListModalComponent, + ); + }); + + describe('setRoomAvailability', () => { + const change = new Subject(); + const loading = signal(true); + // The change handler awaits the save before it clears the loading flag + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + const notify_open = vi.fn(() => ({ + onAction: () => of(), + dismiss: vi.fn(), + })); + + beforeEach(() => { + loading.set(true); + setNotifyOutlet({ open: notify_open } as any, true); + (spectator.inject(MatDialog).open as any).mockReturnValue({ + componentInstance: { change, loading }, + }); + }); + + afterEach(() => setNotifyOutlet(null, true)); + + it('should open the modal and save the selected rooms', async () => { + spectator.service.setRoomAvailability(); + expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( + AvailableRoomsStateModalComponent, + { data: { type: 'Catering', disabled_rooms: [] } }, + ); + + change.next(['room-2']); + await settle(); + + expect(ts_client.updateMetadata).toHaveBeenCalledWith( + 'bld-1', + expect.objectContaining({ + name: 'catering-settings', + details: expect.objectContaining({ + disabled_rooms: ['room-2'], + }), + }), + ); + expect(notify_open).toHaveBeenCalledWith( + 'Room availability settings saved', + 'OK', + expect.objectContaining({ panelClass: ['success'] }), + ); + expect(loading()).toBe(false); + }); + + it('should show the error and stop loading when the save fails', async () => { + vi.mocked(ts_client.updateMetadata).mockRejectedValueOnce( + new Error('save failed'), + ); + spectator.service.setRoomAvailability(); + + change.next(['room-2']); + await settle(); + + expect(notify_open).toHaveBeenCalledWith( + 'Failed to save room availability. save failed', + 'OK', + expect.objectContaining({ panelClass: ['error'] }), + ); + expect(loading()).toBe(false); + }); + }); + it('should allow user to get catering config', async () => { vi.mocked(ts_client.showMetadata).mockResolvedValue({} as any); let config = await spectator.service.getCateringConfig(); diff --git a/libs/common/src/lib/api.ts b/libs/common/src/lib/api.ts index 07e32d71878..d2a8d66dd6f 100644 --- a/libs/common/src/lib/api.ts +++ b/libs/common/src/lib/api.ts @@ -19,3 +19,18 @@ export function toQueryString(map: HashMap) { } return str; } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** The HTTP status carried by a ts-client error or its wrapped response. */ +export function errorStatus(error: unknown): number | undefined { + if (!isRecord(error)) return undefined; + const status = error['status']; + if (typeof status === 'number') return status; + const nested = error['error']; + if (!isRecord(nested)) return undefined; + const nested_status = nested['status']; + return typeof nested_status === 'number' ? nested_status : undefined; +} diff --git a/libs/common/src/lib/general.ts b/libs/common/src/lib/general.ts index c442fd18961..460109e8070 100644 --- a/libs/common/src/lib/general.ts +++ b/libs/common/src/lib/general.ts @@ -666,15 +666,23 @@ export function firstTruthyValueFrom(obs: Observable): Promise { : Promise.resolve(null); } -/** Reject a promise if it does not settle within the specified time. */ +/** + * Reject a promise if it does not settle within the specified time. + * `on_timeout` runs before the rejection, so the caller can abandon the work + * that the promise waits on (for example, abort a fetch). + */ export function withTimeout( promise: Promise, timeout_ms: number, message = 'Operation timed out.', + on_timeout?: () => void, ): Promise { if (timeout_ms <= 0) return promise; return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(message)), timeout_ms); + const timer = setTimeout(() => { + on_timeout?.(); + reject(new Error(message)); + }, timeout_ms); promise.then( (value) => { clearTimeout(timer); diff --git a/libs/common/src/tests/api.spec.ts b/libs/common/src/tests/api.spec.ts index 333f6291d97..65ceab9df6f 100644 --- a/libs/common/src/tests/api.spec.ts +++ b/libs/common/src/tests/api.spec.ts @@ -1,4 +1,4 @@ -import { toQueryString } from '../lib/api'; +import { errorStatus, toQueryString } from '../lib/api'; describe('toQueryString', () => { it('should convert a map into a query string', () => { @@ -6,9 +6,9 @@ describe('toQueryString', () => { }); it('should ignore empty values', () => { - expect( - toQueryString({ a: undefined, b: null, c: '', d: 'keep' }), - ).toBe('d=keep'); + expect(toQueryString({ a: undefined, b: null, c: '', d: 'keep' })).toBe( + 'd=keep', + ); }); it('should keep falsy but meaningful values', () => { @@ -24,3 +24,11 @@ describe('toQueryString', () => { expect(toQueryString(null)).toBe(''); }); }); + +describe('errorStatus', () => { + it('should read direct and wrapped HTTP status codes', () => { + expect(errorStatus({ status: 429, message: 'quota' })).toBe(429); + expect(errorStatus({ error: { status: 503 } })).toBe(503); + expect(errorStatus(new Error('network'))).toBeUndefined(); + }); +}); diff --git a/libs/common/src/tests/general.spec.ts b/libs/common/src/tests/general.spec.ts index b7984b52b8d..a98deb9edc1 100644 --- a/libs/common/src/tests/general.spec.ts +++ b/libs/common/src/tests/general.spec.ts @@ -15,6 +15,7 @@ import { jsonToCsv, mapWithLimit, markUserDateChange, + parseJWT, removeUrlParam, responseErrorMessage, setupFormTimeSync, @@ -70,6 +71,47 @@ describe('General Methods', () => { await assertion; vi.useRealTimers(); }); + + it('should call on_timeout before it rejects', async () => { + vi.useFakeTimers(); + const on_timeout = vi.fn(); + const result = withTimeout( + new Promise(() => undefined), + 1000, + 'Timed out.', + on_timeout, + ); + const assertion = expect(result).rejects.toThrow('Timed out.'); + + await vi.advanceTimersByTimeAsync(1000); + + await assertion; + expect(on_timeout).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + }); + + describe('parseJWT', () => { + /** JWT-shaped token with a base64url payload */ + const token = (payload: Record) => + `header.${btoa(JSON.stringify(payload)) + .replace(/\+/g, '-') + .replace(/\//g, '_')}.signature`; + + it('should decode the payload segment of a token', () => { + expect(parseJWT(token({ exp: 42, u: { e: 'a@b.com' } }))).toEqual({ + exp: 42, + u: { e: 'a@b.com' }, + }); + }); + + it('should decode base64url-specific characters', () => { + // '>>>' and '???' encode to base64 containing '+' and '/', which the + // base64url variant replaces with '-' and '_' + const payload = { note: '>>>???' }; + expect(token(payload)).toMatch(/[-_]/); + expect(parseJWT(token(payload))).toEqual(payload); + }); }); describe('getItemWithKeys', () => { diff --git a/libs/map-editor/src/lib/ai/api.ts b/libs/map-editor/src/lib/ai/api.ts index 8495b418b04..5f415adb55d 100644 --- a/libs/map-editor/src/lib/ai/api.ts +++ b/libs/map-editor/src/lib/ai/api.ts @@ -1,4 +1,5 @@ import { apiEndpoint, get, post } from '@placeos/ts-client'; +import { errorStatus } from 'libs/common/src/lib/api'; import { MapAICapabilities, @@ -118,17 +119,6 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -/** The HTTP status carried by a ts-client error or its wrapped response. */ -export function errorStatus(error: unknown): number | undefined { - if (!isRecord(error)) return undefined; - const status = error['status']; - if (typeof status === 'number') return status; - const nested = error['error']; - if (!isRecord(nested)) return undefined; - const nested_status = nested['status']; - return typeof nested_status === 'number' ? nested_status : undefined; -} - /** The server's message for a failed request, when it sent one. */ export function errorMessage(error: unknown): string { if (!isRecord(error)) return typeof error === 'string' ? error : ''; diff --git a/libs/map-editor/src/lib/ai/thread.ts b/libs/map-editor/src/lib/ai/thread.ts index f3d9cbfb1fb..d0deaa0be4f 100644 --- a/libs/map-editor/src/lib/ai/thread.ts +++ b/libs/map-editor/src/lib/ai/thread.ts @@ -1,11 +1,11 @@ import { computed, signal, Signal, WritableSignal } from '@angular/core'; +import { errorStatus } from 'libs/common/src/lib/api'; import { MapIndex } from '../map-index'; import { OpKind } from '../ops'; import { annotationCandidates } from './annotations'; import { cancelMapAIJob, errorMessage, - errorStatus, isFinalJob, mapAICapabilities, MapAiTransport, diff --git a/libs/map-editor/src/tests/ai-api.spec.ts b/libs/map-editor/src/tests/ai-api.spec.ts index ab4b48e8063..35c7489c02a 100644 --- a/libs/map-editor/src/tests/ai-api.spec.ts +++ b/libs/map-editor/src/tests/ai-api.spec.ts @@ -2,7 +2,6 @@ import { backoffDelay, cancelMapAIJob, errorMessage, - errorStatus, isFinalJob, isTransientError, mapAICapabilities, @@ -71,10 +70,7 @@ describe('map AI client', () => { }); }); - it('reads the status and message of a ts-client error', () => { - expect(errorStatus({ status: 429, message: 'quota' })).toBe(429); - expect(errorStatus({ error: { status: 503 } })).toBe(503); - expect(errorStatus(new Error('network'))).toBeUndefined(); + it('reads the message of a ts-client error', () => { expect( errorMessage({ status: 422, response: { message: 'bad' } }), ).toBe('bad'); From cbe66ebaf89ba548a7c5972a203b8f4ae27568dd Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:57 +1100 Subject: [PATCH 07/10] refactor(signage-manager): share tabs, headers, panels and pickers One tab bar with keyboard support, one section header, one assigned playlists panel, one playlist status badge, one group members card and one picker modal shell replace the copies. Status badges use one colour set. --- .../app/displays/display-content.component.ts | 284 ++---------------- .../app/displays/display-header.component.ts | 58 +--- .../app/displays/display-list.component.ts | 24 +- .../src/app/displays/displays.component.ts | 246 +++------------ .../app/displays/signage-display.service.ts | 3 - .../src/app/groups/groups.component.ts | 33 +- .../groups/signage-group-content.component.ts | 13 - .../groups/signage-group-list.component.ts | 6 - .../groups/signage-group-members.component.ts | 206 +++++++++++++ .../groups/signage-group-tabs.component.ts | 66 ---- .../groups/signage-group-users.component.ts | 168 ++--------- .../groups/signage-group-zones.component.ts | 173 ++--------- .../src/app/media/media-list.component.ts | 127 +++----- .../app/media/playlist-sidebar.component.ts | 58 +--- .../src/app/media/signage-media.service.ts | 2 - .../playlists/playlist-header.component.ts | 58 +--- .../app/playlists/playlist-list.component.ts | 82 +---- .../src/app/playlists/playlists.component.ts | 129 ++------ .../src/app/schedules/schedules.component.ts | 42 ++- .../assigned-playlists-panel.component.ts | 173 +++++++++++ .../shared/display-select-modal.component.ts | 137 +++------ .../app/shared/linked-item-row.component.ts | 42 +++ .../shared/media-select-modal.component.ts | 146 +++------ .../src/app/shared/picker-modal.component.ts | 153 ++++++++++ .../shared/playlist-select-modal.component.ts | 134 ++------- .../shared/playlist-status-badge.component.ts | 56 ++++ .../src/app/shared/routed-tab.util.ts | 35 +++ .../src/app/shared/scroll-selection.util.ts | 32 ++ .../app/shared/section-header.component.ts | 71 +++++ .../src/app/shared/section-tabs.component.ts | 95 ++++++ .../templates/template-header.component.ts | 58 +--- .../template-layout-list.component.ts | 72 +---- .../src/app/templates/template-layout.util.ts | 13 - .../app/templates/template-list.component.ts | 58 +--- .../src/app/templates/templates.component.ts | 95 +----- .../src/app/zones/signage-zone.service.ts | 2 - .../src/app/zones/zone-content.component.ts | 277 ++--------------- .../src/app/zones/zone-header.component.ts | 59 +--- .../src/app/zones/zones.component.ts | 229 +++----------- apps/signage-manager/src/styles.css | 22 ++ .../display-content.component.spec.ts | 46 +-- .../tests/displays/displays.component.spec.ts | 6 +- .../signage-group-members.component.spec.ts | 60 ++++ .../signage-group-tabs.component.spec.ts | 58 ---- .../signage-group-users.component.spec.ts | 3 +- .../playlist-header.component.spec.ts | 31 +- ...assigned-playlists-panel.component.spec.ts | 94 ++++++ .../display-select-modal.component.spec.ts | 18 +- .../playlist-select-modal.component.spec.ts | 7 +- .../shared/section-header.component.spec.ts | 62 ++++ .../shared/section-tabs.component.spec.ts | 96 ++++++ .../template-layout-list.component.spec.ts | 18 -- .../templates/templates.component.spec.ts | 29 -- .../zones/zone-content.component.spec.ts | 25 +- .../src/tests/zones/zones.component.spec.ts | 6 +- 55 files changed, 1740 insertions(+), 2556 deletions(-) create mode 100644 apps/signage-manager/src/app/groups/signage-group-members.component.ts delete mode 100644 apps/signage-manager/src/app/groups/signage-group-tabs.component.ts create mode 100644 apps/signage-manager/src/app/shared/assigned-playlists-panel.component.ts create mode 100644 apps/signage-manager/src/app/shared/linked-item-row.component.ts create mode 100644 apps/signage-manager/src/app/shared/picker-modal.component.ts create mode 100644 apps/signage-manager/src/app/shared/playlist-status-badge.component.ts create mode 100644 apps/signage-manager/src/app/shared/routed-tab.util.ts create mode 100644 apps/signage-manager/src/app/shared/scroll-selection.util.ts create mode 100644 apps/signage-manager/src/app/shared/section-header.component.ts create mode 100644 apps/signage-manager/src/app/shared/section-tabs.component.ts create mode 100644 apps/signage-manager/src/tests/groups/signage-group-members.component.spec.ts delete mode 100644 apps/signage-manager/src/tests/groups/signage-group-tabs.component.spec.ts create mode 100644 apps/signage-manager/src/tests/shared/assigned-playlists-panel.component.spec.ts create mode 100644 apps/signage-manager/src/tests/shared/section-header.component.spec.ts create mode 100644 apps/signage-manager/src/tests/shared/section-tabs.component.spec.ts diff --git a/apps/signage-manager/src/app/displays/display-content.component.ts b/apps/signage-manager/src/app/displays/display-content.component.ts index 775870a0fd7..cb0b1c1d0e1 100644 --- a/apps/signage-manager/src/app/displays/display-content.component.ts +++ b/apps/signage-manager/src/app/displays/display-content.component.ts @@ -1,18 +1,14 @@ import { Component, computed, inject, input } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterLink } from '@angular/router'; import { IconComponent, LoadErrorComponent, TranslatePipe, } from '@placeos/components'; -import { SignagePlaylist } from '@placeos/ts-client'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; -import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component'; +import { AssignedPlaylistsPanelComponent } from '../shared/assigned-playlists-panel.component'; +import { LinkedItemRowComponent } from '../shared/linked-item-row.component'; import { TemplateMappingsComponent } from '../shared/template-mappings.component'; import { SignageContextService } from '../signage-context.service'; -import { playlistStatus } from '../signage-playlist.util'; import { DisplayScheduleComponent } from './display-schedule.component'; import { SignageDisplayService } from './signage-display.service'; @@ -56,209 +52,24 @@ import { SignageDisplayService } from './signage-display.service'; aria-labelledby="display-playlists-tab" class="bg-base-100 border-base-300 min-h-0 flex-1 overflow-auto rounded-lg border" > -
-
- playlist_play - {{ - 'SIGNAGE_MANAGER.PLAYLISTS_COUNT' - | translate - : { - count: display_playlists() - .length, - } - : display_playlists().length - }} -
- @if (can_update()) { - - } -
- - } @else if ( + " + [error]=" has_assigned_playlists() && playlists_error() - ) { - - } @else { -
- playlist_remove -

- {{ - 'SIGNAGE_MANAGER.NO_PLAYLISTS_DISPLAY' - | translate - }} -

-
- } -
+ " + [can_update]="can_update()" + (add)="addPlaylist()" + (remove)="removePlaylist($event)" + (retry)="reloadPlaylists()" + />
} @@ -294,42 +105,15 @@ import { SignageDisplayService } from './signage-display.service'; zone of display_zones(); track zone.id ) { - - layers -
-
- {{ - zone.display_name || - zone.name - }} -
- @if (zone.description) { -
- {{ zone.description }} -
- } -
-
+ [description]="zone.description" + /> } } @else if (display_zones_loading()) {
('schedule'); public readonly selected_display = this._display_service.selected_display; - public readonly playlist_approval_status = - this._playlist_service.playlist_approval_status; public readonly can_update = this._context.can_update; public readonly display_playlists = computed(() => @@ -439,9 +219,7 @@ export class DisplayContentComponent { if (display) this._display_service.addPlaylistToDisplay(display); } - public removePlaylist(event: Event, playlist_id: string) { - event.preventDefault(); - event.stopPropagation(); + public removePlaylist(playlist_id: string) { const display = this.selected_display(); if (display) this._display_service.removePlaylistFromDisplay( @@ -449,12 +227,4 @@ export class DisplayContentComponent { playlist_id, ); } - - public getStatus(playlist: SignagePlaylist) { - return playlistStatus( - playlist, - this.playlist_approval_status(), - this._playlist_service.playlist_approval_requested_status(), - ); - } } diff --git a/apps/signage-manager/src/app/displays/display-header.component.ts b/apps/signage-manager/src/app/displays/display-header.component.ts index 1d2b57760c6..f1d31de6616 100644 --- a/apps/signage-manager/src/app/displays/display-header.component.ts +++ b/apps/signage-manager/src/app/displays/display-header.component.ts @@ -1,60 +1,22 @@ import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { Router } from '@angular/router'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; +import { SectionHeaderComponent } from '../shared/section-header.component'; import { SignageContextService } from '../signage-context.service'; import { SignageDisplayService } from './signage-display.service'; @Component({ selector: 'display-header', template: ` -
-
-

- {{ 'SIGNAGE_MANAGER.DISPLAYS_TITLE' | translate }} -

-
-
- {{ - 'COMMON.ITEM_COUNT' - | translate - : { count: total_count() } - : total_count() - }} -
- -
-
-
- @if (can_create()) { - - } -
+ `, - imports: [ - MatRippleModule, - IconComponent, - TranslatePipe, - GroupBreadcrumbsComponent, - MatTooltipModule, - ], + imports: [SectionHeaderComponent], }) export class DisplayHeaderComponent { private readonly _context = inject(SignageContextService); diff --git a/apps/signage-manager/src/app/displays/display-list.component.ts b/apps/signage-manager/src/app/displays/display-list.component.ts index 4a8503648c7..6e85f6c2d70 100644 --- a/apps/signage-manager/src/app/displays/display-list.component.ts +++ b/apps/signage-manager/src/app/displays/display-list.component.ts @@ -1,6 +1,5 @@ import { DatePipe } from '@angular/common'; import { - afterRenderEffect, Component, DestroyRef, ElementRef, @@ -23,6 +22,7 @@ import { } from '@placeos/components'; import { isSameDay } from 'date-fns'; import { IntersectDirective } from '../shared/intersect.directive'; +import { scrollSelectionIntoView } from '../shared/scroll-selection.util'; import { isDisplayOnline } from './display-status.util'; import { SignageDisplayService } from './signage-display.service'; @@ -191,23 +191,11 @@ export class DisplayListComponent { const timer = setInterval(() => this._now.set(Date.now()), 60 * 1000); inject(DestroyRef).onDestroy(() => clearInterval(timer)); - afterRenderEffect({ - earlyRead: () => { - const selected_id = this.selected()?.id; - if (!selected_id) return; - const display_index = this.displays().findIndex( - ({ id }) => id === selected_id, - ); - return this._display_items()[display_index]?.nativeElement; - }, - write: (selected_item) => { - selected_item()?.scrollIntoView?.({ - behavior: 'instant', - block: 'nearest', - inline: 'nearest', - }); - }, - }); + scrollSelectionIntoView( + this.displays, + this._display_items, + this.selected, + ); } public loadMore() { diff --git a/apps/signage-manager/src/app/displays/displays.component.ts b/apps/signage-manager/src/app/displays/displays.component.ts index bc104b82968..bc4e6168b68 100644 --- a/apps/signage-manager/src/app/displays/displays.component.ts +++ b/apps/signage-manager/src/app/displays/displays.component.ts @@ -1,19 +1,17 @@ -import { - Component, - computed, - effect, - inject, - input, - signal, -} from '@angular/core'; +import { Component, computed, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { settingSignal } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { routedTab } from '../shared/routed-tab.util'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; import { DisplayContentComponent } from './display-content.component'; import { DisplayHeaderComponent } from './display-header.component'; @@ -22,11 +20,9 @@ import { selectRoutedItem } from './routed-selection.util'; import { showSignageDisplay } from './signage-display'; import { SignageDisplayService } from './signage-display.service'; -const TAB_QUERY_PARAM = 'tab'; +type DisplayTab = 'schedule' | 'templates' | 'playlists' | 'zones'; -function parseDisplayTab( - value: string | null, -): 'schedule' | 'templates' | 'playlists' | 'zones' { +function parseDisplayTab(value: string | null): DisplayTab { if (value === 'templates' || value === 'playlists' || value === 'zones') { return value; } @@ -146,155 +142,17 @@ function parseDisplayTab( open_in_new
-
- - @if (templates_enabled()) { - - } - - -
+ /> } `, - styles: [ - ` - .mobile-hidden { - @media (max-width: 639px) { - display: none !important; - } - } - - .mobile-full { - @media (max-width: 639px) { - flex: 1; - } - } - `, - ], imports: [ NavSidebarComponent, NavFooterComponent, DisplayHeaderComponent, DisplayListComponent, DisplayContentComponent, + SectionTabsComponent, MatRippleModule, MatTooltipModule, IconComponent, @@ -337,16 +181,19 @@ export class DisplaysSectionComponent { private readonly _context = inject(SignageContextService); private readonly _display_service = inject(SignageDisplayService); private readonly _playlist_service = inject(SignagePlaylistService); - private readonly _route = inject(ActivatedRoute); private readonly _router = inject(Router); public readonly id = input(''); public readonly tab = input(null); public readonly signage_path = settingSignal('signage_path'); public readonly templates_enabled = this._context.templates_enabled; - public readonly view_tab = signal< - 'schedule' | 'templates' | 'playlists' | 'zones' - >('schedule'); + private readonly _view_tab = routedTab(this.tab, (value) => { + const tab = parseDisplayTab(value); + return tab === 'templates' && !this.templates_enabled() + ? 'schedule' + : tab; + }); + public readonly view_tab = this._view_tab.active; public readonly selected_display = this._display_service.selected_display; public readonly can_update = this._context.can_update; public readonly can_delete_displays = this._context.can_delete_displays; @@ -371,6 +218,31 @@ export class DisplaysSectionComponent { public readonly zone_count = computed( () => this._display_service.selected_display_zones().length, ); + public readonly tabs = computed[]>(() => [ + { id: 'schedule', label: 'SIGNAGE_MANAGER.SCHEDULE' }, + ...(this.templates_enabled() + ? [ + { + id: 'templates' as const, + label: 'SIGNAGE_MANAGER.NAV_TEMPLATES', + count: this.template_count(), + count_loading: this.template_count_loading(), + }, + ] + : []), + { + id: 'playlists', + label: 'SIGNAGE_MANAGER.NAV_PLAYLISTS', + count: this.playlist_count(), + count_loading: this.playlist_count_loading(), + }, + { + id: 'zones', + label: 'SIGNAGE_MANAGER.NAV_ZONES', + count: this.zone_count(), + count_loading: this.zone_count_loading(), + }, + ]); public readonly panel_link = computed(() => { const display = this.selected_display(); if (!display?.id) return ''; @@ -379,17 +251,6 @@ export class DisplaysSectionComponent { }); constructor() { - effect(() => { - const route_tab = parseDisplayTab(this.tab()); - const available_tab = - route_tab === 'templates' && !this.templates_enabled() - ? 'schedule' - : route_tab; - if (available_tab !== this.view_tab()) { - this.view_tab.set(available_tab); - } - }); - selectRoutedItem({ id: this.id, list: this._display_service.displays, @@ -416,14 +277,7 @@ export class DisplaysSectionComponent { await this._router.navigate(['/displays'], {}); } - public setViewTab(tab: 'schedule' | 'templates' | 'playlists' | 'zones') { - if (tab === this.view_tab()) return; - this.view_tab.set(tab); - void this._router.navigate([], { - relativeTo: this._route, - queryParams: { [TAB_QUERY_PARAM]: tab }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); + public setViewTab(tab: DisplayTab) { + this._view_tab.select(tab); } } diff --git a/apps/signage-manager/src/app/displays/signage-display.service.ts b/apps/signage-manager/src/app/displays/signage-display.service.ts index a47dadedc45..d894f0fc114 100644 --- a/apps/signage-manager/src/app/displays/signage-display.service.ts +++ b/apps/signage-manager/src/app/displays/signage-display.service.ts @@ -485,7 +485,6 @@ export class SignageDisplayService { const { DisplaySelectModalComponent } = await import('../shared/display-select-modal.component'); const ref = this._dialog.open(DisplaySelectModalComponent, { - data: { zone_id: zone.id }, panelClass: 'mobile-fullscreen', }); const display_id = await dialogClosed(ref); @@ -512,7 +511,6 @@ export class SignageDisplayService { const { PlaylistSelectModalComponent } = await import('../shared/playlist-select-modal.component'); const ref = this._dialog.open(PlaylistSelectModalComponent, { - data: { display_id: display.id }, panelClass: 'mobile-fullscreen', }); const playlist_id = await dialogClosed(ref); @@ -530,7 +528,6 @@ export class SignageDisplayService { const { DisplaySelectModalComponent } = await import('../shared/display-select-modal.component'); const ref = this._dialog.open(DisplaySelectModalComponent, { - data: { playlist_id: playlist.id }, panelClass: 'mobile-fullscreen', }); const display_id = await dialogClosed(ref); diff --git a/apps/signage-manager/src/app/groups/groups.component.ts b/apps/signage-manager/src/app/groups/groups.component.ts index 1fa8258a278..23c166c1f5e 100644 --- a/apps/signage-manager/src/app/groups/groups.component.ts +++ b/apps/signage-manager/src/app/groups/groups.component.ts @@ -2,12 +2,15 @@ import { Component, inject } from '@angular/core'; import { TranslatePipe } from '@placeos/components'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageGroupAdminService } from './signage-group-admin.service'; import { SignageGroupContentComponent } from './signage-group-content.component'; import { SignageGroupDetailHeaderComponent } from './signage-group-detail-header.component'; import { SignageGroupHeaderComponent } from './signage-group-header.component'; import { SignageGroupListComponent } from './signage-group-list.component'; -import { SignageGroupTabsComponent } from './signage-group-tabs.component'; @Component({ selector: 'groups-section', @@ -24,7 +27,16 @@ import { SignageGroupTabsComponent } from './signage-group-tabs.component'; > @if (selected_group()) { - + } @else {
`, - styles: [ - ` - .mobile-hidden { - @media (max-width: 639px) { - display: none !important; - } - } - `, - ], imports: [ NavSidebarComponent, NavFooterComponent, SignageGroupHeaderComponent, SignageGroupListComponent, SignageGroupDetailHeaderComponent, - SignageGroupTabsComponent, + SectionTabsComponent, SignageGroupContentComponent, TranslatePipe, ], @@ -66,4 +69,10 @@ export class GroupsSectionComponent { private readonly _group_admin = inject(SignageGroupAdminService); public readonly selected_group = this._group_admin.managed_group; + public readonly active_tab = this._group_admin.managed_group_tab; + /** Tabs shown below the lg breakpoint. Wider screens show both panels. */ + public readonly tabs: SectionTab<'users' | 'zones'>[] = [ + { id: 'users', label: 'SIGNAGE_MANAGER.TAB_USERS' }, + { id: 'zones', label: 'SIGNAGE_MANAGER.TAB_ZONES' }, + ]; } diff --git a/apps/signage-manager/src/app/groups/signage-group-content.component.ts b/apps/signage-manager/src/app/groups/signage-group-content.component.ts index 1d538823250..f822af1345f 100644 --- a/apps/signage-manager/src/app/groups/signage-group-content.component.ts +++ b/apps/signage-manager/src/app/groups/signage-group-content.component.ts @@ -44,19 +44,6 @@ import { SignageGroupZonesComponent } from './signage-group-zones.component'; flex-direction: column; height: 100%; } - - .tablet-hidden { - @media (max-width: 1023px) { - display: none !important; - } - } - - .tablet-full { - @media (max-width: 1023px) { - flex: 1; - min-width: 0; - } - } `, ], imports: [ diff --git a/apps/signage-manager/src/app/groups/signage-group-list.component.ts b/apps/signage-manager/src/app/groups/signage-group-list.component.ts index f3e2cd8bebe..c61b315be71 100644 --- a/apps/signage-manager/src/app/groups/signage-group-list.component.ts +++ b/apps/signage-manager/src/app/groups/signage-group-list.component.ts @@ -240,12 +240,6 @@ interface GroupListRow { `, styles: [ ` - .mobile-hidden { - @media (max-width: 639px) { - display: none !important; - } - } - .group-tree { background: transparent; } diff --git a/apps/signage-manager/src/app/groups/signage-group-members.component.ts b/apps/signage-manager/src/app/groups/signage-group-members.component.ts new file mode 100644 index 00000000000..32083ae3189 --- /dev/null +++ b/apps/signage-manager/src/app/groups/signage-group-members.component.ts @@ -0,0 +1,206 @@ +import { Component, computed, input, output } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { SignageGroupPermissionLabelsComponent } from './signage-group-permission-labels.component'; + +/** One user or zone of a signage group, as `` shows it */ +export interface SignageGroupMember { + readonly id: string; + readonly name: string; + /** Second line under the name, such as an email */ + readonly detail?: string; + /** Shows the "Denied" label after the permissions */ + readonly deny?: boolean; + readonly permissions: number; + /** Row of the service, given back by the edit and remove events */ + readonly row: T; +} + +const MEMBER_TEXT = { + users: { + icon: 'group', + row_icon: 'person', + count: 'SIGNAGE_MANAGER.USERS_COUNT', + add_tooltip: 'SIGNAGE_MANAGER.ADD_USER_TOOLTIP', + add_label: 'SIGNAGE_MANAGER.ADD_USER_ARIA', + load_error: 'SIGNAGE_MANAGER.USERS_LOAD_ERROR', + edit: 'SIGNAGE_MANAGER.EDIT_USER_PERMS', + remove: 'SIGNAGE_MANAGER.REMOVE_USER', + empty_icon: 'group_off', + empty: 'SIGNAGE_MANAGER.NO_USERS_ASSIGNED', + }, + zones: { + icon: 'layers', + row_icon: 'layers', + count: 'SIGNAGE_MANAGER.ZONES_COUNT', + add_tooltip: 'SIGNAGE_MANAGER.ADD_ZONE_TOOLTIP', + add_label: 'SIGNAGE_MANAGER.ADD_ZONE_ARIA', + load_error: 'SIGNAGE_MANAGER.ZONES_LOAD_ERROR', + edit: 'SIGNAGE_MANAGER.EDIT_ZONE_PERMS', + remove: 'SIGNAGE_MANAGER.REMOVE_ZONE', + empty_icon: 'layers_clear', + empty: 'SIGNAGE_MANAGER.NO_ZONES_ASSIGNED', + }, +} as const; + +/** + * Card that lists the users or the zones of a signage group, with their + * permissions and add, edit and remove buttons. The parent opens the dialogs + * and calls the service. + */ +@Component({ + selector: 'signage-group-members', + template: ` + @let t = text(); +
+
+
+ {{ t.icon }} + {{ + t.count + | translate + : { count: members().length } + : members().length + }} +
+ +
+
+ @if (failed() && members().length) { + + } + @if (members().length) { + @for (member of members(); track member.id) { +
+ {{ + t.row_icon + }} +
+
+ {{ member.name }} +
+ @if (member.detail) { +
+ {{ member.detail }} +
+ } +
+ + @if (member.deny) { + + {{ + 'SIGNAGE_MANAGER.DENIED' + | translate + }} + } +
+
+ + +
+ } + } @else if (loading()) { +
+ +
+ } @else if (failed()) { +
+ error +

+ {{ t.load_error | translate }} +

+
+ } @else { +
+ {{ t.empty_icon }} +

+ {{ t.empty | translate }} +

+
+ } +
+
+ `, + host: { class: 'flex min-h-0 flex-1 flex-col' }, + imports: [ + MatProgressSpinnerModule, + MatRippleModule, + MatTooltipModule, + IconComponent, + TranslatePipe, + SignageGroupPermissionLabelsComponent, + ], +}) +export class SignageGroupMembersComponent { + /** Picks the icons and the text */ + public readonly kind = input.required<'users' | 'zones'>(); + public readonly members = + input.required[]>(); + public readonly loading = input(false); + public readonly failed = input(false); + + public readonly add = output(); + public readonly edit = output(); + public readonly remove = output(); + + public readonly text = computed(() => MEMBER_TEXT[this.kind()]); +} diff --git a/apps/signage-manager/src/app/groups/signage-group-tabs.component.ts b/apps/signage-manager/src/app/groups/signage-group-tabs.component.ts deleted file mode 100644 index d144fd24c5d..00000000000 --- a/apps/signage-manager/src/app/groups/signage-group-tabs.component.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Component, ElementRef, inject } from '@angular/core'; -import { TranslatePipe } from '@placeos/components'; -import { SignageGroupAdminService } from './signage-group-admin.service'; - -type GroupTab = 'users' | 'zones'; - -@Component({ - selector: 'signage-group-tabs', - imports: [TranslatePipe], - template: ` -
- @for (tab of tabs; track tab.id) { - - } -
- `, -}) -export class SignageGroupTabsComponent { - private readonly _group_admin = inject(SignageGroupAdminService); - private readonly _element = inject>(ElementRef); - - public readonly active_tab = this._group_admin.managed_group_tab; - public readonly tabs: { id: GroupTab; label: string }[] = [ - { id: 'users', label: 'SIGNAGE_MANAGER.TAB_USERS' }, - { id: 'zones', label: 'SIGNAGE_MANAGER.TAB_ZONES' }, - ]; - - /** Arrow keys, Home and End move between the tabs, as in a tab list */ - public onKeydown(event: KeyboardEvent) { - const index = this.tabs.findIndex(({ id }) => id === this.active_tab()); - const last = this.tabs.length - 1; - const targets: Record = { - ArrowLeft: index > 0 ? index - 1 : last, - ArrowRight: index < last ? index + 1 : 0, - Home: 0, - End: last, - }; - const next = targets[event.key]; - if (next === undefined) return; - event.preventDefault(); - const tab = this.tabs[next].id; - this.active_tab.set(tab); - this._element.nativeElement - .querySelector(`#group-${tab}-tab`) - ?.focus(); - } -} diff --git a/apps/signage-manager/src/app/groups/signage-group-users.component.ts b/apps/signage-manager/src/app/groups/signage-group-users.component.ts index 58c8abf9dc8..8e8a551c77d 100644 --- a/apps/signage-manager/src/app/groups/signage-group-users.component.ts +++ b/apps/signage-manager/src/app/groups/signage-group-users.component.ts @@ -1,153 +1,25 @@ -import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; +import { Component, computed, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { i18n } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; import { PlaceGroupUser } from '@placeos/ts-client'; import { dialogClosed } from '../signage-service.util'; import { SignageGroupAdminService } from './signage-group-admin.service'; -import { SignageGroupPermissionLabelsComponent } from './signage-group-permission-labels.component'; +import { SignageGroupMembersComponent } from './signage-group-members.component'; import { SignageGroupPermissionsModalComponent } from './signage-group-permissions-modal.component'; import { SignageGroupUserSelectModalComponent } from './signage-group-user-select-modal.component'; @Component({ selector: 'signage-group-users', template: ` -
-
-
- group - {{ - 'SIGNAGE_MANAGER.USERS_COUNT' - | translate - : { count: users().length } - : users().length - }} -
- -
-
- @if (failed() && users().length) { - - } - @if (users().length) { - @for (row of users(); track row.user_id) { -
- person -
-
- {{ row.user?.name || row.user_id }} -
- @if (row.user?.email) { -
- {{ row.user?.email }} -
- } -
- -
-
- - -
- } - } @else if (loading()) { -
- -
- } @else if (failed()) { -
- error -

- {{ 'SIGNAGE_MANAGER.USERS_LOAD_ERROR' | translate }} -

-
- } @else { -
- group_off -

- {{ - 'SIGNAGE_MANAGER.NO_USERS_ASSIGNED' | translate - }} -

-
- } -
-
+ `, styles: [ ` @@ -157,14 +29,7 @@ import { SignageGroupUserSelectModalComponent } from './signage-group-user-selec } `, ], - imports: [ - MatProgressSpinnerModule, - MatRippleModule, - MatTooltipModule, - IconComponent, - TranslatePipe, - SignageGroupPermissionLabelsComponent, - ], + imports: [SignageGroupMembersComponent], }) export class SignageGroupUsersComponent { private readonly _group_admin = inject(SignageGroupAdminService); @@ -173,6 +38,15 @@ export class SignageGroupUsersComponent { public readonly users = this._group_admin.managed_group_users; public readonly loading = this._group_admin.managed_group_users_loading; public readonly failed = this._group_admin.managed_group_users_failed; + public readonly members = computed(() => + this.users().map((row) => ({ + id: row.user_id, + name: row.user?.name || row.user_id, + detail: row.user?.email, + permissions: row.permissions, + row, + })), + ); public async addUser() { const user = await dialogClosed( diff --git a/apps/signage-manager/src/app/groups/signage-group-zones.component.ts b/apps/signage-manager/src/app/groups/signage-group-zones.component.ts index 011a8ccbf2c..3177ca808a4 100644 --- a/apps/signage-manager/src/app/groups/signage-group-zones.component.ts +++ b/apps/signage-manager/src/app/groups/signage-group-zones.component.ts @@ -1,158 +1,25 @@ -import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; +import { Component, computed, inject } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { i18n } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; import { PlaceGroupZone } from '@placeos/ts-client'; import { dialogClosed } from '../signage-service.util'; import { SignageGroupAdminService } from './signage-group-admin.service'; -import { SignageGroupPermissionLabelsComponent } from './signage-group-permission-labels.component'; +import { SignageGroupMembersComponent } from './signage-group-members.component'; import { SignageGroupPermissionsModalComponent } from './signage-group-permissions-modal.component'; import { SignageGroupZoneSelectModalComponent } from './signage-group-zone-select-modal.component'; @Component({ selector: 'signage-group-zones', template: ` -
-
-
- layers - {{ - 'SIGNAGE_MANAGER.ZONES_COUNT' - | translate - : { count: zones().length } - : zones().length - }} -
- -
-
- @if (failed() && zones().length) { - - } - @if (zones().length) { - @for (row of zones(); track row.zone_id) { -
- layers -
-
- {{ - row.zone?.display_name || - row.zone?.name || - row.zone_id - }} -
-
- - @if (row.deny) { - - {{ - 'SIGNAGE_MANAGER.DENIED' - | translate - }} - } -
-
- - -
- } - } @else if (loading()) { -
- -
- } @else if (failed()) { -
- error -

- {{ 'SIGNAGE_MANAGER.ZONES_LOAD_ERROR' | translate }} -

-
- } @else { -
- layers_clear -

- {{ - 'SIGNAGE_MANAGER.NO_ZONES_ASSIGNED' | translate - }} -

-
- } -
-
+ `, styles: [ ` @@ -162,14 +29,7 @@ import { SignageGroupZoneSelectModalComponent } from './signage-group-zone-selec } `, ], - imports: [ - MatProgressSpinnerModule, - MatRippleModule, - MatTooltipModule, - IconComponent, - TranslatePipe, - SignageGroupPermissionLabelsComponent, - ], + imports: [SignageGroupMembersComponent], }) export class SignageGroupZonesComponent { private readonly _group_admin = inject(SignageGroupAdminService); @@ -178,6 +38,15 @@ export class SignageGroupZonesComponent { public readonly zones = this._group_admin.managed_group_zones; public readonly loading = this._group_admin.managed_group_zones_loading; public readonly failed = this._group_admin.managed_group_zones_failed; + public readonly members = computed(() => + this.zones().map((row) => ({ + id: row.zone_id, + name: row.zone?.display_name || row.zone?.name || row.zone_id, + deny: row.deny, + permissions: row.permissions, + row, + })), + ); public async addZone() { const zone = await dialogClosed( diff --git a/apps/signage-manager/src/app/media/media-list.component.ts b/apps/signage-manager/src/app/media/media-list.component.ts index 169f94f15a4..07a2cf9926e 100644 --- a/apps/signage-manager/src/app/media/media-list.component.ts +++ b/apps/signage-manager/src/app/media/media-list.component.ts @@ -1,4 +1,4 @@ -import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'; +import { DragDropModule } from '@angular/cdk/drag-drop'; import { NgTemplateOutlet } from '@angular/common'; import { @@ -36,6 +36,45 @@ import { SignageMediaService } from './signage-media.service'; // Sentinel folder for media items without any tags. const UNTAGGED = '\0untagged'; +/** + * Tag chips of a media item in the list and grid layouts: the first two tags, + * then a "+N" chip with the other tags in its tooltip. + */ +@Component({ + selector: 'media-tag-chips', + template: ` + @for (tag of visible_tags(); track tag) { + + {{ tag }} + + } + @if (other_tags().length > 0) { + + +{{ other_tags().length }} + + } + `, + host: { class: 'contents' }, + imports: [MatTooltipModule], +}) +export class MediaTagChipsComponent { + public readonly tags = input.required(); + /** Size classes of each chip */ + public readonly chip_class = input.required(); + + public readonly visible_tags = computed(() => + (this.tags() || []).slice(0, 2), + ); + public readonly other_tags = computed(() => (this.tags() || []).slice(2)); +} + @Component({ selector: 'media-list', template: ` @@ -203,7 +242,6 @@ const UNTAGGED = '\0untagged'; role="list" [cdkDropListData]="display_media()" [cdkDropListConnectedTo]="playlist_ids()" - (cdkDropListDropped)="drop($event)" > @for (media_item of display_media(); track media_item.id) { @if (view_mode() === 'list') { @@ -293,38 +331,10 @@ const UNTAGGED = '\0untagged'; } @@ -460,34 +470,10 @@ const UNTAGGED = '\0untagged';
- @for ( - tag of visibleTags(media_item); - track tag - ) { - - {{ tag }} - - } - @if ( - remainingTagCount(media_item) > 0 - ) { - - +{{ - remainingTagCount( - media_item - ) - }} - - } +
} @@ -780,6 +766,7 @@ const UNTAGGED = '\0untagged'; IntersectDirective, MediaThumbnailComponent, MediaSharedGroupsComponent, + MediaTagChipsComponent, ], }) export class MediaListComponent implements OnInit { @@ -984,18 +971,6 @@ export class MediaListComponent implements OnInit { } } - public visibleTags(item: SignageMedia): string[] { - return (item.tags || []).slice(0, 2); - } - - public remainingTags(item: SignageMedia): string[] { - return (item.tags || []).slice(2); - } - - public remainingTagCount(item: SignageMedia): number { - return this.remainingTags(item).length; - } - public readonly previewItem = (item: SignageMedia) => this._media_service.previewMedia(item); @@ -1060,8 +1035,4 @@ export class MediaListComponent implements OnInit { public readonly can_update_media_tags = this._context.can_update_media_tags; public readonly can_delete = this._context.can_delete; public readonly can_share = this._context.can_share; - - public drop(_event: CdkDragDrop) { - // No-op for media list drops - } } diff --git a/apps/signage-manager/src/app/media/playlist-sidebar.component.ts b/apps/signage-manager/src/app/media/playlist-sidebar.component.ts index e76e15629f5..0ea624f5cd7 100644 --- a/apps/signage-manager/src/app/media/playlist-sidebar.component.ts +++ b/apps/signage-manager/src/app/media/playlist-sidebar.component.ts @@ -15,6 +15,7 @@ import { import { SignageMedia, SignagePlaylist } from '@placeos/ts-client'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { IntersectDirective } from '../shared/intersect.directive'; +import { PlaylistStatusBadgeComponent } from '../shared/playlist-status-badge.component'; import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component'; import { SignageContextService } from '../signage-context.service'; import { playlistStatus } from '../signage-playlist.util'; @@ -102,57 +103,13 @@ import { playlistStatus } from '../signage-playlist.util'; class="flex flex-wrap gap-1 text-[0.625rem]" > @if (!playlist.enabled) { - - {{ - 'COMMON.DISABLED' - | translate - }} - - } - @switch (getStatus(playlist)) { - @case ('expired') { - - {{ - 'SIGNAGE_MANAGER.STATUS_EXPIRED' - | translate - }} - - } - @case ('pending') { - - {{ - 'COMMON.PENDING' - | translate - }} - - } - @case ('awaiting_review') { - - {{ - 'SIGNAGE_MANAGER.STATUS_AWAITING_REVIEW' - | translate - }} - - } - @case ('awaiting_approval') { - - {{ - 'COMMON.APPROVAL_REQUIRED' - | translate - }} - - } + } + @if (playlist.description) {
-
-

- {{ 'SIGNAGE_MANAGER.PLAYLISTS_PAGE_TITLE' | translate }} -

-
-
- {{ - 'COMMON.ITEM_COUNT' - | translate - : { count: total_count() } - : total_count() - }} -
- -
-
-
- @if (can_create()) { - - } -
+ `, - imports: [ - MatRippleModule, - IconComponent, - TranslatePipe, - GroupBreadcrumbsComponent, - MatTooltipModule, - ], + imports: [SectionHeaderComponent], }) export class PlaylistHeaderComponent { private readonly _context = inject(SignageContextService); diff --git a/apps/signage-manager/src/app/playlists/playlist-list.component.ts b/apps/signage-manager/src/app/playlists/playlist-list.component.ts index dce562dd689..2d6998cc409 100644 --- a/apps/signage-manager/src/app/playlists/playlist-list.component.ts +++ b/apps/signage-manager/src/app/playlists/playlist-list.component.ts @@ -1,10 +1,4 @@ -import { - afterRenderEffect, - Component, - ElementRef, - inject, - viewChildren, -} from '@angular/core'; +import { Component, ElementRef, inject, viewChildren } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -18,7 +12,9 @@ import { } from '@placeos/components'; import { SignagePlaylist } from '@placeos/ts-client'; import { IntersectDirective } from '../shared/intersect.directive'; +import { PlaylistStatusBadgeComponent } from '../shared/playlist-status-badge.component'; import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component'; +import { scrollSelectionIntoView } from '../shared/scroll-selection.util'; import { playlistStatus } from '../signage-playlist.util'; import { SignagePlaylistService } from './signage-playlist.service'; @@ -95,52 +91,11 @@ import { SignagePlaylistService } from './signage-playlist.service'; class="flex flex-wrap gap-1 text-[0.625rem] font-medium uppercase" > @if (!playlist.enabled) { - - {{ 'COMMON.DISABLED' | translate }} - - } - @switch (getStatus(playlist)) { - @case ('expired') { - - {{ - 'SIGNAGE_MANAGER.STATUS_EXPIRED' - | translate - }} - - } - @case ('pending') { - - {{ 'COMMON.PENDING' | translate }} - - } - @case ('awaiting_review') { - - {{ - 'SIGNAGE_MANAGER.STATUS_AWAITING_REVIEW' - | translate - }} - - } - @case ('awaiting_approval') { - - {{ - 'COMMON.APPROVAL_REQUIRED' - | translate - }} - - } + } + @if (playlist.description) {
{ - const selected_id = this.selected()?.id; - if (!selected_id) return; - const playlist_index = this.playlists().findIndex( - ({ id }) => id === selected_id, - ); - return this._playlist_items()[playlist_index]?.nativeElement; - }, - write: (selected_item) => { - selected_item()?.scrollIntoView?.({ - behavior: 'instant', - block: 'nearest', - inline: 'nearest', - }); - }, - }); + scrollSelectionIntoView( + this.playlists, + this._playlist_items, + this.selected, + ); } public loadMore() { diff --git a/apps/signage-manager/src/app/playlists/playlists.component.ts b/apps/signage-manager/src/app/playlists/playlists.component.ts index 94d2e27e057..a1c2a6e2e10 100644 --- a/apps/signage-manager/src/app/playlists/playlists.component.ts +++ b/apps/signage-manager/src/app/playlists/playlists.component.ts @@ -1,10 +1,15 @@ -import { Component, effect, inject, input, signal } from '@angular/core'; +import { Component, effect, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { i18n, notifyWarn } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { routedTab } from '../shared/routed-tab.util'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; import { PlaylistActionsComponent } from './playlist-actions.component'; import { PlaylistHeaderComponent } from './playlist-header.component'; @@ -13,9 +18,9 @@ import { PlaylistItemsComponent } from './playlist-items.component'; import { PlaylistListComponent } from './playlist-list.component'; import { SignagePlaylistService } from './signage-playlist.service'; -const TAB_QUERY_PARAM = 'tab'; +type PlaylistTab = 'items' | 'details'; -function parsePlaylistTab(value: string | null): 'items' | 'details' { +function parsePlaylistTab(value: string | null): PlaylistTab { return value === 'details' ? 'details' : 'items'; } @@ -68,63 +73,17 @@ function parsePlaylistTab(value: string | null): 'items' | 'details' {
-
- - -
+ /> }
`, - styles: [ - ` - .tablet-hidden { - @media (max-width: 1023px) { - display: none !important; - } - } - - .tablet-full { - @media (max-width: 1023px) { - flex: 1; - min-width: 0; - } - } - - .mobile-hidden { - @media (max-width: 639px) { - display: none !important; - } - } - - .mobile-full { - @media (max-width: 639px) { - flex: 1; - } - } - `, - ], imports: [ NavSidebarComponent, NavFooterComponent, @@ -185,6 +116,7 @@ function parsePlaylistTab(value: string | null): 'items' | 'details' { PlaylistItemsComponent, PlaylistItemDetailsComponent, PlaylistActionsComponent, + SectionTabsComponent, MatRippleModule, IconComponent, TranslatePipe, @@ -193,13 +125,18 @@ function parsePlaylistTab(value: string | null): 'items' | 'details' { export class PlaylistsSectionComponent { private readonly _context = inject(SignageContextService); private readonly _playlist_service = inject(SignagePlaylistService); - private readonly _route = inject(ActivatedRoute); private readonly _router = inject(Router); public readonly id = input(''); public readonly tab = input(null); public readonly item = input(null); - public readonly view_tab = signal<'items' | 'details'>('items'); + private readonly _view_tab = routedTab(this.tab, parsePlaylistTab); + public readonly view_tab = this._view_tab.active; + /** Tabs shown below the lg breakpoint. Wider screens show both panels. */ + public readonly tabs: SectionTab[] = [ + { id: 'items', label: 'SIGNAGE_MANAGER.TAB_ITEMS' }, + { id: 'details', label: 'COMMON.DETAILS' }, + ]; public readonly selected_playlist = this._playlist_service.selected_playlist; @@ -212,13 +149,6 @@ export class PlaylistsSectionComponent { private _fetched_id = ''; constructor() { - effect(() => { - const route_tab = parsePlaylistTab(this.tab()); - if (route_tab !== this.view_tab()) { - this.view_tab.set(route_tab); - } - }); - // Sync selected playlist from route param effect(() => { const id = this.id(); @@ -301,14 +231,7 @@ export class PlaylistsSectionComponent { this._router.navigate(['/playlists'], {}); } - public setViewTab(tab: 'items' | 'details') { - if (tab === this.view_tab()) return; - this.view_tab.set(tab); - void this._router.navigate([], { - relativeTo: this._route, - queryParams: { [TAB_QUERY_PARAM]: tab }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); + public setViewTab(tab: PlaylistTab) { + this._view_tab.select(tab); } } diff --git a/apps/signage-manager/src/app/schedules/schedules.component.ts b/apps/signage-manager/src/app/schedules/schedules.component.ts index 93704aa1e7a..4eedcb877e0 100644 --- a/apps/signage-manager/src/app/schedules/schedules.component.ts +++ b/apps/signage-manager/src/app/schedules/schedules.component.ts @@ -5,7 +5,6 @@ import { computed, inject, input, - linkedSignal, resource, signal, } from '@angular/core'; @@ -15,7 +14,6 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; import { i18n } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { addDays, isSameDay, startOfDay } from 'date-fns'; @@ -23,6 +21,8 @@ import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { routedTab } from '../shared/routed-tab.util'; +import { tabKeyIndex } from '../shared/section-tabs.component'; import { SignageInventoryService } from '../signage-inventory.service'; import { ScheduleTimelineComponent } from './schedule-timeline.component'; import { @@ -32,9 +32,11 @@ import { buildZoneScheduleAssignments, } from './signage-schedule.util'; -const TAB_QUERY_PARAM = 'tab'; +type ScheduleTab = 'displays' | 'zones'; -function parseScheduleTab(value: string | null): 'displays' | 'zones' { +const SCHEDULE_TABS: readonly ScheduleTab[] = ['displays', 'zones']; + +function parseScheduleTab(value: string | null): ScheduleTab { return value === 'zones' ? 'zones' : 'displays'; } @@ -323,13 +325,11 @@ function filterRows(rows: ScheduleTimelineRow[], search_term: string) { export class SchedulesSectionComponent { private readonly _inventory_service = inject(SignageInventoryService); private readonly _playlist_service = inject(SignagePlaylistService); - private readonly _route = inject(ActivatedRoute); - private readonly _router = inject(Router); private readonly _destroy_ref = inject(DestroyRef); public readonly tab = input(null); - /** Follows the route, and changes at once when users pick a tab */ - public readonly view_tab = linkedSignal(() => parseScheduleTab(this.tab())); + private readonly _view_tab = routedTab(this.tab, parseScheduleTab); + public readonly view_tab = this._view_tab.active; public readonly search_term = signal(''); public readonly selected_date = signal(startOfDay(new Date())); public readonly current_time = signal(new Date()); @@ -505,28 +505,20 @@ export class SchedulesSectionComponent { this.search_term.set(''); } - public setViewTab(tab: 'displays' | 'zones') { - if (tab === this.view_tab()) return; - this.view_tab.set(tab); - void this._router.navigate([], { - relativeTo: this._route, - queryParams: { [TAB_QUERY_PARAM]: tab }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); + public setViewTab(tab: ScheduleTab) { + this._view_tab.select(tab); } /** Move between the tabs with the arrow, Home and End keys */ public onTabKeydown(event: KeyboardEvent) { - const keys: Record = { - ArrowLeft: 'displays', - Home: 'displays', - ArrowRight: 'zones', - End: 'zones', - }; - const tab = keys[event.key]; - if (!tab) return; + const next = tabKeyIndex( + event.key, + SCHEDULE_TABS.indexOf(this.view_tab()), + SCHEDULE_TABS.length, + ); + if (next === null) return; event.preventDefault(); + const tab = SCHEDULE_TABS[next]; this.setViewTab(tab); document.getElementById(`schedules-tab-${tab}`)?.focus(); } diff --git a/apps/signage-manager/src/app/shared/assigned-playlists-panel.component.ts b/apps/signage-manager/src/app/shared/assigned-playlists-panel.component.ts new file mode 100644 index 00000000000..74d395d0330 --- /dev/null +++ b/apps/signage-manager/src/app/shared/assigned-playlists-panel.component.ts @@ -0,0 +1,173 @@ +import { Component, inject, input, output } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { + IconComponent, + LoadErrorComponent, + TranslatePipe, +} from '@placeos/components'; +import { SignagePlaylist } from '@placeos/ts-client'; +import { SignagePlaylistService } from '../playlists/signage-playlist.service'; +import { playlistStatus } from '../signage-playlist.util'; +import { PlaylistStatusBadgeComponent } from './playlist-status-badge.component'; +import { PlaylistThumbnailComponent } from './playlist-thumbnail.component'; + +/** + * Playlists assigned to a display or a zone, with their status, a link to + * each playlist, and add and remove buttons for users who can update. + */ +@Component({ + selector: 'assigned-playlists-panel', + template: ` +
+
+ playlist_play + {{ + 'SIGNAGE_MANAGER.PLAYLISTS_COUNT' + | translate + : { count: playlists().length } + : playlists().length + }} +
+ @if (can_update()) { + + } +
+
+ @if (playlists().length > 0) { + @for (playlist of playlists(); track playlist.id) { + + } + } @else if (loading()) { +
+ {{ 'COMMON.LOADING' | translate }} +
+ } @else if (error()) { + + } @else { +
+ playlist_remove +

+ {{ empty_label() | translate }} +

+
+ } +
+ `, + host: { class: 'block' }, + imports: [ + MatRippleModule, + MatTooltipModule, + RouterLink, + IconComponent, + LoadErrorComponent, + TranslatePipe, + PlaylistStatusBadgeComponent, + PlaylistThumbnailComponent, + ], +}) +export class AssignedPlaylistsPanelComponent { + private readonly _playlist_service = inject(SignagePlaylistService); + + public readonly playlists = input.required(); + /** Shows the loading state in place of the empty state */ + public readonly loading = input(false); + /** Shows a retry in place of the empty state */ + public readonly error = input(false); + public readonly can_update = input(false); + /** Translation key of the add button's accessible name */ + public readonly add_label = input.required(); + /** Translation key of each remove button's accessible name, given `name` */ + public readonly remove_label = input.required(); + /** Translation key of the text with no playlists */ + public readonly empty_label = input.required(); + + public readonly add = output(); + /** Emits the id of the playlist to remove */ + public readonly remove = output(); + public readonly retry = output(); + + public getStatus(playlist: SignagePlaylist) { + return playlistStatus( + playlist, + this._playlist_service.playlist_approval_status(), + this._playlist_service.playlist_approval_requested_status(), + ); + } +} diff --git a/apps/signage-manager/src/app/shared/display-select-modal.component.ts b/apps/signage-manager/src/app/shared/display-select-modal.component.ts index ab4eb5d14f8..82d2d7b3708 100644 --- a/apps/signage-manager/src/app/shared/display-select-modal.component.ts +++ b/apps/signage-manager/src/app/shared/display-select-modal.component.ts @@ -1,125 +1,56 @@ import { Component, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { - IconComponent, - LoadErrorComponent, - TranslatePipe, -} from '@placeos/components'; +import { IconComponent } from '@placeos/components'; import { PlaceSystem } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; -import { IntersectDirective } from './intersect.directive'; import { byDisplayName, PagedSearch } from './paged-search'; +import { + PickerItemDirective, + PickerModalComponent, +} from './picker-modal.component'; @Component({ selector: 'display-select-modal', template: ` -
-

- {{ 'SIGNAGE_MANAGER.ADD_DISPLAY_TITLE' | translate }} -

- -
-
- - - - @if (list.items().length > 0) { - @for (display of list.items(); track display.id) { - - } - @if (list.has_more()) { -
- } @else if (list.error()) { - - } - } @else if (list.loading()) { -
-
- {{ 'COMMON.LOADING' | translate }} -
-
- } @else if (list.error()) { - - } @else { -
- tv -
- {{ 'SIGNAGE_MANAGER.NO_DISPLAYS' | translate }} + @if (display.description) { +
+ {{ display.description }} +
+ }
-
- } -
+ + + `, imports: [ - FormsModule, MatRippleModule, MatDialogModule, - MatFormFieldModule, - MatInputModule, IconComponent, - LoadErrorComponent, - TranslatePipe, - IntersectDirective, + PickerModalComponent, + PickerItemDirective, ], }) export class DisplaySelectModalComponent { diff --git a/apps/signage-manager/src/app/shared/linked-item-row.component.ts b/apps/signage-manager/src/app/shared/linked-item-row.component.ts new file mode 100644 index 00000000000..73a9b8f6a1c --- /dev/null +++ b/apps/signage-manager/src/app/shared/linked-item-row.component.ts @@ -0,0 +1,42 @@ +import { Component, input } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { RouterLink } from '@angular/router'; +import { IconComponent, TranslatePipe } from '@placeos/components'; + +/** + * Row that links to a related display or zone, with an icon, the name and + * the description. + */ +@Component({ + selector: 'linked-item-row', + template: ` + + {{ icon() }} +
+
{{ name() }}
+ @if (description()) { +
+ {{ description() }} +
+ } +
+
+ `, + host: { class: 'block' }, + imports: [MatRippleModule, RouterLink, IconComponent, TranslatePipe], +}) +export class LinkedItemRowComponent { + /** Router link of the item */ + public readonly link = input.required(); + /** Material icon name */ + public readonly icon = input.required(); + public readonly name = input.required(); + public readonly description = input(''); + /** Translation key of the link's accessible name, given `name` */ + public readonly open_label = input.required(); +} diff --git a/apps/signage-manager/src/app/shared/media-select-modal.component.ts b/apps/signage-manager/src/app/shared/media-select-modal.component.ts index 7d68ebb1b14..420af99b380 100644 --- a/apps/signage-manager/src/app/shared/media-select-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-select-modal.component.ts @@ -1,19 +1,15 @@ import { Component, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { - IconComponent, - LoadErrorComponent, - TranslatePipe, -} from '@placeos/components'; +import { IconComponent } from '@placeos/components'; import { SignageMedia } from '@placeos/ts-client'; import { SignageMediaService } from '../media/signage-media.service'; -import { IntersectDirective } from './intersect.directive'; import { MediaThumbnailComponent } from './media-thumbnail.component'; import { byName, PagedSearch } from './paged-search'; +import { + PickerItemDirective, + PickerModalComponent, +} from './picker-modal.component'; export interface MediaSelectModalData { selected_id: string; @@ -22,111 +18,51 @@ export interface MediaSelectModalData { @Component({ selector: 'media-select-modal', template: ` -
-

- {{ 'SIGNAGE_MANAGER.TEMPLATE_BACKGROUND_SELECT' | translate }} -

- -
-
- - - - @if (list.items().length > 0) { - @for (media_item of list.items(); track media_item.id) { - - } - @if (list.has_more()) { -
- } @else if (list.error()) { - - } - } @else if (list.loading()) { -
+
- } @else if (list.error()) { - - } @else { -
- image -
- {{ 'SIGNAGE_MANAGER.NO_MEDIA' | translate }} + +
+
{{ media_item.name }}
+
+ {{ media_item.media_type }} +
-
- } -
+ @if (media_item.id === selected_id) { + check + } + + + `, imports: [ - FormsModule, MatRippleModule, MatDialogModule, - MatFormFieldModule, - MatInputModule, IconComponent, - LoadErrorComponent, - TranslatePipe, - IntersectDirective, MediaThumbnailComponent, + PickerModalComponent, + PickerItemDirective, ], }) export class MediaSelectModalComponent { diff --git a/apps/signage-manager/src/app/shared/picker-modal.component.ts b/apps/signage-manager/src/app/shared/picker-modal.component.ts new file mode 100644 index 00000000000..80c1218201c --- /dev/null +++ b/apps/signage-manager/src/app/shared/picker-modal.component.ts @@ -0,0 +1,153 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { + Component, + contentChild, + Directive, + inject, + input, + TemplateRef, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatRippleModule } from '@angular/material/core'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { + IconComponent, + LoadErrorComponent, + TranslatePipe, +} from '@placeos/components'; +import { IntersectDirective } from './intersect.directive'; +import { PagedSearch } from './paged-search'; + +/** + * Marks the item template of a ``. Bind the list to type the + * item: ``. + */ +@Directive({ selector: 'ng-template[pickerItem]' }) +export class PickerItemDirective { + /** List of the items, which sets the item type */ + public readonly pickerItem = input.required>(); + public readonly template = + inject>(TemplateRef); + + public static ngTemplateContextGuard( + _directive: PickerItemDirective, + context: unknown, + ): context is { $implicit: T } { + return true; + } +} + +/** + * Frame of a dialog that picks one item from a searchable, paged list: the + * header with a close button, the search field, the list with its loading, + * error and empty states, and the sentinel that loads the next page. + * + * Give the item button as an `` + * child. It usually closes the dialog with `[mat-dialog-close]`. + */ +@Component({ + selector: 'picker-modal', + template: ` +
+

+ {{ heading() | translate }} +

+ +
+
+ + + + @if (list().items().length > 0) { + @for (entry of list().items(); track entry.id) { + + } + @if (list().has_more()) { +
+ } @else if (list().error()) { + + } + } @else if (list().loading()) { +
+
+ {{ 'COMMON.LOADING' | translate }} +
+
+ } @else if (list().error()) { + + } @else { +
+ {{ + empty_icon() + }} +
+ {{ empty_label() | translate }} +
+
+ } +
+ `, + // Lays the header and the main out as children of the dialog component + host: { class: 'contents' }, + imports: [ + NgTemplateOutlet, + FormsModule, + MatRippleModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + IconComponent, + LoadErrorComponent, + TranslatePipe, + IntersectDirective, + ], +}) +export class PickerModalComponent { + public readonly list = input.required>(); + /** Translation key of the dialog title */ + public readonly heading = input.required(); + /** Translation key of the close button's accessible name */ + public readonly close_label = input.required(); + /** Translation key of the search field's placeholder and name */ + public readonly search_label = input.required(); + /** Material icon of the empty state */ + public readonly empty_icon = input.required(); + /** Translation key of the empty state */ + public readonly empty_label = input.required(); + + public readonly item_template = + contentChild.required>(PickerItemDirective); +} diff --git a/apps/signage-manager/src/app/shared/playlist-select-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-select-modal.component.ts index 55c214df61a..b0a97210f66 100644 --- a/apps/signage-manager/src/app/shared/playlist-select-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-select-modal.component.ts @@ -1,126 +1,54 @@ import { Component, inject } from '@angular/core'; -import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { - IconComponent, - LoadErrorComponent, - TranslatePipe, -} from '@placeos/components'; +import { IconComponent } from '@placeos/components'; import { SignagePlaylist } from '@placeos/ts-client'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; -import { IntersectDirective } from './intersect.directive'; import { byName, PagedSearch } from './paged-search'; +import { + PickerItemDirective, + PickerModalComponent, +} from './picker-modal.component'; @Component({ selector: 'playlist-select-modal', template: ` -
-

- {{ 'SIGNAGE_MANAGER.ADD_TO_PLAYLIST' | translate }} -

- -
-
- - - - @if (list.items().length > 0) { - @for (playlist of list.items(); track playlist.id) { - - } - @if (list.has_more()) { -
- } @else if (list.error()) { - - } - } @else if (list.loading()) { -
-
- {{ 'COMMON.LOADING' | translate }} -
-
- } @else if (list.error()) { - - } @else { -
+
+ +
+
`, imports: [ - FormsModule, MatRippleModule, MatDialogModule, - MatFormFieldModule, - MatInputModule, IconComponent, - LoadErrorComponent, - TranslatePipe, - IntersectDirective, + PickerModalComponent, + PickerItemDirective, ], }) export class PlaylistSelectModalComponent { diff --git a/apps/signage-manager/src/app/shared/playlist-status-badge.component.ts b/apps/signage-manager/src/app/shared/playlist-status-badge.component.ts new file mode 100644 index 00000000000..609d58a74c5 --- /dev/null +++ b/apps/signage-manager/src/app/shared/playlist-status-badge.component.ts @@ -0,0 +1,56 @@ +import { Component, input } from '@angular/core'; +import { TranslatePipe } from '@placeos/components'; +import { PlaylistStatus } from '../signage-playlist.util'; + +/** State that a `` shows */ +export type PlaylistBadgeStatus = 'disabled' | PlaylistStatus; + +const BADGES: Record< + NonNullable, + { label: string; colour: string } +> = { + disabled: { + label: 'COMMON.DISABLED', + colour: 'bg-warning text-warning-content', + }, + expired: { + label: 'SIGNAGE_MANAGER.STATUS_EXPIRED', + colour: 'bg-error text-error-content', + }, + pending: { label: 'COMMON.PENDING', colour: 'bg-info text-info-content' }, + awaiting_review: { + label: 'SIGNAGE_MANAGER.STATUS_AWAITING_REVIEW', + colour: 'bg-warning text-warning-content', + }, + awaiting_approval: { + label: 'COMMON.APPROVAL_REQUIRED', + colour: 'bg-base-300', + }, +}; + +/** + * Small label for the state of a playlist or template, such as expired or + * awaiting review. Shows nothing for a null status. + */ +@Component({ + selector: 'playlist-status-badge', + imports: [TranslatePipe], + template: ` + @let status_value = status(); + @if (status_value) { + + {{ badges[status_value].label | translate }} + + } + `, + host: { class: 'contents' }, +}) +export class PlaylistStatusBadgeComponent { + public readonly status = input.required(); + + public readonly badges = BADGES; +} diff --git a/apps/signage-manager/src/app/shared/routed-tab.util.ts b/apps/signage-manager/src/app/shared/routed-tab.util.ts new file mode 100644 index 00000000000..f60774024d3 --- /dev/null +++ b/apps/signage-manager/src/app/shared/routed-tab.util.ts @@ -0,0 +1,35 @@ +import { inject, linkedSignal, type Signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +/** + * Tab of a page that the `tab` query param holds. The tab follows the route, + * and changes at once when users pick a tab. Picking a tab writes it to the + * URL in place of the current history entry. + * + * Injects the router, so call it in an injection context. + * @param param Value of the `tab` query param, such as a route-bound input + * @param parse Turns the query value into a tab the page can show + */ +export function routedTab( + param: Signal, + parse: (value: string | null) => T, +) { + const route = inject(ActivatedRoute); + const router = inject(Router); + const active = linkedSignal(() => parse(param())); + + return { + active: active.asReadonly(), + /** Show `tab` and write it to the URL */ + select(tab: T) { + if (tab === active()) return; + active.set(tab); + void router.navigate([], { + relativeTo: route, + queryParams: { tab }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + }, + }; +} diff --git a/apps/signage-manager/src/app/shared/scroll-selection.util.ts b/apps/signage-manager/src/app/shared/scroll-selection.util.ts new file mode 100644 index 00000000000..b42320daa91 --- /dev/null +++ b/apps/signage-manager/src/app/shared/scroll-selection.util.ts @@ -0,0 +1,32 @@ +import { afterRenderEffect, type ElementRef } from '@angular/core'; + +/** + * Keep the row of the selected item in view, such as after a route opens an + * item further down a list. Rows must be in the same order as the items. + * + * Creates an after-render effect, so call it in an injection context. + * @param items Items of the list + * @param rows Row element of each item, such as from `viewChildren` + * @param selected Item to show + */ +export function scrollSelectionIntoView( + items: () => readonly T[], + rows: () => readonly ElementRef[], + selected: () => T | null | undefined, +) { + afterRenderEffect({ + earlyRead: () => { + const selected_id = selected()?.id; + if (!selected_id) return; + const index = items().findIndex(({ id }) => id === selected_id); + return rows()[index]?.nativeElement; + }, + write: (row) => { + row()?.scrollIntoView?.({ + behavior: 'instant', + block: 'nearest', + inline: 'nearest', + }); + }, + }); +} diff --git a/apps/signage-manager/src/app/shared/section-header.component.ts b/apps/signage-manager/src/app/shared/section-header.component.ts new file mode 100644 index 00000000000..a2ed8253444 --- /dev/null +++ b/apps/signage-manager/src/app/shared/section-header.component.ts @@ -0,0 +1,71 @@ +import { Component, input, output } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { GroupBreadcrumbsComponent } from './group-breadcrumbs.component'; + +/** + * Sticky header of a list page. Shows the page title, the item count, the + * group breadcrumbs and an icon button that emits `add`. + */ +@Component({ + selector: 'section-header', + template: ` +
+
+

+ {{ heading() | translate }} +

+
+ @let total = count(); + @if (total !== null) { +
+ {{ + 'COMMON.ITEM_COUNT' + | translate: { count: total } : total + }} +
+ } + +
+
+
+ @if (can_add()) { + + } +
+ `, + imports: [ + MatRippleModule, + IconComponent, + TranslatePipe, + GroupBreadcrumbsComponent, + MatTooltipModule, + ], +}) +export class SectionHeaderComponent { + /** Translation key of the page title. Not `title`, which would also set the native tooltip. */ + public readonly heading = input.required(); + /** Number of items, or null to hide the count while it is not known */ + public readonly count = input.required(); + public readonly can_add = input(false); + /** Translation key of the add button's accessible name */ + public readonly add_label = input.required(); + /** Translation key of the add button's tooltip */ + public readonly add_tooltip = input.required(); + + public readonly add = output(); +} diff --git a/apps/signage-manager/src/app/shared/section-tabs.component.ts b/apps/signage-manager/src/app/shared/section-tabs.component.ts new file mode 100644 index 00000000000..8ff77d68c82 --- /dev/null +++ b/apps/signage-manager/src/app/shared/section-tabs.component.ts @@ -0,0 +1,95 @@ +import { Component, ElementRef, inject, input, model } from '@angular/core'; +import { TranslatePipe } from '@placeos/components'; + +/** One tab of a `` bar */ +export interface SectionTab { + readonly id: T; + /** Translation key of the tab name */ + readonly label: string; + /** Number in a badge after the name. Leave it out for no badge. */ + readonly count?: number; + /** Shows "?" in the badge and marks it busy while the count loads */ + readonly count_loading?: boolean; +} + +/** + * Index of the tab that a key moves to in a tab list of `count` tabs. + * Home and End go to the ends, the arrow keys wrap around. + * @returns The new index, or null when the key does not move between tabs + */ +export function tabKeyIndex(key: string, index: number, count: number) { + if (key === 'Home') return 0; + if (key === 'End') return count - 1; + if (key === 'ArrowLeft') return (index - 1 + count) % count; + if (key === 'ArrowRight') return (index + 1) % count; + return null; +} + +/** + * Tab bar of a section. Only the active tab is in the tab order. The arrow + * keys, Home and End move between the tabs. + * + * Each tab gets the id `{id_prefix}-{id}-tab` and controls the panel + * `{id_prefix}-{id}-panel`. Set the `aria-label` and the layout classes, such as + * `flex`, on the host element, which is the tab list. + */ +@Component({ + selector: 'section-tabs', + imports: [TranslatePipe], + host: { + role: 'tablist', + '(keydown)': 'onKeydown($event)', + }, + template: ` + @for (tab of tabs(); track tab.id) { + + } + `, +}) +export class SectionTabsComponent { + private readonly _element = inject>(ElementRef); + + public readonly tabs = input.required[]>(); + public readonly active = model.required(); + /** Start of the tab and panel ids */ + public readonly id_prefix = input.required(); + + /** Move to and focus the tab that an arrow, Home or End key points to */ + public onKeydown(event: KeyboardEvent) { + const tabs = this.tabs(); + const next = tabKeyIndex( + event.key, + tabs.findIndex(({ id }) => id === this.active()), + tabs.length, + ); + if (next === null) return; + event.preventDefault(); + this.active.set(tabs[next].id); + this._element.nativeElement + .querySelectorAll('[role="tab"]') + [next]?.focus(); + } +} diff --git a/apps/signage-manager/src/app/templates/template-header.component.ts b/apps/signage-manager/src/app/templates/template-header.component.ts index d593ee2b3d0..3399f55ffe3 100644 --- a/apps/signage-manager/src/app/templates/template-header.component.ts +++ b/apps/signage-manager/src/app/templates/template-header.component.ts @@ -1,59 +1,21 @@ import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; +import { SectionHeaderComponent } from '../shared/section-header.component'; import { SignageContextService } from '../signage-context.service'; import { SignageTemplateService } from './signage-template.service'; @Component({ selector: 'template-header', template: ` -
-
-

- {{ 'SIGNAGE_MANAGER.TEMPLATES_PAGE_TITLE' | translate }} -

-
-
- {{ - 'COMMON.ITEM_COUNT' - | translate - : { count: total_count() } - : total_count() - }} -
- -
-
-
- @if (can_create()) { - - } -
+ `, - imports: [ - MatRippleModule, - IconComponent, - TranslatePipe, - GroupBreadcrumbsComponent, - MatTooltipModule, - ], + imports: [SectionHeaderComponent], }) export class TemplateHeaderComponent { private readonly _context = inject(SignageContextService); diff --git a/apps/signage-manager/src/app/templates/template-layout-list.component.ts b/apps/signage-manager/src/app/templates/template-layout-list.component.ts index d458f87eacc..8bad4ec4336 100644 --- a/apps/signage-manager/src/app/templates/template-layout-list.component.ts +++ b/apps/signage-manager/src/app/templates/template-layout-list.component.ts @@ -38,6 +38,10 @@ import { SignageTemplateMapping, } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; import { playlistScheduleLabel } from '../signage-playlist.util'; import { SignagePluginService } from '../signage-plugin.service'; @@ -56,7 +60,6 @@ import { layoutPositionIcon, layoutPositionLabel, layoutPositionValid, - tabKeyIndex, } from './template-layout.util'; @Component({ @@ -65,50 +68,15 @@ import {
- + /> @if (view_tab() === 'items') {
('items', { alias: 'tab', }); + public readonly tabs: SectionTab<'items' | 'details'>[] = [ + { id: 'items', label: 'SIGNAGE_MANAGER.TEMPLATE_LAYOUT_ITEMS' }, + { id: 'details', label: 'COMMON.DETAILS' }, + ]; public readonly positions = LAYOUT_POSITIONS; public readonly layouts = this._template_service.template_layout_draft; public readonly selected_template = @@ -852,23 +825,6 @@ export class TemplateLayoutListComponent { this.view_tab.set(tab); } - public handleTabKeydown( - event: KeyboardEvent, - items_tab: HTMLButtonElement, - details_tab: HTMLButtonElement, - ) { - const tabs = ['items', 'details'] as const; - const next = tabKeyIndex( - event.key, - tabs.indexOf(this.view_tab()), - tabs.length, - ); - if (next === null) return; - event.preventDefault(); - this.view_tab.set(tabs[next]); - [items_tab, details_tab][next].focus(); - } - public mappingTargetLabel(mapping: SignageTemplateMapping) { if (mapping.control_system_id) { const display = this.displays().find( diff --git a/apps/signage-manager/src/app/templates/template-layout.util.ts b/apps/signage-manager/src/app/templates/template-layout.util.ts index 89c66eb8d0a..1072e30a0a2 100644 --- a/apps/signage-manager/src/app/templates/template-layout.util.ts +++ b/apps/signage-manager/src/app/templates/template-layout.util.ts @@ -84,19 +84,6 @@ export function layoutPositionAxes(layout: SignageTemplateLayout) { return axes; } -/** - * Index of the tab that a key moves to in a tab list of `count` tabs. - * Home and End go to the ends, the arrow keys wrap around. - * @returns The new index, or null when the key does not move between tabs - */ -export function tabKeyIndex(key: string, index: number, count: number) { - if (key === 'Home') return 0; - if (key === 'End') return count - 1; - if (key === 'ArrowLeft') return (index - 1 + count) % count; - if (key === 'ArrowRight') return (index + 1) % count; - return null; -} - /** Add the displayed position defaults before sending a layout to the API. */ export function applyLayoutPositionDefaults( layout: SignageTemplateLayout, diff --git a/apps/signage-manager/src/app/templates/template-list.component.ts b/apps/signage-manager/src/app/templates/template-list.component.ts index f4667a7faf6..18bb5873492 100644 --- a/apps/signage-manager/src/app/templates/template-list.component.ts +++ b/apps/signage-manager/src/app/templates/template-list.component.ts @@ -1,10 +1,4 @@ -import { - afterRenderEffect, - Component, - ElementRef, - inject, - viewChildren, -} from '@angular/core'; +import { Component, ElementRef, inject, viewChildren } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -18,6 +12,8 @@ import { } from '@placeos/components'; import { SignageTemplate } from '@placeos/ts-client'; import { IntersectDirective } from '../shared/intersect.directive'; +import { PlaylistStatusBadgeComponent } from '../shared/playlist-status-badge.component'; +import { scrollSelectionIntoView } from '../shared/scroll-selection.util'; import { SignageTemplateService } from './signage-template.service'; type TemplateStatus = 'awaiting_approval' | 'awaiting_review' | null; @@ -89,28 +85,9 @@ type TemplateStatus = 'awaiting_approval' | 'awaiting_review' | null; } }} - @switch (getStatus(template)) { - @case ('awaiting_review') { - - {{ - 'SIGNAGE_MANAGER.STATUS_AWAITING_REVIEW' - | translate - }} - - } - @case ('awaiting_approval') { - - {{ - 'COMMON.APPROVAL_REQUIRED' - | translate - }} - - } - } +
@if (template.description) {
{ - const selected_id = this.selected()?.id; - if (!selected_id) return; - const template_index = this.templates().findIndex( - ({ id }) => id === selected_id, - ); - return this._template_items()[template_index]?.nativeElement; - }, - write: (selected_item) => { - selected_item()?.scrollIntoView?.({ - behavior: 'instant', - block: 'nearest', - inline: 'nearest', - }); - }, - }); + scrollSelectionIntoView( + this.templates, + this._template_items, + this.selected, + ); } public loadMore() { diff --git a/apps/signage-manager/src/app/templates/templates.component.ts b/apps/signage-manager/src/app/templates/templates.component.ts index 80cdc3b28d2..de4de95633f 100644 --- a/apps/signage-manager/src/app/templates/templates.component.ts +++ b/apps/signage-manager/src/app/templates/templates.component.ts @@ -13,6 +13,10 @@ import { Router } from '@angular/router'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; import { SignageTemplateService, @@ -20,7 +24,6 @@ import { } from './signage-template.service'; import { TemplateHeaderComponent } from './template-header.component'; import { TemplateLayoutListComponent } from './template-layout-list.component'; -import { tabKeyIndex } from './template-layout.util'; import { TemplateListComponent } from './template-list.component'; import { TemplatePreviewComponent } from './template-preview.component'; @@ -199,45 +202,13 @@ type TemplateViewTab = 'preview' | 'layouts' | 'details'; }
-
- @for (tab of tabs; track tab.id) { - - } -
+ />
@@ -293,34 +264,6 @@ type TemplateViewTab = 'preview' | 'layouts' | 'details';
`, host: { '(window:beforeunload)': 'onBeforeUnload($event)' }, - styles: [ - ` - .mobile-hidden { - @media (max-width: 639px) { - display: none !important; - } - } - - .mobile-full { - @media (max-width: 639px) { - flex: 1; - } - } - - .tablet-hidden { - @media (max-width: 1023px) { - display: none !important; - } - } - - .tablet-full { - @media (max-width: 1023px) { - flex: 1; - min-width: 0; - } - } - `, - ], imports: [ NavSidebarComponent, NavFooterComponent, @@ -328,6 +271,7 @@ type TemplateViewTab = 'preview' | 'layouts' | 'details'; TemplateListComponent, TemplatePreviewComponent, TemplateLayoutListComponent, + SectionTabsComponent, MatRippleModule, MatProgressSpinnerModule, MatTooltipModule, @@ -342,7 +286,7 @@ export class TemplatesSectionComponent { public readonly id = input(''); /** Tabs shown below the lg breakpoint. Wider screens show all panels. */ - public readonly tabs: { id: TemplateViewTab; label: string }[] = [ + public readonly tabs: SectionTab[] = [ { id: 'preview', label: 'COMMON.PREVIEW' }, { id: 'layouts', label: 'SIGNAGE_MANAGER.TEMPLATE_LAYOUT_ITEMS' }, { id: 'details', label: 'COMMON.DETAILS' }, @@ -505,21 +449,6 @@ export class TemplatesSectionComponent { this.view_tab.set(tab === 'details' ? 'details' : 'layouts'); } - public handleTabKeydown(event: KeyboardEvent) { - const ids = this.tabs.map(({ id }) => id); - const next = tabKeyIndex( - event.key, - ids.indexOf(this.view_tab()), - ids.length, - ); - if (next === null) return; - event.preventDefault(); - this.view_tab.set(ids[next]); - (event.currentTarget as HTMLElement | null)?.parentElement - ?.querySelectorAll('[role="tab"]') - [next]?.focus(); - } - /** * Fetch the template of the route and select it. The service lists it * unless a search is active, so it is selected here directly. diff --git a/apps/signage-manager/src/app/zones/signage-zone.service.ts b/apps/signage-manager/src/app/zones/signage-zone.service.ts index 9cae3390d5d..933ff2fcac4 100644 --- a/apps/signage-manager/src/app/zones/signage-zone.service.ts +++ b/apps/signage-manager/src/app/zones/signage-zone.service.ts @@ -319,7 +319,6 @@ export class SignageZoneService { const { PlaylistSelectModalComponent } = await import('../shared/playlist-select-modal.component'); const ref = this._dialog.open(PlaylistSelectModalComponent, { - data: { zone_id: zone.id }, panelClass: 'mobile-fullscreen', }); const playlist_id = await dialogClosed(ref); @@ -346,7 +345,6 @@ export class SignageZoneService { const { ZoneSelectModalComponent } = await import('../shared/zone-select-modal.component'); const ref = this._dialog.open(ZoneSelectModalComponent, { - data: { playlist_id: playlist.id }, panelClass: 'mobile-fullscreen', }); const zone_id = await dialogClosed(ref); diff --git a/apps/signage-manager/src/app/zones/zone-content.component.ts b/apps/signage-manager/src/app/zones/zone-content.component.ts index 8b4932919c3..fa5109d7fe8 100644 --- a/apps/signage-manager/src/app/zones/zone-content.component.ts +++ b/apps/signage-manager/src/app/zones/zone-content.component.ts @@ -1,19 +1,17 @@ import { Component, computed, inject, input } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterLink } from '@angular/router'; import { IconComponent, LoadErrorComponent, TranslatePipe, } from '@placeos/components'; -import { SignagePlaylist } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; -import { PlaylistThumbnailComponent } from '../shared/playlist-thumbnail.component'; +import { AssignedPlaylistsPanelComponent } from '../shared/assigned-playlists-panel.component'; +import { LinkedItemRowComponent } from '../shared/linked-item-row.component'; import { TemplateMappingsComponent } from '../shared/template-mappings.component'; import { SignageContextService } from '../signage-context.service'; -import { playlistStatus } from '../signage-playlist.util'; import { SignageZoneService } from './signage-zone.service'; @Component({ @@ -29,204 +27,22 @@ import { SignageZoneService } from './signage-zone.service'; class="bg-base-100 border-base-300 min-h-0 flex-1 overflow-auto rounded-lg border" [class.hidden]="activeTab() !== 'playlists'" > -
-
- playlist_play - {{ - 'SIGNAGE_MANAGER.PLAYLISTS_COUNT' - | translate - : { count: zone_playlists().length } - : zone_playlists().length - }} -
- @if (can_update()) { - - } -
- - } @else if ( + " + [error]=" has_assigned_playlists() && playlists_error() - ) { - - } @else { -
- playlist_remove -

- {{ - 'SIGNAGE_MANAGER.NO_PLAYLISTS_ZONE' - | translate - }} -

-
- } -
+ " + [can_update]="can_update()" + (add)="addPlaylist()" + (remove)="removePlaylist($event)" + (retry)="reloadPlaylists()" + />
- tv -
-
- {{ - display.display_name || - display.name - }} -
- @if (display.description) { -
- {{ display.description }} -
- } -
- + [description]="display.description" + /> } } @else if (zone_displays_loading()) {
@@ -428,9 +215,7 @@ export class ZoneContentComponent { if (zone) this._zone_service.addPlaylistToZone(zone); } - public removePlaylist(event: Event, playlist_id: string) { - event.preventDefault(); - event.stopPropagation(); + public removePlaylist(playlist_id: string) { const zone = this.selected_zone(); if (zone) this._zone_service.removePlaylistFromZone(zone, playlist_id); } @@ -439,12 +224,4 @@ export class ZoneContentComponent { const zone = this.selected_zone(); if (zone) this._display_service.addDisplayToZone(zone); } - - public getStatus(playlist: SignagePlaylist) { - return playlistStatus( - playlist, - this.playlist_approval_status(), - this._playlist_service.playlist_approval_requested_status(), - ); - } } diff --git a/apps/signage-manager/src/app/zones/zone-header.component.ts b/apps/signage-manager/src/app/zones/zone-header.component.ts index f0ef35747bd..51340bbef3f 100644 --- a/apps/signage-manager/src/app/zones/zone-header.component.ts +++ b/apps/signage-manager/src/app/zones/zone-header.component.ts @@ -1,61 +1,22 @@ import { Component, computed, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { Router } from '@angular/router'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; +import { SectionHeaderComponent } from '../shared/section-header.component'; import { SignageContextService } from '../signage-context.service'; import { SignageZoneService } from './signage-zone.service'; @Component({ selector: 'zone-header', template: ` -
-
-

- {{ 'SIGNAGE_MANAGER.ZONES_TITLE' | translate }} -

-
- @let count = total_count(); - @if (count !== null) { -
- {{ - 'COMMON.ITEM_COUNT' - | translate: { count } : count - }} -
- } - -
-
-
- @if (can_manage_zones()) { - - } -
+ `, - imports: [ - MatRippleModule, - IconComponent, - TranslatePipe, - GroupBreadcrumbsComponent, - MatTooltipModule, - ], + imports: [SectionHeaderComponent], }) export class ZoneHeaderComponent { private readonly _context = inject(SignageContextService); diff --git a/apps/signage-manager/src/app/zones/zones.component.ts b/apps/signage-manager/src/app/zones/zones.component.ts index e31028ce6a1..6c175c3317f 100644 --- a/apps/signage-manager/src/app/zones/zones.component.ts +++ b/apps/signage-manager/src/app/zones/zones.component.ts @@ -1,15 +1,7 @@ -import { - Component, - computed, - effect, - inject, - input, - resource, - signal, -} from '@angular/core'; +import { Component, computed, inject, input, resource } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { showZone } from '@placeos/ts-client'; import { selectRoutedItem } from '../displays/routed-selection.util'; @@ -18,6 +10,11 @@ import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { NavFooterComponent } from '../shared/nav-footer.component'; import { NavSidebarComponent } from '../shared/nav-sidebar.component'; +import { routedTab } from '../shared/routed-tab.util'; +import { + SectionTab, + SectionTabsComponent, +} from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; import { SignageTemplateService } from '../templates/signage-template.service'; import { SignageZoneService } from './signage-zone.service'; @@ -25,11 +22,9 @@ import { ZoneContentComponent } from './zone-content.component'; import { ZoneHeaderComponent } from './zone-header.component'; import { ZoneListComponent } from './zone-list.component'; -const TAB_QUERY_PARAM = 'tab'; +type ZoneTab = 'playlists' | 'displays' | 'templates'; -function parseZoneTab( - value: string | null, -): 'playlists' | 'displays' | 'templates' { +function parseZoneTab(value: string | null): ZoneTab { if (value === 'displays' || value === 'templates') return value; return 'playlists'; } @@ -123,137 +118,17 @@ function parseZoneTab( }
-
- - - @if (templates_enabled()) { - - } -
+ /> }
`, - styles: [ - ` - .mobile-hidden { - @media (max-width: 639px) { - display: none !important; - } - } - - .mobile-full { - @media (max-width: 639px) { - flex: 1; - } - } - `, - ], imports: [ NavSidebarComponent, NavFooterComponent, ZoneHeaderComponent, ZoneListComponent, ZoneContentComponent, + SectionTabsComponent, MatRippleModule, MatTooltipModule, IconComponent, @@ -298,15 +159,18 @@ export class ZonesSectionComponent { private readonly _playlist_service = inject(SignagePlaylistService); private readonly _template_service = inject(SignageTemplateService); private readonly _zone_service = inject(SignageZoneService); - private readonly _route = inject(ActivatedRoute); private readonly _router = inject(Router); public readonly id = input(''); public readonly tab = input(null); public readonly templates_enabled = this._context.templates_enabled; - public readonly view_tab = signal<'playlists' | 'displays' | 'templates'>( - 'playlists', - ); + private readonly _view_tab = routedTab(this.tab, (value) => { + const tab = parseZoneTab(value); + return tab === 'templates' && !this.templates_enabled() + ? 'playlists' + : tab; + }); + public readonly view_tab = this._view_tab.active; public readonly selected_zone = this._zone_service.selected_zone; public readonly can_manage_selected_zone = computed(() => { const zone = this.selected_zone(); @@ -352,18 +216,32 @@ export class ZonesSectionComponent { () => this._display_service.selected_zone_displays().length, ); - constructor() { - effect(() => { - const route_tab = parseZoneTab(this.tab()); - const available_tab = - route_tab === 'templates' && !this.templates_enabled() - ? 'playlists' - : route_tab; - if (available_tab !== this.view_tab()) { - this.view_tab.set(available_tab); - } - }); + public readonly tabs = computed[]>(() => [ + { + id: 'playlists', + label: 'SIGNAGE_MANAGER.NAV_PLAYLISTS', + count: this.playlist_count(), + count_loading: this.playlist_count_loading(), + }, + { + id: 'displays', + label: 'SIGNAGE_MANAGER.NAV_DISPLAYS', + count: this.display_count(), + count_loading: this.display_count_loading(), + }, + ...(this.templates_enabled() + ? [ + { + id: 'templates' as const, + label: 'SIGNAGE_MANAGER.NAV_TEMPLATES', + count: this.template_count(), + count_loading: this.template_count_loading(), + }, + ] + : []), + ]); + constructor() { selectRoutedItem({ id: this.id, list: this._zone_service.all_zones, @@ -389,14 +267,7 @@ export class ZonesSectionComponent { await this._router.navigate(['/zones'], {}); } - public setViewTab(tab: 'playlists' | 'displays' | 'templates') { - if (tab === this.view_tab()) return; - this.view_tab.set(tab); - void this._router.navigate([], { - relativeTo: this._route, - queryParams: { [TAB_QUERY_PARAM]: tab }, - queryParamsHandling: 'merge', - replaceUrl: true, - }); + public setViewTab(tab: ZoneTab) { + this._view_tab.select(tab); } } diff --git a/apps/signage-manager/src/styles.css b/apps/signage-manager/src/styles.css index f3b026ad8b2..e0a48c676b7 100644 --- a/apps/signage-manager/src/styles.css +++ b/apps/signage-manager/src/styles.css @@ -49,6 +49,28 @@ select:focus-visible, } } +/* List and details pages show one panel at a time on small screens */ +@media (max-width: 639px) { + .mobile-hidden { + display: none !important; + } + + .mobile-full { + flex: 1; + } +} + +@media (max-width: 1023px) { + .tablet-hidden { + display: none !important; + } + + .tablet-full { + flex: 1; + min-width: 0; + } +} + :root { --bg: #212121; --transition-delay: 1000ms; diff --git a/apps/signage-manager/src/tests/displays/display-content.component.spec.ts b/apps/signage-manager/src/tests/displays/display-content.component.spec.ts index 10448a7ef7f..edf4d812134 100644 --- a/apps/signage-manager/src/tests/displays/display-content.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/display-content.component.spec.ts @@ -6,8 +6,6 @@ import { SignageDisplayService } from '../../app/displays/signage-display.servic import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { SignageContextService } from '../../app/signage-context.service'; -const NOW_S = Math.floor(Date.now() / 1000); - describe('DisplayContentComponent', () => { const selected_display = signal(null); const playlists = signal([]); @@ -114,40 +112,6 @@ describe('DisplayContentComponent', () => { expect(component.display_zones()).toEqual([]); }); - it('marks playlists past their valid_until window as expired', async () => { - const component = await make(); - expect( - component.getStatus({ - id: 'p1', - valid_until: NOW_S - 3600, - } as any), - ).toBe('expired'); - }); - - it('marks playlists before their valid_from window as pending', async () => { - const component = await make(); - expect( - component.getStatus({ - id: 'p1', - valid_from: NOW_S + 3600, - } as any), - ).toBe('pending'); - }); - - it('marks unapproved playlists as awaiting approval', async () => { - playlist_approval_status.set({ p1: false }); - const component = await make(); - expect(component.getStatus({ id: 'p1' } as any)).toBe( - 'awaiting_approval', - ); - }); - - it('returns no status for an approved, in-window playlist', async () => { - playlist_approval_status.set({ p1: true }); - const component = await make(); - expect(component.getStatus({ id: 'p1' } as any)).toBeNull(); - }); - it('adds a playlist to the selected display via the service', async () => { const display = { id: 'd1', playlists: [] }; selected_display.set(display); @@ -158,19 +122,13 @@ describe('DisplayContentComponent', () => { expect(add_playlist).toHaveBeenCalledWith(display); }); - it('removes a playlist from the display and stops the click event', async () => { + it('removes a playlist from the display via the service', async () => { const display = { id: 'd1', playlists: ['p1'] }; selected_display.set(display); const component = await make(); - const event = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - }; - component.removePlaylist(event as any, 'p1'); + component.removePlaylist('p1'); - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); expect(remove_playlist).toHaveBeenCalledWith(display, 'p1'); }); diff --git a/apps/signage-manager/src/tests/displays/displays.component.spec.ts b/apps/signage-manager/src/tests/displays/displays.component.spec.ts index fc8574e4475..0f300ae3152 100644 --- a/apps/signage-manager/src/tests/displays/displays.component.spec.ts +++ b/apps/signage-manager/src/tests/displays/displays.component.spec.ts @@ -8,6 +8,7 @@ import { PlaceSystem, show } from '@placeos/ts-client'; import { DisplaysSectionComponent } from '../../app/displays/displays.component'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; +import { SectionTabsComponent } from '../../app/shared/section-tabs.component'; import { SignageContextService } from '../../app/signage-context.service'; vi.mock('@placeos/ts-client', { spy: true }); @@ -66,7 +67,10 @@ describe('DisplaysSectionComponent', () => { }) .overrideComponent(DisplaysSectionComponent, { set: render_template - ? { imports: [TranslatePipe], schemas: [NO_ERRORS_SCHEMA] } + ? { + imports: [TranslatePipe, SectionTabsComponent], + schemas: [NO_ERRORS_SCHEMA], + } : { template: '' }, }) .compileComponents(); diff --git a/apps/signage-manager/src/tests/groups/signage-group-members.component.spec.ts b/apps/signage-manager/src/tests/groups/signage-group-members.component.spec.ts new file mode 100644 index 00000000000..50945eb936c --- /dev/null +++ b/apps/signage-manager/src/tests/groups/signage-group-members.component.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { + SignageGroupMember, + SignageGroupMembersComponent, +} from '../../app/groups/signage-group-members.component'; + +describe('SignageGroupMembersComponent', () => { + async function render( + kind: 'users' | 'zones', + members: SignageGroupMember[], + ) { + const fixture = TestBed.createComponent( + SignageGroupMembersComponent, + ); + fixture.componentRef.setInput('kind', kind); + fixture.componentRef.setInput('members', members); + await fixture.whenStable(); + return fixture; + } + + it('shows the detail line of users and the denied label of zones', async () => { + const users = await render('users', [ + { + id: 'u1', + name: 'Ann', + detail: 'ann@example.com', + permissions: 0, + row: 'u1', + }, + ]); + expect(users.nativeElement.textContent).toContain('ann@example.com'); + expect(users.nativeElement.textContent).toContain('person'); + + const zones = await render('zones', [ + { id: 'z1', name: 'Lobby', deny: true, permissions: 0, row: 'z1' }, + ]); + expect(zones.nativeElement.textContent).toContain('Denied'); + expect(zones.nativeElement.textContent).not.toContain('person'); + }); + + it('emits the row of the member to edit or remove', async () => { + const fixture = await render('zones', [ + { id: 'z1', name: 'Lobby', permissions: 0, row: 'zone-row' }, + ]); + const edit = vi.fn(); + const remove = vi.fn(); + fixture.componentInstance.edit.subscribe(edit); + fixture.componentInstance.remove.subscribe(remove); + const element: HTMLElement = fixture.nativeElement; + // The add button, then the edit and remove buttons of the row + const [, edit_button, remove_button] = + element.querySelectorAll('button'); + + edit_button?.click(); + remove_button?.click(); + + expect(edit).toHaveBeenCalledWith('zone-row'); + expect(remove).toHaveBeenCalledWith('zone-row'); + }); +}); diff --git a/apps/signage-manager/src/tests/groups/signage-group-tabs.component.spec.ts b/apps/signage-manager/src/tests/groups/signage-group-tabs.component.spec.ts deleted file mode 100644 index cf1feaa3bea..00000000000 --- a/apps/signage-manager/src/tests/groups/signage-group-tabs.component.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { SignageGroupAdminService } from '../../app/groups/signage-group-admin.service'; -import { SignageGroupTabsComponent } from '../../app/groups/signage-group-tabs.component'; - -describe('SignageGroupTabsComponent', () => { - const managed_group_tab = signal<'users' | 'zones'>('users'); - const service_stub = { - managed_group_tab, - }; - - function make() { - TestBed.configureTestingModule({ - providers: [ - { provide: SignageGroupAdminService, useValue: service_stub }, - ], - }).overrideComponent(SignageGroupTabsComponent, { - set: { template: '', imports: [] }, - }); - return TestBed.createComponent(SignageGroupTabsComponent) - .componentInstance; - } - - beforeEach(() => managed_group_tab.set('users')); - - it('lists the users and zones tabs', () => { - const component = make(); - expect(component.tabs.map((tab) => tab.id)).toEqual(['users', 'zones']); - }); - - it('switches the active tab through the shared service signal', () => { - const component = make(); - expect(component.active_tab()).toBe('users'); - - component.active_tab.set('zones'); - - expect(managed_group_tab()).toBe('zones'); - }); - - it('moves between the tabs with the arrow, Home and End keys', () => { - const component = make(); - const press = (key: string) => - component.onKeydown(new KeyboardEvent('keydown', { key })); - - press('ArrowRight'); - expect(managed_group_tab()).toBe('zones'); - press('ArrowRight'); - expect(managed_group_tab()).toBe('users'); - press('ArrowLeft'); - expect(managed_group_tab()).toBe('zones'); - press('Home'); - expect(managed_group_tab()).toBe('users'); - press('End'); - expect(managed_group_tab()).toBe('zones'); - press('a'); - expect(managed_group_tab()).toBe('zones'); - }); -}); diff --git a/apps/signage-manager/src/tests/groups/signage-group-users.component.spec.ts b/apps/signage-manager/src/tests/groups/signage-group-users.component.spec.ts index 88d58240c69..df92193fe3e 100644 --- a/apps/signage-manager/src/tests/groups/signage-group-users.component.spec.ts +++ b/apps/signage-manager/src/tests/groups/signage-group-users.component.spec.ts @@ -5,6 +5,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { SignageGroupAdminService } from '../../app/groups/signage-group-admin.service'; +import { SignageGroupMembersComponent } from '../../app/groups/signage-group-members.component'; import { SignageGroupPermissionsModalComponent } from '../../app/groups/signage-group-permissions-modal.component'; import { SignageGroupUserSelectModalComponent } from '../../app/groups/signage-group-user-select-modal.component'; import { SignageGroupUsersComponent } from '../../app/groups/signage-group-users.component'; @@ -72,7 +73,7 @@ describe('SignageGroupUsersComponent', () => { { provide: SignageGroupAdminService, useValue: service_stub }, { provide: MatDialog, useValue: dialog }, ], - }).overrideComponent(SignageGroupUsersComponent, { + }).overrideComponent(SignageGroupMembersComponent, { set: { imports: [ MatProgressSpinnerModule, diff --git a/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts b/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts index cadd839d03a..f3e82b58b98 100644 --- a/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts +++ b/apps/signage-manager/src/tests/playlists/playlist-header.component.spec.ts @@ -1,7 +1,5 @@ -import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { LocaleService } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; import { PlaylistHeaderComponent } from '../../app/playlists/playlist-header.component'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { SignageContextService } from '../../app/signage-context.service'; @@ -44,33 +42,6 @@ describe('PlaylistHeaderComponent', () => { expect(component.total_count()).toBe(450); }); - it('passes the count as the plural so one playlist reads "1 item"', async () => { - playlists_total.set(1); - // Plural 1 picks the ITEM_COUNT_1 form, as the locale service does - const locale = { - get: (key: string, args: { count?: number }, plural?: number) => - key === 'COMMON.ITEM_COUNT' - ? `${args.count} ${plural === 1 ? 'item' : 'items'}` - : key, - }; - await TestBed.configureTestingModule({ - imports: [PlaylistHeaderComponent], - providers: [ - { provide: LocaleService, useValue: locale }, - { provide: SignageContextService, useValue: context_stub }, - { provide: SignagePlaylistService, useValue: playlist_stub }, - ], - }) - .overrideComponent(PlaylistHeaderComponent, { - set: { imports: [TranslatePipe], schemas: [NO_ERRORS_SCHEMA] }, - }) - .compileComponents(); - const fixture = TestBed.createComponent(PlaylistHeaderComponent); - fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('1 item'); - expect(fixture.nativeElement.textContent).not.toContain('1 items'); - }); - it('mirrors the service create permission', async () => { can_create.set(true); const component = await make(); diff --git a/apps/signage-manager/src/tests/shared/assigned-playlists-panel.component.spec.ts b/apps/signage-manager/src/tests/shared/assigned-playlists-panel.component.spec.ts new file mode 100644 index 00000000000..1569a0933af --- /dev/null +++ b/apps/signage-manager/src/tests/shared/assigned-playlists-panel.component.spec.ts @@ -0,0 +1,94 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; +import { AssignedPlaylistsPanelComponent } from '../../app/shared/assigned-playlists-panel.component'; + +const NOW_S = Math.floor(Date.now() / 1000); + +describe('AssignedPlaylistsPanelComponent', () => { + const playlist_approval_status = signal>({}); + const playlist_approval_requested_status = signal>( + {}, + ); + const playlist_stub = { + playlist_approval_status, + playlist_approval_requested_status, + playlist_thumbnail_media: signal>({}), + queuePlaylistMeta: vi.fn(), + }; + + async function make(playlists: unknown[], can_update = true) { + await TestBed.configureTestingModule({ + imports: [AssignedPlaylistsPanelComponent], + providers: [ + provideRouter([]), + { provide: SignagePlaylistService, useValue: playlist_stub }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent( + AssignedPlaylistsPanelComponent, + ); + fixture.componentRef.setInput('playlists', playlists); + fixture.componentRef.setInput('can_update', can_update); + fixture.componentRef.setInput('add_label', 'ADD'); + fixture.componentRef.setInput('remove_label', 'REMOVE'); + fixture.componentRef.setInput('empty_label', 'EMPTY'); + await fixture.whenStable(); + return fixture; + } + + beforeEach(() => { + playlist_approval_status.set({}); + playlist_approval_requested_status.set({}); + }); + + it('classifies playlist status by validity window and approval state', async () => { + playlist_approval_status.set({ p_wait: false, p_review: false }); + playlist_approval_requested_status.set({ p_review: true }); + const component = (await make([])).componentInstance; + + expect( + component.getStatus({ id: 'p', valid_until: NOW_S - 10 } as any), + ).toBe('expired'); + expect( + component.getStatus({ id: 'p', valid_from: NOW_S + 10 } as any), + ).toBe('pending'); + expect(component.getStatus({ id: 'p_wait' } as any)).toBe( + 'awaiting_approval', + ); + expect(component.getStatus({ id: 'p_review' } as any)).toBe( + 'awaiting_review', + ); + expect(component.getStatus({ id: 'p' } as any)).toBeNull(); + }); + + it('shows the disabled and status badges of each playlist', async () => { + const fixture = await make([ + { id: 'p1', name: 'Old', enabled: false, valid_until: NOW_S - 10 }, + ]); + const element: HTMLElement = fixture.nativeElement; + + expect( + element.querySelector('[data-playlist-status="disabled"]'), + ).not.toBeNull(); + expect( + element.querySelector('[data-playlist-status="expired"]'), + ).not.toBeNull(); + }); + + it('emits the id of the playlist to remove', async () => { + const fixture = await make([ + { id: 'p1', name: 'Lobby', enabled: true }, + ]); + const remove = vi.fn(); + fixture.componentInstance.remove.subscribe(remove); + + const element: HTMLElement = fixture.nativeElement; + element + .querySelector('button[aria-label="REMOVE"]') + ?.click(); + + expect(remove).toHaveBeenCalledWith('p1'); + }); +}); diff --git a/apps/signage-manager/src/tests/shared/display-select-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/display-select-modal.component.spec.ts index e803fae9722..ac1788cdffb 100644 --- a/apps/signage-manager/src/tests/shared/display-select-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/display-select-modal.component.spec.ts @@ -1,5 +1,4 @@ import { TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { DisplaySelectModalComponent } from '../../app/shared/display-select-modal.component'; @@ -22,10 +21,7 @@ describe('DisplaySelectModalComponent', () => { ); await TestBed.configureTestingModule({ imports: [DisplaySelectModalComponent], - providers: [ - { provide: MAT_DIALOG_DATA, useValue: { zone_id: 'zone-1' } }, - { provide: SignageDisplayService, useValue: service }, - ], + providers: [{ provide: SignageDisplayService, useValue: service }], }) .overrideComponent(DisplaySelectModalComponent, { set: { template: '', imports: [] }, @@ -39,9 +35,9 @@ describe('DisplaySelectModalComponent', () => { await flush(); expect(queryDisplays).toHaveBeenCalledWith(''); - expect( - fixture.componentInstance.list.items().map((_) => _.id), - ).toEqual(['d2', 'd1']); + expect(fixture.componentInstance.list.items().map((_) => _.id)).toEqual( + ['d2', 'd1'], + ); }); }); @@ -55,7 +51,6 @@ describe('DisplaySelectModalComponent errors', () => { await TestBed.configureTestingModule({ imports: [DisplaySelectModalComponent], providers: [ - { provide: MAT_DIALOG_DATA, useValue: {} }, { provide: SignageDisplayService, useValue: { queryDisplays } }, ], }).compileComponents(); @@ -77,9 +72,8 @@ describe('DisplaySelectModalComponent errors', () => { fixture.detectChanges(); const element: HTMLElement = fixture.nativeElement; - const retry = element.querySelector( - 'load-error button', - ); + const retry = + element.querySelector('load-error button'); expect(retry).toBeTruthy(); retry?.click(); diff --git a/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts index 10bb6bd4392..926b418aa06 100644 --- a/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts @@ -1,5 +1,4 @@ import { TestBed } from '@angular/core/testing'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { PlaylistSelectModalComponent } from '../../app/shared/playlist-select-modal.component'; @@ -22,10 +21,7 @@ describe('PlaylistSelectModalComponent', () => { ); await TestBed.configureTestingModule({ imports: [PlaylistSelectModalComponent], - providers: [ - { provide: MAT_DIALOG_DATA, useValue: { media_id: 'media-1' } }, - { provide: SignagePlaylistService, useValue: service }, - ], + providers: [{ provide: SignagePlaylistService, useValue: service }], }) .overrideComponent(PlaylistSelectModalComponent, { set: { template: '', imports: [] }, @@ -55,7 +51,6 @@ describe('PlaylistSelectModalComponent errors', () => { await TestBed.configureTestingModule({ imports: [PlaylistSelectModalComponent], providers: [ - { provide: MAT_DIALOG_DATA, useValue: {} }, { provide: SignagePlaylistService, useValue: { queryPlaylists }, diff --git a/apps/signage-manager/src/tests/shared/section-header.component.spec.ts b/apps/signage-manager/src/tests/shared/section-header.component.spec.ts new file mode 100644 index 00000000000..0257e80a166 --- /dev/null +++ b/apps/signage-manager/src/tests/shared/section-header.component.spec.ts @@ -0,0 +1,62 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { LocaleService } from '@placeos/common'; +import { GroupBreadcrumbsComponent } from '../../app/shared/group-breadcrumbs.component'; +import { SectionHeaderComponent } from '../../app/shared/section-header.component'; + +describe('SectionHeaderComponent', () => { + // Plural 1 picks the ITEM_COUNT_1 form, as the locale service does + const locale = { + get: (key: string, args: { count?: number }, plural?: number) => + key === 'COMMON.ITEM_COUNT' + ? `${args.count} ${plural === 1 ? 'item' : 'items'}` + : key, + }; + + async function make(count: number | null, can_add = false) { + await TestBed.configureTestingModule({ + imports: [SectionHeaderComponent], + providers: [{ provide: LocaleService, useValue: locale }], + }) + .overrideComponent(SectionHeaderComponent, { + remove: { imports: [GroupBreadcrumbsComponent] }, + add: { schemas: [NO_ERRORS_SCHEMA] }, + }) + .compileComponents(); + const fixture = TestBed.createComponent(SectionHeaderComponent); + fixture.componentRef.setInput('heading', 'TITLE'); + fixture.componentRef.setInput('count', count); + fixture.componentRef.setInput('can_add', can_add); + fixture.componentRef.setInput('add_label', 'ADD_LABEL'); + fixture.componentRef.setInput('add_tooltip', 'ADD_TOOLTIP'); + await fixture.whenStable(); + return fixture; + } + + it('passes the count as the plural so one item reads "1 item"', async () => { + const fixture = await make(1); + expect(fixture.nativeElement.textContent).toContain('1 item'); + expect(fixture.nativeElement.textContent).not.toContain('1 items'); + }); + + it('hides the count while it is not known', async () => { + const fixture = await make(null); + expect(fixture.nativeElement.textContent).not.toContain('item'); + }); + + it('shows a labelled add button only when adding is allowed', async () => { + const hidden = await make(2); + expect(hidden.nativeElement.querySelector('button')).toBeNull(); + TestBed.resetTestingModule(); + + const fixture = await make(2, true); + const add = vi.fn(); + fixture.componentInstance.add.subscribe(add); + const button: HTMLButtonElement = + fixture.nativeElement.querySelector('button'); + + expect(button.getAttribute('aria-label')).toBe('ADD_LABEL'); + button.click(); + expect(add).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/signage-manager/src/tests/shared/section-tabs.component.spec.ts b/apps/signage-manager/src/tests/shared/section-tabs.component.spec.ts new file mode 100644 index 00000000000..6ff41a91574 --- /dev/null +++ b/apps/signage-manager/src/tests/shared/section-tabs.component.spec.ts @@ -0,0 +1,96 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + SectionTab, + SectionTabsComponent, +} from '../../app/shared/section-tabs.component'; + +type Tab = 'users' | 'zones' | 'media'; + +@Component({ + imports: [SectionTabsComponent], + template: ` + + `, +}) +class HostComponent { + public readonly active = signal('users'); + public readonly tabs = signal[]>([ + { id: 'users', label: 'Users' }, + { id: 'zones', label: 'Zones', count: 3, count_loading: true }, + { id: 'media', label: 'Media', count: 2 }, + ]); +} + +describe('SectionTabsComponent', () => { + async function make() { + const fixture = TestBed.createComponent(HostComponent); + await fixture.whenStable(); + const element: HTMLElement = fixture.nativeElement; + const tab = (id: Tab) => + element.querySelector(`#test-${id}-tab`); + const press = async (key: string) => { + element + .querySelector('section-tabs') + ?.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true }), + ); + await fixture.whenStable(); + }; + return { fixture, host: fixture.componentInstance, tab, press }; + } + + it('links each tab to its panel and keeps only the active tab in the tab order', async () => { + const { tab } = await make(); + + expect(tab('users')?.getAttribute('aria-controls')).toBe( + 'test-users-panel', + ); + expect(tab('users')?.getAttribute('aria-selected')).toBe('true'); + expect(tab('users')?.getAttribute('tabindex')).toBe('0'); + expect(tab('zones')?.getAttribute('tabindex')).toBe('-1'); + }); + + it('shows a count badge only for tabs with a count', async () => { + const { tab } = await make(); + + expect(tab('users')?.querySelector('span')).toBeNull(); + expect(tab('zones')?.querySelector('span')?.textContent?.trim()).toBe( + '?', + ); + expect( + tab('zones')?.querySelector('span')?.getAttribute('aria-busy'), + ).toBe('true'); + expect(tab('media')?.querySelector('span')?.textContent?.trim()).toBe( + '2', + ); + }); + + it('selects a tab on click', async () => { + const { fixture, host, tab } = await make(); + + tab('media')?.click(); + await fixture.whenStable(); + + expect(host.active()).toBe('media'); + expect(tab('media')?.getAttribute('aria-selected')).toBe('true'); + }); + + it('moves and focuses tabs with the arrow, Home and End keys', async () => { + const { host, tab, press } = await make(); + + await press('ArrowRight'); + expect(host.active()).toBe('zones'); + expect(document.activeElement).toBe(tab('zones')); + await press('End'); + expect(host.active()).toBe('media'); + await press('ArrowRight'); + expect(host.active()).toBe('users'); + await press('ArrowLeft'); + expect(host.active()).toBe('media'); + await press('Home'); + expect(host.active()).toBe('users'); + await press('a'); + expect(host.active()).toBe('users'); + }); +}); diff --git a/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts b/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts index 025d0e57d80..1064444b89d 100644 --- a/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts +++ b/apps/signage-manager/src/tests/templates/template-layout-list.component.spec.ts @@ -85,24 +85,6 @@ describe('TemplateLayoutListComponent', () => { expect(selected_index()).toBeNull(); }); - it('switches and focuses sidebar tabs with the arrow keys', async () => { - const component = await make(); - const event = { - key: 'ArrowRight', - preventDefault: vi.fn(), - } as unknown as KeyboardEvent; - const items_tab = { focus: vi.fn() } as unknown as HTMLButtonElement; - const details_tab = { - focus: vi.fn(), - } as unknown as HTMLButtonElement; - - component.handleTabKeydown(event, items_tab, details_tab); - - expect(component.view_tab()).toBe('details'); - expect(event.preventDefault).toHaveBeenCalled(); - expect(details_tab.focus).toHaveBeenCalled(); - }); - it('does not reload mappings when a save replaces the same template', async () => { selected_template.set({ id: 'template-1' }); const component = await make(); diff --git a/apps/signage-manager/src/tests/templates/templates.component.spec.ts b/apps/signage-manager/src/tests/templates/templates.component.spec.ts index ab48b105d73..3066b2705a7 100644 --- a/apps/signage-manager/src/tests/templates/templates.component.spec.ts +++ b/apps/signage-manager/src/tests/templates/templates.component.spec.ts @@ -73,35 +73,6 @@ describe('TemplatesSectionComponent', () => { expect(component.view_tab()).toBe('layouts'); }); - it('switches and focuses tabs with the arrow keys', async () => { - const component = await make(); - const layouts_tab = { focus: vi.fn() }; - const details_tab = { focus: vi.fn() }; - const tablist = { - querySelectorAll: () => [ - { focus: vi.fn() }, - layouts_tab, - details_tab, - ], - }; - const event = { - key: 'ArrowRight', - preventDefault: vi.fn(), - currentTarget: { parentElement: tablist }, - } as unknown as KeyboardEvent; - - component.handleTabKeydown(event); - - expect(component.view_tab()).toBe('layouts'); - expect(event.preventDefault).toHaveBeenCalled(); - expect(layouts_tab.focus).toHaveBeenCalled(); - - component.handleTabKeydown(event); - - expect(component.view_tab()).toBe('details'); - expect(details_tab.focus).toHaveBeenCalled(); - }); - it('mirrors the layout list tab into the mobile tabs', async () => { const component = await make(); diff --git a/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts b/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts index daa9c86d09d..e00e8e5c843 100644 --- a/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts +++ b/apps/signage-manager/src/tests/zones/zone-content.component.spec.ts @@ -7,8 +7,6 @@ import { SignageContextService } from '../../app/signage-context.service'; import { SignageZoneService } from '../../app/zones/signage-zone.service'; import { ZoneContentComponent } from '../../app/zones/zone-content.component'; -const NOW_S = Math.floor(Date.now() / 1000); - describe('ZoneContentComponent', () => { const selected_zone = signal(null); const playlists = signal([]); @@ -114,39 +112,18 @@ describe('ZoneContentComponent', () => { expect(component.zone_displays()).toEqual([]); }); - it('classifies playlist status by validity window and approval state', async () => { - playlist_approval_status.set({ p_wait: false }); - const component = await make(); - - expect( - component.getStatus({ id: 'p', valid_until: NOW_S - 10 } as any), - ).toBe('expired'); - expect( - component.getStatus({ id: 'p', valid_from: NOW_S + 10 } as any), - ).toBe('pending'); - expect(component.getStatus({ id: 'p_wait' } as any)).toBe( - 'awaiting_approval', - ); - expect(component.getStatus({ id: 'p' } as any)).toBeNull(); - }); - it('routes add/remove actions through the service for the selected zone', async () => { const zone = { id: 'z1', playlists: ['p1'] }; selected_zone.set(zone); const component = await make(); - const event = { - preventDefault: vi.fn(), - stopPropagation: vi.fn(), - }; component.addPlaylist(); component.addDisplay(); - component.removePlaylist(event as any, 'p1'); + component.removePlaylist('p1'); expect(add_playlist).toHaveBeenCalledWith(zone); expect(add_display).toHaveBeenCalledWith(zone); expect(remove_playlist).toHaveBeenCalledWith(zone, 'p1'); - expect(event.preventDefault).toHaveBeenCalled(); }); // The empty states use these icons diff --git a/apps/signage-manager/src/tests/zones/zones.component.spec.ts b/apps/signage-manager/src/tests/zones/zones.component.spec.ts index 05a80aebef4..dd9e075d202 100644 --- a/apps/signage-manager/src/tests/zones/zones.component.spec.ts +++ b/apps/signage-manager/src/tests/zones/zones.component.spec.ts @@ -5,6 +5,7 @@ import { TranslatePipe } from '@placeos/components'; import { PlaceZone, showZone } from '@placeos/ts-client'; import { SignageDisplayService } from '../../app/displays/signage-display.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; +import { SectionTabsComponent } from '../../app/shared/section-tabs.component'; import { SignageContextService } from '../../app/signage-context.service'; import { SignageTemplateService } from '../../app/templates/signage-template.service'; import { SignageZoneService } from '../../app/zones/signage-zone.service'; @@ -67,7 +68,10 @@ describe('ZonesSectionComponent', () => { }) .overrideComponent(ZonesSectionComponent, { set: render_template - ? { imports: [TranslatePipe], schemas: [NO_ERRORS_SCHEMA] } + ? { + imports: [TranslatePipe, SectionTabsComponent], + schemas: [NO_ERRORS_SCHEMA], + } : { template: '' }, }) .compileComponents(); From e995ff872941709389fb3c77665b0fc0da3b5309 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:58 +1100 Subject: [PATCH 08/10] refactor(signage-manager): share assignment, approval and schedule text - Display and zone services share playlist assignment. - Playlist undo has a confirm step, like template undo. - One translated schedule summary replaces the English-only copy. Times follow the app's 24-hour setting. - One confirm helper for removes shows progress and always closes. - Change keys are counters, so two changes in one millisecond are not lost. --- .../src/app/branding/branding.component.ts | 3 +- .../app/displays/display-list.component.ts | 37 +- .../src/app/displays/display-status.util.ts | 45 ++ .../src/app/displays/displays.component.ts | 7 +- .../app/displays/signage-display.service.ts | 241 ++++------- .../app/groups/signage-group-admin.service.ts | 54 +-- .../image-gen/image-gen-modal.component.ts | 15 +- .../src/app/image-gen/image-gen.service.ts | 5 +- .../app/media/media-list-header.component.ts | 9 +- .../src/app/media/media-list.component.ts | 12 +- .../src/app/media/signage-media.service.ts | 306 +++++++------- .../playlist-item-details.component.ts | 32 +- .../app/playlists/playlist-items.component.ts | 34 +- .../app/playlists/signage-playlist.service.ts | 205 +++++----- .../schedules/schedule-timeline.component.ts | 43 +- .../app/schedules/signage-schedule.util.ts | 17 +- .../src/app/shared/confirm-then-run.ts | 41 ++ .../app/shared/media-edit-modal.component.ts | 53 +-- .../src/app/shared/media-labels.util.ts | 46 +++ .../shared/media-preview-modal.component.ts | 40 +- .../playlist-approve-modal.component.ts | 33 +- .../src/app/shared/playlist-assignments.ts | 161 ++++++++ .../shared/playlist-edit-modal.component.ts | 96 +---- .../playlist-item-schedule-modal.component.ts | 43 +- ...aylist-request-approval-modal.component.ts | 35 +- .../playlist-schedule-form.component.ts | 338 +++++---------- .../src/app/shared/save-hotkey.ts | 26 ++ .../src/app/shared/schedule-summary.util.ts | 386 ++++++++++++++++++ .../src/app/shared/stored-signal.ts | 51 +++ .../shared/template-edit-modal.component.ts | 20 +- .../app/shared/template-mappings.component.ts | 9 +- .../src/app/signage-context.service.ts | 117 ++++-- .../src/app/signage-playlist.util.ts | 209 +--------- .../src/app/signage-url.util.ts | 20 + .../app/templates/signage-template.service.ts | 166 +++----- .../template-layout-list.component.ts | 7 +- .../templates/template-preview.component.ts | 13 +- .../src/app/zones/signage-zone.service.ts | 165 ++------ .../tests/media/signage-media.service.spec.ts | 2 + .../playlist-item-details.component.spec.ts | 4 +- .../signage-playlist.service.spec.ts | 36 ++ .../playlist-approve-modal.component.spec.ts | 42 +- .../playlist-edit-modal.component.spec.ts | 44 +- ...list-item-schedule-modal.component.spec.ts | 28 +- ...t-request-approval-modal.component.spec.ts | 38 +- .../shared/schedule-summary.util.spec.ts | 143 +++++++ .../template-mappings.component.spec.ts | 4 +- .../src/tests/signage-playlist.util.spec.ts | 60 +-- .../src/tests/signage-zone-search.spec.ts | 2 +- shared/assets/locale/ar.json | 16 +- shared/assets/locale/en-AU.json | 14 + shared/assets/locale/en-GB.json | 16 +- shared/assets/locale/en-US.json | 16 +- shared/assets/locale/es.json | 16 +- shared/assets/locale/fr-CA.json | 16 +- shared/assets/locale/fr.json | 16 +- shared/assets/locale/it.json | 16 +- shared/assets/locale/ja-JP.json | 16 +- shared/assets/locale/pt.json | 16 +- shared/assets/locale/zh.json | 16 +- 60 files changed, 1963 insertions(+), 1754 deletions(-) create mode 100644 apps/signage-manager/src/app/shared/confirm-then-run.ts create mode 100644 apps/signage-manager/src/app/shared/media-labels.util.ts create mode 100644 apps/signage-manager/src/app/shared/playlist-assignments.ts create mode 100644 apps/signage-manager/src/app/shared/save-hotkey.ts create mode 100644 apps/signage-manager/src/app/shared/schedule-summary.util.ts create mode 100644 apps/signage-manager/src/app/shared/stored-signal.ts create mode 100644 apps/signage-manager/src/tests/shared/schedule-summary.util.spec.ts diff --git a/apps/signage-manager/src/app/branding/branding.component.ts b/apps/signage-manager/src/app/branding/branding.component.ts index 3b02a90a1b9..bc4c19cd17c 100644 --- a/apps/signage-manager/src/app/branding/branding.component.ts +++ b/apps/signage-manager/src/app/branding/branding.component.ts @@ -28,6 +28,7 @@ import { } from '../image-gen/image-gen.types'; import { actionError } from '../image-gen/image-gen.util'; import { SignageContextService } from '../signage-context.service'; +import { uploadUrl } from '../signage-url.util'; import { brandEditingOn, canEditBrandKit } from './brand-access'; import { BRAND_FONTS, ensureBrandFont } from './brand-fonts'; @@ -495,7 +496,7 @@ export class BrandingComponent implements OnInit { public logoUrl(slot: ImageGenLogoSlot) { const id = this.logos()[slot]; - return id ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` : ''; + return id ? uploadUrl(id) : ''; } public other(slot: ImageGenLogoSlot): ImageGenLogoSlot { diff --git a/apps/signage-manager/src/app/displays/display-list.component.ts b/apps/signage-manager/src/app/displays/display-list.component.ts index 6e85f6c2d70..f8bf4b7fec5 100644 --- a/apps/signage-manager/src/app/displays/display-list.component.ts +++ b/apps/signage-manager/src/app/displays/display-list.component.ts @@ -1,4 +1,3 @@ -import { DatePipe } from '@angular/common'; import { Component, DestroyRef, @@ -15,15 +14,17 @@ import { MatInputModule } from '@angular/material/input'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; import { - DateFromPipe, IconComponent, LoadErrorComponent, TranslatePipe, } from '@placeos/components'; -import { isSameDay } from 'date-fns'; import { IntersectDirective } from '../shared/intersect.directive'; import { scrollSelectionIntoView } from '../shared/scroll-selection.util'; -import { isDisplayOnline } from './display-status.util'; +import { + displayLastSeen, + displayStatusKey, + isDisplayOnline, +} from './display-status.util'; import { SignageDisplayService } from './signage-display.service'; @Component({ @@ -184,8 +185,7 @@ export class DisplayListComponent { // Ticks each minute so a display that stops checking in turns offline // without a reload. private readonly _now = signal(Date.now()); - private readonly _date_from = new DateFromPipe(); - private readonly _date = new DatePipe(inject(LOCALE_ID)); + private readonly _locale = inject(LOCALE_ID); constructor() { const timer = setInterval(() => this._now.set(Date.now()), 60 * 1000); @@ -212,26 +212,15 @@ export class DisplayListComponent { /** Translation key for the status tooltip of a display */ public statusLabel(display: { signage_last_seen?: number }) { - if (!display.signage_last_seen) { - return 'SIGNAGE_MANAGER.DISPLAY_STATUS_NEVER_SEEN'; - } - return this.isOnline(display) - ? 'SIGNAGE_MANAGER.DISPLAY_STATUS_ONLINE' - : 'SIGNAGE_MANAGER.DISPLAY_STATUS_OFFLINE'; + return displayStatusKey(display.signage_last_seen, this._now()); } - /** - * When the display's player last checked in: minutes ago within the last - * hour, the time earlier today, and the date and time before today. - */ + /** When the display's player last checked in */ public lastSeen(display: { signage_last_seen?: number }) { - const now = this._now(); - if (!display.signage_last_seen) return ''; - const last_seen = display.signage_last_seen * 1000; - if (now - last_seen < 60 * 60 * 1000) { - return this._date_from.transform(last_seen); - } - const date_format = isSameDay(last_seen, now) ? 'shortTime' : 'short'; - return this._date.transform(last_seen, date_format) || ''; + return displayLastSeen( + display.signage_last_seen, + this._now(), + this._locale, + ); } } diff --git a/apps/signage-manager/src/app/displays/display-status.util.ts b/apps/signage-manager/src/app/displays/display-status.util.ts index 37a40b66353..97b22969f15 100644 --- a/apps/signage-manager/src/app/displays/display-status.util.ts +++ b/apps/signage-manager/src/app/displays/display-status.util.ts @@ -1,3 +1,7 @@ +import { formatDate } from '@angular/common'; +import { DateFromPipe } from '@placeos/components'; +import { isSameDay } from 'date-fns'; + /** Minutes without a player check-in before a display counts as offline */ const DISPLAY_OFFLINE_MINUTES = 5; @@ -15,3 +19,44 @@ export function isDisplayOnline( Math.abs(now - last_seen * 1000) <= DISPLAY_OFFLINE_MINUTES * 60 * 1000 ); } + +/** + * Translation key of the status of a display. The keys take the last seen + * time as `time`. + * @param last_seen Last player check-in in Unix seconds, 0 or omitted if never seen + * @param now Current time in milliseconds + */ +export function displayStatusKey( + last_seen: number | undefined, + now = Date.now(), +) { + if (!last_seen) return 'SIGNAGE_MANAGER.DISPLAY_STATUS_NEVER_SEEN'; + return isDisplayOnline(last_seen, now) + ? 'SIGNAGE_MANAGER.DISPLAY_STATUS_ONLINE' + : 'SIGNAGE_MANAGER.DISPLAY_STATUS_OFFLINE'; +} + +const date_from = new DateFromPipe(); + +/** + * When a display's player last checked in: relative within the last hour, + * the time earlier today, and the date and time before today. Empty when + * it was never seen. + * @param last_seen Last player check-in in Unix seconds + * @param now Current time in milliseconds + * @param locale Locale of the date and time + */ +export function displayLastSeen( + last_seen: number | undefined, + now: number, + locale: string, +) { + if (!last_seen) return ''; + const seen_at = last_seen * 1000; + if (now - seen_at < 60 * 60 * 1000) return date_from.transform(seen_at); + return formatDate( + seen_at, + isSameDay(seen_at, now) ? 'shortTime' : 'short', + locale, + ); +} diff --git a/apps/signage-manager/src/app/displays/displays.component.ts b/apps/signage-manager/src/app/displays/displays.component.ts index bc4e6168b68..45ad3e583bb 100644 --- a/apps/signage-manager/src/app/displays/displays.component.ts +++ b/apps/signage-manager/src/app/displays/displays.component.ts @@ -13,6 +13,7 @@ import { SectionTabsComponent, } from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; +import { signagePlayerUrl } from '../signage-url.util'; import { DisplayContentComponent } from './display-content.component'; import { DisplayHeaderComponent } from './display-header.component'; import { DisplayListComponent } from './display-list.component'; @@ -246,8 +247,10 @@ export class DisplaysSectionComponent { public readonly panel_link = computed(() => { const display = this.selected_display(); if (!display?.id) return ''; - const signage_path = this.signage_path() || '/signage'; - return `${signage_path.replace(/\/$/, '')}/#/signage/${encodeURIComponent(display.id)}?debug=true`; + return signagePlayerUrl( + this.signage_path(), + `signage/${encodeURIComponent(display.id)}`, + ); }); constructor() { diff --git a/apps/signage-manager/src/app/displays/signage-display.service.ts b/apps/signage-manager/src/app/displays/signage-display.service.ts index d894f0fc114..5b26fee8462 100644 --- a/apps/signage-manager/src/app/displays/signage-display.service.ts +++ b/apps/signage-manager/src/app/displays/signage-display.service.ts @@ -16,7 +16,6 @@ import { notifySuccess, OrganisationService, } from '@placeos/common'; -import { openConfirmModal } from '@placeos/components'; import { PlaceSystem, PlaceZone, @@ -27,8 +26,10 @@ import { SignagePlaylist, } from '@placeos/ts-client'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { PagedList } from '../shared/paged-list'; +import { PlaylistAssignments } from '../shared/playlist-assignments'; import { SignageContextService } from '../signage-context.service'; import { dialogClosed, @@ -143,15 +144,6 @@ export class SignageDisplayService { }); }); - // Local edits only bridge the gap until the lists reload. Drop them when - // the group or data changes, so a stale copy never hides newer server data - // or sends an old version with the next patch. - private readonly _clear_overrides = effect(() => { - this._context.api_group_id(); - this._context.data_change(); - untracked(() => this._display_overrides.set({})); - }); - /** * Paged queries for the picker modals, which search on their own without * disturbing the lists behind them. Null when the user may not query. @@ -186,6 +178,22 @@ export class SignageDisplayService { }, ); + private readonly _assignments = new PlaylistAssignments({ + context: this._context, + playlist_service: this._playlist_service, + dialog: this._dialog, + overrides: this._display_overrides, + selected: this.selected_display, + target: 'display_id', + already_assigned_key: 'SIGNAGE_MANAGER.SVC_PLAYLIST_IN_DISPLAY', + add_error_key: 'SIGNAGE_MANAGER.SVC_PLAYLIST_ADD_DISPLAY_ERROR', + save_playlists: (display, playlists) => + updateSignageDisplay(display.id, { + playlists, + version: display.version, + }), + }); + // The listing itself, which is whatever page(s) of the (possibly // searched) query have been loaded so far. Local edits are applied over // the loaded items, but never add a display the query didn't return. @@ -305,20 +313,6 @@ export class SignageDisplayService { () => !!this._selected_display_template_mappings.error(), ); - /** - * Keep a saved display until the lists reload. - * @returns The display with its names decoded, to use for selection - */ - private _cacheDisplay(display: PlaceSystem) { - const item = decodeEntityNames(display); - if (!item?.id) return item; - this._display_overrides.update((state) => ({ - ...state, - [item.id]: item, - })); - return item; - } - private _addDisplayToList(display: PlaceSystem) { const item = decodeEntityNames(display); if (!item?.id) return item; @@ -330,7 +324,7 @@ export class SignageDisplayService { ...cache, [item.id]: item, })); - return this._cacheDisplay(item); + return this._assignments.cache(item); } private _removeDisplayFromList(display_id: string) { @@ -421,7 +415,14 @@ export class SignageDisplayService { ) ) return false; - const result = await openConfirmModal( + const used_elsewhere = !!( + display.map_id || + display.email || + display.modules.length || + display.module_list.length + ); + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_DISPLAY_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -429,27 +430,16 @@ export class SignageDisplayService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return false; - const used_elsewhere = !!( - display.map_id || - display.email || - display.modules.length || - display.module_list.length - ); - const request: Promise = used_elsewhere - ? updateSignageDisplay(display.id, { signage: false }) - : removeSystem(display.id); - const removed = await request.then( - () => true, - () => false, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_DISPLAY_REMOVE_ERROR', + action: () => + used_elsewhere + ? updateSignageDisplay(display.id, { signage: false }) + : removeSystem(display.id), + }, ); - result.close(); - if (!removed) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_DISPLAY_REMOVE_ERROR')); - return false; - } + if (!removed) return false; this._removeDisplayFromList(display.id); if (this.selected_display()?.id === display.id) { this.selected_display.set(null); @@ -481,15 +471,9 @@ export class SignageDisplayService { } public async addDisplayToZone(zone: PlaceZone) { - if (!this._canAssign()) return; - const { DisplaySelectModalComponent } = - await import('../shared/display-select-modal.component'); - const ref = this._dialog.open(DisplaySelectModalComponent, { - panelClass: 'mobile-fullscreen', - }); - const display_id = await dialogClosed(ref); - if (!display_id) return; - const display = await this._findDisplay(display_id); + if (!this._assignments.canAssign()) return; + const display = await this._pickDisplay(); + if (display === undefined) return; if (!display) { notifyError(i18n('SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR')); return; @@ -499,23 +483,23 @@ export class SignageDisplayService { return; } const zone_ids = await this._zone_service.zoneIdsWithAncestors([zone]); - await this._saveDisplay( - display, - { zones: [...new Set([...(display.zones || []), ...zone_ids])] }, + await this._assignments.save( + () => + updateSignageDisplay(display.id, { + zones: [ + ...new Set([...(display.zones || []), ...zone_ids]), + ], + version: display.version, + }), 'SIGNAGE_MANAGER.SVC_DISPLAY_ADDED_ZONE', ); } public async addPlaylistToDisplay(display: PlaceSystem) { - if (!this._canAssign()) return; - const { PlaylistSelectModalComponent } = - await import('../shared/playlist-select-modal.component'); - const ref = this._dialog.open(PlaylistSelectModalComponent, { - panelClass: 'mobile-fullscreen', - }); - const playlist_id = await dialogClosed(ref); + if (!this._assignments.canAssign()) return; + const playlist_id = await this._assignments.pickPlaylist(); if (!playlist_id) return; - const updated = await this._addDisplayPlaylist( + const updated = await this._assignments.add( display, { playlist_id }, 'SIGNAGE_MANAGER.SVC_PLAYLIST_ADDED_DISPLAY', @@ -524,44 +508,38 @@ export class SignageDisplayService { } public async addDisplayToPlaylist(playlist: SignagePlaylist) { - if (!this._canAssign()) return; - const { DisplaySelectModalComponent } = - await import('../shared/display-select-modal.component'); - const ref = this._dialog.open(DisplaySelectModalComponent, { - panelClass: 'mobile-fullscreen', - }); - const display_id = await dialogClosed(ref); - if (!display_id) return; - const display = await this._findDisplay(display_id); + if (!this._assignments.canAssign()) return; + const display = await this._pickDisplay(); + if (display === undefined) return; if (!display) { notifyError(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_ADD_DISPLAY_ERROR')); return; } - const updated = await this._addDisplayPlaylist( + const updated = await this._assignments.add( display, { playlist_id: playlist.id, playlist }, 'SIGNAGE_MANAGER.SVC_DISPLAY_ADDED_PLAYLIST', ); - this._updateSelection(updated); + this._assignments.updateSelection(updated); } public async removeDisplayFromPlaylist( playlist: SignagePlaylist, display: PlaceSystem, ) { - const updated = await this._removeDisplayPlaylist( + const updated = await this._assignments.remove( display, playlist.id, 'SIGNAGE_MANAGER.SVC_DISPLAY_REMOVED_PLAYLIST', ); - this._updateSelection(updated); + this._assignments.updateSelection(updated); } public async removePlaylistFromDisplay( display: PlaceSystem, playlist_id: string, ) { - const updated = await this._removeDisplayPlaylist( + const updated = await this._assignments.remove( display, playlist_id, 'SIGNAGE_MANAGER.SVC_PLAYLIST_REMOVED_DISPLAY', @@ -569,105 +547,26 @@ export class SignageDisplayService { if (updated) this.selected_display.set(updated); } - private _canAssign() { - return this._context.requirePermission( - this._context.can_update(), - 'SIGNAGE_MANAGER.SVC_NO_UPDATE_ASSIGNMENTS', - ); - } - /** - * A display from the loaded list, or read by ID. The pickers search the - * backend, so the choice may be a display the list never loaded. + * Let the user pick a display. The picker searches the backend, so the + * choice may be a display the list never loaded. + * @returns The display, null when it cannot be read, or undefined when + * the picker was closed */ - private async _findDisplay(display_id: string) { + private async _pickDisplay() { + const { DisplaySelectModalComponent } = + await import('../shared/display-select-modal.component'); + const ref = this._dialog.open(DisplaySelectModalComponent, { + panelClass: 'mobile-fullscreen', + }); + const display_id = await dialogClosed(ref); + if (!display_id) return undefined; return ( this.displays().find(({ id }) => id === display_id) || showSignageDisplay(display_id).catch(() => null) ); } - /** Show a saved display in place of the selected display, when it is - * that display */ - private _updateSelection(display: PlaceSystem | null) { - if (display && this.selected_display()?.id === display.id) { - this.selected_display.set(display); - } - } - - /** - * Add a playlist to a display, after a check for takeover conflicts. - * @returns The saved display, or null when nothing was saved - */ - private async _addDisplayPlaylist( - display: PlaceSystem, - change: { playlist_id: string; playlist?: SignagePlaylist }, - success_key: string, - ) { - if (display.playlists?.includes(change.playlist_id)) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_IN_DISPLAY')); - return null; - } - const confirmed = await this._playlist_service.confirmTakeoverChange({ - ...change, - display_id: display.id, - }); - if (!confirmed) return null; - return this._saveDisplay( - display, - { playlists: [...(display.playlists || []), change.playlist_id] }, - success_key, - 'SIGNAGE_MANAGER.SVC_PLAYLIST_ADD_DISPLAY_ERROR', - ); - } - - /** - * Take a playlist off a display. - * @returns The saved display, or null when nothing was saved - */ - private async _removeDisplayPlaylist( - display: PlaceSystem, - playlist_id: string, - success_key: string, - ) { - if (!this._canAssign()) return null; - return this._saveDisplay( - display, - { - playlists: (display.playlists || []).filter( - (id) => id !== playlist_id, - ), - }, - success_key, - ); - } - - /** - * Save the playlists or zones of a display and keep the result until the - * lists reload. - * @param error_key Translation key of the error to show when it fails - * @returns The saved display, or null after showing an error when it fails - */ - private async _saveDisplay( - display: PlaceSystem, - data: Pick, 'playlists' | 'zones'>, - success_key: string, - error_key = 'SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR', - ) { - const updated = await updateSignageDisplay(display.id, { - ...data, - version: display.version, - }).catch(() => null); - if (!updated) { - notifyError(i18n(error_key)); - return null; - } - const saved = this._cacheDisplay(updated); - this._context.changed(); - notifySuccess(i18n(success_key)); - return saved; - } - constructor() { // Playlists of the selected display and its zones can be outside the // loaded pages diff --git a/apps/signage-manager/src/app/groups/signage-group-admin.service.ts b/apps/signage-manager/src/app/groups/signage-group-admin.service.ts index f291a503224..8057a5029ce 100644 --- a/apps/signage-manager/src/app/groups/signage-group-admin.service.ts +++ b/apps/signage-manager/src/app/groups/signage-group-admin.service.ts @@ -10,7 +10,7 @@ import { } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { i18n, notifyError, notifySuccess, notifyWarn } from '@placeos/common'; -import { openConfirmModal } from '@placeos/components'; +import { type ConfirmModalData, openConfirmModal } from '@placeos/components'; import { addGroup, addGroupUser, @@ -35,6 +35,7 @@ import { updateGroupUser, updateGroupZone, } from '@placeos/ts-client'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { groupHierarchy, @@ -425,7 +426,7 @@ export class SignageGroupAdminService { notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_MANAGE_GROUP')); return; } - const result = await openConfirmModal( + const removed = await this._confirmGroupRemove( { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_GROUP_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -433,13 +434,7 @@ export class SignageGroupAdminService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return; - const removed = await this._saveGroupChange( - removeGroup(group.id) - .then(() => true) - .finally(() => result.close()), + () => removeGroup(group.id), 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_GROUP', 'SIGNAGE_MANAGER.SVC_GROUP_REMOVED', ); @@ -508,7 +503,7 @@ export class SignageGroupAdminService { * remove themselves, as they can lose access to the group. */ public async removeManagedGroupUser(item: PlaceGroupUser) { if (!this._context.canManageSignageGroup(item.group_id)) return; - const result = await openConfirmModal( + await this._confirmGroupRemove( { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_USER_TITLE'), content: this._isCurrentUser(item.user_id) @@ -518,13 +513,7 @@ export class SignageGroupAdminService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return; - await this._saveGroupChange( - removeGroupUser(item.user_id, item.group_id).finally(() => - result.close(), - ), + () => removeGroupUser(item.user_id, item.group_id), 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_USER', 'SIGNAGE_MANAGER.SVC_USER_REMOVED', ); @@ -566,7 +555,7 @@ export class SignageGroupAdminService { public async removeManagedGroupZone(item: PlaceGroupZone) { if (!this._context.canManageSignageGroup(item.group_id)) return; - const result = await openConfirmModal( + await this._confirmGroupRemove( { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_ZONE_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_REMOVE_NAMED_FROM_GROUP', { @@ -574,18 +563,33 @@ export class SignageGroupAdminService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return; - await this._saveGroupChange( - removeGroupZone(item.group_id, item.zone_id).finally(() => - result.close(), - ), + () => removeGroupZone(item.group_id, item.zone_id), 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_ZONE', 'SIGNAGE_MANAGER.SVC_ZONE_REMOVED', ); } + /** + * Ask to confirm a group remove, run it, then reload the group lists. + * @returns Whether the remove was confirmed and succeeded + */ + private async _confirmGroupRemove( + confirm: ConfirmModalData, + action: () => Promise, + error_key: string, + success_key: string, + ) { + const removed = await confirmThenRun(this._dialog, confirm, { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key, + action, + }); + if (!removed) return false; + this._context.reloadSignageGroups(); + notifySuccess(i18n(success_key)); + return true; + } + /** * Wait for a group save, then reload the group lists and confirm it. * Shows an error and returns null when the save fails, so callers bound diff --git a/apps/signage-manager/src/app/image-gen/image-gen-modal.component.ts b/apps/signage-manager/src/app/image-gen/image-gen-modal.component.ts index 7ba0e3e0488..d4386825404 100644 --- a/apps/signage-manager/src/app/image-gen/image-gen-modal.component.ts +++ b/apps/signage-manager/src/app/image-gen/image-gen-modal.component.ts @@ -34,6 +34,7 @@ import { brandEditingOn, canEditBrandKit } from '../branding/brand-access'; import { SignageMediaService } from '../media/signage-media.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import { SignageContextService } from '../signage-context.service'; +import { uploadUrl } from '../signage-url.util'; import { ImageGenLayerControlsComponent, newTextBlock, @@ -723,7 +724,7 @@ export class ImageGenModalComponent implements OnDestroy { /** the image being changed, so the brief is not written blind */ public readonly source_url = computed(() => { const id = this._data.source_upload_id; - return id ? `/api/engine/v2/uploads/${encodeURIComponent(id)}/url` : ''; + return id ? uploadUrl(id) : ''; }); public readonly has_logo = computed( () => !!this._image_gen.capabilities()?.logo_layer, @@ -768,9 +769,7 @@ export class ImageGenModalComponent implements OnDestroy { job_id: id, index, upload_id: image.upload_id, - url: - image.url || - `/api/engine/v2/uploads/${encodeURIComponent(image.upload_id)}/url`, + url: image.url || uploadUrl(image.upload_id), width: image.width, height: image.height, version, @@ -1174,9 +1173,7 @@ export class ImageGenModalComponent implements OnDestroy { // briefly empty if (media.thumbnail_id) { await this._image_gen - .loadImage( - `/api/engine/v2/uploads/${media.thumbnail_id}/url`, - ) + .loadImage(uploadUrl(media.thumbnail_id)) .catch(() => ''); } this._dialog_ref.close(media); @@ -1291,9 +1288,7 @@ export class ImageGenModalComponent implements OnDestroy { private _readUpload(id?: string) { if (!id) return Promise.resolve(''); - return this._image_gen - .loadImage(`/api/engine/v2/uploads/${encodeURIComponent(id)}/url`) - .catch(() => ''); + return this._image_gen.loadImage(uploadUrl(id)).catch(() => ''); } private _name() { diff --git a/apps/signage-manager/src/app/image-gen/image-gen.service.ts b/apps/signage-manager/src/app/image-gen/image-gen.service.ts index 5022eda8afb..13d09e02ed9 100644 --- a/apps/signage-manager/src/app/image-gen/image-gen.service.ts +++ b/apps/signage-manager/src/app/image-gen/image-gen.service.ts @@ -11,6 +11,7 @@ import { loadAuthenticatedImage } from '@placeos/components'; import { showMetadata, updateMetadata } from '@placeos/ts-client'; import { flipLightness, inkIsLight } from '../branding/logo-variant'; +import { uploadUrl } from '../signage-url.util'; import { cancelSignageImageGenJob, claimSignageImageGenImage, @@ -209,9 +210,7 @@ export class ImageGenService extends AsyncHandler { throw new UserFacingError( i18n('SIGNAGE_MANAGER.IMAGE_GEN_NO_LOGO_YET'), ); - const url = await this.loadImage( - `/api/engine/v2/uploads/${encodeURIComponent(source_id)}/url`, - ); + const url = await this.loadImage(uploadUrl(source_id)); const upload_id = await this._flip(url, target); return this.saveBrandKit({ [logoKey(target)]: upload_id, diff --git a/apps/signage-manager/src/app/media/media-list-header.component.ts b/apps/signage-manager/src/app/media/media-list-header.component.ts index 77a938ea5ef..5820032814b 100644 --- a/apps/signage-manager/src/app/media/media-list-header.component.ts +++ b/apps/signage-manager/src/app/media/media-list-header.component.ts @@ -17,6 +17,7 @@ import { SignagePlugin } from '@placeos/ts-client'; import { ImageGenService } from '../image-gen/image-gen.service'; import { GroupBreadcrumbsComponent } from '../shared/group-breadcrumbs.component'; import { MediaAddModalComponent } from '../shared/media-add-modal.component'; +import { MEDIA_TYPE_LABELS } from '../shared/media-labels.util'; import { SignageContextService } from '../signage-context.service'; import { SignagePluginService } from '../signage-plugin.service'; import { normaliseWebPageUrl } from '../signage-url.util'; @@ -27,7 +28,6 @@ import { MEDIA_TYPE_FILTERS, type MediaExpiryFilter, type MediaSort, - type MediaTypeFilter, type MediaViewOptions, } from './media-view.util'; import { SignageMediaService } from './signage-media.service'; @@ -450,12 +450,7 @@ export class MediaListHeaderComponent { expiry: 'SIGNAGE_MANAGER.MEDIA_SORT_EXPIRY', }; // Same labels as the type badge on media cards - public readonly type_labels: Record = { - image: 'COMMON.IMAGE', - video: 'COMMON.VIDEO', - webpage: 'COMMON.WEBPAGE', - plugin: 'SIGNAGE_MANAGER.TYPE_PLUGIN', - }; + public readonly type_labels = MEDIA_TYPE_LABELS; public readonly expiry_labels: Record = { expiring: 'SIGNAGE_MANAGER.MEDIA_FILTER_EXPIRING', expired: 'SIGNAGE_MANAGER.STATUS_EXPIRED', diff --git a/apps/signage-manager/src/app/media/media-list.component.ts b/apps/signage-manager/src/app/media/media-list.component.ts index 07a2cf9926e..78c0a371bc4 100644 --- a/apps/signage-manager/src/app/media/media-list.component.ts +++ b/apps/signage-manager/src/app/media/media-list.component.ts @@ -27,6 +27,7 @@ import { import { SignageMedia } from '@placeos/ts-client'; import { ImageGenService } from '../image-gen/image-gen.service'; import { IntersectDirective } from '../shared/intersect.directive'; +import { mediaTypeLabel } from '../shared/media-labels.util'; import { MediaThumbnailComponent } from '../shared/media-thumbnail.component'; import { SignageContextService } from '../signage-context.service'; import { playlistMediaThumbnailUrl } from '../signage-playlist.util'; @@ -946,16 +947,7 @@ export class MediaListComponent implements OnInit { } public typeLabelKey(item: SignageMedia): string { - switch (item.media_type) { - case 'image': - return 'COMMON.IMAGE'; - case 'webpage': - return 'COMMON.WEBPAGE'; - case 'plugin': - return 'SIGNAGE_MANAGER.TYPE_PLUGIN'; - default: - return 'COMMON.VIDEO'; - } + return mediaTypeLabel(item.media_type) ?? 'COMMON.VIDEO'; } public typeBadgeClass(item: SignageMedia): string { diff --git a/apps/signage-manager/src/app/media/signage-media.service.ts b/apps/signage-manager/src/app/media/signage-media.service.ts index b6f38beb99d..498f0446401 100644 --- a/apps/signage-manager/src/app/media/signage-media.service.ts +++ b/apps/signage-manager/src/app/media/signage-media.service.ts @@ -22,11 +22,10 @@ import { UploadPermissions, UploadsService, } from '@placeos/common'; -import { loadAuthenticatedImage, openConfirmModal } from '@placeos/components'; +import { loadAuthenticatedImage } from '@placeos/components'; import { addSignageMedia, apiEndpoint, - del, post, type QueryResponse, querySignageMedia, @@ -40,15 +39,21 @@ import { updateSignageMedia, } from '@placeos/ts-client'; import type { ImageGenModalData } from '../image-gen/image-gen-modal.component'; +import { removeSignageUpload } from '../image-gen/image-gen.fn'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; import type { BulkMediaUploadItem, BulkMediaUploadModalData, } from '../shared/bulk-media-upload-modal.component'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import type { MediaEditChanges } from '../shared/media-edit-modal.component'; -import type { MediaTagModalResult } from '../shared/media-tag-modal.component'; +import type { + MediaTagModalData, + MediaTagModalResult, +} from '../shared/media-tag-modal.component'; import { PagedList } from '../shared/paged-list'; +import { storedSignal } from '../shared/stored-signal'; import { SignageContextService } from '../signage-context.service'; import { listSignageMediaTagCounts, @@ -65,6 +70,7 @@ import { } from '../signage-media-upload.util'; import { SignagePluginService } from '../signage-plugin.service'; import { dialogClosed, PAGE_SIZE, searchParam } from '../signage-service.util'; +import { uploadUrl } from '../signage-url.util'; import { convertImageToWebp, dataURLtoFile, @@ -129,9 +135,9 @@ function screenshotPageURL(url: string) { return page?.protocol === 'https:' ? page.href : ''; } -/** Absolute URL that serves an upload */ -function uploadUrl(upload_id: string) { - return `${location.origin}/api/engine/v2/uploads/${encodeURIComponent(upload_id)}/url`; +/** Absolute URL that serves an upload, to store in a media record */ +function absoluteUploadUrl(upload_id: string) { + return `${location.origin}${uploadUrl(upload_id)}`; } /** @@ -189,25 +195,6 @@ interface SignageUploadOptions { const SIGNAGE_VIEW_MODE_STORAGE_KEY = 'PlaceOS.SIGNAGE:media-view-mode:v1'; type MediaViewMode = 'grid' | 'list' | 'folder'; -function loadMediaViewMode(): MediaViewMode { - if (typeof localStorage === 'undefined') return 'grid'; - try { - const stored = localStorage.getItem(SIGNAGE_VIEW_MODE_STORAGE_KEY); - return stored === 'list' || stored === 'folder' ? stored : 'grid'; - } catch { - return 'grid'; - } -} - -function persistMediaViewMode(mode: MediaViewMode) { - if (typeof localStorage === 'undefined') return; - try { - localStorage.setItem(SIGNAGE_VIEW_MODE_STORAGE_KEY, mode); - } catch { - // Local storage can be unavailable in private browsing or restricted embeds. - } -} - /** * Signage media library: paging, tags, uploads and thumbnails, and * generated images @@ -237,8 +224,11 @@ export class SignageMediaService { ); public readonly search_term = signal(''); - public readonly media_view_mode = - signal(loadMediaViewMode()); + public readonly media_view_mode = storedSignal( + SIGNAGE_VIEW_MODE_STORAGE_KEY, + (stored) => + stored === 'list' || stored === 'folder' ? stored : 'grid', + ); // --- Media (paged incrementally as the user scrolls) --- // Searching is done by the backend so results are paged like the full @@ -596,7 +586,7 @@ export class SignageMediaService { orientation: 'landscape', ...media_item, media_id: upload_id, - media_uri: uploadUrl(upload_id), + media_uri: absoluteUploadUrl(upload_id), media_type: 'image', thumbnail_id, }); @@ -886,7 +876,7 @@ export class SignageMediaService { ...media_item, name: media_item.name || upload_file.name, media_id, - media_uri: uploadUrl(media_id), + media_uri: absoluteUploadUrl(media_id), media_type, orientation: is_landscape ? 'landscape' : 'portrait', thumbnail_id, @@ -983,50 +973,6 @@ export class SignageMediaService { } } - public async removeMedia(item: SignageMedia) { - if (!item?.id) return; - if ( - !this._context.requirePermission( - this._context.can_delete(), - 'SIGNAGE_MANAGER.SVC_NO_DELETE_MEDIA', - ) - ) - return; - const playlists = await this._playlistsUsingMedia([item.id]); - const result = await openConfirmModal( - { - title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_MEDIA_TITLE'), - content: this._withMediaUsage( - i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED_PLAIN', { - name: item.name, - }), - playlists, - ), - icon: { content: 'delete' }, - }, - this._dialog, - ); - if (result.reason !== 'done') return; - result.loading(i18n('SIGNAGE_MANAGER.SVC_MEDIA_REMOVING')); - try { - await removeSignageMedia( - item.id, - this._context.groupQueryParams({}), - ); - } catch { - result.close(); - notifyError(i18n('SIGNAGE_MANAGER.SVC_ERR_REMOVE_MEDIA')); - return; - } - this._removeMediaFromList([item.id]); - await this._removeDeletedMediaFromPlaylists( - [item.id], - playlists.map(({ id }) => id), - ); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_MEDIA_REMOVED')); - result.close(); - } - /** * Take deleted media out of the playlists that held it. The media is * already gone, so a failure here only warns. @@ -1045,9 +991,37 @@ export class SignageMediaService { } } + public async removeMedia(item: SignageMedia) { + if (!item?.id) return false; + return this._removeMediaItems( + [item], + i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED_PLAIN', { name: item.name }), + ); + } + public async removeMediaItems(items: SignageMedia[]) { const media_items = items.filter((item) => !!item?.id); if (!media_items.length) return false; + return this._removeMediaItems( + media_items, + i18n( + 'SIGNAGE_MANAGER.SVC_DELETE_SELECTED_MEDIA', + { count: media_items.length }, + media_items.length, + ), + ); + } + + /** + * Delete media after the user confirms, and take it out of the + * playlists that held it. + * @param content Text of the confirm modal, before the playlist usage + * @returns Whether every item was deleted + */ + private async _removeMediaItems( + media_items: SignageMedia[], + content: string, + ) { if ( !this._context.requirePermission( this._context.can_delete(), @@ -1057,42 +1031,44 @@ export class SignageMediaService { return false; const media_ids = media_items.map((item) => item.id); const playlists = await this._playlistsUsingMedia(media_ids); - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_MEDIA_TITLE'), content: this._withMediaUsage( - i18n( - 'SIGNAGE_MANAGER.SVC_DELETE_SELECTED_MEDIA', - { count: media_items.length }, - media_items.length, - ), + content, playlists, media_items.length, ), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return false; - result.loading(i18n('SIGNAGE_MANAGER.SVC_MEDIA_REMOVING')); - const results = await settleEach(media_ids, (id) => - removeSignageMedia(id, this._context.groupQueryParams({})), - ); - const removed_ids = media_ids.filter( - (_, index) => results[index].status === 'fulfilled', + { + loading_key: 'SIGNAGE_MANAGER.SVC_MEDIA_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_MEDIA', + action: async () => { + const results = await settleEach(media_ids, (id) => + removeSignageMedia( + id, + this._context.groupQueryParams({}), + ), + ); + const removed_ids = media_ids.filter( + (_, index) => results[index].status === 'fulfilled', + ); + if (removed_ids.length) { + this._removeMediaFromList(removed_ids); + await this._removeDeletedMediaFromPlaylists( + removed_ids, + playlists.map(({ id }) => id), + ); + } + if (removed_ids.length < media_ids.length) { + throw new Error('Some media was not deleted'); + } + }, + }, ); - if (removed_ids.length) { - this._removeMediaFromList(removed_ids); - await this._removeDeletedMediaFromPlaylists( - removed_ids, - playlists.map(({ id }) => id), - ); - } - result.close(); - if (removed_ids.length < media_ids.length) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_ERR_REMOVE_MEDIA')); - return false; - } + if (!removed) return false; notifySuccess(i18n('SIGNAGE_MANAGER.SVC_MEDIA_REMOVED')); return true; } @@ -1163,84 +1139,86 @@ export class SignageMediaService { } public async renameMediaTag(tag: string, count: number) { - if (!tag) return false; + const result = await this._openMediaTagModal({ + action: 'rename', + tag, + count, + can_delete_media: false, + }); + if (result?.action !== 'rename') return false; + const group_id = this._context.api_group_id(); + return this._saveMediaTagChange( + () => + renameSignageMediaTag({ + current_tag: tag, + new_tag: result.new_tag, + ...(group_id ? { group_id } : {}), + }), + 'SIGNAGE_MANAGER.SVC_MEDIA_TAG_RENAMED', + ); + } + + public async removeMediaTag(tag: string, count: number) { + const result = await this._openMediaTagModal({ + action: 'remove', + tag, + count, + can_delete_media: this._context.can_delete_tagged_media(), + }); + if (result?.action !== 'remove') return false; if ( + result.remove_media && !this._context.requirePermission( - this._context.can_update_media_tags(), - 'SIGNAGE_MANAGER.SVC_NO_UPDATE_MEDIA', + this._context.can_delete_tagged_media(), + 'SIGNAGE_MANAGER.SVC_NO_DELETE_MEDIA', ) ) return false; - const { MediaTagModalComponent } = - await import('../shared/media-tag-modal.component'); - const ref = this._dialog.open(MediaTagModalComponent, { - data: { - action: 'rename', - tag, - count, - can_delete_media: false, - }, - width: 'min(28rem, calc(100vw - 2rem))', - }); - const result = await dialogClosed(ref); - if (result?.action !== 'rename') return false; - try { - const group_id = this._context.api_group_id(); - await renameSignageMediaTag({ - current_tag: tag, - new_tag: result.new_tag, - ...(group_id ? { group_id } : {}), - }); - } catch (error) { - notifyError( - i18n('SIGNAGE_MANAGER.SVC_MEDIA_TAG_ERROR', { - error: error instanceof Error ? error.message : `${error}`, + const group_id = this._context.api_group_id(); + return this._saveMediaTagChange( + () => + removeSignageMediaTag({ + tag, + ...(result.remove_media ? { remove_media: true } : {}), + ...(group_id ? { group_id } : {}), }), - ); - return false; - } - this._context.changed(); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_MEDIA_TAG_RENAMED')); - return true; + 'SIGNAGE_MANAGER.SVC_MEDIA_TAG_REMOVED', + ); } - public async removeMediaTag(tag: string, count: number) { - if (!tag) return false; + /** + * Ask how to change a media tag. Warns and gives undefined when the user + * cannot change tags. + */ + private async _openMediaTagModal(data: MediaTagModalData) { + if (!data.tag) return undefined; if ( !this._context.requirePermission( this._context.can_update_media_tags(), 'SIGNAGE_MANAGER.SVC_NO_UPDATE_MEDIA', ) ) - return false; + return undefined; const { MediaTagModalComponent } = await import('../shared/media-tag-modal.component'); const ref = this._dialog.open(MediaTagModalComponent, { - data: { - action: 'remove', - tag, - count, - can_delete_media: this._context.can_delete_tagged_media(), - }, + data, width: 'min(28rem, calc(100vw - 2rem))', }); - const result = await dialogClosed(ref); - if (result?.action !== 'remove') return false; - if ( - result.remove_media && - !this._context.requirePermission( - this._context.can_delete_tagged_media(), - 'SIGNAGE_MANAGER.SVC_NO_DELETE_MEDIA', - ) - ) - return false; + return dialogClosed(ref); + } + + /** + * Save a media tag change and reload the lists. Shows the error of the + * request when it fails. + * @returns Whether the change was saved + */ + private async _saveMediaTagChange( + request: () => Promise, + success_key: string, + ) { try { - const group_id = this._context.api_group_id(); - await removeSignageMediaTag({ - tag, - ...(result.remove_media ? { remove_media: true } : {}), - ...(group_id ? { group_id } : {}), - }); + await request(); } catch (error) { notifyError( i18n('SIGNAGE_MANAGER.SVC_MEDIA_TAG_ERROR', { @@ -1250,7 +1228,7 @@ export class SignageMediaService { return false; } this._context.changed(); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_MEDIA_TAG_REMOVED')); + notifySuccess(i18n(success_key)); return true; } @@ -1297,7 +1275,7 @@ export class SignageMediaService { /** Read an upload, with the auth the uploads route needs */ private async _fetchUpload(upload_id: string) { const source = await loadAuthenticatedImage( - uploadUrl(upload_id), + absoluteUploadUrl(upload_id), '/api/engine/v2/uploads', ); return (await fetch(source)).blob(); @@ -1337,9 +1315,7 @@ export class SignageMediaService { return ''; } finally { if (screenshot_id) { - del( - `${apiEndpoint()}/uploads/${encodeURIComponent(screenshot_id)}`, - ).catch(() => undefined); + removeSignageUpload(screenshot_id).catch(() => undefined); } } } @@ -1371,8 +1347,4 @@ export class SignageMediaService { ), }; } - - constructor() { - effect(() => persistMediaViewMode(this.media_view_mode())); - } } diff --git a/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts b/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts index b12b6d4b70f..d38bc21f134 100644 --- a/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts +++ b/apps/signage-manager/src/app/playlists/playlist-item-details.component.ts @@ -10,19 +10,17 @@ import { MatRippleModule } from '@angular/material/core'; import { MatTabsModule } from '@angular/material/tabs'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; +import { LocaleService } from '@placeos/common'; import { IconComponent, MediaDurationPipe, TranslatePipe, } from '@placeos/components'; -import { - MediaAnimation, - PlaceSystem, - PlaceZone, - SignagePlaylist, -} from '@placeos/ts-client'; +import { PlaceSystem, PlaceZone, SignagePlaylist } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; import { playlistSchedules } from '../schedules/signage-schedule.util'; +import { animationLabel } from '../shared/media-labels.util'; +import { scheduleSummary } from '../shared/schedule-summary.util'; import { SignageSharedWithComponent } from '../shared/signage-shared-with.component'; import { SignageContextService } from '../signage-context.service'; import { SignageInventoryService } from '../signage-inventory.service'; @@ -30,7 +28,6 @@ import { playlistAnimation, playlistNextPlayLabels, playlistScheduleExpiryTooltip, - playlistScheduleLabel, } from '../signage-playlist.util'; import { SignageZoneService } from '../zones/signage-zone.service'; import { SignagePlaylistService } from './signage-playlist.service'; @@ -621,6 +618,7 @@ import { SignagePlaylistService } from './signage-playlist.service'; }) export class PlaylistItemDetailsComponent { private readonly _context = inject(SignageContextService); + private readonly _locale = inject(LocaleService); private readonly _display_service = inject(SignageDisplayService); private readonly _inventory_service = inject(SignageInventoryService); private readonly _playlist_service = inject(SignagePlaylistService); @@ -680,23 +678,7 @@ export class PlaylistItemDetailsComponent { public readonly animation_label = computed(() => { const pl = this.playlist(); - if (!pl) return 'COMMON.DEFAULT'; - switch (playlistAnimation(pl)) { - case MediaAnimation.Cut: - return 'SIGNAGE_MANAGER.ANIM_CUT'; - case MediaAnimation.CrossFade: - return 'SIGNAGE_MANAGER.ANIM_CROSS_FADE'; - case MediaAnimation.SlideTop: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP'; - case MediaAnimation.SlideLeft: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT'; - case MediaAnimation.SlideRight: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT'; - case MediaAnimation.SlideBottom: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM'; - default: - return 'COMMON.DEFAULT'; - } + return pl ? animationLabel(playlistAnimation(pl)) : 'COMMON.DEFAULT'; }); public readonly valid_from = computed(() => { @@ -715,7 +697,7 @@ export class PlaylistItemDetailsComponent { const pl = this.playlist(); if (!pl || pl.distribution) return []; return playlistSchedules(pl).map((schedule) => - playlistScheduleLabel(schedule), + scheduleSummary(schedule, this._locale.locale), ); }); diff --git a/apps/signage-manager/src/app/playlists/playlist-items.component.ts b/apps/signage-manager/src/app/playlists/playlist-items.component.ts index b1df79f6b25..3e633f84352 100644 --- a/apps/signage-manager/src/app/playlists/playlist-items.component.ts +++ b/apps/signage-manager/src/app/playlists/playlist-items.component.ts @@ -19,7 +19,7 @@ import { MatRippleModule } from '@angular/material/core'; import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { i18n } from '@placeos/common'; +import { i18n, LocaleService } from '@placeos/common'; import { IconComponent, LoadErrorComponent, @@ -33,12 +33,13 @@ import { } from '@placeos/ts-client'; import { MediaPreviewService } from '../media/media-preview.service'; import { MediaThumbnailComponent } from '../shared/media-thumbnail.component'; +import { scheduleSummary } from '../shared/schedule-summary.util'; +import { storedSignal } from '../shared/stored-signal'; import { SignageContextService } from '../signage-context.service'; import { playlistLoopDuration, playlistNextPlayTime, playlistScheduleExpiryTooltip, - playlistScheduleLabel, playlistScheduleNextPlayLabels, } from '../signage-playlist.util'; import { PlaylistActionsComponent } from './playlist-actions.component'; @@ -46,24 +47,6 @@ import { SignagePlaylistService } from './signage-playlist.service'; const NEXT_PLAY_SORT_STORAGE_KEY = 'PlaceOS.SIGNAGE:playlist-next-play-sort:v1'; -function loadNextPlaySort() { - if (typeof localStorage === 'undefined') return false; - try { - return localStorage.getItem(NEXT_PLAY_SORT_STORAGE_KEY) === 'true'; - } catch { - return false; - } -} - -function persistNextPlaySort(sorted: boolean) { - if (typeof localStorage === 'undefined') return; - try { - localStorage.setItem(NEXT_PLAY_SORT_STORAGE_KEY, `${sorted}`); - } catch { - // Local storage can be unavailable in private browsing or restricted embeds. - } -} - @Component({ selector: 'playlist-items', template: ` @@ -748,6 +731,7 @@ function persistNextPlaySort(sorted: boolean) { }) export class PlaylistItemsComponent { private readonly _context = inject(SignageContextService); + private readonly _locale = inject(LocaleService); private readonly _media_preview = inject(MediaPreviewService); private readonly _playlist_service = inject(SignagePlaylistService); private readonly _injector = inject(Injector); @@ -778,7 +762,10 @@ export class PlaylistItemsComponent { * Shows distribution items by when they next play. Only changes the view. * Kept in the browser, so distribution playlists open sorted once chosen. */ - public readonly sort_by_next_play = signal(loadNextPlaySort()); + public readonly sort_by_next_play = storedSignal( + NEXT_PLAY_SORT_STORAGE_KEY, + (stored) => stored === 'true', + ); /** * Time the next plays are counted from. Set when the sort is turned on * and when the playlist or its schedules change, so rows do not move @@ -897,10 +884,11 @@ export class PlaylistItemsComponent { public toggleNextPlaySort() { this.sort_by_next_play.update((sorted) => !sorted); - persistNextPlaySort(this.sort_by_next_play()); } - public scheduleLabel = playlistScheduleLabel; + public scheduleLabel(schedule: Partial) { + return scheduleSummary(schedule, this._locale.locale); + } /** Next plays tooltip of each item schedule. Updates when the schedules change. */ public readonly schedule_tooltips = computed( diff --git a/apps/signage-manager/src/app/playlists/signage-playlist.service.ts b/apps/signage-manager/src/app/playlists/signage-playlist.service.ts index 36bc34451ea..94396d344c2 100644 --- a/apps/signage-manager/src/app/playlists/signage-playlist.service.ts +++ b/apps/signage-manager/src/app/playlists/signage-playlist.service.ts @@ -23,7 +23,6 @@ import { listSignagePlaylistApprovers, listSignagePlaylistMedia, mediaThumbnail, - PlaceCurrentGroup, type QueryResponse, querySignagePlaylists, removeSignagePlaylist, @@ -49,10 +48,12 @@ import { hasTakeoverSchedule, type ScheduleItem, } from '../schedules/signage-schedule.util'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { PagedList } from '../shared/paged-list'; import { byName } from '../shared/paged-search'; import type { PlaylistRequestApprovalModalResult } from '../shared/playlist-request-approval-modal.component'; +import { storedSignal } from '../shared/stored-signal'; import { SignageContextService } from '../signage-context.service'; import { type SignageInventory, @@ -98,30 +99,6 @@ function escapeHtml(text: string) { .replace(/"/g, '"'); } -function loadPlaylistMetaSessionCache(): Record { - if (typeof sessionStorage === 'undefined') return {}; - try { - const stored_value = sessionStorage.getItem(PLAYLIST_META_SESSION_KEY); - return stored_value ? JSON.parse(stored_value) : {}; - } catch { - return {}; - } -} - -function persistPlaylistMetaSessionCache( - cache: Record, -) { - if (typeof sessionStorage === 'undefined') return; - try { - sessionStorage.setItem( - PLAYLIST_META_SESSION_KEY, - JSON.stringify(cache), - ); - } catch { - // Session cache persistence is opportunistic only. - } -} - /** * Signage playlists, the selected playlist and its media, approvals and * takeover checks @@ -364,9 +341,18 @@ export class SignagePlaylistService { computation: () => null, }); - private readonly _playlist_meta_state = signal< + // Kept for the session, so list rows show thumbnails and approval state + // before their requests finish + private readonly _playlist_meta_state = storedSignal< Record - >(loadPlaylistMetaSessionCache()); + >( + PLAYLIST_META_SESSION_KEY, + (stored) => (stored ? JSON.parse(stored) : {}), + { + storage: 'session', + serialise: JSON.stringify, + }, + ); private readonly _playlist_meta_loading = signal>( {}, ); @@ -466,7 +452,8 @@ export class SignagePlaylistService { .sort(byName); } - private readonly _playlist_change = signal(Date.now()); + // A counter, so two changes in the same millisecond still reload + private readonly _playlist_change = signal(0); // Keyed by id, so a new copy of the selected playlist from a list reload // does not load the media again. @@ -597,7 +584,8 @@ export class SignagePlaylistService { ) ) return; - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_PLAYLIST_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -605,16 +593,13 @@ export class SignagePlaylistService { }), icon: { content: 'delete' }, }, - this._dialog, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_ERR_REMOVE_PLAYLIST', + action: () => removeSignagePlaylist(playlist.id), + }, ); - if (result.reason !== 'done') return; - try { - await removeSignagePlaylist(playlist.id); - } catch { - result.close(); - notifyError(i18n('SIGNAGE_MANAGER.SVC_ERR_REMOVE_PLAYLIST')); - return; - } + if (!removed) return; if (this.selected_playlist()?.id === playlist.id) { this.selected_playlist.set(null); this.selected_playlist_item.set(null); @@ -623,7 +608,6 @@ export class SignagePlaylistService { this._removePlaylistMediaState(playlist.id); this._context.changed(); notifySuccess(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_REMOVED')); - result.close(); } /** Whether a playlist is being duplicated */ @@ -734,58 +718,42 @@ export class SignagePlaylistService { public async requestPlaylistApproval(playlist: SignagePlaylist) { if (!playlist?.id) return; - if (this.playlist_approval_request_loading()) return; - if (this._context.can_approve()) { - await this.approvePlaylist(playlist); - return; - } - let approvers: SignagePlaylistApprover[] = []; - let group: PlaceCurrentGroup | null = null; - this.playlist_approval_request_loading.set(true); - try { - [group] = await this._context.groupsHolding( - playlist.id, - (group_id) => querySignagePlaylists({ group_id, limit: 500 }), - ); - if (!group) { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_PLAYLIST')); - return; - } - approvers = - ((await listSignagePlaylistApprovers( - group.group.id, - )) as SignagePlaylistApprover[]) || []; - } catch { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_APPROVERS')); - } finally { - this.playlist_approval_request_loading.set(false); - } - if (!group) return; - const { PlaylistRequestApprovalModalComponent } = - await import('../shared/playlist-request-approval-modal.component'); - const ref = this._dialog.open(PlaylistRequestApprovalModalComponent, { - data: { - playlist, - approvers, + await this._context.requestApproval({ + item_id: playlist.id, + loading: this.playlist_approval_request_loading, + approve: () => this.approvePlaylist(playlist), + query: (group_id) => + querySignagePlaylists({ group_id, limit: 500 }), + list_approvers: (group_id) => + listSignagePlaylistApprovers(group_id) as Promise< + SignagePlaylistApprover[] + >, + pick: async (approvers) => { + const { PlaylistRequestApprovalModalComponent } = + await import('../shared/playlist-request-approval-modal.component'); + const ref = this._dialog.open( + PlaylistRequestApprovalModalComponent, + { + data: { playlist, approvers }, + panelClass: 'mobile-fullscreen', + }, + ); + return dialogClosed(ref); }, - panelClass: 'mobile-fullscreen', + request: (group_id, message, approver_id) => + requestApprovalSignagePlaylist( + playlist.id, + group_id, + message, + approver_id, + ), + requested: () => + this.setPlaylistApprovalStatus(playlist.id, false, true), + no_groups_key: 'SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_PLAYLIST', + no_approvers_key: 'SIGNAGE_MANAGER.SVC_NO_APPROVERS', + error_key: 'SIGNAGE_MANAGER.SVC_ERR_REQUEST_APPROVAL', + success_key: 'SIGNAGE_MANAGER.SVC_APPROVAL_REQUESTED', }); - const result: PlaylistRequestApprovalModalResult | undefined = - await dialogClosed(ref); - if (!result) return; - try { - await requestApprovalSignagePlaylist( - playlist.id, - group.group.id, - result.message || '', - result.approver_id || '', - ); - } catch { - notifyError(i18n('SIGNAGE_MANAGER.SVC_ERR_REQUEST_APPROVAL')); - return; - } - this.setPlaylistApprovalStatus(playlist.id, false, true); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_APPROVAL_REQUESTED')); } public async removeMediaFromPlaylist( @@ -1000,17 +968,59 @@ export class SignagePlaylistService { }); const result = await dialogClosed(ref); if (result) { - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); this._context.changed(); } return !!result; } + /** + * Put the media of a playlist back to an older version, after the user + * confirms. Used by the approval modals. + * @param previous_version Version of the media to restore + * @returns Whether the media was restored + */ + public async undoPlaylistChanges( + playlist: SignagePlaylist, + previous_version: SignagePlaylistMedia, + ) { + if ( + !this._context.requirePermission( + this._context.can_update(), + 'SIGNAGE_MANAGER.SVC_NO_UPDATE_PLAYLISTS', + ) + ) + return false; + const items = previous_version.items; + if (!items) return false; + const undone = await confirmThenRun( + this._dialog, + { + title: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'), + content: i18n('SIGNAGE_MANAGER.PLAYLIST_REVERT_CONFIRM', { + name: playlist.name, + }), + confirm_text: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'), + icon: { content: 'undo' }, + }, + { + loading_key: 'SIGNAGE_MANAGER.UNDOING_CHANGES', + error_key: 'SIGNAGE_MANAGER.PLAYLIST_REVERT_ERROR', + action: () => updateSignagePlaylistMedia(playlist.id, items), + }, + ); + if (!undone) return false; + this.setPlaylistApprovalStatus(playlist.id, false); + notifySuccess(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERTED')); + this.refreshPlaylist(playlist.id); + return true; + } + public refreshPlaylist(playlist_id: string) { if (!playlist_id) return; this._removePlaylistMediaState(playlist_id); if (this.selected_playlist()?.id === playlist_id) { - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); } this._context.changed(); } @@ -1048,7 +1058,7 @@ export class SignagePlaylistService { }); const result = await dialogClosed(ref); if (!result) return false; - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); return true; } @@ -1070,7 +1080,7 @@ export class SignagePlaylistService { await updateSignagePlaylistMedia(playlist_id, list); this._setPlaylistMediaState(playlist_id, list, { approved: false }); notifySuccess(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_UPDATED')); - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); } /** @@ -1380,7 +1390,7 @@ export class SignagePlaylistService { this.selected_playlist_item.set(null); this.selected_playlist_item_index.set(null); } - this._playlist_change.set(Date.now()); + this._playlist_change.update((count) => count + 1); } private _updatePlaylistMetaState( @@ -1388,12 +1398,7 @@ export class SignagePlaylistService { state: Record, ) => Record, ) { - let next_state: Record = {}; - this._playlist_meta_state.update((state) => { - next_state = updater(state); - return next_state; - }); - persistPlaylistMetaSessionCache(next_state); + this._playlist_meta_state.update(updater); } /** Playlist IDs with a boolean meta flag, mapped to its value */ diff --git a/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts b/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts index c1e653b120d..c8bf12335f3 100644 --- a/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts +++ b/apps/signage-manager/src/app/schedules/schedule-timeline.component.ts @@ -1,15 +1,13 @@ -import { DatePipe } from '@angular/common'; import { Component, computed, inject, input, LOCALE_ID } from '@angular/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; import { i18n } from '@placeos/common'; +import { IconComponent, TranslatePipe } from '@placeos/components'; import { - DateFromPipe, - IconComponent, - TranslatePipe, -} from '@placeos/components'; -import { isSameDay } from 'date-fns'; -import { isDisplayOnline } from '../displays/display-status.util'; + displayLastSeen, + displayStatusKey, + isDisplayOnline, +} from '../displays/display-status.util'; import { MINUTES_PER_DAY, ScheduleTimelineRow, @@ -348,9 +346,7 @@ function dayPercent(minutes: number) { imports: [MatTooltipModule, RouterLink, IconComponent, TranslatePipe], }) export class ScheduleTimelineComponent { - private readonly _date_from = new DateFromPipe(); private readonly _locale = inject(LOCALE_ID); - private readonly _date = new DatePipe(this._locale); public readonly rows = input([]); public readonly view_tab = input<'displays' | 'zones'>('displays'); @@ -399,37 +395,14 @@ export class ScheduleTimelineComponent { if (this.view_tab() !== 'displays') return statuses; for (const row of this.rows()) { const online = isDisplayOnline(row.signage_last_seen, now); - const label = !row.signage_last_seen - ? i18n('SIGNAGE_MANAGER.DISPLAY_STATUS_NEVER_SEEN') - : i18n( - online - ? 'SIGNAGE_MANAGER.DISPLAY_STATUS_ONLINE' - : 'SIGNAGE_MANAGER.DISPLAY_STATUS_OFFLINE', - { - time: this._lastSeen( - row.signage_last_seen * 1000, - now, - ), - }, - ); + const label = i18n(displayStatusKey(row.signage_last_seen, now), { + time: displayLastSeen(row.signage_last_seen, now, this._locale), + }); statuses.set(row.id, { online, label }); } return statuses; }); - /** - * When a display last checked in: relative within the last hour, the - * time earlier today, and the date and time before today. Matches the - * display list. - */ - private _lastSeen(last_seen: number, now: number) { - if (now - last_seen < 60 * 60 * 1000) { - return this._date_from.transform(last_seen); - } - const date_format = isSameDay(last_seen, now) ? 'shortTime' : 'short'; - return this._date.transform(last_seen, date_format) || ''; - } - private _blockView( row: ScheduleTimelineRow, block: TimelineBlock, diff --git a/apps/signage-manager/src/app/schedules/signage-schedule.util.ts b/apps/signage-manager/src/app/schedules/signage-schedule.util.ts index d90ae56a61e..244b45442b0 100644 --- a/apps/signage-manager/src/app/schedules/signage-schedule.util.ts +++ b/apps/signage-manager/src/app/schedules/signage-schedule.util.ts @@ -13,9 +13,11 @@ import { isSameDay, startOfDay, } from 'date-fns'; +import { formatClockTime } from '../shared/schedule-summary.util'; import { createScheduleMaskFilter, playOnceStart, + playPeriodMinutes, } from '../signage-playlist.util'; const BLOCK_PALETTE = [ @@ -32,7 +34,6 @@ export const DAY_COUNT = 7; export const MINUTES_PER_DAY = 1440; /** Shortest block length that the timeline shows, so short plays stay visible */ const MIN_VISIBLE_MINUTES = 15; -const DEFAULT_PLAYLIST_DURATION = 24 * 60; export interface ScheduleBlock { playlist: SignagePlaylist; @@ -113,23 +114,11 @@ export function hasTakeoverSchedule(playlist: SignagePlaylist) { ); } -function playPeriodMinutes(schedule: Partial) { - return Number.isFinite(schedule.play_period) - ? Math.max(0, schedule.play_period || 0) - : DEFAULT_PLAYLIST_DURATION; -} - -function formatTime(minutes: number): string { - const hours = Math.floor(minutes / 60) % 24; - const mins = minutes % 60; - return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; -} - function formatTimeRange( start_minutes: number, duration_minutes: number, ): string { - return `${formatTime(start_minutes)} – ${formatTime(start_minutes + duration_minutes)}`; + return `${formatClockTime(start_minutes)} – ${formatClockTime(start_minutes + duration_minutes)}`; } /** Label and all day flag of a block. A length of 0 plays the playlist once. */ diff --git a/apps/signage-manager/src/app/shared/confirm-then-run.ts b/apps/signage-manager/src/app/shared/confirm-then-run.ts new file mode 100644 index 00000000000..28fbc34df6a --- /dev/null +++ b/apps/signage-manager/src/app/shared/confirm-then-run.ts @@ -0,0 +1,41 @@ +import { MatDialog } from '@angular/material/dialog'; +import { i18n, notifyError } from '@placeos/common'; +import { + type ConfirmModalData, + ConfirmModalComponent, + openConfirmModal, +} from '@placeos/components'; + +/** + * Ask the user to confirm, then run `action` while the confirm modal shows + * a loading message. The modal cannot be closed while `action` runs, as that + * would not stop the request, and it always closes when `action` ends. + * Shows the error of `error_key` when `action` throws. + * @returns Whether the user confirmed and `action` succeeded + */ +export async function confirmThenRun( + dialog: MatDialog, + confirm: ConfirmModalData, + options: { + loading_key: string; + error_key: string; + action: () => Promise; + }, +) { + const result = await openConfirmModal(confirm, dialog); + if (result.reason !== 'done') return false; + result.loading(i18n(options.loading_key)); + const confirm_ref = dialog.openDialogs?.at(-1); + if (confirm_ref?.componentInstance instanceof ConfirmModalComponent) { + confirm_ref.disableClose = true; + } + try { + await options.action(); + return true; + } catch { + notifyError(i18n(options.error_key)); + return false; + } finally { + result.close(); + } +} diff --git a/apps/signage-manager/src/app/shared/media-edit-modal.component.ts b/apps/signage-manager/src/app/shared/media-edit-modal.component.ts index 8b4c6ea6d00..a390ac863e4 100644 --- a/apps/signage-manager/src/app/shared/media-edit-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-edit-modal.component.ts @@ -1,7 +1,6 @@ import { Component, computed, - DestroyRef, effect, inject, OnDestroy, @@ -15,18 +14,13 @@ import { submit, validate, } from '@angular/forms/signals'; -import { - MAT_DIALOG_DATA, - MatDialog, - MatDialogRef, -} from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSliderModule } from '@angular/material/slider'; import { - HotkeysService, i18n, mediaAnimation, notifyError, @@ -71,6 +65,8 @@ import { normaliseWebPageUrl, webPageFrameUrl, } from '../signage-url.util'; +import { ANIMATION_OPTIONS } from './media-labels.util'; +import { saveHotkey } from './save-hotkey'; import { SignageSharedWithComponent } from './signage-shared-with.component'; /** Media fields the modal saves. `thumbnail_image` is a picked image as a @@ -106,33 +102,6 @@ export interface MediaEditModalData { preview: (item: SignageMedia) => void; } -/** Animations the user can pick, with their label keys */ -const ANIMATION_OPTIONS: { value: MediaAnimation; label: string }[] = [ - { value: MediaAnimation.Default, label: 'COMMON.DEFAULT' }, - { value: MediaAnimation.Cut, label: 'SIGNAGE_MANAGER.ANIM_CUT' }, - { - value: MediaAnimation.CrossFade, - label: 'SIGNAGE_MANAGER.ANIM_CROSS_FADE', - }, - { value: MediaAnimation.SlideTop, label: 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP' }, - { - value: MediaAnimation.SlideLeft, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT', - }, - { - value: MediaAnimation.SlideRight, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT', - }, - { - value: MediaAnimation.SlideBottom, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM', - }, -]; - -/** Focus targets where a plain key press belongs to the control, not a hotkey */ -const HOTKEY_BLOCKING_FOCUS = - 'select, mat-select, [role="combobox"], [role="listbox"], [role="option"], [role="menu"], [role="menuitem"]'; - interface MediaEditFormModel { name: string; media_uri: string; @@ -607,7 +576,6 @@ export class MediaEditModalComponent implements OnDestroy { private _data = inject(MAT_DIALOG_DATA); private _dialog_ref = inject>(MatDialogRef); - private readonly _dialog = inject(MatDialog); private readonly _schema_form = viewChild(SchemaFormComponent); @@ -744,10 +712,7 @@ export class MediaEditModalComponent implements OnDestroy { } constructor() { - const save_hotkey = inject(HotkeysService).listen(['KeyS'], () => { - if (this._canUseSaveHotkey()) this.saveMedia(); - }); - inject(DestroyRef).onDestroy(() => save_hotkey?.unsubscribe()); + saveHotkey(this._dialog_ref, () => this.saveMedia()); if (this.media_type === 'webpage') { this.preview_url.set( webPageFrameUrl(this.item.media_uri || this.item.media_url), @@ -786,16 +751,6 @@ export class MediaEditModalComponent implements OnDestroy { }); } - /** - * The save hotkey is a plain key, so it only acts while this modal is the - * top-most dialog and focus is not on a control that takes key presses. - */ - private _canUseSaveHotkey() { - const dialogs = this._dialog.openDialogs; - if (dialogs[dialogs.length - 1] !== this._dialog_ref) return false; - return !document.activeElement?.closest(HOTKEY_BLOCKING_FOCUS); - } - private _resolvePluginSchema(): Record | null { return ( pluginSchema(this.plugin_embed_schema()) || diff --git a/apps/signage-manager/src/app/shared/media-labels.util.ts b/apps/signage-manager/src/app/shared/media-labels.util.ts new file mode 100644 index 00000000000..26045d80bda --- /dev/null +++ b/apps/signage-manager/src/app/shared/media-labels.util.ts @@ -0,0 +1,46 @@ +import { MediaAnimation, type MediaType } from '@placeos/ts-client'; +import { type MediaTypeFilter } from '../media/media-view.util'; + +/** Label keys of the media types the library shows */ +export const MEDIA_TYPE_LABELS: Record = { + image: 'COMMON.IMAGE', + video: 'COMMON.VIDEO', + webpage: 'COMMON.WEBPAGE', + plugin: 'SIGNAGE_MANAGER.TYPE_PLUGIN', +}; + +/** Label key of a media type. Undefined for types the library does not show. */ +export function mediaTypeLabel(type: MediaType): string | undefined { + return MEDIA_TYPE_LABELS[type as MediaTypeFilter]; +} + +/** Animations the user can pick, with their label keys */ +export const ANIMATION_OPTIONS: { value: MediaAnimation; label: string }[] = [ + { value: MediaAnimation.Default, label: 'COMMON.DEFAULT' }, + { value: MediaAnimation.Cut, label: 'SIGNAGE_MANAGER.ANIM_CUT' }, + { + value: MediaAnimation.CrossFade, + label: 'SIGNAGE_MANAGER.ANIM_CROSS_FADE', + }, + { value: MediaAnimation.SlideTop, label: 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP' }, + { + value: MediaAnimation.SlideLeft, + label: 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT', + }, + { + value: MediaAnimation.SlideRight, + label: 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT', + }, + { + value: MediaAnimation.SlideBottom, + label: 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM', + }, +]; + +/** Label key of an animation. Unknown values show as the default. */ +export function animationLabel(animation: MediaAnimation) { + return ( + ANIMATION_OPTIONS.find(({ value }) => value === animation)?.label || + 'COMMON.DEFAULT' + ); +} diff --git a/apps/signage-manager/src/app/shared/media-preview-modal.component.ts b/apps/signage-manager/src/app/shared/media-preview-modal.component.ts index cf11424ffe6..2deea37c560 100644 --- a/apps/signage-manager/src/app/shared/media-preview-modal.component.ts +++ b/apps/signage-manager/src/app/shared/media-preview-modal.component.ts @@ -17,7 +17,6 @@ import { TranslatePipe, } from '@placeos/components'; import { - MediaAnimation, showSignageMedia, SignageMedia, SignagePlaylist, @@ -29,6 +28,7 @@ import { playlistMediaUrl, } from '../signage-playlist.util'; import { webPageFrameUrl } from '../signage-url.util'; +import { animationLabel, mediaTypeLabel } from './media-labels.util'; import { SignageSharedWithComponent } from './signage-shared-with.component'; interface MediaPreviewModalData { @@ -447,39 +447,13 @@ export class MediaPreviewModalComponent implements OnInit { }), ); - public readonly type_label = computed(() => { - switch (this.item.media_type) { - case 'video': - return 'COMMON.VIDEO'; - case 'image': - return 'COMMON.IMAGE'; - case 'webpage': - return 'COMMON.WEBPAGE'; - case 'plugin': - return 'SIGNAGE_MANAGER.TYPE_PLUGIN'; - default: - return this.item.media_type; - } - }); + public readonly type_label = computed( + () => mediaTypeLabel(this.item.media_type) ?? this.item.media_type, + ); - public readonly animation_label = computed(() => { - switch (mediaAnimation(this.item.animation)) { - case MediaAnimation.Cut: - return 'SIGNAGE_MANAGER.ANIM_CUT'; - case MediaAnimation.CrossFade: - return 'SIGNAGE_MANAGER.ANIM_CROSS_FADE'; - case MediaAnimation.SlideTop: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP'; - case MediaAnimation.SlideLeft: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT'; - case MediaAnimation.SlideRight: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT'; - case MediaAnimation.SlideBottom: - return 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM'; - default: - return 'COMMON.DEFAULT'; - } - }); + public readonly animation_label = computed(() => + animationLabel(mediaAnimation(this.item.animation)), + ); public ngOnInit() { return this.loadPlaylists(); diff --git a/apps/signage-manager/src/app/shared/playlist-approve-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-approve-modal.component.ts index ca6e4a77fed..3aefa2d9faa 100644 --- a/apps/signage-manager/src/app/shared/playlist-approve-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-approve-modal.component.ts @@ -13,7 +13,6 @@ import { approveSignagePlaylist, SignageMedia, SignagePlaylist, - updateSignagePlaylistMedia, } from '@placeos/ts-client'; import { MediaPreviewService } from '../media/media-preview.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; @@ -171,33 +170,15 @@ export class PlaylistApproveModalComponent { playlistMediaItems(playlist), ); + /** Restore the last approved version. The service asks the user to confirm. */ public async undoChanges() { - if (!this.can_update()) { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_UPDATE_PLAYLISTS')); - return; - } const [, previous_version] = this.playlist_versions(); - if (!previous_version?.items) return; - this.loading.set(i18n('SIGNAGE_MANAGER.UNDOING_CHANGES')); - this._dialog_ref.disableClose = true; - try { - await updateSignagePlaylistMedia( - this._data.playlist.id, - previous_version.items, - ); - this._playlist_service.setPlaylistApprovalStatus( - this._data.playlist.id, - false, - ); - notifySuccess(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERTED')); - this._dialog_ref.close(true); - this._playlist_service.refreshPlaylist(this._data.playlist.id); - } catch { - notifyError(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERT_ERROR')); - } finally { - this.loading.set(''); - this._dialog_ref.disableClose = false; - } + if (!previous_version) return; + const undone = await this._playlist_service.undoPlaylistChanges( + this._data.playlist, + previous_version, + ); + if (undone) this._dialog_ref.close(true); } /** diff --git a/apps/signage-manager/src/app/shared/playlist-assignments.ts b/apps/signage-manager/src/app/shared/playlist-assignments.ts new file mode 100644 index 00000000000..d96fd51ee9e --- /dev/null +++ b/apps/signage-manager/src/app/shared/playlist-assignments.ts @@ -0,0 +1,161 @@ +import { effect, untracked, type WritableSignal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { i18n, notifyError, notifySuccess } from '@placeos/common'; +import { type SignagePlaylist } from '@placeos/ts-client'; +import { type SignagePlaylistService } from '../playlists/signage-playlist.service'; +import { type SignageContextService } from '../signage-context.service'; +import { dialogClosed } from '../signage-service.util'; +import { decodeEntityNames } from './decode-entity-names.util'; + +/** Display or zone that holds a list of playlist IDs */ +interface PlaylistHolder { + id: string; + version: number; + playlists?: readonly string[]; +} + +/** Parts that differ between display and zone playlist assignment */ +export interface PlaylistAssignmentsOptions { + context: SignageContextService; + playlist_service: SignagePlaylistService; + dialog: MatDialog; + /** Saved copies of items, kept until the lists reload */ + overrides: WritableSignal>; + /** Selected item of the page that shows the items */ + selected: WritableSignal; + /** Field of the takeover check that holds the item ID */ + target: 'display_id' | 'zone_id'; + /** Translation key of the error when the playlist is already assigned */ + already_assigned_key: string; + /** Translation key of the error when adding a playlist fails */ + add_error_key: string; + /** Save new playlist IDs of an item */ + save_playlists: (item: T, playlists: string[]) => Promise; +} + +const ASSIGNMENT_ERROR = 'SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR'; + +/** + * Adds playlists to displays or zones and takes them off. Keeps each saved + * item in `overrides` until the lists reload. Create it in an injection + * context, as it clears `overrides` when the group or data changes. + */ +export class PlaylistAssignments { + constructor(private readonly _options: PlaylistAssignmentsOptions) { + // Local edits only bridge the gap until the lists reload. Drop them + // when the group or data changes, so a stale copy never hides newer + // server data or sends an old version with the next patch. + effect(() => { + _options.context.api_group_id(); + _options.context.data_change(); + untracked(() => _options.overrides.set({})); + }); + } + + /** Whether the user can change assignments. Warns when not. */ + public canAssign() { + return this._options.context.requirePermission( + this._options.context.can_update(), + 'SIGNAGE_MANAGER.SVC_NO_UPDATE_ASSIGNMENTS', + ); + } + + /** Let the user pick a playlist. Undefined when the picker is closed. */ + public async pickPlaylist() { + const { PlaylistSelectModalComponent } = + await import('./playlist-select-modal.component'); + const ref = this._options.dialog.open(PlaylistSelectModalComponent, { + panelClass: 'mobile-fullscreen', + }); + return dialogClosed(ref); + } + + /** + * Keep a saved item until the lists reload. + * @returns The item with its names decoded, to use for selection + */ + public cache(item: T) { + const decoded = decodeEntityNames(item); + if (!decoded?.id) return decoded; + this._options.overrides.update((state) => ({ + ...state, + [decoded.id]: decoded, + })); + return decoded; + } + + /** Show a saved item in place of the selected item, when it is that item */ + public updateSelection(item: T | null) { + if (item && this._options.selected()?.id === item.id) { + this._options.selected.set(item); + } + } + + /** + * Add a playlist to an item, after a check for takeover conflicts. + * @param change Playlist to add, with its unsaved version when known + * @returns The saved item, or null when nothing was saved + */ + public async add( + item: T, + change: { playlist_id: string; playlist?: SignagePlaylist }, + success_key: string, + ) { + if (item.playlists?.includes(change.playlist_id)) { + notifyError(i18n(this._options.already_assigned_key)); + return null; + } + const confirmed = + await this._options.playlist_service.confirmTakeoverChange({ + ...change, + [this._options.target]: item.id, + }); + if (!confirmed) return null; + return this.save( + () => + this._options.save_playlists(item, [ + ...(item.playlists || []), + change.playlist_id, + ]), + success_key, + this._options.add_error_key, + ); + } + + /** + * Take a playlist off an item. + * @returns The saved item, or null when nothing was saved + */ + public async remove(item: T, playlist_id: string, success_key: string) { + if (!this.canAssign()) return null; + return this.save( + () => + this._options.save_playlists( + item, + (item.playlists || []).filter((id) => id !== playlist_id), + ), + success_key, + ); + } + + /** + * Run a save of an item and keep the result until the lists reload. + * @param error_key Translation key of the error to show when it fails + * @returns The saved item, or null after showing an error when it fails + */ + public async save( + request: () => Promise, + success_key: string, + error_key = ASSIGNMENT_ERROR, + ) { + const updated = await request().catch(() => null); + if (!updated) { + notifyError(i18n(error_key)); + return null; + } + const saved = this.cache(updated); + this._options.context.changed(); + notifySuccess(i18n(success_key)); + return saved; + } +} diff --git a/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts index f8f03e44441..aa31ee7fd55 100644 --- a/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-edit-modal.component.ts @@ -1,4 +1,4 @@ -import { Component, DestroyRef, inject, signal } from '@angular/core'; +import { Component, inject, signal } from '@angular/core'; import { applyEach, applyWhen, @@ -13,12 +13,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSliderModule } from '@angular/material/slider'; -import { - HotkeysService, - i18n, - notifyError, - notifySuccess, -} from '@placeos/common'; +import { i18n, notifyError, notifySuccess } from '@placeos/common'; import { FullscreenModalShellComponent, MediaDurationPipe, @@ -29,6 +24,7 @@ import { DateFieldComponent } from '@placeos/form-fields'; import { MediaAnimation, SignagePlaylist } from '@placeos/ts-client'; import { endOfDay, getUnixTime, startOfDay } from 'date-fns'; import { playlistAnimation } from '../signage-playlist.util'; +import { ANIMATION_OPTIONS } from './media-labels.util'; import { createPlaylistScheduleModel, PlaylistScheduleFormComponent, @@ -36,7 +32,9 @@ import { playlistSchedulePayload, playlistSchedules, playlistScheduleSchema, + ScheduleList, } from './playlist-schedule-form.component'; +import { saveHotkey } from './save-hotkey'; import { SignageSharedWithComponent } from './signage-shared-with.component'; export interface PlaylistEditModalData { @@ -54,33 +52,6 @@ export interface PlaylistEditModalData { beforeSave?: (data: Partial) => Promise; } -/** Focus targets where a plain key press belongs to the control, not a hotkey */ -const HOTKEY_BLOCKING_FOCUS = - 'select, mat-select, [role="combobox"], [role="listbox"], [role="option"], [role="menu"], [role="menuitem"]'; - -/** Animations the user can pick, with their label keys */ -const ANIMATION_OPTIONS: { value: MediaAnimation; label: string }[] = [ - { value: MediaAnimation.Default, label: 'COMMON.DEFAULT' }, - { value: MediaAnimation.Cut, label: 'SIGNAGE_MANAGER.ANIM_CUT' }, - { - value: MediaAnimation.CrossFade, - label: 'SIGNAGE_MANAGER.ANIM_CROSS_FADE', - }, - { value: MediaAnimation.SlideTop, label: 'SIGNAGE_MANAGER.ANIM_SLIDE_TOP' }, - { - value: MediaAnimation.SlideLeft, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_LEFT', - }, - { - value: MediaAnimation.SlideRight, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_RIGHT', - }, - { - value: MediaAnimation.SlideBottom, - label: 'SIGNAGE_MANAGER.ANIM_SLIDE_BOTTOM', - }, -]; - export interface PlaylistEditFormModel { name: string; description: string; @@ -303,16 +274,18 @@ export interface PlaylistEditFormModel { } @@ -351,7 +324,6 @@ export class PlaylistEditModalComponent { public readonly loading = signal(false); public readonly animation_options = ANIMATION_OPTIONS; - public readonly active_schedule_index = signal(0); public readonly playlist = this._data.playlist; public readonly group_id = this._data.group_id || ''; /** Default animation when the modal opened */ @@ -375,6 +347,7 @@ export class PlaylistEditModalComponent { ? this.playlist.valid_until * 1000 : null, }); + public readonly schedule_list = new ScheduleList(this.model); public readonly form = form(this.model, (path) => { required(path.name); minLength(path.schedules, 1); @@ -388,53 +361,12 @@ export class PlaylistEditModalComponent { }); constructor() { - // The save hotkey is a plain key, so a select or list that has focus - // keeps it, e.g. to jump to an option such as "Square" - const save_hotkey = inject(HotkeysService).listen(['KeyS'], () => { - if (!document.activeElement?.closest(HOTKEY_BLOCKING_FOCUS)) { - this.savePlaylist(); - } - }); - inject(DestroyRef).onDestroy(() => save_hotkey?.unsubscribe()); + saveHotkey(this._dialog_ref, () => this.savePlaylist()); if (!this.model().distribution && !this.model().schedules.length) { - this.addSchedule(); + this.schedule_list.add(); } } - public addSchedule() { - this.model.update((model) => ({ - ...model, - schedules: [...model.schedules, createPlaylistScheduleModel()], - })); - this.active_schedule_index.set(this.model().schedules.length - 1); - } - - public removeSchedule(event: Event, index: number) { - event.preventDefault(); - event.stopPropagation(); - if (this.model().schedules.length <= 1) return; - this.model.update((model) => ({ - ...model, - schedules: model.schedules.filter((_, item_index) => { - return item_index !== index; - }), - })); - this.active_schedule_index.update((active_index) => { - if (active_index === index) return null; - return active_index > index ? active_index - 1 : active_index; - }); - } - - public openSchedule(index: number) { - this.active_schedule_index.update((active_index) => - active_index === index ? null : index, - ); - } - - public isScheduleOpen(index: number) { - return this.active_schedule_index() === index; - } - public async savePlaylist() { await submit(this.form, async () => { this.loading.set(true); diff --git a/apps/signage-manager/src/app/shared/playlist-item-schedule-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-item-schedule-modal.component.ts index d33c8ae9801..94a6b8cc74b 100644 --- a/apps/signage-manager/src/app/shared/playlist-item-schedule-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-item-schedule-modal.component.ts @@ -19,6 +19,7 @@ import { PlaylistScheduleFormModel, playlistSchedulePayload, playlistScheduleSchema, + ScheduleList, } from './playlist-schedule-form.component'; export interface PlaylistItemScheduleModalData { @@ -81,16 +82,16 @@ export interface PlaylistItemScheduleModalData { } @@ -114,50 +115,18 @@ export class PlaylistItemScheduleModalComponent { /** Media the schedules are attached to, shown as context in the modal header */ public readonly media = new SignageMedia(this._data.item.media || {}); public readonly loading = signal(false); - public readonly active_schedule_index = signal(0); public readonly model = signal<{ schedules: PlaylistScheduleFormModel[] }>({ schedules: (this._data.item.schedules?.length ? this._data.item.schedules : [{}] ).map((schedule) => createPlaylistScheduleModel(schedule)), }); + public readonly schedule_list = new ScheduleList(this.model); public readonly form_model = form(this.model, (path) => { minLength(path.schedules, 1); applyEach(path.schedules, playlistScheduleSchema); }); - public addSchedule() { - this.model.update((model) => ({ - schedules: [...model.schedules, createPlaylistScheduleModel()], - })); - this.active_schedule_index.set(this.model().schedules.length - 1); - } - - public removeSchedule(event: Event, index: number) { - event.preventDefault(); - event.stopPropagation(); - if (this.model().schedules.length <= 1) return; - this.model.update((model) => ({ - schedules: model.schedules.filter( - (_, item_index) => item_index !== index, - ), - })); - this.active_schedule_index.update((active_index) => { - if (active_index === index) return null; - return active_index > index ? active_index - 1 : active_index; - }); - } - - public openSchedule(index: number) { - this.active_schedule_index.update((active_index) => - active_index === index ? null : index, - ); - } - - public isScheduleOpen(index: number) { - return this.active_schedule_index() === index; - } - public async saveSchedule() { await submit(this.form_model, async () => { this.loading.set(true); diff --git a/apps/signage-manager/src/app/shared/playlist-request-approval-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-request-approval-modal.component.ts index aa997fd4878..872e3fc9e42 100644 --- a/apps/signage-manager/src/app/shared/playlist-request-approval-modal.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-request-approval-modal.component.ts @@ -11,14 +11,13 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; -import { i18n, notifyError, notifySuccess, notifyWarn } from '@placeos/common'; +import { i18n, notifyError } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { SignageMedia, SignagePlaylist, type SignagePlaylistApprover, SignagePlaylistMedia, - updateSignagePlaylistMedia, } from '@placeos/ts-client'; import { MediaPreviewService } from '../media/media-preview.service'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; @@ -297,33 +296,15 @@ export class PlaylistRequestApprovalModalComponent { }); } + /** Restore the last approved version. The service asks the user to confirm. */ public async undoChanges() { - if (!this.can_update()) { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_UPDATE_PLAYLISTS')); - return; - } const [, previous_version] = await this._loadPlaylistVersions(); - if (!previous_version?.items) return; - this.loading.set(i18n('SIGNAGE_MANAGER.UNDOING_CHANGES')); - this._dialog_ref.disableClose = true; - try { - await updateSignagePlaylistMedia( - this.data.playlist.id, - previous_version.items, - ); - this._playlist_service.setPlaylistApprovalStatus( - this.data.playlist.id, - false, - ); - notifySuccess(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERTED')); - this._dialog_ref.close(); - this._context.changed(); - } catch { - notifyError(i18n('SIGNAGE_MANAGER.PLAYLIST_REVERT_ERROR')); - } finally { - this.loading.set(''); - this._dialog_ref.disableClose = false; - } + if (!previous_version) return; + const undone = await this._playlist_service.undoPlaylistChanges( + this.data.playlist, + previous_version, + ); + if (undone) this._dialog_ref.close(); } public previewItem(item: SignageMedia) { diff --git a/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts b/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts index 82c5dfc7785..c91a3c503c8 100644 --- a/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts +++ b/apps/signage-manager/src/app/shared/playlist-schedule-form.component.ts @@ -8,6 +8,7 @@ import { input, output, signal, + type WritableSignal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FieldTree, FormField, schema, validate } from '@angular/forms/signals'; @@ -25,14 +26,10 @@ import { getTimezoneOffsetString, hasPlayableScheduleMask, i18n, - isCronMonthlyWeekday, isPlayOnceSchedule, isValidScheduleMask, LOCAL_TIMEZONE, LocaleService, - parseCronNumber, - parseCronWeekdays, - parseCronWeeksOfMonth, parsePlayAtLocal, setTimeInTimezone, settingSignal, @@ -58,20 +55,27 @@ import { formatPlayAtLocal, formatPlayDateTime, formatPlayDateTimeRange, - ordinal, - parseCronMonthDays, - playlistScheduleExpiryLabel, + playPeriodMinutes, } from '../signage-playlist.util'; +import { + DEFAULT_RECURRING_CRON, + DEFAULT_RECURRING_TIME, + formatClockTime, + normaliseMonthDays, + normaliseWeekdays, + normaliseWeeksOfMonth, + ordinalLabel, + parseRecurringCron, + playLengthLabel, + recurringScheduleSummary, + type RecurringScheduleType, + scheduleExpiryLabel, + scheduleMaskLabel, + WEEK_OF_MONTH_OPTIONS, + WEEKDAY_OPTIONS, +} from './schedule-summary.util'; export type PlaylistScheduleType = 'play_at' | 'play_cron'; -type RecurringScheduleType = - | 'hours' - | 'daily' - | 'weekdays' - | 'weekly' - | 'monthly' - | 'monthly_weekday' - | 'custom'; export interface PlaylistScheduleFormModel { schedule_type: PlaylistScheduleType; @@ -101,29 +105,15 @@ export interface PlaylistScheduleFormModel { const FULL_DAY_START_MINUTES = 0; const FULL_DAY_END_MINUTES = 23 * 60 + 59; -const DEFAULT_RECURRING_TIME = '00:00'; -const DEFAULT_RECURRING_CRON = '0 0 * * *'; -const WEEKDAY_OPTIONS = [ - { value: 1 }, - { value: 2 }, - { value: 3 }, - { value: 4 }, - { value: 5 }, - { value: 6 }, - { value: 0 }, -]; -const WEEK_OF_MONTH_OPTIONS = [1, 2, 3, 4, 5]; +/** Clock time of minutes from midnight, kept inside the day */ function minutesToTime(value: number) { - const safe_value = Math.max( - FULL_DAY_START_MINUTES, - Math.min(FULL_DAY_END_MINUTES, value || 0), + return formatClockTime( + Math.max( + FULL_DAY_START_MINUTES, + Math.min(FULL_DAY_END_MINUTES, value || 0), + ), ); - const hours = Math.floor(safe_value / 60) - .toString() - .padStart(2, '0'); - const minutes = (safe_value % 60).toString().padStart(2, '0'); - return `${hours}:${minutes}`; } function timeToMinutes(value: string) { @@ -134,41 +124,6 @@ function timeToMinutes(value: string) { ); } -function normaliseWeekdays(value: number[] | null | undefined) { - const seen_days = new Set(); - for (const day of value || []) { - if (day >= 0 && day <= 6) seen_days.add(day); - } - return WEEKDAY_OPTIONS.map((day) => day.value).filter((day) => - seen_days.has(day), - ); -} - -function normaliseMonthDays(value: number[] | null | undefined) { - const seen_days = new Set(); - for (const day of value || []) { - if (day >= 1 && day <= 31) seen_days.add(day); - } - return Array.from({ length: 31 }, (_, index) => index + 1).filter((day) => - seen_days.has(day), - ); -} - -function normaliseWeeksOfMonth(value: number[] | null | undefined) { - const seen_weeks = new Set(); - for (const week of value || []) { - if (week >= 1 && week <= 5) seen_weeks.add(week); - } - return WEEK_OF_MONTH_OPTIONS.filter((week) => seen_weeks.has(week)); -} - -function parseCronStep(value: string, min: number, max: number) { - const match = /^\*\/(\d+)$/.exec(value || ''); - if (!match) return null; - const step = +match[1]; - return step >= min && step <= max ? step : null; -} - function dayRangeForWeekOfMonth(value: number | null | undefined) { const week = Math.max(1, Math.min(5, value || 1)); if (week === 5) return '29-31'; @@ -176,79 +131,6 @@ function dayRangeForWeekOfMonth(value: number | null | undefined) { return `${start}-${start + 6}`; } -function parseRecurringCron(value: string | null | undefined) { - const [minute_part, hour_part, day_part, month_part, weekday_part] = ( - value || DEFAULT_RECURRING_CRON - ) - .trim() - .split(/\s+/); - const minute = parseCronNumber(minute_part, 0, 59); - const hour = parseCronNumber(hour_part, 0, 23); - const time = - minute === null || hour === null - ? DEFAULT_RECURRING_TIME - : `${hour.toString().padStart(2, '0')}:${minute - .toString() - .padStart(2, '0')}`; - const custom = { - recurrence_type: 'custom' as RecurringScheduleType, - recurrence_time: time, - recurrence_interval: 1, - recurrence_week_of_month: [1], - recurrence_weekdays: [1], - recurrence_day_of_month: [1], - }; - if (day_part === '*' && month_part === '*' && weekday_part === '*') { - const minute_step = - minute_part === '*' ? 1 : parseCronStep(minute_part, 1, 59); - if (minute_step !== null && hour_part === '*') return custom; - const hour_step = - hour_part === '*' ? 1 : parseCronStep(hour_part, 1, 23); - if (parseCronNumber(minute_part, 0, 59) === 0 && hour_step !== null) { - return { - ...custom, - recurrence_type: 'hours' as RecurringScheduleType, - recurrence_interval: hour_step, - }; - } - } - if (minute === null || hour === null || month_part !== '*') return custom; - if (day_part === '*' && weekday_part === '*') { - return { ...custom, recurrence_type: 'daily' as RecurringScheduleType }; - } - if (day_part === '*' && weekday_part === '1-5') { - return { - ...custom, - recurrence_type: 'weekdays' as RecurringScheduleType, - }; - } - const weekdays = normaliseWeekdays(parseCronWeekdays(weekday_part)); - if (isCronMonthlyWeekday(day_part, weekday_part)) { - return { - ...custom, - recurrence_type: 'monthly_weekday' as RecurringScheduleType, - recurrence_week_of_month: parseCronWeeksOfMonth(day_part), - recurrence_weekdays: weekdays, - }; - } - if (day_part === '*' && weekdays.length) { - return { - ...custom, - recurrence_type: 'weekly' as RecurringScheduleType, - recurrence_weekdays: weekdays, - }; - } - const days_of_month = parseCronMonthDays(day_part); - if (days_of_month?.length && weekday_part === '*') { - return { - ...custom, - recurrence_type: 'monthly' as RecurringScheduleType, - recurrence_day_of_month: days_of_month, - }; - } - return custom; -} - function isIntervalRecurringType( value: RecurringScheduleType | null | undefined, ) { @@ -304,12 +186,6 @@ function buildRecurringCron(value: { return `${minute} ${hour} * * *`; } -function playlistPlayPeriod(schedule: Partial) { - return Number.isFinite(schedule.play_period) - ? Math.max(0, schedule.play_period) - : DEFAULT_PLAY_PERIOD_MINUTES; -} - function scheduleTypeFor( schedule: Partial, ): PlaylistScheduleType { @@ -338,32 +214,6 @@ export function playlistSchedules(playlist: SignagePlaylist) { return playlist.schedules?.length ? playlist.schedules : [schedule]; } -function formatMinutes(value: number | null | undefined) { - const total_minutes = Math.max(0, Math.round(value || 0)); - if (!total_minutes) return ''; - const days = Math.floor(total_minutes / (24 * 60)); - const hours = Math.floor((total_minutes % (24 * 60)) / 60); - const minutes = total_minutes % 60; - const parts = [ - { - value: days, - key: 'SIGNAGE_MANAGER.DURATION_DAY', - }, - { - value: hours, - key: 'SIGNAGE_MANAGER.DURATION_HOUR', - }, - { - value: minutes, - key: 'SIGNAGE_MANAGER.DURATION_MINUTE', - }, - ]; - return parts - .filter((part) => part.value) - .map((part) => i18n(part.key, { count: part.value }, part.value)) - .join(' '); -} - /** Time ranges of the next five plays of a recurring schedule in the timezone. */ function nextCronPlayTimes( cron: string, @@ -444,6 +294,58 @@ function maskOccurrenceDates( return result; } +/** + * Schedules of a modal form, shown as an accordion with one schedule open + * at a time. A form keeps at least one schedule. + */ +export class ScheduleList { + /** Index of the open schedule, or null when all are closed */ + public readonly open_index = signal(0); + + constructor( + private readonly _model: WritableSignal<{ + schedules: PlaylistScheduleFormModel[]; + }>, + ) {} + + /** Add a new schedule and open it */ + public add() { + this._model.update((model) => ({ + ...model, + schedules: [...model.schedules, createPlaylistScheduleModel()], + })); + this.open_index.set(this._model().schedules.length - 1); + } + + /** Remove a schedule, unless it is the last one */ + public remove(event: Event, index: number) { + event.preventDefault(); + event.stopPropagation(); + if (this._model().schedules.length <= 1) return; + this._model.update((model) => ({ + ...model, + schedules: model.schedules.filter( + (_, item_index) => item_index !== index, + ), + })); + this.open_index.update((open_index) => { + if (open_index === index) return null; + return open_index > index ? open_index - 1 : open_index; + }); + } + + /** Open a schedule, or close it when it is open */ + public toggle(index: number) { + this.open_index.update((open_index) => + open_index === index ? null : index, + ); + } + + public isOpen(index: number) { + return this.open_index() === index; + } +} + export function createPlaylistScheduleModel( schedule?: Partial, ): PlaylistScheduleFormModel { @@ -467,7 +369,7 @@ export function createPlaylistScheduleModel( recurrence_week_of_month: recurring_schedule.recurrence_week_of_month, recurrence_weekdays: recurring_schedule.recurrence_weekdays, recurrence_day_of_month: recurring_schedule.recurrence_day_of_month, - play_period: playlistPlayPeriod(source), + play_period: playPeriodMinutes(source), has_mask: !!source.mask, mask: source.mask || '', has_valid_from: !!source.valid_from, @@ -1481,7 +1383,7 @@ export class PlaylistScheduleFormComponent { { length: 31 }, (_, index) => index + 1, ); - public readonly ordinal = ordinal; + public readonly ordinal = ordinalLabel; public readonly value = computed(() => this.schedule()().value()); public readonly mask_occurrence_dates = computed(() => maskOccurrenceDates(this.value(), this.timezone()), @@ -1642,78 +1544,28 @@ export class PlaylistScheduleFormComponent { public recurringScheduleSummary() { const value = this.value(); - if (value.recurrence_type === 'custom') { - return i18n('SIGNAGE_MANAGER.SUMMARY_ADVANCED'); - } - const interval = value.recurrence_interval || 1; - const start_time = this.formatPlayHour(value.play_start); - const period = value.play_period ?? DEFAULT_PLAY_PERIOD_MINUTES; - const duration = - formatMinutes(period) || i18n('SIGNAGE_MANAGER.ONE_PLAYLIST_PASS'); - if (value.recurrence_type === 'hours') { - return i18n( - 'SIGNAGE_MANAGER.SUMMARY_EVERY_HOUR', - { interval }, - interval, - ); - } - if (value.recurrence_type === 'weekdays') { - return i18n('SIGNAGE_MANAGER.SUMMARY_WEEKDAYS', { - time: start_time, - duration, - }); - } - if (value.recurrence_type === 'weekly') { - const days = this._weekdayNames(value.recurrence_weekdays); - return i18n('SIGNAGE_MANAGER.SUMMARY_WEEKLY', { - days, - time: start_time, - duration, - }); - } - if (value.recurrence_type === 'monthly') { - const days = normaliseMonthDays(value.recurrence_day_of_month); - return i18n('SIGNAGE_MANAGER.SUMMARY_MONTHLY', { - days: (days.length ? days : [1]) - .map((day) => ordinal(day)) - .join(', '), - time: start_time, - duration, - }); - } - if (value.recurrence_type === 'monthly_weekday') { - const weeks = normaliseWeeksOfMonth(value.recurrence_week_of_month); - return i18n('SIGNAGE_MANAGER.SUMMARY_MONTHLY_WEEKDAY', { - weeks: (weeks.length ? weeks : [1]) - .map((week) => ordinal(week)) - .join(', '), - days: this._weekdayNames(value.recurrence_weekdays), - time: start_time, - duration, - }); - } - return i18n('SIGNAGE_MANAGER.SUMMARY_DAILY', { - time: start_time, - duration, - }); + return ( + recurringScheduleSummary( + value, + this.formatPlayHour(value.play_start), + value.play_period ?? DEFAULT_PLAY_PERIOD_MINUTES, + this._locale.locale, + ) ?? i18n('SIGNAGE_MANAGER.SUMMARY_ADVANCED') + ); } public scheduleSummary() { const value = this.value(); const period = value.play_period ?? DEFAULT_PLAY_PERIOD_MINUTES; - const duration = - formatMinutes(period) || i18n('SIGNAGE_MANAGER.ONE_PLAYLIST_PASS'); const takeover = value.play_takeover ? i18n('SIGNAGE_MANAGER.TAKEOVER_SUFFIX') : ''; - const expiry = playlistScheduleExpiryLabel( - playlistSchedulePayload(value), + const expiry = scheduleExpiryLabel( + playlistSchedulePayload(value).valid_until, ); const expiry_suffix = (expiry ? ` · ${expiry}` : '') + - (value.has_mask - ? ` · ${i18n('SIGNAGE_MANAGER.SCHEDULE_MASK_SUMMARY', { mask: value.mask, size: value.mask.length })}` - : ''); + (value.has_mask ? ` · ${scheduleMaskLabel(value.mask)}` : ''); if (value.schedule_type === 'play_at') { const date = new Date(value.play_at || Date.now()); const datetime = value.play_at_exact @@ -1721,7 +1573,7 @@ export class PlaylistScheduleFormComponent { : `${formatPlayDateTime(date)} ${i18n('SIGNAGE_MANAGER.DISPLAY_LOCAL_TIME')}`; return `${i18n('SIGNAGE_MANAGER.SUMMARY_PLAY_ONCE', { datetime, - duration, + duration: playLengthLabel(period), })}${takeover}${expiry_suffix}`; } return `${this.recurringScheduleSummary()} · ${this.timezone()}${takeover}${expiry_suffix}`; @@ -1810,12 +1662,4 @@ export class PlaylistScheduleFormComponent { const date = new Date(2024, 0, 1 + monday_based_offset); return this._date_pipe.transform(date, format) || ''; } - - private _weekdayNames(value: number[] | null | undefined) { - const weekdays = normaliseWeekdays(value); - return (weekdays.length ? weekdays : [1]) - .map((day) => this.weekdayLabel(day, 'EEEE')) - .filter((_) => _) - .join(', '); - } } diff --git a/apps/signage-manager/src/app/shared/save-hotkey.ts b/apps/signage-manager/src/app/shared/save-hotkey.ts new file mode 100644 index 00000000000..29161572f47 --- /dev/null +++ b/apps/signage-manager/src/app/shared/save-hotkey.ts @@ -0,0 +1,26 @@ +import { DestroyRef, inject } from '@angular/core'; +import { MatDialog, type MatDialogRef } from '@angular/material/dialog'; +import { HotkeysService } from '@placeos/common'; + +/** Focus targets where a plain key press belongs to the control, not a hotkey */ +const HOTKEY_BLOCKING_FOCUS = + 'select, mat-select, [role="combobox"], [role="listbox"], [role="option"], [role="menu"], [role="menuitem"]'; + +/** + * Run `save` when the user presses S in a modal. S is a plain key, so it + * only acts while `dialog_ref` is the top-most dialog and focus is not on a + * control that takes key presses, such as a select that jumps to an option. + * Call it in an injection context. It stops when that context is destroyed. + */ +export function saveHotkey( + dialog_ref: MatDialogRef, + save: () => unknown, +) { + const dialog = inject(MatDialog); + const listener = inject(HotkeysService).listen(['KeyS'], () => { + if (dialog.openDialogs.at(-1) !== dialog_ref) return; + if (document.activeElement?.closest(HOTKEY_BLOCKING_FOCUS)) return; + save(); + }); + inject(DestroyRef).onDestroy(() => listener?.unsubscribe()); +} diff --git a/apps/signage-manager/src/app/shared/schedule-summary.util.ts b/apps/signage-manager/src/app/shared/schedule-summary.util.ts new file mode 100644 index 00000000000..f0d98814d95 --- /dev/null +++ b/apps/signage-manager/src/app/shared/schedule-summary.util.ts @@ -0,0 +1,386 @@ +import { + i18n, + isCronMonthlyWeekday, + isPlayOnceSchedule, + parseCronNumber, + parseCronWeekdays, + parseCronWeeksOfMonth, + settingSignal, +} from '@placeos/common'; +import { type SignagePlaylistSchedule } from '@placeos/ts-client'; +import { format, fromUnixTime } from 'date-fns'; +import { + formatPlayDateTime, + playOnceStart, + playPeriodMinutes, +} from '../signage-playlist.util'; + +export type RecurringScheduleType = + | 'hours' + | 'daily' + | 'weekdays' + | 'weekly' + | 'monthly' + | 'monthly_weekday' + | 'custom'; + +/** Repeat pattern of a recurring schedule, as the schedule form edits it */ +export interface RecurringSchedule { + recurrence_type: RecurringScheduleType; + /** Start time as `HH:mm` */ + recurrence_time: string; + recurrence_interval: number; + recurrence_week_of_month: number[]; + recurrence_weekdays: number[]; + recurrence_day_of_month: number[]; +} + +export const DEFAULT_RECURRING_TIME = '00:00'; +export const DEFAULT_RECURRING_CRON = '0 0 * * *'; +/** Weekdays in the order the form shows them, Monday first */ +export const WEEKDAY_OPTIONS = [1, 2, 3, 4, 5, 6, 0].map((value) => ({ + value, +})); +export const WEEK_OF_MONTH_OPTIONS = [1, 2, 3, 4, 5]; + +/** + * Clock time of minutes from midnight as `HH:mm`. Minutes past the end of + * the day wrap to the next day. + */ +export function formatClockTime(minutes: number) { + const hours = Math.floor(minutes / 60) % 24; + const mins = minutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +/** + * Clock time such as `09:30` in the time format of the app: 24 hour when + * the `use_24_hour_time` setting is on, otherwise 12 hour such as `9:30 AM`. + * Matches `SettingsService.time_format_signal`. + * @param time Time as `HH:mm` + */ +export function formatSettingTime(time: string) { + const [hours, minutes] = time.split(':').map((part) => +part || 0); + return format( + new Date(2000, 0, 1, hours, minutes), + settingSignal('use_24_hour_time', false)() ? 'HH:mm' : 'h:mm a', + ); +} + +/** + * Minutes between plays of a cron that plays every N minutes, such as + * `*\/5 * * * *`. Null for other crons. + */ +function cronMinuteInterval(cron: string) { + const [minute, ...rest] = cron.trim().split(/\s+/); + if (rest.length !== 4 || rest.some((part) => part !== '*')) return null; + return minute === '*' ? 1 : parseCronStep(minute, 1, 59); +} + +/** Valid weekdays with no repeats, Monday first */ +export function normaliseWeekdays(value: number[] | null | undefined) { + const seen_days = new Set(); + for (const day of value || []) { + if (day >= 0 && day <= 6) seen_days.add(day); + } + return WEEKDAY_OPTIONS.map((day) => day.value).filter((day) => + seen_days.has(day), + ); +} + +/** Valid days of the month with no repeats, in order */ +export function normaliseMonthDays(value: number[] | null | undefined) { + const seen_days = new Set(); + for (const day of value || []) { + if (day >= 1 && day <= 31) seen_days.add(day); + } + return Array.from({ length: 31 }, (_, index) => index + 1).filter((day) => + seen_days.has(day), + ); +} + +/** Valid weeks of the month with no repeats, in order */ +export function normaliseWeeksOfMonth(value: number[] | null | undefined) { + const seen_weeks = new Set(); + for (const week of value || []) { + if (week >= 1 && week <= 5) seen_weeks.add(week); + } + return WEEK_OF_MONTH_OPTIONS.filter((week) => seen_weeks.has(week)); +} + +/** Days of the month in a plain list such as "1,15". Empty for other values. */ +function parseCronMonthDays(value: string) { + if (!value || value === '*') return []; + const days = value.split(',').map((part) => parseCronNumber(part, 1, 31)); + return days.every((day) => day !== null) + ? [...new Set(days)].sort((a, b) => a - b) + : []; +} + +function parseCronStep(value: string, min: number, max: number) { + const match = /^\*\/(\d+)$/.exec(value || ''); + if (!match) return null; + const step = +match[1]; + return step >= min && step <= max ? step : null; +} + +/** + * Repeat pattern of a cron expression. Expressions the form cannot edit + * give the `custom` type. + */ +export function parseRecurringCron( + value: string | null | undefined, +): RecurringSchedule { + const [minute_part, hour_part, day_part, month_part, weekday_part] = ( + value || DEFAULT_RECURRING_CRON + ) + .trim() + .split(/\s+/); + const minute = parseCronNumber(minute_part, 0, 59); + const hour = parseCronNumber(hour_part, 0, 23); + const time = + minute === null || hour === null + ? DEFAULT_RECURRING_TIME + : formatClockTime(hour * 60 + minute); + const custom: RecurringSchedule = { + recurrence_type: 'custom', + recurrence_time: time, + recurrence_interval: 1, + recurrence_week_of_month: [1], + recurrence_weekdays: [1], + recurrence_day_of_month: [1], + }; + if (day_part === '*' && month_part === '*' && weekday_part === '*') { + const minute_step = + minute_part === '*' ? 1 : parseCronStep(minute_part, 1, 59); + if (minute_step !== null && hour_part === '*') return custom; + const hour_step = + hour_part === '*' ? 1 : parseCronStep(hour_part, 1, 23); + if (parseCronNumber(minute_part, 0, 59) === 0 && hour_step !== null) { + return { + ...custom, + recurrence_type: 'hours', + recurrence_interval: hour_step, + }; + } + } + if (minute === null || hour === null || month_part !== '*') return custom; + if (day_part === '*' && weekday_part === '*') { + return { ...custom, recurrence_type: 'daily' }; + } + if (day_part === '*' && weekday_part === '1-5') { + return { ...custom, recurrence_type: 'weekdays' }; + } + const weekdays = normaliseWeekdays(parseCronWeekdays(weekday_part)); + if (isCronMonthlyWeekday(day_part, weekday_part)) { + return { + ...custom, + recurrence_type: 'monthly_weekday', + recurrence_week_of_month: parseCronWeeksOfMonth(day_part), + recurrence_weekdays: weekdays, + }; + } + if (day_part === '*' && weekdays.length) { + return { + ...custom, + recurrence_type: 'weekly', + recurrence_weekdays: weekdays, + }; + } + const days_of_month = parseCronMonthDays(day_part); + if (days_of_month.length && weekday_part === '*') { + return { + ...custom, + recurrence_type: 'monthly', + recurrence_day_of_month: days_of_month, + }; + } + return custom; +} + +/** Translated ordinal of a day or week of the month, such as "2nd" */ +export function ordinalLabel(value: number) { + return i18n('SIGNAGE_MANAGER.ORDINAL', { count: value }, value); +} + +/** Translated length such as "1 hour 30 minutes". Empty for 0. */ +export function formatDuration(value: number | null | undefined) { + const total_minutes = Math.max(0, Math.round(value || 0)); + if (!total_minutes) return ''; + const parts = [ + { + value: Math.floor(total_minutes / (24 * 60)), + key: 'SIGNAGE_MANAGER.DURATION_DAY', + }, + { + value: Math.floor((total_minutes % (24 * 60)) / 60), + key: 'SIGNAGE_MANAGER.DURATION_HOUR', + }, + { value: total_minutes % 60, key: 'SIGNAGE_MANAGER.DURATION_MINUTE' }, + ]; + return parts + .filter((part) => part.value) + .map((part) => i18n(part.key, { count: part.value }, part.value)) + .join(' '); +} + +/** Translated play length. A length of 0 plays the playlist once. */ +export function playLengthLabel(minutes: number) { + return formatDuration(minutes) || i18n('SIGNAGE_MANAGER.ONE_PLAYLIST_PASS'); +} + +/** Names of weekdays in the locale, Monday first, such as "Monday, Friday" */ +function weekdayNames(value: number[], locale: string) { + const format = new Intl.DateTimeFormat(locale, { weekday: 'long' }); + const weekdays = normaliseWeekdays(value); + // 1 January 2024 is a Monday + return (weekdays.length ? weekdays : [1]) + .map((day) => format.format(new Date(2024, 0, day === 0 ? 7 : day))) + .join(', '); +} + +/** + * Translated summary of a repeat pattern, such as "Starts every weekday at + * 09:00 for 1 hour.". Null for a `custom` pattern, which callers describe. + * @param time Start time to show + * @param period Play length in minutes + * @param locale Locale of the weekday names + */ +export function recurringScheduleSummary( + value: Omit, + time: string, + period: number, + locale: string, +) { + const duration = playLengthLabel(period); + switch (value.recurrence_type) { + case 'custom': + return null; + case 'hours': { + const interval = value.recurrence_interval || 1; + return i18n( + 'SIGNAGE_MANAGER.SUMMARY_EVERY_HOUR', + { interval }, + interval, + ); + } + case 'weekdays': + return i18n('SIGNAGE_MANAGER.SUMMARY_WEEKDAYS', { time, duration }); + case 'weekly': + return i18n('SIGNAGE_MANAGER.SUMMARY_WEEKLY', { + days: weekdayNames(value.recurrence_weekdays, locale), + time, + duration, + }); + case 'monthly': { + const days = normaliseMonthDays(value.recurrence_day_of_month); + return i18n('SIGNAGE_MANAGER.SUMMARY_MONTHLY', { + days: (days.length ? days : [1]).map(ordinalLabel).join(', '), + time, + duration, + }); + } + case 'monthly_weekday': { + const weeks = normaliseWeeksOfMonth(value.recurrence_week_of_month); + return i18n('SIGNAGE_MANAGER.SUMMARY_MONTHLY_WEEKDAY', { + weeks: (weeks.length ? weeks : [1]) + .map(ordinalLabel) + .join(', '), + days: weekdayNames(value.recurrence_weekdays, locale), + time, + duration, + }); + } + default: + return i18n('SIGNAGE_MANAGER.SUMMARY_DAILY', { time, duration }); + } +} + +/** Translated distance in its largest whole unit, such as "3 days" */ +function distanceLabel(milliseconds: number) { + const minutes = Math.max(1, Math.round(milliseconds / 60_000)); + const [value, key] = + minutes >= 24 * 60 + ? [Math.round(minutes / (24 * 60)), 'SIGNAGE_MANAGER.DURATION_DAY'] + : minutes >= 60 + ? [Math.round(minutes / 60), 'SIGNAGE_MANAGER.DURATION_HOUR'] + : [minutes, 'SIGNAGE_MANAGER.DURATION_MINUTE']; + return i18n(key, { count: value }, value); +} + +/** + * Translated end of a schedule relative to now, such as "until 3 days from + * now". Empty when the schedule has no end. + * @param valid_until End of the schedule in Unix seconds + */ +export function scheduleExpiryLabel( + valid_until: number | undefined, + now = Date.now(), +) { + if (!valid_until) return ''; + const expiry = fromUnixTime(valid_until).getTime(); + const time = distanceLabel(Math.abs(expiry - now)); + return expiry >= now + ? i18n('SIGNAGE_MANAGER.SCHEDULE_UNTIL_FROM_NOW', { time }) + : i18n('SIGNAGE_MANAGER.SCHEDULE_UNTIL_AGO', { time }); +} + +/** Translated summary of a schedule mask */ +export function scheduleMaskLabel(mask: string) { + return i18n('SIGNAGE_MANAGER.SCHEDULE_MASK_SUMMARY', { + mask, + size: mask.length, + }); +} + +/** + * Translated one line summary of a saved playlist schedule, for lists. + * @param locale Locale of the weekday names + */ +export function scheduleSummary( + schedule: Partial, + locale: string, +) { + const period = playPeriodMinutes(schedule); + const takeover = schedule.play_takeover + ? i18n('SIGNAGE_MANAGER.TAKEOVER_SUFFIX') + : ''; + const suffix = [ + scheduleExpiryLabel(schedule.valid_until), + schedule.mask ? scheduleMaskLabel(schedule.mask) : '', + ] + .filter((part) => part) + .map((part) => ` · ${part}`) + .join(''); + if (isPlayOnceSchedule(schedule)) { + const start = playOnceStart(schedule); + // Follow the app's time format, like the recurring summaries + const hour12 = !settingSignal('use_24_hour_time', false)(); + const date = start ? formatPlayDateTime(start, undefined, hour12) : ''; + const datetime = schedule.play_at + ? date + : `${date} ${i18n('SIGNAGE_MANAGER.DISPLAY_LOCAL_TIME')}`; + return `${i18n('SIGNAGE_MANAGER.SUMMARY_PLAY_ONCE', { + datetime, + duration: playLengthLabel(period), + })}${takeover}${suffix}`; + } + const cron = schedule.play_cron || DEFAULT_RECURRING_CRON; + const recurrence = parseRecurringCron(cron); + // The form has no minute repeat, so its parser reads one as custom + const minute_interval = cronMinuteInterval(cron); + const summary = + minute_interval !== null + ? i18n( + 'SIGNAGE_MANAGER.SUMMARY_EVERY_MINUTE', + { interval: minute_interval }, + minute_interval, + ) + : (recurringScheduleSummary( + recurrence, + formatSettingTime(recurrence.recurrence_time), + period, + locale, + ) ?? `${i18n('SIGNAGE_MANAGER.CUSTOM_SCHEDULE')} (${cron})`); + return `${summary}${takeover}${suffix}`; +} diff --git a/apps/signage-manager/src/app/shared/stored-signal.ts b/apps/signage-manager/src/app/shared/stored-signal.ts new file mode 100644 index 00000000000..37d2750aeda --- /dev/null +++ b/apps/signage-manager/src/app/shared/stored-signal.ts @@ -0,0 +1,51 @@ +import { signal, untracked, type WritableSignal } from '@angular/core'; + +/** Browser storage, or null when the page cannot use it */ +function storageArea(type: 'local' | 'session') { + try { + return (type === 'session' ? sessionStorage : localStorage) ?? null; + } catch { + // Storage can be unavailable in private browsing or restricted embeds + return null; + } +} + +/** + * Writable signal that starts from a stored value and stores each new value + * at once. When storage is unavailable, the value is kept in memory only. + * @param parse Value of the stored text, or of null when nothing is stored + * or the text cannot be read + * @param options.serialise Text to store. Null removes the key. + */ +export function storedSignal( + key: string, + parse: (stored: string | null) => T, + options: { + storage?: 'local' | 'session'; + serialise?: (value: T) => string | null; + } = {}, +): WritableSignal { + const type = options.storage || 'local'; + const serialise = options.serialise || String; + let initial: T; + try { + initial = parse(storageArea(type)?.getItem(key) ?? null); + } catch { + initial = parse(null); + } + const value = signal(initial); + const set = value.set; + value.set = (next: T) => { + set(next); + try { + const text = serialise(next); + const area = storageArea(type); + if (text === null) area?.removeItem(key); + else area?.setItem(key, text); + } catch { + // Storing is best effort. The value stays in memory. + } + }; + value.update = (fn: (current: T) => T) => value.set(fn(untracked(value))); + return value; +} diff --git a/apps/signage-manager/src/app/shared/template-edit-modal.component.ts b/apps/signage-manager/src/app/shared/template-edit-modal.component.ts index fd3eed73711..31c336533c2 100644 --- a/apps/signage-manager/src/app/shared/template-edit-modal.component.ts +++ b/apps/signage-manager/src/app/shared/template-edit-modal.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { form, FormField, required, submit } from '@angular/forms/signals'; import { MatRippleModule } from '@angular/material/core'; import { @@ -9,7 +9,6 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { - HotkeysService, i18n, notifyError, notifySuccess, @@ -32,6 +31,7 @@ import { MediaSelectModalComponent, MediaSelectModalData, } from './media-select-modal.component'; +import { saveHotkey } from './save-hotkey'; import { SignageSharedWithComponent } from './signage-shared-with.component'; export interface TemplateEditModalData { @@ -253,10 +253,7 @@ export class TemplateEditModalComponent { }); constructor() { - const save_hotkey = inject(HotkeysService).listen(['KeyS'], () => { - if (this._canUseSaveHotkey()) this.saveTemplate(); - }); - inject(DestroyRef).onDestroy(() => save_hotkey?.unsubscribe()); + saveHotkey(this._dialog_ref, () => this.saveTemplate()); } public async selectBackground() { @@ -315,15 +312,4 @@ export class TemplateEditModalComponent { } }); } - - /** - * Whether the "S" hotkey may save. Ignored while another dialog (e.g. the - * background picker) is on top, or while focus is in a select or list. - */ - private _canUseSaveHotkey() { - if (this._dialog.openDialogs.at(-1) !== this._dialog_ref) return false; - return !document.activeElement?.closest( - 'select, [role="combobox"], [role="listbox"], [role="option"]', - ); - } } diff --git a/apps/signage-manager/src/app/shared/template-mappings.component.ts b/apps/signage-manager/src/app/shared/template-mappings.component.ts index b9d8e549051..43fe194bfbc 100644 --- a/apps/signage-manager/src/app/shared/template-mappings.component.ts +++ b/apps/signage-manager/src/app/shared/template-mappings.component.ts @@ -3,7 +3,7 @@ import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; -import { computeTemplateLayout, i18n } from '@placeos/common'; +import { computeTemplateLayout, i18n, LocaleService } from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, @@ -16,7 +16,6 @@ import { import { SignageContextService } from '../signage-context.service'; import { playlistScheduleExpiryTooltip, - playlistScheduleLabel, playlistScheduleNextPlayLabels, } from '../signage-playlist.util'; import { @@ -25,6 +24,7 @@ import { } from '../signage-template-mapping'; import { SignageTemplateService } from '../templates/signage-template.service'; import { SignageZoneService } from '../zones/signage-zone.service'; +import { scheduleSummary } from './schedule-summary.util'; export type TemplateMappingTargetType = 'display' | 'zone'; @@ -335,6 +335,7 @@ export type TemplateMappingTargetType = 'display' | 'zone'; }) export class TemplateMappingsComponent { private readonly _context = inject(SignageContextService); + private readonly _locale = inject(LocaleService); private readonly _template_service = inject(SignageTemplateService); private readonly _zone_service = inject(SignageZoneService); @@ -396,7 +397,9 @@ export class TemplateMappingsComponent { ); } - public scheduleLabel = playlistScheduleLabel; + public scheduleLabel(schedule: Partial) { + return scheduleSummary(schedule, this._locale.locale); + } public scheduleTooltip(schedule: Partial) { const labels = playlistScheduleNextPlayLabels(schedule); diff --git a/apps/signage-manager/src/app/signage-context.service.ts b/apps/signage-manager/src/app/signage-context.service.ts index 1ada2695b58..10babe4e8b6 100644 --- a/apps/signage-manager/src/app/signage-context.service.ts +++ b/apps/signage-manager/src/app/signage-context.service.ts @@ -8,6 +8,7 @@ import { resource, signal, untracked, + type WritableSignal, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { @@ -36,6 +37,7 @@ import { } from '@placeos/ts-client'; import { decodeEntityNames } from './shared/decode-entity-names.util'; import { byName } from './shared/paged-search'; +import { storedSignal } from './shared/stored-signal'; import { effectiveFeatures, SIGNAGE_FEATURE_IDS, @@ -65,6 +67,37 @@ const SIGNAGE_SHARE_CONFIG = { } as const; const SIGNAGE_GROUP_STORAGE_KEY = 'PlaceOS.SIGNAGE:selected-group:v1'; +/** Choice made in an approval request modal */ +export interface ApprovalRequestChoice { + approver_id: string; + message: string; +} + +/** Parts of an approval request that differ between playlists and templates */ +export interface ApprovalRequestFlow { + item_id: string; + /** Set while the groups and approvers load */ + loading: WritableSignal; + /** Approve at once, for users who can approve */ + approve: () => Promise; + /** Query of the item type in one group, to find the groups that hold it */ + query: (group_id: string) => Promise<{ data?: { id: string }[] }>; + list_approvers: (group_id: string) => Promise; + /** Let the user pick an approver. Undefined when cancelled. */ + pick: (approvers: A[]) => Promise; + request: ( + group_id: string, + message: string, + approver_id: string, + ) => Promise; + /** Mark the item as waiting for approval */ + requested: () => void; + no_groups_key: string; + no_approvers_key: string; + error_key: string; + success_key: string; +} + /** Signage flags of a group, with the ID of the group they were read for */ interface LoadedGroupFeatures { group_id: string; @@ -105,29 +138,6 @@ export enum SignageGroupPermission { Share = 1 << 7, } -function loadSelectedGroupId() { - if (typeof localStorage === 'undefined') return ''; - try { - return localStorage.getItem(SIGNAGE_GROUP_STORAGE_KEY) || ''; - } catch { - // Local storage can be unavailable in private browsing or restricted embeds. - return ''; - } -} - -function persistSelectedGroupId(group_id: string) { - if (typeof localStorage === 'undefined') return; - try { - if (group_id) { - localStorage.setItem(SIGNAGE_GROUP_STORAGE_KEY, group_id); - } else { - localStorage.removeItem(SIGNAGE_GROUP_STORAGE_KEY); - } - } catch { - // Ignore persistence failures; the selector can still default safely. - } -} - /** Groups with their names decoded, sorted by name */ export function sortGroups(groups: T[]) { return groups.map(decodeEntityNames).sort(byName); @@ -209,8 +219,10 @@ export class SignageContextService { private readonly _org = inject(OrganisationService); private readonly _settings = inject(SettingsService); private readonly _dialog = inject(MatDialog); - private readonly _change = signal(Date.now()); - private readonly _groups_change = signal(Date.now()); + // Counters, not timestamps: two saves in the same millisecond must still + // change the value + private readonly _change = signal(0); + private readonly _groups_change = signal(0); /** Whether the navigation offers a group selector. Hiding it leaves the * section header breadcrumbs as the way to change group. */ @@ -244,7 +256,12 @@ export class SignageContextService { status === 'resolved' || status === 'local' || status === 'error' ); }); - public readonly selected_group_id = signal(loadSelectedGroupId()); + /** Stored, so the app opens in the last group. "All groups" stores none. */ + public readonly selected_group_id = storedSignal( + SIGNAGE_GROUP_STORAGE_KEY, + (stored) => stored || '', + { serialise: (group_id) => group_id || null }, + ); /** * Changes each time the user switches group. Services keep their * selected item in a `linkedSignal` of it, so a switch clears them. @@ -299,7 +316,7 @@ export class SignageContextService { public readonly signage_groups_failed = signal(false); /** Load the signage groups again, after a save or a failed request */ public reloadSignageGroups() { - this._groups_change.set(Date.now()); + this._groups_change.update((count) => count + 1); } // Keeps the groups while a save reloads them, or when the reload fails, // so the selected group and its permissions stay. A new user starts empty. @@ -536,8 +553,6 @@ export class SignageContextService { this.can_manage_all_groups() ? '' : groups[0].group.id, ); }); - - effect(() => persistSelectedGroupId(this.selected_group_id())); } /** Whether the organisation has loaded and the lists can be queried */ @@ -547,7 +562,7 @@ export class SignageContextService { /** Mark signage data as saved, so the lists reload */ public changed() { - this._change.set(Date.now()); + this._change.update((count) => count + 1); } public canManageSignageGroup(group_id = '') { @@ -710,6 +725,48 @@ export class SignageContextService { return groups.filter((_, index) => matches[index]); } + /** + * Ask a group approver to approve an item. Users who can approve + * approve it at once instead. Shows warnings and errors on failure. + */ + public async requestApproval(flow: ApprovalRequestFlow) { + if (flow.loading()) return; + if (this.can_approve()) { + await flow.approve(); + return; + } + let approvers: A[] = []; + let group: PlaceCurrentGroup | undefined; + flow.loading.set(true); + try { + [group] = await this.groupsHolding(flow.item_id, flow.query); + if (!group) { + notifyWarn(i18n(flow.no_groups_key)); + return; + } + approvers = (await flow.list_approvers(group.group.id)) || []; + } catch { + notifyWarn(i18n(flow.no_approvers_key)); + } finally { + flow.loading.set(false); + } + if (!group) return; + const choice = await flow.pick(approvers); + if (!choice) return; + try { + await flow.request( + group.group.id, + choice.message || '', + choice.approver_id || '', + ); + } catch { + notifyError(i18n(flow.error_key)); + return; + } + flow.requested(); + notifySuccess(i18n(flow.success_key)); + } + /** Let the user pick a group from the group select modal */ private async _pickGroup(data: { title: string; diff --git a/apps/signage-manager/src/app/signage-playlist.util.ts b/apps/signage-manager/src/app/signage-playlist.util.ts index 041c31e218b..a939a9674dd 100644 --- a/apps/signage-manager/src/app/signage-playlist.util.ts +++ b/apps/signage-manager/src/app/signage-playlist.util.ts @@ -4,11 +4,8 @@ import { DEFAULT_PLAY_PERIOD_MINUTES, doesCronMatchDay, hasPlayableScheduleMask, - isCronMonthlyWeekday, isPlayOnceSchedule, mediaAnimation, - parseCronNumber, - parseCronWeekdays, parsePlayAtLocal, } from '@placeos/common'; import { @@ -17,21 +14,16 @@ import { type SignagePlaylistItemSchedule, type SignagePlaylistSchedule, } from '@placeos/ts-client'; -import { format, formatDistance, fromUnixTime, getUnixTime } from 'date-fns'; +import { format, fromUnixTime, getUnixTime } from 'date-fns'; import { fromZonedTime, toZonedTime } from 'date-fns-tz'; import { nextCronDates } from './signage-cron.util'; +import { uploadUrl } from './signage-url.util'; /** Format a date as a `play_at_local` value in the viewer's timezone. */ export function formatPlayAtLocal(date: Date | number) { return format(date, "yyyy-MM-dd'T'HH:mm:ss"); } -/** Play once start time for labels. Local times note the display timezone. */ -function playOnceLabel(schedule: Partial) { - const start = playOnceStart(schedule)?.toLocaleString() ?? ''; - return schedule.play_at ? start : `${start} display local time`; -} - /** * Start of a play once schedule, or null for a recurring schedule. * The viewer's timezone resolves `play_at_local` values. @@ -42,16 +34,6 @@ export function playOnceStart(schedule: Partial) { return local_time === null ? null : new Date(local_time); } -const WEEKDAY_NAMES = [ - 'Sunday', - 'Monday', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - 'Saturday', -]; - /** * Default animation of a playlist that ts-client loaded. The API returns * the index of the animation, and `SignagePlaylist` replaces a falsy value @@ -80,9 +62,7 @@ export function playlistMediaThumbnailUrl(item: SignageMedia) { } export function playlistMediaUrl(item: SignageMedia) { - return item?.media_id - ? `/api/engine/v2/uploads/${item.media_id}/url` - : item?.media_uri || ''; + return item?.media_id ? uploadUrl(item.media_id) : item?.media_uri || ''; } export function playlistMediaIcon(item: SignageMedia) { @@ -162,136 +142,13 @@ export function playlistItemScheduleMap(list: { return map; } -/** English ordinal of a number, e.g. "1st" or "12th" */ -export function ordinal(value: number) { - if (value >= 11 && value <= 13) return `${value}th`; - switch (value % 10) { - case 1: - return `${value}st`; - case 2: - return `${value}nd`; - case 3: - return `${value}rd`; - default: - return `${value}th`; - } -} - -function formatCronTime(hour_part: string, minute_part: string) { - const date = new Date(); - date.setHours(+hour_part || 0, +minute_part || 0, 0, 0); - return date.toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - }); -} - -function durationLabel(duration_minutes: number) { - if (!duration_minutes) return 'one playlist pass'; - if (duration_minutes < 60) { - return `${duration_minutes} minute${duration_minutes === 1 ? '' : 's'}`; - } - if (duration_minutes % 60 === 0) { - const hours = duration_minutes / 60; - return `${hours} hour${hours === 1 ? '' : 's'}`; - } - const hours = Math.floor(duration_minutes / 60); - const minutes = duration_minutes % 60; - return `${hours} hr ${minutes} min`; -} - -/** Days of the month in a plain list such as "1,15". Empty for other values. */ -export function parseCronMonthDays(value: string) { - if (!value || value === '*') return []; - const days = value.split(',').map((part) => parseCronNumber(part, 1, 31)); - return days.every((day) => day !== null) - ? [...new Set(days)].sort((a, b) => a - b) - : []; -} - -function listText(values: string[]) { - if (values.length <= 1) return values[0] || ''; - if (values.length === 2) return `${values[0]} and ${values[1]}`; - return `${values.slice(0, -1).join(', ')} and ${values.at(-1)}`; -} - -function weekOfMonthLabel(day_part: string) { - const [start, end] = day_part.split('-').map(Number); - if (start === 1 && end === 7) return '1st'; - if (start === 8 && end === 14) return '2nd'; - if (start === 15 && end === 21) return '3rd'; - if (start === 22 && end === 28) return '4th'; - if (start === 29 && end === 31) return '5th'; - return ''; -} - -function weekOfMonthLabels(day_part: string) { - const labels = day_part.split(',').map((range) => weekOfMonthLabel(range)); - return labels.every((label) => label) ? labels : []; -} - -function humanizeCronSchedule(cron: string, duration_minutes: number) { - const parts = (cron || '0 0 * * *').trim().split(/\s+/); - if (parts.length !== 5) return `Custom schedule (${cron})`; - const [minute, hour, day, month, day_of_week] = parts; - const duration = durationLabel(duration_minutes); - const suffix = ` for ${duration}`; - if (month !== '*') return `Custom schedule (${cron})`; - const minute_interval = /^\*\/(\d+)$/.exec(minute)?.[1]; - if (minute === '*' && hour === '*' && day === '*' && day_of_week === '*') { - return `Every minute${suffix}`; - } - if (minute_interval && hour === '*' && day === '*' && day_of_week === '*') { - return `Every ${minute_interval} minutes${suffix}`; - } - const hour_interval = /^\*\/(\d+)$/.exec(hour)?.[1]; - if (minute === '0' && hour === '*' && day === '*' && day_of_week === '*') { - return `Every hour${suffix}`; - } - if (minute === '0' && hour_interval && day === '*' && day_of_week === '*') { - return `Every ${hour_interval} hours${suffix}`; - } - if (!/^\d+$/.test(minute) || !/^\d+$/.test(hour)) { - return `Custom schedule (${cron})`; - } - const time = formatCronTime(hour, minute); - if (day === '*' && day_of_week === '*') { - return `Every day at ${time}${suffix}`; - } - if (day === '*' && day_of_week === '1-5') { - return `Weekdays at ${time}${suffix}`; - } - if (day === '*' && day_of_week !== '*') { - const weekdays = parseCronWeekdays(day_of_week).map( - (day_value) => WEEKDAY_NAMES[day_value], - ); - return weekdays.length - ? `Every ${listText(weekdays)} at ${time}${suffix}` - : `Custom schedule (${cron})`; - } - if (day !== '*' && day_of_week === '*') { - const days = parseCronMonthDays(day).map((day_value) => - ordinal(day_value), - ); - return days.length - ? `On the ${listText(days)} of each month at ${time}${suffix}` - : `Custom schedule (${cron})`; - } - if (isCronMonthlyWeekday(day, day_of_week)) { - const weeks = weekOfMonthLabels(day); - const weekdays = parseCronWeekdays(day_of_week).map( - (day_value) => WEEKDAY_NAMES[day_value], - ); - return weeks.length && weekdays.length - ? `On the ${listText(weeks)} ${listText(weekdays)} of each month at ${time}${suffix}` - : `Custom schedule (${cron})`; - } - return `Custom schedule (${cron})`; -} - -function schedulePeriod(schedule: Partial) { +/** + * Play length of a schedule in minutes. A schedule without one plays all + * day. A length of 0 plays the playlist once. + */ +export function playPeriodMinutes(schedule: Partial) { return Number.isFinite(schedule.play_period) - ? schedule.play_period || 0 + ? Math.max(0, schedule.play_period || 0) : DEFAULT_PLAY_PERIOD_MINUTES; } @@ -320,18 +177,6 @@ export function playlistLoopDuration( ); } -export function playlistScheduleExpiryLabel( - schedule: Partial, - now = Date.now(), -) { - if (!schedule.valid_until) return ''; - const expiry = fromUnixTime(schedule.valid_until); - const distance = formatDistance(expiry, new Date(now)); - const relative_time = - expiry.getTime() >= now ? `${distance} from now` : `${distance} ago`; - return `until ${relative_time}`; -} - export function playlistScheduleExpiryTooltip( schedule: Partial, ) { @@ -397,30 +242,6 @@ export function playlistStatus( return requests[playlist.id] ? 'awaiting_review' : 'awaiting_approval'; } -export function playlistScheduleLabel( - schedule: Partial, -) { - const period = schedulePeriod(schedule); - const expiry = playlistScheduleExpiryLabel(schedule); - const suffix = [ - schedule.play_takeover ? 'takeover' : '', - expiry, - schedule.mask - ? `mask ${schedule.mask}, repeats every ${schedule.mask.length} instances` - : '', - ] - .filter((_) => _) - .join(' · '); - if (isPlayOnceSchedule(schedule)) { - return `Plays once on ${playOnceLabel(schedule)} for ${durationLabel(period)}${ - suffix ? ` · ${suffix}` : '' - }`; - } - return `${humanizeCronSchedule(schedule.play_cron || '0 0 * * *', period)}${ - suffix ? ` · ${suffix}` : '' - }`; -} - /** * Count cron instances from valid_from, before applying the repeating mask. * Cache complete days so successive preview times do not recount the past. @@ -505,10 +326,16 @@ export function createScheduleMaskFilter( /** * Date and time of a play, e.g. "Mon, Jan 5, 9:00 AM". * @param timeZone IANA timezone. The viewer's timezone when not set. + * @param hour12 12 hour clock when true, 24 hour when false. The viewer's locale decides when not set. */ -export function formatPlayDateTime(date: Date, timeZone?: string) { +export function formatPlayDateTime( + date: Date, + timeZone?: string, + hour12?: boolean, +) { return date.toLocaleString(undefined, { timeZone, + hour12, weekday: 'short', month: 'short', day: 'numeric', @@ -568,7 +395,7 @@ function nextSchedulePlays( count: number, now: number, ): PlaySession[] { - const period = schedulePeriod(schedule); + const period = playPeriodMinutes(schedule); if (isPlayOnceSchedule(schedule)) { const start = playOnceStart(schedule); if (!start) return []; @@ -606,7 +433,7 @@ export function playlistNextPlayTime( // One-off plays already count until they end. const from = isPlayOnceSchedule(schedule) ? now - : now - schedulePeriod(schedule) * 60_000; + : now - playPeriodMinutes(schedule) * 60_000; const [play] = nextSchedulePlays(schedule, 1, from); if (play) next = Math.min(next, Math.max(play.start.getTime(), now)); } diff --git a/apps/signage-manager/src/app/signage-url.util.ts b/apps/signage-manager/src/app/signage-url.util.ts index f2561710130..f6665bb1d59 100644 --- a/apps/signage-manager/src/app/signage-url.util.ts +++ b/apps/signage-manager/src/app/signage-url.util.ts @@ -21,3 +21,23 @@ export function isWebPageUrl(url: string): boolean { export function webPageFrameUrl(url: string): string { return isWebPageUrl(url) ? url : 'about:blank'; } + +/** URL that serves an upload, relative to the page origin */ +export function uploadUrl(upload_id: string) { + return `/api/engine/v2/uploads/${encodeURIComponent(upload_id)}/url`; +} + +/** + * Link to a route of the signage player, with debug output on. + * @param setting Player path from the `signage_path` setting. Zone metadata + * can override it, so a scheme that would run script in this origin, such + * as `javascript:`, falls back to `/signage`. + * @param route Player route after `#/`, with its parts already encoded + */ +export function signagePlayerUrl(setting: string | undefined, route: string) { + const signage_path = + setting && parseWebUrl(setting, document.baseURI) + ? setting + : '/signage'; + return `${signage_path.replace(/\/$/, '')}/#/${route}?debug=true`; +} diff --git a/apps/signage-manager/src/app/templates/signage-template.service.ts b/apps/signage-manager/src/app/templates/signage-template.service.ts index 13cdf0cd031..8f3e4fc667e 100644 --- a/apps/signage-manager/src/app/templates/signage-template.service.ts +++ b/apps/signage-manager/src/app/templates/signage-template.service.ts @@ -16,12 +16,10 @@ import { notifyWarn, OrganisationService, } from '@placeos/common'; -import { ConfirmModalComponent, openConfirmModal } from '@placeos/components'; import { addSignageTemplate, addSignageTemplateMapping, listSignageTemplateApprovers, - PlaceCurrentGroup, query, querySignageTemplates, removeSignageTemplate, @@ -36,6 +34,7 @@ import { updateSignageTemplate, updateSignageTemplateMapping, } from '@placeos/ts-client'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; import { PagedList } from '../shared/paged-list'; import { byName } from '../shared/paged-search'; @@ -387,7 +386,8 @@ export class SignageTemplateService { ) ) return false; - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n( 'SIGNAGE_MANAGER.SVC_REMOVE_TEMPLATE_MAPPING_TITLE', @@ -398,22 +398,16 @@ export class SignageTemplateService { ), icon: { content: 'delete' }, }, - this._dialog, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVE_ERROR', + action: () => removeSignageTemplateMapping(mapping.id), + }, ); - if (result.reason !== 'done') return false; - try { - await removeSignageTemplateMapping(mapping.id); - this.template_mappings_revision.update((value) => value + 1); - result.close(); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVED')); - return true; - } catch { - result.close(); - notifyError( - i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVE_ERROR'), - ); - return false; - } + if (!removed) return false; + this.template_mappings_revision.update((value) => value + 1); + notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_MAPPING_REMOVED')); + return true; } public async approveTemplate(template: SignageTemplate) { @@ -436,56 +430,42 @@ export class SignageTemplateService { public async requestTemplateApproval(template: SignageTemplate) { if (!template?.id || this.template_approval_request_loading()) return; if (this._templateLayoutUnsaved(template)) return; - if (this._context.can_approve()) { - await this.approveTemplate(template); - return; - } - let approvers: SignageTemplateApprover[] = []; - let group: PlaceCurrentGroup | null = null; - this.template_approval_request_loading.set(true); - try { - [group] = await this._context.groupsHolding( - template.id, - (group_id) => querySignageTemplates({ group_id, limit: 500 }), - ); - if (!group) { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_TEMPLATE')); - return; - } - approvers = - ((await listSignageTemplateApprovers( - group.group.id, - )) as SignageTemplateApprover[]) || []; - } catch { - notifyWarn(i18n('SIGNAGE_MANAGER.SVC_NO_TEMPLATE_APPROVERS')); - } finally { - this.template_approval_request_loading.set(false); - } - if (!group) return; - const { TemplateRequestApprovalModalComponent } = - await import('../shared/template-request-approval-modal.component'); - const ref = this._dialog.open(TemplateRequestApprovalModalComponent, { - data: { template, approvers }, - panelClass: 'mobile-fullscreen', + await this._context.requestApproval({ + item_id: template.id, + loading: this.template_approval_request_loading, + approve: () => this.approveTemplate(template), + query: (group_id) => + querySignageTemplates({ group_id, limit: 500 }), + list_approvers: (group_id) => + listSignageTemplateApprovers(group_id) as Promise< + SignageTemplateApprover[] + >, + pick: async (approvers) => { + const { TemplateRequestApprovalModalComponent } = + await import('../shared/template-request-approval-modal.component'); + const ref = this._dialog.open( + TemplateRequestApprovalModalComponent, + { + data: { template, approvers }, + panelClass: 'mobile-fullscreen', + }, + ); + return dialogClosed(ref); + }, + request: (group_id, message, approver_id) => + requestApprovalSignageTemplate( + template.id, + group_id, + message, + approver_id, + ), + requested: () => + this.setTemplateApprovalStatus(template.id, false, true), + no_groups_key: 'SIGNAGE_MANAGER.SVC_NO_GROUPS_FOR_TEMPLATE', + no_approvers_key: 'SIGNAGE_MANAGER.SVC_NO_TEMPLATE_APPROVERS', + error_key: 'SIGNAGE_MANAGER.SVC_TEMPLATE_APPROVAL_REQUEST_ERROR', + success_key: 'SIGNAGE_MANAGER.SVC_TEMPLATE_APPROVAL_REQUESTED', }); - const result: TemplateRequestApprovalModalResult | undefined = - await dialogClosed(ref); - if (!result) return; - try { - await requestApprovalSignageTemplate( - template.id, - group.group.id, - result.message || '', - result.approver_id || '', - ); - } catch { - notifyError( - i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_APPROVAL_REQUEST_ERROR'), - ); - return; - } - this.setTemplateApprovalStatus(template.id, false, true); - notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_APPROVAL_REQUESTED')); } /** @@ -501,7 +481,8 @@ export class SignageTemplateService { ) ) return false; - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_TEMPLATE_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -509,19 +490,17 @@ export class SignageTemplateService { }), icon: { content: 'delete' }, }, - this._dialog, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_TEMPLATE_REMOVE_ERROR', + action: () => + removeSignageTemplate( + template.id, + this._context.groupQueryParams({}), + ), + }, ); - if (result.reason !== 'done') return false; - try { - await removeSignageTemplate( - template.id, - this._context.groupQueryParams({}), - ); - } catch { - result.close(); - notifyError(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_REMOVE_ERROR')); - return false; - } + if (!removed) return false; this._releaseDraft(template.id); // The reload keeps the loaded rows on screen, so drop this one now this._template_list.update((items) => @@ -534,7 +513,6 @@ export class SignageTemplateService { } this._context.changed(); notifySuccess(i18n('SIGNAGE_MANAGER.SVC_TEMPLATE_REMOVED')); - result.close(); return true; } @@ -632,7 +610,8 @@ export class SignageTemplateService { ) ) return false; - const result = await openConfirmModal( + const undone = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'), content: i18n('SIGNAGE_MANAGER.TEMPLATE_REVERT_CONFIRM', { @@ -641,24 +620,13 @@ export class SignageTemplateService { confirm_text: i18n('SIGNAGE_MANAGER.UNDO_CHANGES'), icon: { content: 'undo' }, }, - this._dialog, + { + loading_key: 'SIGNAGE_MANAGER.UNDOING_CHANGES', + error_key: 'SIGNAGE_MANAGER.TEMPLATE_REVERT_ERROR', + action: () => removeSignageTemplateDraft(template_id), + }, ); - if (result.reason !== 'done') return false; - result.loading(i18n('SIGNAGE_MANAGER.UNDOING_CHANGES')); - // Keep the confirmation open until the draft is gone. Closing it - // would not stop the request. - const confirm_ref = this._dialog.openDialogs.at(-1); - if (confirm_ref?.componentInstance instanceof ConfirmModalComponent) { - confirm_ref.disableClose = true; - } - try { - await removeSignageTemplateDraft(template_id); - } catch { - notifyError(i18n('SIGNAGE_MANAGER.TEMPLATE_REVERT_ERROR')); - return false; - } finally { - result.close(); - } + if (!undone) return false; this.updateCachedTemplate(previous_version); notifySuccess(i18n('SIGNAGE_MANAGER.TEMPLATE_REVERTED')); this._context.changed(); diff --git a/apps/signage-manager/src/app/templates/template-layout-list.component.ts b/apps/signage-manager/src/app/templates/template-layout-list.component.ts index 8bad4ec4336..a8dfeb8d4a4 100644 --- a/apps/signage-manager/src/app/templates/template-layout-list.component.ts +++ b/apps/signage-manager/src/app/templates/template-layout-list.component.ts @@ -21,7 +21,7 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; -import { i18n, notifyWarn } from '@placeos/common'; +import { i18n, LocaleService, notifyWarn } from '@placeos/common'; import { AuthenticatedImageDirective, buildFormFromFields, @@ -38,12 +38,12 @@ import { SignageTemplateMapping, } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; +import { scheduleSummary } from '../shared/schedule-summary.util'; import { SectionTab, SectionTabsComponent, } from '../shared/section-tabs.component'; import { SignageContextService } from '../signage-context.service'; -import { playlistScheduleLabel } from '../signage-playlist.util'; import { SignagePluginService } from '../signage-plugin.service'; import { pluginName, @@ -737,6 +737,7 @@ import { }) export class TemplateLayoutListComponent { private readonly _context = inject(SignageContextService); + private readonly _locale = inject(LocaleService); private readonly _display_service = inject(SignageDisplayService); private readonly _plugin_service = inject(SignagePluginService); private readonly _template_service = inject(SignageTemplateService); @@ -858,7 +859,7 @@ export class TemplateLayoutListComponent { public mappingSchedule(mapping: SignageTemplateMapping) { return mapping.schedule - ? playlistScheduleLabel(mapping.schedule) + ? scheduleSummary(mapping.schedule, this._locale.locale) : i18n('SIGNAGE_MANAGER.DEFAULT_TEMPLATE'); } diff --git a/apps/signage-manager/src/app/templates/template-preview.component.ts b/apps/signage-manager/src/app/templates/template-preview.component.ts index b7a49a9f613..1a763ad2c86 100644 --- a/apps/signage-manager/src/app/templates/template-preview.component.ts +++ b/apps/signage-manager/src/app/templates/template-preview.component.ts @@ -29,6 +29,7 @@ import { mediaThumbnail } from '@placeos/ts-client'; import { SignageDisplayService } from '../displays/signage-display.service'; import { SignagePluginService } from '../signage-plugin.service'; import { pluginName } from '../signage-plugin.util'; +import { signagePlayerUrl } from '../signage-url.util'; import { SignageTemplateService } from './signage-template.service'; import { applyLayoutPositionDefaults, @@ -330,14 +331,10 @@ export class TemplatePreviewComponent { const template_id = this.live_template_id(); const display_id = this.selected_display_id(); if (!template_id || !display_id) return ''; - // Zone metadata can override the path, so refuse schemes that would - // run script in this origin, such as `javascript:` - const setting = this.signage_path(); - const signage_path = - setting && parseWebUrl(setting, document.baseURI) - ? setting - : '/signage'; - return `${signage_path.replace(/\/$/, '')}/#/template/${encodeURIComponent(template_id)}/${encodeURIComponent(display_id)}?debug=true`; + return signagePlayerUrl( + this.signage_path(), + `template/${encodeURIComponent(template_id)}/${encodeURIComponent(display_id)}`, + ); }); /** diff --git a/apps/signage-manager/src/app/zones/signage-zone.service.ts b/apps/signage-manager/src/app/zones/signage-zone.service.ts index 933ff2fcac4..af2f7ff8d7c 100644 --- a/apps/signage-manager/src/app/zones/signage-zone.service.ts +++ b/apps/signage-manager/src/app/zones/signage-zone.service.ts @@ -1,14 +1,12 @@ import { computed, debounced, - effect, inject, Injectable, linkedSignal, type Resource, resource, signal, - untracked, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { @@ -17,7 +15,6 @@ import { notifySuccess, OrganisationService, } from '@placeos/common'; -import { openConfirmModal } from '@placeos/components'; import { addZone as createZone, removeZone as deleteZone, @@ -30,7 +27,9 @@ import { } from '@placeos/ts-client'; import { displayZoneIds, type ZoneNode } from '../displays/display-zones.util'; import { SignagePlaylistService } from '../playlists/signage-playlist.service'; +import { confirmThenRun } from '../shared/confirm-then-run'; import { decodeEntityNames } from '../shared/decode-entity-names.util'; +import { PlaylistAssignments } from '../shared/playlist-assignments'; import { SignageContextService } from '../signage-context.service'; import { dialogClosed, mergeItems } from '../signage-service.util'; import type { ZoneEditFormModel } from './zone-edit-modal.component'; @@ -58,15 +57,6 @@ export class SignageZoneService { private readonly _zone_overrides = signal>({}); - // Local edits only bridge the gap until the lists reload. Drop them when - // the group or data changes, so a stale copy never hides newer server data - // or sends an old version with the next patch. - private readonly _clear_overrides = effect(() => { - this._context.api_group_id(); - this._context.data_change(); - untracked(() => this._zone_overrides.set({})); - }); - /** Search reachable zones, or the direct children of a selected parent. */ public querySelectableZones( search: string, @@ -252,6 +242,18 @@ export class SignageZoneService { source: this._context.group_switch, computation: () => null, }); + private readonly _assignments = new PlaylistAssignments({ + context: this._context, + playlist_service: this._playlist_service, + dialog: this._dialog, + overrides: this._zone_overrides, + selected: this.selected_zone, + target: 'zone_id', + already_assigned_key: 'SIGNAGE_MANAGER.SVC_PLAYLIST_IN_ZONE', + add_error_key: 'SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR', + save_playlists: (zone, playlists) => + updateZone(zone.id, { playlists, version: zone.version }, 'patch'), + }); // Cleared when the selected zone changes, as the search is within it public readonly zone_search_term = linkedSignal({ source: () => this.selected_zone()?.id, @@ -300,30 +302,11 @@ export class SignageZoneService { ); } - /** - * Keep a saved zone until the lists reload. - * @returns The zone with its names decoded, to use for selection - */ - private _cacheZone(zone: PlaceZone) { - const item = decodeEntityNames(zone); - if (!item?.id) return item; - this._zone_overrides.update((state) => ({ - ...state, - [item.id]: item, - })); - return item; - } - public async addPlaylistToZone(zone: PlaceZone) { - if (!this._canAssign()) return; - const { PlaylistSelectModalComponent } = - await import('../shared/playlist-select-modal.component'); - const ref = this._dialog.open(PlaylistSelectModalComponent, { - panelClass: 'mobile-fullscreen', - }); - const playlist_id = await dialogClosed(ref); + if (!this._assignments.canAssign()) return; + const playlist_id = await this._assignments.pickPlaylist(); if (!playlist_id) return; - const updated = await this._addZonePlaylist( + const updated = await this._assignments.add( zone, { playlist_id }, 'SIGNAGE_MANAGER.SVC_PLAYLIST_ADDED_ZONE', @@ -332,7 +315,7 @@ export class SignageZoneService { } public async removePlaylistFromZone(zone: PlaceZone, playlist_id: string) { - const updated = await this._removeZonePlaylist( + const updated = await this._assignments.remove( zone, playlist_id, 'SIGNAGE_MANAGER.SVC_PLAYLIST_REMOVED_ZONE', @@ -341,7 +324,7 @@ export class SignageZoneService { } public async addZoneToPlaylist(playlist: SignagePlaylist) { - if (!this._canAssign()) return; + if (!this._assignments.canAssign()) return; const { ZoneSelectModalComponent } = await import('../shared/zone-select-modal.component'); const ref = this._dialog.open(ZoneSelectModalComponent, { @@ -358,104 +341,24 @@ export class SignageZoneService { notifyError(i18n('SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR')); return; } - const updated = await this._addZonePlaylist( + const updated = await this._assignments.add( zone, { playlist_id: playlist.id, playlist }, 'SIGNAGE_MANAGER.SVC_ZONE_ADDED_PLAYLIST', ); - this._updateSelection(updated); + this._assignments.updateSelection(updated); } public async removeZoneFromPlaylist( playlist: SignagePlaylist, zone: PlaceZone, ) { - const updated = await this._removeZonePlaylist( + const updated = await this._assignments.remove( zone, playlist.id, 'SIGNAGE_MANAGER.SVC_ZONE_REMOVED_PLAYLIST', ); - this._updateSelection(updated); - } - - private _canAssign() { - return this._context.requirePermission( - this._context.can_update(), - 'SIGNAGE_MANAGER.SVC_NO_UPDATE_ASSIGNMENTS', - ); - } - - /** Show a saved zone in place of the selected zone, when it is that zone */ - private _updateSelection(zone: PlaceZone | null) { - if (zone && this.selected_zone()?.id === zone.id) { - this.selected_zone.set(zone); - } - } - - /** - * Add a playlist to a zone, after a check for takeover conflicts. - * @returns The saved zone, or null when nothing was saved - */ - private async _addZonePlaylist( - zone: PlaceZone, - change: { playlist_id: string; playlist?: SignagePlaylist }, - success_key: string, - ) { - if (zone.playlists?.includes(change.playlist_id)) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_PLAYLIST_IN_ZONE')); - return null; - } - const confirmed = await this._playlist_service.confirmTakeoverChange({ - ...change, - zone_id: zone.id, - }); - if (!confirmed) return null; - return this._saveZonePlaylists( - zone, - [...(zone.playlists || []), change.playlist_id], - success_key, - ); - } - - /** - * Take a playlist off a zone. - * @returns The saved zone, or null when nothing was saved - */ - private async _removeZonePlaylist( - zone: PlaceZone, - playlist_id: string, - success_key: string, - ) { - if (!this._canAssign()) return null; - return this._saveZonePlaylists( - zone, - (zone.playlists || []).filter((id) => id !== playlist_id), - success_key, - ); - } - - /** - * Save the playlists of a zone and keep the result until the lists reload. - * @returns The saved zone, or null after showing an error when it fails - */ - private async _saveZonePlaylists( - zone: PlaceZone, - playlists: string[], - success_key: string, - ) { - const updated = await updateZone( - zone.id, - { playlists, version: zone.version }, - 'patch', - ).catch(() => null); - if (!updated) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_ASSIGNMENT_ERROR')); - return null; - } - const saved = this._cacheZone(updated); - this._context.changed(); - notifySuccess(i18n(success_key)); - return saved; + this._assignments.updateSelection(updated); } public async addZone() { @@ -532,7 +435,7 @@ export class SignageZoneService { notifyError(i18n('SIGNAGE_MANAGER.SVC_SIGNAGE_ZONE_SAVE_ERROR')); return null; } - const saved = this._cacheZone(result); + const saved = this._assignments.cache(result); this.zone_tree_children_cache.set({}); this.selected_zone.set(saved); this._context.changed(); @@ -561,7 +464,8 @@ export class SignageZoneService { public async removeZone(zone: PlaceZone) { if (!zone?.id || !zone.tags?.includes('signage')) return false; if (!this._canManageZones()) return false; - const result = await openConfirmModal( + const removed = await confirmThenRun( + this._dialog, { title: i18n('SIGNAGE_MANAGER.SVC_REMOVE_SIGNAGE_ZONE_TITLE'), content: i18n('SIGNAGE_MANAGER.SVC_DELETE_NAMED', { @@ -569,18 +473,13 @@ export class SignageZoneService { }), icon: { content: 'delete' }, }, - this._dialog, - ); - if (result.reason !== 'done') return false; - const removed = await deleteZone(zone.id).then( - () => true, - () => false, + { + loading_key: 'SIGNAGE_MANAGER.SVC_REMOVING', + error_key: 'SIGNAGE_MANAGER.SVC_SIGNAGE_ZONE_REMOVE_ERROR', + action: () => deleteZone(zone.id), + }, ); - result.close(); - if (!removed) { - notifyError(i18n('SIGNAGE_MANAGER.SVC_SIGNAGE_ZONE_REMOVE_ERROR')); - return false; - } + if (!removed) return false; this._zone_overrides.update((overrides) => { const next = { ...overrides }; delete next[zone.id]; diff --git a/apps/signage-manager/src/tests/media/signage-media.service.spec.ts b/apps/signage-manager/src/tests/media/signage-media.service.spec.ts index f7da6b5e486..b68efd0b10a 100644 --- a/apps/signage-manager/src/tests/media/signage-media.service.spec.ts +++ b/apps/signage-manager/src/tests/media/signage-media.service.spec.ts @@ -589,6 +589,7 @@ describe('SignageMediaService', () => { expect.stringMatching( new RegExp(`/uploads/screenshot-${screenshot_count}$`), ), + { response_type: 'void' }, ); }); @@ -650,6 +651,7 @@ describe('SignageMediaService', () => { expect.stringMatching( new RegExp(`/uploads/screenshot-${screenshot_count}$`), ), + { response_type: 'void' }, ); expect(addSignageMedia).toHaveBeenCalledWith( expect.not.objectContaining({ diff --git a/apps/signage-manager/src/tests/playlists/playlist-item-details.component.spec.ts b/apps/signage-manager/src/tests/playlists/playlist-item-details.component.spec.ts index 415db9ddcf5..d324bcc087e 100644 --- a/apps/signage-manager/src/tests/playlists/playlist-item-details.component.spec.ts +++ b/apps/signage-manager/src/tests/playlists/playlist-item-details.component.spec.ts @@ -215,7 +215,7 @@ describe('PlaylistItemDetailsComponent', () => { const component = await make(); const labels = component.schedule_labels(); expect(labels.length).toBe(1); - expect(labels[0]).toContain('Every day at'); + expect(labels[0]).toContain('Starts every day at'); expect(labels[0]).toContain('for 3 hours'); expect(labels[0]).toContain(' · until '); expect(component.schedule_expiry_tooltips()).toEqual([ @@ -264,7 +264,7 @@ describe('PlaylistItemDetailsComponent', () => { schedules: [{ play_cron: '0 9 * * *', valid_from, mask: '10' }], }); const component = await make(); - expect(component.schedule_labels()[0]).toContain('mask 10'); + expect(component.schedule_labels()[0]).toContain('Mask 10'); // A mask that skips every occurrence never plays selected_playlist.set({ diff --git a/apps/signage-manager/src/tests/playlists/signage-playlist.service.spec.ts b/apps/signage-manager/src/tests/playlists/signage-playlist.service.spec.ts index 71ae32e8bec..b3a9430bd86 100644 --- a/apps/signage-manager/src/tests/playlists/signage-playlist.service.spec.ts +++ b/apps/signage-manager/src/tests/playlists/signage-playlist.service.spec.ts @@ -409,6 +409,42 @@ describe('SignagePlaylistService', () => { ); }); + it('restores the approved media after the user confirms the undo', async () => { + const service = createService(); + const refresh = vi.spyOn(service, 'refreshPlaylist'); + confirmNextDialog(); + vi.mocked(updateSignagePlaylistMedia).mockResolvedValue({} as never); + + const undone = await service.undoPlaylistChanges( + new SignagePlaylist({ id: 'pl-1', name: 'Lobby' }), + new SignagePlaylistMedia({ items: ['media-1'] }), + ); + + expect(undone).toBe(true); + expect(updateSignagePlaylistMedia).toHaveBeenCalledWith('pl-1', [ + 'media-1', + ]); + expect(refresh).toHaveBeenCalledWith('pl-1'); + expect(dialog.open.mock.results[0].value.close).toHaveBeenCalled(); + }); + + it('keeps the playlist media when the user cancels the undo', async () => { + const service = createService(); + dialog.open.mockReturnValue({ + componentInstance: { event: NEVER, loading: { set: vi.fn() } }, + afterClosed: () => of(undefined), + close: vi.fn(), + }); + + const undone = await service.undoPlaylistChanges( + new SignagePlaylist({ id: 'pl-1', name: 'Lobby' }), + new SignagePlaylistMedia({ items: ['media-1'] }), + ); + + expect(undone).toBe(false); + expect(updateSignagePlaylistMedia).not.toHaveBeenCalled(); + }); + it('closes the confirm modal and restores the items when removing them fails', async () => { const service = createService(); showPlaylistMedia(service, 'playlist-1', { diff --git a/apps/signage-manager/src/tests/shared/playlist-approve-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-approve-modal.component.spec.ts index c4ccbbf190a..d90befcce51 100644 --- a/apps/signage-manager/src/tests/shared/playlist-approve-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-approve-modal.component.spec.ts @@ -6,7 +6,6 @@ import { approveSignagePlaylist, listSignagePlaylistMediaRevisions, SignagePlaylistMedia, - updateSignagePlaylistMedia, } from '@placeos/ts-client'; import { MediaPreviewService } from '../../app/media/media-preview.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; @@ -34,8 +33,8 @@ describe('PlaylistApproveModalComponent', () => { previewMedia: vi.fn(), }; const playlist_service = { - refreshPlaylist: vi.fn(), setPlaylistApprovalStatus: vi.fn(), + undoPlaylistChanges: vi.fn(), }; beforeEach(async () => { @@ -43,8 +42,7 @@ describe('PlaylistApproveModalComponent', () => { setNotifyOutlet({ open: notify_open } as any, true); dialog_ref.disableClose = false; context.can_update.set(true); - playlist_service.refreshPlaylist.mockReset(); - (updateSignagePlaylistMedia as any).mockResolvedValue({}); + playlist_service.undoPlaylistChanges.mockResolvedValue(true); (listSignagePlaylistMediaRevisions as any).mockResolvedValue([ { id: 'current-version', @@ -178,37 +176,22 @@ describe('PlaylistApproveModalComponent', () => { expect(button_text).not.toContain('Undo Changes'); }); - it('does not undo changes when user does not have update permissions', async () => { - context.can_update.set(false); - const fixture = TestBed.createComponent(PlaylistApproveModalComponent); - const component = fixture.componentInstance; - - await component.undoChanges(); - - expect(updateSignagePlaylistMedia).not.toHaveBeenCalled(); - expect(notify_open).toHaveBeenCalledWith( - 'You cannot update playlists in this group.', - expect.anything(), - expect.objectContaining({ panelClass: ['warn'] }), - ); - }); - - it('refreshes selected playlist data after undoing changes', async () => { + it('restores the approved version through the service', async () => { const fixture = TestBed.createComponent(PlaylistApproveModalComponent); const component = fixture.componentInstance; + const previous = { id: 'previous-version', items: ['media-2'] }; (component as any).playlist_versions = () => [ { id: 'current-version', items: ['media-1'] }, - { id: 'previous-version', items: ['media-2'] }, + previous, ]; await component.undoChanges(); - expect(updateSignagePlaylistMedia).toHaveBeenCalledWith('playlist-1', [ - 'media-2', - ]); - expect(playlist_service.refreshPlaylist).toHaveBeenCalledWith( - 'playlist-1', + expect(playlist_service.undoPlaylistChanges).toHaveBeenCalledWith( + { id: 'playlist-1' }, + previous, ); + expect(dialog_ref.close).toHaveBeenCalledWith(true); }); it('blocks approval and shows an error when the versions fail to load', async () => { @@ -252,9 +235,10 @@ describe('PlaylistApproveModalComponent', () => { await component.undoChanges(); - expect(updateSignagePlaylistMedia).toHaveBeenCalledWith('playlist-1', [ - 'media-1', - ]); + expect(playlist_service.undoPlaylistChanges).toHaveBeenCalledWith( + { id: 'playlist-1' }, + expect.objectContaining({ id: 'approved', items: ['media-1'] }), + ); }); it('shows fallback icons in preview lists', async () => { diff --git a/apps/signage-manager/src/tests/shared/playlist-edit-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-edit-modal.component.spec.ts index 430c261796c..49626162f87 100644 --- a/apps/signage-manager/src/tests/shared/playlist-edit-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-edit-modal.component.spec.ts @@ -1,7 +1,11 @@ import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { form } from '@angular/forms/signals'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { + MAT_DIALOG_DATA, + MatDialog, + MatDialogRef, +} from '@angular/material/dialog'; import { HotkeysService, setNotifyOutlet } from '@placeos/common'; import { MediaAnimation, SignagePlaylist } from '@placeos/ts-client'; import { PlaylistEditModalComponent } from '../../app/shared/playlist-edit-modal.component'; @@ -24,10 +28,12 @@ describe('PlaylistEditModalComponent', () => { const onEdit = vi.fn(); const before_save = vi.fn(); const hotkey_listen = vi.fn(); + const dialog = { openDialogs: [] as unknown[] }; let hotkey_callback: () => void; beforeEach(async () => { vi.clearAllMocks(); + dialog.openDialogs = [dialog_ref]; setNotifyOutlet({ open: notify_open } as any, true); dialog_ref.disableClose = false; onEdit.mockResolvedValue({ id: 'playlist-1' }); @@ -63,6 +69,7 @@ describe('PlaylistEditModalComponent', () => { }, }, { provide: MatDialogRef, useValue: dialog_ref }, + { provide: MatDialog, useValue: dialog }, { provide: HotkeysService, useValue: { listen: hotkey_listen }, @@ -148,6 +155,7 @@ describe('PlaylistEditModalComponent', () => { }, }, { provide: MatDialogRef, useValue: dialog_ref }, + { provide: MatDialog, useValue: dialog }, { provide: HotkeysService, useValue: { listen: hotkey_listen }, @@ -353,6 +361,18 @@ describe('PlaylistEditModalComponent', () => { expect(save).toHaveBeenCalled(); }); + it('ignores the S hotkey while another dialog is on top', () => { + const fixture = TestBed.createComponent(PlaylistEditModalComponent); + const save = vi + .spyOn(fixture.componentInstance, 'savePlaylist') + .mockResolvedValue(); + dialog.openDialogs = [dialog_ref, { id: 'confirm' }]; + + hotkey_callback(); + + expect(save).not.toHaveBeenCalled(); + }); + it('ignores the S hotkey while a select has focus', () => { const fixture = TestBed.createComponent(PlaylistEditModalComponent); const save = vi @@ -412,7 +432,7 @@ describe('PlaylistEditModalComponent', () => { }, ], })); - component.addSchedule(); + component.schedule_list.add(); const play_at = Date.UTC(2026, 2, 2, 10, 30); component.model.update((model) => ({ ...model, @@ -466,7 +486,7 @@ describe('PlaylistEditModalComponent', () => { const component = fixture.componentInstance; const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() }; - component.removeSchedule(event as any, 0); + component.schedule_list.remove(event as any, 0); expect(component.model().schedules.length).toBe(1); expect(event.stopPropagation).toHaveBeenCalled(); @@ -476,19 +496,19 @@ describe('PlaylistEditModalComponent', () => { const fixture = TestBed.createComponent(PlaylistEditModalComponent); const component = fixture.componentInstance; - component.addSchedule(); - expect(component.isScheduleOpen(1)).toBe(true); - expect(component.isScheduleOpen(0)).toBe(false); + component.schedule_list.add(); + expect(component.schedule_list.isOpen(1)).toBe(true); + expect(component.schedule_list.isOpen(0)).toBe(false); - component.openSchedule(0); + component.schedule_list.toggle(0); - expect(component.isScheduleOpen(0)).toBe(true); - expect(component.isScheduleOpen(1)).toBe(false); + expect(component.schedule_list.isOpen(0)).toBe(true); + expect(component.schedule_list.isOpen(1)).toBe(false); - component.openSchedule(0); + component.schedule_list.toggle(0); - expect(component.isScheduleOpen(0)).toBe(false); - expect(component.isScheduleOpen(1)).toBe(false); + expect(component.schedule_list.isOpen(0)).toBe(false); + expect(component.schedule_list.isOpen(1)).toBe(false); }); it('shows a summary for one-off schedules', () => { diff --git a/apps/signage-manager/src/tests/shared/playlist-item-schedule-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-item-schedule-modal.component.spec.ts index b4f5436247a..5559a7451b4 100644 --- a/apps/signage-manager/src/tests/shared/playlist-item-schedule-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-item-schedule-modal.component.spec.ts @@ -161,28 +161,28 @@ describe('PlaylistItemScheduleModalComponent', () => { const component = await createComponent(); expect(component.model().schedules.length).toBe(1); - expect(component.isScheduleOpen(0)).toBe(true); + expect(component.schedule_list.isOpen(0)).toBe(true); }); it('adds a schedule and focuses the new entry', async () => { const component = await createComponent(); - component.addSchedule(); + component.schedule_list.add(); expect(component.model().schedules.length).toBe(2); - expect(component.isScheduleOpen(1)).toBe(true); - expect(component.isScheduleOpen(0)).toBe(false); + expect(component.schedule_list.isOpen(1)).toBe(true); + expect(component.schedule_list.isOpen(0)).toBe(false); }); it('removes a schedule but keeps at least one', async () => { const component = await createComponent(); const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() }; - component.addSchedule(); + component.schedule_list.add(); - component.removeSchedule(event as any, 1); + component.schedule_list.remove(event as any, 1); expect(component.model().schedules.length).toBe(1); - component.removeSchedule(event as any, 0); + component.schedule_list.remove(event as any, 0); expect(component.model().schedules.length).toBe(1); expect(event.preventDefault).toHaveBeenCalled(); }); @@ -204,9 +204,9 @@ describe('PlaylistItemScheduleModalComponent', () => { .queryAll(By.directive(ScheduleFormStubComponent)) .map(({ componentInstance }) => componentInstance); const [, second, third] = forms(); - component.openSchedule(2); + component.schedule_list.toggle(2); - component.removeSchedule( + component.schedule_list.remove( { preventDefault() {}, stopPropagation() {} } as Event, 0, ); @@ -214,17 +214,17 @@ describe('PlaylistItemScheduleModalComponent', () => { expect(forms()[0]).toBe(second); expect(forms()[1]).toBe(third); - expect(component.isScheduleOpen(1)).toBe(true); + expect(component.schedule_list.isOpen(1)).toBe(true); }); it('collapses an open schedule when toggled again', async () => { const component = await createComponent(); - component.openSchedule(0); - expect(component.isScheduleOpen(0)).toBe(false); + component.schedule_list.toggle(0); + expect(component.schedule_list.isOpen(0)).toBe(false); - component.openSchedule(0); - expect(component.isScheduleOpen(0)).toBe(true); + component.schedule_list.toggle(0); + expect(component.schedule_list.isOpen(0)).toBe(true); }); it('requires a start and preserves an item mask on save', async () => { diff --git a/apps/signage-manager/src/tests/shared/playlist-request-approval-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-request-approval-modal.component.spec.ts index feff6130cbb..29724b95ae9 100644 --- a/apps/signage-manager/src/tests/shared/playlist-request-approval-modal.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/playlist-request-approval-modal.component.spec.ts @@ -2,10 +2,7 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { setNotifyOutlet } from '@placeos/common'; -import { - listSignagePlaylistMediaRevisions, - updateSignagePlaylistMedia, -} from '@placeos/ts-client'; +import { listSignagePlaylistMediaRevisions } from '@placeos/ts-client'; import { MediaPreviewService } from '../../app/media/media-preview.service'; import { SignagePlaylistService } from '../../app/playlists/signage-playlist.service'; import { PlaylistRequestApprovalModalComponent } from '../../app/shared/playlist-request-approval-modal.component'; @@ -34,7 +31,7 @@ describe('PlaylistRequestApprovalModalComponent', () => { changed: vi.fn(), }; const media_service = { previewMedia: vi.fn() }; - const playlist_service = { setPlaylistApprovalStatus: vi.fn() }; + const playlist_service = { undoPlaylistChanges: vi.fn() }; beforeEach(async () => { vi.clearAllMocks(); @@ -66,7 +63,6 @@ describe('PlaylistRequestApprovalModalComponent', () => { approved: true, }, ]); - (updateSignagePlaylistMedia as any).mockResolvedValue({}); await TestBed.configureTestingModule({ imports: [PlaylistRequestApprovalModalComponent], providers: [ @@ -222,40 +218,32 @@ describe('PlaylistRequestApprovalModalComponent', () => { expect(fixture.nativeElement.textContent).not.toContain('Undo Changes'); }); - it('does not undo changes when user does not have update permissions', async () => { - context.can_update.set(false); + it('restores the approved version through the service', async () => { + playlist_service.undoPlaylistChanges.mockResolvedValue(true); const fixture = TestBed.createComponent( PlaylistRequestApprovalModalComponent, ); const component = fixture.componentInstance; + fixture.detectChanges(); await component.undoChanges(); - expect(updateSignagePlaylistMedia).not.toHaveBeenCalled(); - expect(notify_open).toHaveBeenCalledWith( - 'You cannot update playlists in this group.', - expect.anything(), - expect.objectContaining({ panelClass: ['warn'] }), + expect(playlist_service.undoPlaylistChanges).toHaveBeenCalledWith( + { id: 'playlist-1', name: 'Lobby Loop' }, + expect.objectContaining({ id: 'previous-version' }), ); + expect(dialog_ref.close).toHaveBeenCalledWith(); }); - it('reverts playlist media to the previous version', async () => { + it('stays open when the undo is cancelled', async () => { + playlist_service.undoPlaylistChanges.mockResolvedValue(false); const fixture = TestBed.createComponent( PlaylistRequestApprovalModalComponent, ); - const component = fixture.componentInstance; fixture.detectChanges(); - await component.undoChanges(); + await fixture.componentInstance.undoChanges(); - expect(updateSignagePlaylistMedia).toHaveBeenCalledWith('playlist-1', [ - 'media-2', - ]); - expect(playlist_service.setPlaylistApprovalStatus).toHaveBeenCalledWith( - 'playlist-1', - false, - ); - expect(dialog_ref.close).toHaveBeenCalledWith(); - expect(context.changed).toHaveBeenCalled(); + expect(dialog_ref.close).not.toHaveBeenCalled(); }); }); diff --git a/apps/signage-manager/src/tests/shared/schedule-summary.util.spec.ts b/apps/signage-manager/src/tests/shared/schedule-summary.util.spec.ts new file mode 100644 index 00000000000..72af308a02f --- /dev/null +++ b/apps/signage-manager/src/tests/shared/schedule-summary.util.spec.ts @@ -0,0 +1,143 @@ +import { settingSignal } from '@placeos/common'; +import { getUnixTime } from 'date-fns'; +import { + parseRecurringCron, + scheduleExpiryLabel, + scheduleSummary, +} from '../../app/shared/schedule-summary.util'; +import { formatPlayDateTime } from '../../app/signage-playlist.util'; + +describe('schedule summary util', () => { + afterEach(() => settingSignal('use_24_hour_time', false).set(false)); + + // play_at arrives from the API as unix seconds, never milliseconds + it('summarises a play once schedule at its stored time', () => { + const play_at = new Date('2026-03-02T09:30:00'); + + expect( + scheduleSummary( + { play_at: getUnixTime(play_at), play_period: 30 }, + 'en-AU', + ), + ).toBe( + `Plays once on ${formatPlayDateTime(play_at, undefined, true)} for 30 minutes`, + ); + }); + + it('shows a play once time in the 24 hour format when the app uses it', () => { + settingSignal('use_24_hour_time', false).set(true); + const play_at = new Date('2026-03-02T21:00:00'); + + const summary = scheduleSummary( + { play_at: getUnixTime(play_at), play_period: 30 }, + 'en-AU', + ); + + expect(summary).toContain('21:00'); + expect(summary).not.toMatch(/PM/i); + }); + + it('notes the display timezone of a local play once schedule', () => { + const summary = scheduleSummary( + { + play_at_local: '2026-03-02T09:30:00', + // The API always sends a fallback cron with one-off schedules. + play_cron: '0 0 * * *', + play_period: 30, + }, + 'en-AU', + ); + + expect(summary).toContain( + `${formatPlayDateTime(new Date(2026, 2, 2, 9, 30), undefined, true)} display local time`, + ); + }); + + it('summarises recurring schedules with translated text', () => { + expect( + scheduleSummary({ play_cron: '0 9 * * *', play_period: 30 }, 'en'), + ).toBe('Starts every day at 9:00 AM for 30 minutes.'); + expect( + scheduleSummary( + { play_cron: '30 8 1,22 * *', play_period: 0 }, + 'en', + ), + ).toBe( + 'Starts on the 1st, 22nd of each month at 8:30 AM for one playlist pass.', + ); + }); + + it('shows times in the 24 hour format when the app uses it', () => { + settingSignal('use_24_hour_time', false).set(true); + + expect( + scheduleSummary({ play_cron: '0 21 * * *', play_period: 30 }, 'en'), + ).toBe('Starts every day at 21:00 for 30 minutes.'); + }); + + it('summarises schedules that repeat every few minutes', () => { + expect( + scheduleSummary({ play_cron: '*/5 * * * *', play_period: 1 }, 'en'), + ).toBe('Plays every 5 minutes.'); + expect( + scheduleSummary({ play_cron: '* * * * *', play_period: 1 }, 'en'), + ).toBe('Plays every 1 minute.'); + }); + + it('names weekdays in the given locale', () => { + expect( + scheduleSummary({ play_cron: '0 9 * * 1', play_period: 60 }, 'fr'), + ).toContain('lundi'); + }); + + it('shows the cron of a schedule the form cannot edit', () => { + expect( + scheduleSummary({ play_cron: '0 9 1 6 *', play_period: 1 }, 'en'), + ).toBe('Custom schedule (0 9 1 6 *)'); + }); + + it('adds the takeover, end and mask of a schedule', () => { + const summary = scheduleSummary( + { + play_cron: '0 9 * * *', + play_period: 30, + play_takeover: true, + valid_until: getUnixTime(Date.now() + 3 * 24 * 60 * 60 * 1000), + mask: '101', + }, + 'en', + ); + + expect(summary).toBe( + 'Starts every day at 9:00 AM for 30 minutes. · takeover · until 3 days from now · Mask 101, repeats every 3 instances', + ); + }); + + it('describes the end of a schedule relative to now', () => { + const now = Date.UTC(2026, 0, 1, 9); + + expect( + scheduleExpiryLabel( + getUnixTime(new Date(Date.UTC(2026, 0, 22, 9))), + now, + ), + ).toBe('until 21 days from now'); + expect( + scheduleExpiryLabel(getUnixTime(now - 2 * 60 * 60 * 1000), now), + ).toBe('until 2 hours ago'); + expect(scheduleExpiryLabel(undefined, now)).toBe(''); + }); + + it('reads the repeat pattern of a cron expression', () => { + expect(parseRecurringCron('0 9 8-14 * 1')).toMatchObject({ + recurrence_type: 'monthly_weekday', + recurrence_time: '09:00', + recurrence_week_of_month: [2], + recurrence_weekdays: [1], + }); + expect(parseRecurringCron('0 */3 * * *')).toMatchObject({ + recurrence_type: 'hours', + recurrence_interval: 3, + }); + }); +}); diff --git a/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts b/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts index 5ee7fdfe560..22156447eaf 100644 --- a/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts +++ b/apps/signage-manager/src/tests/shared/template-mappings.component.spec.ts @@ -127,7 +127,9 @@ describe('TemplateMappingsComponent', () => { ); expect(zone_link?.textContent).toContain('First floor'); expect(zone_link?.textContent).not.toContain('zone-1'); - expect(fixture.nativeElement.textContent).toContain('Weekdays at'); + expect(fixture.nativeElement.textContent).toContain( + 'Starts every weekday at', + ); expect(fixture.nativeElement.textContent).toContain('for 30 minutes'); expect(fixture.nativeElement.querySelectorAll('button').length).toBe(2); expect(fixture.nativeElement.querySelector('button[error]')).toBeNull(); diff --git a/apps/signage-manager/src/tests/signage-playlist.util.spec.ts b/apps/signage-manager/src/tests/signage-playlist.util.spec.ts index 00552481b41..5d268d38287 100644 --- a/apps/signage-manager/src/tests/signage-playlist.util.spec.ts +++ b/apps/signage-manager/src/tests/signage-playlist.util.spec.ts @@ -10,9 +10,7 @@ import { playlistMediaItems, playlistNextPlayLabels, playlistNextPlayTime, - playlistScheduleExpiryLabel, playlistScheduleExpiryTooltip, - playlistScheduleLabel, playlistScheduleNextPlayLabels, playlistStatus, playOnceStart, @@ -77,33 +75,6 @@ describe('signage playlist util', () => { ).toBe(item); }); - // play_at arrives from the API as unix seconds, never milliseconds - it('labels a one-off schedule at its stored time', () => { - const play_at = new Date('2026-03-02T09:30:00'); - - const label = playlistScheduleLabel({ - play_at: getUnixTime(play_at), - play_period: 30, - }); - - expect(label).toContain('Plays once on'); - expect(label).toContain(play_at.toLocaleString()); - expect(label).toContain('for 30 minutes'); - }); - - it('labels a local one-off schedule in the viewer timezone', () => { - const label = playlistScheduleLabel({ - play_at_local: '2026-03-02T09:30:00', - // The API always sends a fallback cron with one-off schedules. - play_cron: '0 0 * * *', - play_period: 30, - }); - - expect(label).toContain( - `Plays once on ${new Date(2026, 2, 2, 9, 30).toLocaleString()} display local time`, - ); - }); - it('starts a local play once schedule at its date time', () => { expect(playOnceStart({ play_at_local: '2027-01-01T00:00:00' })).toEqual( new Date(2027, 0, 1), @@ -113,39 +84,12 @@ describe('signage playlist util', () => { ).toBeNull(); }); - it('labels cron schedules like playlist details', () => { - expect( - playlistScheduleLabel({ - play_cron: '0 9 * * *', - play_period: 30, - }), - ).toContain('Every day at'); - expect( - playlistScheduleLabel({ - play_cron: '0 9 * * *', - play_period: 30, - }), - ).toContain('for 30 minutes'); - }); - - it('adds relative and exact schedule expiry descriptions', () => { - const now = Date.UTC(2026, 0, 1, 9); + it('describes the exact end of a schedule', () => { const valid_until = getUnixTime(new Date(Date.UTC(2026, 0, 22, 9))); - const schedule = { valid_until }; - expect(playlistScheduleExpiryLabel(schedule, now)).toBe( - 'until 21 days from now', - ); - expect(playlistScheduleExpiryTooltip(schedule)).toBe( + expect(playlistScheduleExpiryTooltip({ valid_until })).toBe( new Date(valid_until * 1000).toLocaleString(), ); - expect( - playlistScheduleLabel({ - ...schedule, - play_cron: '0 9 * * *', - play_period: 30, - }), - ).toContain(' · until '); }); it('lists the next five play blocks for a recurring schedule', () => { diff --git a/apps/signage-manager/src/tests/signage-zone-search.spec.ts b/apps/signage-manager/src/tests/signage-zone-search.spec.ts index 8116bdfde82..cdf49ccb440 100644 --- a/apps/signage-manager/src/tests/signage-zone-search.spec.ts +++ b/apps/signage-manager/src/tests/signage-zone-search.spec.ts @@ -269,7 +269,7 @@ describe('SignageZoneService zone search', () => { TestBed.tick(); await flush(); - (service as any)._cacheZone( + (service as any)._assignments.cache( new PlaceZone({ id: 'level-1', display_name: 'Level 1' }), ); diff --git a/shared/assets/locale/ar.json b/shared/assets/locale/ar.json index 4026abecf96..7ede6342caa 100644 --- a/shared/assets/locale/ar.json +++ b/shared/assets/locale/ar.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "لا توجد مناطق متاحة.", "NO_ZONES_USE_PLAYLIST": "لا توجد مناطق تستخدم قائمة التشغيل هذه.", "ONE_PLAYLIST_PASS": "دورة واحدة لقائمة التشغيل", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "فتح الشاشة {{ name }}", "OPEN_GROUP": "فتح المجموعة {{ name }}", "OPEN_PANEL": "فتح لوحة اللافتات", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "اسم قائمة التشغيل", "PLAYLIST_ORIENTATION_ARIA": "اتجاه قائمة التشغيل", "PLAYLIST_REVERTED": "تمت إعادة قائمة التشغيل إلى الإصدار السابق", + "PLAYLIST_REVERT_CONFIRM": "هل تريد تجاهل التغييرات المعلقة واستعادة النسخة المعتمدة من {{ name }}؟ لا يمكنك استرجاع التغييرات المعلقة.", "PLAYLIST_REVERT_ERROR": "خطأ في التراجع عن تغييرات قائمة التشغيل", "PLAYLIST_SAVED": "تم حفظ قائمة التشغيل", "PLAYLIST_SAVE_ERROR": "خطأ في حفظ قائمة التشغيل", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "يبدأ كل يوم عند {{ time }} لمدة {{ duration }}.", "SUMMARY_EVERY_HOUR": "يعمل كل {{ interval }} ساعة.", "SUMMARY_EVERY_HOUR_1": "يعمل كل {{ interval }} ساعة.", + "SUMMARY_EVERY_MINUTE": "يعمل كل {{ interval }} دقيقة.", + "SUMMARY_EVERY_MINUTE_1": "يعمل كل {{ interval }} دقيقة.", "SUMMARY_MONTHLY": "يبدأ في {{ days }} من كل شهر عند {{ time }} لمدة {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "يبدأ في {{ weeks }} {{ days }} من كل شهر عند {{ time }} لمدة {{ duration }}.", "SUMMARY_PLAY_ONCE": "يعمل مرة واحدة في {{ datetime }} لمدة {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} منطقة", "ZONE_DETAILS_TABS": "علامات تبويب تفاصيل المنطقة", "ZONE_PERMISSIONS": "أذونات المنطقة", - "ZONE_SELECT_DETAILS": "حدد منطقة لعرض تفاصيلها." + "ZONE_SELECT_DETAILS": "حدد منطقة لعرض تفاصيلها.", + "SVC_REMOVING": "جارٍ الإزالة...", + "SCHEDULE_UNTIL_FROM_NOW": "ينتهي بعد {{ time }}", + "SCHEDULE_UNTIL_AGO": "انتهى منذ {{ time }}" } } diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 61ddf686eab..22281f45f02 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -440,6 +440,14 @@ "NO_ZONES_AVAILABLE": "No zones available.", "NO_ZONES_USE_PLAYLIST": "No zones use this playlist.", "ONE_PLAYLIST_PASS": "one playlist pass", + "ORDINAL": "{{ count }}th", + "ORDINAL_1": "{{ count }}st", + "ORDINAL_2": "{{ count }}nd", + "ORDINAL_3": "{{ count }}rd", + "ORDINAL_21": "{{ count }}st", + "ORDINAL_22": "{{ count }}nd", + "ORDINAL_23": "{{ count }}rd", + "ORDINAL_31": "{{ count }}st", "OPEN_DISPLAY": "Open display {{ name }}", "OPEN_GROUP": "Open group {{ name }}", "OPEN_PANEL": "Open signage panel", @@ -482,6 +490,7 @@ "PLAYLIST_NOT_FOUND": "Could not open the playlist. It may have been deleted.", "PLAYLIST_ORIENTATION_ARIA": "Playlist orientation", "PLAYLIST_REVERTED": "Playlist reverted to previous version", + "PLAYLIST_REVERT_CONFIRM": "Discard the pending changes and restore the approved version of {{ name }}? You cannot get the pending changes back.", "PLAYLIST_REVERT_ERROR": "Error reverting playlist changes", "PLAYLIST_SAVED": "Playlist saved", "PLAYLIST_SAVE_ERROR": "Error saving playlist", @@ -593,6 +602,8 @@ "SUMMARY_DAILY": "Starts every day at {{ time }} for {{ duration }}.", "SUMMARY_EVERY_HOUR": "Plays every {{ interval }} hours.", "SUMMARY_EVERY_HOUR_1": "Plays every {{ interval }} hour.", + "SUMMARY_EVERY_MINUTE": "Plays every {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Plays every {{ interval }} minute.", "SUMMARY_MONTHLY": "Starts on the {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Starts on the {{ weeks }} {{ days }} of each month at {{ time }} for {{ duration }}.", "DISPLAY_LOCAL_TIME": "display local time", @@ -649,6 +660,7 @@ "SVC_MEDIA_PLAYLISTS_NOT_UPDATED": "The media was removed, but some playlists could not be updated.", "SVC_MEDIA_REMOVED": "Media removed", "SVC_MEDIA_REMOVING": "Removing media...", + "SVC_REMOVING": "Removing...", "SVC_MEDIA_TAG_ERROR": "Unable to update tag: {{ error }}", "SVC_MEDIA_TAG_REMOVED": "Tag removed", "SVC_MEDIA_TAG_RENAMED": "Tag renamed", @@ -840,6 +852,8 @@ "SCHEDULE_MASK": "Repeat mask", "SCHEDULE_MASK_HINT": "Select each occurrence to switch between Play and Skip. Occurrence 1 starts at or after Valid From. The pattern then repeats.", "SCHEDULE_MASK_SUMMARY": "Mask {{ mask }}, repeats every {{ size }} instances", + "SCHEDULE_UNTIL_FROM_NOW": "until {{ time }} from now", + "SCHEDULE_UNTIL_AGO": "until {{ time }} ago", "MASK_REPEAT_LENGTH": "Repeat every", "MASK_OCCURRENCES": "{{ count }} occurrences", "MASK_OCCURRENCES_1": "{{ count }} occurrence", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index 55b6ebe6767..e2749ba8775 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -2617,6 +2617,14 @@ "NO_ZONES_AVAILABLE": "No zones available.", "NO_ZONES_USE_PLAYLIST": "No zones use this playlist.", "ONE_PLAYLIST_PASS": "one playlist pass", + "ORDINAL": "{{ count }}th", + "ORDINAL_1": "{{ count }}st", + "ORDINAL_2": "{{ count }}nd", + "ORDINAL_3": "{{ count }}rd", + "ORDINAL_21": "{{ count }}st", + "ORDINAL_22": "{{ count }}nd", + "ORDINAL_23": "{{ count }}rd", + "ORDINAL_31": "{{ count }}st", "OPEN_DISPLAY": "Open display {{ name }}", "OPEN_GROUP": "Open group {{ name }}", "OPEN_PANEL": "Open signage panel", @@ -2658,6 +2666,7 @@ "PLAYLIST_NOT_FOUND": "Could not open the playlist. It may have been deleted.", "PLAYLIST_ORIENTATION_ARIA": "Playlist orientation", "PLAYLIST_REVERTED": "Playlist reverted to previous version", + "PLAYLIST_REVERT_CONFIRM": "Discard the pending changes and restore the approved version of {{ name }}? You cannot get the pending changes back.", "PLAYLIST_REVERT_ERROR": "Error reverting playlist changes", "PLAYLIST_SAVED": "Playlist saved", "PLAYLIST_SAVE_ERROR": "Error saving playlist", @@ -2759,6 +2768,8 @@ "SUMMARY_DAILY": "Starts every day at {{ time }} for {{ duration }}.", "SUMMARY_EVERY_HOUR": "Plays every {{ interval }} hours.", "SUMMARY_EVERY_HOUR_1": "Plays every {{ interval }} hour.", + "SUMMARY_EVERY_MINUTE": "Plays every {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Plays every {{ interval }} minute.", "SUMMARY_MONTHLY": "Starts on the {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Starts on the {{ weeks }} {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_PLAY_ONCE": "Plays once on {{ datetime }} for {{ duration }}", @@ -2812,6 +2823,7 @@ "SVC_MEDIA_PLAYLISTS_NOT_UPDATED": "The media was removed, but some playlists could not be updated.", "SVC_MEDIA_REMOVED": "Media removed", "SVC_MEDIA_REMOVING": "Removing media...", + "SVC_REMOVING": "Removing...", "SVC_MEDIA_SHARED": "Media shared", "SVC_NO_APPROVERS": "Unable to load playlist approvers.", "SVC_NO_APPROVE_PLAYLISTS": "You cannot approve playlists in this group.", @@ -2972,6 +2984,8 @@ "ZONE_DETAILS_TABS": "Zone details tabs", "ZONE_PERMISSIONS": "Zone permissions", "ZONE_SELECT_DETAILS": "Select a zone to view its details.", - "BRAND_DISABLED": "Branding changes are turned off." + "BRAND_DISABLED": "Branding changes are turned off.", + "SCHEDULE_UNTIL_FROM_NOW": "until {{ time }} from now", + "SCHEDULE_UNTIL_AGO": "until {{ time }} ago" } } diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index 844c93e5ab1..67fe55b6a87 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -2639,6 +2639,14 @@ "NO_ZONES_AVAILABLE": "No zones available.", "NO_ZONES_USE_PLAYLIST": "No zones use this playlist.", "ONE_PLAYLIST_PASS": "one playlist pass", + "ORDINAL": "{{ count }}th", + "ORDINAL_1": "{{ count }}st", + "ORDINAL_2": "{{ count }}nd", + "ORDINAL_3": "{{ count }}rd", + "ORDINAL_21": "{{ count }}st", + "ORDINAL_22": "{{ count }}nd", + "ORDINAL_23": "{{ count }}rd", + "ORDINAL_31": "{{ count }}st", "OPEN_DISPLAY": "Open display {{ name }}", "OPEN_GROUP": "Open group {{ name }}", "OPEN_PANEL": "Open signage panel", @@ -2681,6 +2689,7 @@ "PLAYLIST_NOT_FOUND": "Could not open the playlist. It may have been deleted.", "PLAYLIST_ORIENTATION_ARIA": "Playlist orientation", "PLAYLIST_REVERTED": "Playlist reverted to previous version", + "PLAYLIST_REVERT_CONFIRM": "Discard the pending changes and restore the approved version of {{ name }}? You cannot get the pending changes back.", "PLAYLIST_REVERT_ERROR": "Error reverting playlist changes", "PLAYLIST_SAVED": "Playlist saved", "PLAYLIST_SAVE_ERROR": "Error saving playlist", @@ -2788,6 +2797,8 @@ "SUMMARY_DAILY": "Starts every day at {{ time }} for {{ duration }}.", "SUMMARY_EVERY_HOUR": "Plays every {{ interval }} hours.", "SUMMARY_EVERY_HOUR_1": "Plays every {{ interval }} hour.", + "SUMMARY_EVERY_MINUTE": "Plays every {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Plays every {{ interval }} minute.", "SUMMARY_MONTHLY": "Starts on the {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Starts on the {{ weeks }} {{ days }} of each month at {{ time }} for {{ duration }}.", "SUMMARY_PLAY_ONCE": "Plays once on {{ datetime }} for {{ duration }}", @@ -2843,6 +2854,7 @@ "SVC_MEDIA_PLAYLISTS_NOT_UPDATED": "The media was removed, but some playlists could not be updated.", "SVC_MEDIA_REMOVED": "Media removed", "SVC_MEDIA_REMOVING": "Removing media...", + "SVC_REMOVING": "Removing...", "SVC_MEDIA_TAG_ERROR": "Unable to update tag: {{ error }}", "SVC_MEDIA_TAG_REMOVED": "Tag removed", "SVC_MEDIA_TAG_RENAMED": "Tag renamed", @@ -3028,6 +3040,8 @@ "ZONE_PERMISSIONS": "Zone permissions", "ZONE_SAVING": "Saving zone...", "ZONE_SELECT_DETAILS": "Select a zone to view its details.", - "BRAND_DISABLED": "Branding changes are turned off." + "BRAND_DISABLED": "Branding changes are turned off.", + "SCHEDULE_UNTIL_FROM_NOW": "until {{ time }} from now", + "SCHEDULE_UNTIL_AGO": "until {{ time }} ago" } } diff --git a/shared/assets/locale/es.json b/shared/assets/locale/es.json index 83bdb338437..31ee0b172df 100644 --- a/shared/assets/locale/es.json +++ b/shared/assets/locale/es.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "No hay zonas disponibles.", "NO_ZONES_USE_PLAYLIST": "Ninguna zona usa esta lista de reproducción.", "ONE_PLAYLIST_PASS": "una pasada de la lista de reproducción", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Abrir pantalla {{ name }}", "OPEN_GROUP": "Abrir grupo {{ name }}", "OPEN_PANEL": "Abrir panel de señalización", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nombre de la lista de reproducción", "PLAYLIST_ORIENTATION_ARIA": "Orientación de la lista de reproducción", "PLAYLIST_REVERTED": "Lista de reproducción revertida a la versión anterior", + "PLAYLIST_REVERT_CONFIRM": "¿Descartar los cambios pendientes y restaurar la versión aprobada de {{ name }}? No podrá recuperar los cambios pendientes.", "PLAYLIST_REVERT_ERROR": "Error al revertir los cambios de la lista de reproducción", "PLAYLIST_SAVED": "Lista de reproducción guardada", "PLAYLIST_SAVE_ERROR": "Error al guardar la lista de reproducción", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Empieza todos los días a las {{ time }} durante {{ duration }}.", "SUMMARY_EVERY_HOUR": "Se reproduce cada {{ interval }} horas.", "SUMMARY_EVERY_HOUR_1": "Se reproduce cada {{ interval }} hora.", + "SUMMARY_EVERY_MINUTE": "Se reproduce cada {{ interval }} minutos.", + "SUMMARY_EVERY_MINUTE_1": "Se reproduce cada {{ interval }} minuto.", "SUMMARY_MONTHLY": "Empieza el día {{ days }} de cada mes a las {{ time }} durante {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Empieza el {{ weeks }} {{ days }} de cada mes a las {{ time }} durante {{ duration }}.", "SUMMARY_PLAY_ONCE": "Se reproduce una vez el {{ datetime }} durante {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zona", "ZONE_DETAILS_TABS": "Pestañas de detalles de la zona", "ZONE_PERMISSIONS": "Permisos de zona", - "ZONE_SELECT_DETAILS": "Selecciona una zona para ver sus detalles." + "ZONE_SELECT_DETAILS": "Selecciona una zona para ver sus detalles.", + "SVC_REMOVING": "Eliminando...", + "SCHEDULE_UNTIL_FROM_NOW": "termina dentro de {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminó hace {{ time }}" } } diff --git a/shared/assets/locale/fr-CA.json b/shared/assets/locale/fr-CA.json index 946ef331aeb..c6fb9e202a8 100644 --- a/shared/assets/locale/fr-CA.json +++ b/shared/assets/locale/fr-CA.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "Aucune zone disponible.", "NO_ZONES_USE_PLAYLIST": "Aucune zone n'utilise cette liste de lecture.", "ONE_PLAYLIST_PASS": "un passage de la liste de lecture", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}er", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Ouvrir l'écran {{ name }}", "OPEN_GROUP": "Ouvrir le groupe {{ name }}", "OPEN_PANEL": "Ouvrir le panneau d'affichage", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nom de la liste de lecture", "PLAYLIST_ORIENTATION_ARIA": "Orientation de la liste de lecture", "PLAYLIST_REVERTED": "Liste de lecture rétablie à la version précédente", + "PLAYLIST_REVERT_CONFIRM": "Abandonner les modifications en attente et restaurer la version approuvée de {{ name }} ? Vous ne pourrez pas récupérer les modifications en attente.", "PLAYLIST_REVERT_ERROR": "Erreur lors du rétablissement des changements de la liste de lecture", "PLAYLIST_SAVED": "Liste de lecture enregistrée", "PLAYLIST_SAVE_ERROR": "Erreur lors de l'enregistrement de la liste de lecture", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Commence tous les jours à {{ time }} pendant {{ duration }}.", "SUMMARY_EVERY_HOUR": "Se lit toutes les {{ interval }} heures.", "SUMMARY_EVERY_HOUR_1": "Se lit toutes les {{ interval }} heure.", + "SUMMARY_EVERY_MINUTE": "Se lit toutes les {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Se lit toutes les {{ interval }} minute.", "SUMMARY_MONTHLY": "Commence le {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Commence le {{ weeks }} {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_PLAY_ONCE": "Se lit une fois le {{ datetime }} pendant {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zone", "ZONE_DETAILS_TABS": "Onglets des détails de la zone", "ZONE_PERMISSIONS": "Autorisations de la zone", - "ZONE_SELECT_DETAILS": "Sélectionnez une zone pour afficher ses détails." + "ZONE_SELECT_DETAILS": "Sélectionnez une zone pour afficher ses détails.", + "SVC_REMOVING": "Suppression...", + "SCHEDULE_UNTIL_FROM_NOW": "se termine dans {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminé il y a {{ time }}" } } diff --git a/shared/assets/locale/fr.json b/shared/assets/locale/fr.json index 3fd1defc4bd..a3b863370ad 100644 --- a/shared/assets/locale/fr.json +++ b/shared/assets/locale/fr.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "Aucune zone disponible.", "NO_ZONES_USE_PLAYLIST": "Aucune zone n'utilise cette liste de lecture.", "ONE_PLAYLIST_PASS": "un passage de la liste de lecture", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}er", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Ouvrir l'écran {{ name }}", "OPEN_GROUP": "Ouvrir le groupe {{ name }}", "OPEN_PANEL": "Ouvrir le panneau d'affichage", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nom de la liste de lecture", "PLAYLIST_ORIENTATION_ARIA": "Orientation de la liste de lecture", "PLAYLIST_REVERTED": "Liste de lecture rétablie à la version précédente", + "PLAYLIST_REVERT_CONFIRM": "Abandonner les modifications en attente et restaurer la version approuvée de {{ name }} ? Vous ne pourrez pas récupérer les modifications en attente.", "PLAYLIST_REVERT_ERROR": "Erreur lors du rétablissement des changements de la liste de lecture", "PLAYLIST_SAVED": "Liste de lecture enregistrée", "PLAYLIST_SAVE_ERROR": "Erreur lors de l'enregistrement de la liste de lecture", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Commence tous les jours à {{ time }} pendant {{ duration }}.", "SUMMARY_EVERY_HOUR": "Se lit toutes les {{ interval }} heures.", "SUMMARY_EVERY_HOUR_1": "Se lit toutes les {{ interval }} heure.", + "SUMMARY_EVERY_MINUTE": "Se lit toutes les {{ interval }} minutes.", + "SUMMARY_EVERY_MINUTE_1": "Se lit toutes les {{ interval }} minute.", "SUMMARY_MONTHLY": "Commence le {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Commence le {{ weeks }} {{ days }} de chaque mois à {{ time }} pendant {{ duration }}.", "SUMMARY_PLAY_ONCE": "Se lit une fois le {{ datetime }} pendant {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zone", "ZONE_DETAILS_TABS": "Onglets des détails de la zone", "ZONE_PERMISSIONS": "Autorisations de la zone", - "ZONE_SELECT_DETAILS": "Sélectionnez une zone pour afficher ses détails." + "ZONE_SELECT_DETAILS": "Sélectionnez une zone pour afficher ses détails.", + "SVC_REMOVING": "Suppression...", + "SCHEDULE_UNTIL_FROM_NOW": "se termine dans {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminé il y a {{ time }}" } } diff --git a/shared/assets/locale/it.json b/shared/assets/locale/it.json index d582c479d4e..9dde8706f20 100644 --- a/shared/assets/locale/it.json +++ b/shared/assets/locale/it.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "Nessuna zona disponibile.", "NO_ZONES_USE_PLAYLIST": "Nessuna zona usa questa lista di riproduzione.", "ONE_PLAYLIST_PASS": "un passaggio della lista di riproduzione", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Apri schermo {{ name }}", "OPEN_GROUP": "Apri gruppo {{ name }}", "OPEN_PANEL": "Apri pannello di segnaletica", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nome lista di riproduzione", "PLAYLIST_ORIENTATION_ARIA": "Orientamento lista di riproduzione", "PLAYLIST_REVERTED": "Lista di riproduzione ripristinata alla versione precedente", + "PLAYLIST_REVERT_CONFIRM": "Annullare le modifiche in sospeso e ripristinare la versione approvata di {{ name }}? Non sarà possibile recuperare le modifiche in sospeso.", "PLAYLIST_REVERT_ERROR": "Errore durante il ripristino delle modifiche alla lista di riproduzione", "PLAYLIST_SAVED": "Lista di riproduzione salvata", "PLAYLIST_SAVE_ERROR": "Errore durante il salvataggio della lista di riproduzione", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Inizia ogni giorno alle {{ time }} per {{ duration }}.", "SUMMARY_EVERY_HOUR": "Riproduce ogni {{ interval }} ore.", "SUMMARY_EVERY_HOUR_1": "Riproduce ogni {{ interval }} ora.", + "SUMMARY_EVERY_MINUTE": "Riproduce ogni {{ interval }} minuti.", + "SUMMARY_EVERY_MINUTE_1": "Riproduce ogni {{ interval }} minuto.", "SUMMARY_MONTHLY": "Inizia il {{ days }} di ogni mese alle {{ time }} per {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Inizia il {{ weeks }} {{ days }} di ogni mese alle {{ time }} per {{ duration }}.", "SUMMARY_PLAY_ONCE": "Riproduce una volta il {{ datetime }} per {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zona", "ZONE_DETAILS_TABS": "Schede dettagli zona", "ZONE_PERMISSIONS": "Autorizzazioni zona", - "ZONE_SELECT_DETAILS": "Seleziona una zona per visualizzarne i dettagli." + "ZONE_SELECT_DETAILS": "Seleziona una zona per visualizzarne i dettagli.", + "SVC_REMOVING": "Rimozione in corso...", + "SCHEDULE_UNTIL_FROM_NOW": "termina tra {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminato {{ time }} fa" } } diff --git a/shared/assets/locale/ja-JP.json b/shared/assets/locale/ja-JP.json index 3e2e855fdbd..e17a7a79902 100644 --- a/shared/assets/locale/ja-JP.json +++ b/shared/assets/locale/ja-JP.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "利用可能なゾーンがありません。", "NO_ZONES_USE_PLAYLIST": "このプレイリストを使用しているゾーンはありません。", "ONE_PLAYLIST_PASS": "プレイリストを 1 周", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "ディスプレイ {{ name }} を開く", "OPEN_GROUP": "グループ {{ name }} を開く", "OPEN_PANEL": "サイネージパネルを開く", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "プレイリスト名", "PLAYLIST_ORIENTATION_ARIA": "プレイリストの向き", "PLAYLIST_REVERTED": "プレイリストを前のバージョンに戻しました", + "PLAYLIST_REVERT_CONFIRM": "保留中の変更を破棄し、{{ name }} の承認済みバージョンに戻しますか?保留中の変更は元に戻せません。", "PLAYLIST_REVERT_ERROR": "プレイリストの変更を戻す際にエラーが発生しました", "PLAYLIST_SAVED": "プレイリストを保存しました", "PLAYLIST_SAVE_ERROR": "プレイリストの保存中にエラーが発生しました", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "毎日 {{ time }} に開始し、{{ duration }} 継続します。", "SUMMARY_EVERY_HOUR": "{{ interval }} 時間ごとに再生します。", "SUMMARY_EVERY_HOUR_1": "{{ interval }} 時間ごとに再生します。", + "SUMMARY_EVERY_MINUTE": "{{ interval }} 分ごとに再生します。", + "SUMMARY_EVERY_MINUTE_1": "{{ interval }} 分ごとに再生します。", "SUMMARY_MONTHLY": "毎月 {{ days }} 日の {{ time }} に開始し、{{ duration }} 継続します。", "SUMMARY_MONTHLY_WEEKDAY": "毎月第 {{ weeks }} {{ days }} の {{ time }} に開始し、{{ duration }} 継続します。", "SUMMARY_PLAY_ONCE": "{{ datetime }} に 1 回再生し、{{ duration }} 継続します", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} 件のゾーン", "ZONE_DETAILS_TABS": "ゾーン詳細タブ", "ZONE_PERMISSIONS": "ゾーン権限", - "ZONE_SELECT_DETAILS": "詳細を表示するゾーンを選択してください。" + "ZONE_SELECT_DETAILS": "詳細を表示するゾーンを選択してください。", + "SVC_REMOVING": "削除しています...", + "SCHEDULE_UNTIL_FROM_NOW": "{{ time }}後まで", + "SCHEDULE_UNTIL_AGO": "{{ time }}前に終了" } } diff --git a/shared/assets/locale/pt.json b/shared/assets/locale/pt.json index 0537404462a..a125deccf57 100644 --- a/shared/assets/locale/pt.json +++ b/shared/assets/locale/pt.json @@ -2303,6 +2303,14 @@ "NO_ZONES_AVAILABLE": "Nenhuma zona disponível.", "NO_ZONES_USE_PLAYLIST": "Nenhuma zona usa esta lista de reprodução.", "ONE_PLAYLIST_PASS": "uma passagem da lista de reprodução", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "Abrir ecrã {{ name }}", "OPEN_GROUP": "Abrir grupo {{ name }}", "OPEN_PANEL": "Abrir painel de sinalização", @@ -2337,6 +2345,7 @@ "PLAYLIST_NAME_ARIA": "Nome da lista de reprodução", "PLAYLIST_ORIENTATION_ARIA": "Orientação da lista de reprodução", "PLAYLIST_REVERTED": "Lista de reprodução revertida para a versão anterior", + "PLAYLIST_REVERT_CONFIRM": "Descartar as alterações pendentes e repor a versão aprovada de {{ name }}? Não poderá recuperar as alterações pendentes.", "PLAYLIST_REVERT_ERROR": "Erro ao reverter alterações da lista de reprodução", "PLAYLIST_SAVED": "Lista de reprodução guardada", "PLAYLIST_SAVE_ERROR": "Erro ao guardar lista de reprodução", @@ -2421,6 +2430,8 @@ "SUMMARY_DAILY": "Começa todos os dias às {{ time }} durante {{ duration }}.", "SUMMARY_EVERY_HOUR": "Reproduz a cada {{ interval }} horas.", "SUMMARY_EVERY_HOUR_1": "Reproduz a cada {{ interval }} hora.", + "SUMMARY_EVERY_MINUTE": "Reproduz a cada {{ interval }} minutos.", + "SUMMARY_EVERY_MINUTE_1": "Reproduz a cada {{ interval }} minuto.", "SUMMARY_MONTHLY": "Começa no dia {{ days }} de cada mês às {{ time }} durante {{ duration }}.", "SUMMARY_MONTHLY_WEEKDAY": "Começa no {{ weeks }} {{ days }} de cada mês às {{ time }} durante {{ duration }}.", "SUMMARY_PLAY_ONCE": "Reproduz uma vez em {{ datetime }} durante {{ duration }}", @@ -2533,6 +2544,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} zona", "ZONE_DETAILS_TABS": "Separadores de detalhes da zona", "ZONE_PERMISSIONS": "Permissões da zona", - "ZONE_SELECT_DETAILS": "Selecione uma zona para ver os detalhes." + "ZONE_SELECT_DETAILS": "Selecione uma zona para ver os detalhes.", + "SVC_REMOVING": "A remover...", + "SCHEDULE_UNTIL_FROM_NOW": "termina daqui a {{ time }}", + "SCHEDULE_UNTIL_AGO": "terminou há {{ time }}" } } diff --git a/shared/assets/locale/zh.json b/shared/assets/locale/zh.json index c80eee3b668..eea73b47814 100644 --- a/shared/assets/locale/zh.json +++ b/shared/assets/locale/zh.json @@ -2304,6 +2304,14 @@ "NO_ZONES_AVAILABLE": "没有可用区域。", "NO_ZONES_USE_PLAYLIST": "没有区域使用此播放列表。", "ONE_PLAYLIST_PASS": "一次播放列表轮播", + "ORDINAL": "{{ count }}", + "ORDINAL_1": "{{ count }}", + "ORDINAL_2": "{{ count }}", + "ORDINAL_3": "{{ count }}", + "ORDINAL_21": "{{ count }}", + "ORDINAL_22": "{{ count }}", + "ORDINAL_23": "{{ count }}", + "ORDINAL_31": "{{ count }}", "OPEN_DISPLAY": "打开显示屏 {{ name }}", "OPEN_GROUP": "打开组 {{ name }}", "OPEN_PANEL": "打开数字标牌面板", @@ -2338,6 +2346,7 @@ "PLAYLIST_NAME_ARIA": "播放列表名称", "PLAYLIST_ORIENTATION_ARIA": "播放列表方向", "PLAYLIST_REVERTED": "播放列表已恢复到上一版本", + "PLAYLIST_REVERT_CONFIRM": "放弃待处理的更改并恢复 {{ name }} 的已批准版本?待处理的更改将无法找回。", "PLAYLIST_REVERT_ERROR": "恢复播放列表变更时出错", "PLAYLIST_SAVED": "播放列表已保存", "PLAYLIST_SAVE_ERROR": "保存播放列表时出错", @@ -2422,6 +2431,8 @@ "SUMMARY_DAILY": "每天 {{ time }} 开始,持续 {{ duration }}。", "SUMMARY_EVERY_HOUR": "每 {{ interval }} 小时播放一次。", "SUMMARY_EVERY_HOUR_1": "每 {{ interval }} 小时播放一次。", + "SUMMARY_EVERY_MINUTE": "每 {{ interval }} 分钟播放一次。", + "SUMMARY_EVERY_MINUTE_1": "每 {{ interval }} 分钟播放一次。", "SUMMARY_MONTHLY": "每月 {{ days }} 日 {{ time }} 开始,持续 {{ duration }}。", "SUMMARY_MONTHLY_WEEKDAY": "每月第 {{ weeks }} 个 {{ days }} 的 {{ time }} 开始,持续 {{ duration }}。", "SUMMARY_PLAY_ONCE": "在 {{ datetime }} 播放一次,持续 {{ duration }}", @@ -2534,6 +2545,9 @@ "ZONE_COUNT_LABEL_1": "{{ count }} 个区域", "ZONE_DETAILS_TABS": "区域详情标签", "ZONE_PERMISSIONS": "区域权限", - "ZONE_SELECT_DETAILS": "选择区域以查看其详情。" + "ZONE_SELECT_DETAILS": "选择区域以查看其详情。", + "SVC_REMOVING": "正在删除...", + "SCHEDULE_UNTIL_FROM_NOW": "{{ time }}后结束", + "SCHEDULE_UNTIL_AGO": "已于 {{ time }}前结束" } } From bd7983befb7ea84af2fc0828eba823c0c977370f Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 20:24:58 +1100 Subject: [PATCH 09/10] refactor(stagehand,map-studio,public): share page, geometry and helpers - Stagehand: one page layout, one URL filter handler, one nav list. - Map Studio: one bookable check, bounds helper, image loader and validation on the objects in memory. - Public: shared query helpers; registration and save failures no longer leave unhandled rejections. --- .../src/app/data/floorplan-ai.service.ts | 17 +- apps/map-studio/src/app/data/load-image.ts | 23 + .../src/app/data/placeos.service.ts | 35 +- apps/map-studio/src/app/data/store.service.ts | 111 +-- apps/map-studio/src/app/data/svg-import.ts | 41 +- apps/map-studio/src/app/data/thumbnail.ts | 9 +- .../map-studio/src/app/editor/availability.ts | 18 +- .../src/app/editor/canvas.component.ts | 140 ++-- apps/map-studio/src/app/editor/constants.ts | 15 + .../src/app/editor/editor-state.service.ts | 50 +- .../src/app/editor/editor.component.ts | 4 +- apps/map-studio/src/app/editor/geometry.ts | 24 + .../src/app/editor/minimap.component.ts | 14 +- .../app/editor/object-list-panel.component.ts | 14 +- .../src/app/editor/publish-panel.component.ts | 6 +- apps/map-studio/src/app/editor/source-svg.ts | 33 +- apps/map-studio/src/app/editor/svg-export.ts | 70 +- .../app/editor/validation-panel.component.ts | 53 +- .../app/kiosk/availability-polling.service.ts | 7 +- .../src/app/kiosk/floor-plan-2d.component.ts | 21 +- apps/map-studio/src/app/kiosk/heatmap.ts | 6 +- .../src/app/kiosk/kiosk.component.ts | 6 +- .../src/app/pages/import.component.ts | 14 +- .../src/tests/store.service.spec.ts | 26 - apps/map-studio/src/tests/svg-export.spec.ts | 23 +- apps/public/src/app/public-event.helpers.ts | 28 + .../public/src/app/public-events.component.ts | 75 +- apps/public/src/app/public-events.service.ts | 17 +- .../src/app/public-guest-details.component.ts | 51 +- .../src/app/public-page-shell.component.ts | 21 + .../src/tests/public-event.helpers.spec.ts | 23 + .../src/tests/public-events.component.spec.ts | 10 + .../src/tests/public-events.service.spec.ts | 13 + .../tests/public-page-shell.component.spec.ts | 13 + apps/stagehand/src/app/alerts.component.ts | 666 ++++++++--------- apps/stagehand/src/app/analytics.component.ts | 97 +-- .../alert-condition-compare-form.component.ts | 2 +- .../alert-conditions-modal.component.ts | 6 +- .../dashboard-alert-list.component.ts | 8 +- .../dashboard-alert-manage.component.ts | 10 +- .../dashboards/dashboard-list.component.ts | 8 +- .../dashboards/dashboard-mange.component.ts | 8 +- .../app/dashboards/dashboards.component.ts | 100 ++- .../src/app/dashboards/dashboards.service.ts | 61 +- .../system-search-field.component.ts | 2 +- .../src/app/recorder-view.component.ts | 32 +- .../src/app/remote-support.component.ts | 702 ++++++++---------- .../stagehand/src/app/ui/sidebar.component.ts | 150 ++-- .../src/app/ui/stagehand-page.component.ts | 38 + ...t-condition-compare-form.component.spec.ts | 2 +- .../src/tests/alerts.component.spec.ts | 62 +- .../src/tests/dashboards.service.spec.ts | 57 +- .../dashboard-alert-list.component.spec.ts | 3 +- .../dashboard-alert-manage.component.spec.ts | 10 +- .../dashboard-list.component.spec.ts | 3 +- .../dashboard-mange.component.spec.ts | 5 +- .../dashboards/dashboards.component.spec.ts | 2 +- .../tests/remote-support.component.spec.ts | 22 +- 58 files changed, 1436 insertions(+), 1651 deletions(-) create mode 100644 apps/map-studio/src/app/data/load-image.ts create mode 100644 apps/stagehand/src/app/ui/stagehand-page.component.ts diff --git a/apps/map-studio/src/app/data/floorplan-ai.service.ts b/apps/map-studio/src/app/data/floorplan-ai.service.ts index ef7b124c030..91aa3fb93e6 100644 --- a/apps/map-studio/src/app/data/floorplan-ai.service.ts +++ b/apps/map-studio/src/app/data/floorplan-ai.service.ts @@ -1,5 +1,6 @@ import { Injectable, inject } from '@angular/core'; +import { loadImage } from './load-image'; import { PlaceOSService } from './placeos.service'; import { StoreService } from './store.service'; @@ -142,22 +143,6 @@ async function prepareImage(source: Blob): Promise<{ return { base64: await blobBase64(jpeg), width, height }; } -function loadImage(source: Blob): Promise { - const url = URL.createObjectURL(source); - return new Promise((resolve, reject) => { - const image = new Image(); - image.onload = () => { - URL.revokeObjectURL(url); - resolve(image); - }; - image.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Could not read source image')); - }; - image.src = url; - }); -} - function drawCoordinateGrid( context: CanvasRenderingContext2D, width: number, diff --git a/apps/map-studio/src/app/data/load-image.ts b/apps/map-studio/src/app/data/load-image.ts new file mode 100644 index 00000000000..aa074f9e1db --- /dev/null +++ b/apps/map-studio/src/app/data/load-image.ts @@ -0,0 +1,23 @@ +/** + * Loads an image from a URL or a blob. A blob's object URL is revoked once + * the image loads or fails. Rejects when the image cannot be read. + */ +export function loadImage(source: string | Blob): Promise { + const url = + typeof source === 'string' ? source : URL.createObjectURL(source); + const release = () => { + if (url !== source) URL.revokeObjectURL(url); + }; + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + release(); + resolve(image); + }; + image.onerror = () => { + release(); + reject(new Error('Could not read source image')); + }; + image.src = url; + }); +} diff --git a/apps/map-studio/src/app/data/placeos.service.ts b/apps/map-studio/src/app/data/placeos.service.ts index 32d40f1fac2..f69b55514c0 100644 --- a/apps/map-studio/src/app/data/placeos.service.ts +++ b/apps/map-studio/src/app/data/placeos.service.ts @@ -1,5 +1,5 @@ import { Injectable, signal } from '@angular/core'; -import { requestTokenFromOpener } from '@placeos/common'; +import { requestTokenFromOpener, withTimeout } from '@placeos/common'; import { authorise, authority, @@ -39,25 +39,6 @@ export function withoutLocalFields( return data; } -function withDeadline(promise: Promise, message: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(message)), - PLACEOS_REQUEST_TIMEOUT, - ); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error) => { - clearTimeout(timer); - reject(error); - }, - ); - }); -} - function fetchWithDeadline( input: RequestInfo | URL, init: RequestInit, @@ -67,9 +48,11 @@ function fetchWithDeadline( const request = fetch(input, { ...init, signal: controller.signal }).then( read, ); - return withDeadline(request, 'PlaceOS request timed out.').finally(() => - controller.abort(), - ); + return withTimeout( + request, + PLACEOS_REQUEST_TIMEOUT, + 'PlaceOS request timed out.', + ).finally(() => controller.abort()); } export interface PlaceOSSettings { @@ -229,7 +212,11 @@ export class PlaceOSService { handle_login: false, }; try { - await withDeadline(setup(options), 'PlaceOS setup timed out.'); + await withTimeout( + setup(options), + PLACEOS_REQUEST_TIMEOUT, + 'PlaceOS setup timed out.', + ); if (!token()) await this._useOpenerToken(); options.handle_login = true; // With handle_login on, this redirects to the login page diff --git a/apps/map-studio/src/app/data/store.service.ts b/apps/map-studio/src/app/data/store.service.ts index b50f5daa872..0506c2c2e6b 100644 --- a/apps/map-studio/src/app/data/store.service.ts +++ b/apps/map-studio/src/app/data/store.service.ts @@ -14,7 +14,6 @@ import { MapObject, MapObjectType, Project, - ValidationIssue, } from './types'; type ZoneReference = Pick; @@ -713,26 +712,28 @@ export class StoreService { for (const [index, parsed] of analysis.objects.entries()) { const mapping = mappings?.get(parsed.svgId); if (mappings && !mapping) continue; + const fill = + parsed.presentation?.fill || parsed.attributes['fill'] || null; + const stroke = + parsed.presentation?.stroke || + parsed.attributes['stroke'] || + null; + const opacity = + parsed.presentation?.opacity ?? + Number(parsed.attributes['opacity'] ?? 1); + const label = + mapping?.label || + parsed.label || + (parsed.tag === 'text' ? '' : parsed.svgId); objects.push({ object_type: (mapping?.objectType ?? parsed.suggestedType) as MapObjectType, svg_id: parsed.svgId, - label: - mapping?.label || - parsed.label || - (parsed.tag === 'text' ? '' : parsed.svgId), + label, geometry: parsed.geometry, - fill_color: - parsed.presentation?.fill || - parsed.attributes['fill'] || - null, - stroke_color: - parsed.presentation?.stroke || - parsed.attributes['stroke'] || - null, - opacity: - parsed.presentation?.opacity ?? - Number(parsed.attributes['opacity'] ?? 1), + fill_color: fill, + stroke_color: stroke, + opacity, layer: parsed.layer, group_id: parsed.groupId, z_index: index, @@ -741,21 +742,10 @@ export class StoreService { originalTag: parsed.tag, originalAttributes: parsed.attributes, originalGeometry: parsed.geometry, - originalFill: - parsed.presentation?.fill || - parsed.attributes['fill'] || - null, - originalStroke: - parsed.presentation?.stroke || - parsed.attributes['stroke'] || - null, - originalOpacity: - parsed.presentation?.opacity ?? - Number(parsed.attributes['opacity'] ?? 1), - originalLabel: - mapping?.label || - parsed.label || - (parsed.tag === 'text' ? '' : parsed.svgId), + originalFill: fill, + originalStroke: stroke, + originalOpacity: opacity, + originalLabel: label, parentMatrix: parsed.parentMatrix, }, }); @@ -821,22 +811,6 @@ export class StoreService { }); } - public async updateObject( - floorplan_id: string, - id: string, - data: Partial, - ): Promise { - const object = await this._db.get( - key.object(floorplan_id, id), - ); - if (!object) throw new Error('Object not found'); - await this._db.set(key.object(floorplan_id, id), { - ...object, - ...data, - id, - }); - } - /** * Writes `objects` and removes `remove_ids` in one transaction. Returns the * objects as stored, with defaults filled in. @@ -853,47 +827,4 @@ export class StoreService { }); return records.map(([, object]) => object); } - - // ── Validation ────────────────────────────────────────────────────────── - - public async validateFloorplan( - floorplan_id: string, - ): Promise { - const objects = await this.listObjects(floorplan_id); - const issues: ValidationIssue[] = []; - const seen = new Set(); - const duplicates = new Set(); - - for (const obj of objects) { - if (!obj.label) { - issues.push({ - type: 'warning', - objectId: obj.id, - message: `Object ${obj.id} (${obj.object_type}) has no label`, - }); - } - if (obj.svg_id) { - if (seen.has(obj.svg_id)) duplicates.add(obj.svg_id); - seen.add(obj.svg_id); - } - if (!obj.geometry || Object.keys(obj.geometry).length === 0) { - issues.push({ - type: 'error', - objectId: obj.id, - message: `Object ${obj.id} (${obj.object_type}) has empty geometry`, - }); - } - } - - for (const svg_id of duplicates) { - for (const obj of objects.filter((o) => o.svg_id === svg_id)) { - issues.push({ - type: 'error', - objectId: obj.id, - message: `Duplicate svg_id "${svg_id}" on object ${obj.id}`, - }); - } - } - return issues; - } } diff --git a/apps/map-studio/src/app/data/svg-import.ts b/apps/map-studio/src/app/data/svg-import.ts index 0f20feed93d..56c21b0e7bc 100644 --- a/apps/map-studio/src/app/data/svg-import.ts +++ b/apps/map-studio/src/app/data/svg-import.ts @@ -1,7 +1,9 @@ import { invert, multiply, + pointsIn, sanitiseTree, + transformBBox, translation, } from '@placeos/map-editor'; import { MapObjectType, ObjectGeometry } from './types'; @@ -137,17 +139,6 @@ function parseNumber(value: string | null): number | undefined { return isNaN(num) ? undefined : num; } -function parsePoints(points: string): { x: number; y: number }[] { - const parts = points.trim().split(/[\s,]+/); - const parsed: { x: number; y: number }[] = []; - for (let i = 0; i < parts.length - 1; i += 2) { - const x = parseFloat(parts[i]); - const y = parseFloat(parts[i + 1]); - if (!isNaN(x) && !isNaN(y)) parsed.push({ x, y }); - } - return parsed; -} - function extractGeometry(el: Element): ObjectGeometry { switch (el.tagName.toLowerCase()) { case 'rect': @@ -181,7 +172,7 @@ function extractGeometry(el: Element): ObjectGeometry { const points = el.getAttribute('points'); return { type: 'polygon', - points: points ? parsePoints(points) : [], + points: pointsIn(points).map(([x, y]) => ({ x, y })), }; } case 'path': @@ -379,25 +370,13 @@ export function analyzeSvg(svg_content: string): SvgAnalysis { f: pm.f, }; if (matrix && (box.width || box.height)) { - const corners = [ - [box.x, box.y], - [box.x + box.width, box.y], - [box.x, box.y + box.height], - [box.x + box.width, box.y + box.height], - ]; - const xs = corners.map( - ([x, y]) => matrix.a * x + matrix.c * y + matrix.e, - ); - const ys = corners.map( - ([x, y]) => matrix.b * x + matrix.d * y + matrix.f, - ); - geometry = { - type: 'rect', - x: Math.min(...xs), - y: Math.min(...ys), - width: Math.max(...xs) - Math.min(...xs), - height: Math.max(...ys) - Math.min(...ys), - }; + const [x, y, width, height] = transformBBox(matrix, [ + box.x, + box.y, + box.width, + box.height, + ]); + geometry = { type: 'rect', x, y, width, height }; } } catch { /* Non-rendering SVG nodes have no bounding box. */ diff --git a/apps/map-studio/src/app/data/thumbnail.ts b/apps/map-studio/src/app/data/thumbnail.ts index d950c06e652..884985a8c78 100644 --- a/apps/map-studio/src/app/data/thumbnail.ts +++ b/apps/map-studio/src/app/data/thumbnail.ts @@ -1,3 +1,5 @@ +import { loadImage } from './load-image'; + /** Longest edge of a stored preview, in pixels */ const THUMBNAIL_SIZE = 480; @@ -7,11 +9,8 @@ const THUMBNAIL_SIZE = 480; * to the original image if it cannot be drawn. */ export async function makeThumbnail(image: Blob): Promise { - const url = URL.createObjectURL(image); try { - const element = new Image(); - element.src = url; - await element.decode(); + const element = await loadImage(image); const width = element.naturalWidth; const height = element.naturalHeight; if (!width || !height) return image; @@ -28,7 +27,5 @@ export async function makeThumbnail(image: Blob): Promise { return thumbnail ?? image; } catch { return image; - } finally { - URL.revokeObjectURL(url); } } diff --git a/apps/map-studio/src/app/editor/availability.ts b/apps/map-studio/src/app/editor/availability.ts index 34957e8ca0a..215f4342650 100644 --- a/apps/map-studio/src/app/editor/availability.ts +++ b/apps/map-studio/src/app/editor/availability.ts @@ -1,4 +1,20 @@ -import { AvailabilityState } from '../data/types'; +import { AvailabilityState, MapObject } from '../data/types'; + +/** Whether PlaceOS can bind a booking to the object: rooms and desks */ +export const isBookable = (object: Pick) => + object.object_type === 'room' || object.object_type === 'desk'; + +/** + * Converts a `#rrggbb` colour into rgba() at the given opacity. A missing `#` + * is allowed, and a part that is not hex reads as 0. + */ +export function toFillOpacity(hex: string, opacity: number): string { + const clean = hex.replace('#', '').slice(0, 6); + const r = parseInt(clean.slice(0, 2), 16) || 0; + const g = parseInt(clean.slice(2, 4), 16) || 0; + const b = parseInt(clean.slice(4, 6), 16) || 0; + return `rgba(${r},${g},${b},${opacity})`; +} /** Colour each availability state paints objects in preview mode */ export const STATE_COLORS: Record = { diff --git a/apps/map-studio/src/app/editor/canvas.component.ts b/apps/map-studio/src/app/editor/canvas.component.ts index 786543da424..77a52eab0df 100644 --- a/apps/map-studio/src/app/editor/canvas.component.ts +++ b/apps/map-studio/src/app/editor/canvas.component.ts @@ -12,7 +12,7 @@ import { import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { MapObject } from '../data/types'; -import { getAvailabilityColor } from './availability'; +import { getAvailabilityColor, isBookable } from './availability'; import { AMENITY_ICONS, FURNITURE_ASSETS, @@ -30,12 +30,22 @@ import { closestPointOnSegment, computeSnap, getHandlePos, + objectBounds, pointInRotatedRect, rotatePoint, } from './geometry'; import { composeSourceSvg, importedObject } from './source-svg'; +import { sequentialId } from './svg-export'; import { ToastService } from './toast.service'; +/** Position of each resize handle on the given bounding box */ +function handlePositions(x: number, y: number, w: number, h: number) { + return HANDLES.map((handle) => { + const [hx, hy] = getHandlePos(handle, x, y, w, h); + return { handle, x: hx, y: hy }; + }); +} + interface DragState { id: string; offsetX: number; @@ -310,17 +320,17 @@ const clamp01 = (value: number) => Math.max(0, Math.min(1, value)); object.id === state.selected_id() && state.multi_select().length < 2 ) { - @for (handle of handles; track handle) { + @for (pos of selected_handles(); track pos.handle) { } Math.max(0, Math.min(1, value)); stroke-dasharray="6 3" class="pointer-events-none" /> - @for (handle of handles; track handle) { + @for (pos of group_handles(); track pos.handle) { } Math.max(0, Math.min(1, value)); state.floorplan()?.svg_source && !object.locked ) { - @for (handle of handles; track handle) { + @for (pos of selected_handles(); track pos.handle) { } >('renameInput'); - public readonly handles = HANDLES; - public readonly rect_draw = signal(null); public readonly selection_box = signal(null); public readonly outline_points = signal([]); @@ -849,8 +857,7 @@ export class CanvasComponent { /** Move cursor over objects the select tool can drag, else inherit */ public moveCursor(object: MapObject) { const cycles = - this.state.availability_enabled() && - (object.object_type === 'room' || object.object_type === 'desk'); + this.state.availability_enabled() && isBookable(object); return this._can_move() && !cycles && !object.locked && @@ -970,6 +977,25 @@ export class CanvasComponent { }; }); + /** Resize handle positions around the selected object */ + public readonly selected_handles = computed(() => { + const geometry = this.state.selected()?.geometry; + if (!geometry) return []; + return handlePositions( + geometry.x ?? 0, + geometry.y ?? 0, + geometry.width ?? 50, + geometry.height ?? 50, + ); + }); + + /** Resize handle positions around the multi-selection */ + public readonly group_handles = computed(() => { + const group = this.groupBounds(); + if (!group) return []; + return handlePositions(group.x, group.y, group.width, group.height); + }); + public readonly groupBounds = computed(() => { const ids = this.state.multi_select_ids(); if (ids.size < 2) return null; @@ -977,7 +1003,7 @@ export class CanvasComponent { .objects() .filter((object) => ids.has(object.id)); if (objects.length < 2) return null; - const bounds = objects.map((object) => this._objectBounds(object)); + const bounds = objects.map((object) => objectBounds(object.geometry)); const x = Math.min(...bounds.map((item) => item.x)); const y = Math.min(...bounds.map((item) => item.y)); const right = Math.max(...bounds.map((item) => item.x + item.width)); @@ -1098,50 +1124,10 @@ export class CanvasComponent { return (object.geometry.y ?? 0) - 25 / (this.state.stroke_width() / 2); } - public handleX(object: MapObject, handle: Handle) { - return getHandlePos( - handle, - object.geometry.x ?? 0, - object.geometry.y ?? 0, - object.geometry.width ?? 50, - object.geometry.height ?? 50, - )[0]; - } - - public handleY(object: MapObject, handle: Handle) { - return getHandlePos( - handle, - object.geometry.x ?? 0, - object.geometry.y ?? 0, - object.geometry.width ?? 50, - object.geometry.height ?? 50, - )[1]; - } - public cursorFor(handle: Handle) { return HANDLE_CURSORS[handle]; } - public groupHandleX(group: GroupTransformState['bounds'], handle: Handle) { - return getHandlePos( - handle, - group.x, - group.y, - group.width, - group.height, - )[0]; - } - - public groupHandleY(group: GroupTransformState['bounds'], handle: Handle) { - return getHandlePos( - handle, - group.x, - group.y, - group.width, - group.height, - )[1]; - } - public groupRotateY(group: GroupTransformState['bounds']) { return group.y - 25 / (this.state.stroke_width() / 2); } @@ -1449,10 +1435,7 @@ export class CanvasComponent { const hit = this._hitTest(x, y); if (hit) { // In preview mode a click cycles the availability state instead - if ( - this.state.availability_enabled() && - (hit.object_type === 'room' || hit.object_type === 'desk') - ) { + if (this.state.availability_enabled() && isBookable(hit)) { this.state.select(hit.id); this.state.cycleAvailability(hit); event.preventDefault(); @@ -1995,7 +1978,7 @@ export class CanvasComponent { geometry.points = geometry.points.map(transform); Object.assign(geometry, boundsOf(geometry.points)); } else { - const bounds = this._objectBounds(object); + const bounds = objectBounds(object.geometry); const center = transform({ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2, @@ -2053,7 +2036,7 @@ export class CanvasComponent { .visible_objects() .filter((object) => { if (object.layer !== activeLayer || object.locked) return false; - const bounds = this._objectBounds(object); + const bounds = objectBounds(object.geometry); return ( bounds.x >= left && bounds.y >= top && @@ -2073,26 +2056,6 @@ export class CanvasComponent { .map((object) => structuredClone(object)); } - private _objectBounds(object: MapObject) { - const geometry = object.geometry; - if (geometry.points?.length) return boundsOf(geometry.points); - if (geometry.type === 'circle') { - const r = geometry.r ?? 12; - return { - x: (geometry.x ?? 0) - r, - y: (geometry.y ?? 0) - r, - width: r * 2, - height: r * 2, - }; - } - return { - x: geometry.x ?? 0, - y: geometry.y ?? 0, - width: geometry.width ?? 0, - height: geometry.height ?? 0, - }; - } - private _applyVertexDrag( vertex: { id: string; idx: number }, x: number, @@ -2149,12 +2112,7 @@ export class CanvasComponent { /** Topmost selectable object under the cursor, preferring the active layer */ private _hitTest(x: number, y: number): MapObject | null { - const selectable = new Set( - this.state - .layers() - .filter((l) => l.visible && !l.locked) - .map((l) => l.id), - ); + const selectable = this._pickable_layers(); const active = this.state.active_layer_id(); const hits: { object: MapObject; area: number; onActive: boolean }[] = []; @@ -2330,7 +2288,7 @@ export class CanvasComponent { { object_type: 'decorative', label: `Wall ${count + 1}`, - svg_id: `wall-${String(count + 1).padStart(3, '0')}`, + svg_id: sequentialId('wall-', count + 1), geometry: { type: 'polygon', points, ...boundsOf(points) }, layer: 'walls', fill_color: '#374151', diff --git a/apps/map-studio/src/app/editor/constants.ts b/apps/map-studio/src/app/editor/constants.ts index eb232ad52b0..91d8a94588d 100644 --- a/apps/map-studio/src/app/editor/constants.ts +++ b/apps/map-studio/src/app/editor/constants.ts @@ -36,6 +36,21 @@ export const TYPE_COLORS: Record = { locker: '#9333ea', }; +/** + * Type colours of the object list and the minimap. They are not the canvas + * colours in `TYPE_COLORS`. + */ +export const TYPE_ACCENT_COLORS: Record = { + room: '#3b82f6', + desk: '#22c55e', + zone: '#a855f7', + area: '#f59e0b', + amenity: '#06b6d4', + decorative: '#6b7280', + parking: '#8b5cf6', + locker: '#ec4899', +}; + export const OBJECT_TYPES: MapObjectType[] = [ 'room', 'desk', diff --git a/apps/map-studio/src/app/editor/editor-state.service.ts b/apps/map-studio/src/app/editor/editor-state.service.ts index 7ea136a84bc..0bb3ea8d884 100644 --- a/apps/map-studio/src/app/editor/editor-state.service.ts +++ b/apps/map-studio/src/app/editor/editor-state.service.ts @@ -3,6 +3,7 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { AsyncHandler, errorMessage } from '@placeos/common'; import type { DetectedRoom } from '../data/floorplan-ai.service'; +import { loadImage } from '../data/load-image'; import { PlaceOSService } from '../data/placeos.service'; import { StoreService } from '../data/store.service'; import { @@ -13,7 +14,12 @@ import { MapObject, MapObjectType, } from '../data/types'; -import { cycleState, defaultStateFor, getStatesForType } from './availability'; +import { + cycleState, + defaultStateFor, + getStatesForType, + isBookable, +} from './availability'; import { AMENITY_ICONS, DESK_LAYOUTS, @@ -474,19 +480,10 @@ export class EditorStateService extends AsyncHandler { this._image_dims.set(null); return; } - const dims = await new Promise<{ - width: number; - height: number; - } | null>((resolve) => { - const img = new Image(); - img.onload = () => - resolve({ - width: img.naturalWidth, - height: img.naturalHeight, - }); - img.onerror = () => resolve(null); - img.src = url; - }); + const dims = await loadImage(url).then( + (img) => ({ width: img.naturalWidth, height: img.naturalHeight }), + () => null, + ); this._image_dims.set(dims); await this._storeImageSize(floorplan_id, dims); } @@ -642,7 +639,7 @@ export class EditorStateService extends AsyncHandler { const name = type.charAt(0).toUpperCase() + type.slice(1); return { label: `${name} ${count + 1}`, - svg_id: `${type}-${String(count + 1).padStart(3, '0')}`, + svg_id: sequentialId(`${type}-`, count + 1), }; } @@ -751,19 +748,14 @@ export class EditorStateService extends AsyncHandler { public async updateObject(id: string, updates: Partial) { this.patchLocal(id, updates); this.markDirty(); - await this._store - .updateObject(this._floorplan_id, id, updates) - .catch(() => undefined); + await this._persist([id]); } /** Persists whatever geometry the object currently holds after a drag */ public async commitGeometry(id: string) { - const object = this._objects().find((o) => o.id === id); - if (!object) return; + if (!this._objects().some((o) => o.id === id)) return; this.markDirty(); - await this._store - .updateObject(this._floorplan_id, id, { geometry: object.geometry }) - .catch(() => undefined); + await this._persist([id]); } public async deleteObject(id: string) { @@ -863,7 +855,7 @@ export class EditorStateService extends AsyncHandler { this.patchLocal(existing.id, values); this._selected_id.set(existing.id); this.markDirty(); - await this._store.updateObject(this._floorplan_id, existing.id, values); + await this._persist([existing.id]); return { ...existing, ...values } as MapObject; } @@ -955,7 +947,7 @@ export class EditorStateService extends AsyncHandler { await this.createObject({ object_type: 'amenity', label: asset?.label ?? 'Amenity', - svg_id: `amenity-${String(count + 1).padStart(3, '0')}`, + svg_id: sequentialId('amenity-', count + 1), geometry: { type: 'circle', x, y, r: 12 }, layer: PLACE_LAYERS.amenity, fill_color: '#dc2626', @@ -973,7 +965,7 @@ export class EditorStateService extends AsyncHandler { await this.createObject({ object_type: 'decorative', label: asset.label, - svg_id: `furniture-${String(count + 1).padStart(3, '0')}`, + svg_id: sequentialId('furniture-', count + 1), geometry: { type: 'rect', x: x - asset.w / 2, @@ -1011,7 +1003,7 @@ export class EditorStateService extends AsyncHandler { desks.push({ object_type: 'desk', label: `Desk ${index}`, - svg_id: `desk-${String(index).padStart(3, '0')}`, + svg_id: sequentialId('desk-', index), geometry: { type: 'rect', x: start_x + col * (layout.deskW + layout.gap), @@ -1076,9 +1068,7 @@ export class EditorStateService extends AsyncHandler { } public readonly bookable = computed(() => - this._objects().filter( - (o) => o.object_type === 'room' || o.object_type === 'desk', - ), + this._objects().filter(isBookable), ); // ── Multi-select ──────────────────────────────────────────────────────── diff --git a/apps/map-studio/src/app/editor/editor.component.ts b/apps/map-studio/src/app/editor/editor.component.ts index 1354a1cca84..15949fc9c13 100644 --- a/apps/map-studio/src/app/editor/editor.component.ts +++ b/apps/map-studio/src/app/editor/editor.component.ts @@ -17,6 +17,7 @@ import type { DetectedRoom } from '../data/floorplan-ai.service'; import { FloorplanAiService } from '../data/floorplan-ai.service'; import { MapObject } from '../data/types'; import { AssetPickerComponent } from './asset-picker.component'; +import { isBookable } from './availability'; import { AvailabilityPanelComponent } from './availability-panel.component'; import { CanvasComponent } from './canvas.component'; import { @@ -951,8 +952,7 @@ export class EditorComponent { .objects() .filter( (object) => - (object.object_type === 'room' || - object.object_type === 'desk') && + isBookable(object) && object.label?.toLowerCase().includes(query), ) .slice(0, 8); diff --git a/apps/map-studio/src/app/editor/geometry.ts b/apps/map-studio/src/app/editor/geometry.ts index ab78a678126..898d90140f7 100644 --- a/apps/map-studio/src/app/editor/geometry.ts +++ b/apps/map-studio/src/app/editor/geometry.ts @@ -1,3 +1,4 @@ +import { ObjectGeometry } from '../data/types'; import { Handle } from './constants'; export interface Point { @@ -194,6 +195,29 @@ export function boundsOf(points: Point[]) { }; } +/** + * Bounding box of an object's geometry, in geometry field order. A circle + * with no radius gets the 12 unit radius the canvas draws it with. + */ +export function objectBounds(geometry: ObjectGeometry) { + if (geometry.points?.length) return boundsOf(geometry.points); + if (geometry.type === 'circle') { + const r = geometry.r ?? 12; + return { + x: (geometry.x ?? 0) - r, + y: (geometry.y ?? 0) - r, + width: r * 2, + height: r * 2, + }; + } + return { + x: geometry.x ?? 0, + y: geometry.y ?? 0, + width: geometry.width ?? 0, + height: geometry.height ?? 0, + }; +} + /** * Aligns a dragged rectangle to the edges and centres of its neighbours. * Returns the snapped origin plus the guides that should be drawn. diff --git a/apps/map-studio/src/app/editor/minimap.component.ts b/apps/map-studio/src/app/editor/minimap.component.ts index abe20f4a9cc..6c9073d1db4 100644 --- a/apps/map-studio/src/app/editor/minimap.component.ts +++ b/apps/map-studio/src/app/editor/minimap.component.ts @@ -13,20 +13,10 @@ import { import { IconComponent } from '@placeos/components'; import { MapObject } from '../data/types'; +import { TYPE_ACCENT_COLORS } from './constants'; import { EditorStateService } from './editor-state.service'; import { importedObject } from './source-svg'; -const TYPE_COLORS: Record = { - room: '#3b82f6', - desk: '#22c55e', - zone: '#a855f7', - area: '#f59e0b', - amenity: '#06b6d4', - decorative: '#6b7280', - parking: '#8b5cf6', - locker: '#ec4899', -}; - /** Length of the minimap's longer side, so tall plans do not cover the canvas */ const MINIMAP_SIZE = 180; @@ -243,7 +233,7 @@ export class MinimapComponent { }); public readonly colorFor = (object: MapObject) => - TYPE_COLORS[object.object_type] ?? '#94a3b8'; + TYPE_ACCENT_COLORS[object.object_type] ?? '#94a3b8'; public readonly pointsOf = (object: MapObject) => (object.geometry.points ?? []).map((p) => `${p.x},${p.y}`).join(' '); diff --git a/apps/map-studio/src/app/editor/object-list-panel.component.ts b/apps/map-studio/src/app/editor/object-list-panel.component.ts index 0a156f94cfa..65482cb6508 100644 --- a/apps/map-studio/src/app/editor/object-list-panel.component.ts +++ b/apps/map-studio/src/app/editor/object-list-panel.component.ts @@ -2,19 +2,9 @@ import { Component, computed, inject, output, signal } from '@angular/core'; import { IconComponent } from '@placeos/components'; import { MapObject } from '../data/types'; +import { TYPE_ACCENT_COLORS } from './constants'; import { EditorStateService } from './editor-state.service'; -const TYPE_COLORS: Record = { - room: '#3b82f6', - desk: '#22c55e', - zone: '#a855f7', - area: '#f59e0b', - amenity: '#06b6d4', - decorative: '#6b7280', - parking: '#8b5cf6', - locker: '#ec4899', -}; - const TYPE_ORDER = [ 'room', 'desk', @@ -158,7 +148,7 @@ export class ObjectListPanelComponent { public readonly asValue = (event: Event) => (event.target as HTMLInputElement).value; - public readonly colorFor = (type: string) => TYPE_COLORS[type] ?? '#6b7280'; + public readonly colorFor = (type: string) => TYPE_ACCENT_COLORS[type] ?? '#6b7280'; public readonly plural = (type: string) => type === 'amenity' ? 'amenities' : `${type}s`; diff --git a/apps/map-studio/src/app/editor/publish-panel.component.ts b/apps/map-studio/src/app/editor/publish-panel.component.ts index 5333510720e..9fc3efbebbc 100644 --- a/apps/map-studio/src/app/editor/publish-panel.component.ts +++ b/apps/map-studio/src/app/editor/publish-panel.component.ts @@ -16,7 +16,7 @@ import { import { StoreService } from '../data/store.service'; import { Floorplan, MapObject } from '../data/types'; import { EditorStateService } from './editor-state.service'; -import { bookableObjects, buildSvg, preflightChecks } from './svg-export'; +import { buildSvg, preflightChecks } from './svg-export'; import { ToastService } from './toast.service'; /** A PlaceOS list request and its result */ @@ -481,9 +481,7 @@ export class PublishPanelComponent { this.checks().every((check) => check.pass), ); - public readonly bookable = computed(() => - bookableObjects(this.state.objects()), - ); + public readonly bookable = this.state.bookable; public readonly bookableSystems = computed(() => this.systems().items.filter((system) => system.bookable), diff --git a/apps/map-studio/src/app/editor/source-svg.ts b/apps/map-studio/src/app/editor/source-svg.ts index 2a3bf781673..f810cbe7be7 100644 --- a/apps/map-studio/src/app/editor/source-svg.ts +++ b/apps/map-studio/src/app/editor/source-svg.ts @@ -7,6 +7,7 @@ import { sanitiseTree, } from '@placeos/map-editor'; import { MapObject, ObjectGeometry } from '../data/types'; +import { objectBounds } from './geometry'; export function importedObject(object: MapObject): boolean { return object.metadata?.['source'] === 'svg-import'; @@ -39,34 +40,6 @@ function selectorPattern(id: string, selector_only = false) { ); } -function bounds(geometry: ObjectGeometry) { - if (geometry.points?.length) { - const xs = geometry.points.map((p) => p.x); - const ys = geometry.points.map((p) => p.y); - return { - x: Math.min(...xs), - y: Math.min(...ys), - width: Math.max(...xs) - Math.min(...xs), - height: Math.max(...ys) - Math.min(...ys), - }; - } - if (geometry.type === 'circle') { - const r = geometry.r || 0; - return { - x: (geometry.x || 0) - r, - y: (geometry.y || 0) - r, - width: r * 2, - height: r * 2, - }; - } - return { - x: geometry.x || 0, - y: geometry.y || 0, - width: geometry.width || 0, - height: geometry.height || 0, - }; -} - function patchObject(element: Element, object: MapObject) { const metadata = object.metadata || {}; const original = metadata['originalGeometry'] as ObjectGeometry | undefined; @@ -74,8 +47,8 @@ function patchObject(element: Element, object: MapObject) { original && JSON.stringify(original) !== JSON.stringify(object.geometry) ) { - const before = bounds(original); - const after = bounds(object.geometry); + const before = objectBounds(original); + const after = objectBounds(object.geometry); const sx = before.width ? after.width / before.width : 1; const sy = before.height ? after.height / before.height : 1; let delta: Matrix = { diff --git a/apps/map-studio/src/app/editor/svg-export.ts b/apps/map-studio/src/app/editor/svg-export.ts index 1fbca7e1e86..bcc9ab72b62 100644 --- a/apps/map-studio/src/app/editor/svg-export.ts +++ b/apps/map-studio/src/app/editor/svg-export.ts @@ -1,4 +1,5 @@ -import { MapObject, ObjectGeometry } from '../data/types'; +import { MapObject, ObjectGeometry, ValidationIssue } from '../data/types'; +import { isBookable, toFillOpacity } from './availability'; import { TYPE_COLORS } from './constants'; import { composeSourceSvg, importedObject } from './source-svg'; @@ -42,22 +43,60 @@ export function sequentialIdRange( : `${first} … ${sequentialId(prefix, start + count - 1)}`; } -/** Rooms and desks — the objects PlaceOS can bind a booking to */ -export const bookableObjects = (objects: MapObject[]) => - objects.filter((o) => o.object_type === 'room' || o.object_type === 'desk'); +/** How many times each id occurs */ +function idCounts(ids: string[]): Map { + const counts = new Map(); + for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1); + return counts; +} + +/** + * Problems in the editor's objects: a missing label (warning), empty + * geometry, or an svg id that more than one object uses (errors). + */ +export function validateObjects( + objects: readonly MapObject[], +): ValidationIssue[] { + const issues: ValidationIssue[] = []; + for (const object of objects) { + if (!object.label) { + issues.push({ + type: 'warning', + objectId: object.id, + message: `Object ${object.id} (${object.object_type}) has no label`, + }); + } + if (!object.geometry || Object.keys(object.geometry).length === 0) { + issues.push({ + type: 'error', + objectId: object.id, + message: `Object ${object.id} (${object.object_type}) has empty geometry`, + }); + } + } + const svg_ids = idCounts( + objects.filter((o) => o.svg_id).map((o) => o.svg_id), + ); + for (const [svg_id, count] of svg_ids) { + if (count < 2) continue; + for (const object of objects.filter((o) => o.svg_id === svg_id)) { + issues.push({ + type: 'error', + objectId: object.id, + message: `Duplicate svg_id "${svg_id}" on object ${object.id}`, + }); + } + } + return issues; +} /** * Checks that must pass before a map can be published to PlaceOS. * Mirrors the reference application's pre-publish checklist. */ export function preflightChecks(objects: MapObject[]): PreflightCheck[] { - const bookable = bookableObjects(objects); - - const duplicates = new Map(); - for (const object of bookable) { - const id = idOf(object); - duplicates.set(id, (duplicates.get(id) ?? 0) + 1); - } + const bookable = objects.filter(isBookable); + const duplicates = idCounts(bookable.map(idOf)); return [ { @@ -100,15 +139,6 @@ export function preflightChecks(objects: MapObject[]): PreflightCheck[] { ]; } -/** Convert a hex colour into rgba() at the given opacity */ -function toFillOpacity(hex: string, opacity: number): string { - const clean = hex.replace('#', '').slice(0, 6); - const r = parseInt(clean.slice(0, 2), 16) || 0; - const g = parseInt(clean.slice(2, 4), 16) || 0; - const b = parseInt(clean.slice(4, 6), 16) || 0; - return `rgba(${r},${g},${b},${opacity})`; -} - function exportFill(object: MapObject): string { const base = TYPE_COLORS[object.object_type] ?? '#4b5563'; const color = object.fill_color || base; diff --git a/apps/map-studio/src/app/editor/validation-panel.component.ts b/apps/map-studio/src/app/editor/validation-panel.component.ts index 4303caca9c3..d90c35bbe1d 100644 --- a/apps/map-studio/src/app/editor/validation-panel.component.ts +++ b/apps/map-studio/src/app/editor/validation-panel.component.ts @@ -1,11 +1,13 @@ import { Component, computed, inject, output, signal } from '@angular/core'; -import { errorMessage } from '@placeos/common'; import { IconComponent } from '@placeos/components'; -import { StoreService } from '../data/store.service'; import { MapObject, ValidationIssue } from '../data/types'; import { EditorStateService } from './editor-state.service'; -import { PreflightCheck, preflightChecks } from './svg-export'; +import { + PreflightCheck, + preflightChecks, + validateObjects, +} from './svg-export'; @Component({ selector: 'map-studio-validation-panel', @@ -18,20 +20,13 @@ import { PreflightCheck, preflightChecks } from './svg-export'; - @if (error(); as message) { -
- {{ message }} -
- } - - @if (has_run() && !running()) { + @if (has_run()) {
@@ -59,11 +54,11 @@ import { PreflightCheck, preflightChecks } from './svg-export'; }
- @if (!has_run() && !running()) { + @if (!has_run()) {

Run the checks to find problems before you publish.

- } @else if (stale() && !running()) { + } @else if (stale()) {

@@ -74,8 +69,7 @@ import { PreflightCheck, preflightChecks } from './svg-export'; !stale() && has_run() && !issues().length && - !failed_checks().length && - !running() + !failed_checks().length ) {

+ @if (!configured()) { +
+

+ Public events are not configured yet +

+

+ Configure a default public events source in the + authority settings, or provide one with the shared + event link. +

+
+ }
`, @@ -81,4 +100,6 @@ export class PublicPageShellComponent { public readonly description = input( 'Browse public events, view details, and register to attend.', ); + /** False when there is no events source to show */ + public readonly configured = input(true); } diff --git a/apps/public/src/tests/public-event.helpers.spec.ts b/apps/public/src/tests/public-event.helpers.spec.ts index 8ff015dc701..a1af88dcae2 100644 --- a/apps/public/src/tests/public-event.helpers.spec.ts +++ b/apps/public/src/tests/public-event.helpers.spec.ts @@ -1,6 +1,9 @@ +import { convertToParamMap } from '@angular/router'; import { eventDescriptionText, isSystemEvent, + preservedQueryParams, + querySystemId, } from '../app/public-event.helpers'; describe('public event helpers', () => { @@ -22,6 +25,26 @@ describe('public event helpers', () => { ); }); + it('should read the source id from the first query param that is set', () => { + expect(querySystemId(convertToParamMap({ id: 'c', system: 'b' }))).toBe( + 'b', + ); + expect(querySystemId(convertToParamMap({}))).toBe(''); + }); + + it('should keep only the query params that do not pick a source or event', () => { + expect( + preservedQueryParams({ + system_id: 'a', + system: 'b', + id: 'c', + event_id: 'e', + direct_event: 'true', + lang: 'en', + }), + ).toEqual({ lang: 'en' }); + }); + it('should read description text without creating elements in the page', () => { const create = vi.spyOn(document, 'createElement'); expect( diff --git a/apps/public/src/tests/public-events.component.spec.ts b/apps/public/src/tests/public-events.component.spec.ts index e7219ea99b8..990e7ae3072 100644 --- a/apps/public/src/tests/public-events.component.spec.ts +++ b/apps/public/src/tests/public-events.component.spec.ts @@ -150,6 +150,16 @@ describe('PublicEventsComponent', () => { expect(fixture.componentInstance.registered()).toBe(true); }); + it('stays unregistered when registration fails', async () => { + service.authenticated.set(true); + service.guest.set({ name: 'A', email: 'a@b.com' }); + service.default_system_id.set('sys-1'); + service.register.mockRejectedValueOnce(new Error('Full')); + const fixture = await init(); + await fixture.componentInstance.register({ id: 'e1' }); + expect(fixture.componentInstance.registered()).toBe(false); + }); + it('does not load events when there is no system id', async () => { service.authenticated.set(true); service.guest.set({ name: 'A', email: 'a@b.com' }); diff --git a/apps/public/src/tests/public-events.service.spec.ts b/apps/public/src/tests/public-events.service.spec.ts index f4b8edc1aab..83aaa365553 100644 --- a/apps/public/src/tests/public-events.service.spec.ts +++ b/apps/public/src/tests/public-events.service.spec.ts @@ -56,6 +56,19 @@ describe('PublicEventsService', () => { expect(service.loading()).toBe(''); }); + it('should show an authority load failure as the error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false } as Response), + ); + const service = TestBed.inject(PublicEventsService); + + await service.init(); + + expect(service.error()).toBe('Unable to load PlaceOS authority.'); + vi.unstubAllGlobals(); + }); + it('should ignore stored values that are not guest details', () => { localStorage.setItem( 'PLACEOS.public.guest', diff --git a/apps/public/src/tests/public-page-shell.component.spec.ts b/apps/public/src/tests/public-page-shell.component.spec.ts index a3d5b1f5c06..2e2ad1c4418 100644 --- a/apps/public/src/tests/public-page-shell.component.spec.ts +++ b/apps/public/src/tests/public-page-shell.component.spec.ts @@ -44,6 +44,19 @@ describe('PublicPageShellComponent', () => { expect(projected?.textContent).toContain('Projected body'); }); + it('shows the not-configured message only when not configured', () => { + const fixture = TestBed.createComponent(PublicPageShellComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).not.toContain( + 'not configured yet', + ); + fixture.componentRef.setInput('configured', false); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain( + 'Public events are not configured yet', + ); + }); + it('falls back to the default logo and description', () => { const fixture = TestBed.createComponent(PublicPageShellComponent); fixture.detectChanges(); diff --git a/apps/stagehand/src/app/alerts.component.ts b/apps/stagehand/src/app/alerts.component.ts index de6707d1dcb..e445911cda5 100644 --- a/apps/stagehand/src/app/alerts.component.ts +++ b/apps/stagehand/src/app/alerts.component.ts @@ -15,374 +15,324 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - settingSignal, -} from '@placeos/common'; +import { AsyncHandler } from '@placeos/common'; import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { showSystem } from '@placeos/ts-client'; import { DashboardsService } from './dashboards/dashboards.service'; -import { SidebarComponent } from './ui/sidebar.component'; +import { backofficeLink } from './ui/sidebar.component'; +import { StagehandPageComponent } from './ui/stagehand-page.component'; @Component({ selector: 'stagehand-alerts', template: ` -
- - -
-
+
+ @if (dashboard()) { +
-

AV Systems Alerts

-
- @if (dashboard()) { -
settings + + } +
+ + + Disconnected Devices - settings - - } -
+ @for (item of dashboards(); track item.id) { + {{ + item.name + }} + } + + +
+
+
+ warning +
+

Critical

+
+ Attention required +
+
+
+ {{ critical_alerts() || '0' }} +
+
+
+ error +
+

Warnings

+
+ May require attention +
+
+
+ {{ warning_alerts() || '0' }} +
+
+
+ schedule +
+

Open

+
+ Pending resolution +
+
+
+ {{ open_alerts() || '0' }} +
+
+
+
+
+ + search + + +
+
+
- Disconnected DevicesAll Severities + Critical - @for (item of dashboards(); track item.id) { - {{ - item.name - }} - } + Warning + Info
-
-
-
-
+ - warning -
-

Critical

-
- Attention required -
-
-
- {{ critical_alerts() || '0' }} -
+ All Devices + Display + Audio + Video + Network + Control System + +
+
+
+
+
+ @if (show_alert_list_loading()) { +
+ +
+ } @else { + + } + +
+
+ {{ row.subject }}
-
- error -
-

Warnings

-
- May require attention -
-
-
- {{ warning_alerts() || '0' }} -
+
+ {{ row.body }}
-
+ + +
+ {{ severity_types[data]?.icon }} - schedule -
-

Open

-
- Pending resolution -
-
-
- {{ open_alerts() || '0' }} -
-
+
{{ severity_types[data]?.text }}
+ +
-
- - search - - -
-
{{ device_types[alert.type]?.icon }} -
- - - All Severities - Critical - Warning - Info - - -
-
- - - All Devices - Display - Audio - Video - Network - Control System - - -
+
+ {{ device_types[alert.type]?.text }}
-
- @if (show_alert_list_loading()) { -
- -
- } @else { - - } - -
-
- {{ row.subject }} -
-
- {{ row.body }} -
-
-
- -
- {{ severity_types[data]?.icon }} -
{{ severity_types[data]?.text }}
-
-
- -
- {{ alert.device }} - {{ device_types[alert.type]?.icon }} -
- {{ device_types[alert.type]?.text }} -
-
-
- -
- @let name = locationName(data); -
{{ name || data }}
- @if (name) { -
- {{ data }} -
- } -
-
- -
+ + +
+ @let name = locationName(data); +
{{ name || data }}
+ @if (name) { +
{{ data }}
- - - - + } +
+
+ +
+ {{ data }}
- +
+ + +
-
+ `, styles: [``], imports: [ @@ -393,7 +343,7 @@ import { SidebarComponent } from './ui/sidebar.component'; MatFormFieldModule, MatSelectModule, MatInputModule, - SidebarComponent, + StagehandPageComponent, FormsModule, MatTooltipModule, MatProgressSpinnerModule, @@ -404,7 +354,6 @@ export class AlertsComponent extends AsyncHandler implements OnInit { private _route = inject(ActivatedRoute); private _router = inject(Router); private _dashboards = inject(DashboardsService); - private _org = inject(OrganisationService); private _initialized = signal(false); private _waiting_for_first_alert = signal(true); private _location_names = signal>({}); @@ -446,8 +395,7 @@ export class AlertsComponent extends AsyncHandler implements OnInit { const severity = this.severity(); const device_type = this.device_type(); const status = this.status(); - const region = this._dashboards.region_id(); - const building = this._dashboards.building_id(); + const location_params = this._dashboards.location_query_params(); const initialized = this._initialized(); // Skip until after we've loaded from query params @@ -460,10 +408,7 @@ export class AlertsComponent extends AsyncHandler implements OnInit { severity: severity || undefined, device_type: device_type || undefined, status: status || undefined, - // Use 'all' to represent all regions selected, actual region id otherwise - region: region || 'all', - // Use 'all' to represent all buildings when region is selected - building: region ? building || 'all' : undefined, + ...location_params, }; this._router.navigate([], { @@ -500,10 +445,7 @@ export class AlertsComponent extends AsyncHandler implements OnInit { () => this._waiting_for_first_alert() && this.alert_list().length < 1, ); - public readonly backoffice_link = settingSignal( - 'backoffice_link', - `${location.origin}/backoffice/`, - ); + public readonly backoffice_link = backofficeLink(); public ngOnInit() { // Load filters from query parameters @@ -520,35 +462,7 @@ export class AlertsComponent extends AsyncHandler implements OnInit { if (query_params['status']) { this.status.set(query_params['status']); } - // Handle region query param - 'all' means all regions, otherwise specific region id - const region_param = query_params['region']; - const building_param = query_params['building']; - if (region_param === 'all') { - // Explicitly set to all regions via dashboards service - this._dashboards.setRegionFromParams('', ''); - } else if (region_param) { - const region = this._org.regions.find((r) => r.id === region_param); - if (region) { - this._org.region = region; - // Handle building param - 'all' means all buildings, otherwise specific building id - const building_id = - building_param === 'all' ? '' : building_param || ''; - // Set via dashboards service to prevent constructor overwrite - this._dashboards.setRegionFromParams(region.id, building_id); - - // Only apply building to org if specific building is set (not 'all') - if (building_param && building_param !== 'all') { - const building = this._org.buildings.find( - (b) => b.id === building_param, - ); - if (building) { - this._org.building = building; - } - } - } - } - // Note: If no region param exists, we don't call setRegionFromParams - // so the constructor can set defaults from org service + this._dashboards.applyLocationParams(query_params); // Enable effect after loading from query params this._initialized.set(true); diff --git a/apps/stagehand/src/app/analytics.component.ts b/apps/stagehand/src/app/analytics.component.ts index bc3e2540025..cc67935bab3 100644 --- a/apps/stagehand/src/app/analytics.component.ts +++ b/apps/stagehand/src/app/analytics.component.ts @@ -5,7 +5,7 @@ import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { IconComponent, SafePipe, TranslatePipe } from '@placeos/components'; -import { SidebarComponent } from './ui/sidebar.component'; +import { StagehandPageComponent } from './ui/stagehand-page.component'; interface AnalyticsPage { name: string; @@ -15,63 +15,44 @@ interface AnalyticsPage { @Component({ selector: 'stagehand-analytics', template: ` -
- -
-
-

AV Systems Analytics

-
- - - @for ( - page of analytics_pages(); - track page.name - ) { - {{ - page.name - }} - } - - -
-
-
- @if (page()?.url; as url) { - - - } @else { -
- arrow_warm_up -

- {{ - 'Select an analytics dashboard from above' - | translate - }} -

-
- } -
+ +
+ + + @for (page of analytics_pages(); track page.name) { + {{ + page.name + }} + } + +
-
+ @if (page()?.url; as url) { + + + } @else { +
+ arrow_warm_up +

+ {{ + 'Select an analytics dashboard from above' + | translate + }} +

+
+ } + `, styles: [``], imports: [ @@ -81,7 +62,7 @@ interface AnalyticsPage { MatFormFieldModule, MatSelectModule, FormsModule, - SidebarComponent, + StagehandPageComponent, ], }) export class AnalyticsComponent extends AsyncHandler { diff --git a/apps/stagehand/src/app/dashboards/alert-condition-compare-form.component.ts b/apps/stagehand/src/app/dashboards/alert-condition-compare-form.component.ts index 7602df55ca6..c645d1578bc 100644 --- a/apps/stagehand/src/app/dashboards/alert-condition-compare-form.component.ts +++ b/apps/stagehand/src/app/dashboards/alert-condition-compare-form.component.ts @@ -22,8 +22,8 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { Identity, i18n, notifyError } from '@placeos/common'; +import { TranslatePipe } from '@placeos/components'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; type ComparisonSide = 'left' | 'right'; diff --git a/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts b/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts index f0eed7725ad..1e392bb0c4d 100644 --- a/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts +++ b/apps/stagehand/src/app/dashboards/alert-conditions-modal.component.ts @@ -9,8 +9,10 @@ import { TriggerConditionOperator, TriggerStatusVariable, } from '@placeos/ts-client'; -import { FullscreenModalShellComponent } from 'libs/components/src/lib/fullscreen-modal-shell.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + FullscreenModalShellComponent, + TranslatePipe, +} from '@placeos/components'; import { AlertConditionComparisonFormComponent } from './alert-condition-compare-form.component'; export interface AlertConditionData { diff --git a/apps/stagehand/src/app/dashboards/dashboard-alert-list.component.ts b/apps/stagehand/src/app/dashboards/dashboard-alert-list.component.ts index 749be7962e7..b83407d37fa 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-alert-list.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-alert-list.component.ts @@ -10,9 +10,11 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { AsyncHandler } from '@placeos/common'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + IconComponent, + SimpleTableComponent, + TranslatePipe, +} from '@placeos/components'; import { DashboardsService } from './dashboards.service'; @Component({ diff --git a/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts b/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts index 90aeb669196..85eb7a7d62a 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-alert-manage.component.ts @@ -27,10 +27,12 @@ import { TriggerConditions, updateAlert, } from '@placeos/ts-client'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + IconComponent, + SettingsToggleComponent, + SimpleTableComponent, + TranslatePipe, +} from '@placeos/components'; import { AlertConditionModalComponent } from './alert-conditions-modal.component'; import { DashboardsService } from './dashboards.service'; import { SystemSearchFieldComponent } from './system-search-field.component'; diff --git a/apps/stagehand/src/app/dashboards/dashboard-list.component.ts b/apps/stagehand/src/app/dashboards/dashboard-list.component.ts index c85ce873fa8..0979b08d086 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-list.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-list.component.ts @@ -11,9 +11,11 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { PlaceAlertDashboard } from '@placeos/ts-client'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + IconComponent, + SimpleTableComponent, + TranslatePipe, +} from '@placeos/components'; import { DashboardsService } from './dashboards.service'; @Component({ diff --git a/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts b/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts index c8d50f7f079..df21b8cc4fb 100644 --- a/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboard-mange.component.ts @@ -16,9 +16,11 @@ import { notifyError, } from '@placeos/common'; import { addAlertDashboard, updateAlertDashboard } from '@placeos/ts-client'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { + IconComponent, + SettingsToggleComponent, + TranslatePipe, +} from '@placeos/components'; import { DashboardsService } from './dashboards.service'; @Component({ diff --git a/apps/stagehand/src/app/dashboards/dashboards.component.ts b/apps/stagehand/src/app/dashboards/dashboards.component.ts index 94189e61665..5721b75bd26 100644 --- a/apps/stagehand/src/app/dashboards/dashboards.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboards.component.ts @@ -10,69 +10,63 @@ import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Router, RouterModule } from '@angular/router'; import { AsyncHandler } from '@placeos/common'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { SidebarComponent } from '../ui/sidebar.component'; +import { IconComponent, TranslatePipe } from '@placeos/components'; +import { StagehandPageComponent } from '../ui/stagehand-page.component'; import { DashboardsService } from './dashboards.service'; @Component({ selector: `stagehand-dashboards`, template: ` -
- -
-
+ @if (page() === 'alerts') { + - @if (page() === 'alerts') { - - arrow_back - - } -

- {{ - (page() === 'alerts' - ? 'APP.STAGEHAND.DASHBOARD_ALERTS_HEADER' - : 'APP.STAGEHAND.DASHBOARD_HEADER' - ) | translate - }} -

- @if (page() === 'alerts') { -
- {{ dashboard()?.name }} -
- } -
- {{ - (page() === 'alerts' - ? 'APP.STAGEHAND.DASHBOARD_ALERTS_ADD' - : 'APP.STAGEHAND.DASHBOARD_ADD' - ) | translate - }} -
-
arrow_back + + } + + @if (page() === 'alerts') { +
- -
-
-
+ {{ dashboard()?.name }} +
+ } +
+ {{ + (page() === 'alerts' + ? 'APP.STAGEHAND.DASHBOARD_ALERTS_ADD' + : 'APP.STAGEHAND.DASHBOARD_ADD' + ) | translate + }} + + `, styles: [], imports: [ - SidebarComponent, + StagehandPageComponent, MatRippleModule, RouterModule, TranslatePipe, diff --git a/apps/stagehand/src/app/dashboards/dashboards.service.ts b/apps/stagehand/src/app/dashboards/dashboards.service.ts index 35777758dc1..fe96752f096 100644 --- a/apps/stagehand/src/app/dashboards/dashboards.service.ts +++ b/apps/stagehand/src/app/dashboards/dashboards.service.ts @@ -1,4 +1,11 @@ -import { effect, inject, Injectable, signal, untracked } from '@angular/core'; +import { + computed, + effect, + inject, + Injectable, + signal, + untracked, +} from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { AsyncHandler, @@ -83,6 +90,19 @@ export class DashboardsService extends AsyncHandler { ); public readonly dashboard_alerts = signal([]); + /** + * Region and building query params that keep the URL in step with the + * selection. 'all' stands for every region, or every building of the + * selected region. + */ + public readonly location_query_params = computed(() => { + const region = this.region_id(); + return { + region: region || 'all', + building: region ? this.building_id() || 'all' : undefined, + }; + }); + constructor() { super(); @@ -148,22 +168,9 @@ export class DashboardsService extends AsyncHandler { /** Initialize region/building from URL query params */ private _initFromQueryParams() { // Use query string captured at construction time to avoid reading - // params injected by the _query_sync effect in AlertsComponent + // params the pages write back to the URL const params = new URLSearchParams(this._initial_query_string); - - const region_param = params.get('region'); - const building_param = params.get('building'); - - if (region_param) { - this._region_set_from_params = true; - // 'all' means empty string (all regions/buildings) - this.region_id.set(region_param === 'all' ? '' : region_param); - if (building_param) { - this.building_id.set( - building_param === 'all' ? '' : building_param, - ); - } - } + this.applyLocationParams(Object.fromEntries(params)); } /** Set region from query params, preventing constructor from overwriting */ @@ -175,6 +182,28 @@ export class DashboardsService extends AsyncHandler { } } + /** + * Applies the `region` and `building` URL query params. 'all' selects + * every region, or every building of the region. A region id applies only + * when it is a known region; it then also becomes the org region, and a + * known building id the org building. With no region param nothing + * changes, so the org selection stays the default. + */ + public applyLocationParams(params: { region?: string; building?: string }) { + const { region: region_param, building: building_param } = params; + if (!region_param) return; + if (region_param === 'all') return this.setRegionFromParams('', ''); + const region = this._org.regions.find((r) => r.id === region_param); + if (!region) return; + this._org.region = region; + const building_id = + building_param === 'all' ? '' : building_param || ''; + this.setRegionFromParams(region.id, building_id); + if (!building_id) return; + const building = this._org.buildings.find((b) => b.id === building_id); + if (building) this._org.building = building; + } + /** * Shows `key` in `loading` while `task` runs. A failed task is logged * and reported, and the key is always removed. diff --git a/apps/stagehand/src/app/dashboards/system-search-field.component.ts b/apps/stagehand/src/app/dashboards/system-search-field.component.ts index a8d62aa87fe..4c381f24fb4 100644 --- a/apps/stagehand/src/app/dashboards/system-search-field.component.ts +++ b/apps/stagehand/src/app/dashboards/system-search-field.component.ts @@ -17,9 +17,9 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { AsyncHandler, provideValueAccessor } from '@placeos/common'; +import { IconComponent } from '@placeos/components'; import { inGroupScope } from '../support-groups'; import { SupportGroupsService } from '../support-groups.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; @Component({ selector: 'system-search-field', diff --git a/apps/stagehand/src/app/recorder-view.component.ts b/apps/stagehand/src/app/recorder-view.component.ts index 2a4763dd257..b64f1b99313 100644 --- a/apps/stagehand/src/app/recorder-view.component.ts +++ b/apps/stagehand/src/app/recorder-view.component.ts @@ -1,13 +1,8 @@ -import { - Component, - inject, - OnInit, - signal, -} from '@angular/core'; +import { Component, inject, OnInit, signal } from '@angular/core'; import { AsyncHandler, OrganisationService } from '@placeos/common'; import { SafePipe } from '@placeos/components'; import { showMetadata } from '@placeos/ts-client'; -import { SidebarComponent } from './ui/sidebar.component'; +import { StagehandPageComponent } from './ui/stagehand-page.component'; interface RecorderStreamImage { id: string; @@ -23,17 +18,10 @@ interface RecorderStreamMetadata { @Component({ selector: `recorder-grid-view`, - template: `
- -
-
-

Recorder Streams

-
-
+
@for (image of images(); track image.id) {
} -
-
-
`, +
+ + `, styles: ``, - imports: [SidebarComponent, SafePipe], + imports: [StagehandPageComponent, SafePipe], }) export class RecorderGridViewComponent extends AsyncHandler implements OnInit { private _org = inject(OrganisationService); diff --git a/apps/stagehand/src/app/remote-support.component.ts b/apps/stagehand/src/app/remote-support.component.ts index ef80346ef0c..dc8036bb73f 100644 --- a/apps/stagehand/src/app/remote-support.component.ts +++ b/apps/stagehand/src/app/remote-support.component.ts @@ -6,6 +6,7 @@ import { inject, OnInit, signal, + WritableSignal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; @@ -18,7 +19,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { AsyncHandler, CalendarEvent, - OrganisationService, randomInt, settingSignal, Space, @@ -35,365 +35,320 @@ import { CameraSnapshotModalComponent } from './camera-snapshot-modal.component' import { Alert } from './dashboards/broker-alerts'; import { DashboardsService } from './dashboards/dashboards.service'; import { SupportService } from './support.service'; -import { SidebarComponent } from './ui/sidebar.component'; +import { backofficeLink } from './ui/sidebar.component'; +import { StagehandPageComponent } from './ui/stagehand-page.component'; @Component({ selector: 'stagehand-remote-support', template: ` -
- -
-
+
+
-

- AV Systems Remote Support -

-
-
sensors +
+

Total Rooms

+
+ Total room count with devices +
+
+
+ {{ zone_rooms()?.length || '0' }} +
+
+
-
warning +
+

Active Alerts

+
+
+ {{ room_alerts().length }} +
+
+
+
+
+ -
search - sensors -
-

Total Rooms

-
- Total room count with devices -
-
-
- {{ zone_rooms()?.length || '0' }} -
-
- +
+ + - warning -
-

- Active Alerts -

-
-
- {{ room_alerts().length }} -
-
-
-
-
- - search - - -
+ All Rooms + In Use + Available + Has Issues + + +
+
+
+ @for (space of room_list(); track space.id) { + + } + + + + +
+
- - - All Rooms - In Use - Available - Has Issues - - + {{ + status()[space.id] === 'free' + ? 'Available' + : status()[space.id] === 'busy' + ? 'In Use' + : status()[space.id] === 'pending' + ? 'Pending' + : 'No status' + }}
-
- @for (space of room_list(); track space.id) { - + + + @let events = key === 'current_event' ? current() : next(); +
+ @if (events[space.id]) { +
{{ events[space.id].title }}
+
+ {{ + events[space.id].event_start * 1000 + | date: 'shortTime' + }} + – + {{ + events[space.id].event_end * 1000 + | date: 'shortTime' + }} +
+ } @else { + None } - + @let feed_url = cameraSnapshotFeed(space); + @if (feed_url) { + - } @else { -
- hide_image -
- } - - - @if (data?.length) { - @let issue = data[0]; -
- {{ - issue.severity === 'critical' - ? 'warning' - : 'error' - }} -
-
{{ issue.subject }}
- @if (data.length > 1) { -
- +{{ data.length - 1 }} more - issues -
- } -
-
- } @else { -
- No issues -
- } -
- - - +
+ } @else { +
No issues
+ } +
+ + - +
-
+ `, styles: [ ` @@ -419,7 +374,7 @@ import { SidebarComponent } from './ui/sidebar.component'; MatFormFieldModule, MatSelectModule, MatInputModule, - SidebarComponent, + StagehandPageComponent, FormsModule, MatTooltipModule, ViewportVisibilityComponent, @@ -429,7 +384,6 @@ import { SidebarComponent } from './ui/sidebar.component'; export class RemoteSupportComponent extends AsyncHandler implements OnInit { private readonly _dashboard = inject(DashboardsService); private readonly _support = inject(SupportService); - private readonly _org = inject(OrganisationService); private readonly _router = inject(Router); private readonly _route = inject(ActivatedRoute); private readonly _dialog = inject(MatDialog); @@ -450,8 +404,7 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { private _query_checks = effect(() => { const search = this.search_term(); const state = this.state(); - const region = this._dashboard.region_id(); - const building = this._dashboard.building_id(); + const location_params = this._dashboard.location_query_params(); const initialized = this._initialized(); // Skip until after we've loaded from query params @@ -462,10 +415,7 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { const query_params: Record = { search: search || undefined, state: state || undefined, - // Use 'all' to represent all regions selected, actual region id otherwise - region: region || 'all', - // Use 'all' to represent all buildings when region is selected - building: region ? building || 'all' : undefined, + ...location_params, }; this._router.navigate([], { @@ -506,10 +456,7 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { ); return alerts; }); - public readonly backoffice_link = settingSignal( - 'backoffice_link', - `${location.origin}/backoffice/`, - ); + public readonly backoffice_link = backofficeLink(); public readonly service_link = settingSignal( 'service_link', `${location.origin}/control/`, @@ -606,35 +553,7 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { if (query_params['state']) { this.state.set(query_params['state']); } - // Handle region query param - 'all' means all regions, otherwise specific region id - const region_param = query_params['region']; - const building_param = query_params['building']; - if (region_param === 'all') { - // Explicitly set to all regions via dashboard service - this._dashboard.setRegionFromParams('', ''); - } else if (region_param) { - const region = this._org.regions.find((r) => r.id === region_param); - if (region) { - this._org.region = region; - // Handle building param - 'all' means all buildings, otherwise specific building id - const building_id = - building_param === 'all' ? '' : building_param || ''; - // Set via dashboard service to prevent constructor overwrite - this._dashboard.setRegionFromParams(region.id, building_id); - - // Only apply building to org if specific building is set (not 'all') - if (building_param && building_param !== 'all') { - const building = this._org.buildings.find( - (b) => b.id === building_param, - ); - if (building) { - this._org.building = building; - } - } - } - } - // Note: If no region param exists, we don't call setRegionFromParams - // so the constructor can set defaults from org service + this._dashboard.applyLocationParams(query_params); // Enable effect after loading from query params this._initialized.set(true); @@ -648,30 +567,29 @@ export class RemoteSupportComponent extends AsyncHandler implements OnInit { }); public setCurrentBooking(space: Space, event: CalendarEvent) { - const current = this.current(); - if (!event && current[space.id]) { - // a new object, so the signal sees the booking end - const { [space.id]: _, ...rest } = current; - this.current.set(rest); - return; - } - // Only update if the event actually changed - if (JSON.stringify(current[space.id]) !== JSON.stringify(event)) { - this.current.update((old) => ({ ...old, [space.id]: event })); - } + this._setBooking(this.current, space, event); } public setNextBooking(space: Space, event: CalendarEvent) { - const current = this.next(); + this._setBooking(this.next, space, event); + } + + /** Sets the room's booking in `bookings`, or removes it when there is none */ + private _setBooking( + bookings: WritableSignal>, + space: Space, + event: CalendarEvent, + ) { + const current = bookings(); if (!event && current[space.id]) { // a new object, so the signal sees the booking end const { [space.id]: _, ...rest } = current; - this.next.set(rest); + bookings.set(rest); return; } // Only update if the event actually changed if (JSON.stringify(current[space.id]) !== JSON.stringify(event)) { - this.next.update((old) => ({ ...old, [space.id]: event })); + bookings.update((old) => ({ ...old, [space.id]: event })); } } diff --git a/apps/stagehand/src/app/ui/sidebar.component.ts b/apps/stagehand/src/app/ui/sidebar.component.ts index 52dfeb34845..9eab8b05631 100644 --- a/apps/stagehand/src/app/ui/sidebar.component.ts +++ b/apps/stagehand/src/app/ui/sidebar.component.ts @@ -1,9 +1,4 @@ -import { - Component, - computed, - inject, - signal, -} from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; @@ -11,17 +6,25 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; -import { firstValueFrom } from 'rxjs'; import { OrganisationService, SettingsService, settingSignal, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; +import { firstValueFrom } from 'rxjs'; import { DashboardsService } from '../dashboards/dashboards.service'; -import { SupportGroupsService } from '../support-groups.service'; import { PushNotificationSettingsComponent } from '../push-notification-settings.component'; import { AlertNotificationService } from '../push-notification.service'; +import { SupportGroupsService } from '../support-groups.service'; + +/** + * Signal of the backoffice URL from the `backoffice_link` setting. Call it + * after the settings load, for example in a component field. + */ +export function backofficeLink() { + return settingSignal('backoffice_link', `${location.origin}/backoffice/`); +} /** Start collapsed on narrow screens, so the sidebar does not cover the page */ const COMPACT_SIGNAL = signal( @@ -135,10 +138,8 @@ const COMPACT_SIGNAL = signal( }} @for (region of r_list; track region.id) { {{ - region.display_name || - region.name - }} + region.display_name || region.name + }} } @@ -167,9 +168,8 @@ const COMPACT_SIGNAL = signal( }} @for (bld of bld_list; track bld.id) { {{ - bld.display_name || bld.name - }} + bld.display_name || bld.name + }} } @@ -178,82 +178,20 @@ const COMPACT_SIGNAL = signal( } }
- - notifications - @if (!is_compact()) { - Alerts - } - - - wifi - @if (!is_compact()) { - Remote Support - } - - @if (analytics_pages()?.length > 0) { - - show_chart - @if (!is_compact()) { - Analytics - } - - } - - dashboard - @if (!is_compact()) { - Manage Dashboards - } - - @if (show_recorder_view()) { + @for (link of nav_links(); track link.route) { - stream + {{ link.icon }} @if (!is_compact()) { - Recorder Streams + {{ link.name }} } } @@ -404,15 +342,53 @@ export class SidebarComponent { public readonly can_change_dark_mode = computed( () => !!this._allow_dark_mode(), ); - public readonly backoffice_link = settingSignal( - 'backoffice_link', - `${location.origin}/backoffice/`, - ); + public readonly backoffice_link = backofficeLink(); public readonly show_recorder_view = settingSignal( 'show_recorder_view', false, ); public readonly analytics_pages = settingSignal('analytics_pages', []); + /** Page links, without the optional pages that are turned off */ + public readonly nav_links = computed(() => [ + { + route: '/alerts', + name: 'Alerts', + aria_label: 'Alerts', + icon: 'notifications', + }, + { + route: '/remote-support', + name: 'Remote Support', + aria_label: 'Remote support', + icon: 'wifi', + }, + ...(this.analytics_pages()?.length > 0 + ? [ + { + route: '/analytics', + name: 'Analytics', + aria_label: 'Analytics', + icon: 'show_chart', + }, + ] + : []), + { + route: '/dashboards', + name: 'Manage Dashboards', + aria_label: 'Manage dashboards', + icon: 'dashboard', + }, + ...(this.show_recorder_view() + ? [ + { + route: '/recorder-grid', + name: 'Recorder Streams', + aria_label: 'Recorder streams', + icon: 'stream', + }, + ] + : []), + ]); public readonly region = this._dash.region_id; public readonly building = this._dash.building_id; diff --git a/apps/stagehand/src/app/ui/stagehand-page.component.ts b/apps/stagehand/src/app/ui/stagehand-page.component.ts new file mode 100644 index 00000000000..40e2a6305b2 --- /dev/null +++ b/apps/stagehand/src/app/ui/stagehand-page.component.ts @@ -0,0 +1,38 @@ +import { Component, input } from '@angular/core'; +import { SidebarComponent } from './sidebar.component'; + +/** + * Page shell of the Stagehand pages: the sidebar, a header with the page + * heading, and the main area that the skip link goes to. + * Header content with the `page-header-start` attribute shows before the + * heading, and content with the `page-header` attribute after it. Other content + * goes in the main area. + */ +@Component({ + selector: 'stagehand-page', + template: ` +
+ +
+
+ +

{{ heading() }}

+ +
+
+ +
+
+
+ `, + imports: [SidebarComponent], +}) +export class StagehandPageComponent { + /** Page heading */ + public readonly heading = input.required(); +} diff --git a/apps/stagehand/src/tests/alert-condition-compare-form.component.spec.ts b/apps/stagehand/src/tests/alert-condition-compare-form.component.spec.ts index 86a3517ebe9..913a9d987ac 100644 --- a/apps/stagehand/src/tests/alert-condition-compare-form.component.spec.ts +++ b/apps/stagehand/src/tests/alert-condition-compare-form.component.spec.ts @@ -3,12 +3,12 @@ import { ComponentFixtureAutoDetect } from '@angular/core/testing'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { TriggerConditionOperator } from '@placeos/ts-client'; -import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; vi.mock('@placeos/ts-client', { spy: true }); import { queryModules, systemModuleState } from '@placeos/ts-client'; +import { setNotifyOutlet } from '@placeos/common'; import { AlertConditionComparisonFormComponent, calculateModuleIndex, diff --git a/apps/stagehand/src/tests/alerts.component.spec.ts b/apps/stagehand/src/tests/alerts.component.spec.ts index 1b8bc51ac50..35402343010 100644 --- a/apps/stagehand/src/tests/alerts.component.spec.ts +++ b/apps/stagehand/src/tests/alerts.component.spec.ts @@ -1,16 +1,15 @@ -import type { Mock } from 'vitest'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { ActivatedRoute, Router } from '@angular/router'; -import { OrganisationService } from '@placeos/common'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { showSystem } from '@placeos/ts-client'; import { MockComponent, MockProvider } from 'ng-mocks'; +import type { Mock } from 'vitest'; import { AlertsComponent } from '../app/alerts.component'; import { DashboardsService } from '../app/dashboards/dashboards.service'; -import { SidebarComponent } from '../app/ui/sidebar.component'; +import { StagehandPageComponent } from '../app/ui/stagehand-page.component'; vi.mock('@placeos/ts-client', { spy: true }); @@ -36,13 +35,12 @@ describe('AlertsComponent', () => { let router: { navigate: Mock }; let route: unknown; let dashboards_service: any; - let org: unknown; const create_component = createComponentFactory({ component: AlertsComponent, detectChanges: false, declarations: [ - MockComponent(SidebarComponent), + MockComponent(StagehandPageComponent), MockComponent(SimpleTableComponent), MockComponent(IconComponent), ], @@ -69,15 +67,14 @@ describe('AlertsComponent', () => { loadDashboards: vi.fn(), setDashboard: vi.fn(() => Promise.resolve()), listenForDashboardAlerts: vi.fn(), - setRegionFromParams: vi.fn(), + applyLocationParams: vi.fn(), + location_query_params: signal({ region: 'all' }), }; - org = { regions: [], buildings: [], region: null, building: null }; return create_component({ providers: [ { provide: Router, useValue: router }, { provide: ActivatedRoute, useValue: route }, MockProvider(DashboardsService, dashboards_service), - MockProvider(OrganisationService, org), ], }); }; @@ -103,9 +100,24 @@ describe('AlertsComponent', () => { it('should filter the alert list by severity, device type and status', () => { spectator = build(); dashboard_alerts.set([ - make_alert({ id: '1', severity: 'critical', type: 'display', status: 'open' }), - make_alert({ id: '2', severity: 'high', type: 'audio', status: 'closed' }), - make_alert({ id: '3', severity: 'critical', type: 'audio', status: 'open' }), + make_alert({ + id: '1', + severity: 'critical', + type: 'display', + status: 'open', + }), + make_alert({ + id: '2', + severity: 'high', + type: 'audio', + status: 'closed', + }), + make_alert({ + id: '3', + severity: 'critical', + type: 'audio', + status: 'open', + }), ]); spectator.component.severity.set('critical'); @@ -168,29 +180,13 @@ describe('AlertsComponent', () => { expect(dashboards_service.loadDashboards).toHaveBeenCalled(); }); - it('should apply all-regions scope when the region param is "all"', () => { - spectator = build({ region: 'all' }); - spectator.component.ngOnInit(); - expect(dashboards_service.setRegionFromParams).toHaveBeenCalledWith( - '', - '', - ); - }); - - it('should apply a specific region and building from query params', () => { - const region = { id: 'region-1' }; - const building = { id: 'building-1' }; - spectator = build({ region: 'region-1', building: 'building-1' }); - const injected_org: any = spectator.inject(OrganisationService); - injected_org.regions = [region]; - injected_org.buildings = [building]; + it('should apply the region and building query params on init', () => { + const params = { region: 'region-1', building: 'building-1' }; + spectator = build(params); spectator.component.ngOnInit(); - expect(injected_org.region).toBe(region); - expect(dashboards_service.setRegionFromParams).toHaveBeenCalledWith( - 'region-1', - 'building-1', + expect(dashboards_service.applyLocationParams).toHaveBeenCalledWith( + params, ); - expect(injected_org.building).toBe(building); }); it('should navigate to the selected dashboard route, keeping the filters', () => { diff --git a/apps/stagehand/src/tests/dashboards.service.spec.ts b/apps/stagehand/src/tests/dashboards.service.spec.ts index b5cb2da50c3..3deb1e81683 100644 --- a/apps/stagehand/src/tests/dashboards.service.spec.ts +++ b/apps/stagehand/src/tests/dashboards.service.spec.ts @@ -5,12 +5,11 @@ import { createServiceFactory, SpectatorService, } from '@ngneat/spectator/vitest'; -import { OrganisationService } from '@placeos/common'; +import { OrganisationService, setNotifyOutlet } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; import { NEVER, of } from 'rxjs'; import * as ts_client from '@placeos/ts-client'; -import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; import { DashboardsService } from '../app/dashboards/dashboards.service'; import { AlertNotificationService } from '../app/push-notification.service'; @@ -164,6 +163,60 @@ describe('DashboardsService', () => { }); }); + describe('applyLocationParams', () => { + it('should select every region for "all"', () => { + spectator.service.region_id.set('region-1'); + spectator.service.building_id.set('building-1'); + spectator.service.applyLocationParams({ region: 'all' }); + expect(spectator.service.region_id()).toBe(''); + expect(spectator.service.building_id()).toBe(''); + }); + + it('should apply a known region and building to the org', () => { + const region = { id: 'region-1' }; + const building = { id: 'building-1' }; + const injected: any = spectator.inject(OrganisationService); + injected.regions = [region]; + injected.buildings = [building]; + spectator.service.applyLocationParams({ + region: 'region-1', + building: 'building-1', + }); + expect(injected.region).toBe(region); + expect(injected.building).toBe(building); + expect(spectator.service.region_id()).toBe('region-1'); + expect(spectator.service.building_id()).toBe('building-1'); + }); + + it('should ignore an unknown region', () => { + const injected: any = spectator.inject(OrganisationService); + injected.regions = []; + spectator.service.applyLocationParams({ region: 'region-x' }); + expect(spectator.service.region_id()).toBe(''); + }); + }); + + describe('location_query_params', () => { + it('should write "all" for no region and keep building unset', () => { + expect(spectator.service.location_query_params()).toEqual({ + region: 'all', + building: undefined, + }); + }); + + it('should write "all" buildings for a region with no building', () => { + spectator.service.region_id.set('region-1'); + expect(spectator.service.location_query_params()).toEqual({ + region: 'region-1', + building: 'all', + }); + spectator.service.building_id.set('building-1'); + expect(spectator.service.location_query_params().building).toBe( + 'building-1', + ); + }); + }); + describe('setAlert', () => { it('should load the alert and clear the loading flag', async () => { const alert = { id: 'alert-1', name: 'Alert One' }; diff --git a/apps/stagehand/src/tests/dashboards/dashboard-alert-list.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-alert-list.component.spec.ts index c752221d599..9b76acf7aee 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-alert-list.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-alert-list.component.spec.ts @@ -5,10 +5,9 @@ import { } from '@ngneat/spectator/vitest'; import { MockComponent, MockProvider } from 'ng-mocks'; +import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { DashboardAlertListComponent } from '../../app/dashboards/dashboard-alert-list.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; describe('DashboardAlertListComponent', () => { let spectator: SpectatorRouting; diff --git a/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts index dd69b883a11..93632a4a889 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-alert-manage.component.spec.ts @@ -9,14 +9,16 @@ import { import { MockComponent, MockProvider } from 'ng-mocks'; import { delay, of } from 'rxjs'; -import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; import { addAlert, updateAlert } from '@placeos/ts-client'; +import { + IconComponent, + SettingsToggleComponent, + SimpleTableComponent, +} from '@placeos/components'; +import { setNotifyOutlet } from '@placeos/common'; import { DashboardAlertManageComponent } from '../../app/dashboards/dashboard-alert-manage.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; import { SystemSearchFieldComponent } from '../../app/dashboards/system-search-field.component'; vi.mock('@placeos/ts-client', { spy: true }); diff --git a/apps/stagehand/src/tests/dashboards/dashboard-list.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-list.component.spec.ts index eb1696d9d2b..139246b5e32 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-list.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-list.component.spec.ts @@ -5,10 +5,9 @@ import { } from '@ngneat/spectator/vitest'; import { MockComponent, MockProvider } from 'ng-mocks'; +import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { DashboardListComponent } from '../../app/dashboards/dashboard-list.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; describe('DashboardListComponent', () => { let spectator: SpectatorRouting; diff --git a/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts index 51dca841ff4..df1cbe0b1db 100644 --- a/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboard-mange.component.spec.ts @@ -6,13 +6,12 @@ import { } from '@ngneat/spectator/vitest'; import { MockComponent, MockProvider } from 'ng-mocks'; -import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; import { addAlertDashboard, updateAlertDashboard } from '@placeos/ts-client'; +import { IconComponent, SettingsToggleComponent } from '@placeos/components'; +import { setNotifyOutlet } from '@placeos/common'; import { DashboardManageComponent } from '../../app/dashboards/dashboard-mange.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; vi.mock('@placeos/ts-client', { spy: true }); diff --git a/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts b/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts index b8631c38293..90d9fe40abe 100644 --- a/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts +++ b/apps/stagehand/src/tests/dashboards/dashboards.component.spec.ts @@ -3,9 +3,9 @@ import { provideRouter } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockComponent, MockProvider } from 'ng-mocks'; +import { IconComponent } from '@placeos/components'; import { DashboardsComponent } from '../../app/dashboards/dashboards.component'; import { DashboardsService } from '../../app/dashboards/dashboards.service'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; import { SidebarComponent } from '../../app/ui/sidebar.component'; describe('DashboardsComponent', () => { diff --git a/apps/stagehand/src/tests/remote-support.component.spec.ts b/apps/stagehand/src/tests/remote-support.component.spec.ts index 927604398a9..1ad9a7dcc73 100644 --- a/apps/stagehand/src/tests/remote-support.component.spec.ts +++ b/apps/stagehand/src/tests/remote-support.component.spec.ts @@ -1,11 +1,10 @@ -import type { Mock } from 'vitest'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Router } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; +import type { Mock } from 'vitest'; import { CameraSnapshotModalComponent } from '../app/camera-snapshot-modal.component'; import { DashboardsService } from '../app/dashboards/dashboards.service'; @@ -37,14 +36,12 @@ describe('RemoteSupportComponent', () => { detectChanges: false, // Render nothing: these tests exercise the component's signals, computeds // and methods, not its (heavy) template with the sidebar + data table. - overrideComponents: [[RemoteSupportComponent, { set: { template: '' } }]], + overrideComponents: [ + [RemoteSupportComponent, { set: { template: '' } }], + ], providers: [ MockProvider(DashboardsService, {}), MockProvider(SupportService, {}), - MockProvider(OrganisationService, { - regions: [], - buildings: [], - }), MockProvider(MatDialog, {}), MockProvider(Router, {}), MockProvider(ActivatedRoute, { @@ -70,7 +67,8 @@ describe('RemoteSupportComponent', () => { dashboard_alerts, setDashboard: vi.fn(), listenForDashboardAlerts: vi.fn(), - setRegionFromParams: vi.fn(), + applyLocationParams: vi.fn(), + location_query_params: signal({ region: 'all' }), }, }, { provide: SupportService, useValue: { space_list } }, @@ -84,7 +82,9 @@ describe('RemoteSupportComponent', () => { expect(spectator.component.snapshotUrl('')).toBe(''); const url = spectator.component.snapshotUrl('http://host/snap'); expect(url).toMatch(/^http:\/\/host\/snap\?t=\d+$/); - const with_query = spectator.component.snapshotUrl('http://host/snap?a=1'); + const with_query = spectator.component.snapshotUrl( + 'http://host/snap?a=1', + ); expect(with_query).toMatch(/^http:\/\/host\/snap\?a=1&t=\d+$/); }); @@ -147,7 +147,9 @@ describe('RemoteSupportComponent', () => { room({ id: 'b', zones: ['bld-2'] }), ] as any); building_id.set('bld-1'); - expect(spectator.component.zone_rooms().map((r) => r.id)).toEqual(['a']); + expect(spectator.component.zone_rooms().map((r) => r.id)).toEqual([ + 'a', + ]); }); it('should attach matching alerts to each room and de-duplicate them', () => { From 4b945acfdfb02a54b22eeb67123a7913115d9201 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 3 Nov 2026 08:01:34 +1100 Subject: [PATCH 10/10] fix(components): keep chat messages that cannot be sent If no chat system is set, the chat cannot connect. sendMessage now shows an error and returns false, and the chat keeps the text in the input. An error also shows if the connection closes before it opens. --- .../components/src/lib/chat/chat.component.ts | 3 +- libs/components/src/lib/chat/chat.service.ts | 37 ++++++++--- .../src/tests/chat.component.spec.ts | 12 +++- .../components/src/tests/chat.service.spec.ts | 65 ++++++++++++++++++- shared/assets/locale/ar.json | 1 + shared/assets/locale/en-AU.json | 1 + shared/assets/locale/en-GB.json | 1 + shared/assets/locale/en-US.json | 1 + shared/assets/locale/es.json | 1 + shared/assets/locale/fr-CA.json | 1 + shared/assets/locale/fr.json | 1 + shared/assets/locale/it.json | 1 + shared/assets/locale/ja-JP.json | 1 + shared/assets/locale/pt.json | 1 + shared/assets/locale/zh.json | 1 + 15 files changed, 114 insertions(+), 14 deletions(-) diff --git a/libs/components/src/lib/chat/chat.component.ts b/libs/components/src/lib/chat/chat.component.ts index 42b2828244e..522c97043db 100644 --- a/libs/components/src/lib/chat/chat.component.ts +++ b/libs/components/src/lib/chat/chat.component.ts @@ -297,7 +297,8 @@ export class ChatComponent extends AsyncHandler implements OnInit { public sendMessage() { if (!this.message()) return; - this._chat.sendMessage(this.message()); + // Keep the text in the input if the chat cannot connect + if (!this._chat.sendMessage(this.message())) return; this.message.set(''); this.height.set(56); setTimeout(() => this._input_el().nativeElement.focus(), 100); diff --git a/libs/components/src/lib/chat/chat.service.ts b/libs/components/src/lib/chat/chat.service.ts index 194fb27a373..ad13af17c53 100644 --- a/libs/components/src/lib/chat/chat.service.ts +++ b/libs/components/src/lib/chat/chat.service.ts @@ -4,7 +4,9 @@ import { OrganisationService, SettingsService, currentUser, + i18n, log, + notifyError, randomString, } from '@placeos/common'; import { apiKey, getModule, token } from '@placeos/ts-client'; @@ -111,23 +113,40 @@ export class ChatService extends AsyncHandler { /** * Sends a message to the chat. Opens a new connection first if the chat * has ended, for example after the idle timeout or `close()`. + * Returns `false` and shows an error if no chat system is set, so the + * caller can keep the message. An error also shows if the connection + * closes before it opens. */ - public sendMessage(message: string) { - if (!message) return; + public sendMessage(message: string): boolean { + if (!message) return false; const current = this._socket; if (!current || current.readyState >= WebSocket.CLOSING) { this._cleanup(current); this.startChat(); } - this._onMessage({ chat_id: '', message, user_id: currentUser().id }); const socket = this._socket; - // Sending before the socket opens throws, so wait for it to open - if (socket?.readyState === WebSocket.CONNECTING) { - socket.addEventListener('open', () => socket.send(message), { - once: true, - }); - } else socket?.send(message); + if (!socket) { + notifyError(i18n('APP.WORKPLACE.CHAT_SEND_FAILED')); + return false; + } + this._onMessage({ chat_id: '', message, user_id: currentUser().id }); + // Sending before the socket opens throws, so wait for it to open. + // A socket that fails before it opens always fires `close`. + if (socket.readyState === WebSocket.CONNECTING) { + const failed = () => + notifyError(i18n('APP.WORKPLACE.CHAT_SEND_FAILED')); + socket.addEventListener( + 'open', + () => { + socket.removeEventListener('close', failed); + socket.send(message); + }, + { once: true }, + ); + socket.addEventListener('close', failed, { once: true }); + } else socket.send(message); + return true; } private _timeoutSocket(delay = 55 * 1000) { diff --git a/libs/components/src/tests/chat.component.spec.ts b/libs/components/src/tests/chat.component.spec.ts index 903e589a32a..59b82f32f5a 100644 --- a/libs/components/src/tests/chat.component.spec.ts +++ b/libs/components/src/tests/chat.component.spec.ts @@ -14,7 +14,7 @@ describe('ChatComponent', () => { chat_hint: signal(''), messages: signal([]), progress: signal(null), - sendMessage: vi.fn(), + sendMessage: vi.fn(() => true), close: vi.fn(), }; @@ -69,6 +69,16 @@ describe('ChatComponent', () => { vi.advanceTimersByTime(200); }); + it('should keep the message when the chat cannot connect', () => { + chat_enabled.set(true); + spectator.component.show.set(true); + spectator.detectChanges(); + chat_mock.sendMessage.mockReturnValueOnce(false); + spectator.component.message.set('Hello there'); + spectator.component.sendMessage(); + expect(spectator.component.message()).toBe('Hello there'); + }); + it('should toggle the progress detail when tapped', () => { chat_enabled.set(true); spectator.component.show.set(true); diff --git a/libs/components/src/tests/chat.service.spec.ts b/libs/components/src/tests/chat.service.spec.ts index 2d1792b7ed8..0f1fed4986f 100644 --- a/libs/components/src/tests/chat.service.spec.ts +++ b/libs/components/src/tests/chat.service.spec.ts @@ -3,7 +3,11 @@ import { createServiceFactory, SpectatorService, } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { + OrganisationService, + setNotifyOutlet, + SettingsService, +} from '@placeos/common'; vi.mock('@placeos/ts-client', { spy: true }); @@ -12,14 +16,23 @@ import { ChatService } from '../lib/chat/chat.service'; /** WebSocket stand-in that records instances */ class FakeSocket { + public static readonly CONNECTING = 0; + public static readonly OPEN = 1; + public static readonly CLOSING = 2; public static instances: FakeSocket[] = []; - public readyState = 1; + public readyState = FakeSocket.OPEN; public onmessage: ((e: MessageEvent) => void) | null = null; public onerror: ((e: Event) => void) | null = null; public onclose: (() => void) | null = null; public close = vi.fn(); public send = vi.fn(); - public addEventListener = vi.fn(); + public listeners: Record void> = {}; + public addEventListener = vi.fn( + (name: string, fn: () => void) => (this.listeners[name] = fn), + ); + public removeEventListener = vi.fn( + (name: string) => delete this.listeners[name], + ); constructor(public url: string) { FakeSocket.instances.push(this); } @@ -42,7 +55,18 @@ describe('ChatService', () => { ], }); + // Workspace modules cannot be mocked here, so spy on the snackbar outlet + // that `notifyError` uses + const snackbar = { + open: vi.fn(() => ({ + onAction: () => ({ subscribe: () => undefined }), + dismiss: () => undefined, + })), + }; + beforeEach(() => { + snackbar.open.mockClear(); + setNotifyOutlet(snackbar as never); FakeSocket.instances = []; (window as unknown as { WebSocket: unknown }).WebSocket = FakeSocket; vi.mocked(client.getModule).mockImplementation( @@ -87,4 +111,39 @@ describe('ChatService', () => { expect(FakeSocket.instances).toHaveLength(2); expect(FakeSocket.instances[1].send).toHaveBeenCalledWith('Hello'); }); + + it('should not show a message that it cannot send', () => { + spectator.service.setBinding(''); + + expect(spectator.service.sendMessage('Hello')).toBe(false); + + expect(FakeSocket.instances).toHaveLength(0); + expect(spectator.service.messages()).toEqual([]); + expect(snackbar.open).toHaveBeenCalled(); + }); + + it('should show an error if the chat closes before it opens', () => { + spectator.service.startChat(); + const socket = FakeSocket.instances[0]; + socket.readyState = FakeSocket.CONNECTING; + + expect(spectator.service.sendMessage('Hello')).toBe(true); + socket.listeners['close'](); + + expect(socket.send).not.toHaveBeenCalled(); + expect(snackbar.open).toHaveBeenCalled(); + }); + + it('should send the message once the chat opens', () => { + spectator.service.startChat(); + const socket = FakeSocket.instances[0]; + socket.readyState = FakeSocket.CONNECTING; + + spectator.service.sendMessage('Hello'); + socket.listeners['open'](); + + expect(socket.send).toHaveBeenCalledWith('Hello'); + expect(socket.listeners['close']).toBeUndefined(); + expect(snackbar.open).not.toHaveBeenCalled(); + }); }); diff --git a/shared/assets/locale/ar.json b/shared/assets/locale/ar.json index 7ede6342caa..113fb566b48 100644 --- a/shared/assets/locale/ar.json +++ b/shared/assets/locale/ar.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "المساعد", "CHAT_WAITING": "في انتظار الرد...", "CHAT_MESSAGE_PLACEHOLDER": "رسالة جديدة...", + "CHAT_SEND_FAILED": "تعذر إرسال الرسالة. المساعد غير متاح.", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "ترتيب حسب التاريخ (الأحدث أولاً)", diff --git a/shared/assets/locale/en-AU.json b/shared/assets/locale/en-AU.json index 22281f45f02..68f184eb9d4 100644 --- a/shared/assets/locale/en-AU.json +++ b/shared/assets/locale/en-AU.json @@ -2050,6 +2050,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "Waiting for a reply...", "CHAT_MESSAGE_PLACEHOLDER": "New message...", + "CHAT_SEND_FAILED": "Unable to send the message. The assistant is not available.", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "Sort by Date (Newest First)", diff --git a/shared/assets/locale/en-GB.json b/shared/assets/locale/en-GB.json index e2749ba8775..3c2186d5726 100644 --- a/shared/assets/locale/en-GB.json +++ b/shared/assets/locale/en-GB.json @@ -967,6 +967,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "Waiting for a reply...", "CHAT_MESSAGE_PLACEHOLDER": "New message...", + "CHAT_SEND_FAILED": "Unable to send the message. The assistant is not available.", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "Sort by Date (Newest First)", diff --git a/shared/assets/locale/en-US.json b/shared/assets/locale/en-US.json index 67fe55b6a87..7be8e86dc86 100644 --- a/shared/assets/locale/en-US.json +++ b/shared/assets/locale/en-US.json @@ -967,6 +967,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "Waiting for a reply...", "CHAT_MESSAGE_PLACEHOLDER": "New message...", + "CHAT_SEND_FAILED": "Unable to send the message. The assistant is not available.", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "Sort by Date (Newest First)", diff --git a/shared/assets/locale/es.json b/shared/assets/locale/es.json index 31ee0b172df..c9025bef109 100644 --- a/shared/assets/locale/es.json +++ b/shared/assets/locale/es.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Asistente", "CHAT_WAITING": "Esperando una respuesta...", "CHAT_MESSAGE_PLACEHOLDER": "Nuevo mensaje...", + "CHAT_SEND_FAILED": "No se pudo enviar el mensaje. El asistente no está disponible.", "DEAL_COUNT_1": "{{ count }} Oferta", "DEAL_COUNT": "{{ count }} Ofertas", "SORT_BY_DATE_DESC": "Ordenar por Fecha (Más Reciente Primero)", diff --git a/shared/assets/locale/fr-CA.json b/shared/assets/locale/fr-CA.json index c6fb9e202a8..c583e3693a4 100644 --- a/shared/assets/locale/fr-CA.json +++ b/shared/assets/locale/fr-CA.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "En attente d'une réponse...", "CHAT_MESSAGE_PLACEHOLDER": "Nouveau message...", + "CHAT_SEND_FAILED": "Impossible d'envoyer le message. L'assistant n'est pas disponible.", "DEAL_COUNT_1": "{{ count }} offre", "DEAL_COUNT": "{{ count }} offres", "SORT_BY_DATE_DESC": "Trier par date (plus récent en premier)", diff --git a/shared/assets/locale/fr.json b/shared/assets/locale/fr.json index a3b863370ad..22f7edcd600 100644 --- a/shared/assets/locale/fr.json +++ b/shared/assets/locale/fr.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Assistant", "CHAT_WAITING": "En attente d’une réponse...", "CHAT_MESSAGE_PLACEHOLDER": "Nouveau message...", + "CHAT_SEND_FAILED": "Impossible d'envoyer le message. L'assistant n'est pas disponible.", "DEAL_COUNT_1": "{{ count }} offre", "DEAL_COUNT": "{{ count }} offres", "SORT_BY_DATE_DESC": "Trier par date (plus récent d'abord)", diff --git a/shared/assets/locale/it.json b/shared/assets/locale/it.json index 9dde8706f20..48c00b395e7 100644 --- a/shared/assets/locale/it.json +++ b/shared/assets/locale/it.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Assistente", "CHAT_WAITING": "In attesa di una risposta...", "CHAT_MESSAGE_PLACEHOLDER": "Nuovo messaggio...", + "CHAT_SEND_FAILED": "Impossibile inviare il messaggio. L'assistente non è disponibile.", "DEAL_COUNT_1": "{{ count }} Offerta", "DEAL_COUNT": "{{ count }} Offerte", "SORT_BY_DATE_DESC": "Ordina per Data (Prima i Più Recenti)", diff --git a/shared/assets/locale/ja-JP.json b/shared/assets/locale/ja-JP.json index e17a7a79902..44d50c861f1 100644 --- a/shared/assets/locale/ja-JP.json +++ b/shared/assets/locale/ja-JP.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "アシスタント", "CHAT_WAITING": "返信を待っています...", "CHAT_MESSAGE_PLACEHOLDER": "新しいメッセージ...", + "CHAT_SEND_FAILED": "メッセージを送信できません。アシスタントは利用できません。", "DEAL_COUNT_1": "{{ count }} Offer", "DEAL_COUNT": "{{ count }} Offers", "SORT_BY_DATE_DESC": "日付順(新しい順)", diff --git a/shared/assets/locale/pt.json b/shared/assets/locale/pt.json index a125deccf57..2c6b938a959 100644 --- a/shared/assets/locale/pt.json +++ b/shared/assets/locale/pt.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "Assistente", "CHAT_WAITING": "Aguardando resposta...", "CHAT_MESSAGE_PLACEHOLDER": "Nova mensagem...", + "CHAT_SEND_FAILED": "Não foi possível enviar a mensagem. O assistente não está disponível.", "DEAL_COUNT_1": "{{ count }} Oferta", "DEAL_COUNT": "{{ count }} Ofertas", "SORT_BY_DATE_DESC": "Ordenar por Data (Mais Recente Primeiro)", diff --git a/shared/assets/locale/zh.json b/shared/assets/locale/zh.json index eea73b47814..0cfd1c03764 100644 --- a/shared/assets/locale/zh.json +++ b/shared/assets/locale/zh.json @@ -894,6 +894,7 @@ "CHAT_ASSISTANT": "助手", "CHAT_WAITING": "等待回复...", "CHAT_MESSAGE_PLACEHOLDER": "新消息...", + "CHAT_SEND_FAILED": "无法发送消息。助手不可用。", "DEAL_COUNT_1": "{{ count }} 个优惠", "DEAL_COUNT": "{{ count }} 个优惠", "SORT_BY_DATE_DESC": "按日期排序(最新优先)",