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/app/app.component.ts b/apps/app-loader/src/app/app.component.ts index 0f13ee79da1..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, @@ -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 { @@ -97,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/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/app/panel-view.component.ts b/apps/assistant-panel/src/app/panel-view.component.ts index a55f4cd6c78..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; @@ -360,6 +353,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(); @@ -657,12 +654,9 @@ 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; - this._chat.startChat(); this._chat.sendMessage(last_text); this._previous_message = last_text; this.last_text.set(''); 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/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/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/app/catering-topbar.component.ts b/apps/caterer-ui/src/app/catering-topbar.component.ts index 484c232805b..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') { @@ -278,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); @@ -294,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( @@ -344,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 }); @@ -365,7 +341,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; @@ -382,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/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']" >
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/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/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/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/concierge/src/app/catering/catering.component.ts b/apps/concierge/src/app/catering/catering.component.ts index d5f7b6150e2..98d73898ae5 100644 --- a/apps/concierge/src/app/catering/catering.component.ts +++ b/apps/concierge/src/app/catering/catering.component.ts @@ -53,7 +53,7 @@ import { CateringTopbarComponent } from './catering-topbar.component';
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 0aad1da6e7d..0984092cbe3 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'; @@ -11,9 +10,7 @@ import { initialisationFailure, initSentryFromSettings, lazySnackbar, - log, markInitialisationComplete, - OrganisationService, retryInitialisation, setAppName, setNotifyOutlet, @@ -29,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', @@ -80,10 +76,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(); @@ -99,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/enrolment/src/app/enrolment-error.component.ts b/apps/enrolment/src/app/enrolment-error.component.ts index acd058f8bbd..9a3a0e2768e 100644 --- a/apps/enrolment/src/app/enrolment-error.component.ts +++ b/apps/enrolment/src/app/enrolment-error.component.ts @@ -32,6 +32,14 @@ import { EnrolmentStateService } from './enrolment-state.service'; Meeting was cancelled, please check your calendar for updates. } + @case ('checkin') { + We could not check you in. Reload the page and try + again. + } + @case ('update') { + We could not save your details. Reload the page and try + again. + } }

