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/ngsw-config.json b/apps/booking-panel/ngsw-config.json deleted file mode 100644 index 3b65a2681c1..00000000000 --- a/apps/booking-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/booking-panel/project.json b/apps/booking-panel/project.json index bffe4d5268a..4e1b03700d5 100644 --- a/apps/booking-panel/project.json +++ b/apps/booking-panel/project.json @@ -60,7 +60,7 @@ "with": "apps/booking-panel/src/environments/settings.ts" } ], - "serviceWorker": "apps/booking-panel/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -98,7 +98,7 @@ "maximumError": "10kb" } ], - "serviceWorker": "apps/booking-panel/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "mobile": { "fileReplacements": [ @@ -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/app/bootstrap.component.ts b/apps/booking-panel/src/app/bootstrap.component.ts index 9757d1010f0..13147f74582 100644 --- a/apps/booking-panel/src/app/bootstrap.component.ts +++ b/apps/booking-panel/src/app/bootstrap.component.ts @@ -1,295 +1,45 @@ -import { Component, effect, inject, OnInit, signal } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { querySystems } from '@placeos/ts-client'; +import { Component, inject } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - getNativeSystemId, - OrganisationService, - syncNativeManagedConfig, - VERSION, -} from '@placeos/common'; +import { SystemBootstrapComponent } from '@placeos/components'; -import { CommonModule } from '@angular/common'; -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 { Space } from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; -import { AnnounceDirective } from './announce.directive'; +/** Local storage flag that opens the events view instead of the panel */ +const EVENT_KEY = 'PLACEOS.Bookings.event'; +/** + * Setup screen that binds the booking panel to a system. + * `?event` (or a saved event flag) opens the events view for the system. + */ @Component({ selector: '[app-bootstrap]', template: ` -
-
-
-

- {{ 'APP.BOOKING_PANEL.BOOTSTRAP_TITLE' | translate }} -

-
-
-
- {{ 'COMMON.BOOTSTRAP_SETUP' | translate }} -
-
-
- @if (!loading() || loading() === 'search') { -

- {{ 'COMMON.BOOTSTRAP_DESCRIPTION' | translate }} -

- - {{ - 'COMMON.BOOTSTRAP_LABEL' | translate - }} - - @if (loading() === 'search') { - - } - - - @for (option of space_list(); track option.id) { - -
-
-
- {{ - 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 }} -
-
- } -
-
-
- {{ 'COMMON.CONTROLS_VERSION' | translate }}: - {{ version.hash }} -
-
- {{ version.time | date: 'longDate' }} - ({{ version.time | date: 'shortTime' }}) -
-
-
-
+ `, - styles: [ - ` - mat-form-field { - width: calc(100% - 2rem); - } - - button { - width: 8rem; - margin: 0.5rem; - margin-top: 0; - } - `, - ], - imports: [ - CommonModule, - MatRippleModule, - TranslatePipe, - MatProgressSpinnerModule, - MatAutocompleteModule, - MatFormFieldModule, - MatInputModule, - FormsModule, - AnnounceDirective, - ], + imports: [SystemBootstrapComponent], }) -export class BootstrapComponent extends AsyncHandler implements OnInit { +export class BootstrapComponent { private _route = inject(ActivatedRoute); - private _router = inject(Router); - private _org = inject(OrganisationService); - - public readonly version = VERSION; - - /** Whether application data is loading */ - public loading = signal(''); - /** ID of the system to bootstrap */ - public system_id = signal(''); - - public readonly space_list = signal([]); - - private _event = false; - private _search_id = 0; - constructor() { - super(); - effect(() => { - const search = this.system_id(); - if (!this._org.initialised()) return; - const search_id = ++this._search_id; - this.timeout( - 'system-search', - async () => { - this.loading.set('search'); - const results = - search.length < 2 - ? { data: [] as unknown[] } - : await querySystems({ - q: search, - limit: 20, - fields: [ - 'id', - 'name', - 'display_name', - 'email', - ].join(','), - zone_id: this._org.organisation.id, - }); - if (search_id !== this._search_id) return; - this.space_list.set( - results.data.map((item: unknown) => new Space(item)), - ); - this.loading.set(''); - }, - 300, - ); - }); - } + /** Open the events view or the booking panel for the system */ + public readonly systemRoute = (system_id: string) => [ + this.isEventPanel() ? 'events' : 'panel', + system_id, + ]; - public async ngOnInit() { - const params = this._route.snapshot.queryParamMap; - if (params.has('clear') && !!params.get('clear')) { - this.clearBootstrap(); - } - if (params.has('event')) this._event = true; - 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(); + /** Keep the events view for later loads when it is in use */ + public saveEventFlag() { + if (this.isEventPanel()) localStorage.setItem(EVENT_KEY, 'true'); } - /** - * 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 async checkBootstrapped(): Promise { - this.loading.set('Checking'); - // Wait for any MDM managed configuration to be stored locally, as it - // takes precedence over previously stored bootstrap settings. - await syncNativeManagedConfig(); - if (localStorage) { - const system_id = localStorage.getItem('PLACEOS.BOOKINGS.system'); - this._event = - this._event || - localStorage.getItem('PLACEOS.Bookings.event') === 'true'; - // A system pushed via MDM managed config overrides the stored one - const mdm_system_id = getNativeSystemId(); - if (mdm_system_id && mdm_system_id !== system_id) { - this.system_id.set(mdm_system_id); - return this.configure(mdm_system_id); - } - if (system_id) { - this._router.navigate( - [this._event ? 'events' : 'panel', system_id], - { - queryParamsHandling: 'preserve', - }, - ); - return; - } - } - this.loading.set(''); + private isEventPanel() { + return ( + this._route.snapshot.queryParamMap.has('event') || + localStorage.getItem(EVENT_KEY) === 'true' + ); } - - /** - * 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('PLACEOS.BOOKINGS.system', system_id); - localStorage.setItem('trust', 'true'); - localStorage.setItem('fixed_device', 'true'); - if (this._event) { - localStorage.setItem('PLACEOS.Bookings.event', 'true'); - } - } - this._router.navigate([this._event ? 'events' : 'panel', system_id], { - queryParamsHandling: 'preserve', - }); - this.loading.set(''); - } - - /** - * Remove any previously set bootstrapping details - */ - private readonly clearBootstrap = () => - localStorage.removeItem('PLACEOS.BOOKINGS.system'); } diff --git a/apps/booking-panel/src/app/checkin/checkin-view.component.ts b/apps/booking-panel/src/app/checkin/checkin-view.component.ts index a5b97606cfb..c4756b63b78 100644 --- a/apps/booking-panel/src/app/checkin/checkin-view.component.ts +++ b/apps/booking-panel/src/app/checkin/checkin-view.component.ts @@ -10,7 +10,7 @@ import { ActivatedRoute } from '@angular/router'; import { AsyncHandler, notifyError } from '@placeos/common'; import { MatRippleModule } from '@angular/material/core'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { TranslatePipe } from '@placeos/components'; import { getNextFreeTimeSlot } from '@placeos/events'; import { currentPeriod, nextPeriod } from '../new-panel/helpers'; import { PanelStateService } from '../panel-state.service'; @@ -176,61 +176,6 @@ const SLOT_MINUTES = 15; (event)="bookSlot($event)" > - @if (false) { -

- {{ 'COMMON.FEATURES' | translate }} -

- } - @if (false) { -
- - - - -
- } `, styles: [ ` @@ -246,12 +191,7 @@ const SLOT_MINUTES = 15; `, ], providers: [PanelStateService], - imports: [ - IconComponent, - TranslatePipe, - MatRippleModule, - CheckinTimetableComponent, - ], + imports: [TranslatePipe, MatRippleModule, CheckinTimetableComponent], }) export class CheckinViewComponent extends AsyncHandler implements OnInit { private _state = inject(PanelStateService); diff --git a/apps/booking-panel/src/app/event-panel.component.ts b/apps/booking-panel/src/app/event-panel.component.ts index 8c8c31f3c1c..2520f7e8288 100644 --- a/apps/booking-panel/src/app/event-panel.component.ts +++ b/apps/booking-panel/src/app/event-panel.component.ts @@ -6,6 +6,7 @@ import { AsyncHandler, OrganisationService, SettingsService, + themedLogo, } from '@placeos/common'; import { generateQRCode } from '@placeos/common/qr-code'; @@ -30,12 +31,7 @@ import { PanelStateService } from './panel-state.service';

{{ time() | date: time_format() }}

- Logo + Logo
- Logo + Logo

{{ time() | date: time_format() }}

@@ -228,14 +219,7 @@ export class EventPanelComponent extends AsyncHandler implements OnInit { '', ); - public readonly logo = computed( - () => ( - this._org.active_building(), - (this._settings.theme_signal() === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ), - ); + public readonly logo = themedLogo(); public get text_color() { return this._settings.get('app.text_color') || '#FFFFFF'; @@ -261,10 +245,6 @@ export class EventPanelComponent extends AsyncHandler implements OnInit { return this._state.setting('show_qr_code') !== false; } - public get custom_qr() { - return !!this._state.setting('custom_qr_url'); - } - public async ngOnInit() { await this._org.waitUntilInitialised(); const params = this._route.snapshot.paramMap; diff --git a/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts b/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts index b0dcbcd9bfb..75a9f2399e7 100644 --- a/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts +++ b/apps/booking-panel/src/app/new-panel/panel-view-details.component.ts @@ -6,11 +6,11 @@ import { generateQRCode } from '@placeos/common/qr-code'; import { CommonModule } from '@angular/common'; import { + AnnounceDirective, AuthenticatedImageDirective, IconComponent, TranslatePipe, } from '@placeos/components'; -import { AnnounceDirective } from '../announce.directive'; import { PanelStateService } from '../panel-state.service'; import { endingSoon } from './helpers'; @@ -180,10 +180,6 @@ export class PanelViewDetailsComponent extends AsyncHandler implements OnInit { return this._state.setting('show_qr_code') !== false; } - public get custom_qr() { - return !!this._state.setting('custom_qr_url'); - } - public get hide_qr_text() { return this._state.setting('hide_qr_text'); } diff --git a/apps/booking-panel/src/app/new-panel/panel-view.component.ts b/apps/booking-panel/src/app/new-panel/panel-view.component.ts index 825f9449f4f..a839efd1b04 100644 --- a/apps/booking-panel/src/app/new-panel/panel-view.component.ts +++ b/apps/booking-panel/src/app/new-panel/panel-view.component.ts @@ -9,8 +9,12 @@ import { } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { AsyncHandler, RemoteLoggingService, VERSION } from '@placeos/common'; -import { IconComponent, SafePipe, TranslatePipe } from '@placeos/components'; -import { AnnounceDirective } from '../announce.directive'; +import { + AnnounceDirective, + IconComponent, + SafePipe, + TranslatePipe, +} from '@placeos/components'; import { PanelStateService, PanelTimelinePosition, diff --git a/apps/booking-panel/src/app/overlays/booking-modal.component.ts b/apps/booking-panel/src/app/overlays/booking-modal.component.ts index 5d9aae16556..b822a77e4b6 100644 --- a/apps/booking-panel/src/app/overlays/booking-modal.component.ts +++ b/apps/booking-panel/src/app/overlays/booking-modal.component.ts @@ -21,14 +21,17 @@ import { Space, User, } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { + AnnounceDirective, + IconComponent, + TranslatePipe, +} from '@placeos/components'; import { DurationFieldComponent, TimeFieldComponent, UserSearchFieldComponent, } from '@placeos/form-fields'; import { getModule } from '@placeos/ts-client'; -import { AnnounceDirective } from '../announce.directive'; export interface BookingModalData extends HashMap { title?: string; diff --git a/apps/booking-panel/src/app/panel-state.service.ts b/apps/booking-panel/src/app/panel-state.service.ts index 3c4c006e3f0..674a20ecad4 100644 --- a/apps/booking-panel/src/app/panel-state.service.ts +++ b/apps/booking-panel/src/app/panel-state.service.ts @@ -29,7 +29,6 @@ import { notifyWarn, SettingsService, Space, - timePeriodsIntersect, } from '@placeos/common'; import { EventFormService, updateEvent } from '@placeos/events'; @@ -132,26 +131,6 @@ export interface PanelSettings { timeline_position?: PanelTimelinePosition; } -export function currentBooking( - list: CalendarEvent[], - date: number = new Date().valueOf(), -) { - return list.find((bkn) => - timePeriodsIntersect( - date, - date, - bkn.date, - bkn.date + bkn.duration * 1000, - ), - ); -} - -export type CalendarEventStatus = - | 'available' - | 'pending' - | 'busy' - | 'not-bookable'; - declare global { interface Window { panel_module?: PlaceModuleBinding; @@ -177,8 +156,6 @@ export class PanelStateService extends AsyncHandler { private _load_id = 0; /** Current time. Updates every 5 seconds */ public readonly clock = this._clock.asReadonly(); - /** Whether the websocket to the PlaceOS backend is connected */ - public readonly connected = signal(true); /** Time the websocket disconnected. `0` while connected */ public readonly offline_since = signal(0); private _bound_system = ''; @@ -356,7 +333,6 @@ export class PanelStateService extends AsyncHandler { this.subscription( 'websocket-status', websocketStatus().subscribe((online) => { - this.connected.set(online); if (online) this.offline_since.set(0); else if (!this.offline_since()) this.offline_since.set(Date.now()); @@ -425,9 +401,6 @@ export class PanelStateService extends AsyncHandler { future = false, force_api = false, ) { - // if (date <= Date.now() && !user) { - // return this.confirmBookNow(); - // } const current = this._current(); if ( current && @@ -495,61 +468,6 @@ export class PanelStateService extends AsyncHandler { } } - public async confirmBookNow() { - this.timeout( - 'reset_view', - () => this._dialog.closeAll(), - 2 * 60 * 1000, - ); - const date = Date.now(); - const current = this._current(); - if ( - current && - isAfter(date, current.date) && - isBefore(date, addMinutes(current.date, current.duration)) - ) { - return notifyError('Booking already exists for this time'); - } - let max_duration = undefined; - const next = this._next(); - if (next && date <= Date.now()) { - const diff = Math.abs(differenceInMinutes(next.date, date)); - const max = this._settings().max_duration || 480; - max_duration = diff < max ? diff : max; - } - if (max_duration != null && max_duration < 15) { - return notifyError( - 'Unable to make bookings as the time available before the next meeting is less than 15 minutes', - ); - } - const ref = await openConfirmModal( - { - title: 'Book Meeting', - content: `Do you wish to book a meeting in this room for ${Math.min( - max_duration || 180, - 30, - )} minutes?`, - icon: { content: 'event' }, - }, - this._dialog, - ); - if (ref.reason !== 'done') return; - ref.loading('Creating Meeting...'); - try { - const module = getModule(this.system, 'Bookings'); - if (!module) throw 'Unable to find module'; - await module.execute('book_now', [ - Math.min(max_duration || 180, 30) * 60, - 'Ad-hoc Panel Booking', - ]); - notifySuccess('Successfully created meeting.'); - } catch (e) { - notifyError(`Error creating meeting. ${e}`); - } - ref.close(); - this.clearTimeout('reset_view'); - } - /** * Book the room from now for `minutes` without the booking form. * The signed-in user is the host. @@ -636,33 +554,6 @@ export class PanelStateService extends AsyncHandler { notifySuccess('Successfully created meeting.'); } - /** - * Open confirmation modal for starting the meeting - */ - public async confirmStart() { - this.timeout( - 'reset_view', - () => this._dialog.closeAll(), - 2 * 60 * 1000, - ); - const details = await openConfirmModal( - { - title: 'Do you wish to start your meeting?', - content: `If you don't start your meeting it will be cancelled ${ - this._settings().pending_period - } minutes after the start time.`, - icon: { - class: 'material-symbols-rounded', - content: 'play_arrow', - }, - }, - this._dialog, - ); - if (details.reason !== 'done') return; - this.startMeeting(); - this.clearTimeout('reset_view'); - } - /** * Execute the logic on the engine driver to start the current or upcoming meeting */ 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/src/tests/bootstrap.component.spec.ts b/apps/booking-panel/src/tests/bootstrap.component.spec.ts index a85f944c7f8..608ccd5af86 100644 --- a/apps/booking-panel/src/tests/bootstrap.component.spec.ts +++ b/apps/booking-panel/src/tests/bootstrap.component.spec.ts @@ -4,29 +4,21 @@ 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 { + createRoutingFactory, + SpectatorRouting, +} from '@ngneat/spectator/vitest'; import { MockModule, MockProvider } from 'ng-mocks'; -import { signal } from '@angular/core'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { SpacesService } from '@placeos/events'; +import { SettingsService } from '@placeos/common'; +import { SystemBootstrapComponent } from '@placeos/components'; import { BootstrapComponent } from '../app/bootstrap.component'; describe('BootstrapComponent', () => { let spectator: SpectatorRouting; const createComponent = createRoutingFactory({ component: BootstrapComponent, - providers: [ - MockProvider(SpacesService, { - initialised: signal(true), - space_list: [{ id: '1', name: 'Space 1' }], - } as any), - MockProvider(SettingsService, { get: vi.fn() }), - MockProvider(OrganisationService, { - organisation: { id: 'org-123', name: 'Test Org' }, - initialised: signal(true), - } as any), - ], + providers: [MockProvider(SettingsService, { get: vi.fn() })], imports: [ FormsModule, MockModule(MatAutocompleteModule), @@ -36,65 +28,34 @@ describe('BootstrapComponent', () => { ], }); - beforeEach(() => { - spectator = createComponent(); - }); + const submit = (system_id: string) => { + const bootstrap = spectator.query(SystemBootstrapComponent); + bootstrap.system_id.set(system_id); + bootstrap.bootstrap(); + }; afterEach(() => { localStorage.clear(); }); - it('should create the bootstrap', () => { - expect(spectator.component).toBeTruthy(); - }); - - it('should stop loading when spaces have loaded', () => { - spectator.detectChanges(); - expect('[load]').not.toExist(); - expect('mat-spinner').not.toExist(); - expect('p.description').toExist(); - }); - - it('should not allow for submit when no system is set', () => { - spectator.detectChanges(); - expect('[load]').not.toExist(); - expect('mat-spinner').not.toExist(); - expect('p.description').toExist(); - const button: HTMLButtonElement = spectator.query('button'); - expect(button).toBeTruthy(); - // expect(button.disabled).toBeTruthy(); - spectator.component.system_id.set('sys-B0'); - spectator.detectChanges(); - expect(button.disabled).toBeFalsy(); - }); - - it('should route to the panel on submit', () => { - spectator.component.system_id.set('sys-B0'); - spectator.detectChanges(); - expect(spectator.query('button[disabled]')).toBeFalsy(); - spectator.click('button'); - spectator.detectChanges(); - const router = spectator.inject(Router); - expect(router.navigate).toHaveBeenCalledWith(['panel', 'sys-B0'], { - queryParamsHandling: 'preserve', - }); - }); - - it('should auto bootstrap if there is a system query parameter', () => { - spectator.setRouteQueryParam('system_id', 'sys-B0'); - spectator.component.ngOnInit(); - spectator.detectChanges(); - const router = spectator.inject(Router); - expect(router.navigate).toHaveBeenCalledWith(['panel', 'sys-B0'], { - queryParamsHandling: 'preserve', - }); - }); - - it('should clear bootstrap if there is a clear query parameter', () => { - localStorage.setItem('PLACEOS.BOOKINGS.system', 'sys-B0'); - spectator.setRouteQueryParam('clear', 'true'); - spectator.component.ngOnInit(); - spectator.detectChanges(); - expect(localStorage.getItem('PLACEOS.BOOKINGS.system')).toBeFalsy(); + it('should save the system and route to the panel on submit', () => { + spectator = createComponent(); + submit('sys-B0'); + expect(localStorage.getItem('PLACEOS.BOOKINGS.system')).toBe('sys-B0'); + expect(localStorage.getItem('PLACEOS.Bookings.event')).toBeNull(); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith( + ['panel', 'sys-B0'], + { queryParamsHandling: 'preserve' }, + ); + }); + + it('should route to the events view and save the flag with ?event', () => { + spectator = createComponent({ queryParams: { event: 'true' } }); + submit('sys-B0'); + expect(localStorage.getItem('PLACEOS.Bookings.event')).toBe('true'); + expect(spectator.inject(Router).navigate).toHaveBeenCalledWith( + ['events', 'sys-B0'], + { queryParamsHandling: 'preserve' }, + ); }); }); diff --git a/apps/booking-panel/src/tests/checkin/checkin-view.component.spec.ts b/apps/booking-panel/src/tests/checkin/checkin-view.component.spec.ts index 3d5f6354e77..742a41813cf 100644 --- a/apps/booking-panel/src/tests/checkin/checkin-view.component.spec.ts +++ b/apps/booking-panel/src/tests/checkin/checkin-view.component.spec.ts @@ -4,7 +4,7 @@ import { SpectatorRouting, } from '@ngneat/spectator/vitest'; import { mockComponent } from '@placeos/common/tests'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { TranslatePipe } from '@placeos/components'; import { ngMocks } from 'ng-mocks'; import { CheckinTimetableComponent } from '../../app/checkin/checkin-timetable.component'; @@ -40,10 +40,7 @@ describe('CheckinViewComponent', () => { component: CheckinViewComponent, detectChanges: false, params: { system_id: 'sys-42' }, - declarations: [ - mockComponent(CheckinTimetableComponent), - mockComponent(IconComponent), - ], + declarations: [mockComponent(CheckinTimetableComponent)], componentProviders: [ { provide: PanelStateService, useValue: state_service }, ], diff --git a/apps/booking-panel/src/tests/event-panel.component.spec.ts b/apps/booking-panel/src/tests/event-panel.component.spec.ts index eedaada3403..8e3c2267b2f 100644 --- a/apps/booking-panel/src/tests/event-panel.component.spec.ts +++ b/apps/booking-panel/src/tests/event-panel.component.spec.ts @@ -16,7 +16,6 @@ describe('EventPanelComponent', () => { const space = signal(null); const settings = signal({}); const state_setting = vi.fn(); - const theme = signal('light'); const settings_get = vi.fn((_key: string): unknown => undefined); const createComponent = createRoutingFactory({ @@ -38,7 +37,6 @@ describe('EventPanelComponent', () => { MockProvider(SettingsService, { get: settings_get as SettingsService['get'], overrideCssVariable: vi.fn(), - theme_signal: theme, }), MockProvider(OrganisationService, { active_building: signal(null), @@ -54,7 +52,6 @@ describe('EventPanelComponent', () => { settings.set({}); state_setting.mockReset(); state_setting.mockReturnValue(undefined); - theme.set('light'); settings_get.mockImplementation(() => undefined); spectator = createComponent(); }); @@ -63,14 +60,6 @@ describe('EventPanelComponent', () => { expect(spectator.component).toBeTruthy(); }); - it('should use the dark logo in the dark theme', () => { - settings_get.mockImplementation((key: string) => - key === 'app.logo_dark' ? 'dark.svg' : 'light.svg', - ); - theme.set('dark'); - expect(spectator.component.logo()).toBe('dark.svg'); - }); - it('should display the space name in the header', () => { space.set({ display_name: 'Meeting Room 1' }); spectator.detectChanges(); 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/e2e/parking.spec.ts b/apps/concierge/e2e/parking.spec.ts index 8c4239d05f5..ad73ace9f38 100644 --- a/apps/concierge/e2e/parking.spec.ts +++ b/apps/concierge/e2e/parking.spec.ts @@ -18,8 +18,6 @@ test.describe('Parking Bookings Page', () => { await gotoMockRoute(page, '/#/book/parking/events/requests'); await waitForHost(page, 'app-parking'); await waitForHost(page, 'parking-bookings-list'); - - await expect(page.locator('parking-requests-list')).toHaveCount(0); }); // US-030 / US-035: Map view uses a single selected level. diff --git a/apps/concierge/project.json b/apps/concierge/project.json index 0d54a4e9e94..f706d0df967 100644 --- a/apps/concierge/project.json +++ b/apps/concierge/project.json @@ -65,7 +65,7 @@ "with": "apps/concierge/src/environments/settings.ts" } ], - "serviceWorker": "apps/concierge/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -103,7 +103,7 @@ "maximumError": "16kb" } ], - "serviceWorker": "apps/concierge/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" } } }, diff --git a/apps/concierge/src/app/asset-manager/asset-category-management-modal.component.ts b/apps/concierge/src/app/asset-manager/asset-category-management-modal.component.ts index 243cca0d091..b7b253b813c 100644 --- a/apps/concierge/src/app/asset-manager/asset-category-management-modal.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-category-management-modal.component.ts @@ -1,10 +1,6 @@ import { Component, EventEmitter, Signal, inject } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { - MAT_DIALOG_DATA, - MatDialogModule, - MatDialogRef, -} from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; import { AssetCategory } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; @@ -97,10 +93,6 @@ export class AssetCategoryManagementModalComponent { list: Signal; edit: (i?) => any; }>(MAT_DIALOG_DATA); - private _dialog_ref = - inject>( - MatDialogRef, - ); public readonly changed = new EventEmitter(); public readonly list = this._data.list; diff --git a/apps/concierge/src/app/asset-manager/asset-group-form.component.ts b/apps/concierge/src/app/asset-manager/asset-group-form.component.ts index a714b816685..f9c0cbbb16e 100644 --- a/apps/concierge/src/app/asset-manager/asset-group-form.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-group-form.component.ts @@ -1,6 +1,5 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { FormField } from '@angular/forms/signals'; -import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; @@ -161,7 +160,6 @@ export class AssetGroupFormComponent extends AsyncHandler implements OnInit { private readonly _uses_lists = this._state.use('categories'); private _route = inject(ActivatedRoute); private _router = inject(Router); - private _dialog = inject(MatDialog); private readonly _form_ref = generateAssetGroupForm(); public readonly form = this._form_ref.form; diff --git a/apps/concierge/src/app/asset-manager/asset-manager-state.service.ts b/apps/concierge/src/app/asset-manager/asset-manager-state.service.ts index 1392206606e..f2cf0343530 100644 --- a/apps/concierge/src/app/asset-manager/asset-manager-state.service.ts +++ b/apps/concierge/src/app/asset-manager/asset-manager-state.service.ts @@ -71,7 +71,6 @@ export type AssetManagerList = export interface AssetOptions { date?: number; search?: string; - sort_by?: string; view: 'grid' | 'list'; active_item?: string; } @@ -368,10 +367,6 @@ export class AssetManagerStateService extends AsyncHandler { return this._form_ref.model; } - public get is_new_ui() { - return true; - } - public get base_route() { return '/book/assets'; } diff --git a/apps/concierge/src/app/asset-manager/asset-manager-topbar.component.ts b/apps/concierge/src/app/asset-manager/asset-manager-topbar.component.ts index d983565ffb8..ed5c0851d2b 100644 --- a/apps/concierge/src/app/asset-manager/asset-manager-topbar.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-manager-topbar.component.ts @@ -13,7 +13,6 @@ import { notifySuccess, OrganisationService, settingSignal, - SettingsService, } from '@placeos/common'; import { AvailableRoomsStateModalComponent, @@ -195,7 +194,6 @@ import { AssetManagerStateService } from './asset-manager-state.service'; export class AssetManagerTopbarComponent extends AsyncHandler { private _state = inject(AssetManagerStateService); private _org = inject(OrganisationService); - private _settings = inject(SettingsService); private _dialog = inject(MatDialog); public readonly active = input(''); diff --git a/apps/concierge/src/app/asset-manager/asset-manager.component.ts b/apps/concierge/src/app/asset-manager/asset-manager.component.ts index 217dc0c01f3..5dd90453dbe 100644 --- a/apps/concierge/src/app/asset-manager/asset-manager.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-manager.component.ts @@ -1,6 +1,5 @@ -import { Component, computed, inject, signal } from '@angular/core'; -import { Router, RouterModule } from '@angular/router'; -import { AsyncHandler } from '@placeos/common'; +import { Component } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; @Component({ @@ -26,22 +25,4 @@ import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; ], imports: [ApplicationSidebarComponent, RouterModule], }) -export class AssetManagerComponent extends AsyncHandler { - private _router = inject(Router); - - private readonly _url = signal(null); - - public readonly path = computed(() => { - this._url(); - const parts = this._router.url.split('/'); - return parts[parts.length - 1].split('?')[0]; - }); - - constructor() { - super(); - this.subscription( - 'router', - this._router.events.subscribe((event) => this._url.set(event)), - ); - } -} +export class AssetManagerComponent {} diff --git a/apps/concierge/src/app/asset-manager/asset-request-details.component.ts b/apps/concierge/src/app/asset-manager/asset-request-details.component.ts index faf84677d73..f56905a332b 100644 --- a/apps/concierge/src/app/asset-manager/asset-request-details.component.ts +++ b/apps/concierge/src/app/asset-manager/asset-request-details.component.ts @@ -11,7 +11,7 @@ import { } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatMenuModule } from '@angular/material/menu'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { SettingsService } from '@placeos/common'; import { DurationPipe, IconComponent, @@ -298,7 +298,6 @@ import { SplitJoinPipe } from './split-join.pipe'; }) export class AssetRequestDetailsComponent { private _state = inject(AssetManagerStateService); - private _org = inject(OrganisationService); private _settings = inject(SettingsService); public readonly requestInput = input(undefined, { alias: 'request' }); diff --git a/apps/concierge/src/app/asset-manager/duration.pipe.ts b/apps/concierge/src/app/asset-manager/duration.pipe.ts deleted file mode 100644 index 7ecefb475f8..00000000000 --- a/apps/concierge/src/app/asset-manager/duration.pipe.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import { formatDuration } from '@placeos/common'; - -@Pipe({ - name: 'duration', -}) -export class DurationFormatPipe implements PipeTransform { - public transform(value: number, short = true): string { - const duration = formatDuration({ - days: Math.floor(value / (24 * 60)), - hours: Math.floor((value / 60) % 24), - minutes: Math.floor(value % 60), - }); - if (!short) return duration; - return duration - .replace(' day', 'd') - .replace(' hour', 'hr') - .replace(' minute', 'min'); - } -} diff --git a/apps/concierge/src/app/building-manager/auto-release-settings-modal.component.ts b/apps/concierge/src/app/building-manager/auto-release-settings-modal.component.ts index a0720ea1c47..7931b5e0a1e 100644 --- a/apps/concierge/src/app/building-manager/auto-release-settings-modal.component.ts +++ b/apps/concierge/src/app/building-manager/auto-release-settings-modal.component.ts @@ -50,6 +50,7 @@ import { } from '@placeos/form-fields'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import { appMetadataKey } from '../ui/app-settings/app-settings.utilities'; import { errorText } from '../ui/modal-actions'; @Component({ @@ -460,9 +461,7 @@ export class AutoReleaseSettingsModalComponent implements OnInit { ? await updateSettings(unencrypted.id, unencrypted) : await addSettings(unencrypted); - const metadata_key = - this._settings.get('app.workplace_metadata_key') || - 'workplace_app'; + const metadata_key = appMetadataKey(this._settings, 'workplace'); const metadata = await showMetadata(this.id, metadata_key); const details: any = metadata.details || {}; details.auto_release = new_settings; diff --git a/apps/concierge/src/app/building-manager/building-form.component.ts b/apps/concierge/src/app/building-manager/building-form.component.ts index 2665151e1b1..3e8892cfdeb 100644 --- a/apps/concierge/src/app/building-manager/building-form.component.ts +++ b/apps/concierge/src/app/building-manager/building-form.component.ts @@ -1,6 +1,5 @@ import { Component, - computed, effect, inject, input, @@ -10,7 +9,6 @@ import { untracked, } from '@angular/core'; import { form, FormField, required } 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'; @@ -23,9 +21,9 @@ import { notifyError, notifySuccess, } from '@placeos/common'; -import { TIMEZONES_IANA } from '@placeos/common/timezones'; -import { IconComponent, TranslatePipe } from '@placeos/components'; +import { TranslatePipe } from '@placeos/components'; import { addZone, authority, updateZone } from '@placeos/ts-client'; +import { TimezoneFieldComponent } from '../ui/timezone-field.component'; @Component({ selector: 'building-form', @@ -70,31 +68,10 @@ import { addZone, authority, updateZone } from '@placeos/ts-client'; />
-
- - - search - - - - @for (tz of filtered_timezones(); track tz) { - {{ tz }} - } - @if (!timezones.length) { - - {{ 'COMMON.TIMEZONE_EMPTY' | translate }} - - } - -
+
- `, - styles: [ - ` - button { - min-width: 0; - padding: 0 0.85rem; - } - - mat-form-field { - height: 3.25em; - width: 8em; - margin-left: 1em; - } - - mat-slide-toggle div { - width: 5.5em; - white-space: initial; - line-height: 1.2em; - } - `, - ], - imports: [ - IconComponent, - SettingsToggleComponent, - DateOptionsComponent, - TranslatePipe, - MatFormFieldModule, - MatSelectModule, - MatSlideToggleModule, - MatRippleModule, - FormsModule, - ], -}) -export class DayviewTopbarComponent extends AsyncHandler implements OnInit { - private _state = inject(EventsStateService); - private _org = inject(OrganisationService); - private _route = inject(ActivatedRoute); - private _router = inject(Router); - private _settings = inject(SettingsService); - - /** List of selected levels */ - public readonly zones = signal([]); - - public readonly types: Identity[] = [ - { id: 'internal', name: 'Internal' }, - { id: 'external', name: 'External' }, - { id: 'cancelled', name: 'Cancelled' }, - ]; - /** List of selected types */ - public readonly type_list = signal(this.types.map((i) => `${i.id}`)); - /** List of levels for the active building */ - public readonly levels = this._state.levels; - /** UI display options for bookings */ - public readonly ui_options = this._state.options; - /** Set filtered date */ - public readonly setDate = (d) => this._state.setDate(d); - /** */ - public readonly newBooking = (d?) => this._state.newBooking(d); - /** List of levels for the active building */ - public readonly updateZones = (zones: string[]) => { - const zone_ids = this._clean_zone_ids(zones); - this.zones.set(zone_ids); - this._state.setZones?.(zone_ids); - this._router.navigate([], { - relativeTo: this._route, - queryParams: { - zone_ids: zone_ids.length ? zone_ids.join(',') : null, - }, - queryParamsHandling: 'merge', - }); - }; - /** List of levels for the active building */ - public readonly updateTypes = (types: string[]) => { - this.type_list.set(types); - this._state.setFilters({ - hide_type: this.types.reduce((list, item) => { - !types.includes(`${item.id}`) ? list.push(item) : ''; - return list; - }, []), - }); - }; - - public updateUIOptions(options: BookingUIOptions) { - this._state.setUIOptions(options); - } - - public get allow_setup_breakdown() { - return this._settings.get('app.events.allow_setup_breakdown'); - } - - public async ngOnInit() { - await this._org.waitUntilInitialised(); - this.subscription( - 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('zone_ids')) { - const zones = this._clean_zone_ids( - params.get('zone_ids').split(','), - ); - if (zones.length) { - this.zones.set(zones); - this._state.setZones?.(zones); - const level = this._org.levelWithID(zones); - if (!level) return; - this._org.building = this._org.buildings.find( - (bld) => bld.id === level.parent_id, - ); - } - } - }), - ); - this.updateTypes(this.type_list()); - } - - private _clean_zone_ids(zones: string[] = []) { - return (zones || []).filter((zone_id) => !!zone_id); - } -} diff --git a/apps/concierge/src/app/day-view/events-state.service.ts b/apps/concierge/src/app/day-view/events-state.service.ts index 69e9da8d09e..79408df3e35 100644 --- a/apps/concierge/src/app/day-view/events-state.service.ts +++ b/apps/concierge/src/app/day-view/events-state.service.ts @@ -28,6 +28,8 @@ import { } from '@placeos/common'; import { CalendarEventList, + EventDetailsModalComponent, + SetupBreakdownModalComponent, declineEvent, queryEventListOrThrow, requestSpacesForZone, @@ -67,10 +69,6 @@ export interface BookingFilters { export interface BookingUIOptions { /** Whether to show setup and breakdown times for events */ show_overflow?: boolean; - /** Whether to show cleaning status of the events */ - show_cleaning?: boolean; - /** Whether weekends should be shown on week view */ - show_weekends?: boolean; } type DayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6; @@ -119,8 +117,6 @@ export class EventsStateService extends AsyncHandler { private _poll = signal(0); /** Period to list bookings for */ private _period = signal<'month' | 'week' | 'day'>('day'); - /** Event to display */ - private _event = signal(null); /** Events optimistically removed from the bookings list */ private _removed_events = signal([]); /** Events optimistically added to the bookings list */ @@ -146,8 +142,6 @@ export class EventsStateService extends AsyncHandler { public readonly zones = this._zones.asReadonly(); /** Signal for UI display options */ public readonly options = this._options.asReadonly(); - /** Signal for the viewed event */ - public readonly event = this._event.asReadonly(); /** Period of time to show events for */ public readonly period = this._period.asReadonly(); /** Pending room booking approval requests */ @@ -453,7 +447,6 @@ export class EventsStateService extends AsyncHandler { this._period.set(period); public readonly setZones = (zones: string[]) => this._zones.set(this._clean_zone_ids(zones)); - public readonly setEvent = (event: CalendarEvent) => this._event.set(event); public setUIOptions(options: BookingUIOptions) { this._options.update((old_options) => ({ ...old_options, ...options })); @@ -504,6 +497,38 @@ export class EventsStateService extends AsyncHandler { this.replace(details.metadata); } + /** + * Open the details modal for a booking. `remove` runs when the user + * removes the booking from the modal. Ignores system events. + */ + public viewEvent( + event: CalendarEvent, + remove: (event: CalendarEvent) => unknown, + ) { + if (event.is_system_event) return; + const ref = this._dialog.open(EventDetailsModalComponent, { + data: { + event, + edit_fn: (e: CalendarEvent) => this.newBooking(e), + remove_fn: (e: CalendarEvent) => remove(e), + }, + }); + ref.componentInstance.hide_edit.set( + !this._settings.get('app.events.allow_edit'), + ); + this.subscription( + 'actions', + ref.componentInstance.action.subscribe(async (action) => { + if (!action.includes('breakdown')) return; + const ref = this._dialog.open(SetupBreakdownModalComponent, { + data: event, + }); + const data = await nextValueFrom(ref.afterClosed()); + if (data) this.replace(data); + }), + ); + } + public async removeBooking(event: CalendarEvent, series = false) { const time = `${format(event.date, 'dd MMM yyyy ' + this.time_format)}`; const resource_name = event.space?.display_name || event.location; diff --git a/apps/concierge/src/app/day-view/room-bookings.component.ts b/apps/concierge/src/app/day-view/room-bookings.component.ts index bc513ac4b17..947874429f2 100644 --- a/apps/concierge/src/app/day-view/room-bookings.component.ts +++ b/apps/concierge/src/app/day-view/room-bookings.component.ts @@ -36,7 +36,11 @@ import { import { requestSpacesForZone } from '@placeos/events'; import { UserPipe } from '@placeos/users'; import { format } from 'date-fns'; -import { loadPersistedZones, persistZones } from '../ui/zone-persistence'; +import { + loadPersistedZones, + persistZones, + zoneScopeId, +} from '../ui/zone-persistence'; import { EventsStateService } from './events-state.service'; import { RoomBookingsApprovalsComponent } from './room-approvals.component'; import { RoomBookingsListComponent } from './room-bookings-list.component'; @@ -382,7 +386,7 @@ export class RoomBookingsComponent extends AsyncHandler implements OnInit { // without an explicit URL filter. Empty means "all levels". const persisted = loadPersistedZones( 'room-bookings', - this._persistScopeId(), + zoneScopeId(this._org, !!this.use_region), ).filter((zone) => levels.find((lvl) => lvl.id === zone)); if (persisted.length) { this.updateZones(persisted); @@ -404,7 +408,11 @@ export class RoomBookingsComponent extends AsyncHandler implements OnInit { queryParamsHandling: 'merge', }); this._state.setZones(zone_ids); - persistZones('room-bookings', this._persistScopeId(), zone_ids); + persistZones( + 'room-bookings', + zoneScopeId(this._org, !!this.use_region), + zone_ids, + ); }; public readonly updateUIOptions = (o) => this._state.setUIOptions(o); public readonly setPeriod = (p) => { @@ -480,12 +488,6 @@ export class RoomBookingsComponent extends AsyncHandler implements OnInit { ); } - private _persistScopeId() { - return this.use_region - ? this._org.region?.id || '' - : this._org.building?.id || ''; - } - public setFilter(id: string, value: boolean) { const filters = this._state.filters; let hide_type = filters.hide_type || []; diff --git a/apps/concierge/src/app/day-view/room-timeline-inverted.component.ts b/apps/concierge/src/app/day-view/room-timeline-inverted.component.ts index 7fc46aab544..0857e33c25f 100644 --- a/apps/concierge/src/app/day-view/room-timeline-inverted.component.ts +++ b/apps/concierge/src/app/day-view/room-timeline-inverted.component.ts @@ -1,4 +1,4 @@ -import { CommonModule, DatePipe } from '@angular/common'; +import { CommonModule } from '@angular/common'; import { AfterViewInit, Component, @@ -18,34 +18,25 @@ import { CalendarEvent, OrganisationService, SettingsService, - getTimezoneDifferenceInHours, getTimezoneOffsetString, HashMap, - nextValueFrom, notifyError, notifySuccess, } from '@placeos/common'; import { TranslatePipe, openConfirmModal } from '@placeos/components'; -import { - EventDetailsModalComponent, - SetupBreakdownModalComponent, - declineEvent, -} from '@placeos/events'; +import { declineEvent } from '@placeos/events'; import { DateOptionsComponent } from '@placeos/form-fields'; -import { - addHours, - differenceInMinutes, - format, - isSameDay, - setHours, - startOfDay, - startOfMinute, -} from 'date-fns'; +import { format, isSameDay, startOfMinute } from 'date-fns'; import { EventsStateService } from './events-state.service'; import { RoomBookingSearchComponent } from './room-booking-search.component'; import { - groupRoomTimelineEvents, - isActiveRoomTimelineEvent, + timelineEvents, + timelineHourLabel, + timelineHours, + timelineLength, + timelineOffset, + timelineTooltip, + timelineWindow, } from './room-timeline.utilities'; @Component({ @@ -76,7 +67,7 @@ import { }
@@ -320,58 +311,39 @@ export class RoomBookingsInvertedTimelineComponent ); public readonly show_time = computed(() => { this._building(); - const date = this.date(); - const today = isSameDay(date, Date.now()); - const offset = this.timezone - ? getTimezoneDifferenceInHours(this.timezone) - : 0; - const start = addHours( - setHours(startOfDay(Date.now()), this.block_start), - -offset, - ).valueOf(); - const end = addHours( - setHours(startOfDay(Date.now()), this.block_end), - -offset, - ).valueOf(); - return today && Date.now() >= start && Date.now() <= end; + const now = Date.now(); + const { start, end } = timelineWindow( + now, + this.block_start, + this.block_end, + this.timezone, + ); + return isSameDay(this.date(), now) && now >= start && now <= end; }); public readonly events = computed(() => { - const spaces = this.spaces(); - const events = this._filtered().filter(isActiveRoomTimelineEvent); - const date = this.date(); - const offset = this.timezone - ? getTimezoneDifferenceInHours(this.timezone) - : 0; - const start = addHours( - setHours(startOfDay(date), this.block_start), - -offset, - ).valueOf(); - const end = addHours( - setHours(startOfDay(date), this.block_end), - -offset, - ).valueOf(); + const window = timelineWindow( + this.date(), + this.block_start, + this.block_end, + this.timezone, + ); // HashMap keeps the loose template types of the old `{}` map - const event_map: HashMap = groupRoomTimelineEvents( - spaces, - events.filter( - (event) => event.date_end >= start && event.date <= end, - ), + const event_map: HashMap = timelineEvents( + this.spaces(), + this._filtered(), + window, ); return event_map; }); - private _hour_list = Array.from({ length: 24 }, (_, i) => i); public readonly hours = computed(() => - this._hour_list.filter( - (hour) => hour >= this.block_start && hour < this.block_end, - ), + timelineHours(this.block_start, this.block_end), ); public get now() { return startOfMinute(Date.now()).valueOf(); } - public readonly edit = (e) => this._state.newBooking(e); public readonly setDate = (d) => this._state.setDate(d); private _local_tz = getTimezoneOffsetString( @@ -408,19 +380,14 @@ export class RoomBookingsInvertedTimelineComponent } public formatHour(hour: number) { - const date = setHours(Date.now(), hour); - return this._settings.get('app.use_24_hour_time') - ? format(date, 'HH:00') - : format(date, 'h a'); + return timelineHourLabel( + hour, + !!this._settings.get('app.use_24_hour_time'), + ); } - private _date_pipe = new DatePipe('en'); - public eventTooltip(event: CalendarEvent) { - const tooltip = `Start: ${event.all_day ? 'All Day' : this._date_pipe.transform(event.date, this.time_format)} -Title: ${event.title} -Host: ${event.organiser?.name || event.host}`; - return tooltip; + return timelineTooltip(event, this.time_format); } public ngOnInit() { @@ -437,53 +404,22 @@ Host: ${event.organiser?.name || event.host}`; } public timeToOffset(date: number) { - const current_tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - const offset = !this.timezone - ? 0 - : getTimezoneDifferenceInHours(this.timezone, current_tz); - const start_time = setHours( - startOfDay(this._state.getDate()), - this.block_start - offset, + return timelineOffset( + date, + this._state.getDate(), + this.block_start, + this.block_range, + this.timezone, ); - const diff = differenceInMinutes(date, start_time); - - return +((Math.max(0, diff / 60) / this.block_range) * 100).toFixed(2); } public endToOffset(duration: number) { - return +( - (Math.min(this.block_range, duration / 60) / this.block_range) * - 100 - ).toFixed(2); + return timelineLength(duration, this.block_range); } - public viewEvent( - event: CalendarEvent, - space_id: string, - _scroll_to = false, - ) { - if (event.is_system_event) return; - const ref = this._dialog.open(EventDetailsModalComponent, { - data: { - event, - edit_fn: (e) => this.edit(e), - remove_fn: (e) => this.remove(e, space_id), - }, - }); - ref.componentInstance.hide_edit.set( - !this._settings.get('app.events.allow_edit'), - ); - this.subscription( - 'actions', - ref.componentInstance.action.subscribe(async (action) => { - if (!action.includes('breakdown')) return; - const ref = this._dialog.open(SetupBreakdownModalComponent, { - data: event, - }); - const data = await nextValueFrom(ref.afterClosed()); - if (data) this._state.replace(data); - }), - ); + /** Open the details of a booking shown in the column of `space_id` */ + public viewEvent(event: CalendarEvent, space_id: string) { + this._state.viewEvent(event, (e) => this.remove(e, space_id)); } public async remove(item: CalendarEvent, space_id: string) { diff --git a/apps/concierge/src/app/day-view/room-timeline.component.ts b/apps/concierge/src/app/day-view/room-timeline.component.ts index 76ee3e423fc..c685c0a96d0 100644 --- a/apps/concierge/src/app/day-view/room-timeline.component.ts +++ b/apps/concierge/src/app/day-view/room-timeline.component.ts @@ -1,4 +1,4 @@ -import { CommonModule, DatePipe } from '@angular/common'; +import { CommonModule } from '@angular/common'; import { Component, OnInit, computed, inject } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; @@ -8,35 +8,26 @@ import { CalendarEvent, OrganisationService, SettingsService, - getTimezoneDifferenceInHours, getTimezoneOffsetString, HashMap, - nextValueFrom, notifyError, notifyInfo, notifySuccess, } from '@placeos/common'; import { TranslatePipe, openConfirmModal } from '@placeos/components'; -import { - EventDetailsModalComponent, - SetupBreakdownModalComponent, - declineEvent, -} from '@placeos/events'; +import { declineEvent } from '@placeos/events'; import { DateOptionsComponent } from '@placeos/form-fields'; -import { - addHours, - differenceInMinutes, - format, - isSameDay, - setHours, - startOfDay, - startOfMinute, -} from 'date-fns'; +import { format, isSameDay, startOfMinute } from 'date-fns'; import { EventsStateService } from './events-state.service'; import { RoomBookingSearchComponent } from './room-booking-search.component'; import { - groupRoomTimelineEvents, - isActiveRoomTimelineEvent, + timelineEvents, + timelineHourLabel, + timelineHours, + timelineLength, + timelineOffset, + timelineTooltip, + timelineWindow, } from './room-timeline.utilities'; @Component({ @@ -67,7 +58,7 @@ import { }
@@ -273,58 +264,39 @@ export class RoomBookingsTimelineComponent ); public readonly show_time = computed(() => { this._building(); - const date = this.date(); - const today = isSameDay(date, Date.now()); - const offset = this.timezone - ? getTimezoneDifferenceInHours(this.timezone) - : 0; - const start = addHours( - setHours(startOfDay(Date.now()), this.block_start), - -offset, - ).valueOf(); - const end = addHours( - setHours(startOfDay(Date.now()), this.block_end), - -offset, - ).valueOf(); - return today && Date.now() >= start && Date.now() <= end; + const now = Date.now(); + const { start, end } = timelineWindow( + now, + this.block_start, + this.block_end, + this.timezone, + ); + return isSameDay(this.date(), now) && now >= start && now <= end; }); public readonly events = computed(() => { - const spaces = this.spaces(); - const events = this._filtered().filter(isActiveRoomTimelineEvent); - const date = this.date(); - const offset = this.timezone - ? getTimezoneDifferenceInHours(this.timezone) - : 0; - const start = addHours( - setHours(startOfDay(date), this.block_start), - -offset, - ).valueOf(); - const end = addHours( - setHours(startOfDay(date), this.block_end), - -offset, - ).valueOf(); + const window = timelineWindow( + this.date(), + this.block_start, + this.block_end, + this.timezone, + ); // HashMap keeps the loose template types of the old `{}` map - const event_map: HashMap = groupRoomTimelineEvents( - spaces, - events.filter( - (event) => event.date_end >= start && event.date <= end, - ), + const event_map: HashMap = timelineEvents( + this.spaces(), + this._filtered(), + window, ); return event_map; }); - private _hour_list = Array.from({ length: 24 }, (_, i) => i); public readonly hours = computed(() => - this._hour_list.filter( - (hour) => hour >= this.block_start && hour < this.block_end, - ), + timelineHours(this.block_start, this.block_end), ); public get now() { return startOfMinute(Date.now()).valueOf(); } - public readonly edit = (e) => this._state.newBooking(e); public readonly setDate = (d) => this._state.setDate(d); private _local_tz = getTimezoneOffsetString( @@ -365,19 +337,14 @@ export class RoomBookingsTimelineComponent } public formatHour(hour: number) { - const date = setHours(Date.now(), hour); - return this._settings.get('app.use_24_hour_time') - ? format(date, 'HH:00') - : format(date, 'h a'); + return timelineHourLabel( + hour, + !!this._settings.get('app.use_24_hour_time'), + ); } - private _date_pipe = new DatePipe('en'); - public eventTooltip(event: CalendarEvent) { - const tooltip = `Start: ${event.all_day ? 'All Day' : this._date_pipe.transform(event.date, this.time_format)} -Title: ${event.title} -Host: ${event.organiser?.name || event.host}`; - return tooltip; + return timelineTooltip(event, this.time_format); } public ngOnInit() { @@ -385,53 +352,22 @@ Host: ${event.organiser?.name || event.host}`; } public timeToOffset(date: number) { - const current_tz = Intl.DateTimeFormat().resolvedOptions().timeZone; - const offset = !this.timezone - ? 0 - : getTimezoneDifferenceInHours(this.timezone, current_tz); - const start_time = setHours( - startOfDay(this._state.getDate()), - this.block_start - offset, + return timelineOffset( + date, + this._state.getDate(), + this.block_start, + this.block_range, + this.timezone, ); - const diff = differenceInMinutes(date, start_time); - - return +((Math.max(0, diff / 60) / this.block_range) * 100).toFixed(2); } public endToOffset(duration: number) { - return +( - (Math.min(this.block_range, duration / 60) / this.block_range) * - 100 - ).toFixed(2); + return timelineLength(duration, this.block_range); } - public viewEvent( - event: CalendarEvent, - space_id: string, - _scroll_to = false, - ) { - if (event.is_system_event) return; - const ref = this._dialog.open(EventDetailsModalComponent, { - data: { - event, - edit_fn: (e) => this.edit(e), - remove_fn: (e) => this.remove(e, space_id), - }, - }); - ref.componentInstance.hide_edit.set( - !this._settings.get('app.events.allow_edit'), - ); - this.subscription( - 'actions', - ref.componentInstance.action.subscribe(async (action) => { - if (!action.includes('breakdown')) return; - const ref = this._dialog.open(SetupBreakdownModalComponent, { - data: event, - }); - const data = await nextValueFrom(ref.afterClosed()); - if (data) this._state.replace(data); - }), - ); + /** Open the details of a booking shown in the column of `space_id` */ + public viewEvent(event: CalendarEvent, space_id: string) { + this._state.viewEvent(event, (e) => this.remove(e, space_id)); } public async remove(item: CalendarEvent, space_id: string) { diff --git a/apps/concierge/src/app/day-view/room-timeline.utilities.ts b/apps/concierge/src/app/day-view/room-timeline.utilities.ts index e4c5a544efb..ccfe1cff874 100644 --- a/apps/concierge/src/app/day-view/room-timeline.utilities.ts +++ b/apps/concierge/src/app/day-view/room-timeline.utilities.ts @@ -1,4 +1,12 @@ -import { CalendarEvent } from '@placeos/common'; +import { DatePipe } from '@angular/common'; +import { CalendarEvent, getTimezoneDifferenceInHours } from '@placeos/common'; +import { + addHours, + differenceInMinutes, + format, + setHours, + startOfDay, +} from 'date-fns'; export function isActiveRoomTimelineEvent(event: CalendarEvent): boolean { const state = (event as any).state; @@ -59,3 +67,96 @@ export function groupRoomTimelineEvents( } return event_map; } + +/** Time range of the shown hours, as Unix milliseconds */ +export interface TimelineWindow { + start: number; + end: number; +} + +/** + * Start and end of the shown hours on the day of `date`. When `timezone` is + * set, the hours are in that timezone. + */ +export function timelineWindow( + date: number, + block_start: number, + block_end: number, + timezone: string, +): TimelineWindow { + const offset = timezone ? getTimezoneDifferenceInHours(timezone) : 0; + const day = startOfDay(date); + return { + start: addHours(setHours(day, block_start), -offset).valueOf(), + end: addHours(setHours(day, block_end), -offset).valueOf(), + }; +} + +/** Group the active events that overlap `window` by the spaces they book. */ +export function timelineEvents( + spaces: TimelineSpace[], + events: CalendarEvent[], + window: TimelineWindow, +): Record { + return groupRoomTimelineEvents( + spaces, + events.filter( + (event) => + isActiveRoomTimelineEvent(event) && + event.date_end >= window.start && + event.date <= window.end, + ), + ); +} + +/** Hours of the day from `block_start` up to, but not including, `block_end`. */ +export function timelineHours(block_start: number, block_end: number) { + return Array.from({ length: 24 }, (_, i) => i).filter( + (hour) => hour >= block_start && hour < block_end, + ); +} + +/** + * Position of `date` as a percent of the shown hours on the day of `day`. + * When `timezone` is set, the hours are in that timezone. + */ +export function timelineOffset( + date: number, + day: number, + block_start: number, + block_range: number, + timezone: string, +): number { + const current_tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + const offset = timezone + ? getTimezoneDifferenceInHours(timezone, current_tz) + : 0; + const start_time = setHours(startOfDay(day), block_start - offset); + const diff = differenceInMinutes(date, start_time); + return +((Math.max(0, diff / 60) / block_range) * 100).toFixed(2); +} + +/** Length of `duration` minutes as a percent of the shown hours. */ +export function timelineLength(duration: number, block_range: number): number { + return +( + (Math.min(block_range, duration / 60) / block_range) * + 100 + ).toFixed(2); +} + +/** Label for an hour on the timeline axis, such as "09:00" or "9 AM". */ +export function timelineHourLabel(hour: number, use_24_hour: boolean): string { + return format(setHours(Date.now(), hour), use_24_hour ? 'HH:00' : 'h a'); +} + +const DATE_PIPE = new DatePipe('en'); + +/** Tooltip text for a booking on the timeline. */ +export function timelineTooltip( + event: CalendarEvent, + time_format: string, +): string { + return `Start: ${event.all_day ? 'All Day' : DATE_PIPE.transform(event.date, time_format)} +Title: ${event.title} +Host: ${event.organiser?.name || event.host}`; +} diff --git a/apps/concierge/src/app/day-view/room-week-timeline.component.ts b/apps/concierge/src/app/day-view/room-week-timeline.component.ts index a0be6a2e82f..a1a457db6e2 100644 --- a/apps/concierge/src/app/day-view/room-week-timeline.component.ts +++ b/apps/concierge/src/app/day-view/room-week-timeline.component.ts @@ -1,22 +1,16 @@ import { CommonModule, DatePipe } from '@angular/common'; import { Component, computed, inject, OnInit, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; import { AsyncHandler, CalendarEvent, getTimezoneOffsetInMinutes, getTimezoneOffsetString, i18n, - nextValueFrom, OrganisationService, SettingsService, } from '@placeos/common'; import { TranslatePipe } from '@placeos/components'; -import { - EventDetailsModalComponent, - SetupBreakdownModalComponent, -} from '@placeos/events'; import { DateOptionsComponent } from '@placeos/form-fields'; import { UserPipe } from '@placeos/users'; import { @@ -62,7 +56,7 @@ import { isActiveRoomTimelineEvent } from './room-timeline.utilities'; }
@@ -126,13 +120,7 @@ import { isActiveRoomTimelineEvent } from './room-timeline.utilities'; - - @if (period() === 'week') { - - } @else { - - - -
- {{ - isAllDayBooking(row) - ? ('COMMON.ALL_DAY' | translate) - : (row.date | date: time_format : timezone) + - ' - ' + - (row.date_end | date: time_format : timezone) - }} -
-
- -
- {{ request_type_label(request_type(row)) | translate }} -
-
- -
- @if (request_submitted_at(row)) { - {{ - request_submitted_at(row) - | date: 'MMM d, ' + time_format - }} - } @else { - {{ 'COMMON.EMPTY' | translate }} - } -
-
- -
-
{{ row.user_name || row.user_email }}
- @if (row.user_name && row.user_email) { -
- {{ row.user_email }} -
- } -
-
- -
-
- {{ row.booked_by_name || row.booked_by_email }} -
- @if (row.booked_by_name && row.booked_by_email) { -
- {{ row.booked_by_email }} -
- } -
-
- - @if (!row?.checked_in && row.checked_out_at) { -
- done -
- } - @if (!row?.checked_in && !row.checked_out_at) { -
- question_mark -
- } - @if (row?.checked_in) { -
- done -
- } -
- -
- {{ row?.extension_data?.plate_number }} - @if (!row?.extension_data?.plate_number) { - - {{ 'COMMON.EMPTY' | translate }} - - } -
-
- -
- -
- - - - -
- -
- - @if (!hide_assign_space) { - - } - -
-
-
- } - @if (!loading().includes('[BOOKINGS]') && has_more_pages()) { - - } - - - `, - styles: [``], - imports: [ - CommonModule, - MatProgressBarModule, - SimpleTableComponent, - BookingApprovalBarComponent, - TranslatePipe, - MatRippleModule, - IconComponent, - MatMenuModule, - MatTooltipModule, - ParkingRequestsWeekViewComponent, - ], -}) -export class ParkingRequestsListComponent - extends AsyncHandler - implements OnInit -{ - private _state = inject(ParkingStateService); - private _settings = inject(SettingsService); - private _dialog = inject(MatDialog); - - public readonly bookings = this._state.bookings; - public readonly options = this._state.options; - public readonly loading = this._state.loading; - public readonly period = this._state.period; - public readonly has_more_pages = this._state.has_more_pages; - public readonly last_updated = this._state.last_updated; - public readonly loadMore = () => this._state.nextPage(); - public readonly refresh = () => this._state.refresh(); - public readonly load_error = this._state.load_error; - public readonly retryLoad = this.refresh; - public readonly rowKey = bookingRowKey; - public readonly canSelect = (e: Booking) => this.canApproveBooking(e); - /** Whether rows can be selected for bulk approval */ - public readonly bulk_actions = settingSignal('bulk_actions', false); - /** Row keys of the selected bookings */ - public readonly selected = signal([]); - public readonly bulk_busy = signal(false); - - /** Approve or reject the selected bookings */ - public async setApproval(approve: boolean) { - const list = selectedBookings(this.filtered_events(), this.selected()); - this.bulk_busy.set(true); - const done = await this._state - .setBookingsApproval(list, approve) - .finally(() => this.bulk_busy.set(false)); - if (done) this.selected.set([]); - } - - public readonly filtered_events = computed(() => { - const { search, request_filter } = this.options(); - let unallocated = this.bookings().filter((b) => - b.asset_id?.startsWith('unallocated'), - ); - unallocated = this._applyRequestFilter(unallocated, request_filter); - const type_index = (booking: Booking) => - this.request_type(booking) === 'special' - ? 2 - : this.request_type(booking) === 'after_hours' - ? 1 - : 0; - unallocated = [...unallocated].sort((first, second) => { - const type_diff = type_index(first) - type_index(second); - if (type_diff !== 0) return type_diff; - const submitted_first = this.request_submitted_at(first); - const submitted_second = this.request_submitted_at(second); - if (submitted_first !== submitted_second) { - return submitted_second - submitted_first; - } - return second.date - first.date; - }); - return this._state.filterEventSearch(unallocated, search); - }); - - public readonly reject = (e: Booking) => this._state.rejectBooking(e); - public readonly approve = (e: Booking) => this._state.approveBooking(e); - public readonly editReservation = (e: Booking) => - this._state.editReservation(e); - public readonly assignSpace = (e: Booking) => this._state.assignSpace(e); - public readonly viewSpecialNeedsRequest = (booking: Booking) => - this._dialog.open(ParkingSpecialRequestModalComponent, { - data: { booking }, - }); - public readonly request_type = (booking: Booking) => - booking?.extension_data?.request_type || ''; - /** Time that the request was created, in milliseconds */ - public readonly request_submitted_at = (booking: Booking): number => { - const value = booking?.created_at || 0; - // The API returns seconds. A local booking holds milliseconds. - return value < 1_000_000_000_000 ? value * 1000 : value; - }; - - private readonly _request_type_labels: Record = { - standard: 'Standard', - special: 'P2', - after_hours: 'After-hours', - }; - - public readonly request_type_label = (request_type: string) => - this._request_type_labels[request_type] || 'COMMON.EMPTY'; - public readonly canApproveBooking = (e: Booking) => - this._state.canApproveBooking(e); - - private _applyRequestFilter( - list: Booking[], - filter_type: ParkingRequestFilter, - ): Booking[] { - if (filter_type === 'all') return list; - if (filter_type === 'manual') { - return list.filter((b) => this._state.isManualRequest(b)); - } - if (filter_type === 'waitlist') { - if (!this.show_waitlist) return list; - return list.filter((b) => this._state.isWaitlisted(b)); - } - if (filter_type === 'pending') { - return list.filter( - (b) => - b.status === 'tentative' && - (!this.show_waitlist || !this._state.isWaitlisted(b)), - ); - } - return list; - } - - public get time_format() { - return this._settings.time_format; - } - - public get timezone() { - return this._state.timezone; - } - - public get hide_assign_space() { - return !!this._settings.get('app.parking.hide_assign_space'); - } - - public get show_waitlist() { - return this._settings.get('app.parking.show_waitlist') !== false; - } - - /** Status colour tone for tentative requests, empty for any other status */ - public statusTone(booking: Booking): string { - if (booking?.status !== 'tentative') return ''; - return parkingRequestStatusTone(booking, this.show_waitlist); - } - - public requestStatusLabel(booking: Booking): string { - return parkingRequestStatusLabel(booking, this.show_waitlist); - } - - public isAllDayBooking(booking: Booking) { - return isParkingAllDayBooking(booking, this.timezone); - } - - public ngOnInit() { - this._state.refresh(); - } -} diff --git a/apps/concierge/src/app/parking/parking-requests-week-view.component.ts b/apps/concierge/src/app/parking/parking-requests-week-view.component.ts deleted file mode 100644 index afe2f707166..00000000000 --- a/apps/concierge/src/app/parking/parking-requests-week-view.component.ts +++ /dev/null @@ -1,426 +0,0 @@ -import { CommonModule, DatePipe } from '@angular/common'; -import { Component, computed, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { AsyncHandler, Booking, SettingsService } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { addDays, isSameDay, startOfWeek } from 'date-fns'; -import { - ParkingRequestFilter, - ParkingStateService, -} from './parking-state.service'; -import { - isParkingAllDayBooking, - parkingRequestStatusLabel, - parkingRequestStatusTone, -} from './parking.utilities'; - -@Component({ - selector: 'parking-requests-week-view', - template: ` - -
- @for (day of days(); track day) { -
-
-
- {{ day | date: 'EEE, MMM d' : timezone }} -
-
- {{ 'COMMON.TODAY' | translate }} -
-
-
- @for ( - booking of grouped_requests()[day] || []; - track booking.id - ) { -
-
- @if (booking.checked_in) { -
- done -
- } @else if (booking.checked_out_at) { -
- done -
- } @else { -
- question_mark -
- } - - {{ - booking.user_name || - booking.user_email - }} - -
-
- {{ - isAllDayBooking(booking) - ? ('COMMON.ALL_DAY' | translate) - : (booking.date - | date - : time_format - : timezone) + - ' - ' + - (booking.date_end - | date - : time_format - : timezone) - }} -
- @if ( - booking.extension_data?.plate_number; - as plate - ) { -
- {{ plate }} -
- } -
- - - - - - @if (!hide_assign_space) { - - } - -
-
- } - @if (!grouped_requests()[day]?.length) { -
- {{ - 'APP.CONCIERGE.PARKING_REQUESTS_EMPTY' - | translate - }} -
- } -
-
- } -
- `, - styles: [ - ` - :host { - display: flex; - flex-direction: column; - height: 100%; - overflow: auto; - } - `, - ], - imports: [ - CommonModule, - MatProgressBarModule, - MatRippleModule, - MatMenuModule, - MatTooltipModule, - IconComponent, - TranslatePipe, - ], -}) -export class ParkingRequestsWeekViewComponent extends AsyncHandler { - private _state = inject(ParkingStateService); - private _settings = inject(SettingsService); - private _date_pipe = new DatePipe('en'); - - public readonly loading = this._state.loading; - public readonly options = this._state.options; - public readonly bookings = this._state.bookings; - - public readonly days = computed(() => { - const options = this.options(); - const week_start = this._state.week_start; - const start = startOfWeek(options.date, { - weekStartsOn: week_start, - }); - return Array.from({ length: 7 }, (_, i) => addDays(start, i).valueOf()); - }); - - public readonly grouped_requests = computed>( - () => { - const days = this.days(); - const { search, request_filter } = this.options(); - let list = this.bookings().filter((b) => - b.asset_id?.startsWith('unallocated'), - ); - list = this._applyRequestFilter(list, request_filter); - list = this._state.filterEventSearch(list, search); - const grouped: Record = {}; - for (const day of days) { - grouped[day] = list - .filter((b) => { - const booking_date = this._date_pipe.transform( - b.date, - 'yyyy-MM-dd', - this.timezone, - ); - const day_date = this._date_pipe.transform( - day, - 'yyyy-MM-dd', - this.timezone, - ); - return booking_date === day_date; - }) - .sort((a, b) => a.date - b.date); - } - return grouped; - }, - ); - - public readonly reject = (e: Booking) => this._state.rejectBooking(e); - public readonly approve = (e: Booking) => this._state.approveBooking(e); - public readonly editReservation = (e: Booking) => - this._state.editReservation(e); - public readonly assignSpace = (e: Booking) => this._state.assignSpace(e); - public readonly canApproveBooking = (e: Booking) => - this._state.canApproveBooking(e); - - private _applyRequestFilter( - list: Booking[], - filter_type: ParkingRequestFilter, - ): Booking[] { - if (filter_type === 'all') return list; - if (filter_type === 'manual') { - return list.filter((b) => this._state.isManualRequest(b)); - } - if (filter_type === 'waitlist') { - if (!this.show_waitlist) return list; - return list.filter((b) => this._state.isWaitlisted(b)); - } - if (filter_type === 'pending') { - return list.filter( - (b) => - b.status === 'tentative' && - (!this.show_waitlist || !this._state.isWaitlisted(b)), - ); - } - return list; - } - - public get time_format() { - return this._settings.time_format; - } - - public get timezone() { - return this._state.timezone; - } - - public get hide_assign_space() { - return !!this._settings.get('app.parking.hide_assign_space'); - } - - public get show_waitlist() { - return this._settings.get('app.parking.show_waitlist') !== false; - } - - /** Status colour tone for tentative requests, empty for any other status */ - public statusTone(booking: Booking): string { - if (booking?.status !== 'tentative') return ''; - return parkingRequestStatusTone(booking, this.show_waitlist); - } - - public requestStatusLabel(booking: Booking): string { - return parkingRequestStatusLabel(booking, this.show_waitlist); - } - - public isAllDayBooking(booking: Booking) { - return isParkingAllDayBooking(booking, this.timezone); - } - - public isToday(date: number) { - return isSameDay(date, Date.now()); - } -} diff --git a/apps/concierge/src/app/parking/parking-special-request-modal.component.ts b/apps/concierge/src/app/parking/parking-special-request-modal.component.ts deleted file mode 100644 index afafbedc017..00000000000 --- a/apps/concierge/src/app/parking/parking-special-request-modal.component.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, inject } from '@angular/core'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { Booking } from '@placeos/common'; -import { - FullscreenModalShellComponent, - IconComponent, - TranslatePipe, -} from '@placeos/components'; - -@Component({ - selector: 'parking-special-request-modal', - template: ` - -
-
-
- {{ 'BOOKINGS.P2_REASON_FOR_REQUEST' | translate }} -
-
- {{ - request_notes || - ('COMMON.EMPTY' | translate) - }} -
-
- -
-
- {{ 'BOOKINGS.P2_ATTACH_SUPPORTING_DOCS' | translate }} -
- @if (attachments.length) { -
- @for (file of attachments; track file.url) { - - attach_file - - {{ file.name }} - - - } -
- } @else { -
- {{ 'COMMON.EMPTY' | translate }} -
- } -
-
-
- `, - styles: [``], - imports: [ - CommonModule, - TranslatePipe, - IconComponent, - FullscreenModalShellComponent, - ], -}) -export class ParkingSpecialRequestModalComponent { - private _data = inject<{ booking: Booking }>(MAT_DIALOG_DATA); - - public readonly request_notes: string = - `${this._data.booking?.extension_data?.notes || this._data.booking?.description || ''}`.trim(); - - public readonly attachments: { name: string; url: string }[] = (() => { - const urls: string[] = ( - this._data.booking?.extension_data?.attachments || [] - ).filter((url) => !!url); - return urls.map((url) => ({ - url, - name: this._fileNameFromUrl(url), - })); - })(); - - private _fileNameFromUrl(url: string): string { - const last_part = `${url || ''}`.split('/').pop() || ''; - return decodeURIComponent(last_part || 'Uploaded file'); - } -} diff --git a/apps/concierge/src/app/parking/parking-state.service.ts b/apps/concierge/src/app/parking/parking-state.service.ts index 7d8c8440f7a..593308f73aa 100644 --- a/apps/concierge/src/app/parking/parking-state.service.ts +++ b/apps/concierge/src/app/parking/parking-state.service.ts @@ -36,7 +36,6 @@ import { rejectBookingInstance, removeBooking as removeBookingApi, saveBooking, - setBookingCheckedIn, updateBooking, updateBookingInstance, } from '@placeos/bookings'; @@ -612,6 +611,28 @@ export class ParkingStateService extends AsyncHandler { ); } + /** + * Whether the current user can see parking requests. Needs the + * `app.parking.show_requests` setting. Admins can always see them. Other + * users must be in a `parking-requests` feature group, when that list + * has groups. + */ + public get can_view_requests(): boolean { + if (!this._settings.get('app.parking.show_requests')) return false; + const feature_groups = this._settings.get('app.feature_groups') || {}; + const request_groups: string[] = + feature_groups['parking-requests'] || []; + const admin_group = this._settings.get('app.admin_group') || 'admin'; + const groups = currentUser()?.groups || []; + return ( + groups.includes(admin_group) || + groups.includes('placeos_admin') || + groups.includes('placeos_support') || + !request_groups.length || + groups.some((grp) => request_groups.includes(grp)) + ); + } + public filterEventList(list: Booking[], filter_type: ParkingRequestFilter) { const show_requests = !!this._settings.get('app.parking.show_requests'); const visible_list = show_requests @@ -1095,31 +1116,6 @@ export class ParkingStateService extends AsyncHandler { }); } - public async setBookingCheckinState(booking: Booking, state = true) { - const promise = setBookingCheckedIn(booking, state).catch((_) => ({ - state: 'failed', - error: _, - })); - const success = await promise; - success.state === 'failed' - ? notifyError( - i18n( - state - ? 'APP.CONCIERGE.PARKING_CHECKIN_ERROR' - : 'APP.CONCIERGE.PARKING_CHECKOUT_ERROR', - { error: success.error }, - ), - ) - : notifySuccess( - i18n( - state - ? 'APP.CONCIERGE.PARKING_CHECKIN_SUCCESS' - : 'APP.CONCIERGE.PARKING_CHECKOUT_SUCCESS', - ), - ); - if (success.state !== 'failed') this._reloadResources(); - } - public async approveBooking(booking: Booking, series = false) { try { await this._approveBooking(booking, series); diff --git a/apps/concierge/src/app/parking/parking-topbar.component.ts b/apps/concierge/src/app/parking/parking-topbar.component.ts index 8e610322998..9cde5776b6b 100644 --- a/apps/concierge/src/app/parking/parking-topbar.component.ts +++ b/apps/concierge/src/app/parking/parking-topbar.component.ts @@ -11,7 +11,6 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { AsyncHandler, - currentUser, OrganisationService, SettingsService, } from '@placeos/common'; @@ -31,7 +30,12 @@ import { import { DateOptionsComponent } from '@placeos/form-fields'; import { BookingRulesModalComponent } from '../ui/booking-rules-modal.component'; import { SearchbarComponent } from '../ui/searchbar.component'; -import { loadPersistedZones, persistZones } from '../ui/zone-persistence'; +import { + loadPersistedZones, + persistZones, + sameZones, + zoneScopeId, +} from '../ui/zone-persistence'; import { ParkingRequestFilter, ParkingStateService, @@ -503,11 +507,8 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { const zone_param = zones.length ? zones.join(',') : null; const query_zone_param = this._query_params().get('zone_ids') || null; const query_matches = zone_param === query_zone_param; - const selected_zones_match = this._sameZones(zones, this.zones()); - const option_zones_match = this._sameZones( - zones, - this.options().zones || [], - ); + const selected_zones_match = sameZones(zones, this.zones()); + const option_zones_match = sameZones(zones, this.options().zones || []); if (query_matches && selected_zones_match && option_zones_match) { return; } @@ -525,7 +526,7 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { if (!selector_inactive) { persistZones( this.section() === 'manage' ? 'parking-manage' : 'parking', - this._persistScopeId(), + zoneScopeId(this._org, this.use_region), zones, ); } @@ -574,18 +575,7 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { } public get can_view_requests() { - if (!this._settings.get('app.parking.show_requests')) return false; - const feature_groups = this._settings.get('app.feature_groups') || {}; - const request_groups = feature_groups['parking-requests'] || []; - const admin_group = this._settings.get('app.admin_group') || 'admin'; - const groups = currentUser()?.groups || []; - return ( - groups.includes(admin_group) || - groups.includes('placeos_admin') || - groups.includes('placeos_support') || - !request_groups.length || - groups.some((grp) => request_groups.includes(grp)) - ); + return this._state.can_view_requests; } public manageRestrictions() { @@ -662,10 +652,10 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { // selection means all levels. zones = loadPersistedZones( this.section() === 'manage' ? 'parking-manage' : 'parking', - this._persistScopeId(), + zoneScopeId(this._org, this.use_region), ).filter((zone) => levels.find((lvl) => lvl.id === zone)); } - if (this._sameZones(zones, this.zones())) return; + if (sameZones(zones, this.zones())) return; this.updateZones(zones); }); effect(() => { @@ -683,22 +673,16 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { if (has_valid_zone) return; const persisted = loadPersistedZones( 'parking', - this._persistScopeId(), + zoneScopeId(this._org, this.use_region), ).filter((zone) => levels.find((lvl) => lvl.id === zone)); this.updateZones([persisted[0] || levels[0].id]); }); } - private _persistScopeId() { - return this.use_region - ? this._org.region?.id || '' - : this._org.building?.id || ''; - } - private _applyQueryZones(zones: string[]) { if ( - this._sameZones(zones, this.zones()) && - this._sameZones(zones, this.options().zones || []) + sameZones(zones, this.zones()) && + sameZones(zones, this.options().zones || []) ) { return; } @@ -775,16 +759,8 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { } if (section_changed) this.zones.set([]); this._previous_route_key = route_key; - if (current_view === 'bookings') { - this.setRequestFilter('bookings'); - } else if (current_view === 'requests' && this.can_view_requests) { - this.setRequestFilter('requests'); - } - if ( - this.section() === 'events' && - (!this.can_view_requests || - !this._settings.get('app.parking.show_requests')) - ) { + // ParkingComponent maps the bookings and requests routes to a filter. + if (this.section() === 'events' && !this.can_view_requests) { this.setRequestFilter('bookings'); } if ( @@ -804,11 +780,4 @@ export class ParkingTopbarComponent extends AsyncHandler implements OnInit { this.updateZones([]); } } - - private _sameZones(first: string[], second: string[]) { - return ( - first.length === second.length && - first.every((zone, index) => zone === second[index]) - ); - } } diff --git a/apps/concierge/src/app/parking/parking.component.ts b/apps/concierge/src/app/parking/parking.component.ts index d9c7c06c293..ce0414fefd8 100644 --- a/apps/concierge/src/app/parking/parking.component.ts +++ b/apps/concierge/src/app/parking/parking.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { MatTabsModule } from '@angular/material/tabs'; import { NavigationEnd, Router, RouterModule } from '@angular/router'; -import { AsyncHandler, SettingsService, currentUser } from '@placeos/common'; +import { AsyncHandler, SettingsService } from '@placeos/common'; import { TranslatePipe } from '@placeos/components'; import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; import { ParkingStateService } from './parking-state.service'; @@ -156,10 +156,6 @@ export class ParkingComponent extends AsyncHandler implements OnInit { 'bookings' | 'fleet' | 'list' | 'map' | 'requests' | 'spaces' | 'users' >('list'); - public get show_requests() { - return !!this._settings.get('app.parking.show_requests'); - } - public get hide_users_and_vehicles() { return !!this._settings.get('app.parking.hide_users_and_vehicles'); } @@ -178,26 +174,8 @@ export class ParkingComponent extends AsyncHandler implements OnInit { ); } - public get is_admin() { - const groups = currentUser().groups || []; - const admin_group = this._settings.get('app.admin_group') || 'admin'; - return ( - groups.includes(admin_group) || - groups.includes('placeos_admin') || - groups.includes('placeos_support') - ); - } - public get can_view_requests() { - if (!this.show_requests) return false; - const feature_groups = this._settings.get('app.feature_groups') || {}; - const request_groups = feature_groups['parking-requests'] || []; - const groups = currentUser().groups || []; - return ( - this.is_admin || - !request_groups.length || - groups.some((grp) => request_groups.includes(grp)) - ); + return this._state.can_view_requests; } public ngOnInit() { diff --git a/apps/concierge/src/app/poi-manager/poi-manager.component.ts b/apps/concierge/src/app/poi-manager/poi-manager.component.ts index 2c6241c5ba1..0a72bcd3e0d 100644 --- a/apps/concierge/src/app/poi-manager/poi-manager.component.ts +++ b/apps/concierge/src/app/poi-manager/poi-manager.component.ts @@ -65,10 +65,6 @@ import { POIManagementService } from './poi-management.service'; background-color: var(--base-100); } - sidebar { - height: 100%; - } - main { display: flex; flex-direction: column; diff --git a/apps/concierge/src/app/points/points-topbar.component.ts b/apps/concierge/src/app/points/points-topbar.component.ts index 8d3836b3d3a..2336b45c04f 100644 --- a/apps/concierge/src/app/points/points-topbar.component.ts +++ b/apps/concierge/src/app/points/points-topbar.component.ts @@ -1,6 +1,4 @@ -import { Component, computed, inject, signal } from '@angular/core'; -import { Router } from '@angular/router'; -import { AsyncHandler } from '@placeos/common'; +import { Component } from '@angular/core'; import { TranslatePipe } from '@placeos/components'; import { DateOptionsComponent } from '@placeos/form-fields'; @@ -29,22 +27,4 @@ import { DateOptionsComponent } from '@placeos/form-fields'; ], imports: [DateOptionsComponent, TranslatePipe], }) -export class PointsTopbarComponent extends AsyncHandler { - private _router = inject(Router); - - private readonly _url = signal(null); - - public readonly path = computed(() => { - this._url(); - const parts = this._router.url.split('/'); - return parts[parts.length - 1].split('?')[0]; - }); - - constructor() { - super(); - this.subscription( - 'router', - this._router.events.subscribe((event) => this._url.set(event)), - ); - } -} +export class PointsTopbarComponent {} diff --git a/apps/concierge/src/app/region-manager/region-list.component.ts b/apps/concierge/src/app/region-manager/region-list.component.ts index 8f95019ef51..82ebcab6295 100644 --- a/apps/concierge/src/app/region-manager/region-list.component.ts +++ b/apps/concierge/src/app/region-manager/region-list.component.ts @@ -1,8 +1,8 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { Component, inject } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { notifySuccess } from '@placeos/common'; +import { AppSettingsMenuComponent } from '../ui/app-settings/app-settings-menu.component'; import { RegionManagementService } from './region-management.service'; import { MatMenuModule } from '@angular/material/menu'; @@ -68,7 +68,7 @@ import { - - - - - - + - } - - - - -
{{ data | date: 'mediumDate' }}
-
- -
{{ data || '0' }}%
-
- -
- {{ formatPercent(data, row.count) }} -
-
- - - `, - imports: [ - CommonModule, - TranslatePipe, - IconComponent, - SimpleTableComponent, - MatRippleModule, - MatTooltipModule, - ReportMetricGuideComponent, - ], -}) -export class ReportSpacesOverallListComponent { - private _state = inject(ReportsStateService); - - public readonly print = input(false); - public readonly table_metric_guide = TABLE_METRIC_GUIDE; - - public readonly day_list = this._state.day_list; - - public readonly download = async () => { - const data = this.day_list().map((d) => ({ - ...d, - date: format(d.date, 'MMMM d, y(EEE)'), - cancelled: formatReportPercentage(d.cancelled, d.count), - deleted: formatReportPercentage(d.deleted, d.count), - })); - downloadFile('spaces-usage.csv', jsonToCsv(data)); - }; - - public readonly formatPercent = formatReportPercentage; -} diff --git a/apps/concierge/src/app/reports/spaces/report-spaces.component.ts b/apps/concierge/src/app/reports/spaces/report-spaces.component.ts index 273c9091aa0..786f3752e2c 100644 --- a/apps/concierge/src/app/reports/spaces/report-spaces.component.ts +++ b/apps/concierge/src/app/reports/spaces/report-spaces.component.ts @@ -1,23 +1,16 @@ import { Component, computed, inject, OnInit, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; -import { - AuthenticatedImageDirective, - TranslatePipe, -} from '@placeos/components'; +import { AsyncHandler } from '@placeos/common'; import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; +import { ReportUsageChartsComponent } from '../report-usage-charts.component'; +import { ReportUsageListComponent } from '../report-usage-list.component'; import { ReportsOptionsComponent } from '../reports-options.component'; import { ReportsStateService } from '../reports-state.service'; -import { ReportSpacesChartsComponent } from './report-spaces-charts.component'; -import { ReportSpacesOverallListComponent } from './report-spaces-overall-list.component'; +import { reportQueryOptions } from '../reports.utilities'; import { ReportSpacesOverallComponent } from './report-spaces-overall.component'; import { ReportSpacesSpaceListingComponent } from './report-spaces-space-listing.component'; import { ReportSpacesUserListingComponent } from './report-spaces-user-listing.component'; @@ -60,60 +53,27 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

- {{ 'APP.CONCIERGE.REPORTS_ROOMS_HEADER' | translate }} -

-
-
- @if (!loading()) { - @if (total_count()) { - - - - - - - } @else { -
-

- {{ 'APP.CONCIERGE.REPORTS_EMPTY' | translate }} -

-
- } - } @else { -
- -

- {{ 'APP.CONCIERGE.REPORTS_LOADING' | translate }} -

-
- } -
+ + + + + + + + + `, styles: [ ` @@ -125,23 +85,19 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ - MatProgressSpinnerModule, - TranslatePipe, + ReportPageComponent, ReportsOptionsComponent, ReportMetricGuideComponent, - AuthenticatedImageDirective, - ReportSpacesChartsComponent, + ReportUsageChartsComponent, ReportSpacesOverallComponent, - ReportSpacesOverallListComponent, + ReportUsageListComponent, ReportSpacesSpaceListingComponent, ReportSpacesUserListingComponent, ], }) export class ReportSpacesComponent extends AsyncHandler implements OnInit { private _state = inject(ReportsStateService); - private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); public readonly printing = signal(false); public readonly metric_guide = METRIC_GUIDE; @@ -155,33 +111,13 @@ export class ReportSpacesComponent extends AsyncHandler implements OnInit { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); - public ngOnInit() { this._state.setOptions({ type: 'events' }); this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const id_list = - params.get('zones') || params.get('zone_ids'); - const zones = id_list.split(','); - if (zones.length) this._state.setOptions({ zones }); - } else this._state.setOptions({ zones: [] }); - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params)), + ), ); } } diff --git a/apps/concierge/src/app/reports/user/user-report.component.ts b/apps/concierge/src/app/reports/user/user-report.component.ts index d2a5cefc2d0..d7882768343 100644 --- a/apps/concierge/src/app/reports/user/user-report.component.ts +++ b/apps/concierge/src/app/reports/user/user-report.component.ts @@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { SettingsService, themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, DurationPipe, @@ -74,12 +74,7 @@ import { UserReportOptions, UserReportService } from './user-report.service'; class="relative h-1/2 w-full flex-1 overflow-auto print:h-auto print:overflow-visible" >
- +

@@ -221,7 +216,6 @@ import { UserReportOptions, UserReportService } from './user-report.service'; export class UserReportComponent { private _state = inject(UserReportService); private _settings = inject(SettingsService); - private _org = inject(OrganisationService); public readonly printing = signal(false); public readonly options = this._state.options; @@ -242,14 +236,7 @@ export class UserReportComponent { ]; }); - public readonly logo = computed(() => { - 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(); public readonly setOptions = (options: Partial) => this._state.setOptions(options); diff --git a/apps/concierge/src/app/reports/visitors/visitors-report.component.ts b/apps/concierge/src/app/reports/visitors/visitors-report.component.ts index 60188e036f4..ccc71f3a937 100644 --- a/apps/concierge/src/app/reports/visitors/visitors-report.component.ts +++ b/apps/concierge/src/app/reports/visitors/visitors-report.component.ts @@ -1,20 +1,13 @@ import { Component, OnInit, computed, inject, signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute } from '@angular/router'; -import { - AsyncHandler, - OrganisationService, - SettingsService, -} from '@placeos/common'; -import { - AuthenticatedImageDirective, - TranslatePipe, -} from '@placeos/components'; +import { AsyncHandler } from '@placeos/common'; import { ReportMetricGuideComponent, ReportMetricGuideItem, } from '../report-metric-guide.component'; +import { ReportPageComponent } from '../report-page.component'; import { ReportsOptionsComponent } from '../reports-options.component'; +import { reportQueryOptions } from '../reports.utilities'; import { VisitorReportDailyUsageComponent } from './visitor-report-daily-usage.component'; import { VisitorReportListComponent } from './visitor-report-list.component'; import { VisitorReportOverallComponent } from './visitor-report-overall.component'; @@ -57,54 +50,23 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ (download)="downloadReport()" (generate)="generateReport()" /> -
-
-
- -
-

- {{ - 'APP.CONCIERGE.REPORTS_VISITORS_HEADER' | translate - }} -

-
-
- @if (!loading()) { - @if (total_count()) { - - - - - } @else { -
-

- {{ 'APP.CONCIERGE.REPORTS_EMPTY' | translate }} -

-
- } - } @else { -
- -

- {{ 'APP.CONCIERGE.REPORTS_LOADING' | translate }} -

-
- } -
+ + + + + + + `, styles: [ ` @@ -116,21 +78,17 @@ const METRIC_GUIDE: ReportMetricGuideItem[] = [ `, ], imports: [ + ReportPageComponent, ReportsOptionsComponent, ReportMetricGuideComponent, - AuthenticatedImageDirective, - MatProgressSpinnerModule, VisitorReportOverallComponent, VisitorReportDailyUsageComponent, VisitorReportListComponent, - TranslatePipe, ], }) export class VisitorsReportComponent extends AsyncHandler implements OnInit { private _state = inject(VisitorsReportService); - private _settings = inject(SettingsService); private _route = inject(ActivatedRoute); - private _org = inject(OrganisationService); public readonly printing = signal(false); public readonly metric_guide = METRIC_GUIDE; @@ -143,32 +101,12 @@ export class VisitorsReportComponent extends AsyncHandler implements OnInit { public readonly downloadReport = () => this._state.downloadReport(); public readonly generateReport = () => this._state.generateReport(); - public readonly logo = computed(() => { - this._org.active_building(); - return ( - (this._settings.theme === 'dark' - ? this._settings.get('app.logo_dark') - : this._settings.get('app.logo_light')) || {} - ); - }); - public ngOnInit() { this.subscription( 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('start')) { - this._state.setOptions({ start: +params.get('start') }); - } - if (params.has('end')) { - this._state.setOptions({ end: +params.get('end') }); - } - if (params.has('zones') || params.has('zone_ids')) { - const zones = ( - params.get('zones') || params.get('zone_ids') - ).split(','); - if (zones.length) this._state.setOptions({ zones }); - } else this._state.setOptions({ zones: [] }); - }), + this._route.queryParamMap.subscribe((params) => + this._state.setOptions(reportQueryOptions(params)), + ), ); } } diff --git a/apps/concierge/src/app/reports/visitors/visitors-report.service.ts b/apps/concierge/src/app/reports/visitors/visitors-report.service.ts index 7066178682c..d4d52f20145 100644 --- a/apps/concierge/src/app/reports/visitors/visitors-report.service.ts +++ b/apps/concierge/src/app/reports/visitors/visitors-report.service.ts @@ -116,7 +116,7 @@ export class VisitorsReportService { 'yyyy-MM-dd', )}`; downloadFile( - `report+assets+${date}.tsv`, + `report+visitors+${date}.tsv`, jsonToCsv( bookings.map((booking) => { const b: any = booking.toJSON(); diff --git a/apps/concierge/src/app/room-manager/room-manager-topbar.component.ts b/apps/concierge/src/app/room-manager/room-manager-topbar.component.ts index 741c4142d26..97622ddafcc 100644 --- a/apps/concierge/src/app/room-manager/room-manager-topbar.component.ts +++ b/apps/concierge/src/app/room-manager/room-manager-topbar.component.ts @@ -138,21 +138,10 @@ export class RoomManagerTopbarComponent extends AsyncHandler implements OnInit { public readonly newRoom = () => this._manager.editRoom(); - public get building() { - return this._org.building; - } - public get use_region() { return !!this._settings.get('app.use_region'); } - public get root_zone() { - return ( - (this.use_region ? this._org.region.id : '') || - this._org.building.id - ); - } - public manageRestrictions() { this._dialog.open(BookingRulesModalComponent, { data: { type: 'room' }, diff --git a/apps/concierge/src/app/room-manager/room-manager.component.ts b/apps/concierge/src/app/room-manager/room-manager.component.ts index 57bcfb98be6..b1fd87b6d92 100644 --- a/apps/concierge/src/app/room-manager/room-manager.component.ts +++ b/apps/concierge/src/app/room-manager/room-manager.component.ts @@ -26,10 +26,6 @@ import { RoomManagerTopbarComponent } from './room-manager-topbar.component'; background-color: var(--base-100); } - sidebar { - height: 100%; - } - main { display: flex; flex-direction: column; diff --git a/apps/concierge/src/app/room-manager/room-modal.component.ts b/apps/concierge/src/app/room-manager/room-modal.component.ts index 3bebcc34a52..ed0e358d7be 100644 --- a/apps/concierge/src/app/room-manager/room-modal.component.ts +++ b/apps/concierge/src/app/room-manager/room-modal.component.ts @@ -1,15 +1,7 @@ import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes'; -import { - Component, - Injector, - OnInit, - computed, - inject, - signal, -} from '@angular/core'; +import { Component, Injector, OnInit, inject, signal } from '@angular/core'; import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; import { - EncryptionLevel, addSystem, showMetadata, updateMetadata, @@ -18,7 +10,6 @@ import { import { CommonModule } from '@angular/common'; import { FormField, form } from '@angular/forms/signals'; -import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MAT_DIALOG_DATA, MatDialog, @@ -37,11 +28,9 @@ import { i18n, notifyError, notifyWarn, - onFieldChange, patchSignalModel, unique, } from '@placeos/common'; -import { TIMEZONES_IANA } from '@placeos/common/timezones'; import { FullscreenModalShellComponent, IconComponent, @@ -56,6 +45,7 @@ import { } from '@placeos/form-fields'; import { errorText } from '../ui/modal-actions'; import { SelectMapItemModalComponent } from '../ui/select-map-item-modal.component'; +import { TimezoneFieldComponent } from '../ui/timezone-field.component'; @Component({ selector: 'room-form-modal', @@ -375,33 +365,10 @@ import { SelectMapItemModalComponent } from '../ui/select-map-item-modal.compone

} -
- - - search - - - - @for (tz of filtered_timezones(); track tz) { - - {{ tz }} - - } - @if (!timezones().length) { - - {{ 'COMMON.TIMEZONE_EMPTY' | translate }} - - } - -
+ @if (form.images) {
-
- - - - @for (opt of WEEK_START; track opt.value) { - {{ - opt.label - }} - } - - - Day of the week to show initially on various - calendars - - -
-
- - - - - - - @for ( - option of filtered_currency_options(); - track option.code - ) { - - {{ option.display_name }} - - } - @if (!filtered_currency_options().length) { - - No currencies match your search - - } - - - ISO 4217 currency code for pricing - - -
+ +
-
- - - - @for ( - opt of DAY_TIMELINE_VIEW; - track opt.value - ) { - {{ opt.label }} - } - - - Default day timeline layout for room - bookings - - -
+
-
- - - - @for ( - opt of BLOCK_START; - track opt.value - ) { - {{ - opt.label - }} - } - - - Start time of dayview blocks - - -
-
- - - - @for ( - opt of BLOCK_END; - track opt.value - ) { - {{ - opt.label - }} - } - - - End time of dayview blocks - - -
+ +
-
- - - - @for ( - opt of BLOCK_HEIGHT; - track opt.value - ) { - {{ - opt.label - }} - } - - - Height of 1 hour in dayview - - -
-
- - - - @for ( - opt of MAX_DURATION_FULL; - track opt.value - ) { - {{ - opt.label - }} - } - - - Max duration for single day - bookings - - -
-
-
- - - - No limit - @for ( - opt of AVAILABLE_PERIOD_SHORT; - track opt.value - ) { - {{ opt.label }} - } - - - Number of days ahead users can - create group events - - + +
+
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
-
- - - - @for ( - opt of BLOCK_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BLOCK_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
@@ -1062,72 +750,24 @@ import { UploadButtonComponent } from './upload-button.component';
-
- - - - @for ( - opt of BOOKABLE_HOUR_START; - track opt.value - ) { - {{ - opt.label - }} - } - - -
-
- - - - @for ( - opt of BOOKABLE_HOUR_END; - track opt.value - ) { - {{ - opt.label - }} - } - - -
+ +
`, @@ -152,13 +107,13 @@ import { ApplicationSidebarComponent } from '../ui/app-sidebar.component'; RegionListComponent, BuildingListComponent, LevelListComponent, + AppSettingsMenuComponent, ], }) export class ZoneManagerComponent extends AsyncHandler implements OnInit { private readonly _region_service = inject(RegionManagementService); private readonly _building_service = inject(BuildingManagementService); private readonly _level_service = inject(LevelManagementService); - private readonly _dialog = inject(MatDialog); private readonly _org = inject(OrganisationService); private readonly _route = inject(ActivatedRoute); private readonly _router = inject(Router); @@ -167,6 +122,11 @@ export class ZoneManagerComponent extends AsyncHandler implements OnInit { private readonly TAB_NAMES = ['regions', 'buildings', 'levels']; + /** Organisation zone for the organisation App Settings menu. */ + public get organisation() { + return this._org.organisation; + } + public readonly addButtonText = computed(() => { const tab_index = this.selected_tab(); if (tab_index === 0) return 'APP.CONCIERGE.REGIONS_ADD'; @@ -212,36 +172,4 @@ export class ZoneManagerComponent extends AsyncHandler implements OnInit { }), ); } - - public async editWorkplaceSettings() { - const { WorkplaceSettingsFormModalComponent } = - await import('../ui/app-settings/workplace-settings-form-modal.component'); - this._dialog.open(WorkplaceSettingsFormModalComponent, { - data: { zone: this._org.organisation }, - }); - } - - public async editConciergeSettings() { - const { ConciergeSettingsFormModalComponent } = - await import('../ui/app-settings/concierge-settings-form-modal.component'); - this._dialog.open(ConciergeSettingsFormModalComponent, { - data: { zone: this._org.organisation }, - }); - } - - public async editBookingPanelSettings() { - const { BookingPanelSettingsModalComponent } = - await import('../ui/app-settings/booking-panel-settings-modal.component'); - this._dialog.open(BookingPanelSettingsModalComponent, { - data: { zone: this._org.organisation }, - }); - } - - public async editVisitorKioskSettings() { - const { VisitorKioskSettingsFormModalComponent } = - await import('../ui/app-settings/visitor-kiosk-settings-form-modal.component'); - this._dialog.open(VisitorKioskSettingsFormModalComponent, { - data: { zone: this._org.organisation }, - }); - } } diff --git a/apps/concierge/src/tests/asset-manager/asset-manager.component.spec.ts b/apps/concierge/src/tests/asset-manager/asset-manager.component.spec.ts deleted file mode 100644 index 67c94521e67..00000000000 --- a/apps/concierge/src/tests/asset-manager/asset-manager.component.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Router } from '@angular/router'; -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; - -import { AssetManagerComponent } from '../../app/asset-manager/asset-manager.component'; -import { ApplicationSidebarComponent } from '../../app/ui/app-sidebar.component'; - -describe('AssetManagerComponent', () => { - let spectator: SpectatorRouting; - const createComponent = createRoutingFactory({ - component: AssetManagerComponent, - declarations: [MockComponent(ApplicationSidebarComponent)], - }); - - beforeEach(() => (spectator = createComponent())); - - function setUrl(url: string) { - Object.defineProperty(spectator.inject(Router), 'url', { - value: url, - configurable: true, - }); - (spectator.component as any)._url.set(Date.now()); - } - - it('should resolve the last path segment', () => { - setUrl('/book/assets/list/purchase-orders'); - expect(spectator.component.path()).toBe('purchase-orders'); - }); - - it('should strip query parameters from the path', () => { - setUrl('/book/assets/list/items?building=bld-1'); - expect(spectator.component.path()).toBe('items'); - }); -}); diff --git a/apps/concierge/src/tests/asset-manager/duration.pipe.spec.ts b/apps/concierge/src/tests/asset-manager/duration.pipe.spec.ts deleted file mode 100644 index 15a31d24531..00000000000 --- a/apps/concierge/src/tests/asset-manager/duration.pipe.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { DurationFormatPipe } from '../../app/asset-manager/duration.pipe'; - -describe('DurationFormatPipe', () => { - let pipe: DurationFormatPipe; - - beforeEach(() => (pipe = new DurationFormatPipe())); - - it('should abbreviate units in short form by default', () => { - const short = pipe.transform(90); - - expect(short).toContain('hr'); - expect(short).toContain('min'); - expect(short).not.toContain('hour'); - expect(short).not.toContain('minute'); - }); - - it('should keep full unit names in long form', () => { - const long = pipe.transform(90, false); - - expect(long).toContain('hour'); - expect(long).toContain('minute'); - }); - - it('should include days when the duration exceeds a full day', () => { - const value = pipe.transform(24 * 60 + 60, false); - - expect(value).toContain('day'); - expect(value).toContain('hour'); - }); -}); diff --git a/apps/concierge/src/tests/building-manager/building-form.component.spec.ts b/apps/concierge/src/tests/building-manager/building-form.component.spec.ts index 99e6ce78aaf..48b6b0d2484 100644 --- a/apps/concierge/src/tests/building-manager/building-form.component.spec.ts +++ b/apps/concierge/src/tests/building-manager/building-form.component.spec.ts @@ -76,15 +76,6 @@ describe('BuildingFormComponent', () => { expect(model.location).toBe('123 St'); }); - it('should filter the timezone list by the current model timezone', () => { - spectator.component.model.update((m) => ({ ...m, timezone: 'sydney' })); - const filtered = spectator.component.filtered_timezones(); - expect(filtered.length).toBeGreaterThan(0); - expect(filtered.every((_) => _.toLowerCase().includes('sydney'))).toBe( - true, - ); - }); - it('should block saving and notify when required fields are missing', async () => { spectator.component.model.update((m) => ({ ...m, display_name: '' })); diff --git a/apps/concierge/src/tests/building-manager/building-list.component.spec.ts b/apps/concierge/src/tests/building-manager/building-list.component.spec.ts index 2749598bac3..436da4aa6a5 100644 --- a/apps/concierge/src/tests/building-manager/building-list.component.spec.ts +++ b/apps/concierge/src/tests/building-manager/building-list.component.spec.ts @@ -1,6 +1,5 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockProvider } from 'ng-mocks'; @@ -8,7 +7,6 @@ import { setNotifyOutlet } from '@placeos/common'; import { BuildingListComponent } from '../../app/building-manager/building-list.component'; import { BuildingManagementService } from '../../app/building-manager/building-management.service'; -import { WorkplaceSettingsFormModalComponent } from '../../app/ui/app-settings/workplace-settings-form-modal.component'; describe('BuildingListComponent', () => { let spectator: Spectator; @@ -23,7 +21,6 @@ describe('BuildingListComponent', () => { providers: [ MockProvider(BuildingManagementService, {}), MockProvider(Clipboard, {}), - MockProvider(MatDialog, { open: vi.fn() }), ], }); @@ -36,7 +33,6 @@ describe('BuildingListComponent', () => { manager = { filtered_buildings: signal([{ id: 'b1' }]), editBuilding: vi.fn(), - editBuildingMetadata: vi.fn(), removeBuilding: vi.fn(), setAutoRelease: vi.fn(), setInduction: vi.fn(), @@ -47,7 +43,6 @@ describe('BuildingListComponent', () => { providers: [ { provide: BuildingManagementService, useValue: manager }, { provide: Clipboard, useValue: { copy } }, - { provide: MatDialog, useValue: { open: vi.fn() } }, ], }); }); @@ -86,13 +81,4 @@ describe('BuildingListComponent', () => { expect(manager.setSupportIssueTypes).toHaveBeenCalledWith(row); expect(manager.removeBuilding).toHaveBeenCalledWith(row); }); - - it('should open the workplace settings modal for the selected zone', async () => { - const zone = { id: 'b1' }; - await spectator.component.editWorkplaceSettings(zone); - expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( - WorkplaceSettingsFormModalComponent, - { data: { zone } }, - ); - }); }); diff --git a/apps/concierge/src/tests/day-view/dayview-topbar.component.spec.ts b/apps/concierge/src/tests/day-view/dayview-topbar.component.spec.ts deleted file mode 100644 index 5c8b82beb30..00000000000 --- a/apps/concierge/src/tests/day-view/dayview-topbar.component.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { OrganisationService } from '@placeos/common'; -import { IconComponent } from '@placeos/components'; -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { SettingsService } from '@placeos/common'; -import { DateOptionsComponent } from '@placeos/form-fields'; -import { DayviewTopbarComponent } from '../../app/day-view/dayview-topbar.component'; -import { EventsStateService } from '../../app/day-view/events-state.service'; - -describe('DayviewTopbarComponent', () => { - let spectator: SpectatorRouting; - const createComponent = createRoutingFactory({ - component: DayviewTopbarComponent, - providers: [ - MockProvider(OrganisationService, { - initialised: signal(true), - active_levels: signal([]), - levelWithID: vi.fn(), - }), - MockProvider(EventsStateService, { - levels: signal([]), - options: signal({}), - setFilters: vi.fn(), - setDate: vi.fn(), - setZones: vi.fn(), - newBooking: vi.fn(), - }), - MockProvider(SettingsService, { time_format: 'h:mm a' }), - ], - declarations: [ - MockComponent(DateOptionsComponent), - MockComponent(IconComponent), - ], - imports: [ - MatFormFieldModule, - MatSelectModule, - MatSlideToggleModule, - FormsModule, - ], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should handle query parameters', () => { - expect(spectator.component.zones()).toEqual([]); - spectator.setRouteQueryParam('zone_ids', 'zone-1234,zone-2345'); - spectator.detectChanges(); - expect(spectator.component.zones()).toEqual(['zone-1234', 'zone-2345']); - }); - - it('should allow user to make new bookings', () => { - spectator.click('button[new]'); - expect( - spectator.inject(EventsStateService).newBooking, - ).toHaveBeenCalled(); - }); -}); diff --git a/apps/concierge/src/tests/day-view/event-state.service.spec.ts b/apps/concierge/src/tests/day-view/event-state.service.spec.ts index 8e330f8c67d..0f2249d963e 100644 --- a/apps/concierge/src/tests/day-view/event-state.service.spec.ts +++ b/apps/concierge/src/tests/day-view/event-state.service.spec.ts @@ -5,7 +5,7 @@ import { createServiceFactory, SpectatorService, } from '@ngneat/spectator/vitest'; -import { SpacesService } from '@placeos/events'; +import { EventDetailsModalComponent, SpacesService } from '@placeos/events'; import { endOfDay, endOfMonth, @@ -19,6 +19,7 @@ import { EventsStateService } from '../../app/day-view/events-state.service'; import * as ts_client from '@placeos/ts-client'; import { MockProvider } from 'ng-mocks'; +import { Subject } from 'rxjs'; vi.mock('@placeos/ts-client', { spy: true }); @@ -219,6 +220,39 @@ describe('EventsStateService', () => { ]); }); + it('should open booking details and pass removal to the caller', () => { + const dialog = spectator.inject(MatDialog); + const ref = { + componentInstance: { + hide_edit: signal(false), + action: new Subject(), + }, + }; + vi.mocked(dialog.open).mockReturnValue(ref as any); + const remove = vi.fn(); + const event = new CalendarEvent({ id: 'booking' }); + + spectator.service.viewEvent(event, remove); + + expect(dialog.open).toHaveBeenCalledWith(EventDetailsModalComponent, { + data: expect.objectContaining({ event }), + }); + const data = vi.mocked(dialog.open).mock.calls[0][1].data as { + remove_fn: (event: CalendarEvent) => void; + }; + data.remove_fn(event); + expect(remove).toHaveBeenCalledWith(event); + }); + + it('should not open details for system events', () => { + const dialog = spectator.inject(MatDialog); + spectator.service.viewEvent( + { is_system_event: true } as CalendarEvent, + vi.fn(), + ); + expect(dialog.open).not.toHaveBeenCalled(); + }); + it('should load building events when no levels are selected', async () => { spectator.service.startPolling('day'); await settle(); diff --git a/apps/concierge/src/tests/day-view/room-timeline-inverted.component.spec.ts b/apps/concierge/src/tests/day-view/room-timeline-inverted.component.spec.ts index a288a067375..f468c773c65 100644 --- a/apps/concierge/src/tests/day-view/room-timeline-inverted.component.spec.ts +++ b/apps/concierge/src/tests/day-view/room-timeline-inverted.component.spec.ts @@ -39,6 +39,7 @@ describe('RoomBookingsInvertedTimelineComponent', () => { replace: vi.fn(), remove: vi.fn(), restore: vi.fn(), + viewEvent: vi.fn(), }, }, MockProvider(SettingsService, { @@ -108,16 +109,13 @@ describe('RoomBookingsInvertedTimelineComponent', () => { expect(spectator.component.endToOffset(1440)).toBe(100); }); - it('should not open a dialog for system events', () => { - spectator.component.viewEvent({ is_system_event: true } as any, 'sp1'); - expect(dialog_open).not.toHaveBeenCalled(); - }); - - it('should open the details dialog for standard events', () => { - spectator.component.viewEvent( - { is_system_event: false, id: 'e1' } as any, - 'sp1', + it('should open booking details through the events state service', () => { + const state = spectator.inject(EventsStateService); + const event = { id: 'e1' } as any; + spectator.component.viewEvent(event, 'sp1'); + expect(state.viewEvent).toHaveBeenCalledWith( + event, + expect.any(Function), ); - expect(dialog_open).toHaveBeenCalled(); }); }); diff --git a/apps/concierge/src/tests/day-view/room-timeline.component.spec.ts b/apps/concierge/src/tests/day-view/room-timeline.component.spec.ts index 74f001c181c..fb17c6dc595 100644 --- a/apps/concierge/src/tests/day-view/room-timeline.component.spec.ts +++ b/apps/concierge/src/tests/day-view/room-timeline.component.spec.ts @@ -39,6 +39,7 @@ describe('RoomBookingsTimelineComponent', () => { replace: vi.fn(), remove: vi.fn(), restore: vi.fn(), + viewEvent: vi.fn(), }, }, MockProvider(SettingsService, { @@ -151,16 +152,13 @@ describe('RoomBookingsTimelineComponent', () => { expect(spectator.component.is_today()).toBe(false); }); - it('should not open a dialog for system events', () => { - spectator.component.viewEvent({ is_system_event: true } as any, 'sp1'); - expect(dialog_open).not.toHaveBeenCalled(); - }); - - it('should open the details dialog for standard events', () => { - spectator.component.viewEvent( - { is_system_event: false, id: 'e1' } as any, - 'sp1', + it('should open booking details through the events state service', () => { + const state = spectator.inject(EventsStateService); + const event = { id: 'e1' } as any; + spectator.component.viewEvent(event, 'sp1'); + expect(state.viewEvent).toHaveBeenCalledWith( + event, + expect.any(Function), ); - expect(dialog_open).toHaveBeenCalled(); }); }); diff --git a/apps/concierge/src/tests/day-view/room-week-timeline.component.spec.ts b/apps/concierge/src/tests/day-view/room-week-timeline.component.spec.ts index 665a5d5f5a0..c1027c0afca 100644 --- a/apps/concierge/src/tests/day-view/room-week-timeline.component.spec.ts +++ b/apps/concierge/src/tests/day-view/room-week-timeline.component.spec.ts @@ -1,5 +1,4 @@ import { signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { createRoutingFactory, SpectatorRouting, @@ -13,7 +12,7 @@ import { import { UserPipe } from '@placeos/users'; import { setHours, startOfDay, subWeeks } from 'date-fns'; import { MockPipe, MockProvider } from 'ng-mocks'; -import { Subject, Subscription } from 'rxjs'; +import { Subscription } from 'rxjs'; import { EventsStateService } from '../../app/day-view/events-state.service'; import { RoomWeekBookingsTimelineComponent } from '../../app/day-view/room-week-timeline.component'; @@ -32,7 +31,6 @@ describe('RoomWeekBookingsTimelineComponent', () => { }), ); const MockUserPipe = MockPipe(UserPipe, user_pipe_transform); - let dialog_open: any; const createComponent = createRoutingFactory({ component: RoomWeekBookingsTimelineComponent, @@ -60,6 +58,7 @@ describe('RoomWeekBookingsTimelineComponent', () => { removeBooking: vi.fn(), poll: vi.fn(() => new Subscription()), replace: vi.fn(), + viewEvent: vi.fn(), }, }, MockProvider(SettingsService, { @@ -80,16 +79,7 @@ describe('RoomWeekBookingsTimelineComponent', () => { filtered.set([]); zones.set([]); date.set(startOfDay(Date.now()).valueOf()); - dialog_open = vi.fn(() => ({ - componentInstance: { - hide_edit: signal(false), - action: new Subject(), - }, - afterClosed: () => new Subject(), - })); - spectator = createComponent({ - providers: [MockProvider(MatDialog, { open: dialog_open })], - }); + spectator = createComponent(); spectator.detectChanges(); }); @@ -221,16 +211,13 @@ describe('RoomWeekBookingsTimelineComponent', () => { expect(spectator.component.this_week()).toBe(false); }); - it('should not open a dialog for system events', () => { - spectator.component.viewEvent({ is_system_event: true } as any, 'sp1'); - expect(dialog_open).not.toHaveBeenCalled(); - }); - - it('should open the details dialog for standard events', () => { - spectator.component.viewEvent( - { is_system_event: false, id: 'e1' } as any, - 'sp1', + it('should open booking details through the events state service', () => { + const state = spectator.inject(EventsStateService); + const event = { id: 'e1' } as any; + spectator.component.viewEvent(event); + expect(state.viewEvent).toHaveBeenCalledWith( + event, + expect.any(Function), ); - expect(dialog_open).toHaveBeenCalled(); }); }); diff --git a/apps/concierge/src/tests/deals/deals.component.spec.ts b/apps/concierge/src/tests/deals/deals.component.spec.ts deleted file mode 100644 index 82266c176e4..00000000000 --- a/apps/concierge/src/tests/deals/deals.component.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; - -import { DealsComponent } from '../../app/deals/deals.component'; -import { ApplicationSidebarComponent } from '../../app/ui/app-sidebar.component'; - -describe('DealsComponent', () => { - let spectator: SpectatorRouting; - const createComponent = createRoutingFactory({ - component: DealsComponent, - declarations: [MockComponent(ApplicationSidebarComponent)], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should derive the trailing path segment from the router url', () => { - Object.defineProperty(spectator.router, 'url', { - value: '/deals-n-offers/manage', - configurable: true, - }); - (spectator.component as any)._url.set(Date.now()); - expect(spectator.component.path()).toBe('manage'); - }); -}); diff --git a/apps/concierge/src/tests/email-templates/email-templates-state.service.spec.ts b/apps/concierge/src/tests/email-templates/email-templates-state.service.spec.ts index 6ec87b6c869..e78d0c2681a 100644 --- a/apps/concierge/src/tests/email-templates/email-templates-state.service.spec.ts +++ b/apps/concierge/src/tests/email-templates/email-templates-state.service.spec.ts @@ -208,14 +208,17 @@ describe('EmailTemplatesStateService', () => { it('should not write templates when the zone list cannot be read', async () => { (ts_client.showMetadata as any).mockRejectedValue('offline'); - await expect( - spectator.service.removeTemplate({ - id: 'drop', - zone_id: 'bld-1', - } as EmailTemplate), - ).rejects.toBe('offline'); + await spectator.service.removeTemplate({ + id: 'drop', + zone_id: 'bld-1', + } as EmailTemplate); expect(ts_client.updateMetadata).not.toHaveBeenCalled(); + expect(notify_open).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ panelClass: ['error'] }), + ); }); it('should keep the template when removal is cancelled', async () => { diff --git a/apps/concierge/src/tests/email-templates/email-templates.component.spec.ts b/apps/concierge/src/tests/email-templates/email-templates.component.spec.ts deleted file mode 100644 index 6e1b4fe56ad..00000000000 --- a/apps/concierge/src/tests/email-templates/email-templates.component.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Router } from '@angular/router'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent, MockProvider } from 'ng-mocks'; -import { Subject } from 'rxjs'; - -import { EmailTemplatesComponent } from '../../app/email-templates/email-templates.component'; -import { ApplicationSidebarComponent } from '../../app/ui/app-sidebar.component'; - -describe('EmailTemplatesComponent', () => { - let spectator: Spectator; - const router_events = new Subject(); - let router_url: string; - - const createComponent = createComponentFactory({ - component: EmailTemplatesComponent, - shallow: true, - detectChanges: false, - declarations: [MockComponent(ApplicationSidebarComponent)], - providers: [ - MockProvider(Router, { - events: router_events, - get url() { - return router_url; - }, - }), - ], - }); - - beforeEach(() => { - router_url = '/email-templates/manage/template-1?ref=list'; - spectator = createComponent(); - }); - - it('should derive the last path segment from the router url', () => { - router_events.next({}); - expect(spectator.component.path()).toBe('template-1'); - }); - - it('should strip query parameters from the current path', () => { - router_url = '/email-templates/manage?foo=bar'; - router_events.next({}); - expect(spectator.component.path()).toBe('manage'); - }); -}); diff --git a/apps/concierge/src/tests/events/event-manage.component.spec.ts b/apps/concierge/src/tests/events/event-manage.component.spec.ts index 6581986d127..4a5e4b1c0ff 100644 --- a/apps/concierge/src/tests/events/event-manage.component.spec.ts +++ b/apps/concierge/src/tests/events/event-manage.component.spec.ts @@ -98,18 +98,6 @@ describe('EventManageComponent', () => { expect(spectator.component.tag_list()).toEqual([]); }); - it('should filter timezones by the model value', () => { - spectator.component.model.update((m) => ({ - ...m, - timezone: 'Sydney', - })); - const list = spectator.component.filtered_timezones(); - expect(list.length).toBeGreaterThan(0); - expect(list.every((tz) => tz.toLowerCase().includes('sydney'))).toBe( - true, - ); - }); - it('should set the level zone through the form service', () => { spectator.component.setLevel({ id: 'lvl-9' } as any); expect(set_options).toHaveBeenCalledWith({ zones: ['lvl-9'] }); diff --git a/apps/concierge/src/tests/events/event-state.service.spec.ts b/apps/concierge/src/tests/events/event-state.service.spec.ts index bd932774b8d..c3d640d7842 100644 --- a/apps/concierge/src/tests/events/event-state.service.spec.ts +++ b/apps/concierge/src/tests/events/event-state.service.spec.ts @@ -7,9 +7,11 @@ import { Building, OrganisationService, SettingsService, + setNotifyOutlet, } from '@placeos/common'; import { endOfDay, startOfDay } from 'date-fns'; import { MockProvider } from 'ng-mocks'; +import { NEVER, of } from 'rxjs'; import { EventStateService } from '../../app/events/event-state.service'; vi.mock('@placeos/ts-client', { spy: true }); @@ -55,6 +57,40 @@ describe('EventStateService', () => { vi.clearAllMocks(); }); + it('should keep the event details open when removal fails', async () => { + setNotifyOutlet( + { + open: vi.fn(() => ({ + onAction: () => ({ subscribe: () => undefined }), + dismiss: () => undefined, + })), + } as any, + true, + ); + const details = { close: vi.fn() }; + const confirm = { + componentInstance: { event: NEVER, loading: { set: vi.fn() } }, + afterClosed: () => of({ reason: 'done' }), + close: vi.fn(), + }; + const open = vi.mocked(TestBed.inject(MatDialog).open); + open.mockReturnValueOnce(details as any); + open.mockReturnValueOnce(confirm as any); + vi.mocked(ts_client.del).mockRejectedValueOnce( + new Error('Server down'), + ); + + spectator.service.viewEvent({ id: 'evt-1', title: 'Event' } as any); + const data = open.mock.calls[0][1].data as { + remove_fn: () => Promise; + }; + await data.remove_fn(); + + expect(confirm.close).toHaveBeenCalled(); + expect(details.close).not.toHaveBeenCalled(); + setNotifyOutlet(null, true); + }); + it('should not re-request events when options are unchanged', async () => { const date = startOfDay(Date.now()).valueOf(); const end = endOfDay(Date.now()).valueOf(); diff --git a/apps/concierge/src/tests/facilities/facilities.component.spec.ts b/apps/concierge/src/tests/facilities/facilities.component.spec.ts deleted file mode 100644 index 461f28e2ade..00000000000 --- a/apps/concierge/src/tests/facilities/facilities.component.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; - -import { FacilitiesMapComponent } from '../../app/facilities/facilities-map.component'; -import { FacilitiesStatusComponent } from '../../app/facilities/facilities-status.component'; -import { FacilitiesTopbarComponent } from '../../app/facilities/facilities-topbar.component'; -import { FacilitiesComponent } from '../../app/facilities/facilities.component'; -import { ApplicationSidebarComponent } from '../../app/ui/app-sidebar.component'; - -describe('FacilitiesComponent', () => { - let spectator: SpectatorRouting; - const createComponent = createRoutingFactory({ - component: FacilitiesComponent, - declarations: [ - MockComponent(ApplicationSidebarComponent), - MockComponent(FacilitiesTopbarComponent), - MockComponent(FacilitiesMapComponent), - MockComponent(FacilitiesStatusComponent), - ], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should derive the trailing path segment from the router url', () => { - Object.defineProperty(spectator.router, 'url', { - value: '/facilities', - configurable: true, - }); - (spectator.component as any)._url.set(Date.now()); - expect(spectator.component.path()).toBe('facilities'); - }); -}); diff --git a/apps/concierge/src/tests/level-manager/level-list.component.spec.ts b/apps/concierge/src/tests/level-manager/level-list.component.spec.ts index af6b802780d..adeb2f79011 100644 --- a/apps/concierge/src/tests/level-manager/level-list.component.spec.ts +++ b/apps/concierge/src/tests/level-manager/level-list.component.spec.ts @@ -87,7 +87,7 @@ describe('LevelListComponent', () => { }); it('should open the booking panel settings modal for the level', async () => { - const level = { id: 'lvl-1' }; + const level = { id: 'lvl-1', name: 'Level 1' }; await spectator.component.editBookingPanelSettings(level); expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( BookingPanelSettingsModalComponent, diff --git a/apps/concierge/src/tests/lockers/locker-list.component.spec.ts b/apps/concierge/src/tests/lockers/locker-list.component.spec.ts index 901eca24d5e..0ec955c1311 100644 --- a/apps/concierge/src/tests/lockers/locker-list.component.spec.ts +++ b/apps/concierge/src/tests/lockers/locker-list.component.spec.ts @@ -26,21 +26,8 @@ describe('LockerListComponent', () => { clipboard = { copy: vi.fn(() => true) }; state = { filtered_banks: signal([]), - filtered_lockers: signal([ - { id: 'l1', assigned_to: 'a@x.com' }, - { id: 'l2', assigned_to: 'b@x.com' }, - { id: 'l3', assigned_to: 'c@x.com' }, - { id: 'l4', assigned_to: '' }, - { id: 'l5', assigned_to: '' }, - ]), - filters: signal({}), loading: signal(''), search: signal(''), - bookings: signal([ - { asset_id: 'l2', user_email: 'b@x.com', status: 'approved' }, - { asset_id: 'l3', user_email: 'd@x.com', status: 'approved' }, - { asset_id: 'l4', user_email: 'e@x.com', status: 'approved' }, - ]), has_driver: true, editLocker: vi.fn(), editLockerBank: vi.fn(), @@ -69,27 +56,6 @@ describe('LockerListComponent', () => { expect(clipboard.copy).toHaveBeenCalledWith('locker-id'); }); - it('should classify locker occupancy statuses', () => { - const status = spectator.component.locker_status(); - expect(status['l1']).toBe('assigned_free'); - expect(status['l2']).toBe('assigned_busy'); - expect(status['l3']).toBe('reuse_busy'); - expect(status['l4']).toBe('busy'); - expect(status['l5']).toBe('free'); - }); - - it('should map statuses to their tooltip keys', () => { - expect(spectator.component.statusTooltip('assigned_busy')).toBe( - 'APP.CONCIERGE.LOCKERS_STATUS_ASSIGNED_BUSY', - ); - expect(spectator.component.statusTooltip('busy')).toBe( - 'APP.CONCIERGE.LOCKERS_STATUS_BUSY', - ); - expect(spectator.component.statusTooltip('anything-else')).toBe( - 'APP.CONCIERGE.LOCKERS_STATUS_FREE', - ); - }); - it('should release lockers via the state service with force flag', () => { const locker = { id: 'l1' }; spectator.component.releaseLocker(locker); diff --git a/apps/concierge/src/tests/lockers/lockers.component.spec.ts b/apps/concierge/src/tests/lockers/lockers.component.spec.ts index eb8f5e78490..e55341a9224 100644 --- a/apps/concierge/src/tests/lockers/lockers.component.spec.ts +++ b/apps/concierge/src/tests/lockers/lockers.component.spec.ts @@ -1,9 +1,6 @@ import { signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { SettingsService } from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { LockersComponent } from '../../app/lockers/lockers.component'; @@ -20,28 +17,16 @@ describe('LockersComponent', () => { overrideComponents: [ [LockersComponent, { set: { template: '', imports: [] } }], ], - providers: [ - MockProvider(ActivatedRoute, {}), - MockProvider(MatDialog, { open: vi.fn() }), - MockProvider(SettingsService, { - get: vi.fn(() => false), - } as any), - ], }); beforeEach(() => { router = { events: of({}), url: '/lockers/events', - navigate: vi.fn(), }; state = { loading: signal(''), - filters: signal({}), - levels: signal([]), refresh: vi.fn(), - setFilters: vi.fn(), - rejectAllLockers: vi.fn(), }; spectator = createComponent({ providers: [ @@ -59,26 +44,4 @@ describe('LockersComponent', () => { spectator.component.ngOnInit(); expect(state.refresh).toHaveBeenCalledTimes(1); }); - - it('should navigate with zone query params and update filters', () => { - spectator.component.updateZones(['z1', 'z2']); - - expect(router.navigate).toHaveBeenCalledWith( - [], - expect.objectContaining({ - queryParams: { zone_ids: 'z1,z2' }, - queryParamsHandling: 'merge', - }), - ); - expect(state.setFilters).toHaveBeenCalledWith({ zones: ['z1', 'z2'] }); - }); - - it('should open the booking rules modal for lockers', () => { - spectator.component.manageRestrictions(); - - expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( - expect.anything(), - { data: { type: 'locker' } }, - ); - }); }); diff --git a/apps/concierge/src/tests/map-manager/map-management.service.spec.ts b/apps/concierge/src/tests/map-manager/map-management.service.spec.ts index cd3b4294e12..fe4597f5aa4 100644 --- a/apps/concierge/src/tests/map-manager/map-management.service.spec.ts +++ b/apps/concierge/src/tests/map-manager/map-management.service.spec.ts @@ -103,9 +103,6 @@ describe('MapManagementService', () => { ); const file: File = uploads.uploadFileToCompletion.mock.calls[0][0]; expect(file.name).toBe('old.svg'); - expect( - spectator.service.mapName('/api/engine/v2/uploads/upload-9/url'), - ).toBe('old.svg'); expect(file.type).toBe('image/svg+xml'); expect(saved.upload_id).toBe('upload-9'); expect(saved.map_id).toBe('/api/engine/v2/uploads/upload-9/url'); diff --git a/apps/concierge/src/tests/parking/parking-requests-list.component.spec.ts b/apps/concierge/src/tests/parking/parking-requests-list.component.spec.ts deleted file mode 100644 index 80af2e69389..00000000000 --- a/apps/concierge/src/tests/parking/parking-requests-list.component.spec.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { signal } from '@angular/core'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { Booking, SettingsService } from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; - -import { ParkingRequestsListComponent } from '../../app/parking/parking-requests-list.component'; -import { ParkingStateService } from '../../app/parking/parking-state.service'; - -describe('ParkingRequestsListComponent', () => { - let spectator: Spectator; - let bookings: Booking[] = []; - let show_waitlist = true; - let timezone = 'Australia/Perth'; - let request_filter: 'all' | 'pending' | 'waitlist' = 'all'; - - const createComponent = createComponentFactory({ - component: ParkingRequestsListComponent, - providers: [ - MockProvider(ParkingStateService, { - load_error: signal(false), - bookings: (() => bookings) as any, - options: (() => ({ - date: Date.now(), - search: '', - zones: [], - period: 'day', - request_filter, - })) as any, - loading: (() => []) as any, - period: (() => 'day') as any, - has_more_pages: (() => false) as any, - last_updated: (() => 0) as any, - nextPage: vi.fn(), - refresh: vi.fn(), - filterEventSearch: vi.fn((list: Booking[]) => list), - rejectBooking: vi.fn(), - approveBooking: vi.fn(), - editReservation: vi.fn(), - assignSpace: vi.fn(), - canApproveBooking: vi.fn(() => true), - get timezone() { - return timezone; - }, - week_start: 1, - }), - MockProvider(SettingsService as any, { - get: vi.fn((name: string) => - name === 'app.parking.show_waitlist' - ? show_waitlist - : false, - ), - signal: vi.fn((_: string, initial: boolean) => signal(initial)), - time_format: 'h:mm a', - }), - ], - }); - - beforeEach(() => { - bookings = []; - show_waitlist = true; - timezone = 'Australia/Perth'; - request_filter = 'all'; - }); - - it('should expose the parking display timezone', () => { - timezone = 'Australia/Perth'; - spectator = createComponent(); - - expect(spectator.component.timezone).toBe('Australia/Perth'); - }); - - it('should read the created time of a request from the booking', () => { - spectator = createComponent(); - const created_at = 1_780_000_000; - - expect( - spectator.component.request_submitted_at( - new Booking({ created_at }), - ), - ).toBe(created_at * 1000); - }); - - it('should hide waitlisted status when waitlist display is disabled', () => { - show_waitlist = false; - spectator = createComponent(); - - expect( - spectator.component.statusTone({ - id: 'request-1', - asset_id: 'unallocated-1', - approved: false, - status: 'tentative', - process_state: 'wait_list', - date: Date.now(), - } as Booking), - ).toBe('warning'); - }); - - it('should keep current week tentative requests in pending results when waitlist display is disabled', () => { - show_waitlist = false; - request_filter = 'pending'; - bookings = [ - { - id: 'request-1', - asset_id: 'unallocated-1', - status: 'tentative', - date: Date.now(), - date_end: Date.now() + 60 * 60 * 1000, - duration: 60, - extension_data: {}, - } as Booking, - ]; - - spectator = createComponent(); - - expect(spectator.component.filtered_events()).toHaveLength(1); - expect(spectator.component.filtered_events()[0].id).toBe('request-1'); - }); -}); diff --git a/apps/concierge/src/tests/parking/parking-requests-week-view.component.spec.ts b/apps/concierge/src/tests/parking/parking-requests-week-view.component.spec.ts deleted file mode 100644 index fb768200775..00000000000 --- a/apps/concierge/src/tests/parking/parking-requests-week-view.component.spec.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { signal } from '@angular/core'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { parkingRequestStatus } from '@placeos/bookings'; -import { Booking, SettingsService } from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; - -import { ParkingRequestsWeekViewComponent } from '../../app/parking/parking-requests-week-view.component'; -import { ParkingStateService } from '../../app/parking/parking-state.service'; - -describe('ParkingRequestsWeekViewComponent', () => { - let spectator: Spectator; - let bookings: Booking[] = []; - let show_waitlist = true; - const options = signal({ - date: Date.now(), - search: '', - zones: [], - period: 'week', - request_filter: 'all', - }); - - const createComponent = createComponentFactory({ - component: ParkingRequestsWeekViewComponent, - shallow: true, - providers: [ - MockProvider(ParkingStateService, { - loading: signal([]), - options: options, - bookings: (() => bookings) as any, - week_start: 1, - startPolling: vi.fn(() => () => null), - filterEventSearch: vi.fn((list: Booking[]) => list), - rejectBooking: vi.fn(), - approveBooking: vi.fn(), - editReservation: vi.fn(), - assignSpace: vi.fn(), - canApproveBooking: vi.fn(() => true), - isWaitlisted: vi.fn( - (b: Booking) => - b.status === 'tentative' && - parkingRequestStatus(b) === 'waitlist', - ), - isManualRequest: vi.fn( - (b: Booking) => - b.status === 'tentative' && - parkingRequestStatus(b) === 'approval_required', - ), - timezone: 'Australia/Perth', - } as any), - MockProvider(SettingsService as any, { - get: vi.fn((name: string) => - name === 'app.parking.show_waitlist' - ? show_waitlist - : false, - ), - time_format: 'h:mm a', - }), - ], - }); - - beforeEach(() => { - bookings = []; - show_waitlist = true; - options.set({ - date: Date.now(), - search: '', - zones: [], - period: 'week', - request_filter: 'all', - }); - }); - - it('should only include unallocated request bookings', () => { - const today = Date.now(); - bookings = [ - { - id: 'request-1', - asset_id: 'unallocated-1', - status: 'tentative', - date: today, - } as Booking, - { - id: 'booking-1', - asset_id: 'bay-1', - status: 'approved', - date: today, - } as Booking, - ]; - - spectator = createComponent(); - - const grouped = spectator.component.grouped_requests(); - const all_ids = Object.values(grouped) - .flat() - .map((b) => b.id); - expect(all_ids).toContain('request-1'); - expect(all_ids).not.toContain('booking-1'); - }); - - it('should keep only unapproved requests under the waitlist filter', () => { - const today = Date.now(); - options.set({ ...options(), request_filter: 'waitlist' }); - bookings = [ - { - id: 'waitlisted', - asset_id: 'unallocated-1', - approved: false, - status: 'tentative', - process_state: 'wait_list', - date: today, - } as Booking, - { - id: 'new-request', - asset_id: 'unallocated-2', - status: 'tentative', - date: today, - } as Booking, - ]; - - spectator = createComponent(); - - const all_ids = Object.values(spectator.component.grouped_requests()) - .flat() - .map((b) => b.id); - expect(all_ids).toContain('waitlisted'); - expect(all_ids).not.toContain('new-request'); - }); - - it('should surface requests without a process state under the pending filter', () => { - const today = Date.now(); - options.set({ ...options(), request_filter: 'pending', date: today }); - bookings = [ - { - id: 'new-request', - asset_id: 'unallocated-2', - status: 'tentative', - date: today, - } as Booking, - ]; - - spectator = createComponent(); - - const all_ids = Object.values(spectator.component.grouped_requests()) - .flat() - .map((b) => b.id); - expect(all_ids).toContain('new-request'); - }); - - it('should style requests by their request status', () => { - spectator = createComponent(); - const waitlisted = { - approved: false, - status: 'tentative', - process_state: 'wait_list', - date: Date.now(), - } as Booking; - const manual = { - approved: false, - status: 'tentative', - extension_data: { requires_manual_approval: true }, - date: Date.now(), - } as unknown as Booking; - const pending = { status: 'tentative', date: Date.now() } as Booking; - - expect(spectator.component.statusTone(waitlisted)).toBe('info'); - expect(spectator.component.statusTone(manual)).toBe('approval'); - expect(spectator.component.statusTone(pending)).toBe('warning'); - }); - - it('should hide waitlisted styling when the waitlist display is disabled', () => { - show_waitlist = false; - spectator = createComponent(); - const booking = { - approved: false, - status: 'tentative', - process_state: 'wait_list', - date: Date.now(), - } as Booking; - - expect(spectator.component.statusTone(booking)).toBe('warning'); - }); -}); diff --git a/apps/concierge/src/tests/parking/parking-special-request-modal.component.spec.ts b/apps/concierge/src/tests/parking/parking-special-request-modal.component.spec.ts deleted file mode 100644 index 584f95fc8a4..00000000000 --- a/apps/concierge/src/tests/parking/parking-special-request-modal.component.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { Booking } from '@placeos/common'; - -import { ParkingSpecialRequestModalComponent } from '../../app/parking/parking-special-request-modal.component'; - -describe('ParkingSpecialRequestModalComponent', () => { - let spectator: Spectator; - let booking: Partial; - - const createComponent = createComponentFactory({ - component: ParkingSpecialRequestModalComponent, - shallow: true, - providers: [ - { - provide: MAT_DIALOG_DATA, - useFactory: () => ({ booking }), - }, - ], - }); - - it('should read the reason from the booking extension notes', () => { - booking = { - extension_data: { notes: ' Needs wheelchair access ' }, - description: 'ignored', - }; - - spectator = createComponent(); - - expect(spectator.component.request_notes).toBe( - 'Needs wheelchair access', - ); - }); - - it('should fall back to the booking description when no notes exist', () => { - booking = { - extension_data: {}, - description: 'Fallback reason', - }; - - spectator = createComponent(); - - expect(spectator.component.request_notes).toBe('Fallback reason'); - }); - - it('should map attachment urls into named links and drop empty entries', () => { - booking = { - extension_data: { - attachments: [ - 'https://files.example.com/docs/Doctor%20Note.pdf', - '', - ], - }, - }; - - spectator = createComponent(); - - expect(spectator.component.attachments).toEqual([ - { - url: 'https://files.example.com/docs/Doctor%20Note.pdf', - name: 'Doctor Note.pdf', - }, - ]); - }); - - it('should expose an empty attachment list when none are provided', () => { - booking = { extension_data: {} }; - - spectator = createComponent(); - - expect(spectator.component.attachments).toEqual([]); - }); -}); diff --git a/apps/concierge/src/tests/parking/parking-state.service.spec.ts b/apps/concierge/src/tests/parking/parking-state.service.spec.ts index 5f4250c1d4f..ba6709b75d7 100644 --- a/apps/concierge/src/tests/parking/parking-state.service.spec.ts +++ b/apps/concierge/src/tests/parking/parking-state.service.spec.ts @@ -9,6 +9,8 @@ import { Booking, OrganisationService, SettingsService, + StaffUser, + setCurrentUser, setNotifyOutlet, setTimeInTimezone, } from '@placeos/common'; @@ -175,6 +177,7 @@ describe('ParkingStateService', () => { afterEach(() => { setNotifyOutlet(null, true); + setCurrentUser(new StaffUser({})); vi.restoreAllMocks(); }); @@ -807,6 +810,29 @@ describe('ParkingStateService', () => { ).toEqual([request]); }); + it('should hide parking requests from users outside the request groups', () => { + settings_map['app.parking.show_requests'] = true; + settings_map['app.feature_groups'] = { + 'parking-requests': ['parking-team'], + }; + setCurrentUser(new StaffUser({ groups: ['staff'] })); + expect(spectator.service.can_view_requests).toBe(false); + + setCurrentUser(new StaffUser({ groups: ['parking-team'] })); + expect(spectator.service.can_view_requests).toBe(true); + }); + + it('should show parking requests to admins only when requests are enabled', () => { + settings_map['app.feature_groups'] = { + 'parking-requests': ['parking-team'], + }; + setCurrentUser(new StaffUser({ groups: ['admin'] })); + expect(spectator.service.can_view_requests).toBe(false); + + settings_map['app.parking.show_requests'] = true; + expect(spectator.service.can_view_requests).toBe(true); + }); + it('should fall back to request filtering when waitlist display is disabled', () => { settings_map['app.parking.show_requests'] = true; settings_map['app.parking.show_waitlist'] = false; diff --git a/apps/concierge/src/tests/parking/parking.component.spec.ts b/apps/concierge/src/tests/parking/parking.component.spec.ts index d17f5a49d68..1d86129236f 100644 --- a/apps/concierge/src/tests/parking/parking.component.spec.ts +++ b/apps/concierge/src/tests/parking/parking.component.spec.ts @@ -4,7 +4,7 @@ import { SpectatorRouting, createRoutingFactory, } from '@ngneat/spectator/vitest'; -import { SettingsService, StaffUser, setCurrentUser } from '@placeos/common'; +import { SettingsService } from '@placeos/common'; import { MockComponent, MockProvider } from 'ng-mocks'; import { ParkingStateService } from '../../app/parking/parking-state.service'; @@ -25,6 +25,7 @@ describe('ParkingComponent', () => { org_refreshing: signal(false), startPolling: vi.fn(), setOptions: vi.fn(), + can_view_requests: false, } as any), MockProvider(SettingsService, { get: vi.fn((name: string) => settings_map[name]), @@ -38,22 +39,15 @@ describe('ParkingComponent', () => { beforeEach(() => { settings_map = { - 'app.parking.show_requests': true, 'app.parking.hide_users': false, 'app.parking.hide_vehicles': false, 'app.parking.hide_users_and_vehicles': false, - 'app.feature_groups': { 'parking-requests': ['parking-team'] }, - 'app.admin_group': 'admin', }; - // currentUser() is a workspace-lib fn (not spyable under the bundling - // builder); seed the real user store instead. - setCurrentUser(new StaffUser({ groups: ['staff'] })); spectator = createComponent(); }); afterEach(() => { vi.restoreAllMocks(); - setCurrentUser(new StaffUser({ groups: ['staff'] })); }); it('should not show the no parking floors message while org data refreshes', () => { @@ -69,16 +63,6 @@ describe('ParkingComponent', () => { expect(overlay()).not.toBeNull(); }); - it('should hide requests from users outside the configured group', () => { - expect(spectator.component.can_view_requests).toBe(false); - }); - - it('should allow admins to view requests', () => { - setCurrentUser(new StaffUser({ groups: ['admin'] })); - - expect(spectator.component.can_view_requests).toBe(true); - }); - it('should redirect blocked users from the requests route', () => { const router = spectator.inject(Router); const state = spectator.inject(ParkingStateService); diff --git a/apps/concierge/src/tests/region-manager/region-list.component.spec.ts b/apps/concierge/src/tests/region-manager/region-list.component.spec.ts index 116eb85e088..e0557a56d30 100644 --- a/apps/concierge/src/tests/region-manager/region-list.component.spec.ts +++ b/apps/concierge/src/tests/region-manager/region-list.component.spec.ts @@ -1,6 +1,5 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { signal } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockProvider } from 'ng-mocks'; @@ -8,7 +7,6 @@ import { setNotifyOutlet } from '@placeos/common'; import { RegionListComponent } from '../../app/region-manager/region-list.component'; import { RegionManagementService } from '../../app/region-manager/region-management.service'; -import { ConciergeSettingsFormModalComponent } from '../../app/ui/app-settings/concierge-settings-form-modal.component'; describe('RegionListComponent', () => { let spectator: Spectator; @@ -23,7 +21,6 @@ describe('RegionListComponent', () => { providers: [ MockProvider(RegionManagementService, {}), MockProvider(Clipboard, {}), - MockProvider(MatDialog, { open: vi.fn() }), ], }); @@ -43,7 +40,6 @@ describe('RegionListComponent', () => { providers: [ { provide: RegionManagementService, useValue: manager }, { provide: Clipboard, useValue: { copy } }, - { provide: MatDialog, useValue: { open: vi.fn() } }, ], }); }); @@ -75,13 +71,4 @@ describe('RegionListComponent', () => { spectator.component.copyToClipboard('r1'); expect(notify_open).not.toHaveBeenCalled(); }); - - it('should open the concierge settings modal for the selected zone', async () => { - const zone = { id: 'r1' }; - await spectator.component.editConciergeSettings(zone); - expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( - ConciergeSettingsFormModalComponent, - { data: { zone } }, - ); - }); }); diff --git a/apps/concierge/src/tests/region-manager/region-modal.component.spec.ts b/apps/concierge/src/tests/region-manager/region-modal.component.spec.ts index 00e1753e491..68433d22126 100644 --- a/apps/concierge/src/tests/region-manager/region-modal.component.spec.ts +++ b/apps/concierge/src/tests/region-manager/region-modal.component.spec.ts @@ -70,16 +70,6 @@ describe('RegionModalComponent', () => { expect(spectator.component.model().timezone).toBe('Europe/London'); }); - it('should filter timezones by the current model timezone', () => { - spectator = buildRegion(null)(); - spectator.component.model.update((m) => ({ ...m, timezone: 'london' })); - const filtered = spectator.component.filtered_timezones(); - expect(filtered.length).toBeGreaterThan(0); - expect(filtered.every((_) => _.toLowerCase().includes('london'))).toBe( - true, - ); - }); - it('should block saving and notify when the display name is missing', async () => { spectator = buildRegion(null)(); diff --git a/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts b/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts index 9ad0e0d5f83..669811b2ba3 100644 --- a/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/assets/assets-report.component.spec.ts @@ -1,9 +1,6 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { BehaviorSubject } from 'rxjs'; import { AssetReportDailyUsageComponent } from 'apps/concierge/src/app/reports/assets/asset-report-daily-usage.component'; @@ -15,14 +12,13 @@ import { AssetsReportComponent } from 'apps/concierge/src/app/reports/assets/ass import { AssetsReportService } from 'apps/concierge/src/app/reports/assets/assets-report.service'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent } from 'ng-mocks'; describe('AssetsReportComponent', () => { let spectator: Spectator; let query_params: BehaviorSubject; let stats: ReturnType>; let loading: ReturnType>; - let active_building: ReturnType>; let set_options: unknown; let generate_report: unknown; let download_report: unknown; @@ -37,10 +33,7 @@ describe('AssetsReportComponent', () => { MockComponent(AssetReportProductUsageComponent), MockComponent(AssetReportUsersComponent), MockComponent(AssetReportExpiredItemsComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], - providers: [MockProvider(SettingsService, {})], }); beforeEach(() => { @@ -53,7 +46,6 @@ describe('AssetsReportComponent', () => { ); stats = signal({ total_booked_items: 0 }); loading = signal(false); - active_building = signal({ id: 'building-1' }); set_options = vi.fn(); generate_report = vi.fn(); download_report = vi.fn(); @@ -69,23 +61,6 @@ describe('AssetsReportComponent', () => { downloadReport: download_report, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => { - if (key === 'app.logo_light') - return { src: 'light-logo.svg' }; - if (key === 'app.logo_dark') - return { src: 'dark-logo.svg' }; - return undefined; - }), - }, - }, - { - provide: OrganisationService, - useValue: { active_building }, - }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -95,16 +70,20 @@ describe('AssetsReportComponent', () => { }); it('should apply query params to report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['level-1', 'level-2'], }); }); it('should clear zones when query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should expose total count and has_data from stats', () => { @@ -121,8 +100,4 @@ describe('AssetsReportComponent', () => { expect(generate_report).toHaveBeenCalled(); expect(download_report).toHaveBeenCalled(); }); - - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light-logo.svg' }); - }); }); diff --git a/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts b/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts index 89631bbc0ec..936e870e486 100644 --- a/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/attendance/site-attendance-report.component.spec.ts @@ -3,7 +3,7 @@ import { BehaviorSubject } from 'rxjs'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { OrganisationService } from '@placeos/common'; import { SiteAttendanceReportComponent } from 'apps/concierge/src/app/reports/attendance/site-attendance-report.component'; import { EMPTY_REPORT, @@ -30,16 +30,6 @@ describe('SiteAttendanceReportComponent', () => { downloadReport: vi.fn(), generateReport: vi.fn(), }), - MockProvider(SettingsService, { - theme: 'light', - get: vi.fn((key: string) => { - if (key === 'app.logo_light') - return { src: 'light-logo.svg' }; - if (key === 'app.logo_dark') - return { src: 'dark-logo.svg' }; - return undefined; - }), - } as any), MockProvider(OrganisationService, { initialised: signal(true), waitUntilInitialised: () => Promise.resolve(), @@ -118,13 +108,11 @@ describe('SiteAttendanceReportComponent', () => { it('should apply query params to report options', () => { expect( spectator.inject(SiteAttendanceReportService).setOptions, - ).toHaveBeenCalledWith({ start: 100 }); - expect( - spectator.inject(SiteAttendanceReportService).setOptions, - ).toHaveBeenCalledWith({ end: 200 }); - expect( - spectator.inject(SiteAttendanceReportService).setOptions, - ).toHaveBeenCalledWith({ zones: ['level-1', 'level-2'] }); + ).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: ['level-1', 'level-2'], + }); }); it('should expose report state and has_data from the service', () => { @@ -164,16 +152,12 @@ describe('SiteAttendanceReportComponent', () => { ).toHaveBeenCalled(); }); - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light-logo.svg' }); - }); - it('should clear zones when query params do not include them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); spectator.detectChanges(); expect( spectator.inject(SiteAttendanceReportService).setOptions, - ).toHaveBeenCalledWith({ zones: [] }); + ).toHaveBeenCalledWith({ start: 100, end: 200, zones: [] }); }); }); diff --git a/apps/concierge/src/tests/reports/catering/catering-report.component.spec.ts b/apps/concierge/src/tests/reports/catering/catering-report.component.spec.ts index 4acc6762f32..4f4eff78a8b 100644 --- a/apps/concierge/src/tests/reports/catering/catering-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/catering/catering-report.component.spec.ts @@ -2,13 +2,11 @@ import { createRoutingFactory, SpectatorRouting, } from '@ngneat/spectator/vitest'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Router } from '@angular/router'; import { SettingsService } from '@placeos/common'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { CateringReportItemsComponent } from 'apps/concierge/src/app/reports/catering/catering-report-items.component'; import { CateringReportOrdersComponent } from 'apps/concierge/src/app/reports/catering/catering-report-orders.component'; import { CateringReportOverallComponent } from 'apps/concierge/src/app/reports/catering/catering-report-overall.component'; @@ -25,7 +23,6 @@ describe('CateringReportComponent', () => { MockComponent(CateringReportItemsComponent), MockComponent(CateringReportOrdersComponent), MockComponent(ReportsOptionsComponent), - MockDirective(AuthenticatedImageDirective), ], providers: [ MockProvider(ReportsStateService, { @@ -36,7 +33,6 @@ describe('CateringReportComponent', () => { MockProvider(Router, {}), MockProvider(SettingsService, { get: vi.fn() }), ], - imports: [MatProgressSpinnerModule], }); beforeEach(() => (spectator = createComponent())); diff --git a/apps/concierge/src/tests/reports/contact-tracing/contact-tracing-report.component.spec.ts b/apps/concierge/src/tests/reports/contact-tracing/contact-tracing-report.component.spec.ts index 5a10ddd58e7..a88e7e67892 100644 --- a/apps/concierge/src/tests/reports/contact-tracing/contact-tracing-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/contact-tracing/contact-tracing-report.component.spec.ts @@ -3,7 +3,7 @@ import { signal, WritableSignal } from '@angular/core'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { OrganisationService, SettingsService } from '@placeos/common'; +import { SettingsService } from '@placeos/common'; import { AuthenticatedImageDirective, SimpleTableComponent, @@ -21,7 +21,6 @@ describe('ContactTracingReportComponent', () => { let options: WritableSignal; let events: WritableSignal; let settings_map: Record; - let theme: string; const createComponent = createComponentFactory({ component: ContactTracingReportComponent, declarations: [ @@ -41,16 +40,9 @@ describe('ContactTracingReportComponent', () => { provide: SettingsService, useValue: { get: (key: string) => settings_map[key], - get theme() { - return theme; - }, time_format: 'h:mm a', }, }, - { - provide: OrganisationService, - useValue: { active_building: signal({ id: 'bld-1' }) }, - }, ], imports: [MatProgressSpinnerModule], }); @@ -59,11 +51,7 @@ describe('ContactTracingReportComponent', () => { loading = signal(''); options = signal({ start: 0, end: 0 }); events = signal([]); - theme = 'light'; - settings_map = { - 'app.logo_light': { src: 'light.png' }, - 'app.logo_dark': { src: 'dark.png' }, - }; + settings_map = {}; spectator = createComponent(); }); @@ -89,15 +77,6 @@ describe('ContactTracingReportComponent', () => { expect(spectator.query('simple-table')).toBeFalsy(); }); - it('should pick the logo matching the active theme', () => { - expect(spectator.component.logo()).toEqual({ src: 'light.png' }); - theme = 'dark'; - (spectator.inject(OrganisationService).active_building as any).set({ - id: 'bld-2', - }); - expect(spectator.component.logo()).toEqual({ src: 'dark.png' }); - }); - it('should format durations as hours and minutes', () => { expect(spectator.component.formatDuration(90)).toContain('1'); expect(spectator.component.formatDuration(0)).toBeDefined(); diff --git a/apps/concierge/src/tests/reports/desks/report-desks-charts.component.spec.ts b/apps/concierge/src/tests/reports/desks/report-desks-charts.component.spec.ts deleted file mode 100644 index 12aeafb4212..00000000000 --- a/apps/concierge/src/tests/reports/desks/report-desks-charts.component.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { signal } from '@angular/core'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { MockProvider } from 'ng-mocks'; - -import { ReportDesksChartsComponent } from 'apps/concierge/src/app/reports/desks/report-desks-charts.component'; -import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; - -describe('ReportDesksChartsComponent', () => { - let spectator: Spectator; - let options: ReturnType>; - let counts: ReturnType>; - let day_list: ReturnType>; - - const createComponent = createComponentFactory({ - component: ReportDesksChartsComponent, - providers: [ - MockProvider(ReportsStateService, {}), - MockProvider(SettingsService, { - get: vi.fn(() => false), - } as any), - MockProvider(OrganisationService, { - levelsForBuilding: vi.fn(() => []), - levelsForRegion: vi.fn(() => []), - levelWithID: vi.fn(() => ({ display_name: 'Level 1' })), - } as any), - ], - }); - - beforeEach(() => { - vi.useFakeTimers(); - options = signal({ zones: ['level-1'] }); - counts = signal({ 'level-1': 4 }); - day_list = signal([ - { - date: new Date('2026-04-06T00:00:00').valueOf(), - utilisation: 30, - }, - ]); - spectator = createComponent({ - providers: [ - { - provide: ReportsStateService, - useValue: { options, counts, day_list }, - }, - ], - }); - }); - - afterEach(() => { - vi.clearAllTimers(); - vi.useRealTimers(); - }); - - it('should expose the day list from the state service', () => { - expect(spectator.component.day_list()).toHaveLength(1); - }); - - it('should pair the active options with the resource counts', () => { - expect(spectator.component.stats()).toEqual([ - { zones: ['level-1'] }, - { 'level-1': 4 }, - ]); - }); -}); diff --git a/apps/concierge/src/tests/reports/desks/report-desks-overall-list.component.spec.ts b/apps/concierge/src/tests/reports/desks/report-desks-overall-list.component.spec.ts deleted file mode 100644 index c0f4c9b02a2..00000000000 --- a/apps/concierge/src/tests/reports/desks/report-desks-overall-list.component.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { signal } from '@angular/core'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { IconComponent, SimpleTableComponent } from '@placeos/components'; -import { MockComponent, MockProvider } from 'ng-mocks'; - -import { ReportDesksOverallListComponent } from 'apps/concierge/src/app/reports/desks/report-desks-overall-list.component'; -import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; -import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; - -import { captureDownloads } from '../download-capture.helper'; - -describe('ReportDesksOverallListComponent', () => { - let spectator: Spectator; - let downloads: ReturnType; - let day_list: ReturnType>; - - const createComponent = createComponentFactory({ - component: ReportDesksOverallListComponent, - declarations: [ - MockComponent(SimpleTableComponent), - MockComponent(IconComponent), - MockComponent(ReportMetricGuideComponent), - ], - imports: [MatTooltipModule], - providers: [MockProvider(ReportsStateService, {})], - }); - - afterEach(() => downloads.restore()); - - beforeEach(() => { - downloads = captureDownloads(); - day_list = signal([ - { - date: new Date('2026-04-06T00:00:00').valueOf(), - approved: 3, - count: 5, - cancelled: 1, - deleted: 1, - utilisation: '40.0', - }, - ]); - spectator = createComponent({ - providers: [ - { provide: ReportsStateService, useValue: { day_list } }, - ], - }); - }); - - it('should expose the daily list from the state service', () => { - expect(spectator.component.day_list()).toHaveLength(1); - }); - - it('should format booking percentages against total requests', () => { - expect(spectator.component.formatPercent(1, 5)).toBe('1 (20%)'); - }); - - it('should download the daily usage table on request', async () => { - await spectator.component.download(); - expect(downloads.filename).toBe('desks-usage.csv'); - }); -}); diff --git a/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts b/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts index baf3d1b4d82..848cdc92d96 100644 --- a/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts +++ b/apps/concierge/src/tests/reports/desks/report-desks.component.spec.ts @@ -1,26 +1,22 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { BehaviorSubject } from 'rxjs'; -import { ReportDesksChartsComponent } from 'apps/concierge/src/app/reports/desks/report-desks-charts.component'; import { ReportDesksLevelListComponent } from 'apps/concierge/src/app/reports/desks/report-desks-level-list.component'; -import { ReportDesksOverallListComponent } from 'apps/concierge/src/app/reports/desks/report-desks-overall-list.component'; import { ReportDesksComponent } from 'apps/concierge/src/app/reports/desks/report-desks.component'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; +import { ReportUsageChartsComponent } from 'apps/concierge/src/app/reports/report-usage-charts.component'; +import { ReportUsageListComponent } from 'apps/concierge/src/app/reports/report-usage-list.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent } from 'ng-mocks'; describe('ReportDesksComponent', () => { let spectator: Spectator; let query_params: BehaviorSubject; let stats: ReturnType>; let loading: ReturnType>; - let active_building: ReturnType>; let set_options: unknown; let generate_report: unknown; let download_report: unknown; @@ -30,13 +26,10 @@ describe('ReportDesksComponent', () => { declarations: [ MockComponent(ReportsOptionsComponent), MockComponent(ReportMetricGuideComponent), - MockComponent(ReportDesksChartsComponent), + MockComponent(ReportUsageChartsComponent), MockComponent(ReportDesksLevelListComponent), - MockComponent(ReportDesksOverallListComponent), - MockDirective(AuthenticatedImageDirective), + MockComponent(ReportUsageListComponent), ], - imports: [MatProgressSpinnerModule], - providers: [MockProvider(SettingsService, {})], }); beforeEach(() => { @@ -55,7 +48,6 @@ describe('ReportDesksComponent', () => { utilisation: 0.25, }); loading = signal(''); - active_building = signal({ id: 'building-1' }); set_options = vi.fn(); generate_report = vi.fn(); download_report = vi.fn(); @@ -71,20 +63,6 @@ describe('ReportDesksComponent', () => { downloadReport: download_report, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => { - if (key === 'app.logo_light') - return { src: 'light-logo.svg' }; - if (key === 'app.logo_dark') - return { src: 'dark-logo.svg' }; - return undefined; - }), - }, - }, - { provide: OrganisationService, useValue: { active_building } }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -95,16 +73,20 @@ describe('ReportDesksComponent', () => { it('should set the report type to desks and apply query params', () => { expect(set_options).toHaveBeenCalledWith({ type: 'desks' }); - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['level-1', 'level-2'], }); }); it('should clear zones when query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should derive booking counts and utilisation from stats', () => { @@ -121,8 +103,4 @@ describe('ReportDesksComponent', () => { expect(generate_report).toHaveBeenCalled(); expect(download_report).toHaveBeenCalled(); }); - - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light-logo.svg' }); - }); }); diff --git a/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts b/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts index 66597900251..98456d46312 100644 --- a/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/lockers/lockers-report.component.spec.ts @@ -1,9 +1,6 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { BehaviorSubject } from 'rxjs'; import { LockersReportChartsComponent } from 'apps/concierge/src/app/reports/lockers/lockers-report-charts.component'; @@ -14,14 +11,13 @@ import { LockersReportComponent } from 'apps/concierge/src/app/reports/lockers/l import { LockersReportService } from 'apps/concierge/src/app/reports/lockers/lockers-report.service'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent } from 'ng-mocks'; describe('LockersReportComponent', () => { let spectator: Spectator; let query_params: BehaviorSubject; let bookings: ReturnType>; let loading: ReturnType>; - let active_building: ReturnType>; let set_options: unknown; let generate_report: unknown; let download_report: unknown; @@ -35,10 +31,7 @@ describe('LockersReportComponent', () => { MockComponent(LockersReportOverallComponent), MockComponent(LockersReportDailyUsageComponent), MockComponent(LockersReportListComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], - providers: [MockProvider(SettingsService, {})], }); beforeEach(() => { @@ -51,7 +44,6 @@ describe('LockersReportComponent', () => { ); bookings = signal([]); loading = signal(''); - active_building = signal({ id: 'building-1' }); set_options = vi.fn(); generate_report = vi.fn(); download_report = vi.fn(); @@ -67,20 +59,6 @@ describe('LockersReportComponent', () => { downloadReport: download_report, }, }, - { - provide: SettingsService, - useValue: { - theme: 'dark', - get: vi.fn((key: string) => { - if (key === 'app.logo_light') - return { src: 'light-logo.svg' }; - if (key === 'app.logo_dark') - return { src: 'dark-logo.svg' }; - return undefined; - }), - }, - }, - { provide: OrganisationService, useValue: { active_building } }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -90,16 +68,20 @@ describe('LockersReportComponent', () => { }); it('should apply query params to report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['level-1', 'level-2'], }); }); it('should clear zones when query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should expose the booking count from the service', () => { @@ -114,8 +96,4 @@ describe('LockersReportComponent', () => { expect(generate_report).toHaveBeenCalled(); expect(download_report).toHaveBeenCalled(); }); - - it('should select the dark logo when the theme is dark', () => { - expect(spectator.component.logo()).toEqual({ src: 'dark-logo.svg' }); - }); }); diff --git a/apps/concierge/src/tests/reports/lockers/lockers-report.service.spec.ts b/apps/concierge/src/tests/reports/lockers/lockers-report.service.spec.ts index 62bf7f650d3..4219b4fd504 100644 --- a/apps/concierge/src/tests/reports/lockers/lockers-report.service.spec.ts +++ b/apps/concierge/src/tests/reports/lockers/lockers-report.service.spec.ts @@ -142,7 +142,7 @@ describe('LockersReportService', () => { spectator.service.setOptions({ start: day_1, end: day_1 }); await spectator.service.downloadReport(); - expect(downloads.filename).toMatch(/^report\+assets\+2026-04-06\.tsv$/); + expect(downloads.filename).toMatch(/^report\+lockers\+2026-04-06\.tsv$/); }); it('should count available lockers per zone', async () => { diff --git a/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts b/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts index d57b2612c80..fbb60b8dccc 100644 --- a/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/parking/parking-report.component.spec.ts @@ -1,12 +1,9 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { ParkingReportChartsComponent } from 'apps/concierge/src/app/reports/parking/parking-report-charts.component'; import { ParkingReportDailyUsageComponent } from 'apps/concierge/src/app/reports/parking/parking-report-daily-usage.component'; import { ParkingReportListComponent } from 'apps/concierge/src/app/reports/parking/parking-report-list.component'; @@ -33,9 +30,7 @@ describe('ParkingReportComponent', () => { MockComponent(ParkingReportChartsComponent), MockComponent(ParkingReportListComponent), MockComponent(ParkingReportDailyUsageComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], providers: [ MockProvider(ParkingReportService, { bookings: signal([]), @@ -44,13 +39,6 @@ describe('ParkingReportComponent', () => { generateReport: vi.fn(), downloadReport: vi.fn(), }), - MockProvider(SettingsService, { - theme: 'light', - get: vi.fn(), - }), - MockProvider(OrganisationService, { - active_building: signal({ id: 'building-1' }), - } as any), MockProvider(ActivatedRoute, { queryParamMap: new BehaviorSubject(convertToParamMap({})), }), @@ -81,17 +69,6 @@ describe('ParkingReportComponent', () => { downloadReport: download, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => - key === 'app.logo_light' - ? { src: 'light.svg' } - : undefined, - ), - }, - }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -101,16 +78,20 @@ describe('ParkingReportComponent', () => { }); it('should apply query params to the report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['lvl-1', 'lvl-2'], }); }); it('should clear zones when the query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should expose the booking count as the total', () => { @@ -118,10 +99,6 @@ describe('ParkingReportComponent', () => { expect(spectator.component.total_count()).toBe(2); }); - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light.svg' }); - }); - it('should delegate generate and download to the report service', () => { spectator.component.generateReport(); spectator.component.downloadReport(); diff --git a/apps/concierge/src/tests/reports/report-page.component.spec.ts b/apps/concierge/src/tests/reports/report-page.component.spec.ts new file mode 100644 index 00000000000..aaf37c51a09 --- /dev/null +++ b/apps/concierge/src/tests/reports/report-page.component.spec.ts @@ -0,0 +1,50 @@ +import { Component, signal } from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; + +import { ReportPageComponent } from 'apps/concierge/src/app/reports/report-page.component'; + +@Component({ + selector: 'test-report-body', + template: ` + +

Body

+
+ `, + imports: [ReportPageComponent], +}) +class TestReportBodyComponent { + public readonly loading = signal(false); + public readonly has_data = signal(false); +} + +describe('ReportPageComponent', () => { + let spectator: Spectator; + const createComponent = createComponentFactory(TestReportBodyComponent); + + beforeEach(() => (spectator = createComponent())); + + it('should show the spinner and no body while loading', () => { + spectator.component.loading.set(true); + spectator.component.has_data.set(true); + spectator.detectChanges(); + expect(spectator.query('mat-spinner')).toBeTruthy(); + expect(spectator.query('.report-body')).toBeFalsy(); + }); + + it('should show the empty message when there is no data', () => { + expect(spectator.query('.screen-only')).toBeTruthy(); + expect(spectator.query('.report-body')).toBeFalsy(); + }); + + it('should render the body when loaded with data', () => { + spectator.component.has_data.set(true); + spectator.detectChanges(); + expect(spectator.query('.report-body')).toBeTruthy(); + expect(spectator.query('.screen-only')).toBeFalsy(); + expect(spectator.query('mat-spinner')).toBeFalsy(); + }); +}); diff --git a/apps/concierge/src/tests/reports/spaces/report-spaces-charts.component.spec.ts b/apps/concierge/src/tests/reports/report-usage-charts.component.spec.ts similarity index 80% rename from apps/concierge/src/tests/reports/spaces/report-spaces-charts.component.spec.ts rename to apps/concierge/src/tests/reports/report-usage-charts.component.spec.ts index 73cc77dc285..12099c9e721 100644 --- a/apps/concierge/src/tests/reports/spaces/report-spaces-charts.component.spec.ts +++ b/apps/concierge/src/tests/reports/report-usage-charts.component.spec.ts @@ -3,15 +3,15 @@ import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { MockProvider } from 'ng-mocks'; import { OrganisationService, SettingsService } from '@placeos/common'; -import { ReportSpacesChartsComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-charts.component'; +import { ReportUsageChartsComponent } from 'apps/concierge/src/app/reports/report-usage-charts.component'; import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; import * as chart_mod from 'chart.js'; vi.mock('chart.js'); -describe('ReportSpacesChartsComponent', () => { - let spectator: Spectator; +describe('ReportUsageChartsComponent', () => { + let spectator: Spectator; let day_list: ReturnType>; let options: ReturnType>; let counts: ReturnType>>; @@ -22,7 +22,7 @@ describe('ReportSpacesChartsComponent', () => { ]; const createComponent = createComponentFactory({ - component: ReportSpacesChartsComponent, + component: ReportUsageChartsComponent, providers: [ { provide: ReportsStateService, @@ -67,13 +67,18 @@ describe('ReportSpacesChartsComponent', () => { it('should build a daily utilisation line chart from the day list', () => { day_list.set([ - { date: new Date('2026-04-06T09:00:00').valueOf(), utilisation: '30' }, - { date: new Date('2026-04-07T09:00:00').valueOf(), utilisation: '55' }, + { + date: new Date('2026-04-06T09:00:00').valueOf(), + utilisation: '30', + }, + { + date: new Date('2026-04-07T09:00:00').valueOf(), + utilisation: '55', + }, ]); (chart_mod.Chart as unknown as any).mockClear(); spectator.component.updateDailyChart(spectator.component.day_list()); - const [, config] = (chart_mod.Chart as unknown as any).mock - .calls[0]; + const [, config] = (chart_mod.Chart as unknown as any).mock.calls[0]; expect(config.type).toBe('line'); expect(config.data.datasets[0].data).toEqual([30, 55]); }); @@ -81,12 +86,14 @@ describe('ReportSpacesChartsComponent', () => { it('should only chart levels that have bookings', () => { counts.set({ 'lvl-1': 3, 'lvl-2': 0 }); (chart_mod.Chart as unknown as any).mockClear(); - spectator.component.updateLevelChart({ zones: ['lvl-1', 'lvl-2'] }, { - 'lvl-1': 3, - 'lvl-2': 0, - }); - const [, config] = (chart_mod.Chart as unknown as any).mock - .calls[0]; + spectator.component.updateLevelChart( + { zones: ['lvl-1', 'lvl-2'] }, + { + 'lvl-1': 3, + 'lvl-2': 0, + }, + ); + const [, config] = (chart_mod.Chart as unknown as any).mock.calls[0]; expect(config.type).toBe('pie'); expect(config.data.labels).toEqual(['Level 1']); expect(config.data.datasets[0].data).toEqual([3]); diff --git a/apps/concierge/src/tests/reports/spaces/report-spaces-overall-list.component.spec.ts b/apps/concierge/src/tests/reports/report-usage-list.component.spec.ts similarity index 61% rename from apps/concierge/src/tests/reports/spaces/report-spaces-overall-list.component.spec.ts rename to apps/concierge/src/tests/reports/report-usage-list.component.spec.ts index 168ffd1828d..407bac44444 100644 --- a/apps/concierge/src/tests/reports/spaces/report-spaces-overall-list.component.spec.ts +++ b/apps/concierge/src/tests/reports/report-usage-list.component.spec.ts @@ -1,39 +1,39 @@ import { signal } from '@angular/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { IconComponent, SimpleTableComponent } from '@placeos/components'; import { MockComponent } from 'ng-mocks'; -import { IconComponent, SimpleTableComponent } from '@placeos/components'; -import { captureDownloads } from '../download-capture.helper'; -import { ReportSpacesOverallListComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-overall-list.component'; +import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; +import { ReportUsageListComponent } from 'apps/concierge/src/app/reports/report-usage-list.component'; import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; -describe('ReportSpacesOverallListComponent', () => { - let spectator: Spectator; +import { captureDownloads } from './download-capture.helper'; + +describe('ReportUsageListComponent', () => { + let spectator: Spectator; let downloads: ReturnType; let day_list: ReturnType>; const day = new Date('2026-04-06T09:00:00').valueOf(); const createComponent = createComponentFactory({ - component: ReportSpacesOverallListComponent, + component: ReportUsageListComponent, declarations: [ MockComponent(SimpleTableComponent), MockComponent(IconComponent), + MockComponent(ReportMetricGuideComponent), ], imports: [MatTooltipModule], - providers: [ - { - provide: ReportsStateService, - useValue: { day_list: (day_list = signal([])) }, - }, - ], + detectChanges: false, }); afterEach(() => downloads.restore()); beforeEach(() => { - day_list = signal([]); + day_list = signal([ + { date: day, approved: 3, count: 4, cancelled: 1, deleted: 0 }, + ]); downloads = captureDownloads(); spectator = createComponent({ providers: [ @@ -42,25 +42,26 @@ describe('ReportSpacesOverallListComponent', () => { }); }); - it('should expose the day list from the report state', () => { - const rows = [{ date: day, approved: 2, count: 5 }]; - day_list.set(rows); - expect(spectator.component.day_list()).toBe(rows); - }); - it('should format percentages for count and total', () => { expect(spectator.component.formatPercent(1, 4)).toBe('1 (25%)'); expect(spectator.component.formatPercent(0, 0)).toBe('0 (0%)'); }); - it('should export daily rows with formatted date and percentages', async () => { - day_list.set([ - { date: day, approved: 3, count: 4, cancelled: 1, deleted: 0 }, - ]); + it('should export desk rows with a short month name', async () => { + spectator.setInput('type', 'desks'); await spectator.component.download(); const csv = await downloads.text(); + expect(downloads.filename).toBe('desks-usage.csv'); + expect(csv).toContain('Apr 6, 2026(Mon)'); expect(csv).toContain('1 (25%)'); - expect(csv).toContain('0 (0%)'); + }); + + it('should export space rows with a full month name', async () => { + spectator.setInput('type', 'spaces'); + await spectator.component.download(); + const csv = await downloads.text(); expect(downloads.filename).toBe('spaces-usage.csv'); + expect(csv).toContain('April 6, 2026(Mon)'); + expect(csv).toContain('0 (0%)'); }); }); diff --git a/apps/concierge/src/tests/reports/reports-menu.component.spec.ts b/apps/concierge/src/tests/reports/reports-menu.component.spec.ts deleted file mode 100644 index b39ada395fa..00000000000 --- a/apps/concierge/src/tests/reports/reports-menu.component.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { signal, WritableSignal } from '@angular/core'; -import { MockComponent, MockPipe } from 'ng-mocks'; - -import { MatRippleModule } from '@angular/material/core'; -import { SettingsService } from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { ReportsMenuComponent } from 'apps/concierge/src/app/reports/reports-menu.component'; - -describe('ReportsMenuComponent', () => { - let spectator: SpectatorRouting; - let features: WritableSignal; - let custom_reports: WritableSignal; - const createComponent = createRoutingFactory({ - component: ReportsMenuComponent, - declarations: [MockComponent(IconComponent), MockPipe(TranslatePipe)], - providers: [ - { - provide: SettingsService, - useValue: { - signal: (key: string) => - key === 'app.reports.features' - ? features - : custom_reports, - }, - }, - ], - imports: [MatRippleModule], - stubsEnabled: true, - }); - - beforeEach(() => { - features = signal(['desks', 'spaces']); - custom_reports = signal([]); - spectator = createComponent(); - }); - - function card_titles(): string[] { - return spectator - .queryAll('a h3') - .map((el) => el.textContent.trim()); - } - - it('should only render cards for enabled features', () => { - expect(spectator.queryAll('a').length).toBe(2); - expect(card_titles()).toEqual(['Desks', 'Rooms']); - expect(card_titles()).not.toContain('Catering'); - }); - - it('should render additional feature cards when enabled', () => { - features.set(['desks', 'spaces', 'catering', 'contact-tracing']); - spectator.detectChanges(); - expect(spectator.queryAll('a').length).toBe(4); - expect(card_titles()).toContain('Catering'); - expect(card_titles()).toContain('Contact Tracing'); - }); - - it('should render custom report cards', () => { - custom_reports.set([{ id: 'kpi', icon: 'star', name: 'KPI Report' }]); - spectator.detectChanges(); - expect(spectator.queryAll('a').length).toBe(3); - expect(card_titles()).toContain('KPI Report'); - }); -}); diff --git a/apps/concierge/src/tests/reports/reports.component.spec.ts b/apps/concierge/src/tests/reports/reports.component.spec.ts deleted file mode 100644 index bef1e0703a3..00000000000 --- a/apps/concierge/src/tests/reports/reports.component.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Component } from '@angular/core'; -import { RouterModule } from '@angular/router'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; -import { Subject } from 'rxjs'; - -import { Router } from '@angular/router'; -import { ReportsMenuComponent } from 'apps/concierge/src/app/reports/reports-menu.component'; -import { ReportsComponent } from 'apps/concierge/src/app/reports/reports.component'; -import { ApplicationSidebarComponent } from 'apps/concierge/src/app/ui/app-sidebar.component'; - -@Component({ selector: 'router-outlet', template: '' }) -class StubRouterOutlet {} - -describe('ReportsComponent', () => { - let spectator: Spectator; - let router_events: Subject; - let current_url: string; - const createComponent = createComponentFactory({ - component: ReportsComponent, - overrideComponents: [ - [ - ReportsComponent, - { - remove: { imports: [RouterModule] }, - add: { imports: [StubRouterOutlet] }, - }, - ], - ], - declarations: [ - MockComponent(ApplicationSidebarComponent), - MockComponent(ReportsMenuComponent), - ], - providers: [ - { - provide: Router, - useValue: { - get events() { - return router_events; - }, - get url() { - return current_url; - }, - }, - }, - ], - }); - - beforeEach(() => { - router_events = new Subject(); - current_url = '/reports/desks'; - spectator = createComponent(); - }); - - it('should derive the active path from the router url', () => { - expect(spectator.component.path()).toBe('desks'); - }); - - it('should recompute the path when the router emits a navigation event', () => { - current_url = '/reports'; - router_events.next({ id: 1 }); - expect(spectator.component.path()).toBe('reports'); - }); - - it('should show the reports menu only on the base reports path', () => { - expect(spectator.query('[reports-menu]')).toBeFalsy(); - current_url = '/reports'; - router_events.next({ id: 2 }); - spectator.detectChanges(); - expect(spectator.query('[reports-menu]')).toBeTruthy(); - }); -}); diff --git a/apps/concierge/src/tests/reports/reports.utilities.spec.ts b/apps/concierge/src/tests/reports/reports.utilities.spec.ts index 0c8b6a598a4..41ff7f3c56d 100644 --- a/apps/concierge/src/tests/reports/reports.utilities.spec.ts +++ b/apps/concierge/src/tests/reports/reports.utilities.spec.ts @@ -1,3 +1,4 @@ +import { convertToParamMap } from '@angular/router'; import { activeReportBookings, activeReportEvents, @@ -8,9 +9,37 @@ import { reportBookingDuration, reportBookingStatusStats, reportEventStatusStats, + reportQueryOptions, } from 'apps/concierge/src/app/reports/reports.utilities'; describe('report utilities', () => { + describe('reportQueryOptions', () => { + it('should read dates and the zone_ids list from the query', () => { + const params = convertToParamMap({ + start: '100', + end: '200', + zone_ids: 'lvl-1,lvl-2', + }); + expect(reportQueryOptions(params)).toEqual({ + start: 100, + end: 200, + zones: ['lvl-1', 'lvl-2'], + }); + }); + + it('should clear zones when the query has no zone list', () => { + expect(reportQueryOptions(convertToParamMap({}))).toEqual({ + zones: [], + }); + }); + + it('should keep the current zones for an empty list when dropping empty ids', () => { + const params = convertToParamMap({ zones: ',' }); + expect(reportQueryOptions(params, true)).toEqual({}); + expect(reportQueryOptions(params)).toEqual({ zones: ['', ''] }); + }); + }); + describe('noShowReportBookings', () => { it('should exclude unallocated parking bookings', () => { const booking_end = Math.floor(Date.now() / 1000) - 60; diff --git a/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts b/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts index cb72763ade5..4f6163de6b7 100644 --- a/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts +++ b/apps/concierge/src/tests/reports/spaces/report-spaces.component.spec.ts @@ -1,17 +1,14 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; +import { ReportUsageChartsComponent } from 'apps/concierge/src/app/reports/report-usage-charts.component'; +import { ReportUsageListComponent } from 'apps/concierge/src/app/reports/report-usage-list.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; import { ReportsStateService } from 'apps/concierge/src/app/reports/reports-state.service'; -import { ReportSpacesChartsComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-charts.component'; -import { ReportSpacesOverallListComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-overall-list.component'; import { ReportSpacesOverallComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-overall.component'; import { ReportSpacesSpaceListingComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-space-listing.component'; import { ReportSpacesUserListingComponent } from 'apps/concierge/src/app/reports/spaces/report-spaces-user-listing.component'; @@ -32,13 +29,11 @@ describe('ReportSpacesComponent', () => { MockComponent(ReportsOptionsComponent), MockComponent(ReportMetricGuideComponent), MockComponent(ReportSpacesOverallComponent), - MockComponent(ReportSpacesChartsComponent), - MockComponent(ReportSpacesOverallListComponent), + MockComponent(ReportUsageChartsComponent), + MockComponent(ReportUsageListComponent), MockComponent(ReportSpacesSpaceListingComponent), MockComponent(ReportSpacesUserListingComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], providers: [ MockProvider(ReportsStateService, { stats: signal({ count: 0 }), @@ -47,10 +42,6 @@ describe('ReportSpacesComponent', () => { generateReport: vi.fn(), downloadReport: vi.fn(), }), - MockProvider(SettingsService, { theme: 'light', get: vi.fn() }), - MockProvider(OrganisationService, { - active_building: signal({ id: 'building-1' }), - } as any), MockProvider(ActivatedRoute, { queryParamMap: new BehaviorSubject(convertToParamMap({})), }), @@ -82,17 +73,6 @@ describe('ReportSpacesComponent', () => { downloadReport: download, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => - key === 'app.logo_light' - ? { src: 'light.svg' } - : undefined, - ), - }, - }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -106,16 +86,20 @@ describe('ReportSpacesComponent', () => { }); it('should apply query params to the report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['lvl-1', 'lvl-2'], }); }); it('should clear zones when the query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should derive total_count and has_data from the report stats', () => { @@ -131,10 +115,6 @@ describe('ReportSpacesComponent', () => { expect(spectator.component.total_count()).toBe(5); }); - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light.svg' }); - }); - it('should delegate generate and download to the report state', () => { spectator.component.generateReport(); spectator.component.downloadReport(); diff --git a/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts b/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts index 824ead72a78..8b4ae9981e4 100644 --- a/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts +++ b/apps/concierge/src/tests/reports/visitors/visitors-report.component.spec.ts @@ -1,12 +1,9 @@ import { signal } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent, MockDirective, MockProvider } from 'ng-mocks'; +import { MockComponent, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { AuthenticatedImageDirective } from '@placeos/components'; import { ReportMetricGuideComponent } from 'apps/concierge/src/app/reports/report-metric-guide.component'; import { ReportsOptionsComponent } from 'apps/concierge/src/app/reports/reports-options.component'; import { VisitorReportDailyUsageComponent } from 'apps/concierge/src/app/reports/visitors/visitor-report-daily-usage.component'; @@ -32,9 +29,7 @@ describe('VisitorsReportComponent', () => { MockComponent(VisitorReportOverallComponent), MockComponent(VisitorReportDailyUsageComponent), MockComponent(VisitorReportListComponent), - MockDirective(AuthenticatedImageDirective), ], - imports: [MatProgressSpinnerModule], providers: [ MockProvider(VisitorsReportService, { bookings: signal([]), @@ -43,10 +38,6 @@ describe('VisitorsReportComponent', () => { generateReport: vi.fn(), downloadReport: vi.fn(), }), - MockProvider(SettingsService, { theme: 'light', get: vi.fn() }), - MockProvider(OrganisationService, { - active_building: signal({ id: 'building-1' }), - } as any), MockProvider(ActivatedRoute, { queryParamMap: new BehaviorSubject(convertToParamMap({})), }), @@ -78,17 +69,6 @@ describe('VisitorsReportComponent', () => { downloadReport: download, }, }, - { - provide: SettingsService, - useValue: { - theme: 'light', - get: vi.fn((key: string) => - key === 'app.logo_light' - ? { src: 'light.svg' } - : undefined, - ), - }, - }, { provide: ActivatedRoute, useValue: { queryParamMap: query_params }, @@ -98,16 +78,20 @@ describe('VisitorsReportComponent', () => { }); it('should apply query params to the report options', () => { - expect(set_options).toHaveBeenCalledWith({ start: 100 }); - expect(set_options).toHaveBeenCalledWith({ end: 200 }); expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, zones: ['lvl-1', 'lvl-2'], }); }); it('should clear zones when the query params omit them', () => { query_params.next(convertToParamMap({ start: '100', end: '200' })); - expect(set_options).toHaveBeenCalledWith({ zones: [] }); + expect(set_options).toHaveBeenCalledWith({ + start: 100, + end: 200, + zones: [], + }); }); it('should expose the booking count and has_data flag', () => { @@ -118,10 +102,6 @@ describe('VisitorsReportComponent', () => { expect(spectator.component.has_data()).toBe(true); }); - it('should select the light logo by default', () => { - expect(spectator.component.logo()).toEqual({ src: 'light.svg' }); - }); - it('should delegate generate and download to the report service', () => { spectator.component.generateReport(); spectator.component.downloadReport(); diff --git a/apps/concierge/src/tests/reports/visitors/visitors-report.service.spec.ts b/apps/concierge/src/tests/reports/visitors/visitors-report.service.spec.ts index 088325f1b8b..eae56d644c2 100644 --- a/apps/concierge/src/tests/reports/visitors/visitors-report.service.spec.ts +++ b/apps/concierge/src/tests/reports/visitors/visitors-report.service.spec.ts @@ -132,7 +132,7 @@ describe('VisitorsReportService', () => { await settle(); await spectator.service.downloadReport(); expect(downloads.filename).toMatch( - /^report\+assets\+2026-04-06-2026-04-07\.tsv$/, + /^report\+visitors\+2026-04-06-2026-04-07\.tsv$/, ); }); diff --git a/apps/concierge/src/tests/surveys/new-survey.service.spec.ts b/apps/concierge/src/tests/surveys/new-survey.service.spec.ts index 405f018e2ba..da7d15324f4 100644 --- a/apps/concierge/src/tests/surveys/new-survey.service.spec.ts +++ b/apps/concierge/src/tests/surveys/new-survey.service.spec.ts @@ -1,6 +1,6 @@ import { MatDialog } from '@angular/material/dialog'; import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; -import { OrganisationService, setNotifyOutlet } from '@placeos/common'; +import { setNotifyOutlet } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; import { NEVER, of } from 'rxjs'; @@ -10,14 +10,7 @@ import { QuestionModalComponent } from '../../app/surveys/question-modal.compone vi.mock('@placeos/ts-client', { spy: true }); -const { Survey, SurveyQuestion } = ts_client; - -/** Fake dialog ref shaped for `openConfirmModal` driven via MatDialog. */ -const makeConfirmRef = (reason: string) => ({ - componentInstance: { event: NEVER, loading: { set: vi.fn() } }, - afterClosed: () => of({ reason }), - close: vi.fn(), -}); +const { SurveyQuestion } = ts_client; describe('NewSurveyService', () => { let spectator: SpectatorService; @@ -26,12 +19,7 @@ describe('NewSurveyService', () => { const createService = createServiceFactory({ service: NewSurveyService, - providers: [ - MockProvider(MatDialog, { open: vi.fn() }), - MockProvider(OrganisationService, { - buildings: [{ id: 'bld-1', name: 'HQ' }] as any, - }), - ], + providers: [MockProvider(MatDialog, { open: vi.fn() })], }); beforeEach(() => { @@ -41,7 +29,6 @@ describe('NewSurveyService', () => { dismiss: () => undefined, })); setNotifyOutlet({ open: notify_open } as any, true); - vi.mocked(ts_client.removeSurvey).mockResolvedValue(undefined as never); vi.mocked(ts_client.removeQuestion).mockResolvedValue( undefined as never, ); @@ -55,14 +42,6 @@ describe('NewSurveyService', () => { setNotifyOutlet(null, true); }); - it('should resolve the active building from the organisation list', () => { - expect(spectator.service.building).toBeUndefined(); - spectator.service.setBuilding('bld-1'); - expect(spectator.service.building).toEqual( - expect.objectContaining({ id: 'bld-1' }), - ); - }); - it('should open the question modal when editing a question', () => { const question = new SurveyQuestion({ id: 3, title: 'Q' }); @@ -73,33 +52,14 @@ describe('NewSurveyService', () => { }); }); - it('should remove a survey after confirmation and notify', async () => { - dialog_open.mockReturnValue(makeConfirmRef('done')); - - await spectator.service.removeSurvey(new Survey({ id: 8 })); - - expect(ts_client.removeSurvey).toHaveBeenCalledWith('8'); - expect(notify_open).toHaveBeenCalledWith( - expect.anything(), - 'OK', - expect.objectContaining({ panelClass: ['success'] }), - ); - }); - - it('should not remove a survey when confirmation is cancelled', async () => { - dialog_open.mockReturnValue(makeConfirmRef('cancel')); - - await spectator.service.removeSurvey(new Survey({ id: 8 })); - - expect(ts_client.removeSurvey).not.toHaveBeenCalled(); - expect(notify_open).not.toHaveBeenCalled(); - }); + it('should remove a question after confirmation and notify', async () => { + dialog_open.mockReturnValue({ + componentInstance: { event: NEVER, loading: { set: vi.fn() } }, + afterClosed: () => of({ reason: 'done' }), + close: vi.fn(), + }); - it('should remove a question without confirmation when requested', async () => { - await spectator.service.removeQuestion( - new SurveyQuestion({ id: 5 }), - false, - ); + await spectator.service.removeQuestion(new SurveyQuestion({ id: 5 })); expect(ts_client.removeQuestion).toHaveBeenCalledWith('5'); expect(notify_open).toHaveBeenCalledWith( diff --git a/apps/concierge/src/tests/surveys/survey.service.spec.ts b/apps/concierge/src/tests/surveys/survey.service.spec.ts index 356b868f211..5d7af344a52 100644 --- a/apps/concierge/src/tests/surveys/survey.service.spec.ts +++ b/apps/concierge/src/tests/surveys/survey.service.spec.ts @@ -10,7 +10,7 @@ import { SurveyService } from '../../app/surveys/survey.service'; vi.mock('@placeos/ts-client', { spy: true }); -const { Survey, SurveyQuestion } = ts_client; +const { Survey } = ts_client; /** Fake dialog ref shaped for `openConfirmModal` driven via MatDialog. */ const makeConfirmRef = (reason: string) => ({ @@ -44,9 +44,6 @@ describe('SurveyService', () => { })); setNotifyOutlet({ open: notify_open } as any, true); vi.mocked(ts_client.removeSurvey).mockResolvedValue(undefined as never); - vi.mocked(ts_client.removeQuestion).mockResolvedValue( - undefined as never, - ); dialog_open = vi.fn(() => ({ afterClosed: () => of(true) })); building_list = signal(null); const org: unknown = { @@ -65,86 +62,47 @@ describe('SurveyService', () => { setNotifyOutlet(null, true); }); - it('should filter surveys and answers by the active building', () => { + it('should filter surveys by the active building', () => { spectator.service.survey_list.set([ new Survey({ id: 1, building_id: 'bld-1' }), new Survey({ id: 2, building_id: 'bld-2' }), ]); - spectator.service.answer_list.set([ - { id: 10, survey_id: 1 } as any, - { id: 11, survey_id: 2 } as any, - ]); spectator.service.setBuilding('bld-1'); expect(spectator.service.building_surveys().map((s) => s.id)).toEqual([ 1, ]); - expect(spectator.service.building_answers().map((a) => a.id)).toEqual([ - 10, - ]); }); - it('should list only questions referenced by the active survey pages', () => { - spectator.service.question_list.set([ - new SurveyQuestion({ id: 1, title: 'One' }), - new SurveyQuestion({ id: 2, title: 'Two' }), - new SurveyQuestion({ id: 3, title: 'Three' }), - ]); - spectator.service.survey.set( - new Survey({ - id: 5, - pages: [{ title: '', question_order: [1, 3] }], - }), - ); - - expect(spectator.service.survey_questions().map((q) => q.id)).toEqual([ - 1, 3, - ]); - }); - - it('should filter questions by type and search text', () => { - spectator.service.question_list.set([ - new SurveyQuestion({ id: 1, title: 'Coffee rating', type: 'rating' }), - new SurveyQuestion({ id: 2, title: 'Tea comment', type: 'comment' }), - new SurveyQuestion({ id: 3, title: 'Coffee comment', type: 'comment' }), - ]); - - spectator.service.question_filters.set({ type: 'comment' }); - expect(spectator.service.filtered_questions().map((q) => q.id)).toEqual([ - 2, 3, - ]); + it('should remove a survey after confirmation and notify', async () => { + const ref = makeConfirmRef('done'); + dialog_open.mockReturnValue(ref); - spectator.service.question_filters.set({ search_text: 'coffee' }); - expect(spectator.service.filtered_questions().map((q) => q.id)).toEqual([ - 1, 3, - ]); - }); + await spectator.service.removeSurvey(new Survey({ id: 7 })); - it('should load the active survey and set its building', async () => { - (ts_client.showSurvey as any).mockResolvedValue( - new Survey({ id: 9, building_id: 'bld-9' }), + expect(ts_client.removeSurvey).toHaveBeenCalledWith('7'); + expect(ref.close).toHaveBeenCalled(); + expect(notify_open).toHaveBeenCalledWith( + expect.anything(), + 'OK', + expect.objectContaining({ panelClass: ['success'] }), ); - - await spectator.service.setSurvey('9'); - - expect(ts_client.showSurvey).toHaveBeenCalledWith('9'); - expect(spectator.service.survey().id).toBe(9); - expect(spectator.service.building()).toBe('bld-9'); - expect(spectator.service.loading()).toBe(''); }); - it('should remove a survey after confirmation and notify', async () => { + it('should close the dialog and show the error when removal fails', async () => { const ref = makeConfirmRef('done'); dialog_open.mockReturnValue(ref); + vi.mocked(ts_client.removeSurvey).mockRejectedValueOnce( + new Error('Server down'), + ); await spectator.service.removeSurvey(new Survey({ id: 7 })); - expect(ts_client.removeSurvey).toHaveBeenCalledWith('7'); expect(ref.close).toHaveBeenCalled(); expect(notify_open).toHaveBeenCalledWith( - expect.anything(), + expect.stringContaining('Server down'), 'OK', - expect.objectContaining({ panelClass: ['success'] }), + expect.objectContaining({ panelClass: ['error'] }), ); }); @@ -157,20 +115,6 @@ describe('SurveyService', () => { expect(notify_open).not.toHaveBeenCalled(); }); - it('should remove a question without confirmation when requested', async () => { - await spectator.service.removeQuestion( - new SurveyQuestion({ id: 4 }), - false, - ); - - expect(ts_client.removeQuestion).toHaveBeenCalledWith('4'); - expect(notify_open).toHaveBeenCalledWith( - expect.anything(), - 'OK', - expect.objectContaining({ panelClass: ['success'] }), - ); - }); - it('should load surveys at start and answers only on request, six at a time', async () => { const surveys = Array.from( { length: 10 }, diff --git a/apps/concierge/src/tests/ui/app-settings-modal.component.spec.ts b/apps/concierge/src/tests/ui/app-settings-modal.component.spec.ts deleted file mode 100644 index 84967ad5cc8..00000000000 --- a/apps/concierge/src/tests/ui/app-settings-modal.component.spec.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { - OrganisationService, - setNotifyOutlet, - SettingsService, -} from '@placeos/common'; -import { mockComponent } from '@placeos/common/tests'; -import { IconComponent } from '@placeos/components'; -import { MockProvider } from 'ng-mocks'; -import { AppSettingsModalComponent } from '../../app/ui/app-settings-modal.component'; - -import * as ts_client from '@placeos/ts-client'; - -vi.mock('@placeos/ts-client', { spy: true }); - -describe('AppSettingsModalComponent', () => { - let spectator: Spectator; - let notify_open: ReturnType; - - const mock_zone = { - id: 'zone-1', - name: 'Test Zone', - display_name: 'Test Zone Display', - parent_id: 'zone-parent', - }; - - const createComponent = createComponentFactory({ - component: AppSettingsModalComponent, - declarations: [mockComponent(IconComponent)], - providers: [ - MockProvider(MAT_DIALOG_DATA, { zone: mock_zone }), - MockProvider(MatDialogRef, { - close: vi.fn(), - disableClose: false, - }), - MockProvider(SettingsService, { - get: vi.fn(() => 'workplace_app'), - } as any), - MockProvider(OrganisationService, { - organisation: { id: 'org-1' }, - } as any), - ], - imports: [ - NoopAnimationsModule, - FormsModule, - ReactiveFormsModule, - MatCheckboxModule, - MatProgressSpinnerModule, - ], - }); - - beforeEach(() => { - notify_open = vi.fn(() => ({ - onAction: () => ({ subscribe: () => undefined }), - dismiss: () => undefined, - })); - setNotifyOutlet({ open: notify_open } as any, true); - vi.mocked(ts_client.showMetadata).mockResolvedValue({ - details: {}, - } as never); - vi.mocked(ts_client.updateMetadata).mockResolvedValue({} as never); - spectator = createComponent(); - }); - - afterEach(() => { - setNotifyOutlet(null, true); - vi.restoreAllMocks(); - }); - - it('should have a model with general values', () => { - const model = spectator.component.model(); - expect('use_24_hour_time' in model).toBe(true); - expect('features' in model).toBe(true); - }); - - it('should have events model group', () => { - const events = spectator.component.model().events; - expect(events).toBeTruthy(); - expect('allow_all_day' in events).toBe(true); - expect('can_book_for_others' in events).toBe(true); - expect('has_assets' in events).toBe(true); - expect('has_catering' in events).toBe(true); - expect('allow_externals' in events).toBe(true); - }); - - it('should have desks model group', () => { - const desks = spectator.component.model().desks; - expect(desks).toBeTruthy(); - expect('allow_all_day' in desks).toBe(true); - }); - - it('should expose available features list', () => { - expect(spectator.component.available_features).toContain('spaces'); - expect(spectator.component.available_features).toContain('desks'); - expect(spectator.component.available_features).toContain('explore'); - expect(spectator.component.available_features).toContain('parking'); - }); - - it('should expose landing features list', () => { - expect(spectator.component.landing_features).toContain( - 'show_quick_links', - ); - expect(spectator.component.landing_features).toContain( - 'hide_colleagues', - ); - }); - - it('should expose room features list', () => { - expect(spectator.component.room_features).toContain('allow_all_day'); - expect(spectator.component.room_features).toContain( - 'can_book_for_others', - ); - expect(spectator.component.room_features).toContain('has_catering'); - }); - - it('should expose feature descriptions', () => { - expect(spectator.component.feature_descriptions['spaces']).toBe( - 'Book Rooms', - ); - expect(spectator.component.feature_descriptions['desks']).toBe( - 'Book Desks', - ); - expect( - spectator.component.feature_descriptions['use_24_hour_time'], - ).toBe('Use 24 Hour Time'); - }); - - it('should expose the zone from dialog data', () => { - expect(spectator.component.zone).toEqual(mock_zone); - }); - - it('should load settings on init', async () => { - await spectator.component.ngOnInit(); - expect(ts_client.showMetadata).toHaveBeenCalled(); - expect(spectator.component.loading()).toBe(''); - }); - - it('should load metadata from zone, parent, and org', async () => { - await spectator.component.ngOnInit(); - expect(ts_client.showMetadata).toHaveBeenCalledWith( - 'zone-1', - 'workplace_app', - ); - expect(ts_client.showMetadata).toHaveBeenCalledWith( - 'zone-parent', - 'workplace_app', - ); - expect(ts_client.showMetadata).toHaveBeenCalledWith( - 'org-1', - 'workplace_app', - ); - }); - - it('should populate active_features from loaded settings', async () => { - vi.mocked(ts_client.showMetadata).mockResolvedValue({ - details: { - features: ['spaces', 'desks'], - use_24_hour_time: true, - }, - } as never); - await spectator.component.ngOnInit(); - expect(spectator.component.active_features()['spaces']).toBe(true); - expect(spectator.component.active_features()['desks']).toBe(true); - expect(spectator.component.active_features()['use_24_hour_time']).toBe( - true, - ); - }); - - it('should save settings via updateMetadata', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - name: 'workplace_app', - description: 'Workplace Application Settings', - }), - ); - }); - - it('should close dialog after successful save', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - const dialog_ref = spectator.inject(MatDialogRef); - expect(dialog_ref.close).toHaveBeenCalled(); - }); - - it('should show success notification after save', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(notify_open).toHaveBeenCalledWith( - 'Successfully saved settings', - 'OK', - expect.objectContaining({ panelClass: ['success'] }), - ); - }); - - it('should show error notification when save fails', async () => { - vi.mocked(ts_client.updateMetadata).mockRejectedValue({ - message: 'Network error', - } as never); - await spectator.component.ngOnInit(); - await spectator.component.save().catch(() => {}); - expect(notify_open).toHaveBeenCalledWith( - expect.anything(), - 'OK', - expect.objectContaining({ panelClass: ['error'] }), - ); - }); - - it('should block saving when stored settings fail to load', async () => { - vi.mocked(ts_client.showMetadata).mockImplementation(((id: string) => - id === 'zone-1' - ? Promise.reject({ status: 500 }) - : Promise.resolve({ details: {} })) as never); - vi.mocked(ts_client.updateMetadata).mockClear(); - await spectator.component.ngOnInit(); - - await spectator.component.save(); - - expect(spectator.component.load_failed()).toBe(true); - expect(ts_client.updateMetadata).not.toHaveBeenCalled(); - }); - - it('should allow saving when a zone has no stored settings', async () => { - vi.mocked(ts_client.showMetadata).mockRejectedValue({ - status: 404, - } as never); - vi.mocked(ts_client.updateMetadata).mockClear(); - await spectator.component.ngOnInit(); - - await spectator.component.save(); - - expect(spectator.component.load_failed()).toBe(false); - expect(ts_client.updateMetadata).toHaveBeenCalled(); - }); - - it('should build features list from active_features', () => { - spectator.component.active_features.set({ - spaces: true, - desks: false, - explore: true, - parking: false, - help: true, - schedule: false, - wfh: true, - use_24_hour_time: true, - }); - const out = spectator.component.buildSettings(); - const features = out.features; - expect(features).toContain('spaces'); - expect(features).not.toContain('desks'); - expect(features).toContain('explore'); - expect(features).toContain('help'); - expect(features).toContain('wfh'); - }); - - it('should filter features list to only available_features', () => { - spectator.component.active_features.set({ - spaces: true, - use_24_hour_time: true, // not in available_features - allow_all_day: true, // not in available_features - }); - const out = spectator.component.buildSettings(); - const features = out.features; - expect(features).toContain('spaces'); - expect(features).not.toContain('use_24_hour_time'); - expect(features).not.toContain('allow_all_day'); - }); - - it('should copy desks to bookings in buildSettings', () => { - spectator.component.model.update((m) => ({ - ...m, - desks: { ...m.desks, allow_all_day: true }, - })); - const out = spectator.component.buildSettings(); - expect(out.bookings.allow_all_day).toEqual(out.desks.allow_all_day); - }); - - it('should set loading and disableClose during init', async () => { - const dialog_ref = spectator.inject(MatDialogRef); - const init_promise = spectator.component.ngOnInit(); - expect(spectator.component.loading()).toBe('Loading settings...'); - expect(dialog_ref.disableClose).toBe(true); - await init_promise; - expect(spectator.component.loading()).toBe(''); - expect(dialog_ref.disableClose).toBe(false); - }); -}); diff --git a/apps/concierge/src/tests/ui/app-settings/app-settings-menu.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/app-settings-menu.component.spec.ts new file mode 100644 index 00000000000..4fceca5a6f9 --- /dev/null +++ b/apps/concierge/src/tests/ui/app-settings/app-settings-menu.component.spec.ts @@ -0,0 +1,59 @@ +import { MatDialog } from '@angular/material/dialog'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; +import { MockProvider } from 'ng-mocks'; + +import { + AppSettingsMenuComponent, + openAppSettings, +} from '../../../app/ui/app-settings/app-settings-menu.component'; +import { BookingPanelSettingsModalComponent } from '../../../app/ui/app-settings/booking-panel-settings-modal.component'; +import { ConciergeSettingsFormModalComponent } from '../../../app/ui/app-settings/concierge-settings-form-modal.component'; +import { VisitorKioskSettingsFormModalComponent } from '../../../app/ui/app-settings/visitor-kiosk-settings-form-modal.component'; +import { WorkplaceSettingsFormModalComponent } from '../../../app/ui/app-settings/workplace-settings-form-modal.component'; + +describe('openAppSettings', () => { + it.each([ + ['workplace', WorkplaceSettingsFormModalComponent], + ['concierge', ConciergeSettingsFormModalComponent], + ['booking-panel', BookingPanelSettingsModalComponent], + ['visitor-kiosk', VisitorKioskSettingsFormModalComponent], + ] as const)( + 'should open the %s modal for the zone', + async (kind, modal) => { + const dialog = { open: vi.fn() } as unknown as MatDialog; + const zone = { id: 'zone-1', name: 'Zone' }; + + await openAppSettings(dialog, kind, zone); + + expect(dialog.open).toHaveBeenCalledWith(modal, { data: { zone } }); + }, + ); +}); + +describe('AppSettingsMenuComponent', () => { + let spectator: Spectator; + const zone = { id: 'zone-1', name: 'Zone' }; + + const createComponent = createComponentFactory({ + component: AppSettingsMenuComponent, + providers: [MockProvider(MatDialog, { open: vi.fn() })], + detectChanges: false, + }); + + beforeEach(() => { + spectator = createComponent(); + spectator.setInput('zone', zone); + spectator.detectChanges(); + }); + + it('should open the chosen modal for its zone', async () => { + expect(spectator.component.menu()).toBeTruthy(); + + await spectator.component.open('concierge'); + + expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( + ConciergeSettingsFormModalComponent, + { data: { zone } }, + ); + }); +}); diff --git a/apps/concierge/src/tests/ui/app-settings/app-settings.utilities.spec.ts b/apps/concierge/src/tests/ui/app-settings/app-settings.utilities.spec.ts index 1b7a63e9bef..69965c1b03d 100644 --- a/apps/concierge/src/tests/ui/app-settings/app-settings.utilities.spec.ts +++ b/apps/concierge/src/tests/ui/app-settings/app-settings.utilities.spec.ts @@ -1,11 +1,18 @@ +import { setCurrentUser, SettingsService, StaffUser } from '@placeos/common'; +import * as ts_client from '@placeos/ts-client'; import { applyAppSettings, applyBookingFallbacks, + appMetadataKey, appSettingOverrides, + loadLayeredAppSettings, LOCKER_BOOKING_FALLBACKS, mergeAppSettings, + saveAppSettings, } from '../../../app/ui/app-settings/app-settings.utilities'; +vi.mock('@placeos/ts-client', { spy: true }); + describe('application settings utilities', () => { it('should merge settings at two levels and replace nested values', () => { const settings = mergeAppSettings( @@ -190,4 +197,161 @@ describe('application settings utilities', () => { ), ).toEqual({ lockers: { available_period: 30, max_duration: 480 } }); }); + + describe('appMetadataKey', () => { + const settingsWith = (values: Record) => + ({ get: (key: string) => values[key] }) as SettingsService; + + it('should use the first key setting that is set', () => { + const settings = settingsWith({ + 'app.concierge_app': 'legacy_concierge', + 'app.visitor_kiosk_metadata_key': 'kiosk_key', + 'app.visitor_kiosk_app': 'legacy_kiosk', + }); + expect(appMetadataKey(settings, 'concierge')).toBe( + 'legacy_concierge', + ); + expect(appMetadataKey(settings, 'visitor-kiosk')).toBe('kiosk_key'); + }); + + it('should fall back to the default key of each app', () => { + const settings = settingsWith({ 'app.workplace_app': 'ignored' }); + expect(appMetadataKey(settings, 'concierge')).toBe('concierge_app'); + expect(appMetadataKey(settings, 'workplace')).toBe('workplace_app'); + expect(appMetadataKey(settings, 'visitor-kiosk')).toBe( + 'visitor-kiosk_app', + ); + }); + }); + + describe('loadLayeredAppSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ts_client.showMetadata).mockImplementation( + async (id: string) => + ({ details: { default_route: id } }) as never, + ); + }); + + afterEach(() => vi.restoreAllMocks()); + + it('should layer defaults, organisation and parent settings', async () => { + const layers = await loadLayeredAppSettings( + { id: 'zone-1', name: 'Zone', parent_id: 'parent-1' }, + 'org-1', + 'app_key', + { default_route: 'default', use_region: true }, + ); + + expect(ts_client.showMetadata).toHaveBeenCalledWith( + 'org-1', + 'app_key', + ); + expect(layers).toEqual({ + inherited: { default_route: 'parent-1', use_region: true }, + stored: { default_route: 'zone-1' }, + }); + }); + + it('should skip a parent that is the organisation', async () => { + const layers = await loadLayeredAppSettings( + { id: 'zone-1', name: 'Zone', parent_id: 'org-1' }, + 'org-1', + 'app_key', + {}, + ); + + expect(ts_client.showMetadata).toHaveBeenCalledTimes(2); + expect(layers.inherited).toEqual({ default_route: 'org-1' }); + }); + + it('should load only the organisation for the organisation', async () => { + const layers = await loadLayeredAppSettings( + { id: 'org-1', name: 'Org' }, + 'org-1', + 'app_key', + { default_route: 'default' }, + ); + + expect(ts_client.showMetadata).toHaveBeenCalledTimes(1); + expect(layers).toEqual({ + inherited: { default_route: 'default' }, + stored: { default_route: 'org-1' }, + }); + }); + }); + + describe('saveAppSettings', () => { + const save = () => + saveAppSettings( + 'zone-1', + 'app_key', + 'Concierge', + { + inherited: { default_route: '/a', week_start: 1 }, + stored: { use_region: true }, + }, + { default_route: '/a', week_start: 1 }, + { default_route: '/b', week_start: 1 }, + ); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ts_client.updateMetadata).mockResolvedValue({} as never); + }); + + afterEach(() => { + setCurrentUser(null); + vi.restoreAllMocks(); + }); + + it('should save overrides with the editor details', async () => { + setCurrentUser( + new StaffUser({ + id: 'user-1', + name: 'Admin User', + email: 'admin@example.com', + groups: ['placeos_admin'], + }), + ); + + await save(); + + expect(ts_client.updateMetadata).toHaveBeenCalledWith('zone-1', { + name: 'app_key', + description: expect.stringMatching( + /\|C\] Concierge Application Settings$/, + ), + details: { + use_region: true, + default_route: '/b', + edited_by: { + id: 'user-1', + name: 'Admin User', + email: 'admin@example.com', + domain: location.hostname, + role: 'Admin', + }, + }, + }); + }); + + it.each([ + [['placeos_support'], 'Support'], + [[], 'User'], + ])('should save the role of %j users as %s', async (groups, role) => { + setCurrentUser(new StaffUser({ id: 'user-1', groups })); + + await save(); + + expect(ts_client.updateMetadata).toHaveBeenCalledWith( + 'zone-1', + expect.objectContaining({ + details: expect.objectContaining({ + edited_by: expect.objectContaining({ role }), + }), + }), + ); + }); + }); }); diff --git a/apps/concierge/src/tests/ui/app-settings/concierge-settings-form-modal.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/concierge-settings-form-modal.component.spec.ts index 7d6cf5d0f0b..77e7b3e44a4 100644 --- a/apps/concierge/src/tests/ui/app-settings/concierge-settings-form-modal.component.spec.ts +++ b/apps/concierge/src/tests/ui/app-settings/concierge-settings-form-modal.component.spec.ts @@ -201,25 +201,6 @@ describe('ConciergeSettingsFormModalComponent', () => { expect(spectator.component.shown_group()).toBe('parking'); }); - it('should update currency filter', () => { - expect(spectator.component.currency_filter()).toBe(''); - spectator.component.updateCurrencyFilter('USD'); - expect(spectator.component.currency_filter()).toBe('USD'); - }); - - it('should clear currency filter when select closes', () => { - spectator.component.updateCurrencyFilter('EUR'); - expect(spectator.component.currency_filter()).toBe('EUR'); - spectator.component.onCurrencySelectStateChange(false); - expect(spectator.component.currency_filter()).toBe(''); - }); - - it('should not clear currency filter when select opens', () => { - spectator.component.updateCurrencyFilter('EUR'); - spectator.component.onCurrencySelectStateChange(true); - expect(spectator.component.currency_filter()).toBe('EUR'); - }); - it('should load settings on init', async () => { await spectator.component.ngOnInit(); expect(ts_client.showMetadata).toHaveBeenCalled(); @@ -421,22 +402,4 @@ describe('ConciergeSettingsFormModalComponent', () => { expect(spectator.component.date_string).toBeTruthy(); expect(typeof spectator.component.date_string).toBe('string'); }); - - it('should set edited_by on save with admin role', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - id: 'user-1', - name: 'Test User', - email: 'test@example.com', - role: 'Admin', - }), - }), - }), - ); - }); }); diff --git a/apps/concierge/src/tests/ui/app-settings/settings-currency-select.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/settings-currency-select.component.spec.ts new file mode 100644 index 00000000000..11e493c91bf --- /dev/null +++ b/apps/concierge/src/tests/ui/app-settings/settings-currency-select.component.spec.ts @@ -0,0 +1,66 @@ +import { Component, signal } from '@angular/core'; +import { form } from '@angular/forms/signals'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; + +import { SettingsCurrencySelectComponent } from '../../../app/ui/app-settings/settings-currency-select.component'; + +/** Form that hosts the select under test. */ +@Component({ + template: ` + + `, + imports: [SettingsCurrencySelectComponent], +}) +class HostComponent { + public readonly model = signal({ currency: 'USD' }); + public readonly form = form(this.model); +} + +describe('SettingsCurrencySelectComponent', () => { + let spectator: Spectator; + let select: SettingsCurrencySelectComponent; + + const createComponent = createComponentFactory({ + component: HostComponent, + imports: [NoopAnimationsModule], + }); + + beforeEach(() => { + spectator = createComponent(); + select = spectator.query(SettingsCurrencySelectComponent)!; + }); + + it('should name the select with its label', () => { + expect(spectator.query('label')).toHaveAttribute( + 'id', + 'test-currency-label', + ); + expect( + spectator + .query('mat-select') + ?.getAttribute('aria-labelledby') + ?.trim(), + ).toBe('test-currency-label'); + }); + + it('should filter currencies by the search text', () => { + const all = select.filtered_options().length; + select.filter.set(' eur '); + const filtered = select.filtered_options(); + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.length).toBeLessThan(all); + expect(filtered.map((option) => option.code)).toContain('EUR'); + }); + + it('should clear the search only when the select closes', () => { + select.filter.set('EUR'); + select.onOpenedChange(true); + expect(select.filter()).toBe('EUR'); + select.onOpenedChange(false); + expect(select.filter()).toBe(''); + }); +}); diff --git a/apps/concierge/src/tests/ui/app-settings/settings-select.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/settings-select.component.spec.ts new file mode 100644 index 00000000000..4e0163f71b3 --- /dev/null +++ b/apps/concierge/src/tests/ui/app-settings/settings-select.component.spec.ts @@ -0,0 +1,78 @@ +import { Component, signal } from '@angular/core'; +import { form } from '@angular/forms/signals'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; + +import { SettingsSelectComponent } from '../../../app/ui/app-settings/settings-select.component'; + +/** Form that hosts the select under test. */ +@Component({ + template: ` + + `, + imports: [SettingsSelectComponent], +}) +class HostComponent { + public readonly model = signal({ period: 1 as number | null }); + public readonly form = form(this.model); + public readonly OPTIONS = [ + { value: 1, label: '1 Day' }, + { value: 7, label: '1 Week' }, + ]; +} + +describe('SettingsSelectComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: HostComponent, + imports: [NoopAnimationsModule], + }); + + const openOptions = () => { + spectator.click('.mat-mdc-select-trigger'); + return Array.from(document.querySelectorAll('mat-option')); + }; + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should name the select with its label', () => { + const label = spectator.query('label'); + expect(label).toHaveAttribute('for', 'test-period'); + expect(label).toHaveAttribute('id', 'test-period-label'); + expect(label).toHaveText('Available Period'); + expect( + spectator + .query('mat-select') + ?.getAttribute('aria-labelledby') + ?.trim(), + ).toBe('test-period-label'); + expect(spectator.query('mat-hint')).toHaveText( + 'Days ahead users can book', + ); + }); + + it('should list the null option first and write the chosen value', () => { + const options = openOptions(); + expect(options.map((option) => option.textContent?.trim())).toEqual([ + 'No limit', + '1 Day', + '1 Week', + ]); + + (options[2] as HTMLElement).click(); + expect(spectator.component.model().period).toBe(7); + + (openOptions()[0] as HTMLElement).click(); + expect(spectator.component.model().period).toBeNull(); + }); +}); diff --git a/apps/concierge/src/tests/ui/app-settings/visitor-kiosk-settings-form-modal.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/visitor-kiosk-settings-form-modal.component.spec.ts index 5def0b9578c..16068cefc52 100644 --- a/apps/concierge/src/tests/ui/app-settings/visitor-kiosk-settings-form-modal.component.spec.ts +++ b/apps/concierge/src/tests/ui/app-settings/visitor-kiosk-settings-form-modal.component.spec.ts @@ -170,21 +170,6 @@ describe('VisitorKioskSettingsFormModalComponent', () => { ); }); - it('should skip parent metadata when parent is same as org', async () => { - (ts_client.showMetadata as any).mockClear(); - // Override the zone to have parent_id === org id - (spectator.component as any).zone = { - ...mock_zone, - parent_id: 'org-1', - }; - (spectator.component as any)._data = { - zone: { ...mock_zone, parent_id: 'org-1' }, - }; - await spectator.component.ngOnInit(); - // Should only call for org and zone (not parent separately) - expect(ts_client.showMetadata).toHaveBeenCalledTimes(2); - }); - it('should compare organisation settings against application defaults', async () => { (ts_client.showMetadata as any).mockClear(); (ts_client.showMetadata as any).mockResolvedValueOnce({ @@ -315,21 +300,4 @@ describe('VisitorKioskSettingsFormModalComponent', () => { const visitors = spectator.component.model().visitors; expect(visitors.max_duration).toBe(180); }); - - it('should set edited_by on save', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - id: 'user-1', - name: 'Test User', - email: 'test@example.com', - }), - }), - }), - ); - }); }); diff --git a/apps/concierge/src/tests/ui/app-settings/workplace-settings-form-modal.component.spec.ts b/apps/concierge/src/tests/ui/app-settings/workplace-settings-form-modal.component.spec.ts index 01a037d2f9b..199d890c372 100644 --- a/apps/concierge/src/tests/ui/app-settings/workplace-settings-form-modal.component.spec.ts +++ b/apps/concierge/src/tests/ui/app-settings/workplace-settings-form-modal.component.spec.ts @@ -200,18 +200,6 @@ describe('WorkplaceSettingsFormModalComponent', () => { expect(spectator.component.shown_group()).toBe('desks'); }); - it('should update currency filter', () => { - expect(spectator.component.currency_filter()).toBe(''); - spectator.component.updateCurrencyFilter('USD'); - expect(spectator.component.currency_filter()).toBe('USD'); - }); - - it('should clear currency filter when select closes', () => { - spectator.component.updateCurrencyFilter('EUR'); - spectator.component.onCurrencySelectStateChange(false); - expect(spectator.component.currency_filter()).toBe(''); - }); - it('should load settings on init', async () => { await spectator.component.ngOnInit(); expect(ts_client.showMetadata).toHaveBeenCalled(); @@ -371,65 +359,4 @@ describe('WorkplaceSettingsFormModalComponent', () => { expect(spectator.component.date_string).toBeTruthy(); expect(typeof spectator.component.date_string).toBe('string'); }); - - it('should set edited_by with admin role', async () => { - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - role: 'Admin', - }), - }), - }), - ); - }); - - it('should set edited_by with Support role for support users', async () => { - setCurrentUser( - new StaffUser({ - id: 'user-2', - name: 'Support User', - email: 'support@example.com', - groups: ['placeos_support'], - }), - ); - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - role: 'Support', - }), - }), - }), - ); - }); - - it('should set edited_by with User role for regular users', async () => { - setCurrentUser( - new StaffUser({ - id: 'user-3', - name: 'Regular User', - email: 'user@example.com', - groups: [], - }), - ); - await spectator.component.ngOnInit(); - await spectator.component.save(); - expect(ts_client.updateMetadata).toHaveBeenCalledWith( - 'zone-1', - expect.objectContaining({ - details: expect.objectContaining({ - edited_by: expect.objectContaining({ - role: 'User', - }), - }), - }), - ); - }); }); diff --git a/apps/concierge/src/tests/ui/app-sidebar.component.spec.ts b/apps/concierge/src/tests/ui/app-sidebar.component.spec.ts index f64ceef2613..9c0fffd3b62 100644 --- a/apps/concierge/src/tests/ui/app-sidebar.component.spec.ts +++ b/apps/concierge/src/tests/ui/app-sidebar.component.spec.ts @@ -23,9 +23,6 @@ class BlankComponent {} describe('ApplicationSidebarComponent', () => { let spectator: Spectator; let app_settings: Record; - const theme = signal<'light' | 'dark'>('light'); - const logo_dark = signal('dark-logo.png'); - const logo_light = signal('light-logo.png'); const createComponent = createComponentFactory({ component: ApplicationSidebarComponent, shallow: true, @@ -41,11 +38,6 @@ describe('ApplicationSidebarComponent', () => { MockProvider(SettingsService, { get: ((name: string) => app_settings[name]) as SettingsService['get'], - theme_signal: theme, - signal: ((name: string) => - name === 'app.logo_dark' - ? logo_dark - : logo_light) as SettingsService['signal'], }), MockProvider(OrganisationService, { initialised: signal(true), @@ -66,9 +58,6 @@ describe('ApplicationSidebarComponent', () => { beforeEach(() => { app_settings = { 'app.features': ['events'] }; setGroups([]); - theme.set('light'); - logo_dark.set('dark-logo.png'); - logo_light.set('light-logo.png'); spectator = createComponent(); }); @@ -131,19 +120,6 @@ describe('ApplicationSidebarComponent', () => { ]); }); - it('should show the logo for the current theme', () => { - expect(spectator.component.logo_src()).toBe('light-logo.png'); - theme.set('dark'); - expect(spectator.component.logo_src()).toBe('dark-logo.png'); - }); - - it('should resolve the logo from an object or an empty value', () => { - logo_light.set({ src: 'nested-logo.png' }); - expect(spectator.component.logo_src()).toBe('nested-logo.png'); - logo_light.set(null); - expect(spectator.component.logo_src()).toBe(''); - }); - describe('with translations loaded after render', () => { const translations: Record = {}; beforeAll(() => diff --git a/apps/concierge/src/tests/ui/timezone-field.component.spec.ts b/apps/concierge/src/tests/ui/timezone-field.component.spec.ts new file mode 100644 index 00000000000..2e05bcb1320 --- /dev/null +++ b/apps/concierge/src/tests/ui/timezone-field.component.spec.ts @@ -0,0 +1,50 @@ +import { Component, signal } from '@angular/core'; +import { form } from '@angular/forms/signals'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; + +import { TimezoneFieldComponent } from '../../app/ui/timezone-field.component'; + +/** Form that hosts the field under test. */ +@Component({ + template: ` + + `, + imports: [TimezoneFieldComponent], +}) +class HostComponent { + public readonly model = signal({ timezone: '' }); + public readonly form = form(this.model); +} + +describe('TimezoneFieldComponent', () => { + let spectator: Spectator; + let field: TimezoneFieldComponent; + + const createComponent = createComponentFactory({ + component: HostComponent, + imports: [NoopAnimationsModule], + }); + + beforeEach(() => { + spectator = createComponent(); + field = spectator.query(TimezoneFieldComponent)!; + }); + + it('should point the label at the input', () => { + expect(spectator.query('label')).toHaveAttribute( + 'for', + 'test-timezone', + ); + expect(spectator.query('input')).toHaveAttribute('id', 'test-timezone'); + }); + + it('should filter timezones by the field value', () => { + spectator.component.model.set({ timezone: 'SYDNEY' }); + const filtered = field.filtered_timezones(); + expect(filtered.length).toBeGreaterThan(0); + expect( + filtered.every((tz) => tz.toLowerCase().includes('sydney')), + ).toBe(true); + }); +}); diff --git a/apps/concierge/src/tests/url-management/url-list.component.spec.ts b/apps/concierge/src/tests/url-management/url-list.component.spec.ts index 2dff920173b..3ed5195d6dd 100644 --- a/apps/concierge/src/tests/url-management/url-list.component.spec.ts +++ b/apps/concierge/src/tests/url-management/url-list.component.spec.ts @@ -1,6 +1,6 @@ import { signal } from '@angular/core'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { SettingsService, ShortURL } from '@placeos/common'; +import { ShortURL } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; import { UrlListComponent } from '../../app/url-management/url-list.component'; @@ -20,9 +20,6 @@ describe('UrlListComponent', () => { removeURL: vi.fn(), loadQrCode: vi.fn(), }), - MockProvider(SettingsService, { - get: vi.fn(() => '/kiosk'), - } as any), ], }); @@ -41,12 +38,6 @@ describe('UrlListComponent', () => { expect(manager.loadQrCode).toHaveBeenCalledWith(url); }); - it('should build the kiosk URL from settings and origin', () => { - expect(spectator.component.kiosk_url).toBe( - `${window.location.origin}/kiosk`, - ); - }); - it('should trigger printing of the window', () => { const print_spy = vi .spyOn(window, 'print') diff --git a/apps/concierge/src/tests/zone-manager/zone-manager.component.spec.ts b/apps/concierge/src/tests/zone-manager/zone-manager.component.spec.ts index 0861ff015e7..6c95353b2a1 100644 --- a/apps/concierge/src/tests/zone-manager/zone-manager.component.spec.ts +++ b/apps/concierge/src/tests/zone-manager/zone-manager.component.spec.ts @@ -1,6 +1,5 @@ import { ComponentFixtureAutoDetect } from '@angular/core/testing'; import { convertToParamMap, ActivatedRoute, Router } from '@angular/router'; -import { MatDialog } from '@angular/material/dialog'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; import { OrganisationService } from '@placeos/common'; import { MockProvider } from 'ng-mocks'; @@ -10,7 +9,6 @@ import { ZoneManagerComponent } from '../../app/zone-manager/zone-manager.compon import { BuildingManagementService } from '../../app/building-manager/building-management.service'; import { LevelManagementService } from '../../app/level-manager/level-management.service'; import { RegionManagementService } from '../../app/region-manager/region-management.service'; -import { WorkplaceSettingsFormModalComponent } from '../../app/ui/app-settings/workplace-settings-form-modal.component'; describe('ZoneManagerComponent', () => { let spectator: Spectator; @@ -29,7 +27,6 @@ describe('ZoneManagerComponent', () => { MockProvider(RegionManagementService, {}), MockProvider(BuildingManagementService, {}), MockProvider(LevelManagementService, {}), - MockProvider(MatDialog, { open: vi.fn() }), MockProvider(OrganisationService, {}), MockProvider(ActivatedRoute, {}), MockProvider(Router, {}), @@ -47,7 +44,6 @@ describe('ZoneManagerComponent', () => { { provide: RegionManagementService, useValue: region_service }, { provide: BuildingManagementService, useValue: building_service }, { provide: LevelManagementService, useValue: level_service }, - { provide: MatDialog, useValue: { open: vi.fn() } }, { provide: OrganisationService, useValue: { @@ -111,12 +107,4 @@ describe('ZoneManagerComponent', () => { await spectator.component.ngOnInit(); expect(spectator.component.selected_tab()).toBe(1); }); - - it('should open the organisation workplace settings modal', async () => { - await spectator.component.editWorkplaceSettings(); - expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith( - WorkplaceSettingsFormModalComponent, - { data: { zone: { id: 'org-1' } } }, - ); - }); }); diff --git a/apps/control/ngsw-config.json b/apps/control/ngsw-config.json deleted file mode 100644 index 3b65a2681c1..00000000000 --- a/apps/control/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/control/project.json b/apps/control/project.json index 66e4f596213..26cf2c3269a 100644 --- a/apps/control/project.json +++ b/apps/control/project.json @@ -67,7 +67,7 @@ "with": "apps/control/src/environments/settings.ts" } ], - "serviceWorker": "apps/control/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "production": { "fileReplacements": [ @@ -105,7 +105,7 @@ "maximumError": "10kb" } ], - "serviceWorker": "apps/control/ngsw-config.json" + "serviceWorker": "shared/ngsw-config.json" }, "mobile": { "fileReplacements": [ @@ -164,7 +164,8 @@ "tsConfig": "apps/control/tsconfig.spec.json", "setupFiles": [ "libs/common/src/test-storage.ts", - "apps/control/src/test-setup.ts" + "shared/test-setup-base.ts", + "shared/test-setup.ts" ], "isolate": true, "watch": false, diff --git a/apps/control/src/app/bootstrap.component.ts b/apps/control/src/app/bootstrap.component.ts index a7b5843ac67..cb6c87c7d23 100644 --- a/apps/control/src/app/bootstrap.component.ts +++ b/apps/control/src/app/bootstrap.component.ts @@ -1,276 +1,24 @@ -import { - Component, - computed, - debounced, - effect, - inject, - OnInit, - resource, - signal, -} from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Router } from '@angular/router'; -import { querySystems } from '@placeos/ts-client'; +import { Component } from '@angular/core'; -import { CommonModule } from '@angular/common'; -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 { - AsyncHandler, - getNativeSystemId, - OrganisationService, - Space, - syncNativeManagedConfig, - VERSION, -} from '@placeos/common'; -import { TranslatePipe } from '@placeos/components'; +import { SystemBootstrapComponent } from '@placeos/components'; import { CONTROL_STORE_KEY } from './control-store-key'; +/** Setup screen that binds the control panel to a system */ @Component({ selector: '[app-bootstrap]', template: ` -
-
-
-
- {{ 'APP.CONTROL.BOOTSTRAP_TITLE' | translate }} -
-
-
-
- {{ 'COMMON.BOOTSTRAP_SETUP' | translate }} -
-
-
- @if (!loading() || loading() === 'search') { -

- {{ 'COMMON.BOOTSTRAP_DESCRIPTION' | translate }} -

- - {{ - 'COMMON.BOOTSTRAP_LABEL' | translate - }} - - @if (loading() === 'search') { - - } - - - @for (option of space_list(); track option.id) { - -
-
-
- {{ - 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 }} -
-
- } -
-
-
- {{ 'COMMON.CONTROLS_VERSION' | translate }}: - {{ version.hash }} -
-
- {{ version.time | date: 'longDate' }} - ({{ version.time | date: 'shortTime' }}) -
-
-
+ `, - styles: [ - ` - mat-form-field { - width: calc(100% - 2rem); - } - - button { - width: 8rem; - margin: 0.5rem; - margin-top: 0; - } - `, - ], - imports: [ - CommonModule, - TranslatePipe, - MatProgressSpinnerModule, - MatRippleModule, - MatAutocompleteModule, - MatFormFieldModule, - MatInputModule, - FormsModule, - ], + imports: [SystemBootstrapComponent], }) -export class BootstrapComponent extends AsyncHandler implements OnInit { - private route = inject(ActivatedRoute); - private _router = inject(Router); - private _org = inject(OrganisationService); - private readonly _query_params = toSignal(this.route.queryParamMap, { - initialValue: this.route.snapshot.queryParamMap, - }); - - public readonly version = VERSION; - - /** Whether application data is loading */ - public loading = signal(''); - /** ID of the system to bootstrap */ - public system_id = signal(''); - - private readonly _debounced_search = debounced(this.system_id, 300); - private readonly _space_list = resource({ - params: () => this._debounced_search.value(), - loader: async ({ params: search }) => { - if (search.length < 2) return [] as Space[]; - this.loading.set('search'); - try { - const { data } = await querySystems({ - q: search, - limit: 20, - fields: ['id', 'name', 'display_name', 'email'].join(','), - zone_id: this._org.organisation?.id, - }); - return data.map((_) => new Space(_ as any)); - } finally { - this.loading.set(''); - } - }, - }); - public readonly space_list = computed(() => - this._space_list.hasValue() ? this._space_list.value() : [], - ); - - constructor() { - super(); - effect(() => { - const params = this._query_params(); - if (params.has('clear') && !!params.get('clear')) { - this.clearBootstrap(); - } - const system_id = params.get('system_id') || params.get('sys_id'); - if (system_id) { - this.system_id.set(system_id); - this.configure(system_id); - } - }); - } - - public async ngOnInit() { - this.checkBootstrapped(); - } - - /** - * 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 async checkBootstrapped(): Promise { - if (this.system_id()) return; - this.loading.set('Checks'); - // Wait for any MDM managed configuration to be stored locally, as it - // takes precedence over previously stored bootstrap settings. - await syncNativeManagedConfig(); - if (this.system_id()) return; - const system_id = localStorage.getItem(CONTROL_STORE_KEY); - // A system pushed via MDM managed config overrides the stored one - const mdm_system_id = getNativeSystemId(); - if (mdm_system_id && mdm_system_id !== system_id) { - this.system_id.set(mdm_system_id); - return this.configure(mdm_system_id); - } - if (system_id) { - this._router.navigate(['/tabbed', system_id], { - queryParamsHandling: 'preserve', - }); - return; - } - this.loading.set(''); - } - - /** - * Save the bootstrapped ID and redirect to the panel for that ID - * @param system_id System to bootstrap - */ - private configure(system_id: string): void { - localStorage.setItem(CONTROL_STORE_KEY, system_id); - localStorage.setItem('trust', 'true'); - localStorage.setItem('fixed_device', 'true'); - this._router.navigate(['/tabbed', system_id], { - queryParamsHandling: 'preserve', - }); - } +export class BootstrapComponent { + public readonly store_key = CONTROL_STORE_KEY; - /** - * Remove any previously set bootstrapping details - */ - private readonly clearBootstrap = () => - localStorage.removeItem(CONTROL_STORE_KEY); + /** Open the tabbed control view for the system */ + public readonly systemRoute = (system_id: string) => ['/tabbed', system_id]; } diff --git a/apps/control/src/app/status-bar.component.ts b/apps/control/src/app/status-bar.component.ts index 1648698dd70..b913dd1f48d 100644 --- a/apps/control/src/app/status-bar.component.ts +++ b/apps/control/src/app/status-bar.component.ts @@ -5,10 +5,10 @@ import { MatSliderModule } from '@angular/material/slider'; import { BindingDirective, IconComponent, + MediaDurationPipe, TranslatePipe, } from '@placeos/components'; import { ControlStateService } from './control-state.service'; -import { DurationPipe } from './ui/duration.pipe'; @Component({ selector: 'control-status-bar', @@ -118,13 +118,13 @@ import { DurationPipe } from './ui/duration.pipe';
-
{{ rec_remaining | duration }}
+
{{ rec_remaining | mediaDuration }}
-
{{ rec_next | duration }}
+
{{ rec_next | mediaDuration }}
} @@ -181,7 +181,7 @@ import { DurationPipe } from './ui/duration.pipe'; FormsModule, MatRippleModule, IconComponent, - DurationPipe, + MediaDurationPipe, TranslatePipe, ], }) diff --git a/apps/control/src/app/tabbed-view/tabbed-view.component.ts b/apps/control/src/app/tabbed-view/tabbed-view.component.ts index 2ff9b8e18df..49cd8fa4c9a 100644 --- a/apps/control/src/app/tabbed-view/tabbed-view.component.ts +++ b/apps/control/src/app/tabbed-view/tabbed-view.component.ts @@ -1,15 +1,14 @@ -import { Component, computed, effect, inject } from '@angular/core'; +import { Component, effect, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; -import { SettingsService } from '@placeos/common'; +import { themedLogo } from '@placeos/common'; import { AuthenticatedImageDirective, IconComponent, TranslatePipe, } from '@placeos/components'; -import { OrganisationService } from '@placeos/common'; import { ControlStateService } from '../control-state.service'; import { ControlStatusBarComponent } from '../status-bar.component'; import { TopbarHeaderComponent } from '../topbar-header.component'; @@ -51,12 +50,7 @@ import { TabOutletComponent } from './tab-outlet.component'; class="bg-base-100 absolute inset-0 flex flex-col items-center justify-center space-y-2 p-16" >
- Logo + Logo