diff --git a/apps/enrolment/src/app/enrolment-state.service.ts b/apps/enrolment/src/app/enrolment-state.service.ts index 568c5005731..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', }) @@ -111,21 +97,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/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 fc4f49da24a..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()); @@ -323,5 +304,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/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-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/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/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/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 90ea9995bb2..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, @@ -17,27 +17,26 @@ const SETTINGS_KEY = 'MAP_BUILDER.placeos'; const FLOORPLAN_METADATA_KEY = 'map-studio'; const PLACEOS_REQUEST_TIMEOUT = 10 * 1000; const HANDOFF_MIN_DELAY_MS = 30 * 1000; -/** Zones per request, and the most pages read for one query */ -const ZONE_PAGE_SIZE = 100; -const ZONE_PAGE_LIMIT = 50; - -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); - }, - ); - }); +/** Items per request, and the most pages read for one list query */ +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 fetchWithDeadline( @@ -49,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 { @@ -211,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 @@ -318,22 +323,11 @@ export class PlaceOSService { // ── Zones ─────────────────────────────────────────────────────────────── /** Every matching zone, reading one page after another */ - public async getZones( - tags?: string, - parent_id?: string, - ): Promise { - const zones: PlaceOSZone[] = []; - for (let page = 0; page < ZONE_PAGE_LIMIT; page++) { - const results = await this._request('/zones', { - limit: `${ZONE_PAGE_SIZE}`, - offset: `${page * ZONE_PAGE_SIZE}`, - ...(tags ? { tags } : {}), - ...(parent_id ? { parent_id } : {}), - }); - zones.push(...results); - if (results.length < ZONE_PAGE_SIZE) break; - } - return zones; + public getZones(tags?: string, parent_id?: string): Promise { + return this._requestAll('/zones', { + ...(tags ? { tags } : {}), + ...(parent_id ? { parent_id } : {}), + }); } public async getActiveOrganisation(): Promise { @@ -383,13 +377,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) @@ -406,10 +394,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, @@ -451,8 +435,7 @@ export class PlaceOSService { zone_id?: string, bookable?: boolean, ): Promise { - return this._request('/systems', { - limit: '100', + return this._requestAll('/systems', { ...(zone_id ? { zone_id } : {}), ...(bookable === undefined ? {} : { bookable: `${bookable}` }), }); @@ -562,6 +545,24 @@ export class PlaceOSService { }; } + /** Reads every page of a list endpoint, up to {@link PAGE_LIMIT} pages */ + private async _requestAll( + path: string, + params: Record, + ): Promise { + const items: T[] = []; + for (let page = 0; page < PAGE_LIMIT; page++) { + const results = await this._request(path, { + ...params, + limit: `${PAGE_SIZE}`, + offset: `${page * PAGE_SIZE}`, + }); + items.push(...results); + if (results.length < PAGE_SIZE) break; + } + return items; + } + private async _request( path: string, params: Record = {}, diff --git a/apps/map-studio/src/app/data/store.service.ts b/apps/map-studio/src/app/data/store.service.ts index 65aade9e602..0506c2c2e6b 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 { @@ -10,11 +11,9 @@ import { Floorplan, FloorplanCanvas, FloorplanSummary, - FloorplanVersion, MapObject, MapObjectType, Project, - ValidationIssue, } from './types'; type ZoneReference = Pick; @@ -25,17 +24,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}`, @@ -43,10 +31,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 +81,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 +112,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 ''; @@ -310,7 +271,7 @@ export class StoreService { version: 1, created_at: now(), updated_at: now(), - ...localFloorplanData(metadata), + ...withoutLocalFields(metadata), }; await this._db.write({ put: [ @@ -382,26 +343,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'); @@ -441,28 +399,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); @@ -506,7 +462,7 @@ export class StoreService { const floorplan = await this.getFloorplan(id); const updated = { ...floorplan, - ...localFloorplanData(metadata), + ...withoutLocalFields(metadata), id, }; await this._db.write({ @@ -564,7 +520,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 +530,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 +556,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 +665,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 +676,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 ?? @@ -838,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, @@ -866,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, }, }); @@ -946,26 +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, - }); - } - - 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. @@ -982,122 +827,4 @@ 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( - 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/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/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 b49835e4e0a..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); } @@ -1207,8 +1193,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' ); } @@ -1450,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(); @@ -1756,12 +1738,16 @@ export class CanvasComponent { this._startPress(event); const centerX = this.centerX(object); const centerY = this.centerY(object); - const handleY = this.rotateHandleY(object); + // Measure from the pointer, not the handle: a handle inside a rotated + // shape turns with it, so it is not always straight above the center + const pointer = this._toCanvas(event.clientX, event.clientY); this._rotating.set({ id: object.id, centerX, centerY, - startAngle: (Math.atan2(handleY - centerY, 0) * 180) / Math.PI, + startAngle: + (Math.atan2(pointer.y - centerY, pointer.x - centerX) * 180) / + Math.PI, startRotation: object.geometry.rotation ?? 0, }); } @@ -1992,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, @@ -2050,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 && @@ -2070,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, @@ -2146,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 }[] = []; @@ -2327,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 e41e360cfc8..91d8a94588d 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']; @@ -37,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', @@ -73,14 +87,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 +113,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 +242,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'desk-single', label: 'Single Desk', - icon: 'D', svg: '', w: 30, h: 20, @@ -264,7 +250,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'desk-pair', label: 'Desk Pair', - icon: 'DD', svg: '', w: 50, h: 20, @@ -273,7 +258,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'desk-pod', label: 'Desk Pod (4)', - icon: '4D', svg: '', w: 50, h: 40, @@ -282,7 +266,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'table-small', label: 'Small Table', - icon: 'T', svg: '', w: 30, h: 30, @@ -291,7 +274,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'table-medium', label: 'Medium Table', - icon: 'T', svg: '', w: 50, h: 30, @@ -300,7 +282,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'table-large', label: 'Large Table', - icon: 'T', svg: '', w: 80, h: 40, @@ -309,7 +290,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'table-round', label: 'Round Table', - icon: 'O', svg: '', w: 30, h: 30, @@ -318,7 +298,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'standing-desk', label: 'Standing Desk', - icon: 'SD', svg: '', w: 30, h: 15, @@ -328,7 +307,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'bench', label: 'Bench', - icon: 'B', svg: '', w: 50, h: 12, @@ -337,7 +315,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'lounge-chair', label: 'Lounge Chair', - icon: 'LC', svg: '', w: 25, h: 25, @@ -346,7 +323,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'sofa', label: 'Sofa', - icon: 'So', svg: '', w: 55, h: 22, @@ -355,7 +331,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'phone-booth', label: 'Phone Booth', - icon: 'PB', svg: '', w: 25, h: 25, @@ -365,7 +340,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'lockers', label: 'Lockers', - icon: 'Lk', svg: '', w: 40, h: 15, @@ -374,7 +348,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'filing-cabinet', label: 'Filing Cabinet', - icon: 'FC', svg: '', w: 15, h: 20, @@ -383,7 +356,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'bookshelf', label: 'Bookshelf', - icon: 'BS', svg: '', w: 40, h: 12, @@ -392,7 +364,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'printer', label: 'Printer', - icon: 'Pr', svg: '', w: 20, h: 20, @@ -402,7 +373,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'plant', label: 'Plant', - icon: 'Pl', svg: '', w: 12, h: 12, @@ -411,7 +381,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'plant-large', label: 'Large Plant', - icon: 'PL', svg: '', w: 18, h: 18, @@ -420,7 +389,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'partition', label: 'Partition Wall', - icon: '||', svg: '', w: 60, h: 4, @@ -429,7 +397,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'whiteboard', label: 'Whiteboard', - icon: 'WB', svg: '', w: 40, h: 5, @@ -438,7 +405,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'tv-screen', label: 'TV/Screen', - icon: 'TV', svg: '', w: 35, h: 5, @@ -447,7 +413,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'bin', label: 'Waste Bin', - icon: 'Bn', svg: '', w: 8, h: 8, @@ -457,7 +422,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'conference-table', label: 'Conference Table', - icon: 'CT', svg: '', w: 80, h: 50, @@ -466,7 +430,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'monitor-arm', label: 'Monitor Arm', - icon: 'MA', svg: '', w: 15, h: 10, @@ -475,7 +438,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'chair-office', label: 'Office Chair', - icon: 'Ch', svg: '', w: 12, h: 12, @@ -484,7 +446,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'recycling', label: 'Recycling Bin', - icon: 'Rc', svg: '', w: 8, h: 8, @@ -493,7 +454,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'coat-rack', label: 'Coat Rack', - icon: 'CR', svg: '', w: 8, h: 8, @@ -502,7 +462,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'umbrella-stand', label: 'Umbrella Stand', - icon: 'US', svg: '', w: 8, h: 8, @@ -511,7 +470,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'water-cooler', label: 'Water Cooler', - icon: 'WC', svg: '', w: 10, h: 10, @@ -520,7 +478,6 @@ export const FURNITURE_ASSETS: FurnitureAsset[] = [ { id: 'fire-extinguisher', label: 'Fire Extinguisher', - icon: 'FE', svg: '', w: 6, h: 6, @@ -529,7 +486,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 bab5f19c2cf..0bb3ea8d884 100644 --- a/apps/map-studio/src/app/editor/editor-state.service.ts +++ b/apps/map-studio/src/app/editor/editor-state.service.ts @@ -1,8 +1,9 @@ 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 { loadImage } from '../data/load-image'; import { PlaceOSService } from '../data/placeos.service'; import { StoreService } from '../data/store.service'; import { @@ -13,11 +14,15 @@ import { MapObject, MapObjectType, } from '../data/types'; -import { cycleState, defaultStateFor, getStatesForType } from './availability'; +import { + cycleState, + defaultStateFor, + getStatesForType, + isBookable, +} from './availability'; import { AMENITY_ICONS, DESK_LAYOUTS, - EditorMode, FURNITURE_ASSETS, TYPE_COLORS, Tool, @@ -146,14 +151,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 +194,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(); @@ -315,7 +315,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); } @@ -455,7 +455,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); } @@ -480,20 +480,37 @@ 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); + } + + /** + * Saves a traced image's pixel size as the floor's canvas size. The kiosk + * reads only the stored size, so this keeps it in step with the editor. + * Floors with an SVG source already store the SVG's size. + */ + private async _storeImageSize( + floorplan_id: string, + dims: { width: number; height: number } | null, + ) { + const floorplan = this._floorplan(); + if (!dims || floorplan?.id !== floorplan_id || floorplan.svg_source) + return; + if ( + floorplan.canvas_width === dims.width && + floorplan.canvas_height === dims.height + ) + return; + const updated = await this._store.updateFloorplan(floorplan_id, { + canvas_width: dims.width, + canvas_height: dims.height, + }); + if (!this._destroyed && this._floorplan_id === floorplan_id) + this._floorplan.set(updated); } public async uploadSourceImage(file: File) { @@ -504,7 +521,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'); } } @@ -514,20 +531,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); } @@ -630,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), }; } @@ -739,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) { @@ -851,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; } @@ -943,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', @@ -961,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, @@ -999,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), @@ -1064,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 ──────────────────────────────────────────────────────── @@ -1279,35 +1281,11 @@ 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')); + this._error.set(errorMessage(error) || 'Failed to save'); } finally { this._saving.set(false); } } - - 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/app/editor/editor.component.ts b/apps/map-studio/src/app/editor/editor.component.ts index f637dc34ec0..15949fc9c13 100644 --- a/apps/map-studio/src/app/editor/editor.component.ts +++ b/apps/map-studio/src/app/editor/editor.component.ts @@ -10,12 +10,14 @@ 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'; 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 { @@ -950,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); @@ -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/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 b0e4650d6a1..9fc3efbebbc 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 { @@ -15,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 */ @@ -480,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), @@ -595,7 +594,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 +677,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 +704,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/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 1df299af5a2..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,10 +1,13 @@ import { Component, computed, inject, output, signal } from '@angular/core'; 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', @@ -17,20 +20,13 @@ import { PreflightCheck, preflightChecks } from './svg-export';
- @if (error(); as message) { -
- {{ message }} -
- } - - @if (has_run() && !running()) { + @if (has_run()) {
@@ -58,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()) {

@@ -73,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/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/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/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/app/app.component.ts b/apps/redirect/src/app/app.component.ts index 46d4f794997..234e576f60e 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; @@ -80,11 +80,6 @@ import { background-color: #424242; border-color: #888; } - - input { - border-color: #666; - color: #fff; - } } `, ], 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-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-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..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,6 +1,4 @@ -import { DatePipe } from '@angular/common'; import { - afterRenderEffect, Component, DestroyRef, ElementRef, @@ -16,14 +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 { isDisplayOnline } from './display-status.util'; +import { scrollSelectionIntoView } from '../shared/scroll-selection.util'; +import { + displayLastSeen, + displayStatusKey, + isDisplayOnline, +} from './display-status.util'; import { SignageDisplayService } from './signage-display.service'; @Component({ @@ -184,30 +185,17 @@ 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); 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() { @@ -224,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 bc104b82968..45ad3e583bb 100644 --- a/apps/signage-manager/src/app/displays/displays.component.ts +++ b/apps/signage-manager/src/app/displays/displays.component.ts @@ -1,20 +1,19 @@ -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 { signagePlayerUrl } from '../signage-url.util'; import { DisplayContentComponent } from './display-content.component'; import { DisplayHeaderComponent } from './display-header.component'; import { DisplayListComponent } from './display-list.component'; @@ -22,11 +21,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 +143,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 +182,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,25 +219,41 @@ 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 ''; - 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() { - 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 +280,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..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,16 +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, { - data: { zone_id: zone.id }, - 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; @@ -500,24 +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, { - data: { display_id: display.id }, - 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', @@ -526,45 +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, { - data: { playlist_id: playlist.id }, - 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', @@ -572,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/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-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/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-header.component.ts b/apps/signage-manager/src/app/groups/signage-group-header.component.ts index e99d6193a72..824c8b84eb3 100644 --- a/apps/signage-manager/src/app/groups/signage-group-header.component.ts +++ b/apps/signage-manager/src/app/groups/signage-group-header.component.ts @@ -39,6 +39,9 @@ import { SignageGroupEditModalComponent } from './signage-group-edit-modal.compo type="button" class="text-xl" matRipple + [attr.aria-label]=" + 'SIGNAGE_MANAGER.GROUPS_NEW_TOOLTIP' | translate + " [matTooltip]=" 'SIGNAGE_MANAGER.GROUPS_NEW_TOOLTIP' | translate " 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/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 0c69e991a7a..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 @@ -1,6 +1,7 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import { AsyncHandler, + errorStatus, i18n, notifyError, notifyInfo, @@ -10,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, @@ -28,7 +30,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']; @@ -208,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/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/media-list-header.component.ts b/apps/signage-manager/src/app/media/media-list-header.component.ts index f2f9b329e19..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'; @@ -46,7 +46,9 @@ import { SignageMediaService } from './signage-media.service';
{{ 'COMMON.ITEM_COUNT' - | translate: { count: total_count() } + | translate + : { count: total_count() } + : total_count() }}
@@ -448,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 48e50fd0507..78c0a371bc4 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 { @@ -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'; @@ -36,6 +37,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: ` @@ -118,7 +158,9 @@ const UNTAGGED = '\0untagged';
{{ 'COMMON.ITEM_COUNT' - | translate: { count: folder.count } + | translate + : { count: folder.count } + : folder.count }}
@@ -201,7 +243,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') { @@ -291,38 +332,10 @@ const UNTAGGED = '\0untagged'; }
@@ -458,34 +471,10 @@ const UNTAGGED = '\0untagged';
- @for ( - tag of visibleTags(media_item); - track tag - ) { - - {{ tag }} - - } - @if ( - remainingTagCount(media_item) > 0 - ) { - - +{{ - remainingTagCount( - media_item - ) - }} - - } +
}
@@ -778,6 +767,7 @@ const UNTAGGED = '\0untagged'; IntersectDirective, MediaThumbnailComponent, MediaSharedGroupsComponent, + MediaTagChipsComponent, ], }) export class MediaListComponent implements OnInit { @@ -957,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 { @@ -982,18 +963,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); @@ -1058,8 +1027,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/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/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) {
(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" */ @@ -136,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)}`; } /** @@ -196,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 @@ -244,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 @@ -603,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, }); @@ -893,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, @@ -990,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. @@ -1052,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(), @@ -1064,44 +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 Promise.allSettled( - media_ids.map((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; } @@ -1134,8 +1101,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( @@ -1172,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', { @@ -1259,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; } @@ -1267,7 +1236,6 @@ export class SignageMediaService { const { PlaylistSelectModalComponent } = await import('../shared/playlist-select-modal.component'); const ref = this._dialog.open(PlaylistSelectModalComponent, { - data: { media_id }, panelClass: 'mobile-fullscreen', }); const playlist_id = await dialogClosed(ref); @@ -1283,7 +1251,6 @@ export class SignageMediaService { const { PlaylistSelectModalComponent } = await import('../shared/playlist-select-modal.component'); const ref = this._dialog.open(PlaylistSelectModalComponent, { - data: { media_ids }, panelClass: 'mobile-fullscreen', }); const playlist_id = await dialogClosed(ref); @@ -1308,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(); @@ -1348,9 +1315,7 @@ export class SignageMediaService { return ''; } finally { if (screenshot_id) { - del( - `${apiEndpoint()}/uploads/${encodeURIComponent(screenshot_id)}`, - ).catch(() => undefined); + removeSignageUpload(screenshot_id).catch(() => undefined); } } } @@ -1382,8 +1347,4 @@ export class SignageMediaService { ), }; } - - constructor() { - effect(() => persistMediaViewMode(this.media_view_mode())); - } } 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..db512fd6e95 100644 --- a/apps/signage-manager/src/app/playlists/playlist-header.component.ts +++ b/apps/signage-manager/src/app/playlists/playlist-header.component.ts @@ -1,57 +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 { SignagePlaylistService } from './signage-playlist.service'; @Component({ selector: 'playlist-header', template: ` -
-
-

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

-
-
- {{ - 'COMMON.ITEM_COUNT' - | translate: { 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-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/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/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/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/schedules/signage-schedule.util.ts b/apps/signage-manager/src/app/schedules/signage-schedule.util.ts index 7f25cf9e3a9..244b45442b0 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,16 +13,11 @@ import { isSameDay, startOfDay, } from 'date-fns'; -import { - cronDaySlots, - cronParts, - doesCronMatchDay, -} from '../signage-cron.util'; +import { formatClockTime } from '../shared/schedule-summary.util'; import { createScheduleMaskFilter, - isPlayOnceSchedule, - type PlaylistSchedule, playOnceStart, + playPeriodMinutes, } from '../signage-playlist.util'; const BLOCK_PALETTE = [ @@ -33,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; @@ -91,7 +91,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 +103,7 @@ const ALL_DAY_SCHEDULE: Partial = { */ export function playlistSchedules( playlist: Pick, -): Partial[] { +): Partial[] { return playlist.schedules?.length ? playlist.schedules : [ALL_DAY_SCHEDULE]; } @@ -114,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. */ @@ -177,7 +165,7 @@ function playedRun( day: Date, starts_at: Date, duration: number, - schedule: Partial, + schedule: Partial, playlist: Pick, ) { const time = starts_at.getTime(); @@ -208,7 +196,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/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/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/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-edit-modal.component.ts b/apps/signage-manager/src/app/shared/media-edit-modal.component.ts index b1d156e6f1c..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,19 +14,15 @@ 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, notifySuccess, UploadPermissions, @@ -53,7 +48,6 @@ import { SignagePlugin, } from '@placeos/ts-client'; import { endOfDay, getUnixTime, startOfDay } from 'date-fns'; -import { mediaAnimation } from '../media/media-view.util'; import { getVideoContainer, isSupportedImageFile, @@ -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 8402e005c8a..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 @@ -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, @@ -16,19 +17,18 @@ import { TranslatePipe, } from '@placeos/components'; import { - MediaAnimation, showSignageMedia, SignageMedia, SignagePlaylist, SignagePlugin, } from '@placeos/ts-client'; -import { mediaAnimation } from '../media/media-view.util'; import { SignageMediaService } from '../media/signage-media.service'; import { playlistMediaThumbnailUrl, 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/media-select-modal.component.ts b/apps/signage-manager/src/app/shared/media-select-modal.component.ts index f43bdd820c9..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,15 +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, 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; @@ -18,106 +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.loading()) { -
+
- } @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, - TranslatePipe, - IntersectDirective, MediaThumbnailComponent, + PickerModalComponent, + PickerItemDirective, ], }) export class MediaSelectModalComponent { 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/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-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 7ba2aa5fa97..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, @@ -26,14 +21,10 @@ 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 { ANIMATION_OPTIONS } from './media-labels.util'; import { createPlaylistScheduleModel, PlaylistScheduleFormComponent, @@ -41,14 +32,18 @@ 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 { 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, @@ -57,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; @@ -306,16 +274,18 @@ export interface PlaylistEditFormModel { } @@ -354,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 */ @@ -378,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); @@ -391,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); @@ -485,13 +414,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-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 64cb2bf8dc9..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'; @@ -16,12 +17,20 @@ 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, + isPlayOnceSchedule, + isValidScheduleMask, LOCAL_TIMEZONE, LocaleService, + parsePlayAtLocal, setTimeInTimezone, settingSignal, } from '@placeos/common'; @@ -37,44 +46,36 @@ 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, + 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; @@ -104,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) { @@ -137,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'; @@ -179,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, ) { @@ -307,14 +186,8 @@ 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, + schedule: Partial, ): PlaylistScheduleType { return isPlayOnceSchedule(schedule) ? 'play_at' : 'play_cron'; } @@ -341,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, @@ -447,8 +294,60 @@ 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, + schedule?: Partial, ): PlaylistScheduleFormModel { const source = schedule || {}; const recurring_schedule = parseRecurringCron(source.play_cron); @@ -460,7 +359,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, @@ -470,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, @@ -526,7 +425,7 @@ export const playlistScheduleSchema = schema( export function playlistSchedulePayload( value: PlaylistScheduleFormModel, -): PlaylistSchedule { +): SignagePlaylistSchedule { return value.schedule_type === 'play_at' ? { play_at: @@ -1484,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()), @@ -1645,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 @@ -1724,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}`; @@ -1813,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/playlist-select-modal.component.ts b/apps/signage-manager/src/app/shared/playlist-select-modal.component.ts index 1f530f26f3c..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,117 +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, 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.loading()) { -
+
- } @else { -
- playlist_play -
- {{ 'SIGNAGE_MANAGER.NO_PLAYLISTS' | translate }} +
+
{{ playlist.name }}
+ @if (playlist.description) { +
+ {{ playlist.description }} +
+ }
-
- } -
+ +
+
`, imports: [ - FormsModule, MatRippleModule, MatDialogModule, - MatFormFieldModule, - MatInputModule, IconComponent, - 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/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/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/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-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-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 b627754841a..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 { 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 { @@ -24,8 +23,8 @@ 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'; +import { scheduleSummary } from './schedule-summary.util'; export type TemplateMappingTargetType = 'display' | 'zone'; @@ -336,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); @@ -372,15 +372,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, })), }; }); @@ -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 65341bc3ac5..10babe4e8b6 100644 --- a/apps/signage-manager/src/app/signage-context.service.ts +++ b/apps/signage-manager/src/app/signage-context.service.ts @@ -8,10 +8,13 @@ import { resource, signal, untracked, + type WritableSignal, } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { + errorStatus, i18n, + mapWithLimit, notifyError, notifySuccess, notifyWarn, @@ -32,9 +35,9 @@ 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 { storedSignal } from './shared/stored-signal'; import { effectiveFeatures, SIGNAGE_FEATURE_IDS, @@ -64,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; @@ -83,6 +117,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 = { @@ -102,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); @@ -206,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. */ @@ -241,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. @@ -296,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. @@ -533,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 */ @@ -544,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 = '') { @@ -694,18 +712,61 @@ 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]); } + /** + * 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-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-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/app/signage-playlist.util.ts b/apps/signage-manager/src/app/signage-playlist.util.ts index f3c7d8c57f8..a939a9674dd 100644 --- a/apps/signage-manager/src/app/signage-playlist.util.ts +++ b/apps/signage-manager/src/app/signage-playlist.util.ts @@ -1,111 +1,37 @@ +import { + cronDaySlots, + cronParts, + DEFAULT_PLAY_PERIOD_MINUTES, + doesCronMatchDay, + hasPlayableScheduleMask, + isPlayOnceSchedule, + mediaAnimation, + parsePlayAtLocal, +} from '@placeos/common'; import { MediaAnimation, SignageMedia, 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 { - 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'; +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. */ -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); -} - -/** 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', - 'Tuesday', - 'Wednesday', - 'Thursday', - 'Friday', - '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; + const local_time = parsePlayAtLocal(schedule.play_at_local); + return local_time === null ? null : new Date(local_time); } /** @@ -136,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) { @@ -218,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; } @@ -376,20 +177,8 @@ 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, + schedule: Partial, ) { return schedule.valid_until ? fromUnixTime(schedule.valid_until).toLocaleString() @@ -403,7 +192,7 @@ export function playlistScheduleExpiryTooltip( export function playlistExpiredAt( playlist: { valid_until?: number; - schedules?: readonly Partial[]; + schedules?: readonly Partial[]; }, now = Date.now(), ) { @@ -421,7 +210,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 +228,7 @@ export function playlistStatus( id: string; valid_from?: number; valid_until?: number; - schedules?: readonly Partial[]; + schedules?: readonly Partial[]; }, approvals: Record, requests: Record, @@ -449,51 +242,13 @@ 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}` : '' - }`; -} - -/** 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 +256,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 * * *'); @@ -572,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', @@ -631,11 +391,11 @@ interface PlaySession { /** Next plays of one schedule, inside its validity window and mask. */ function nextSchedulePlays( - schedule: Partial, + schedule: Partial, count: number, now: number, ): PlaySession[] { - const period = schedulePeriod(schedule); + const period = playPeriodMinutes(schedule); if (isPlayOnceSchedule(schedule)) { const start = playOnceStart(schedule); if (!start) return []; @@ -664,7 +424,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; @@ -673,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)); } @@ -682,7 +442,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 +455,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/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-header.component.ts b/apps/signage-manager/src/app/templates/template-header.component.ts index b8578b9a9f2..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,57 +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() } - }} -
- -
-
-
- @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..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,8 +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, @@ -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 +826,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( @@ -902,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-layout.util.ts b/apps/signage-manager/src/app/templates/template-layout.util.ts index 432f969a053..1072e30a0a2 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. */ @@ -96,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, @@ -119,24 +94,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 +131,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-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/template-preview.component.ts b/apps/signage-manager/src/app/templates/template-preview.component.ts index 57c7f2e0d7c..1a763ad2c86 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, @@ -23,10 +29,10 @@ 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, - computeTemplateLayoutRects, layoutPositionLabel, } from './template-layout.util'; @@ -36,11 +42,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 +293,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 +308,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 = @@ -335,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)}`, + ); }); /** @@ -356,7 +348,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/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..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,31 +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, { - data: { zone_id: zone.id }, - 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', @@ -333,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', @@ -342,11 +324,10 @@ 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, { - data: { playlist_id: playlist.id }, panelClass: 'mobile-fullscreen', }); const zone_id = await dialogClosed(ref); @@ -360,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() { @@ -534,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(); @@ -563,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', { @@ -571,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/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/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/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/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/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/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/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/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/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/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/playlist-select-modal.component.spec.ts b/apps/signage-manager/src/tests/shared/playlist-select-modal.component.spec.ts index f357fe3ba76..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: [] }, @@ -44,3 +40,51 @@ 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: 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'); + }); +}); 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/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/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-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-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-manager/src/tests/signage-playlist.util.spec.ts b/apps/signage-manager/src/tests/signage-playlist.util.spec.ts index 8690bb1d4e7..5d268d38287 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, @@ -11,9 +10,7 @@ import { playlistMediaItems, playlistNextPlayLabels, playlistNextPlayTime, - playlistScheduleExpiryLabel, playlistScheduleExpiryTooltip, - playlistScheduleLabel, playlistScheduleNextPlayLabels, playlistStatus, playOnceStart, @@ -78,80 +75,21 @@ 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('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(); - } - }); - - 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'); + playOnceStart({ play_at_local: '2027-01-01T00:00:00Z' }), + ).toBeNull(); }); - 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', () => { @@ -398,26 +336,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/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/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..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 @@ -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', () => { @@ -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(); @@ -308,7 +290,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-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(); 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/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-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/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/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/signage/src/app/signage.service.ts b/apps/signage/src/app/signage.service.ts index 079901593bb..e8a6c8d3aae 100644 --- a/apps/signage/src/app/signage.service.ts +++ b/apps/signage/src/app/signage.service.ts @@ -1,11 +1,17 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import { AsyncHandler, + DEFAULT_PLAY_PERIOD_MINUTES, + hasPlayableScheduleMask, + isPlayOnceSchedule, + mediaAnimation, MINUTES, + parsePlayAtLocal, randomInt, scoped_log, SECONDS, shuffleArray, + withTimeout, } from '@placeos/common'; import { apiEndpoint, @@ -17,18 +23,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 +73,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 +123,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 +226,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) { @@ -678,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. @@ -722,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 || []; @@ -787,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/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/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-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/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/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/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/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/alerts.component.ts b/apps/stagehand/src/app/alerts.component.ts index c3e7d7ff4de..e445911cda5 100644 --- a/apps/stagehand/src/app/alerts.component.ts +++ b/apps/stagehand/src/app/alerts.component.ts @@ -11,442 +11,339 @@ 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'; 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 }}
- - + build + +
+
-
+ `, styles: [``], imports: [ CommonModule, - MatMenuModule, MatRippleModule, SimpleTableComponent, IconComponent, MatFormFieldModule, MatSelectModule, MatInputModule, - SidebarComponent, + StagehandPageComponent, FormsModule, MatTooltipModule, MatProgressSpinnerModule, @@ -457,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>({}); @@ -499,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 @@ -513,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([], { @@ -529,12 +421,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, ); @@ -559,14 +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 service_link = settingSignal( - 'service_link', - `${location.origin}/control/`, - ); + public readonly backoffice_link = backofficeLink(); public ngOnInit() { // Load filters from query parameters @@ -583,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 261b1b95e61..cc67935bab3 100644 --- a/apps/stagehand/src/app/analytics.component.ts +++ b/apps/stagehand/src/app/analytics.component.ts @@ -1,13 +1,11 @@ -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'; -import { SidebarComponent } from './ui/sidebar.component'; +import { StagehandPageComponent } from './ui/stagehand-page.component'; interface AnalyticsPage { name: string; @@ -17,81 +15,57 @@ 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: [ - MatRippleModule, TranslatePipe, SafePipe, IconComponent, MatFormFieldModule, MatSelectModule, FormsModule, - SidebarComponent, - FormsModule, + StagehandPageComponent, ], }) 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.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/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-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-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..1e392bb0c4d 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,19 @@ -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 { + FullscreenModalShellComponent, + TranslatePipe, +} from '@placeos/components'; 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 +21,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 +52,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 +91,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 +103,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 +136,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 +185,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-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 780ca484410..85eb7a7d62a 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, @@ -24,13 +25,14 @@ import { addAlert, TriggerComparison, TriggerConditions, - TriggerTimeCondition, 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'; @@ -220,42 +222,6 @@ export type AlertType = 'threshold' | 'status' | 'custom'; | translate " > - - -
- {{ row.type === 'at' ? 'At time' : 'CRON' }} - {{ - row.type === 'at' ? row.time : row.cron - }} -
-
({ ...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( - 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, { @@ -436,6 +404,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 +420,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-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 80614e15609..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({ @@ -45,7 +47,7 @@ import { DashboardsService } from './dashboards.service'; aria-label="Close dashboard editor" [routerLink]=" dashboard() - ? ['/dashboards', dashboard().id] + ? ['/dashboards', dashboard().id, 'alerts'] : ['/dashboards'] " > @@ -134,14 +136,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 +162,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 +175,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/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..5721b75bd26 100644 --- a/apps/stagehand/src/app/dashboards/dashboards.component.ts +++ b/apps/stagehand/src/app/dashboards/dashboards.component.ts @@ -10,79 +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' || page() === 'view') { - - arrow_back - - } -

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

- @if (page() === 'alerts') { -
- {{ dashboard()?.name }} -
- } -
- @if (page() !== 'view') { - {{ - (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, @@ -118,9 +102,7 @@ export class DashboardsComponent extends AsyncHandler implements OnInit { this.page.set( this._router.url.includes('/alerts') ? 'alerts' - : this._router.url.includes('/view') - ? 'view' - : 'dashboards', + : 'dashboards', ); }, 50, diff --git a/apps/stagehand/src/app/dashboards/dashboards.service.ts b/apps/stagehand/src/app/dashboards/dashboards.service.ts index ee456a9763a..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,31 +182,86 @@ 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. + * 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 +321,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/app/dashboards/system-search-field.component.ts b/apps/stagehand/src/app/dashboards/system-search-field.component.ts index 18fd45ceea8..4c381f24fb4 100644 --- a/apps/stagehand/src/app/dashboards/system-search-field.component.ts +++ b/apps/stagehand/src/app/dashboards/system-search-field.component.ts @@ -4,11 +4,8 @@ import { effect, inject, input, - model, - OnChanges, OnInit, signal, - SimpleChanges, viewChild, } from '@angular/core'; import { ControlValueAccessor, FormsModule } from '@angular/forms'; @@ -20,25 +17,20 @@ 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', template: ` -
+
@@ -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..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); - public readonly time = signal(Date.now()); public readonly images = signal([]); public domain = ''; public api_path = ''; @@ -108,7 +95,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..dc8036bb73f 100644 --- a/apps/stagehand/src/app/remote-support.component.ts +++ b/apps/stagehand/src/app/remote-support.component.ts @@ -6,20 +6,19 @@ import { inject, OnInit, signal, + WritableSignal, } from '@angular/core'; 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 { 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'; import { AsyncHandler, CalendarEvent, - OrganisationService, randomInt, settingSignal, Space, @@ -32,436 +31,324 @@ 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'; 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 }} +
+
+
+
+
+ -
- sensors -
-

Total Rooms

-
- Total room count with devices -
-
-
- {{ zone_rooms()?.length || '0' }} -
-
-
search - 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: [ ` @@ -481,14 +368,13 @@ import { SidebarComponent } from './ui/sidebar.component'; imports: [ CommonModule, MatRippleModule, - MatMenuModule, IconComponent, BindingDirective, SimpleTableComponent, MatFormFieldModule, MatSelectModule, MatInputModule, - SidebarComponent, + StagehandPageComponent, FormsModule, MatTooltipModule, ViewportVisibilityComponent, @@ -498,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); @@ -510,11 +395,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>({}); @@ -524,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 @@ -536,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([], { @@ -560,11 +436,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(); @@ -585,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/`, @@ -685,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); @@ -727,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/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/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/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/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/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..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), ], }); }; @@ -97,16 +94,30 @@ 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); }); 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'); @@ -169,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/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.service.spec.ts b/apps/stagehand/src/tests/dashboards.service.spec.ts index 9d1ec3b7385..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' }; @@ -207,6 +260,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/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-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 73abfd4230b..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,18 +9,22 @@ 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 }); +import * as ts_client from '@placeos/ts-client'; + describe('DashboardAlertManageComponent', () => { let spectator: SpectatorRouting; let service: any; @@ -69,8 +73,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 +237,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-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 2afe466e383..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 }); @@ -49,7 +48,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 +91,7 @@ describe('DashboardManageComponent', () => { expect(spectator.inject(Router).navigate).toHaveBeenCalledWith([ '/dashboards', 'new-1', + 'alerts', ]); }); @@ -150,6 +150,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/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..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', () => { @@ -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..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', () => { @@ -167,7 +169,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/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.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/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/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/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/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/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/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/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/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/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/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/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; } 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/components/src/lib/chat/chat.component.ts b/libs/components/src/lib/chat/chat.component.ts index 790e983c515..522c97043db 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';