From 6888ba0e0dee403f87cbaa2e2d1c9e343960034d Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:02 +1100 Subject: [PATCH 01/17] fix(libs): keep catering favourites apart from desks and cap desk tooltip retries The catering select modal saved favourite menu items under the desk favourites key, so they mixed into desk favourites. Both catering views now use SETTING_KEYS.FAVORITE_CATERING. ExploreDeskInfoComponent retried its position lookup every 200 ms with no limit when its parent element was missing. It now stops after 10 tries. --- libs/catering/src/lib/catering-list-field.component.ts | 7 ++++--- .../catering-select-modal.component.ts | 6 +++--- libs/explore/src/lib/explore-desk-info.component.ts | 9 ++++++--- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/libs/catering/src/lib/catering-list-field.component.ts b/libs/catering/src/lib/catering-list-field.component.ts index 1512029d9f5..6f9cb36ed74 100644 --- a/libs/catering/src/lib/catering-list-field.component.ts +++ b/libs/catering/src/lib/catering-list-field.component.ts @@ -21,6 +21,7 @@ import { i18n, OrganisationService, randomString, + SETTING_KEYS, SettingsService, Space, } from '@placeos/common'; @@ -383,7 +384,7 @@ export class CateringListFieldComponent public readonly favorites = computed(() => { return ( this._settings.signal( - 'favourite_menu_items', + SETTING_KEYS.FAVORITE_CATERING, EMPTY_FAVS, true, )() || EMPTY_FAVS @@ -610,13 +611,13 @@ export class CateringListFieldComponent const fav_list = this.favorites(); const new_state = !fav_list.includes(cateringitem.id); if (new_state) { - this._settings.saveUserSetting('favourite_menu_items', [ + this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_CATERING, [ ...fav_list, cateringitem.id, ]); } else { this._settings.saveUserSetting( - 'favourite_menu_items', + SETTING_KEYS.FAVORITE_CATERING, fav_list.filter((_) => _ !== cateringitem.id), ); } diff --git a/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts b/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts index 377a354b26e..cd94c52d528 100644 --- a/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts +++ b/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts @@ -179,7 +179,7 @@ export class CateringSelectModalComponent { public readonly favorites = computed(() => { return ( this._settings.signal( - SETTING_KEYS.FAVORITE_DESKS, + SETTING_KEYS.FAVORITE_CATERING, EMPTY_FAVS, true, )() || EMPTY_FAVS @@ -286,13 +286,13 @@ export class CateringSelectModalComponent { const fav_list = this.favorites(); const new_state = !fav_list.includes(item); if (new_state) { - this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_DESKS, [ + this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_CATERING, [ ...fav_list, item, ]); } else { this._settings.saveUserSetting( - SETTING_KEYS.FAVORITE_DESKS, + SETTING_KEYS.FAVORITE_CATERING, fav_list.filter((_) => _ !== item), ); } diff --git a/libs/explore/src/lib/explore-desk-info.component.ts b/libs/explore/src/lib/explore-desk-info.component.ts index ba15522fe70..51f8795775a 100644 --- a/libs/explore/src/lib/explore-desk-info.component.ts +++ b/libs/explore/src/lib/explore-desk-info.component.ts @@ -273,15 +273,18 @@ export class ExploreDeskInfoComponent extends AsyncHandler implements OnInit { public readonly x_pos = signal<'left' | 'right'>('left'); - public ngOnInit(tries = 0) { - if (tries > 10) return; + public ngOnInit() { setTimeout(() => this.updatePosition(), 200); this.interval('time', () => this.now.set(Date.now()), 5000); } public updatePosition(tries = 0) { const parent = this._element.nativeElement.parentElement?.parentElement; - if (!parent) return setTimeout(() => this.updatePosition(++tries), 200); + if (!parent) { + if (tries < 10) + setTimeout(() => this.updatePosition(tries + 1), 200); + return; + } const box = parent?.getBoundingClientRect(); const wbox = document.body?.getBoundingClientRect(); this.y_pos.set(box.y >= wbox.height / 2 ? 'bottom' : 'top'); From 103794677c8758572143dc10e404555d234669a0 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:02 +1100 Subject: [PATCH 02/17] refactor(libs): remove unused components, pipes and upload permission wiring Removes components, pipes, a guard and a directive in libs/components and libs/form-fields that nothing imports or renders (including the old 608-line indoor-maps copy of maps-indoors), inputs that no caller binds, and the upload permissions modal path. The app-level provider for that modal was removed in f6aa2e0137, so the component-level providers never reached the root UploadsService and the modal could not open. authenticated-image.pipe.ts is renamed authenticated-image.ts as it no longer holds a pipe. --- .../booking-details-modal.component.spec.ts | 2 - libs/common/src/lib/uploads.service.ts | 87 +-- libs/components/src/index.ts | 9 +- .../src/lib/authenticated-image.directive.ts | 2 +- ...d-image.pipe.ts => authenticated-image.ts} | 46 +- .../src/lib/authorised-admin.guard.ts | 43 -- libs/components/src/lib/fixed.pipe.ts | 15 - .../src/lib/indoor-maps.component.ts | 608 ------------------ .../src/lib/limit-input.directive.ts | 73 --- libs/components/src/lib/login.component.ts | 180 ------ .../src/lib/map-polygon.component.ts | 145 ----- libs/components/src/lib/map-viewer.class.ts | 2 +- .../src/lib/popout-menu.component.ts | 78 --- .../src/lib/status-overlay.component.ts | 57 -- libs/components/src/lib/thumbnail.pipe.ts | 45 -- .../lib/upload-permissions-modal.component.ts | 113 ---- libs/components/src/lib/zone.pipe.ts | 18 - ...pe.spec.ts => authenticated-image.spec.ts} | 2 +- .../src/tests/authorised-admin.guard.spec.ts | 53 -- libs/components/src/tests/fixed.pipe.spec.ts | 16 - .../src/tests/map-polygon.component.spec.ts | 77 --- .../src/tests/popout-menu.component.spec.ts | 51 -- .../tests/status-overlay.component.spec.ts | 50 -- ...upload-permissions-modal.component.spec.ts | 65 -- .../explore-device-info.component.spec.ts | 3 +- .../tests/explore-map-view.component.spec.ts | 2 - libs/form-fields/src/index.ts | 1 - .../src/lib/action-field.component.ts | 55 -- .../src/lib/compact-counter.component.ts | 1 - libs/form-fields/src/lib/counter.component.ts | 1 - .../src/lib/duration-field.component.ts | 48 +- .../src/lib/image-field.component.ts | 17 +- .../src/lib/image-list-field.component.ts | 13 +- libs/form-fields/src/lib/place-user.pipe.ts | 31 - .../src/lib/rich-text-input.component.ts | 8 +- .../src/lib/time-field.component.ts | 52 +- .../src/lib/upload-file-field.component.ts | 11 +- .../src/lib/upload-list-field.component.ts | 11 +- .../src/lib/user-list-field.component.ts | 3 - .../src/lib/user-search-field.component.ts | 40 +- .../src/tests/action-field.component.spec.ts | 32 - .../src/tests/image-field.component.spec.ts | 19 +- .../tests/image-list-field.component.spec.ts | 22 +- .../src/tests/place-user.pipe.spec.ts | 57 -- .../tests/user-list-field.component.spec.ts | 4 +- .../tests/user-search-field.component.spec.ts | 14 - 46 files changed, 53 insertions(+), 2229 deletions(-) rename libs/components/src/lib/{authenticated-image.pipe.ts => authenticated-image.ts} (73%) delete mode 100644 libs/components/src/lib/authorised-admin.guard.ts delete mode 100644 libs/components/src/lib/fixed.pipe.ts delete mode 100644 libs/components/src/lib/indoor-maps.component.ts delete mode 100644 libs/components/src/lib/limit-input.directive.ts delete mode 100644 libs/components/src/lib/login.component.ts delete mode 100644 libs/components/src/lib/map-polygon.component.ts delete mode 100644 libs/components/src/lib/popout-menu.component.ts delete mode 100644 libs/components/src/lib/status-overlay.component.ts delete mode 100644 libs/components/src/lib/thumbnail.pipe.ts delete mode 100644 libs/components/src/lib/upload-permissions-modal.component.ts delete mode 100644 libs/components/src/lib/zone.pipe.ts rename libs/components/src/tests/{authenticated-image.pipe.spec.ts => authenticated-image.spec.ts} (98%) delete mode 100644 libs/components/src/tests/authorised-admin.guard.spec.ts delete mode 100644 libs/components/src/tests/fixed.pipe.spec.ts delete mode 100644 libs/components/src/tests/map-polygon.component.spec.ts delete mode 100644 libs/components/src/tests/popout-menu.component.spec.ts delete mode 100644 libs/components/src/tests/status-overlay.component.spec.ts delete mode 100644 libs/components/src/tests/upload-permissions-modal.component.spec.ts delete mode 100644 libs/form-fields/src/lib/action-field.component.ts delete mode 100644 libs/form-fields/src/lib/place-user.pipe.ts delete mode 100644 libs/form-fields/src/tests/action-field.component.spec.ts delete mode 100644 libs/form-fields/src/tests/place-user.pipe.spec.ts diff --git a/libs/bookings/src/test/booking-details-modal.component.spec.ts b/libs/bookings/src/test/booking-details-modal.component.spec.ts index c3ff734979a..4e35a349e60 100644 --- a/libs/bookings/src/test/booking-details-modal.component.spec.ts +++ b/libs/bookings/src/test/booking-details-modal.component.spec.ts @@ -19,7 +19,6 @@ import type { Mock } from 'vitest'; import { Booking } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { ImageCarouselComponent } from 'libs/components/src/lib/image-carousel.component'; -import { IndoorMapsComponent } from 'libs/components/src/lib/indoor-maps.component'; import { InteractiveMapComponent } from 'libs/components/src/lib/interactive-map.component'; import { StatusPillComponent } from 'libs/components/src/lib/status-pill.component'; import { BehaviorSubject, NEVER, of } from 'rxjs'; @@ -70,7 +69,6 @@ describe('BookingDetailsModalComponent', () => { MockComponent(InteractiveMapComponent), MockComponent(IconComponent), MockComponent(StatusPillComponent), - MockComponent(IndoorMapsComponent), ], imports: [MockModule(MatMenuModule), MockModule(MatDialogModule)], }); diff --git a/libs/common/src/lib/uploads.service.ts b/libs/common/src/lib/uploads.service.ts index add199239e3..e315fb36ee5 100644 --- a/libs/common/src/lib/uploads.service.ts +++ b/libs/common/src/lib/uploads.service.ts @@ -1,16 +1,6 @@ -import { - EnvironmentInjector, - inject, - Injectable, - InjectionToken, - signal, - Type, - WritableSignal, -} from '@angular/core'; +import { Injectable, signal, WritableSignal } from '@angular/core'; import { apiKey, authorise, token } from '@placeos/ts-client'; -import { firstValueFrom } from 'rxjs'; import { AsyncHandler } from './async-handler.class'; -import { log } from './general'; import { randomString } from './general'; @@ -37,17 +27,6 @@ export interface UploadDetails { export type UploadPermissions = 'none' | 'support' | 'admin'; -/** - * Rejection raised when the user dismisses the upload permissions modal. - * Lets consumers skip error messaging for a deliberate cancellation. - */ -export class UploadCancelledError extends Error { - constructor() { - super('Upload cancelled'); - this.name = 'UploadCancelledError'; - } -} - /** Failure of an upload, carrying the details of the attempt that failed */ export class UploadFailedError extends Error { constructor( @@ -81,19 +60,10 @@ function uploadFailure(details: any) { ); } -export const UPLOAD_PERMISSIONS_MODAL = new InjectionToken>( - 'UploadPermissionsModalComponent', -); - @Injectable({ providedIn: 'root', }) export class UploadsService extends AsyncHandler { - private _injector = inject(EnvironmentInjector); - private _permissions_modal = inject(UPLOAD_PERMISSIONS_MODAL, { - optional: true, - }); - private _upload_list = signal([]); public readonly upload_list = this._upload_list.asReadonly(); @@ -126,24 +96,6 @@ export class UploadsService extends AsyncHandler { this._upload_list.set(in_progress_list); } - public async uploadFileWithPermissions(file: File, default_public = false) { - if (!this._permissions_modal) { - log( - 'UPLOAD', - 'Permissions modal not initialized', - undefined, - 'warn', - ); - return this.uploadFile(file, default_public); - } - const details = await this._askPermissions(file, default_public); - return this.uploadFile( - details.file, - details.is_public, - details.permissions, - ); - } - public uploadFile( file: File, pub = false, @@ -191,43 +143,6 @@ export class UploadsService extends AsyncHandler { return upload_id; } - public async uploadFileWithPermissionsToCompletion( - file: File, - default_public = false, - ) { - if (!this._permissions_modal) { - log( - 'UPLOAD', - 'Permissions modal not initialized', - undefined, - 'warn', - ); - return this.uploadFileToCompletion(file, default_public); - } - const details = await this._askPermissions(file, default_public); - return this.uploadFileToCompletion( - details.file, - details.is_public, - details.permissions, - ); - } - - /** - * Opens the permissions modal and waits for its result. The dialog - * service is loaded on demand to keep it out of the initial bundle. - */ - private async _askPermissions(file: File, is_public: boolean) { - const { MatDialog } = await import('@angular/material/dialog'); - const ref = this._injector - .get(MatDialog) - .open(this._permissions_modal, { data: { file, is_public } }); - const details = await firstValueFrom(ref.afterClosed(), { - defaultValue: null, - }); - if (!details) throw new UploadCancelledError(); - return details; - } - public uploadFileWithProgress( file: File, pub = false, diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 79c17b75036..dbbb0a6053d 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,7 +1,7 @@ export * from './lib/action-icon.component'; export * from './lib/attached-resource-config-modal.component'; +export * from './lib/authenticated-image'; export * from './lib/authenticated-image.directive'; -export * from './lib/authenticated-image.pipe'; export * from './lib/available-rooms-state-modal.component'; export * from './lib/bulk-actions'; export * from './lib/bulk-actions-bar.component'; @@ -11,7 +11,6 @@ export * from './lib/chat/chat.component'; export * from './lib/confirm-modal.component'; export * from './lib/custom-tooltip.component'; export * from './lib/dynamic-map.component'; -export * from './lib/fixed.pipe'; export * from './lib/fullscreen-modal-shell.component'; export * from './lib/global-banner.component'; export * from './lib/global-loading.component'; @@ -19,7 +18,6 @@ export * from './lib/icon.component'; export * from './lib/image-carousel.component'; export * from './lib/interactive-map.component'; export * from './lib/load-error.component'; -export * from './lib/login.component'; export * from './lib/map-locate-modal.component'; export * from './lib/map-pin.component'; export * from './lib/map-radius.component'; @@ -27,7 +25,6 @@ export * from './lib/map-viewer.class'; export * from './lib/maps-indoors.component'; export * from './lib/misconfigured.component'; export * from './lib/plugin-embed.component'; -export * from './lib/popout-menu.component'; export * from './lib/printable.component'; export * from './lib/redirect.component'; export * from './lib/schema-form.component'; @@ -36,7 +33,6 @@ export * from './lib/settings-debug-panel-launcher.component'; export * from './lib/settings-debug-panel.component'; export * from './lib/settings-toggle.component'; export * from './lib/simple-table.component'; -export * from './lib/status-overlay.component'; export * from './lib/survey-outlet.component'; export * from './lib/translate.pipe'; export * from './lib/unauthorised.component'; @@ -54,10 +50,7 @@ export * from './lib/level.pipe'; export * from './lib/media-duration.pipe'; export * from './lib/safe.pipe'; export * from './lib/sanitise.pipe'; -export * from './lib/zone.pipe'; -export * from './lib/duration.pipe'; -export * from './lib/authorised-admin.guard'; export * from './lib/authorised-user.guard'; export * from './lib/binding.directive'; diff --git a/libs/components/src/lib/authenticated-image.directive.ts b/libs/components/src/lib/authenticated-image.directive.ts index 8d876ec8b66..6e3563e4774 100644 --- a/libs/components/src/lib/authenticated-image.directive.ts +++ b/libs/components/src/lib/authenticated-image.directive.ts @@ -14,7 +14,7 @@ import { getCachedAuthenticatedImage, loadAuthenticatedImage, loadAuthenticatedImageWithHeader, -} from './authenticated-image.pipe'; +} from './authenticated-image'; type AuthenticatedMediaElement = | HTMLImageElement diff --git a/libs/components/src/lib/authenticated-image.pipe.ts b/libs/components/src/lib/authenticated-image.ts similarity index 73% rename from libs/components/src/lib/authenticated-image.pipe.ts rename to libs/components/src/lib/authenticated-image.ts index 95dc6818674..8232080187d 100644 --- a/libs/components/src/lib/authenticated-image.pipe.ts +++ b/libs/components/src/lib/authenticated-image.ts @@ -1,5 +1,4 @@ -import { ChangeDetectorRef, Pipe, PipeTransform, inject } from '@angular/core'; -import { apiKey, authority, token } from '@placeos/ts-client'; +import { apiKey, token } from '@placeos/ts-client'; const MAX_CACHED_IMAGES = 64; const MAX_FAILED_LOADS = 64; @@ -8,7 +7,7 @@ const LEGACY_CACHE_NAME = 'PlaceOS.image-cache-v1'; const LEGACY_CACHE_KEYS = 'PlaceOS.image-cache-keys-v1'; /** Shared, bounded object URL cache for authenticated media. */ -export const IMAGE_STORE = new Map(); +const IMAGE_STORE = new Map(); const IMAGE_LOADS = new Map>(); const FAILED_LOADS = new Map(); let _legacy_cache_removed = false; @@ -44,10 +43,6 @@ export function clearAuthenticatedImageCache(): void { FAILED_LOADS.clear(); } -export function canLoadAuthenticatedImage(source: string): boolean { - return (FAILED_LOADS.get(source) || 0) <= Date.now(); -} - function cacheObjectUrl(source: string, url: string): string { const previous = IMAGE_STORE.get(source); if (previous && previous !== url) URL.revokeObjectURL(previous); @@ -85,7 +80,7 @@ export function setAuthCookie(cookie_path: string): void { }`; } -export function authHeaders(): Record { +function authHeaders(): Record { const tkn = token(); return tkn === 'x-api-key' ? { 'X-API-Key': apiKey() } @@ -141,38 +136,3 @@ async function loadImage( IMAGE_LOADS.set(source, load); return load; } - -@Pipe({ - name: 'authenticatedImage,authImage', - pure: false, -}) -export class AuthenticatedImagePipe implements PipeTransform { - private readonly _change_detector = inject(ChangeDetectorRef); - private readonly _loading = new Set(); - - public transform(source: string | undefined | null): string { - if (!source || typeof source !== 'string') return ''; - if (!source.includes('/api/engine/v2/uploads')) return source; - - const cached = getCachedAuthenticatedImage(source); - if (cached) return cached; - if ( - !authority() || - this._loading.has(source) || - !canLoadAuthenticatedImage(source) - ) { - return ''; - } - - this._loading.add(source); - void loadAuthenticatedImage(source, '/api/engine/v2/uploads') - .catch((error: unknown) => - console.info('Failed to load image:', source, error), - ) - .finally(() => { - this._loading.delete(source); - this._change_detector.markForCheck(); - }); - return ''; - } -} diff --git a/libs/components/src/lib/authorised-admin.guard.ts b/libs/components/src/lib/authorised-admin.guard.ts deleted file mode 100644 index 6627bd44036..00000000000 --- a/libs/components/src/lib/authorised-admin.guard.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { - ActivatedRouteSnapshot, - Route, - Router, - RouterStateSnapshot, - UrlSegment, - UrlTree, -} from '@angular/router'; -import { current_user } from '@placeos/common'; -import { onlineState, waitForSignal } from '@placeos/ts-client'; -import { first } from 'rxjs/operators'; - -@Injectable({ - providedIn: 'root', -}) -export class AuthorisedAdminGuard { - private _router = inject(Router); - - public async canActivate( - _next?: ActivatedRouteSnapshot, - _state?: RouterStateSnapshot, - ): Promise { - return this.checkUser(); - } - - public async canLoad( - _route?: Route, - _segments?: UrlSegment[], - ): Promise { - return this.checkUser(); - } - - private async checkUser() { - await waitForSignal(onlineState(), (_) => _); - const user = await current_user.pipe(first((_) => !!_)).toPromise(); - const can_activate = user && user.groups.includes('placeos_admin'); - if (!can_activate) { - this._router.navigate(['/unauthorised']); - } - return can_activate; - } -} diff --git a/libs/components/src/lib/fixed.pipe.ts b/libs/components/src/lib/fixed.pipe.ts deleted file mode 100644 index 45dae61c589..00000000000 --- a/libs/components/src/lib/fixed.pipe.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -@Pipe({ - name: 'toFixed', -}) -export class FixedPipe implements PipeTransform { - /** - * Fixed a number to x decimal places - * @param value number to convert - * @param places Number of decimal places to convert - */ - public transform(value: number, places: number = 2): string { - return value.toFixed(places); - } -} diff --git a/libs/components/src/lib/indoor-maps.component.ts b/libs/components/src/lib/indoor-maps.component.ts deleted file mode 100644 index 5ff2503b1a0..00000000000 --- a/libs/components/src/lib/indoor-maps.component.ts +++ /dev/null @@ -1,608 +0,0 @@ -import { - AfterViewInit, - Component, - ElementRef, - OnChanges, - OnInit, - SimpleChanges, - inject, - input, - signal, - viewChild, -} from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { MatSelectModule } from '@angular/material/select'; -import { - AsyncHandler, - MapsPeopleService, - OrganisationService, - ViewAction, - ViewerStyles, - log, - notifyError, -} from '@placeos/common'; -import { MapService } from 'libs/common/src/lib/mapspeople.service'; -import { ExploreStateService } from 'libs/explore/src/lib/explore-state.service'; -import { IconComponent } from './icon.component'; -import { TranslatePipe } from './translate.pipe'; -import { SlicePipe } from '@angular/common'; - -declare let mapsindoors: any; -declare let google: any; - -interface GeolocationCoordinates { - latitude: number; - longitude: number; - altitude: number | null; - accuracy: number; - altitudeAccuracy: number | null; - heading: number | null; - speed: number | null; -} - -interface GeolocationPosition { - coords: GeolocationCoordinates; - timestamp: number; -} - -interface CustomCoordinates { - latitude: number; - longitude: number; -} - -function calculateDistance( - lat1: number, - lon1: number, - lat2: number, - lon2: number, -): number { - const radius = 6371; // Earth's radius in kilometers - - const dLat = degreesToRadians(lat2 - lat1); - const dLon = degreesToRadians(lon2 - lon1); - - lat1 = degreesToRadians(lat1); - lat2 = degreesToRadians(lat2); - - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.sin(dLon / 2) * - Math.sin(dLon / 2) * - Math.cos(lat1) * - Math.cos(lat2); - const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - return radius * c; -} - -function degreesToRadians(degrees: number): number { - return degrees * (Math.PI / 180); -} - -@Component({ - selector: 'indoor-maps', - template: ` -
- @if (loading()) { -
- -
- } - @if (geolocation_error_message()) { -
- graphic of magnifying glass -

- {{ geolocation_error_message() | translate }} -

-
- } -
- - -
- -
-
- - @if (search_result_items()?.length) { -
-

- Results ({{ search_result_items().length || '0' }}) -

- -
-
    - @for ( - item of search_result_items() | slice: 0 : 10; - track item - ) { -
  • - -
  • - } -
- } -
- `, - styles: [ - ` - :host { - height: 100%; - width: 100%; - } - - mat-spinner { - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - `, - ], - imports: [ - MatProgressSpinnerModule, - MatRippleModule, - IconComponent, - MatFormFieldModule, - MatSelectModule, - TranslatePipe, - SlicePipe, - ], -}) -export class IndoorMapsComponent - extends AsyncHandler - implements OnInit, OnChanges, AfterViewInit -{ - private _api_service = inject(MapsPeopleService); - private _state = inject(ExploreStateService); - private _org = inject(OrganisationService); - - /** Custom CSS styles to apply to the map */ - public readonly styles = input(undefined); - /** List of available user actions for the map */ - public readonly actions = input(undefined); - /** Custom coordinates to fixate on the map */ - public readonly custom_coordinates = input(undefined); - /** Mark location of a specific item */ - public readonly locate = input(undefined); - /** Default zoom level for the map */ - public readonly default_zoom = input(19); - - public view_instance: any; - public maps_service: any; - public map_instance: any; - public directions_service: any; - public directions_renderer: any; - - public live_data_status: string | boolean = 'enabled'; - public search_result_items = signal([]); - public selected_destination: any = null; - - public loading = signal(false); - public actions_hashmap: { [id: string]: ViewAction } = {}; - - public user_latitude = signal(null); - public user_longitude = signal(null); - public geolocation_error_message = signal(''); - public route_error_message: string = ''; - public coordinates = signal(null); - - public readonly setBuilding = (b) => { - this._org.building = b; - this._setLocationToBuilding(); - }; - - public floor_mapping: { [id: string]: string } = {}; - - readonly searchElement = viewChild('searchInput'); - readonly searchResults = viewChild('searchResultItems'); - - constructor() { - super(); - } - - async ngOnInit() { - this.loading.set(true); - await this._org.waitUntilInitialised(); - this.setBuilding(this._org.building); - const custom_coordinates = this.custom_coordinates(); - if (custom_coordinates) this.coordinates.set(custom_coordinates); - const get_location = () => { - this._getUserLocation(); - document.removeEventListener('click', get_location); - }; - document.addEventListener('click', get_location); - await this._initMapView(); - } - - async ngOnChanges(change: SimpleChanges) { - if (change.styles || change.actions) { - await this.renderSpaceStatus(); - await this.mapActions(); - } - if (change.locate && this.locate() && mapsindoors) { - const searchParams = { - q: this.searchElement().nativeElement.value, - }; - const locations = - await mapsindoors?.services.LocationsService.getLocations( - searchParams, - ); - if (locations.length) this.getRoute(locations[0]); - } - this.mapFloorsToIndex(); - this.loading.set(false); - } - - public ngAfterViewInit() { - this.maps_service?.addListener('click', (location: any, e: Event) => { - const found_action = this.actions_hashmap[location.id]; - if (found_action) found_action.callback(e); - }); - } - - private _initMapView() { - if (!this._api_service.is_ready || !(window as any).mapsindoors) { - this.timeout('init', () => this._initMapView(), 1000); - return; - } - const view_options: any = { - element: document.getElementById('maps-indoors'), - center: { lat: this.user_latitude(), lng: this.user_longitude() }, - zoom: this.default_zoom() || 19, - maxZoom: 24, - }; - - if (this._api_service.map_service === MapService.GoogleMaps) { - log('MapsIndoors', 'Using Google Maps API'); - this.view_instance = new mapsindoors.mapView.GoogleMapsView( - view_options, - ); - } else { - view_options.accessToken = this._api_service.map_token; - log('MapsIndoors', 'Using Mapbox API'); - this.view_instance = new mapsindoors.mapView.MapboxView( - view_options, - ); - } - if (!this.view_instance) { - log( - 'MapsIndoors', - 'Failed to initialise map view instance.', - undefined, - 'warn', - ); - return; - } - this.maps_service = new mapsindoors.MapsIndoors({ - mapView: this.view_instance, - }); - this.map_instance = this.view_instance.getMap(); - this.initDirections(); - this.handleLocationChange(); - this.mapFloorsToIndex(); - this._setLocationToBuilding(); - } - - public initDirections() { - const provider = - this._api_service.map_service === MapService.GoogleMaps - ? new mapsindoors.directions.GoogleMapsProvider() - : new mapsindoors.directions.MapboxProvider( - this._api_service.map_token, - ); - this.directions_service = new mapsindoors.services.DirectionsService( - provider, - ); - const directionsRendererOptions = { - mapsIndoors: this.maps_service, - }; - this.directions_renderer = - new mapsindoors.directions.DirectionsRenderer( - directionsRendererOptions, - ); - } - - public async mapFloorsToIndex() { - const building = await this.maps_service?.getBuilding(); - const input_string = - building?.buildingInfo?.fields?.floorMapping?.value; - const pairs = input_string?.split(',\n').map((pair) => pair.split(':')); - this.floor_mapping = - pairs?.reduce((lvl_map, [key, value]) => { - lvl_map[key] = value; - return lvl_map; - }, {}) || {}; - const floor_index: string = await this.maps_service?.getFloor(); - if (floor_index && this.floor_mapping) { - const level_id = this.floor_mapping[floor_index]; - this._state.setLevel(level_id); - } - } - - public handleLocationChange() { - const floorSelectorElement = document.createElement('div'); - new mapsindoors.FloorSelector(floorSelectorElement, this.maps_service); - if (this._api_service.map_service === MapService.GoogleMaps) { - this.map_instance.controls[ - google.maps.ControlPosition.RIGHT_TOP - ].push(floorSelectorElement); - } else { - const instance = this.view_instance.getMap(); - instance.addControl({ - onAdd: () => floorSelectorElement, - onRemove: () => {}, - }); - } - - this.maps_service?.addListener('building_changed', (e: any) => { - const found_building = this._org.buildings.find((building) => { - building.name.toLowerCase() === - e.buildingInfo?.name.toLowerCase(); - this.setBuilding(found_building); - }); - }); - this.maps_service?.addListener('floor_changed', (e: string) => { - if (!e || !this.floor_mapping) return; - const level_id: string = this.floor_mapping[e]; - this._state.setLevel(level_id); - }); - } - - public onSearch() { - const searchParams = { q: this.searchElement().nativeElement.value }; - mapsindoors?.services.LocationsService.getLocations(searchParams).then( - (locations: any[]) => { - this.search_result_items.set(locations); - }, - ); - } - - private async _getUserLocation(): Promise { - if (!('geolocation' in navigator)) { - log( - 'MapsIndoors', - "User's geolocation not available.", - undefined, - 'warn', - ); - return this._setLocationToBuilding(); - } - if (this.coordinates()) { - this.user_latitude.set(this.coordinates().latitude); - this.user_longitude.set(this.coordinates().longitude); - return; - } else { - navigator.geolocation.watchPosition( - (_) => this._updateGeolocation(_), - (_) => this._handleGeolocationError(_), - ); - const options = { timeout: 10000, enableHighAccuracy: true }; - navigator.geolocation.getCurrentPosition( - (position: GeolocationPosition) => { - if ( - !this._userWithinRadius([ - position.coords.latitude, - position.coords.longitude, - ]) - ) { - // Only use geolocation if user is within 1km of building - return this._setLocationToBuilding(); - } - this._updateGeolocation(position); - this.map_instance.setCenter({ - lat: position.coords.latitude, - lng: position.coords.longitude, - }); - }, - () => this._setLocationToBuilding(), - options, - ); - } - } - - private _userWithinRadius( - [d_lat, d_long]: [number, number], - radius: number = 1, - ) { - const [lat_str, long_str] = - this._org.building?.location.split(',') || []; - if (lat_str && long_str) { - const lat = parseFloat(lat_str); - const long = parseFloat(long_str); - const distance = calculateDistance(lat, long, d_lat, d_long); - if (distance >= radius) return false; - } - return true; - } - - private _setLocationToBuilding() { - log('MapsIndoors', 'Settings location to building:', [ - this._org.building?.display_name || this._org.building?.name, - this._org.building?.location, - ]); - const [lat, long] = this._org.building?.location.split(','); - if (!this.map_instance) return; - this.map_instance.setCenter({ - lat: parseFloat(lat), - lng: parseFloat(long), - }); - } - - private _updateGeolocation(updated_location: GeolocationPosition) { - if (!updated_location?.coords) return; - log( - 'MapsIndoors', - 'Settings location to user:', - updated_location.coords, - 'warn', - ); - const { latitude, longitude } = updated_location.coords; - if ( - latitude !== this.user_latitude() || - longitude !== this.user_longitude() - ) { - this.user_latitude.set(latitude); - this.user_longitude.set(longitude); - this.getRoute(this.selected_destination); - } - } - - private _handleGeolocationError(error: any) { - notifyError(`Error retrieving your geolocation. [${error.message}]`); - } - - public async getRoute(location: any) { - this.maps_service?.highlight([]); - if (!this.directions_service || !location) return; - log('MapsIndoors', 'Getting route to location:', [ - location, - this.user_latitude(), - this.user_longitude(), - ]); - this.selected_destination = location; - const destination = { - lat: location.properties.anchor.coordinates[1], - lng: location.properties.anchor.coordinates[0], - floor: location.properties.floor, - }; - const level_id: string = (this.floor_mapping || {})[ - location.properties.floor - ]; - if (level_id) this._state.setLevel(level_id); - - if ( - !this._userWithinRadius( - [this.user_latitude(), this.user_longitude()], - 1000, - ) - ) { - this.map_instance.setZoom(19); - this.map_instance.setCenter(destination); - this.maps_service.setFloor(destination.floor); - this.maps_service.highlight([location.id]); - return; - } - - if (!this.user_latitude() || !this.user_longitude()) { - return notifyError('Unable to find a route.'); - } - - const origin = { - lat: this.user_latitude(), - lng: this.user_longitude(), - }; - - const routeParameters = { - origin: origin, - destination: destination, - }; - - const result = await this.directions_service - .getRoute(routeParameters) - .catch((e) => { - log( - 'MapsIndoors', - 'Error fetching route: ', - e.message || e, - 'warn', - ); - const origin_error = - e instanceof TypeError && e.message?.includes('origin'); - if (!origin_error) return; - notifyError('Error: Origin location is outside of map area.'); - }); - if (!result) return; - this.directions_renderer?.setRoute(result); - } - - async renderSpaceStatus(): Promise { - const styles = this.styles(); - if (!styles) return; - const promises: Promise[] = []; - for (const key in styles) { - const colour = styles[key]['fill'] as string; - if (key) { - const updated_key = key.substring(1); - promises.push(this._setPolygonFill(updated_key, colour)); - } - } - return await Promise.all(promises); - } - - public async mapActions() { - return this.actions()?.reduce((accumulator, currentValue) => { - accumulator[currentValue.id] = currentValue; - return accumulator; - }, {}); - } - - private _setPolygonFill(location_id: string, colour: string) { - return this.maps_service?.setDisplayRule(location_id, { - polygonVisible: true, - polygonFillOpacity: 0.6, - polygonZoomFrom: 16, - polygonZoomTo: 22, - visible: true, - polygonFillColor: colour, - }); - } -} diff --git a/libs/components/src/lib/limit-input.directive.ts b/libs/components/src/lib/limit-input.directive.ts deleted file mode 100644 index c7f41ba1283..00000000000 --- a/libs/components/src/lib/limit-input.directive.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { - Directive, - ElementRef, - HostListener, - inject, - input, -} from '@angular/core'; - -const ALLOWED_NUMBERS = '0123456789'.split(''); -const ALLOWED_DECIMALS = '0123456789.'.split(''); -const ALLOWED_LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split(''); -const ALLOWED_ALPHANUMERIC = 'abcdefghijklmnopqrstuvwxyz0123456789'.split(''); - -@Directive({ - selector: '[limitInput]', -}) -export class LimitInputDirective { - private _el = inject(ElementRef); - - readonly restriction = input< - 'number' | 'decimals' | 'letters' | 'alphanumeric' | 'custom' - >('decimals'); - readonly custom_list = input([]); - - @HostListener('keydown', ['$event']) onKeyDown(event) { - const e: KeyboardEvent = event; - const key = e.key; - if ( - ['ArrowLeft', 'ArrowRight', 'Tab', 'Backspace', 'Home'].indexOf( - key, - ) !== -1 || - // Allow: Ctrl+A - (e.key === 'a' && (e.ctrlKey || e.metaKey)) || - // Allow: Ctrl+C - (e.key === 'c' && (e.ctrlKey || e.metaKey)) || - // Allow: Ctrl+V - (e.key === 'v' && (e.ctrlKey || e.metaKey)) || - // Allow: Ctrl+X - (e.key === 'x' && (e.ctrlKey || e.metaKey)) - ) { - // let it happen, don't do anything - return; - } - switch (this.restriction()) { - case 'letters': - if (ALLOWED_LETTERS.indexOf(key) === -1) { - e.preventDefault(); - } - break; - case 'alphanumeric': - if (ALLOWED_ALPHANUMERIC.indexOf(key) === -1) { - e.preventDefault(); - } - break; - case 'custom': - if (this.custom_list().indexOf(key) === -1) { - e.preventDefault(); - } - break; - case 'decimals': - if (ALLOWED_DECIMALS.indexOf(key) === -1) { - e.preventDefault(); - } - break; - case 'number': - default: - if (ALLOWED_NUMBERS.indexOf(key) === -1) { - e.preventDefault(); - } - break; - } - } -} diff --git a/libs/components/src/lib/login.component.ts b/libs/components/src/lib/login.component.ts deleted file mode 100644 index 3fbf07a101a..00000000000 --- a/libs/components/src/lib/login.component.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { - Component, - computed, - ElementRef, - inject, - OnInit, - signal, - viewChild, -} from '@angular/core'; -import { form, FormField, required, validate } from '@angular/forms/signals'; -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 { SettingsService } from '@placeos/common'; -import { first } from 'rxjs/operators'; -import { AuthenticatedImageDirective } from './authenticated-image.directive'; - -@Component({ - selector: 'app-login', - template: ` -
-
-
- Logo -
-
- @if (!loading()) { -
- - - - -
-
- - - - Invalid username or password - -
- } @else { -
- -

Checking user credentials...

-
- } -
- @if (!loading()) { -
- -
- } -
-
- `, - styles: [ - ` - :host > div { - background-color: #f0f0f0; - background-image: url("data:image/svg+xml,%3Csvg width='64' height='64' viewBox='0 0 64 64' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 16c4.418 0 8-3.582 8-8s-3.582-8-8-8-8 3.582-8 8 3.582 8 8 8zm0-2c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6zm33.414-6l5.95-5.95L45.95.636 40 6.586 34.05.636 32.636 2.05 38.586 8l-5.95 5.95 1.414 1.414L40 9.414l5.95 5.95 1.414-1.414L41.414 8zM40 48c4.418 0 8-3.582 8-8s-3.582-8-8-8-8 3.582-8 8 3.582 8 8 8zm0-2c3.314 0 6-2.686 6-6s-2.686-6-6-6-6 2.686-6 6 2.686 6 6 6zM9.414 40l5.95-5.95-1.414-1.414L8 38.586l-5.95-5.95L.636 34.05 6.586 40l-5.95 5.95 1.414 1.414L8 41.414l5.95 5.95 1.414-1.414L9.414 40z' fill='%23ad1457' fill-opacity='0.2' fill-rule='evenodd'/%3E%3C/svg%3E"); - } - - form { - width: 20rem; - max-width: calc(100vw - 2rem); - } - - img { - max-height: 3.75rem; - max-width: 15rem; - } - `, - ], - imports: [ - MatRippleModule, - MatProgressSpinnerModule, - MatFormFieldModule, - MatInputModule, - AuthenticatedImageDirective, - FormField, - ], -}) -export class LoginComponent implements OnInit { - private _settings = inject(SettingsService); - private readonly _theme = this._settings.theme_signal; - private readonly _logo_dark = this._settings.signal('logo_dark', null); - private readonly _logo_light = this._settings.signal('logo_light', null); - - /** Whether the user credentials are being checked */ - public loading = signal(false); - /** Current focused field */ - public focus = signal(''); - - /** Whether the last login attempt failed credential validation */ - private readonly _invalid_credentials = signal(false); - - public readonly model = signal({ username: '', password: '' }); - public readonly form = form(this.model, (p) => { - required(p.username); - required(p.password); - validate(p.password, () => - this._invalid_credentials() - ? { kind: 'invalid_credentials' } - : undefined, - ); - }); - - /** Password field element */ - private readonly pwd_field = - viewChild>('pass_field'); - - public readonly logo = computed( - () => - (this._theme() === 'dark' - ? this._logo_dark() - : this._logo_light()) || {}, - ); - - public async ngOnInit() { - this.loading.set(true); - await this._settings.initialised.pipe(first((_) => _)).toPromise(); - this.loading.set(false); - } - - /** Focus on the password field */ - public toPassword() { - const pwd_field = this.pwd_field(); - if (pwd_field && pwd_field.nativeElement) { - pwd_field.nativeElement.focus(); - } - } - - /** Perform user login */ - public login() { - this.loading.set(true); - // this._users.login({ - // username: this.username, - // password: this.password - // }, { form: true }).then(() => { - // this.loading = false; - // }, () => { - // this.loading = false; - // this.error = true; - // }); - this._invalid_credentials.set(true); - this.form().markAsTouched(); - } -} diff --git a/libs/components/src/lib/map-polygon.component.ts b/libs/components/src/lib/map-polygon.component.ts deleted file mode 100644 index fa9d4a42647..00000000000 --- a/libs/components/src/lib/map-polygon.component.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; - -import { MAP_FEATURE_DATA } from 'libs/common/src/lib/types'; - -export interface MapPolygonData { - /** Name of the region */ - name: string; - /** Color to display region in on overlay */ - color: string; - /** Array of points that define the shape of the region */ - points: [number, number][]; - /** */ - ratio?: number; - svg_ratio?: number; - zoom_value?: number; -} - -@Component({ - selector: '[map-polygon]', - template: ` -
-
- - - -
-
-
- {{ name() }} -
- `, - styles: [ - ` - [polygon] { - will-change: transform; - } - - polygon { - stroke-width: 2; - } - circle { - stroke-width: 2; - } - - [text] { - width: 32rem; - max-width: 65vw; - } - `, - ], -}) -export class MapPolygonComponent implements OnInit { - private _details = inject(MAP_FEATURE_DATA); - - /** Message to display above the pin */ - public readonly name = signal(this._details.name); - /** Fill colour for the pin SVG */ - public readonly fill = signal(`${this._details.color || '#e53935'}88`); - /** Stroke colour for the pin SVG */ - public readonly stroke = signal(this._details.color || '#e53935'); - public readonly width = signal(1); - public readonly height = signal(1); - public readonly svg_scale = 20; - - public readonly scale = signal(this._details.svg_ratio || 1); - public readonly zoom_value = signal(this._details.zoom_value || 1); - - /** List of points for drawing the polygon */ - public readonly points = signal('0,0 0,1 1,1 1,0'); - - public readonly point_list = signal<[number, number][]>([]); - - public ngOnInit(): void { - this.processPoints(this._details.points); - } - - public processPoints(points: [number, number][]) { - const diff: Record = points.reduce( - (m, [x, y]) => ({ - x_min: x < m.x_min ? x : m.x_min, - x_max: x > m.x_max ? x : m.x_max, - y_min: y < m.y_min ? y : m.y_min, - y_max: y > m.y_max ? y : m.y_max, - }), - { - x_min: 100, - x_max: -100, - y_min: 100, - y_max: -100, - }, - ); - const range = { - x: diff.x_max - diff.x_min, - y: diff.y_max - diff.y_min, - }; - const { ratio } = this._details; - let w = range.x * 100; - let h = range.y * 100 * (ratio || 1); - w = Math.floor(w * 100); - h = Math.floor(h * 100); - this.width.set(w); - this.height.set(h); - this.points.set( - points - .reduce( - (s, [x, y]) => - `${s}${s ? ' ' : ''}${ - (((x - diff.x_min) / range.x) * w) / 20 - },${(((y - diff.y_min) / range.y) * h) / 20}`, - '', - ) - .replace(/NaN/g, '0'), - ); - this.point_list.set( - points.map(([x, y]) => [ - (((x - diff.x_min) / range.x) * w) / 20, - (((y - diff.y_min) / range.y) * h) / 20, - ]), - ); - } -} diff --git a/libs/components/src/lib/map-viewer.class.ts b/libs/components/src/lib/map-viewer.class.ts index 438e2ebac06..a9715323f3a 100644 --- a/libs/components/src/lib/map-viewer.class.ts +++ b/libs/components/src/lib/map-viewer.class.ts @@ -6,7 +6,7 @@ import { } from '@placeos/common'; import { apiKey, authority, token } from '@placeos/ts-client'; -import { setAuthCookie } from './authenticated-image.pipe'; +import { setAuthCookie } from './authenticated-image'; export interface MapDetails { raw_data: string; diff --git a/libs/components/src/lib/popout-menu.component.ts b/libs/components/src/lib/popout-menu.component.ts deleted file mode 100644 index 628ba89533f..00000000000 --- a/libs/components/src/lib/popout-menu.component.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Component, input, output, signal } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; -import { ApplicationIcon, AsyncHandler } from '@placeos/common'; -import { IconComponent } from './icon.component'; - -export interface PopoutAction extends ApplicationIcon { - /** ID of the action */ - id: string; -} - -@Component({ - selector: 'a-popout-menu', - template: ` - @for (item of actions(); track item; let i = $index) { - - } - - `, - styles: [ - ` - :host { - position: relative; - } - - icon { - transition: transform 200ms; - } - - button { - background-color: #fff; - color: rgba(#000, 0.85); - font-size: 1.25rem; - height: 2.5em; - width: 2.5em; - } - - button.show icon { - transform: rotate(45deg); - } - - button.small { - transition: - top 200ms, - left 200ms; - } - `, - ], - imports: [IconComponent, MatRippleModule], -}) -export class PopoutMenuComponent extends AsyncHandler { - /** List of available actions */ - public readonly actions = input([]); - /** Emitter for user actions */ - public readonly action = output(); - /** Whether actions should show */ - public readonly show = signal(false); - - public close() { - this.timeout('close', () => this.show.set(false)); - } -} diff --git a/libs/components/src/lib/status-overlay.component.ts b/libs/components/src/lib/status-overlay.component.ts deleted file mode 100644 index a397f7f576c..00000000000 --- a/libs/components/src/lib/status-overlay.component.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Component, input } from '@angular/core'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { RouterModule } from '@angular/router'; -import { ApplicationLink } from '@placeos/common'; -import { IconComponent } from './icon.component'; - -@Component({ - selector: 'status-overlay', - template: ` -
- @if (!loading()) { -
- {{ error() ? 'close' : 'done' }} -
-
- @if (!error()) { - - } @else { -

{{ error() }}

- } -
-
- @for (link of links(); track link) { - - {{ link.name }} - - } -
- } @else { - -

{{ loading() }}

- } -
- `, - styles: [``], - imports: [RouterModule, MatProgressSpinnerModule, IconComponent], -}) -export class StatusOverlayComponent { - /** Whether overlay is loading */ - public readonly loading = input(''); - /** Details about any errors that have occurred */ - public readonly error = input(''); - /** Links to display on success */ - public readonly links = input([]); -} diff --git a/libs/components/src/lib/thumbnail.pipe.ts b/libs/components/src/lib/thumbnail.pipe.ts deleted file mode 100644 index b8d688496ee..00000000000 --- a/libs/components/src/lib/thumbnail.pipe.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -@Pipe({ - name: 'thumbnail', -}) -export class ThumbnailPipe implements PipeTransform { - private _thumbnail_cache: Record = {}; - - public transform(url: string): string { - if (!url) return null; - if (!this._thumbnail_cache[url]) { - createVideoThumbnail(url) - .then((_) => (this._thumbnail_cache[url] = _)) - .catch((GENERATING[url] = null)); - } - return this._thumbnail_cache[url] || ''; - } -} - -const GENERATING = {}; - -function createVideoThumbnail(url: string) { - if (!GENERATING[url]) { - GENERATING[url] = new Promise((resolve) => { - var video = document.createElement('video'); - video.crossOrigin = 'anonymous'; - video.src = url; - var canvas = document.createElement('canvas'); - var context = canvas.getContext('2d'); - video.addEventListener('loadedmetadata', () => { - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - video.currentTime = 1; - }); - - video.addEventListener('seeked', () => { - context.drawImage(video, 0, 0, canvas.width, canvas.height); - var thumbnail = canvas.toDataURL('image/png'); - resolve(thumbnail); - }); - video.load(); - }); - } - return GENERATING[url]; -} diff --git a/libs/components/src/lib/upload-permissions-modal.component.ts b/libs/components/src/lib/upload-permissions-modal.component.ts deleted file mode 100644 index ed70d2f6bf5..00000000000 --- a/libs/components/src/lib/upload-permissions-modal.component.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Component, NO_ERRORS_SCHEMA, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; -import { - MAT_DIALOG_DATA, - MatDialogModule, - MatDialogRef, -} from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatSelectModule } from '@angular/material/select'; -import { UploadPermissions } from '@placeos/common'; -import { IconComponent } from './icon.component'; - -@Component({ - selector: 'upload-permissions-modal', - template: ` -
-

Upload File

- -
-
-
- - - - -
- @if (!is_public()) { -
- - - - None - Support - Admin - - -
- } - -
-
- - -
- `, - schemas: [NO_ERRORS_SCHEMA], - styles: [``], - imports: [ - MatDialogModule, - MatFormFieldModule, - FormsModule, - MatSelectModule, - IconComponent, - MatInputModule, - MatRippleModule, - ], -}) -export class UploadPermissionsModalComponent { - private _dialog_ref = - inject>(MatDialogRef); - private _data = inject<{ - file: File; - is_public: boolean; - }>(MAT_DIALOG_DATA); - - /** File to upload */ - public readonly file: File = this._data.file; - /** Whether file should be public */ - public is_public = signal(!!this._data.is_public); - /** Permissions for file */ - public permissions = signal('none'); - - constructor() { - this.file = this._data.file; - } - - public close() { - this._dialog_ref.close(); - } -} diff --git a/libs/components/src/lib/zone.pipe.ts b/libs/components/src/lib/zone.pipe.ts deleted file mode 100644 index 9653402dee3..00000000000 --- a/libs/components/src/lib/zone.pipe.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import { PlaceZone, showZone } from '@placeos/ts-client'; - -const ZONE_LIST: Record = {}; -const EMPTY_ZONE = new PlaceZone(); - -@Pipe({ - name: 'zone', -}) -export class ZonePipe implements PipeTransform { - public async transform(id: string): Promise { - if (ZONE_LIST[id]) return ZONE_LIST[id]; - const zone = await showZone(id).catch((_) => null); - if (!zone) return EMPTY_ZONE; - ZONE_LIST[id] = zone; - return zone; - } -} diff --git a/libs/components/src/tests/authenticated-image.pipe.spec.ts b/libs/components/src/tests/authenticated-image.spec.ts similarity index 98% rename from libs/components/src/tests/authenticated-image.pipe.spec.ts rename to libs/components/src/tests/authenticated-image.spec.ts index b6f9e1d5603..dd4bb13e6d6 100644 --- a/libs/components/src/tests/authenticated-image.pipe.spec.ts +++ b/libs/components/src/tests/authenticated-image.spec.ts @@ -1,7 +1,7 @@ import { clearAuthenticatedImageCache, loadAuthenticatedImageWithHeader, -} from '../lib/authenticated-image.pipe'; +} from '../lib/authenticated-image'; vi.mock('@placeos/ts-client', { spy: true }); diff --git a/libs/components/src/tests/authorised-admin.guard.spec.ts b/libs/components/src/tests/authorised-admin.guard.spec.ts deleted file mode 100644 index 0d8ca99cb96..00000000000 --- a/libs/components/src/tests/authorised-admin.guard.spec.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Router } from '@angular/router'; -import { - createServiceFactory, - SpectatorService, -} from '@ngneat/spectator/vitest'; -import { setCurrentUser } from '@placeos/common'; -import { of } from 'rxjs'; - -// The real `current_user` store is driven via `setCurrentUser`; only the -// ts-client signal helpers used by the guard are stubbed. -vi.mock('@placeos/ts-client', { spy: true }); - -import * as ts_client from '@placeos/ts-client'; - -import { AuthorisedAdminGuard } from '../lib/authorised-admin.guard'; - -describe('AuthorisedAdminGuard', () => { - let spectator: SpectatorService; - const createService = createServiceFactory({ - service: AuthorisedAdminGuard, - providers: [{ provide: Router, useValue: { navigate: vi.fn() } }], - }); - - beforeEach(() => { - vi.clearAllMocks(); - setCurrentUser({ groups: [] } as any); - vi.mocked(ts_client.onlineState).mockReturnValue(of(true) as any); - vi.mocked(ts_client.waitForSignal).mockResolvedValue(true); - spectator = createService(); - }); - - it('should create the service', () => { - expect(spectator.service).toBeTruthy(); - }); - - it('should check if logged in user can activate a route', async () => { - setCurrentUser({ groups: [] } as any); - let can_activate = await spectator.service.canActivate(); - expect(can_activate).toBeFalsy(); - setCurrentUser({ groups: ['placeos_admin'] } as any); - can_activate = await spectator.service.canActivate(); - expect(can_activate).toBeTruthy(); - }); - - it('should check if logged in user can load a route', async () => { - setCurrentUser({ groups: [] } as any); - let can_load = await spectator.service.canLoad(); - expect(can_load).toBeFalsy(); - setCurrentUser({ groups: ['placeos_admin'] } as any); - can_load = await spectator.service.canLoad(); - expect(can_load).toBeTruthy(); - }); -}); diff --git a/libs/components/src/tests/fixed.pipe.spec.ts b/libs/components/src/tests/fixed.pipe.spec.ts deleted file mode 100644 index aea46ca1df8..00000000000 --- a/libs/components/src/tests/fixed.pipe.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { createPipeFactory, SpectatorPipe } from '@ngneat/spectator/vitest'; -import { FixedPipe } from '../lib/fixed.pipe'; - -describe('FixedPipe', () => { - let spectator: SpectatorPipe; - const createPipe = createPipeFactory(FixedPipe); - - it('should fix number to two decimal places by default', () => { - spectator = createPipe(`{{ 0 | toFixed }}`); - expect(spectator.element).toHaveText('0.00'); - }); - it('should allow user to set number of decimal places', () => { - spectator = createPipe(`{{ 1 | toFixed:5 }}`); - expect(spectator.element).toHaveText('1.00000'); - }); -}); diff --git a/libs/components/src/tests/map-polygon.component.spec.ts b/libs/components/src/tests/map-polygon.component.spec.ts deleted file mode 100644 index 5de06c06e17..00000000000 --- a/libs/components/src/tests/map-polygon.component.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest'; -import { MAP_FEATURE_DATA } from '@placeos/common'; - -import { MapPolygonComponent } from '../lib/map-polygon.component'; - -describe('MapPolygonComponent', () => { - let spectator: Spectator; - - const POINTS: [number, number][] = [ - [0.25, 0.25], - [0.75, 0.25], - [0.75, 0.75], - [0.25, 0.75], - ]; - - const createComponent = createComponentFactory({ - component: MapPolygonComponent, - providers: [ - { - provide: MAP_FEATURE_DATA, - useValue: { - name: 'Test Region', - color: '#123456', - points: POINTS, - }, - }, - ], - }); - - it('should create component', () => { - spectator = createComponent(); - expect(spectator.component).toBeTruthy(); - }); - - it('should display the region name', () => { - spectator = createComponent(); - expect('[text]').toContainText('Test Region'); - }); - - it('should normalise points to the bounding box of the region', () => { - spectator = createComponent(); - expect(spectator.component.width()).toBe(5000); - expect(spectator.component.height()).toBe(5000); - expect(spectator.component.points()).toBe('0,0 250,0 250,250 0,250'); - expect(spectator.query('polygon').getAttribute('points')).toBe( - '0,0 250,0 250,250 0,250', - ); - }); - - it('should derive fill and stroke from the region colour', () => { - spectator = createComponent(); - expect(spectator.component.fill()).toBe('#12345688'); - expect(spectator.component.stroke()).toBe('#123456'); - }); - - it('should fallback to the default colour when none is set', () => { - spectator = createComponent({ - providers: [ - { - provide: MAP_FEATURE_DATA, - useValue: { name: 'No Colour', points: POINTS }, - }, - ], - }); - expect(spectator.component.fill()).toBe('#e5393588'); - expect(spectator.component.stroke()).toBe('#e53935'); - }); - - it('should replace NaN with 0 for degenerate point ranges', () => { - spectator = createComponent(); - spectator.component.processPoints([ - [0.5, 0.2], - [0.5, 0.8], - ]); - expect(spectator.component.points()).toBe('0,0 0,300'); - }); -}); diff --git a/libs/components/src/tests/popout-menu.component.spec.ts b/libs/components/src/tests/popout-menu.component.spec.ts deleted file mode 100644 index fa444e1e775..00000000000 --- a/libs/components/src/tests/popout-menu.component.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; - -import { IconComponent } from '../lib/icon.component'; -import { PopoutMenuComponent } from '../lib/popout-menu.component'; - -describe('PopoutMenuComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: PopoutMenuComponent, - declarations: [MockComponent(IconComponent)], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should hide action buttons behind root button', () => { - spectator.setInput({ - actions: [ - { id: 'kill', content: 'delete' }, - { id: 'create', content: 'add' }, - ], - }); - spectator.detectChanges(); - expect('button.small').toHaveLength(2); - expect(spectator.query('button.small')).not.toHaveAttribute('style'); - spectator.click('button[name="root"]'); - spectator.detectChanges(); - expect(spectator.query('button.small')).toHaveAttribute( - 'style', - 'top: -60%;', - ); - }); - - it('should emit action button clicks', () => { - const spy = vi.spyOn(spectator.component.action, 'emit'); - spectator.setInput({ - actions: [ - { id: 'kill', content: 'delete' }, - { id: 'create', content: 'add' }, - ], - }); - spectator.detectChanges(); - expect('button.small').toHaveLength(2); - const buttons = spectator.queryAll('button.small'); - spectator.click(buttons[0]); - expect(spy).toHaveBeenCalledWith('kill'); - expect(spy).not.toHaveBeenCalledWith('create'); - spectator.click(buttons[1]); - expect(spy).toHaveBeenCalledWith('create'); - }); -}); diff --git a/libs/components/src/tests/status-overlay.component.spec.ts b/libs/components/src/tests/status-overlay.component.spec.ts deleted file mode 100644 index 342c32f9cf3..00000000000 --- a/libs/components/src/tests/status-overlay.component.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { - SpectatorRouting, - createRoutingFactory, -} from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; - -import { IconComponent } from '../lib/icon.component'; -import { StatusOverlayComponent } from '../lib/status-overlay.component'; - -describe('StatusOverlayComponent', () => { - let spectator: SpectatorRouting; - const createComponent = createRoutingFactory({ - component: StatusOverlayComponent, - declarations: [MockComponent(IconComponent)], - imports: [MatProgressSpinnerModule], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should be able to show a success message', () => { - expect('[error]').not.toExist(); - spectator.setInput({ error: 'Test Error' }); - spectator.detectChanges(); - expect('[error]').toExist(); - expect('[error]').toContainText('Test Error'); - }); - - it('should be able to show links', () => { - expect('a[btn]').toHaveLength(0); - spectator.setInput({ - links: [ - { name: 'Test Link 1', route: '/one' }, - { name: 'Test Link 2', route: '/two' }, - ], - }); - spectator.detectChanges(); - expect('a[btn]').toHaveLength(2); - expect('a[btn]').toContainText('Test Link 1'); - expect('a[btn]').toContainText('Test Link 2'); - }); - - it('should be able to show a loading state', () => { - expect('mat-spinner').not.toExist(); - spectator.setInput({ loading: 'Test Loading...' }); - spectator.detectChanges(); - expect('mat-spinner').toExist(); - expect('p').toContainText('Test Loading...'); - }); -}); diff --git a/libs/components/src/tests/upload-permissions-modal.component.spec.ts b/libs/components/src/tests/upload-permissions-modal.component.spec.ts deleted file mode 100644 index cede35c9af5..00000000000 --- a/libs/components/src/tests/upload-permissions-modal.component.spec.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { provideNoopAnimations } from '@angular/platform-browser/animations'; -import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; - -import { IconComponent } from '../lib/icon.component'; -import { UploadPermissionsModalComponent } from '../lib/upload-permissions-modal.component'; - -describe('UploadPermissionsModalComponent', () => { - let spectator: Spectator; - const test_file = new File(['contents'], 'report.pdf'); - const close_spy = vi.fn(); - const createComponent = createComponentFactory({ - component: UploadPermissionsModalComponent, - declarations: [MockComponent(IconComponent)], - providers: [ - { provide: MatDialogRef, useValue: { close: close_spy } }, - { - provide: MAT_DIALOG_DATA, - useValue: { file: test_file, is_public: false }, - }, - provideNoopAnimations(), - ], - }); - - beforeEach(() => { - close_spy.mockClear(); - spectator = createComponent(); - }); - - it('should create component', () => { - expect(spectator.component).toBeTruthy(); - expect(spectator.component.file).toBe(test_file); - }); - - it('should show the permission selector for non-public files', () => { - expect('mat-select').toExist(); - spectator.component.is_public.set(true); - spectator.detectChanges(); - expect('mat-select').not.toExist(); - }); - - it('should close with the file details on upload', () => { - spectator.component.permissions.set('admin'); - spectator.detectChanges(); - const buttons = spectator.queryAll('footer button'); - spectator.click(buttons[1]); - expect(close_spy).toHaveBeenCalledWith({ - file: test_file, - is_public: false, - permissions: 'admin', - }); - }); - - it('should close without a result on cancel', () => { - const buttons = spectator.queryAll('footer button'); - spectator.click(buttons[0]); - expect(close_spy).toHaveBeenCalledWith(''); - }); - - it('should close the dialog via the close method', () => { - spectator.component.close(); - expect(close_spy).toHaveBeenCalled(); - }); -}); diff --git a/libs/explore/src/tests/explore-device-info.component.spec.ts b/libs/explore/src/tests/explore-device-info.component.spec.ts index 51ad02e2644..3772563784a 100644 --- a/libs/explore/src/tests/explore-device-info.component.spec.ts +++ b/libs/explore/src/tests/explore-device-info.component.spec.ts @@ -8,13 +8,12 @@ import { ExploreDeviceInfoComponent } from '../lib/explore-device-info.component vi.mock('@placeos/ts-client'); import { MAP_FEATURE_DATA, SettingsService } from '@placeos/common'; -import { FixedPipe } from 'libs/components/src/lib/fixed.pipe'; describe('ExploreDeviceInfoComponent', () => { let spectator: Spectator; const createComponent = createComponentFactory({ component: ExploreDeviceInfoComponent, - ...ngMocks.guts(null, [FixedPipe, PortalModule, OverlayModule]), + ...ngMocks.guts(null, [PortalModule, OverlayModule]), providers: [ MockProvider(MAP_FEATURE_DATA, { mac: 'User', diff --git a/libs/explore/src/tests/explore-map-view.component.spec.ts b/libs/explore/src/tests/explore-map-view.component.spec.ts index 4a0ef36f70b..0b1f1574e36 100644 --- a/libs/explore/src/tests/explore-map-view.component.spec.ts +++ b/libs/explore/src/tests/explore-map-view.component.spec.ts @@ -13,7 +13,6 @@ import { OrganisationService, SettingsService } from '@placeos/common'; import { SpacesService } from 'libs/events/src/lib/spaces.service'; import { MapsPeopleService } from 'libs/common/src/lib/mapspeople.service'; -import { IndoorMapsComponent } from 'libs/components/src/lib/indoor-maps.component'; import { InteractiveMapComponent } from 'libs/components/src/lib/interactive-map.component'; import { SettingsToggleComponent } from 'libs/components/src/lib/settings-toggle.component'; import { SpacePipe } from 'libs/events/src/lib/space.pipe'; @@ -51,7 +50,6 @@ describe('ExploreMapViewComponent', () => { MockComponent(InteractiveMapComponent), MockComponent(ExploreMapControlComponent), MockComponent(ExploreZoomControlComponent), - MockComponent(IndoorMapsComponent), ], componentProviders: [ MockProvider(ExploreSpacesService), diff --git a/libs/form-fields/src/index.ts b/libs/form-fields/src/index.ts index ccce7aa7b09..7507402e09d 100644 --- a/libs/form-fields/src/index.ts +++ b/libs/form-fields/src/index.ts @@ -1,4 +1,3 @@ -export * from './lib/action-field.component'; export * from './lib/counter.component'; export * from './lib/date-calendar.component'; export * from './lib/date-field.component'; diff --git a/libs/form-fields/src/lib/action-field.component.ts b/libs/form-fields/src/lib/action-field.component.ts deleted file mode 100644 index de689e2392a..00000000000 --- a/libs/form-fields/src/lib/action-field.component.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Component, input, output, signal } from '@angular/core'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; - -@Component({ - selector: 'an-action-field', - template: ` - - `, - styles: [ - ` - :host > div:hover { - box-shadow: inset 0 0 0 1px #000; - } - `, - ], - imports: [IconComponent], -}) -export class ActionFieldComponent { - /** Label of the field */ - public readonly label = input(undefined); - /** Whether form field is disabled */ - public readonly disabled = input(undefined); - /** Emitter for user interaction events */ - public readonly on_action = output({ alias: 'onAction' }); - /** Whether to show tooltip */ - public readonly show_tooltip = signal(false); - - /** - * Emit that the user has performed an action on the field - */ - public performAction() { - this.show_tooltip.update((state) => !state); - this.on_action.emit(); - } -} diff --git a/libs/form-fields/src/lib/compact-counter.component.ts b/libs/form-fields/src/lib/compact-counter.component.ts index 64e675f4e87..b3ccf2bfe68 100644 --- a/libs/form-fields/src/lib/compact-counter.component.ts +++ b/libs/form-fields/src/lib/compact-counter.component.ts @@ -40,7 +40,6 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; (ngModelChange)="value.set($event)" (focus)="focused.set(true)" (blur)="setValue(+value()); focused.set(false)" - limitInput />
diff --git a/libs/form-fields/src/lib/counter.component.ts b/libs/form-fields/src/lib/counter.component.ts index 9c5e6554082..ef75398ed58 100644 --- a/libs/form-fields/src/lib/counter.component.ts +++ b/libs/form-fields/src/lib/counter.component.ts @@ -53,7 +53,6 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; (ngModelChange)="value.set($event)" (focus)="focused.set(true)" (blur)="setValue(+value()); focused.set(false)" - limitInput />
- @if (force_time()) { - - } @for (option of time_options(); track option.id) { -
- - -
-
-
-

Label Properties

-
- - -
-
-
- - -
-
- - -
-
-
-

Output size

-
-
- - {{ format_mm(width() * scale()) }} -
-
- - {{ format_mm(height() * scale()) }} -
-
-
-
-
- -
-
- - `, - styles: [``], - imports: [ - UserLabelComponent, - MatDialogModule, - FormsModule, - MatRippleModule, - CounterComponent, - IconComponent, - PrintableComponent, - ], -}) -export class UserLabelModalComponent extends AsyncHandler implements OnInit { - private readonly STORAGE_KEY = 'PlaceOS.User.LabelSettings'; - - public readonly details: UserDetails = inject(MAT_DIALOG_DATA); - public readonly scale = signal(4); - public readonly width = signal(25); - public readonly height = signal(15); - - ngOnInit() { - this.loadSettings(); - } - - public readonly format_mm = (value: number) => - `${Math.floor(value * 10) / 10}mm`; - - public readonly print = () => { - this.saveSettings(); - this.timeout('print', () => window.print()); - }; - - private loadSettings(): void { - try { - const stored = localStorage.getItem(this.STORAGE_KEY); - if (stored) { - const settings: UserLabelSettings = JSON.parse(stored); - this.scale.set(settings.scale); - this.width.set(settings.width); - this.height.set(settings.height); - } - } catch (error) { - console.warn( - 'Failed to load user label settings from localStorage:', - error, - ); - } - } - - private saveSettings(): void { - try { - const settings: UserLabelSettings = { - scale: this.scale(), - width: this.width(), - height: this.height(), - }; - localStorage.setItem(this.STORAGE_KEY, JSON.stringify(settings)); - } catch (error) { - console.warn( - 'Failed to save user label settings to localStorage:', - error, - ); - } - } -} diff --git a/libs/users/src/lib/user.pipe.ts b/libs/users/src/lib/user.pipe.ts index 81d3d05b66f..4599fad1c04 100644 --- a/libs/users/src/lib/user.pipe.ts +++ b/libs/users/src/lib/user.pipe.ts @@ -15,12 +15,6 @@ export function addUser(user: User): void { USER_LIST.push(user); } -export function replaceUser(user: User): void { - const index = USER_LIST.findIndex(({ id }) => id === user.id); - if (index !== -1) USER_LIST[index] = user; - else USER_LIST.push(user); -} - async function fetchUser( user_id: string, lookup_mode: UserLookupMode, diff --git a/libs/users/src/lib/user.utilities.ts b/libs/users/src/lib/user.utilities.ts index 9990f0ab9de..e14d4176233 100644 --- a/libs/users/src/lib/user.utilities.ts +++ b/libs/users/src/lib/user.utilities.ts @@ -1,7 +1,7 @@ import { Injector, signal } from '@angular/core'; import { email, form, required, validate } from '@angular/forms/signals'; -import { GuestUser, predictableRandomInt, unique, User } from '@placeos/common'; +import { predictableRandomInt, unique, User } from '@placeos/common'; const PHONE_PATTERN = /^\+?(\d[\d\s\-\(\)]{5,13}\d)$/; @@ -117,41 +117,3 @@ export function generateUserForm(user?: User, injector?: Injector) { injector ? { injector } : undefined, ); } - -export interface GuestFormValue { - name: string; - email: string; - organisation: string; - phone: string; - pass_number: string; - host: string; -} - -/** Signal-forms field tree for a guest form */ -export type GuestForm = ReturnType; - -export function generateGuestForm( - user: GuestUser = new GuestUser(), - host: string = '', - injector?: Injector, -) { - const model = signal({ - name: user.name || '', - email: user.email || '', - organisation: user.organisation || '', - phone: user.phone || '', - pass_number: user.extension_data.pass_number || '', - host: host || '', - }); - - return form( - model, - (p) => { - required(p.email); - email(p.email); - required(p.organisation); - required(p.host); - }, - injector ? { injector } : undefined, - ); -} diff --git a/libs/users/src/lib/wfh-settings-modal.component.ts b/libs/users/src/lib/wfh-settings-modal.component.ts index a5b8e426f53..49b95d3e298 100644 --- a/libs/users/src/lib/wfh-settings-modal.component.ts +++ b/libs/users/src/lib/wfh-settings-modal.component.ts @@ -269,9 +269,7 @@ export class WFHSettingsModalComponent implements OnInit { public readonly option = signal(''); public readonly settings = signal([]); public readonly weekdays_enabled = signal>({}); - public readonly changed = signal(false); public readonly loading = signal(false); - public readonly available_weekdays = signal([]); public readonly days = new Array(7) .fill(0) .map((_, idx) => addDays(startOfWeek(addDays(Date.now(), 30)), idx)); @@ -283,10 +281,6 @@ export class WFHSettingsModalComponent implements OnInit { ); }); - public readonly option_name = computed( - () => this.options().find((_) => _.id === this.option())?.name || '', - ); - public readonly now = computed(() => startOfMinute(Date.now()).getTime()); public ngOnInit() { diff --git a/libs/users/src/tests/guests.fn.spec.ts b/libs/users/src/tests/guests.fn.spec.ts index a1f9f26c0a9..736057070c4 100644 --- a/libs/users/src/tests/guests.fn.spec.ts +++ b/libs/users/src/tests/guests.fn.spec.ts @@ -1,15 +1,8 @@ -import { - listGuestMeetings, - queryGuests, - removeGuest, - searchGuests, - showGuest, - updateGuest, -} from '../lib/guests.fn'; +import { searchGuests, showGuest, updateGuest } from '../lib/guests.fn'; vi.mock('@placeos/ts-client'); -import { CalendarEvent, GuestUser } from '@placeos/common'; +import { GuestUser } from '@placeos/common'; import * as ts_client from '@placeos/ts-client'; describe('[Guest API]', () => { @@ -38,46 +31,6 @@ describe('[Guest API]', () => { }); }); - describe('queryGuests', () => { - it('should perform GET on guests endpoint', async () => { - const spy = vi.spyOn(ts_client, 'get'); - spy.mockResolvedValue([{ name: 'Jim' }] as any); - const list = await queryGuests({ - period_start: 0, - period_end: 2, - }); - expect(list).toBeInstanceOf(Array); - expect(spy).toHaveBeenCalledWith( - '/api/staff/v1/guests?period_start=0&period_end=2', - ); - }); - - it('should return Guest objects', async () => { - const spy = vi.spyOn(ts_client, 'get'); - spy.mockResolvedValue([{ name: 'Jim' }] as any); - const list = await queryGuests({ - period_start: 0, - period_end: 2, - }); - expect(list).toBeInstanceOf(Array); - expect(list[0]).toBeInstanceOf(GuestUser); - }); - - it('should allow querying Guests', async () => { - const spy = vi.spyOn(ts_client, 'get'); - spy.mockResolvedValue([{ name: 'Jim' }] as any); - await queryGuests({ - period_start: 0, - period_end: 2, - zone_ids: 'zone-123', - system_ids: 'sys-123', - }); - expect(spy).toHaveBeenCalledWith( - '/api/staff/v1/guests?period_start=0&period_end=2&zone_ids=zone-123&system_ids=sys-123', - ); - }); - }); - describe('showGuest', () => { it('should GET guest', async () => { const spy = vi.spyOn(ts_client, 'get'); @@ -101,28 +54,4 @@ describe('[Guest API]', () => { }); }); }); - - describe('removeGuest', () => { - it('should DELETE guest', async () => { - const spy = vi.spyOn(ts_client, 'del'); - spy.mockResolvedValue(undefined); - const item = await removeGuest('jim'); - expect(item).toBeUndefined(); - expect(spy).toHaveBeenCalledWith('/api/staff/v1/guests/jim', { - response_type: 'void', - }); - }); - }); - - describe('listGuestMeetings', () => { - it('should GET calendar events for guest', async () => { - const spy = vi.spyOn(ts_client, 'get'); - spy.mockResolvedValue([{ name: 'Jim' }] as any); - const list = await listGuestMeetings('jim'); - expect(list[0]).toBeInstanceOf(CalendarEvent); - expect(spy).toHaveBeenCalledWith( - '/api/staff/v1/guests/jim/meetings', - ); - }); - }); }); diff --git a/libs/users/src/tests/staff.fn.spec.ts b/libs/users/src/tests/staff.fn.spec.ts index ce61f4a7397..dc4f006ee27 100644 --- a/libs/users/src/tests/staff.fn.spec.ts +++ b/libs/users/src/tests/staff.fn.spec.ts @@ -1,8 +1,4 @@ -import { - locateStaff, - searchStaff as searchDirectory, - showStaff, -} from '../lib/staff.fn'; +import { searchStaff as searchDirectory, showStaff } from '../lib/staff.fn'; vi.mock('@placeos/ts-client', { spy: true }); @@ -172,14 +168,4 @@ describe('[Staff API]', () => { expect(spy).toHaveBeenCalledWith('/api/staff/v1/people/jim'); }); }); - - describe('locateStaff', () => { - it('should GET staff location', async () => { - const spy = vi.spyOn(ts_client, 'get'); - spy.mockResolvedValue({ name: 'Jim' } as any); - const item = await locateStaff('jim'); - expect(item).toBeInstanceOf(StaffUser); - expect(spy).toHaveBeenCalledWith('/api/staff/v1/people/jim'); - }); - }); }); diff --git a/libs/users/src/tests/user-label-modal.component.spec.ts b/libs/users/src/tests/user-label-modal.component.spec.ts deleted file mode 100644 index 2f0486b33ee..00000000000 --- a/libs/users/src/tests/user-label-modal.component.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; - -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { PrintableComponent } from 'libs/components/src/lib/printable.component'; -import { CounterComponent } from 'libs/form-fields/src/lib/counter.component'; -import { UserLabelModalComponent } from '../lib/user-label-modal.component'; -import { UserLabelComponent } from '../lib/user-label.component'; - -const STORAGE_KEY = 'PlaceOS.User.LabelSettings'; - -describe('UserLabelModalComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: UserLabelModalComponent, - declarations: [ - MockComponent(IconComponent), - MockComponent(CounterComponent), - MockComponent(PrintableComponent), - MockComponent(UserLabelComponent), - ], - providers: [ - { - provide: MAT_DIALOG_DATA, - useValue: { name: 'Jane', email: 'jane@place.tech' }, - }, - ], - }); - - beforeEach(() => { - localStorage.clear(); - spectator = createComponent(); - }); - - afterEach(() => localStorage.clear()); - - it('should default the label dimensions', () => { - expect(spectator.component.scale()).toBe(4); - expect(spectator.component.width()).toBe(25); - expect(spectator.component.height()).toBe(15); - }); - - it('should format values as millimetres', () => { - expect(spectator.component.format_mm(3.149)).toBe('3.1mm'); - expect(spectator.component.format_mm(10)).toBe('10mm'); - }); - - it('should load stored settings on init', () => { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify({ scale: 2, width: 50, height: 30 }), - ); - const fresh = createComponent(); - expect(fresh.component.scale()).toBe(2); - expect(fresh.component.width()).toBe(50); - expect(fresh.component.height()).toBe(30); - }); - - it('should persist settings and trigger printing on print', () => { - vi.useFakeTimers(); - const print_spy = vi - .spyOn(window, 'print') - .mockImplementation(() => undefined); - spectator.component.scale.set(6); - spectator.component.width.set(40); - spectator.component.height.set(20); - spectator.component.print(); - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) as string); - expect(stored).toEqual({ scale: 6, width: 40, height: 20 }); - vi.runOnlyPendingTimers(); - expect(print_spy).toHaveBeenCalled(); - vi.useRealTimers(); - print_spy.mockRestore(); - }); -}); diff --git a/libs/users/src/tests/user.pipe.spec.ts b/libs/users/src/tests/user.pipe.spec.ts index 97b376b6841..cc44af57db4 100644 --- a/libs/users/src/tests/user.pipe.spec.ts +++ b/libs/users/src/tests/user.pipe.spec.ts @@ -2,7 +2,7 @@ vi.mock('@placeos/ts-client'); import { GuestUser, StaffUser } from '@placeos/common'; import * as ts_client from '@placeos/ts-client'; -import { addUser, replaceUser, UserPipe } from '../lib/user.pipe'; +import { addUser, UserPipe } from '../lib/user.pipe'; describe('UserPipe', () => { let pipe: UserPipe; @@ -38,24 +38,6 @@ describe('UserPipe', () => { expect(user).toBe(staff); }); - it('should replace an existing user in the cache', async () => { - const original = new StaffUser({ - id: 'user-id-3', - email: 'c@place.tech', - name: 'Original', - }); - addUser(original); - const updated = new StaffUser({ - id: 'user-id-3', - email: 'c@place.tech', - name: 'Updated', - }); - replaceUser(updated); - const user = await pipe.transform('user-id-3'); - expect(user).toBe(updated); - expect(user.name).toBe('Updated'); - }); - it('should fetch staff details for an unknown id', async () => { vi.mocked(ts_client.get).mockResolvedValue({ id: 'user-id-4', diff --git a/libs/users/src/tests/user.utilities.spec.ts b/libs/users/src/tests/user.utilities.spec.ts index 4b119324d62..21a299eb98b 100644 --- a/libs/users/src/tests/user.utilities.spec.ts +++ b/libs/users/src/tests/user.utilities.spec.ts @@ -1,16 +1,10 @@ import { TestBed } from '@angular/core/testing'; -import { GuestUser, User } from '@placeos/common'; -import { - generateGuestForm, - generateMockUser, - generateUserForm, -} from '../lib/user.utilities'; +import { User } from '@placeos/common'; +import { generateMockUser, generateUserForm } from '../lib/user.utilities'; describe('[User Utilities]', () => { const makeUserForm = (user: User) => TestBed.runInInjectionContext(() => generateUserForm(user)); - const makeGuestForm = (user?: GuestUser, host?: string) => - TestBed.runInInjectionContext(() => generateGuestForm(user, host)); describe('generateUserForm', () => { it('should generate form', () => { @@ -45,42 +39,4 @@ describe('[User Utilities]', () => { expect(() => makeUserForm(null)).toThrow(); }); }); - - describe('generateGuestorm', () => { - it('should generate form', () => { - const user = new GuestUser(generateMockUser()); - const form = makeGuestForm(user, 'Testing'); - const value = form().value(); - for (const key of ['name', 'email', 'organisation', 'phone']) { - expect(value[key]).toEqual(user[key]); - } - expect(value.host).toBe('Testing'); - }); - - it('should validate emails', () => { - const user = new GuestUser(); - const form = makeGuestForm(user); - expect(form.email().invalid()).toBeTruthy(); - form.email().value.set('test@place.tech'); - expect(form.email().invalid()).toBeFalsy(); - form.email().value.set('jim'); - expect(form.email().invalid()).toBeTruthy(); - }); - - it('should validate organisation', () => { - const user = new GuestUser(); - const form = makeGuestForm(user); - expect(form.organisation().invalid()).toBeTruthy(); - form.organisation().value.set('PlaceOS'); - expect(form.organisation().invalid()).toBeFalsy(); - }); - - it('should validate host', () => { - const user = new GuestUser(); - const form = makeGuestForm(user); - expect(form.host().invalid()).toBeTruthy(); - form.host().value.set('Will'); - expect(form.host().invalid()).toBeFalsy(); - }); - }); }); From ec28dbd6e6c1de9a5069575e331045d802d5bd82 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:02 +1100 Subject: [PATCH 04/17] refactor(libs): remove unused code from bookings, assets, catering and events Removes DesksService and its confirm modal, about 25 unused query and helper functions, the asset group pipe, the catering order options modal, unused members and inputs, and the events copies of interfaces and helpers that had drifted from @placeos/common. AssetStateService no longer loads an asset list and asset bookings that nothing read, which saves two requests on every options change. A loading entry now covers the availability request instead. --- .../desk-flow/desk-form-details.component.ts | 2 +- .../src/app/explore/explore.component.ts | 9 +- .../tests/explore/explore.component.spec.ts | 2 - libs/assets/src/index.ts | 3 +- libs/assets/src/lib/asset-group.pipe.spec.ts | 71 ------- libs/assets/src/lib/asset-group.pipe.ts | 42 ---- .../asset-filters-display.component.ts | 8 - .../asset-select-modal.component.ts | 13 -- libs/assets/src/lib/asset-state.service.ts | 76 ++----- libs/assets/src/lib/assets.fn.spec.ts | 36 ---- libs/assets/src/lib/assets.fn.ts | 17 -- libs/assets/src/lib/catering-assets.fn.ts | 10 - libs/assets/src/lib/locker-assets.fn.spec.ts | 37 +--- libs/assets/src/lib/locker-assets.fn.ts | 8 - .../assets/src/lib/parking-space.pipe.spec.ts | 27 --- libs/assets/src/lib/parking-space.pipe.ts | 15 +- .../asset-select-modal.component.spec.ts | 12 -- .../src/tests/asset-state.service.spec.ts | 33 ++- libs/bookings/src/index.ts | 7 +- .../lib/booking-details-modal.component.ts | 16 -- libs/bookings/src/lib/booking-form.service.ts | 10 - .../src/lib/booking-link-modal.component.ts | 3 +- .../src/lib/booking-resource.utilities.ts | 29 --- libs/bookings/src/lib/bookings.fn.ts | 124 ----------- .../src/lib/desk-confirm-modal.component.ts | 158 -------------- .../src/lib/desk-list-field.component.ts | 42 +--- .../desk-filters.component.ts | 10 +- libs/bookings/src/lib/desk.service.ts | 167 --------------- .../src/lib/locker-list-field.component.ts | 71 +------ .../parking-location-pin.component.ts | 89 -------- .../test/desk-confirm-modal.component.spec.ts | 60 ------ libs/bookings/src/test/desks.service.spec.ts | 120 ----------- .../test/locker-list-field.component.spec.ts | 37 ---- .../parking-location-pin.component.spec.ts | 47 ----- .../src/lib/catering-menu.component.ts | 3 +- .../catering-order-options-modal.component.ts | 192 ------------------ .../catering-item-list.component.ts | 1 - .../catering-select-modal.component.ts | 9 - .../src/lib/catering-state.service.ts | 53 ----- ...ring-order-options-modal.component.spec.ts | 57 ------ .../catering-select-modal.component.spec.ts | 15 +- .../src/tests/catering-state.service.spec.ts | 30 +-- libs/events/src/lib/calendar.fn.ts | 8 - libs/events/src/lib/calendar.service.ts | 49 ----- libs/events/src/lib/event-form.service.ts | 6 +- libs/events/src/lib/event-save.fn.ts | 13 +- libs/events/src/lib/event.interfaces.ts | 144 ------------- libs/events/src/lib/events.fn.ts | 77 +------ libs/events/src/lib/events.ts | 1 - .../group-event-details-modal.component.ts | 5 - libs/events/src/lib/helpers.ts | 120 +---------- libs/events/src/lib/utilities.ts | 87 +------- libs/events/src/lib/validators.ts | 11 +- libs/events/src/tests/calendar.fn.spec.ts | 16 -- .../events/src/tests/calendar.service.spec.ts | 39 ---- libs/events/src/tests/events.fn.spec.ts | 47 ----- libs/events/src/tests/helpers.spec.ts | 34 +--- libs/events/src/tests/validators.spec.ts | 13 +- .../src/tests/explore-desks.service.spec.ts | 2 - .../src/lib/recurrence-field.component.ts | 2 +- 60 files changed, 66 insertions(+), 2379 deletions(-) delete mode 100644 libs/assets/src/lib/asset-group.pipe.spec.ts delete mode 100644 libs/assets/src/lib/asset-group.pipe.ts delete mode 100644 libs/assets/src/lib/asset-select-modal/asset-filters-display.component.ts delete mode 100644 libs/bookings/src/lib/desk-confirm-modal.component.ts delete mode 100644 libs/bookings/src/lib/desk.service.ts delete mode 100644 libs/bookings/src/lib/parking-select-modal/parking-location-pin.component.ts delete mode 100644 libs/bookings/src/test/desk-confirm-modal.component.spec.ts delete mode 100644 libs/bookings/src/test/desks.service.spec.ts delete mode 100644 libs/bookings/src/test/parking-location-pin.component.spec.ts delete mode 100644 libs/catering/src/lib/catering-order-options-modal.component.ts delete mode 100644 libs/catering/src/tests/catering-order-options-modal.component.spec.ts delete mode 100644 libs/events/src/lib/event.interfaces.ts diff --git a/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts b/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts index 5dc280451bc..75347402fb7 100644 --- a/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts +++ b/apps/workplace/src/app/book/desk-flow/desk-form-details.component.ts @@ -27,11 +27,11 @@ import { BookingRecurrence, Desk, OrganisationService, + RecurrenceDetails, settingSignal, SettingsService, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; -import { RecurrenceDetails } from '@placeos/events'; import { DateFieldComponent, DurationFieldComponent, diff --git a/apps/workplace/src/app/explore/explore.component.ts b/apps/workplace/src/app/explore/explore.component.ts index 84286514e9a..604128a6081 100644 --- a/apps/workplace/src/app/explore/explore.component.ts +++ b/apps/workplace/src/app/explore/explore.component.ts @@ -1,13 +1,11 @@ import { Component, - OnInit, computed, effect, inject, linkedSignal, } from '@angular/core'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { DesksService } from '@placeos/bookings'; import { AsyncHandler, MINUTES, OrganisationService } from '@placeos/common'; import { IconComponent, LevelPipe, TranslatePipe } from '@placeos/components'; import { ExploreMapViewComponent, ExploreStateService } from '@placeos/explore'; @@ -119,8 +117,7 @@ export function msUntilMinuteChange(now: number, targets: number[]) { MatTooltipModule, ], }) -export class ExploreComponent extends AsyncHandler implements OnInit { - private _desks = inject(DesksService); +export class ExploreComponent extends AsyncHandler { private _landing = inject(LandingStateService); private _explore = inject(ExploreStateService); private _org = inject(OrganisationService); @@ -171,10 +168,6 @@ export class ExploreComponent extends AsyncHandler implements OnInit { }); } - public ngOnInit() { - this._desks.error_on_host = false; - } - public pinEvent() { const event = this.next_event(); if (!event) return; diff --git a/apps/workplace/src/tests/explore/explore.component.spec.ts b/apps/workplace/src/tests/explore/explore.component.spec.ts index db2e9fe2d31..d34c0993464 100644 --- a/apps/workplace/src/tests/explore/explore.component.spec.ts +++ b/apps/workplace/src/tests/explore/explore.component.spec.ts @@ -1,6 +1,5 @@ import { signal } from '@angular/core'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { DesksService } from '@placeos/bookings'; import { OrganisationService } from '@placeos/common'; import { mockComponent } from '@placeos/common/tests'; import { ExploreMapViewComponent, ExploreStateService } from '@placeos/explore'; @@ -20,7 +19,6 @@ describe('ExploreComponent', () => { const createComponent = createComponentFactory({ component: ExploreComponent, providers: [ - { provide: DesksService, useValue: {} }, MockProvider(LandingStateService, { upcoming_events, }), diff --git a/libs/assets/src/index.ts b/libs/assets/src/index.ts index ce1f7bf579a..8c50d1e3850 100644 --- a/libs/assets/src/index.ts +++ b/libs/assets/src/index.ts @@ -1,11 +1,10 @@ -export * from './lib/asset-group.pipe'; export * from './lib/asset-list-field.component'; +export * from './lib/asset-select-modal/asset-select-modal.component'; export * from './lib/asset-state.service'; export * from './lib/asset.utilities'; export * from './lib/assets.fn'; export * from './lib/catering-assets.fn'; export * from './lib/desk-assets.fn'; export * from './lib/locker-assets.fn'; -export * from './lib/asset-select-modal/asset-select-modal.component'; export * from './lib/parking-assets.fn'; export * from './lib/parking-space.pipe'; diff --git a/libs/assets/src/lib/asset-group.pipe.spec.ts b/libs/assets/src/lib/asset-group.pipe.spec.ts deleted file mode 100644 index 20197851052..00000000000 --- a/libs/assets/src/lib/asset-group.pipe.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as ts_client from '@placeos/ts-client'; - -vi.mock('@placeos/ts-client', { spy: true }); - -async function load_modules() { - const pipe_module = (await import('./asset-group.pipe')) as any; - return { pipe_module }; -} - -describe('[AssetGroupPipe]', () => { - beforeEach(() => { - vi.resetModules(); - vi.clearAllMocks(); - }); - - it('should return an empty group for an empty id', async () => { - const { pipe_module } = await load_modules(); - const pipe = new pipe_module.AssetGroupPipe(); - - expect(await pipe.transform('')).toEqual({}); - expect(ts_client.showAssetType).not.toHaveBeenCalled(); - }); - - it('should resolve a group from the API and cache it', async () => { - const { pipe_module } = await load_modules(); - vi.mocked(ts_client.showAssetType).mockResolvedValue({ - id: 'grp-1', - name: 'Chairs', - } as any); - const pipe = new pipe_module.AssetGroupPipe(); - - const group = await pipe.transform('grp-1'); - expect(group).toEqual({ id: 'grp-1', name: 'Chairs' }); - expect(ts_client.showAssetType).toHaveBeenCalledWith('grp-1'); - - const cached = await pipe.transform('grp-1'); - expect(cached).toEqual({ id: 'grp-1', name: 'Chairs' }); - expect(ts_client.showAssetType).toHaveBeenCalledTimes(1); - }); - - it('should return a preloaded group without querying the API', async () => { - const { pipe_module } = await load_modules(); - const pipe = new pipe_module.AssetGroupPipe(); - pipe.updateAssetGroupList([{ id: 'grp-2', name: 'Desks' }]); - - const group = await pipe.transform('grp-2'); - expect(group).toEqual({ id: 'grp-2', name: 'Desks' }); - expect(ts_client.showAssetType).not.toHaveBeenCalled(); - }); - - it('should return an empty group when the API errors', async () => { - const { pipe_module } = await load_modules(); - vi.mocked(ts_client.showAssetType).mockRejectedValue( - new Error('nope'), - ); - const pipe = new pipe_module.AssetGroupPipe(); - - expect(await pipe.transform('missing')).toEqual({}); - }); - - it('should ignore duplicate ids when updating the group list', async () => { - const { pipe_module } = await load_modules(); - pipe_module.updateAssetGroupList([{ id: 'grp-3', name: 'First' }]); - pipe_module.updateAssetGroupList([{ id: 'grp-3', name: 'Second' }]); - const pipe = new pipe_module.AssetGroupPipe(); - - const group = await pipe.transform('grp-3'); - expect(group.name).toBe('First'); - expect(ts_client.showAssetType).not.toHaveBeenCalled(); - }); -}); diff --git a/libs/assets/src/lib/asset-group.pipe.ts b/libs/assets/src/lib/asset-group.pipe.ts deleted file mode 100644 index b6a596eace4..00000000000 --- a/libs/assets/src/lib/asset-group.pipe.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Pipe } from '@angular/core'; - -import { AssetGroup } from '@placeos/common'; -import { showAssetType } from '@placeos/ts-client'; - -const ASSET_GROUP_LIST: AssetGroup[] = []; - -const EMPTY_ASSET_GROUP: AssetGroup = {} as AssetGroup; - -export function updateAssetGroupList(assetgroup_list: AssetGroup[]) { - for (const assetgroup of assetgroup_list) { - if (!ASSET_GROUP_LIST.find(({ id }) => id === assetgroup.id)) { - ASSET_GROUP_LIST.push(assetgroup); - } - } -} - -@Pipe({ - name: 'assetgroup', -}) -export class AssetGroupPipe { - /** - * Get details of the assetgroup with the given ID - * @param assetgroup_id ID or Email of the assetgroup - */ - public async transform(group_id: string): Promise { - if (!group_id) return EMPTY_ASSET_GROUP; - let asset_group = ASSET_GROUP_LIST.find(({ id }) => id === group_id); - if (asset_group) return asset_group; - const group = await showAssetType(group_id).catch(() => null); - if (group) { - asset_group = { ...group }; - ASSET_GROUP_LIST.push(asset_group); - return asset_group; - } - return EMPTY_ASSET_GROUP; - } - - public updateAssetGroupList(assetgroup_list: AssetGroup[]) { - updateAssetGroupList(assetgroup_list); - } -} diff --git a/libs/assets/src/lib/asset-select-modal/asset-filters-display.component.ts b/libs/assets/src/lib/asset-select-modal/asset-filters-display.component.ts deleted file mode 100644 index a869d9eb803..00000000000 --- a/libs/assets/src/lib/asset-select-modal/asset-filters-display.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'asset-filters-display', - template: ``, - styles: [``], -}) -export class AssetFiltersDisplayComponent {} diff --git a/libs/assets/src/lib/asset-select-modal/asset-select-modal.component.ts b/libs/assets/src/lib/asset-select-modal/asset-select-modal.component.ts index 8058700b7f0..2de11d94d25 100644 --- a/libs/assets/src/lib/asset-select-modal/asset-select-modal.component.ts +++ b/libs/assets/src/lib/asset-select-modal/asset-select-modal.component.ts @@ -7,7 +7,6 @@ import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { AssetStateService } from '../asset-state.service'; import { AssetDetailsComponent } from './asset-details.component'; -import { AssetFiltersDisplayComponent } from './asset-filters-display.component'; import { AssetFiltersComponent } from './asset-filters.component'; import { AssetListComponent } from './asset-list.component'; @@ -49,7 +48,6 @@ const EMPTY_FAVS: string[] = []; [class.sm:hidden]="displayed()" [class.md:block]="!displayed()" > - _.id === displayed.id); - if (item) { - item.quantity = count; - item.assets = displayed.assets; - } - } - public toggleFavourite(asset: AssetGroup | null) { if (!asset) return; const fav_list = this.favorites(); diff --git a/libs/assets/src/lib/asset-state.service.ts b/libs/assets/src/lib/asset-state.service.ts index 8effd1f06fe..cf9892c6175 100644 --- a/libs/assets/src/lib/asset-state.service.ts +++ b/libs/assets/src/lib/asset-state.service.ts @@ -14,22 +14,9 @@ import { SettingsService, } from '@placeos/common'; import { showMetadata } from '@placeos/ts-client'; -import { - addMinutes, - endOfDay, - endOfMinute, - getUnixTime, - startOfDay, - startOfMinute, -} from 'date-fns'; -import { queryBookings } from 'libs/bookings/src/lib/bookings.fn'; -import { updateAssetGroupList } from './asset-group.pipe'; +import { addMinutes, endOfMinute, getUnixTime, startOfMinute } from 'date-fns'; import { assetAvailable, getAssetRulesForZone } from './asset.utilities'; -import { - queryAssetCategories, - queryAssets, - queryGroupAvailability, -} from './assets.fn'; +import { queryAssetCategories, queryGroupAvailability } from './assets.fn'; export interface AssetOptions { zone?: string; @@ -62,18 +49,20 @@ export class AssetStateService { private _category = signal([]); private _loading = signal(''); private _rules = signal([]); - private _asset_list = signal(null); - private _asset_bookings = signal([]); private _available_groups = signal([]); private _category_list = signal([]); private _settings = signal>({}); private _network_requested = false; private _network_consumed = signal(false); - private _asset_list_request: Promise = null; private _category_list_request: Promise = null; - private _booking_requests = new Map>(); - private _available_group_requests = new Map>(); - private _settings_requests = new Map>>(); + private _available_group_requests = new Map< + string, + Promise + >(); + private _settings_requests = new Map< + string, + Promise> + >(); private readonly _options_debounced = debounced(this._options, 300, { injector: this._injector, @@ -99,14 +88,6 @@ export class AssetStateService { this._requestNetwork(); return this._rules(); }); - public readonly asset_list = computed(() => { - this._requestNetwork(); - return this._asset_list(); - }); - public readonly asset_bookings = computed(() => { - this._requestNetwork(); - return this._asset_bookings(); - }); public readonly available_groups = computed(() => { this._requestNetwork(); return this._available_groups(); @@ -151,8 +132,6 @@ export class AssetStateService { if (!this._requests_ready() || !bld?.id || !options) return; untracked(() => { this._loadRules(options, bld.id); - this._loadAssetList(); - this._loadAssetBookings(options); this._loadAvailableGroups(options, bld.id); this._loadSettings(bld.id); this._loadCategories(); @@ -221,38 +200,6 @@ export class AssetStateService { this._removeLoading('[Rules]'); } - private async _loadAssetList() { - if (this._asset_list_request) return this._asset_list_request; - this._appendLoading('[Assets]'); - this._asset_list_request = queryAssets() - .then((list) => { - this._asset_list.set(list); - return list; - }) - .finally(() => { - this._asset_list_request = null; - this._removeLoading('[Assets]'); - }); - return this._asset_list_request; - } - - private async _loadAssetBookings({ zone, zone_id, date }: AssetOptions) { - const query = { - zones: zone || zone_id || '', - period_start: getUnixTime(startOfDay(date)), - period_end: getUnixTime(endOfDay(date)), - type: 'asset-request' as const, - }; - const key = JSON.stringify(query); - const existing = this._booking_requests.get(key); - this._appendLoading('[Bookings]'); - const request = existing || queryBookings(query); - if (!existing) this._booking_requests.set(key, request); - this._asset_bookings.set(await request); - request.finally(() => this._booking_requests.delete(key)); - this._removeLoading('[Bookings]'); - } - private async _loadAvailableGroups( { zone, zone_id, date, duration, ignore }: AssetOptions, building_id: string, @@ -275,10 +222,11 @@ export class AssetStateService { return [] as AssetGroup[]; }); if (!existing) this._available_group_requests.set(key, request); + this._appendLoading('[Availability]'); const list = await request; + this._removeLoading('[Availability]'); request.finally(() => this._available_group_requests.delete(key)); const sorted_list = list.sort((a, b) => a.name.localeCompare(b.name)); - updateAssetGroupList(sorted_list); this._available_groups.set(sorted_list); } diff --git a/libs/assets/src/lib/assets.fn.spec.ts b/libs/assets/src/lib/assets.fn.spec.ts index 059096654a7..f5bb70c1b0d 100644 --- a/libs/assets/src/lib/assets.fn.spec.ts +++ b/libs/assets/src/lib/assets.fn.spec.ts @@ -326,42 +326,6 @@ describe('[Assets]', () => { }); }); - describe('queryAvailableAssets', () => { - it('should exclude booked assets but keep ignored ones', async () => { - const { assets_fn } = await load_modules(); - vi.mocked(ts_client.queryAssets).mockReturnValue( - response([ - { id: 'a1', asset_type_id: 't1' }, - { id: 'a2', asset_type_id: 't1' }, - ]), - ); - vi.mocked(ts_client.queryAssetTypes).mockReturnValue( - response([{ id: 't1', category_id: 'c1' }]), - ); - vi.mocked(ts_client.queryAssetCategories).mockReturnValue( - response([{ id: 'c1' }]), - ); - // bookedResourceList -> ts_client.query returns ['a1'] as booked - vi.mocked(ts_client.query).mockResolvedValue({ - data: ['a1'], - next: undefined, - total: 1, - }); - - const available = await assets_fn.queryAvailableAssets({ - period_start: 1, - period_end: 2, - }); - expect(available.map((a: any) => a.id)).toEqual(['a2']); - - const with_ignore = await assets_fn.queryAvailableAssets( - { period_start: 1, period_end: 2 }, - ['a1'], - ); - expect(with_ignore.map((a: any) => a.id)).toEqual(['a1', 'a2']); - }); - }); - describe('queryGroupAvailability', () => { it('should drop assets referenced by active bookings', async () => { const { assets_fn } = await load_modules(); diff --git a/libs/assets/src/lib/assets.fn.ts b/libs/assets/src/lib/assets.fn.ts index 8715eb6b2f9..4e689d78e1b 100644 --- a/libs/assets/src/lib/assets.fn.ts +++ b/libs/assets/src/lib/assets.fn.ts @@ -29,7 +29,6 @@ import { } from '@placeos/ts-client'; import { addMinutes, endOfDay, getUnixTime, startOfDay } from 'date-fns'; import { - bookedResourceList, BookingsQueryParams, createBooking, parentBookingId, @@ -291,22 +290,6 @@ export async function showGroupFull(id: string, query: any = {}) { return product; } -export async function queryAvailableAssets( - query: BookingsQueryParams, - ignore?: string[], -) { - query.type = 'asset-request'; - const [assets, booked_assets] = await Promise.all([ - queryAssets(query), - bookedResourceList(query), - ]); - return assets.data.filter( - (asset) => - ignore?.includes(asset.id) || - !booked_assets.find((id) => id === asset.id), - ); -} - export async function queryGroupAvailability( query: BookingsQueryParams, ignore: string[] = [], diff --git a/libs/assets/src/lib/catering-assets.fn.ts b/libs/assets/src/lib/catering-assets.fn.ts index 96f308889cd..cd7f2d4c29f 100644 --- a/libs/assets/src/lib/catering-assets.fn.ts +++ b/libs/assets/src/lib/catering-assets.fn.ts @@ -216,16 +216,6 @@ export async function queryCateringItems( ); } -export async function queryCateringItemsForZones( - zone_ids: string[], -): Promise { - if (!zone_ids?.length) return []; - const results = await Promise.all( - zone_ids.map((zone_id) => queryCateringItems(zone_id)), - ); - return flatten(results); -} - export async function saveCateringItem( item: Partial, zone_id: string, diff --git a/libs/assets/src/lib/locker-assets.fn.spec.ts b/libs/assets/src/lib/locker-assets.fn.spec.ts index d35eb243904..f893d9f7cca 100644 --- a/libs/assets/src/lib/locker-assets.fn.spec.ts +++ b/libs/assets/src/lib/locker-assets.fn.spec.ts @@ -15,7 +15,7 @@ function response(data: any[]) { } async function load_modules() { - const locker = (await import('./locker-assets.fn')); + const locker = await import('./locker-assets.fn'); return { locker }; } @@ -117,29 +117,6 @@ describe('[Locker Assets]', () => { }); }); - describe('queryLockerBankAssets', () => { - it('should query assets for the zone and bank type', async () => { - const { locker } = await load_modules(); - vi.mocked(ts_client.queryAssetCategories).mockReturnValue( - response([{ id: 'cat-1', name: '_LOCKERS_' }]), - ); - vi.mocked(ts_client.queryAssetTypes).mockReturnValue( - response([{ id: 'type-1', name: '_LOCKER_BANKS_' }]), - ); - vi.mocked(ts_client.queryAssets).mockReturnValue( - response([{ id: 'a1' }, { id: 'a2' }]), - ); - - const list = await locker.queryLockerBankAssets('zone-1'); - expect(ts_client.queryAssets).toHaveBeenCalledWith({ - zone_id: 'zone-1', - type_id: 'type-1', - limit: 500, - }); - expect(list.map((a: any) => a.id)).toEqual(['a1', 'a2']); - }); - }); - describe('queryLockerBankAssetsForZones', () => { it('should resolve empty for no zones', async () => { const { locker } = await load_modules(); @@ -154,8 +131,8 @@ describe('[Locker Assets]', () => { vi.mocked(ts_client.queryAssetTypes).mockReturnValue( response([{ id: 'type-1', name: '_LOCKER_BANKS_' }]), ); - vi.mocked(ts_client.queryAssets).mockImplementation( - ({ zone_id }) => response([{ id: `${zone_id}-a` }]), + vi.mocked(ts_client.queryAssets).mockImplementation(({ zone_id }) => + response([{ id: `${zone_id}-a` }]), ); const list = await locker.queryLockerBankAssetsForZones([ @@ -175,7 +152,9 @@ describe('[Locker Assets]', () => { vi.mocked(ts_client.queryAssetTypes).mockReturnValue( response([{ id: 'type-1', name: '_LOCKER_BANKS_' }]), ); - vi.mocked(ts_client.addAsset).mockResolvedValue({ id: 'a1' } as any); + vi.mocked(ts_client.addAsset).mockResolvedValue({ + id: 'a1', + } as any); const res = await locker.saveLockerBankAsset({ name: 'Bank 1' }); expect(ts_client.addAsset).toHaveBeenCalledWith({ @@ -195,7 +174,9 @@ describe('[Locker Assets]', () => { vi.mocked(ts_client.queryAssetTypes).mockReturnValue( response([{ id: 'type-2', name: '_LOCKERS_' }]), ); - vi.mocked(ts_client.addAsset).mockResolvedValue({ id: 'l1' } as any); + vi.mocked(ts_client.addAsset).mockResolvedValue({ + id: 'l1', + } as any); const res = await locker.saveLockerAsset({ name: 'Locker 1' }); expect(ts_client.addAsset).toHaveBeenCalledWith({ diff --git a/libs/assets/src/lib/locker-assets.fn.ts b/libs/assets/src/lib/locker-assets.fn.ts index ca848aceca9..063583b4dec 100644 --- a/libs/assets/src/lib/locker-assets.fn.ts +++ b/libs/assets/src/lib/locker-assets.fn.ts @@ -109,14 +109,6 @@ export function resolveLockerTypeId(): Promise { return _locker_type_id_promise; } -export async function queryLockerBankAssets( - zone_id: string, -): Promise { - const type_id = await resolveLockerBankTypeId(); - const response = await queryAllAssetPages({ zone_id, type_id }); - return response.data; -} - export async function queryLockerBankAssetsForZones( zone_ids: string[], ): Promise { diff --git a/libs/assets/src/lib/parking-space.pipe.spec.ts b/libs/assets/src/lib/parking-space.pipe.spec.ts index 2faf9c9307e..4d5d6214fa8 100644 --- a/libs/assets/src/lib/parking-space.pipe.spec.ts +++ b/libs/assets/src/lib/parking-space.pipe.spec.ts @@ -47,18 +47,6 @@ describe('[ParkingSpacePipe]', () => { expect(ts_client.showAsset).toHaveBeenCalledTimes(1); }); - it('should return a preloaded space without querying the API', async () => { - const { pipe_module } = await load_modules(); - pipe_module.updateParkingSpaceList([ - { id: 'space-2', identifier: 'B-02' }, - ]); - const pipe = new pipe_module.ParkingSpacePipe(); - - const space = await pipe.transform('space-2'); - expect(space).toEqual({ id: 'space-2', identifier: 'B-02' }); - expect(ts_client.showAsset).not.toHaveBeenCalled(); - }); - it('should return an empty space when the API errors', async () => { const { pipe_module } = await load_modules(); vi.mocked(ts_client.showAsset).mockRejectedValue(new Error('nope')); @@ -66,19 +54,4 @@ describe('[ParkingSpacePipe]', () => { expect(await pipe.transform('missing')).toEqual({}); }); - - it('should ignore duplicate ids when updating the space list', async () => { - const { pipe_module } = await load_modules(); - pipe_module.updateParkingSpaceList([ - { id: 'space-3', identifier: 'First' }, - ]); - pipe_module.updateParkingSpaceList([ - { id: 'space-3', identifier: 'Second' }, - ]); - const pipe = new pipe_module.ParkingSpacePipe(); - - const space = await pipe.transform('space-3'); - expect(space.identifier).toBe('First'); - expect(ts_client.showAsset).not.toHaveBeenCalled(); - }); }); diff --git a/libs/assets/src/lib/parking-space.pipe.ts b/libs/assets/src/lib/parking-space.pipe.ts index caf60e66743..32570a4461e 100644 --- a/libs/assets/src/lib/parking-space.pipe.ts +++ b/libs/assets/src/lib/parking-space.pipe.ts @@ -4,15 +4,9 @@ import { PlaceAsset, showAsset } from '@placeos/ts-client'; const PARKING_SPACE_LIST: PlaceAsset[] = []; const EMPTY_PARKING_SPACE: PlaceAsset = {} as PlaceAsset; -const UNALLOCATED_PARKING_SPACE: PlaceAsset = { identifier: 'Unallocated' } as PlaceAsset; - -export function updateParkingSpaceList(spaces: PlaceAsset[]) { - for (const space of spaces) { - if (!PARKING_SPACE_LIST.find(({ id }) => id === space.id)) { - PARKING_SPACE_LIST.push(space); - } - } -} +const UNALLOCATED_PARKING_SPACE: PlaceAsset = { + identifier: 'Unallocated', +} as PlaceAsset; @Pipe({ name: 'parkingSpace', @@ -24,7 +18,8 @@ export class ParkingSpacePipe { */ public async transform(asset_id: string): Promise { if (!asset_id) return EMPTY_PARKING_SPACE; - if (asset_id.startsWith('unallocated')) return UNALLOCATED_PARKING_SPACE + if (asset_id.startsWith('unallocated')) + return UNALLOCATED_PARKING_SPACE; const cached = PARKING_SPACE_LIST.find(({ id }) => id === asset_id); if (cached) return cached; const space = await showAsset(asset_id).catch(() => null); diff --git a/libs/assets/src/tests/asset-select-modal.component.spec.ts b/libs/assets/src/tests/asset-select-modal.component.spec.ts index 28fd118293a..7e0e4c73056 100644 --- a/libs/assets/src/tests/asset-select-modal.component.spec.ts +++ b/libs/assets/src/tests/asset-select-modal.component.spec.ts @@ -5,7 +5,6 @@ import { createSettingsServiceMock } from '@placeos/common/tests'; import { MockComponent, MockProvider } from 'ng-mocks'; import { AssetDetailsComponent } from '../lib/asset-select-modal/asset-details.component'; -import { AssetFiltersDisplayComponent } from '../lib/asset-select-modal/asset-filters-display.component'; import { AssetFiltersComponent } from '../lib/asset-select-modal/asset-filters.component'; import { AssetListComponent } from '../lib/asset-select-modal/asset-list.component'; import { AssetSelectModalComponent } from '../lib/asset-select-modal/asset-select-modal.component'; @@ -24,7 +23,6 @@ describe('AssetSelectModalComponent', () => { MockComponent(AssetFiltersComponent), MockComponent(AssetListComponent), MockComponent(AssetDetailsComponent), - MockComponent(AssetFiltersDisplayComponent), ], providers: [ { provide: SettingsService, useFactory: () => settings_mock }, @@ -79,16 +77,6 @@ describe('AssetSelectModalComponent', () => { expect(spectator.component.selected_ids()).toBe('1,2'); }); - it('should update the quantity of the displayed group', () => { - const group = { id: '1', quantity: 1, assets: [{}, {}] } as AssetGroup; - spectator.component.selected.set([group]); - spectator.component.displayed.set(group); - - spectator.component.updateSelectedCount(2); - - expect(spectator.component.selected()[0].quantity).toBe(2); - }); - it('should add a favourite when not already saved', () => { settings_mock.get.mockReturnValue([]); spectator.component.toggleFavourite({ id: '1' } as AssetGroup); diff --git a/libs/assets/src/tests/asset-state.service.spec.ts b/libs/assets/src/tests/asset-state.service.spec.ts index 15583edb453..7266fb5b2b8 100644 --- a/libs/assets/src/tests/asset-state.service.spec.ts +++ b/libs/assets/src/tests/asset-state.service.spec.ts @@ -16,7 +16,6 @@ import { AssetStateService } from '../lib/asset-state.service'; // assets_mod.queryAssets -> ts_client.queryAssets // assets_mod.queryAssetCategories -> ts_client.queryAssetCategories // assets_mod.queryGroupAvailability -> ts_client.queryAssetTypes -// bookings_mod.queryBookings -> ts_client.get vi.mock('@placeos/ts-client', { spy: true }); function response(data: any[]) { @@ -91,26 +90,22 @@ describe('AssetStateService', () => { expect(ts_client.showMetadata).not.toHaveBeenCalled(); }); - it('should share identical in-flight asset booking requests', async () => { - const options = { - date: new Date(2026, 5, 22, 9).valueOf(), - zone: 'zone-1', - }; - - await Promise.all([ - (spectator.service as any)._loadAssetBookings(options), - (spectator.service as any)._loadAssetBookings(options), - ]); + it('should show loading while availability is pending', async () => { + let resolve: (value: unknown) => void; + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + new Promise((r) => (resolve = r)) as any, + ); + const request = (spectator.service as any)._loadAvailableGroups( + { date: new Date(2026, 5, 22, 9).valueOf(), zone: 'zone-1' }, + 'bld-1', + ); + await Promise.resolve(); - expect(ts_client.get).toHaveBeenCalledTimes(1); - }); + expect(spectator.service.loading()).toContain('[Availability]'); - it('should share identical in-flight asset list requests', async () => { - await Promise.all([ - (spectator.service as any)._loadAssetList(), - (spectator.service as any)._loadAssetList(), - ]); + resolve({ data: [], total: 0, next: () => null }); + await request; - expect(ts_client.queryAssets).toHaveBeenCalledTimes(1); + expect(spectator.service.loading()).not.toContain('[Availability]'); }); }); diff --git a/libs/bookings/src/index.ts b/libs/bookings/src/index.ts index a261d47f688..c1018809e7f 100644 --- a/libs/bookings/src/index.ts +++ b/libs/bookings/src/index.ts @@ -7,14 +7,13 @@ export * from './lib/booking-group.utilities'; export * from './lib/booking.utilities'; export * from './lib/bookings.fn'; export * from './lib/desk-list-field.component'; -export * from './lib/desk.service'; +export * from './lib/desk-select-modal/desk-map.component'; +export * from './lib/desk-select-modal/desk-select-modal.component'; export * from './lib/invite-visitor-form.component'; export * from './lib/locker-grid.component'; export * from './lib/locker-list-field.component'; -export * from './lib/locker.class'; -export * from './lib/desk-select-modal/desk-map.component'; -export * from './lib/desk-select-modal/desk-select-modal.component'; export * from './lib/locker-select-modal/locker-select-modal.component'; +export * from './lib/locker.class'; export * from './lib/parking-select-modal/parking-select-modal.component'; export * from './lib/parking-space-list-field.component'; export * from './lib/parking.service'; diff --git a/libs/bookings/src/lib/booking-details-modal.component.ts b/libs/bookings/src/lib/booking-details-modal.component.ts index 9bc0e4f35f2..bd71168ef3a 100644 --- a/libs/bookings/src/lib/booking-details-modal.component.ts +++ b/libs/bookings/src/lib/booking-details-modal.component.ts @@ -553,22 +553,6 @@ export class BookingDetailsModalComponent { public readonly level = computed(() => this._org.levelWithID(this.booking()?.zones || []), ); - public readonly level_or_building = computed( - () => this.level() || this.building(), - ); - public readonly resource_location = computed(() => { - const location_name = - this.level_or_building()?.display_name || - this.level_or_building()?.name || - ''; - const resource_name = - this.booking().asset_name || - this.booking().location || - this.booking().asset_id; - return location_name - ? `${location_name}, ${resource_name}` - : resource_name; - }); private readonly _use_region = this._settings.signal('use_region', false); public readonly building = computed(() => { const zones = this.booking()?.zones || []; diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 221196a84dd..80be2e20d80 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -91,7 +91,6 @@ import { } from './booking-resource.loaders'; import { findResourceById, - groupAvailability, nearbyResources, pickAutoAllocatedResource, preferredAllocationPool, @@ -625,15 +624,6 @@ export class BookingFormService extends AsyncHandler { this._resources_resource.reload(); } - /** Signal grouping available resources for group bookings */ - public readonly grouped_availability = computed(() => { - const options = this._options(); - const members = options.members?.length - ? options.members - : [currentUser()]; - return groupAvailability(this.available_resources(), members.length); - }); - public get booking() { return this._booking(); } diff --git a/libs/bookings/src/lib/booking-link-modal.component.ts b/libs/bookings/src/lib/booking-link-modal.component.ts index de623ed0180..23a4b6cabbb 100644 --- a/libs/bookings/src/lib/booking-link-modal.component.ts +++ b/libs/bookings/src/lib/booking-link-modal.component.ts @@ -1,7 +1,7 @@ import { Component, inject } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { Booking, SettingsService } from '@placeos/common'; +import { Booking } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { SafePipe } from 'libs/components/src/lib/safe.pipe'; @@ -76,7 +76,6 @@ import { }) export class BookingLinkModalComponent { private _event = inject(MAT_DIALOG_DATA); - private _settings = inject(SettingsService); public readonly outlook_link = generateMicrosoftCalendarLink( this._event as any, diff --git a/libs/bookings/src/lib/booking-resource.utilities.ts b/libs/bookings/src/lib/booking-resource.utilities.ts index 9f85958eadb..93924f5783e 100644 --- a/libs/bookings/src/lib/booking-resource.utilities.ts +++ b/libs/bookings/src/lib/booking-resource.utilities.ts @@ -71,35 +71,6 @@ export async function nearbyResources( return nearby_resources; } -/** Split the resources into groups of `member_count` that share a level. */ -export function groupAvailability( - resources: BookingAsset[], - member_count: number, -): BookingAsset[][] { - const groups: BookingAsset[][] = []; - const asset_list = [...resources].sort((a, b) => - a.zone?.id?.localeCompare(b.zone?.id), - ); - // Each pass pops at least one asset, so this ends when the list is empty. - while (asset_list.length) { - const group: BookingAsset[] = []; - let asset = asset_list.pop(); - while (group.length < member_count) { - if ( - group.length && - !group.find((_) => _.zone?.id === asset.zone?.id) - ) { - break; - } - group.push(asset); - asset = asset_list.pop(); - } - if (group.length < member_count) continue; - groups.push(group); - } - return groups; -} - /** * Resources whose tags or homebase match the user's groups. * Priority: both > homebase > tags > all available. diff --git a/libs/bookings/src/lib/bookings.fn.ts b/libs/bookings/src/lib/bookings.fn.ts index 879d91b5287..00f9f7464d0 100644 --- a/libs/bookings/src/lib/bookings.fn.ts +++ b/libs/bookings/src/lib/bookings.fn.ts @@ -17,7 +17,6 @@ import { currentUser, flatten, fromBookingRecurrence, - GuestUser, isRecurrenceInstanceDate, readAttendeeFlags, setting, @@ -607,87 +606,6 @@ export async function rejectBookingInstance( ); } -export async function setBookingState( - id: string, - state: string, - utm_source?: string, -): Promise { - const query = toQueryString({ state, utm_source }); - return new Booking( - await post( - `${BOOKINGS_ENDPOINT}/${encodeURIComponent(id)}/update_state${ - query ? '?' + query : '' - }`, - {}, - ), - ); -} - -/** - * List the guests in a booking - * @param id ID of the booking to reject - */ -export async function queryBookingGuests(id: string): Promise { - return new GuestUser( - await post(`${BOOKINGS_ENDPOINT}/${encodeURIComponent(id)}/guests`, ''), - ); -} - -/** - * Set the checkin state of a guest in a booking - * @param id ID of the booking to reject - * @param guest_id ID of the guest to check in - */ -export async function checkinBookingGuest( - id: string, - guest_id: string, - state = true, -): Promise { - return new GuestUser( - await post( - `${BOOKINGS_ENDPOINT}/${encodeURIComponent( - id, - )}/guests/${encodeURIComponent(guest_id)}/check_in?state=${state}&utm_source=${bookingUtmSource()}`, - '', - ), - ); -} - -/** - * Add a guest to a booking - * @param id ID of the booking - * @param guest Guest to add to the booking - */ -export async function bookingAddGuest( - id: string, - guest: GuestUser, -): Promise { - return new GuestUser( - await post( - `${BOOKINGS_ENDPOINT}/${encodeURIComponent(id)}/attendee`, - guest, - ), - ); -} - -/** - * Remove an attendee from a booking - * @param id ID of the booking - * @param guest Guest to remove from the booking - */ -export async function bookingRemoveGuest( - id: string, - guest: GuestUser, -): Promise { - return new GuestUser( - await del( - `${BOOKINGS_ENDPOINT}/${encodeURIComponent( - id, - )}/attendee/${encodeURIComponent(guest.email)}`, - ), - ); -} - /** * Set the checkin state of a booking * @param id ID of the booking to grab @@ -760,27 +678,6 @@ export async function setBookingCheckedIn( : checkinBooking(booking.id, state); } -/** - * Set the checkin state of a booking - * @param id ID of the booking to grab - * @param state New checkin state of the booking - */ -export async function checkinBookingAttendee( - id: string, - email: string, - state: boolean, -): Promise { - const query = toQueryString({ state }); - return new GuestUser( - await post( - `${BOOKINGS_ENDPOINT}/${encodeURIComponent( - id, - )}/guests/${encodeURIComponent(email)}/check_in?${query}&utm_source=${bookingUtmSource()}`, - '', - ), - ); -} - /** * Filter list of resources based of the bookings found in the given query * @param asset_id_list List of resource IDs @@ -808,27 +705,6 @@ export async function queryResourceAvailability( ); } -export async function isResourceAvailable( - id: string, - start: number, - duration: number, - ignore?: string, - type: BookingType = 'room', -): Promise { - const bookings = await queryBookings({ - type, - period_start: getUnixTime(start), - period_end: getUnixTime(addMinutes(start, duration)), - }); - return ( - bookings.filter( - (_) => - (_.asset_id === id || _.asset_ids.includes(id)) && - _.id !== ignore, - ).length === 0 - ); -} - /** * Bookings of the given type that this app linked to the event. A native * booking event already holds its child bookings from the API. For a calendar diff --git a/libs/bookings/src/lib/desk-confirm-modal.component.ts b/libs/bookings/src/lib/desk-confirm-modal.component.ts deleted file mode 100644 index ca1fb138359..00000000000 --- a/libs/bookings/src/lib/desk-confirm-modal.component.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, inject, output, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; - -import { - BuildingLevel, - Desk, - DialogEvent, - SettingsService, - User, -} from '@placeos/common'; - -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { DateFieldComponent } from 'libs/form-fields/src/lib/date-field.component'; -import { UserSearchFieldComponent } from 'libs/form-fields/src/lib/user-search-field.component'; - -export interface DeskConfirmModalData { - host: User; - desks: Desk[]; - date: number; - reason: string; - level: BuildingLevel; - can_set_date: boolean; -} - -@Component({ - selector: 'desk-flow-confirm-modal-component', - template: ` -
-

Confirm Booking

-
- @if (!loading()) { - - } -
- @if (!loading()) { -
- @if (can_set_host()) { -
- - -
- } -
- - @if (!can_set_date()) { -
- {{ date() | date: 'mediumDate' }} -
- } @else { - - } -
- @if (!hide_reason()) { -
- - - - -
- } -

- Your desk{{ desks.length === 1 ? '' : 's' }} will be - {{ desk_list() }} on - {{ level?.display_name || level?.name }} -

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

{{ loading() }}

-
- } - `, - styles: [ - ` - main { - width: 24rem; - max-width: calc(100vw - 4.5rem); - } - `, - ], - imports: [ - CommonModule, - TranslatePipe, - IconComponent, - MatProgressSpinnerModule, - MatRippleModule, - MatFormFieldModule, - MatInputModule, - DateFieldComponent, - UserSearchFieldComponent, - MatDialogModule, - FormsModule, - ], -}) -export class DeskConfirmModalComponent { - private _data = inject(MAT_DIALOG_DATA); - private _settings = inject(SettingsService); - - public readonly event = output(); - - public readonly desks = this._data.desks || []; - - public readonly date = signal(this._data.date); - public readonly host = signal(this._data.host); - - public readonly can_set_date = signal(this._data.can_set_date); - - public readonly reason = signal(this._data.reason); - - public readonly level = this._data.level; - - public readonly loading = signal(''); - private readonly _hide_reason = this._settings.signal( - 'desks.hide_reason', - false, - ); - private readonly _can_set_host = this._settings.signal( - 'desks.can_book_for_others', - false, - ); - - public readonly desk_list = computed(() => - this.desks.map((_) => _.name).join(', '), - ); - - public readonly hide_reason = this._hide_reason; - public readonly can_set_host = this._can_set_host; - - public confirm() { - this.loading.set('Requesting desk booking...'); - this.event.emit({ reason: 'done' }); - } -} diff --git a/libs/bookings/src/lib/desk-list-field.component.ts b/libs/bookings/src/lib/desk-list-field.component.ts index 974fb222a18..f5d7a5de60e 100644 --- a/libs/bookings/src/lib/desk-list-field.component.ts +++ b/libs/bookings/src/lib/desk-list-field.component.ts @@ -1,9 +1,7 @@ -import { Component, forwardRef, inject, input, signal } from '@angular/core'; +import { Component, forwardRef, inject, signal } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; import { SETTING_KEYS } from '@placeos/common'; import { SettingsService } from 'libs/common/src/lib/settings.service'; @@ -24,31 +22,6 @@ const EMPTY_FAVS: string[] = []; desk class="border-base-200 relative flex w-full items-center rounded-lg border p-2 shadow-sm" > - @if (features()?.length) { -
- -
- @for (opt of features(); track opt) { - - {{ opt }} - - } -
-
- }
@@ -152,8 +125,6 @@ const EMPTY_FAVS: string[] = []; IconComponent, TranslatePipe, MatRippleModule, - MatCheckboxModule, - FormsModule, AuthenticatedImageDirective, ], }) @@ -161,11 +132,9 @@ export class DeskListFieldComponent implements ControlValueAccessor { private _settings = inject(SettingsService); private _dialog = inject(MatDialog); - public readonly features = input([]); public readonly room_size = signal(3); public readonly items = signal([]); public readonly disabled = signal(false); - public readonly selected_features = signal([]); private _onChange: (_: BookingAsset[]) => void; private _onTouch: (_: BookingAsset[]) => void; @@ -221,15 +190,6 @@ export class DeskListFieldComponent implements ControlValueAccessor { (this._onTouch = fn); public readonly setDisabledState = (s: boolean) => this.disabled.set(s); - public setFeatures(opt: string, value: boolean) { - const features = this.selected_features() || []; - if (value) { - this.selected_features.set([...features, opt]); - } else { - this.selected_features.set(features.filter((f) => f !== opt)); - } - } - public toggleFavourite(space: BookingAsset) { if (!space?.id) return; const fav_list = this.favorites() || EMPTY_FAVS; diff --git a/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts index 0fea663d2f4..db1be5d7615 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-filters.component.ts @@ -1,11 +1,4 @@ -import { - Component, - computed, - effect, - inject, - input, - signal, -} from '@angular/core'; +import { Component, computed, effect, inject, input } from '@angular/core'; import { SettingsService } from '@placeos/common'; import { addDays, endOfDay } from 'date-fns'; @@ -248,7 +241,6 @@ export class DeskFiltersComponent { public readonly hide_levels = input(undefined); - public readonly can_close = signal(false); public readonly options = this._state.options; public readonly features = this._state.features; public readonly buildings = this._org.active_buildings; diff --git a/libs/bookings/src/lib/desk.service.ts b/libs/bookings/src/lib/desk.service.ts deleted file mode 100644 index 31ab83cf668..00000000000 --- a/libs/bookings/src/lib/desk.service.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { inject, Injectable } from '@angular/core'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; -import { - currentUser, - Desk, - DialogEvent, - notifyError, - notifySuccess, - User, -} from '@placeos/common'; -import { endOfDay, getUnixTime, startOfDay } from 'date-fns'; - -import { OrganisationService } from '@placeos/common'; - -import { queryBookings, saveBooking } from './bookings.fn'; -import { DeskConfirmModalComponent } from './desk-confirm-modal.component'; -import { DeskQuestionsModalComponent } from './desk-questions-modal.component'; - -@Injectable({ - providedIn: 'root', -}) -export class DesksService { - private _org = inject(OrganisationService); - private _dialog = inject(MatDialog); - - public can_set_date = true; - public error_on_host = true; - - public async bookDesk({ - desks, - host, - reason, - attendees, - date, - }: { - desks: Desk[]; - host?: User; - attendees?: User[]; - reason?: string; - date?: Date; - }) { - if (this.error_on_host && !host) { - return notifyError('You need to select a host to book a desk.'); - } else { - host = host || currentUser(); - } - reason = reason || ''; - const level = this._org.levelWithID( - desks[0].zone instanceof Array - ? desks[0].zone - : [desks[0].zone?.id], - ); - let ref: MatDialogRef = this._dialog.open( - DeskQuestionsModalComponent, - ); - let success = await Promise.race([ - this._doneEvent(ref.componentInstance.event), - new Promise((resolve) => - ref.afterClosed().subscribe(() => resolve(null)), - ), - ]); - if (!success) return; - ref.close(); - ref = this._dialog.open(DeskConfirmModalComponent, { - data: { - host, - desks, - date: date ? new Date(date) : new Date(), - reason, - level, - can_set_date: this.can_set_date, - }, - }); - success = await Promise.race([ - this._doneEvent(ref.componentInstance.event), - new Promise((resolve) => - ref.afterClosed().subscribe(() => resolve(null)), - ), - ]); - if (!success) return; - host = ref.componentInstance.host || host; - date = ref.componentInstance.date || date; - reason = ref.componentInstance.reason || reason; - if (!host) { - ref.close(); - return notifyError('You need to select a host to book a desk. '); - } - ref.componentInstance.loading = - 'Checking for existing desk bookings...'; - const bookings = await queryBookings({ - type: 'desk', - period_start: getUnixTime(startOfDay(date || new Date())), - period_end: getUnixTime(endOfDay(date || new Date())), - }); - const desk_list = bookings.filter( - (d) => d.user_email?.toLowerCase() === host.email?.toLowerCase(), - ); - if (desk_list?.length) { - ref.close(); - return notifyError( - 'You currently already have a desk booked for the selected date.', - ); - } - ref.componentInstance.loading = 'Booking desk...'; - const users = [host, ...(attendees || [])]; - await Promise.all([ - desks.map((desk, idx) => - this.makeDeskBooking( - desk, - host, - date.valueOf() || new Date().valueOf(), - reason, - users[idx], - ), - ), - ]); - notifySuccess('Successfully booked desk'); - ref.close(); - return true; - } - - private async makeDeskBooking( - desk: Desk, - host: User, - date: number, - reason: string, - for_user: User = null, - ) { - const location = `${desk.zone?.name}-${desk.id}`; - const level = this._org.levelWithID( - desk.zone instanceof Array ? desk.zone : [desk.zone?.id], - ); - const zones = desk.zone?.id - ? [desk.zone?.id, level?.parent_id] - : [level?.parent_id]; - const booking_data = { - booking_start: getUnixTime(startOfDay(date)), - user_id: for_user?.id || host.id, - user_name: for_user?.name || host.name, - user_email: for_user?.email || host.email, - booking_end: Math.floor(endOfDay(date).valueOf() / 1000), - asset_id: desk.id, - asset_name: desk.name, - title: reason, - description: location, - zones, - booking_type: 'desk', - extension_data: { - map_id: desk?.map_id || desk?.id, - groups: desk.groups, - for_user: for_user?.email, - }, - }; - return saveBooking(booking_data as any); - } - - private _doneEvent(event: { subscribe: (callback: unknown) => unknown }) { - return new Promise((resolve) => { - let sub: any; - sub = event.subscribe((details: DialogEvent) => { - if (details.reason !== 'done') return; - sub?.unsubscribe?.(); - resolve(details); - }); - }); - } -} diff --git a/libs/bookings/src/lib/locker-list-field.component.ts b/libs/bookings/src/lib/locker-list-field.component.ts index e4f61284550..55246df883e 100644 --- a/libs/bookings/src/lib/locker-list-field.component.ts +++ b/libs/bookings/src/lib/locker-list-field.component.ts @@ -1,12 +1,8 @@ -import { Component, forwardRef, inject, input, signal } from '@angular/core'; +import { Component, forwardRef, inject, signal } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; -import { SETTING_KEYS } from '@placeos/common'; -import { SettingsService } from 'libs/common/src/lib/settings.service'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { LevelPipe } from 'libs/components/src/lib/level.pipe'; @@ -14,8 +10,6 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; import { BookingAsset } from './booking-form.types'; import { LockerSelectModalComponent } from './locker-select-modal/locker-select-modal.component'; -const EMPTY_FAVS: string[] = []; - @Component({ selector: `locker-list-field`, template: ` @@ -25,33 +19,6 @@ const EMPTY_FAVS: string[] = []; locker class="border-base-200 relative flex w-full items-center rounded-lg border p-2 shadow-sm" > - @if (features()?.length) { -
- -
- @for (opt of features(); track opt) { - - {{ opt }} - - } -
-
- }
@@ -154,30 +121,19 @@ const EMPTY_FAVS: string[] = []; TranslatePipe, MatRippleModule, AuthenticatedImageDirective, - MatCheckboxModule, LevelPipe, - FormsModule, ], }) export class LockerListFieldComponent implements ControlValueAccessor { - private _settings = inject(SettingsService); private _dialog = inject(MatDialog); - public readonly features = input([]); public readonly room_size = signal(3); public readonly items = signal([]); public readonly disabled = signal(false); - public readonly selected_features = signal([]); private _onChange: (_: BookingAsset[]) => void; private _onTouch: (_: BookingAsset[]) => void; - public readonly favorites = this._settings.signal( - SETTING_KEYS.FAVORITE_LOCKERS, - EMPTY_FAVS, - true, - ); - /** Add or edit selected items */ public changeResources() { const ref = this._dialog.open(LockerSelectModalComponent, { @@ -222,29 +178,4 @@ export class LockerListFieldComponent implements ControlValueAccessor { public readonly registerOnTouched = (fn: (_: BookingAsset[]) => void) => (this._onTouch = fn); public readonly setDisabledState = (s: boolean) => this.disabled.set(s); - - public setFeatures(opt: string, value: boolean) { - const features = this.selected_features() || []; - if (value) { - this.selected_features.set([...features, opt]); - } else { - this.selected_features.set(features.filter((f) => f !== opt)); - } - } - - public toggleFavourite(space: BookingAsset) { - const fav_list = this.favorites() || EMPTY_FAVS; - const new_state = !fav_list.includes(space.id); - if (new_state) { - this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_LOCKERS, [ - ...fav_list, - space.id, - ]); - } else { - this._settings.saveUserSetting( - SETTING_KEYS.FAVORITE_LOCKERS, - fav_list.filter((_) => _ !== space.id), - ); - } - } } diff --git a/libs/bookings/src/lib/parking-select-modal/parking-location-pin.component.ts b/libs/bookings/src/lib/parking-select-modal/parking-location-pin.component.ts deleted file mode 100644 index bfa5150d391..00000000000 --- a/libs/bookings/src/lib/parking-select-modal/parking-location-pin.component.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, computed, inject } from '@angular/core'; -import { MAP_FEATURE_DATA } from '@placeos/common'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; - -@Component({ - selector: 'parking-space-location-pin', - template: ` -
- - - - - - - - - - - - - - - - - -
-
- - done - -
-
- `, - styles: [], - imports: [CommonModule, IconComponent], -}) -export class ParkingLocationPinComponent { - private _data = inject(MAP_FEATURE_DATA); - - public readonly selected = computed(() => this._data.selected === true); -} diff --git a/libs/bookings/src/test/desk-confirm-modal.component.spec.ts b/libs/bookings/src/test/desk-confirm-modal.component.spec.ts deleted file mode 100644 index 4c393cbdd74..00000000000 --- a/libs/bookings/src/test/desk-confirm-modal.component.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { FormsModule } from '@angular/forms'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { Spectator, createComponentFactory } from '@ngneat/spectator/vitest'; -import { SettingsService } from '@placeos/common'; -import { createSettingsServiceMock } from '@placeos/common/tests'; -import { MockModule } from 'ng-mocks'; - -import { DeskConfirmModalComponent } from '../lib/desk-confirm-modal.component'; - -describe('DeskConfirmModalComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: DeskConfirmModalComponent, - shallow: true, - providers: [ - { provide: SettingsService, useValue: createSettingsServiceMock() }, - { provide: MAT_DIALOG_DATA, useValue: {} }, - ], - imports: [ - FormsModule, - MockModule(MatFormFieldModule), - MockModule(MatProgressSpinnerModule), - ], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should allow setting the date', () => { - expect('[date]').toExist(); - expect('div[date]').toExist(); - expect('a-date-field').not.toExist(); - }); - - it('should allow setting the host', () => { - expect('[host]').not.toExist(); - const settings = spectator.inject(SettingsService); - settings.get.mockImplementation(() => true); - spectator.detectChanges(); - expect('[host]').toExist(); - }); - - it('should show date field', () => { - spectator.component.can_set_date.set(true); - spectator.detectChanges(); - expect('div[date]').not.toExist(); - expect('a-date-field').toExist(); - }); - - it('should show load state', () => { - expect('footer button').toExist(); - expect('[load]').not.toExist(); - spectator.click('footer button'); - spectator.detectChanges(); - expect('footer button').not.toExist(); - expect('[load]').toExist(); - expect('[load]').toContainText('Requesting desk booking...'); - }); -}); diff --git a/libs/bookings/src/test/desks.service.spec.ts b/libs/bookings/src/test/desks.service.spec.ts deleted file mode 100644 index a0ea6caff83..00000000000 --- a/libs/bookings/src/test/desks.service.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { Mock } from 'vitest'; -import { MatDialog } from '@angular/material/dialog'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; -import { Desk, OrganisationService, StaffUser, User } from '@placeos/common'; -import { setNotifyOutlet } from 'libs/common/src/lib/notifications'; -import { of } from 'rxjs'; - -import { DesksService } from '../lib/desk.service'; - -// bookings.fn and the notification helpers run for real; only the ts-client -// API layer beneath them is stubbed (`queryBookings` -> `get`, -// `saveBooking` -> `post`), and notifications are captured through an injected -// snackbar outlet. -vi.mock('@placeos/ts-client', { spy: true }); - -import * as ts_client from '@placeos/ts-client'; - -describe('DesksService', () => { - let spectator: SpectatorService; - let notify_open: Mock; - const createService = createServiceFactory({ - service: DesksService, - providers: [ - { - provide: OrganisationService, - useValue: { levelWithID: vi.fn() }, - }, - { - provide: MatDialog, - useValue: { - open: vi.fn(() => ({ - componentInstance: { - event: of({ reason: 'done' }), - host: new StaffUser({ email: 'test@example.com' }), - date: new Date(), - reason: 'Test reason', - loading: '', - }, - afterClosed: () => of(''), - close: () => null, - })), - }, - }, - ], - }); - - const messages = () => notify_open.mock.calls.map((call) => call[0]); - - beforeEach(() => { - vi.clearAllMocks(); - notify_open = vi.fn(() => ({ onAction: () => of(), dismiss: vi.fn() })); - setNotifyOutlet({ open: notify_open } as any, true); - vi.mocked(ts_client.get).mockResolvedValue([] as any); - vi.mocked(ts_client.post).mockResolvedValue({} as any); - vi.mocked(ts_client.patch).mockResolvedValue({} as any); - spectator = createService(); - }); - - afterEach(() => setNotifyOutlet(null, true)); - - it('should create service', () => { - expect(spectator.service).toBeTruthy(); - }); - - it('should prevent booking without a host', async () => { - expect(notify_open).not.toHaveBeenCalled(); - await spectator.service.bookDesk({ desks: [new Desk()] }); - expect(messages()).toContain( - 'You need to select a host to book a desk.', - ); - expect(ts_client.post).not.toHaveBeenCalled(); - }); - - it('should fall back to the current user when host errors are disabled', async () => { - // With `error_on_host` disabled and no host provided, the service falls - // back to the current user and proceeds with the booking rather than - // aborting. - spectator.service.error_on_host = false; - await spectator.service.bookDesk({ desks: [new Desk()] }); - expect(messages()).toContain('Successfully booked desk'); - expect(ts_client.post).toHaveBeenCalledTimes(1); - }); - - it('should allow booking a desk', async () => { - // Host already has a desk booked for the day -> booking is blocked. - vi.mocked(ts_client.get).mockResolvedValueOnce([ - { user_email: 'test@example.com' }, - ] as any); - await spectator.service.bookDesk({ - desks: [new Desk()], - host: new StaffUser({ email: 'test@example.com' }), - }); - expect(messages()).toContain( - 'You currently already have a desk booked for the selected date.', - ); - expect(ts_client.post).not.toHaveBeenCalled(); - - // No existing bookings -> booking succeeds. - vi.mocked(ts_client.get).mockResolvedValue([] as any); - await spectator.service.bookDesk({ - desks: [new Desk()], - host: new StaffUser(), - date: new Date(), - }); - expect(messages()).toContain('Successfully booked desk'); - expect(ts_client.post).toHaveBeenCalledTimes(1); - }); - - it('should allow booking desks for multiple people', async () => { - vi.mocked(ts_client.get).mockResolvedValue([] as any); - await spectator.service.bookDesk({ - desks: [new Desk(), new Desk(), new Desk()], - host: new StaffUser(), - attendees: [new User(), new User(), new User()], - date: new Date(), - }); - expect(messages()).toContain('Successfully booked desk'); - expect(ts_client.post).toHaveBeenCalledTimes(3); - }); -}); diff --git a/libs/bookings/src/test/locker-list-field.component.spec.ts b/libs/bookings/src/test/locker-list-field.component.spec.ts index ff5e71bcae1..ab95c414076 100644 --- a/libs/bookings/src/test/locker-list-field.component.spec.ts +++ b/libs/bookings/src/test/locker-list-field.component.spec.ts @@ -3,7 +3,6 @@ import { FormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialog } from '@angular/material/dialog'; import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { SETTING_KEYS } from '@placeos/common'; import { createSettingsServiceMock } from '@placeos/common/tests'; import { MockComponent, MockDirective, MockModule, MockPipe } from 'ng-mocks'; import { of } from 'rxjs'; @@ -118,45 +117,9 @@ describe('LockerListFieldComponent', () => { expect(spectator.component.disabled()).toBe(true); }); - it('should render feature checkboxes and toggle selection', () => { - spectator.setInput('features', ['power', 'usb']); - spectator.component.setValue([{ id: 'a' }] as any); - spectator.detectChanges(); - const boxes = spectator.queryAll('mat-checkbox'); - expect(boxes.length).toBe(2); - spectator.component.setFeatures('power', true); - expect(spectator.component.selected_features()).toEqual(['power']); - spectator.component.setFeatures('usb', true); - expect(spectator.component.selected_features()).toEqual([ - 'power', - 'usb', - ]); - spectator.component.setFeatures('power', false); - expect(spectator.component.selected_features()).toEqual(['usb']); - }); - it('should show the accessible indicator for accessible lockers', () => { spectator.component.setValue([{ id: 'a', accessible: true }] as any); spectator.detectChanges(); expect(spectator.query('icon.text-info')).toExist(); }); - - it('should add a favourite when toggling an unfavourited locker', () => { - const settings = spectator.inject(SettingsService); - spectator.component.toggleFavourite({ id: 'a' } as any); - expect(settings.saveUserSetting).toHaveBeenCalledWith( - SETTING_KEYS.FAVORITE_LOCKERS, - ['a'], - ); - }); - - it('should remove a favourite when toggling a favourited locker', () => { - const settings = spectator.inject(SettingsService); - (settings.get as Mock).mockReturnValue(['a', 'b']); - spectator.component.toggleFavourite({ id: 'a' } as any); - expect(settings.saveUserSetting).toHaveBeenCalledWith( - SETTING_KEYS.FAVORITE_LOCKERS, - ['b'], - ); - }); }); diff --git a/libs/bookings/src/test/parking-location-pin.component.spec.ts b/libs/bookings/src/test/parking-location-pin.component.spec.ts deleted file mode 100644 index 1e8d34add39..00000000000 --- a/libs/bookings/src/test/parking-location-pin.component.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MAP_FEATURE_DATA } from '@placeos/common'; -import { MockComponent } from 'ng-mocks'; - -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { ParkingLocationPinComponent } from '../lib/parking-select-modal/parking-location-pin.component'; - -describe('ParkingLocationPinComponent', () => { - let spectator: Spectator; - let pin_data: unknown; - - const createComponent = createComponentFactory({ - component: ParkingLocationPinComponent, - declarations: [MockComponent(IconComponent)], - providers: [ - { provide: MAP_FEATURE_DATA, useFactory: () => pin_data }, - ], - }); - - it('should create component', () => { - pin_data = { selected: false }; - spectator = createComponent(); - expect(spectator.component).toBeTruthy(); - }); - - it('should render the unselected dot when not selected', () => { - pin_data = { selected: false }; - spectator = createComponent(); - expect(spectator.component.selected()).toBe(false); - expect(spectator.query('div.rounded-full')).toExist(); - expect(spectator.query('icon')).not.toExist(); - }); - - it('should render the done icon when selected', () => { - pin_data = { selected: true }; - spectator = createComponent(); - expect(spectator.component.selected()).toBe(true); - expect(spectator.query('icon')).toExist(); - expect(spectator.query('div.rounded-full')).not.toExist(); - }); - - it('should only treat strict true as selected', () => { - pin_data = { selected: 'yes' }; - spectator = createComponent(); - expect(spectator.component.selected()).toBe(false); - }); -}); diff --git a/libs/catering/src/lib/catering-menu.component.ts b/libs/catering/src/lib/catering-menu.component.ts index ebfe779a5b1..39c9dfe168b 100644 --- a/libs/catering/src/lib/catering-menu.component.ts +++ b/libs/catering/src/lib/catering-menu.component.ts @@ -5,7 +5,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; import { MatMenuModule } from '@angular/material/menu'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { CateringItem, OrganisationService, unique } from '@placeos/common'; +import { CateringItem, unique } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { SimpleTableComponent } from 'libs/components/src/lib/simple-table.component'; @@ -243,7 +243,6 @@ import { CateringStateService } from './catering-state.service'; export class CateringMenuComponent { private _catering = inject(CateringStateService); private _orders = inject(CateringOrdersService); - private _org = inject(OrganisationService); public readonly currency_code = this._catering.currency; diff --git a/libs/catering/src/lib/catering-order-options-modal.component.ts b/libs/catering/src/lib/catering-order-options-modal.component.ts deleted file mode 100644 index 8b79b70bb40..00000000000 --- a/libs/catering/src/lib/catering-order-options-modal.component.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { - Component, - EventEmitter, - Output, - computed, - inject, - signal, -} from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; - -import { unique } from 'libs/common/src/lib/general'; -import { DialogEvent, HashMap } from 'libs/common/src/lib/types'; - -import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatRippleModule } from '@angular/material/core'; -import { MatRadioModule } from '@angular/material/radio'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { CateringOption, CateringOptionGroup } from './catering.interfaces'; - -export interface CateringOrderOptionsModalData { - code: string; - options: CateringOption[]; -} - -@Component({ - selector: 'app-catering-options-modal', - template: ` -
-

Select options

- -
-
- @for (group of groups(); track group) { -
-
- {{ group.name }} -
-
- @if (!group.multiple) { - - - None - - @for (opt of group?.options; track opt) { - -
-
- {{ opt.name }} -
- @if (opt.unit_price) { -
- +{{ - opt.unit_price / 100 - | currency: code() - }} -
- } -
-
- } -
- } @else { - @for (opt of group?.options; track opt) { - -
-
- {{ opt.name }} -
- @if (opt.unit_price) { -
- +{{ - opt.unit_price / 100 - | currency: code() - }} -
- } -
-
- } - } -
-
- } -
-
- -
- `, - styles: [ - ` - main { - min-height: 24em; - width: 24rem; - max-width: calc(100vw - 2rem); - } - `, - ], - imports: [ - CommonModule, - MatRippleModule, - TranslatePipe, - MatCheckboxModule, - MatRadioModule, - MatDialogModule, - IconComponent, - FormsModule, - ], -}) -export class CateringOrderOptionsModalComponent { - private _data = inject(MAT_DIALOG_DATA); - - /** Emitter for events on the modal */ - @Output() public event = new EventEmitter(); - /** List of option groups */ - public readonly groups = signal([]); - /** Mapping of options to their active state */ - public readonly option_state = signal>({}); - public readonly code = computed(() => this._data.code); - - constructor() { - const groups = unique( - this._data.options.map((i) => i.group || 'Other'), - ); - const group_list = []; - for (const group of groups) { - const options = this._data.options.filter((i) => i.group === group); - group_list.push({ - name: group, - multiple: !!options.find((i) => i.multiple), - options, - }); - } - this.groups.set(group_list); - } - - public updateGroupOption(group: CateringOptionGroup, id: string) { - if (!group) return; - const option_state = { ...this.option_state() }; - for (const option of group.options) { - option_state[option.id] = option.id === id; - } - this.option_state.set(option_state); - } - - public setOptionState(id: string, state: boolean) { - this.option_state.update((option_state) => ({ - ...option_state, - [id]: state, - })); - } - - public saveOptions() { - const option_state = this.option_state(); - const options = this._data.options.filter( - (opt) => option_state[opt.id], - ); - this.event.emit({ reason: 'done', metadata: { options } }); - } -} diff --git a/libs/catering/src/lib/catering-select-modal/catering-item-list.component.ts b/libs/catering/src/lib/catering-select-modal/catering-item-list.component.ts index e7e3491616e..b2a36392c92 100644 --- a/libs/catering/src/lib/catering-select-modal/catering-item-list.component.ts +++ b/libs/catering/src/lib/catering-select-modal/catering-item-list.component.ts @@ -90,7 +90,6 @@ export class CateringItemListComponent { private _state = inject(CateringOrderStateService); public readonly active = input(''); - public readonly selected = input([]); public readonly selected_items = input([]); public readonly favorites = input([]); public readonly toggleFav = output(); diff --git a/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts b/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts index cd94c52d528..eafe73ab792 100644 --- a/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts +++ b/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts @@ -54,7 +54,6 @@ const EMPTY_FAVS: string[] = []; > - this.selected().map((_) => this.selectionKey(_)), - ); - - public readonly count = computed(() => - this.selected().reduce((t, i) => t + i.quantity, 0), - ); - public readonly code = computed(() => { this._org.active_building?.(); return this._org.currency_code; diff --git a/libs/catering/src/lib/catering-state.service.ts b/libs/catering/src/lib/catering-state.service.ts index 082f43f9acc..01c7f5e4cd7 100644 --- a/libs/catering/src/lib/catering-state.service.ts +++ b/libs/catering/src/lib/catering-state.service.ts @@ -14,7 +14,6 @@ import { import { AsyncHandler, CateringItem, - CateringOrder, OrganisationService, SettingsService, flatten, @@ -33,10 +32,6 @@ import { CateringItemOptionModalComponent, CateringItemOptionModalData, } from './catering-option-modal.component'; -import { - CateringOrderOptionsModalComponent, - CateringOrderOptionsModalData, -} from './catering-order-options-modal.component'; import { CateringOption } from './catering.interfaces'; import { @@ -59,7 +54,6 @@ export class CateringStateService extends AsyncHandler { private _org = inject(OrganisationService); private _dialog = inject(MatDialog); private _settings = inject(SettingsService); - private _updated = signal(0); /** Active menu */ private _menu = signal([]); /** Whether the menu for the active building is loading */ @@ -285,25 +279,6 @@ export class CateringStateService extends AsyncHandler { ); } - public async selectOptions(options: CateringOption[]) { - const ref = this._dialog.open< - CateringOrderOptionsModalComponent, - CateringOrderOptionsModalData - >(CateringOrderOptionsModalComponent, { - data: { - code: this._currency(), - options, - }, - }); - const details = await Promise.race([ - this._doneEvent(ref.componentInstance.event), - this._closedEvent(ref.afterClosed()), - ]); - if (details?.reason !== 'done') return []; - ref.close(); - return details.metadata.options; - } - public async deleteItem(item: CateringItem) { const details = await openConfirmModal( { @@ -466,34 +441,6 @@ export class CateringStateService extends AsyncHandler { }); } - public addItemToOrder(order: CateringOrder, new_item: CateringItem) { - let items = order.items; - const match = items.find( - (item) => - item.id === new_item.id && - new_item.options?.length === - item.options?.reduce( - (c, o) => - c + - (new_item.options.find((opt) => o.id === opt.id) - ? 1 - : 0), - 0, - ), - ); - match - ? ((match as any).quantity += 1) - : (items = items.concat([ - new CateringItem({ ...new_item, quantity: 1 }), - ])); - const new_order = new CateringOrder({ - ...order, - items, - event: null, - }); - return new_order; - } - private async _loadBuilding(building_id: string, currency: string) { this._loading.set(true); this._menu.set([]); diff --git a/libs/catering/src/tests/catering-order-options-modal.component.spec.ts b/libs/catering/src/tests/catering-order-options-modal.component.spec.ts deleted file mode 100644 index 605bc1ebb86..00000000000 --- a/libs/catering/src/tests/catering-order-options-modal.component.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { MatRadioModule } from '@angular/material/radio'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { MockComponent } from 'ng-mocks'; - -import { CateringOrderOptionsModalComponent } from '../lib/catering-order-options-modal.component'; - -describe('CateringOrderOptionsModalComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: CateringOrderOptionsModalComponent, - declarations: [MockComponent(IconComponent)], - providers: [ - { - provide: MAT_DIALOG_DATA, - useValue: { - options: [ - { id: '1', group: 'Single' }, - { id: '2', group: 'Single' }, - { id: '3', group: 'Multi', multiple: true }, - { id: '4', group: 'Multi', multiple: true }, - ], - }, - }, - ], - imports: [MatCheckboxModule, MatRadioModule, FormsModule], - }); - - beforeEach(() => (spectator = createComponent())); - - it('should allow for multi or single selection', () => { - expect('[group="Multi"] mat-radio-button').not.toExist(); - expect('[group="Single"] mat-checkbox').not.toExist(); - expect('[group="Single"] mat-radio-button').toExist(); - expect('[group="Multi"] mat-checkbox').toExist(); - }); - - it('should submit selected option', async () => { - const emitted = new Promise((resolve) => { - spectator.component.event.subscribe((e) => resolve(e)); - }); - spectator.component.updateGroupOption( - spectator.component.groups()[0], - '1', - ); - expect(spectator.component.option_state()['1']).toBeTruthy(); - expect(spectator.component.option_state()['2']).toBeFalsy(); - spectator.component.saveOptions(); - const e = await emitted; - expect(e.reason).toBe('done'); - expect(e.metadata.options).toHaveLength(1); - expect(e.metadata.options[0].id).toBe('1'); - }); -}); diff --git a/libs/catering/src/tests/catering-select-modal.component.spec.ts b/libs/catering/src/tests/catering-select-modal.component.spec.ts index efb3e16cf6b..1bd38daf68a 100644 --- a/libs/catering/src/tests/catering-select-modal.component.spec.ts +++ b/libs/catering/src/tests/catering-select-modal.component.spec.ts @@ -50,12 +50,10 @@ describe('CateringSelectModalComponent', () => { ], }); spectator.component.setSelected(item, true); - expect(spectator.component.selected_keys()).toContain( + expect(spectator.component.selected()).toHaveLength(1); + expect(spectator.component.selectionKey(variant)).not.toBe( spectator.component.selectionKey(item), ); - expect(spectator.component.selected_keys()).not.toContain( - spectator.component.selectionKey(variant), - ); spectator.component.setSelected(variant, true); expect(spectator.component.selected()).toHaveLength(2); }); @@ -74,14 +72,9 @@ describe('CateringSelectModalComponent', () => { }, ], }); - spectator.component.setSelected(item, true); - expect(spectator.component.selected_keys()).toContain( - spectator.component.selectionKey(item), - ); + const key = spectator.component.selectionKey(item); item.options[0].active = true; - expect(spectator.component.selected_keys()).not.toContain( - spectator.component.selectionKey(item), - ); + expect(spectator.component.selectionKey(item)).not.toBe(key); }); it('should reset the menu item and show the ordered item after adding it', () => { diff --git a/libs/catering/src/tests/catering-state.service.spec.ts b/libs/catering/src/tests/catering-state.service.spec.ts index 2aa8edfc652..f010f2d211a 100644 --- a/libs/catering/src/tests/catering-state.service.spec.ts +++ b/libs/catering/src/tests/catering-state.service.spec.ts @@ -5,12 +5,7 @@ import { MockProvider } from 'ng-mocks'; import { of } from 'rxjs'; import { delay } from 'rxjs/operators'; -import { - Building, - CateringItem, - CateringOrder, - OrganisationService, -} from '@placeos/common'; +import { Building, CateringItem, OrganisationService } from '@placeos/common'; import { SettingsService } from 'libs/common/src/lib/settings.service'; import { CateringStateService } from '../lib/catering-state.service'; @@ -137,18 +132,6 @@ describe('CateringStateService', () => { ); }); - it('should allow user to select catering options to orders', async () => { - const dialog = spectator.inject(MatDialog); - (dialog.open as any).mockImplementation(dialog_fn(true)); - let options = await spectator.service.selectOptions([]); - expect(options).toEqual([]); - (dialog.open as any).mockImplementation( - dialog_fn(false, { options: [{} as any] }), - ); - options = await spectator.service.selectOptions([]); - expect(options).toEqual([{}]); - }); - it('should allow user to remove catering items from menu', async () => { const dialog = spectator.inject(MatDialog); (dialog.open as any).mockImplementation(dialog_fn(true)); @@ -207,17 +190,6 @@ describe('CateringStateService', () => { ); }); - it('should allow user to add items to order', () => { - const order = new CateringOrder(); - const item = new CateringItem({ id: '1' }); - let updated_order = spectator.service.addItemToOrder(order, item); - expect(updated_order.items).toHaveLength(1); - expect(updated_order.items[0].quantity).toBe(1); - updated_order = spectator.service.addItemToOrder(updated_order, item); - expect(updated_order.items).toHaveLength(1); - expect(updated_order.items[0].quantity).toBe(2); - }); - it('should open a copy of an item without its ID', async () => { const add = vi .spyOn(spectator.service, 'addItem') diff --git a/libs/events/src/lib/calendar.fn.ts b/libs/events/src/lib/calendar.fn.ts index b6962c31ed2..290198f81e1 100644 --- a/libs/events/src/lib/calendar.fn.ts +++ b/libs/events/src/lib/calendar.fn.ts @@ -39,14 +39,6 @@ const calendarsToSpaces = (list: Calendar[], org?: OrganisationService) => ) .filter((space) => space.bookable); -/** List available spaces based on the given query */ -export async function querySpaceCalendarAvailability( - q: CalendarAvailabilityQueryParams, - org?: OrganisationService, -): Promise { - return calendarsToSpaces(await queryCalendarAvailability(q), org); -} - export async function queryUserFreeBusy( q: CalendarAvailabilityQueryParams, ): Promise { diff --git a/libs/events/src/lib/calendar.service.ts b/libs/events/src/lib/calendar.service.ts index 5d177568846..f06dd5b5e2c 100644 --- a/libs/events/src/lib/calendar.service.ts +++ b/libs/events/src/lib/calendar.service.ts @@ -1,10 +1,8 @@ import { inject, Injectable, signal } from '@angular/core'; -import { addMinutes, endOfDay, getUnixTime, startOfDay } from 'date-fns'; import { AsyncHandler, Calendar, - CalendarEvent, log, OrganisationService, SettingsService, @@ -52,53 +50,6 @@ export class CalendarService extends AsyncHandler { return this._calendars(); } - /** Get Free busy for the selected day - * @param calendars User calendar - * @param date Selected day - */ - public getFreeBusyDate(date: number, calendars: string) { - return querySpaceFreeBusy( - { - period_start: getUnixTime(startOfDay(date)), - period_end: getUnixTime(endOfDay(date)), - calendars, - }, - this._org, - ); - } - - /** Check rooms availability */ - public async checkSpacesAvailability( - system_ids: string[], - period_start: number, - period_end: number, - old_booking?: CalendarEvent, - ) { - const result = await queryCalendarAvailability({ - period_start, - period_end, - system_ids: system_ids.join(','), - }); - const start = new Date(old_booking?.date).valueOf(); - const end = addMinutes(start, old_booking?.duration).valueOf(); - const available = result.every((i) => { - const availability = i.availability; - if (old_booking && i.id === old_booking.system?.email) { - const index = availability.findIndex((block) => { - return ( - block.date >= start && - addMinutes(block.date, block.duration).valueOf() <= end - ); - }); - if (index !== -1) { - availability.splice(index, 1); - } - } - return !availability.length; - }); - return !!available; - } - public async loadCalendars() { if (this._calendars().length) return; this._calendars_request = diff --git a/libs/events/src/lib/event-form.service.ts b/libs/events/src/lib/event-form.service.ts index 59f01f345e4..cf9a1a352bc 100644 --- a/libs/events/src/lib/event-form.service.ts +++ b/libs/events/src/lib/event-form.service.ts @@ -19,6 +19,7 @@ import { currentUserCanApprove, currentUserIsLoaded, currentUserLoaded, + errorMessage, ExternalCateringService, filterResourcesFromRules, firstValueWhere, @@ -67,7 +68,6 @@ import { checkRecurringClashes, checkSpaceRules, checkSpacesAvailable, - errorMessage, eventSaveQuery, isPermissionError, spaceNames, @@ -465,9 +465,7 @@ export class EventFormService extends AsyncHandler { public readonly unavailable_resources = computed(() => { if (this.loading() || this._spaces_failed()) return []; const filtered = new Set(this.filtered_spaces().map(({ id }) => id)); - const available = new Set( - this.available_spaces().map(({ id }) => id), - ); + const available = new Set(this.available_spaces().map(({ id }) => id)); return (this._model().resources || []).filter( ({ id }) => filtered.has(id) && !available.has(id), ); diff --git a/libs/events/src/lib/event-save.fn.ts b/libs/events/src/lib/event-save.fn.ts index b7c73606ffe..5bfc1aae52b 100644 --- a/libs/events/src/lib/event-save.fn.ts +++ b/libs/events/src/lib/event-save.fn.ts @@ -2,6 +2,7 @@ import type { MatDialog } from '@angular/material/dialog'; import { currentUser, DEFAULT_SETTINGS, + errorMessage, getItemWithKeys, getTimeInTimezone, i18n, @@ -306,18 +307,6 @@ interface ErrorDetails { /** Shape of an error from the API client, possibly nested in `error`. */ type ApiError = ErrorDetails & { error?: string | ErrorDetails }; -/** Readable message of a thrown error, or an empty string. */ -export function errorMessage(error: unknown): string { - if (typeof error === 'string') return error; - if (error instanceof Error && error.message) return error.message; - const api_error = error as ApiError | null | undefined; - const nested = api_error?.error; - if (typeof nested === 'string') return nested; - if (typeof api_error?.message === 'string') return api_error.message; - if (typeof nested?.message === 'string') return nested.message; - return ''; -} - /** Whether a thrown error means that the user may not save the event. */ export function isPermissionError(error: unknown): boolean { const api_error = error as ApiError | null | undefined; diff --git a/libs/events/src/lib/event.interfaces.ts b/libs/events/src/lib/event.interfaces.ts deleted file mode 100644 index 9e51ed6dd61..00000000000 --- a/libs/events/src/lib/event.interfaces.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { AssetRequest, CateringOrder } from '@placeos/common'; - -export interface FileDetails { - /** Name of the file */ - name: string; - - /** Blob contents of the file */ - blob: Blob; -} - -export interface EventExtensionData { - /** Whether event is cleaned */ - cleaned?: boolean; - /** Catering */ - catering?: CateringOrder[]; - /** List of assets assigned to event */ - assets?: AssetRequest[]; - /** Parking */ - needs_parking?: boolean; - /** Configuration */ - configuration?: any; - /** Notes */ - notes?: EventNote[]; - /** Catergorisation of external attendees in the event */ - visitor_type?: string; - /** List of remote attendees */ - remote?: string[]; - /** URL to a meeting/call associated with the booking */ - meeting_link: string; - /** URL to a meeting/call associated with the booking */ - online_meeting_url: string; - /** URL to a meeting/call associated with the booking */ - online_meeting_id: string; - /** URL to a meeting/call associated with the booking */ - online_meeting_provider: string; - /** Email to override the host of the event with */ - host_override: string; - /** Name of the organisational department of the host */ - department: string; - /** Whether a custom-period booking should still behave as all day in the UI */ - custom_all_day?: boolean; - event_type?: string; - /** Event category */ - category?: string; - /** List of tags associated with the event */ - tags?: string[]; - system_id?: string; - event_id?: string; - /** Whether event is featured */ - featured?: boolean; - /** Whether the event is in-person, online or both */ - attendance_type?: 'ONLINE' | 'ONSITE' | 'ANY'; - /** List of image URLs associated with the event */ - images?: string[]; - /** Whether the event is a shared group event */ - shared_event?: boolean; - /** Access level of the event */ - view_access: 'PRIVATE' | 'OPEN' | 'PUBLIC'; -} - -export interface RecurrenceDetails { - /** Start of the recurrence in unix ms */ - start: number; - /** End of the recurrence in unix ms */ - end: number; - /** Days of the week (or month) on which the event should be repeated */ - days_of_week: number[]; - /** Week of the month to recur on */ - nth_of_month?: number; - _pattern?: - | 'none' - | 'daily' - | 'weekly' - | 'monthly' - | 'month_day' - | 'yearly' - | 'custom_display'; - /** Original end type before serialisation */ - _end_type?: 'never' | 'date' | 'instances'; - /** Frequency of the event */ - pattern: 'daily' | 'weekly' | 'monthly' | 'month_day' | 'yearly'; - /** Interval to be used with pattern */ - interval: number; - /** Number of re-occurences to create for this event */ - occurrences?: number; -} - -export interface EventListQueryParams { - /** Comma seperated list of zone ids to filter the events on */ - zone_ids?: string; - /** Comma seperated list of systems ids to grab events from */ - system_ids?: string; - /** Comma seperated list of calendars to grab events from */ - calendars?: string; - /** Minimum capacity required */ - capacity?: number; - /** Comma seperated list of features to filter for */ - features?: string; - /** Unix epoch start time in seconds */ - period_start?: number; - /** Unix epoch end time in seconds */ - period_end?: number; -} - -export interface EventShowQueryParams { - /** Calendar that the event is associated with */ - calendar?: string; - /** System that the event is associated with */ - system_id?: string; -} - -export interface EventDeleteQueryParams extends EventShowQueryParams { - /** Whether attendees should be notified of this action. Defaults to `true` */ - notify?: boolean; -} - -export interface EventNote { - /** Type of note */ - type?: 'equipment' | 'catering' | 'description' | 'private' | 'other'; - /** Name of the note's author */ - author: string; - /** Contents of the note */ - message: string; - /** Time the note was added to the event */ - date: number; - /** Display value for the time */ - time?: string; - /** ID of the space associated with the note */ - space?: string; - /** ID of the catering order associated with the note */ - order_id?: string; -} - -export interface TimeBlock { - start: number; - end: number; -} - -export interface TimePeriod extends Record { - date: number; - duration: number; - setup_time?: number; - breakdown_time?: number; -} diff --git a/libs/events/src/lib/events.fn.ts b/libs/events/src/lib/events.fn.ts index ef657ac2ed0..e31e44113af 100644 --- a/libs/events/src/lib/events.fn.ts +++ b/libs/events/src/lib/events.fn.ts @@ -1,6 +1,7 @@ import { BookingClash, CalendarEvent, + EventExtensionData, GuestUser, setting, toQueryString, @@ -18,7 +19,6 @@ import { import { addMinutes, getUnixTime } from 'date-fns'; import { queryCalendarAvailability, querySpaceFreeBusy } from './calendar.fn'; -import { EventExtensionData } from './event.interfaces'; export interface CalendarEventQueryParams { /** Comma seperated list of zone ids to check availability */ @@ -305,42 +305,6 @@ export function removeEvent( ); } -/** - * Set the approval state of the event to approved - * @param id ID of the event to approve - * @param system_id Associated system to approve - */ -export async function approveEvent( - id: string, - system_id: string, -): Promise { - const item = await post( - `${EVENTS_ENDPOINT}/${encodeURIComponent( - id, - )}/approve?system_id=${encodeURIComponent(system_id)}`, - '', - ); - return new CalendarEvent(item); -} - -/** - * Set the approval state of the event to rejected - * @param id ID of the event to reject - * @param system_id Associated system to reject - */ -export async function rejectEvent( - id: string, - system_id: string, -): Promise { - const item = await post( - `${EVENTS_ENDPOINT}/${encodeURIComponent( - id, - )}/reject?system_id=${encodeURIComponent(system_id)}`, - '', - ); - return new CalendarEvent(item); -} - /** * Decline the given event * @param id ID of the event to decline @@ -360,24 +324,6 @@ export async function declineEvent( return new CalendarEvent(item); } -/** - * List guests for event - * @param id ID of the event to grab - * @param q Parameters to pass to the API request - */ -export async function queryEventGuests( - id: string, - q: CalendarEventShowParams = {}, -): Promise { - const query = toQueryString(q); - const list = await get( - `${EVENTS_ENDPOINT}/${encodeURIComponent(id)}/guests${ - query ? '?' + query : '' - }`, - ); - return list.map((item) => new GuestUser(item)); -} - /** * Set the checkin state of an event guest * @param id ID of the event to grab @@ -498,27 +444,6 @@ export async function updateEventMetadata( )) as EventExtensionData; } -/** - * Check whether a selected space is available - * @param id ID of the space to check - * @param start Start of the availability period to check - * @param duration Duration of the availability block in minutes - * @param ignore ID of a booking to ignore when checking availability - */ -export async function isSpaceAvailable( - id: string, - start: number, - duration: number, - ignore?: string, -): Promise { - const events = await queryEvents({ - system_ids: id, - period_start: getUnixTime(start), - period_end: getUnixTime(addMinutes(start, duration)), - }); - return events.filter((_) => _.id !== ignore).length === 0; -} - /** * Check whether a list of spaces are available * @param id_list List of space IDs to check diff --git a/libs/events/src/lib/events.ts b/libs/events/src/lib/events.ts index f3d463e5bbf..b401e89c307 100644 --- a/libs/events/src/lib/events.ts +++ b/libs/events/src/lib/events.ts @@ -1,5 +1,4 @@ export * from './event-form'; -export * from './event.interfaces'; export * from './events.fn'; export * from './helpers'; export * from './utilities'; diff --git a/libs/events/src/lib/group-event-details-modal.component.ts b/libs/events/src/lib/group-event-details-modal.component.ts index 1a958cca11c..b05a39199fd 100644 --- a/libs/events/src/lib/group-event-details-modal.component.ts +++ b/libs/events/src/lib/group-event-details-modal.component.ts @@ -13,7 +13,6 @@ import { MAT_DIALOG_DATA, MatDialog, MatDialogModule, - MatDialogRef, } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; import { @@ -486,10 +485,6 @@ export class GroupEventDetailsModalComponent implements OnInit { private _org = inject(OrganisationService); private _dialog = inject(MatDialog); private _clipboard = inject(Clipboard); - private _dialog_ref = inject>( - MatDialogRef, - { optional: true }, - ); public readonly edit = this._data?.edit_fn; public readonly space = signal(new Space()); diff --git a/libs/events/src/lib/helpers.ts b/libs/events/src/lib/helpers.ts index 6425cf281d8..f5cbf363787 100644 --- a/libs/events/src/lib/helpers.ts +++ b/libs/events/src/lib/helpers.ts @@ -1,110 +1,12 @@ -import { HashMap } from '@placeos/common'; +import { TimeBlock, TimePeriod } from '@placeos/common'; import { - addDays, addMinutes, - addMonths, addSeconds, - addWeeks, - addYears, differenceInMinutes, - endOfDay, - getUnixTime, isAfter, isBefore, - startOfDay, startOfMinute, } from 'date-fns'; -import { RecurrenceDetails, TimeBlock, TimePeriod } from './event.interfaces'; - -const DAYS_OF_WEEK = [ - 'sunday', - 'monday', - 'tuesday', - 'wednesday', - 'thursday', - 'friday', - 'saturday', -]; - -function weekOfMonth(date: number) { - const day = new Date(date).getDate(); - const week = Math.floor(day / 7) + (day % 7 ? 1 : 0); - if ((week === 4 && day >= 25) || week === 5) return -1; - return week; -} - -export function eventStatus( - details: HashMap, -): 'approved' | 'tentative' | 'declined' { - if (details.status === 'cancelled') return 'declined'; - if (details.resources?.length) { - if ( - details.resources.every( - (i) => i.response_status === 'accepted' || details.approved, - ) - ) { - return 'approved'; - } else if ( - details.resources.some( - (i) => - i.response_status === 'tentative' || - i.response_status === 'needsAction', - ) - ) { - return 'tentative'; - } - return 'declined'; - } - return 'approved'; -} - -export function parseRecurrence(data: RecurrenceDetails) { - let start = data.start || (data as any).range_start * 1000; - let end = data.end || (data as any).range_end; - if (data.occurrences > 1) { - switch (data.pattern) { - case 'daily': - end = addDays( - start || Date.now(), - (data.occurrences - 1) * data.interval, - ).valueOf(); - break; - case 'weekly': - end = addWeeks( - start || Date.now(), - (data.occurrences - 1) * data.interval, - ).valueOf(); - break; - case 'month_day': - case 'monthly': - end = addMonths( - start || Date.now(), - (data.occurrences - 1) * data.interval, - ).valueOf(); - end = addDays(end, 7).valueOf(); - break; - case 'yearly': - end = addYears( - start || Date.now(), - (data.occurrences - 1) * data.interval, - ).valueOf(); - break; - } - } - return { - range_start: getUnixTime(startOfDay(start)), - range_end: getUnixTime(endOfDay(end)), - interval: data.interval, - pattern: data.pattern, - days_of_week: - data.days_of_week?.map((_) => - typeof _ === 'number' ? DAYS_OF_WEEK[_] : _, - ) || [], - nth_of_month: - data.nth_of_month ?? - (data.pattern === 'month_day' ? weekOfMonth(start) : undefined), - }; -} /** * Generate a list of free time slots between the given bookings @@ -151,26 +53,6 @@ export function getFreeTimeSlots( return slots; } -export function periodInFreeTimeSlot( - start: number, - end: number, - list: TimePeriod[], - min_size: number = 29, -) { - const blocks = getFreeTimeSlots(list, min_size); - for (const blk of blocks) { - if ( - start >= blk.start && - start < blk.end && - end > blk.start && - end <= blk.end - ) { - return true; - } - } - return false; -} - /** * Get the next free time slot from the given bookings * @param list List of bookings to find the next slot diff --git a/libs/events/src/lib/utilities.ts b/libs/events/src/lib/utilities.ts index c7d47360a4c..76589227ba9 100644 --- a/libs/events/src/lib/utilities.ts +++ b/libs/events/src/lib/utilities.ts @@ -1,22 +1,5 @@ import { computed, type Signal } from '@angular/core'; -import { - Booking, - CalendarEvent, - SettingsService, - timePeriodsIntersect, - unique, - User, -} from '@placeos/common'; -import { - addMinutes, - differenceInMinutes, - format, - formatDuration, - getTime, - isSameDay, -} from 'date-fns'; - -import { getNextFreeTimeSlot } from './helpers'; +import { Booking, CalendarEvent, SettingsService, User } from '@placeos/common'; /** Whether the current settings allow one event to use multiple spaces. */ export function multipleSpacesEnabled(settings: SettingsService): boolean { @@ -47,74 +30,6 @@ export function organiserTimezone(user?: User): string { return typeof timezone === 'string' ? timezone : ''; } -/** - * Get current status within bookings - * @param bookings List of bookings - * @param host Host of the new event - * @param date Datetime of the new event - */ -export function statusFromBookings( - bookings: CalendarEvent[], - bookable: boolean, - requestable: boolean, - date: number = getTime(new Date()), -) { - const now = new Date(date); - const next_free_slot = getNextFreeTimeSlot(bookings, date, 5); - const start = new Date(next_free_slot.start); - const end = new Date(next_free_slot.end); - const currently_free = timePeriodsIntersect( - date, - date, - next_free_slot.start, - next_free_slot.end, - ); - const time_until_next_block = formatDuration({ - minutes: currently_free - ? differenceInMinutes(end, now) - : differenceInMinutes(start, now), - }); - const free_tomorrow = !currently_free && !isSameDay(start, now); - const free_today = currently_free && !isSameDay(end, now); - return { - status: !bookable - ? 'Not Bookable' - : currently_free - ? requestable - ? 'Available by Request' - : 'Available' - : 'Meeting in Progress', - available_until: free_today - ? 'No meetings today' - : currently_free - ? `Free until ${format(end, 'h:mm B')}(${time_until_next_block})` - : free_tomorrow - ? 'Unavailable today' - : `Free at ${format(start, 'h:mm B')}(${time_until_next_block})`, - }; -} - -export function replaceBookings( - list: CalendarEvent[], - new_bookings: CalendarEvent[], - filter_options: { space: string; from: number; to: number }, -) { - const from = filter_options.from; - const to = filter_options.to; - const filtered_list = list.filter((booking) => { - const start = new Date(booking.date); - const end = addMinutes(start, booking.duration); - return ( - !booking.resources?.find( - (space) => space.email === filter_options.space, - ) || !timePeriodsIntersect(from, to, start.valueOf(), end.valueOf()) - ); - }); - const updated_list = filtered_list.concat(new_bookings); - updated_list.sort((a, b) => a.date - b.date); - return unique(updated_list, 'id'); -} - export function newCalendarEventFromBooking(booking: Booking) { let attendees = [ { diff --git a/libs/events/src/lib/validators.ts b/libs/events/src/lib/validators.ts index 44e4d1474a6..b3153174585 100644 --- a/libs/events/src/lib/validators.ts +++ b/libs/events/src/lib/validators.ts @@ -1,5 +1,5 @@ import { AbstractControl } from '@angular/forms'; -import { addMinutes, isAfter, isBefore } from 'date-fns'; +import { addMinutes, isAfter } from 'date-fns'; export const endInFuture = (control: AbstractControl) => { const date = control.parent?.get('date'); @@ -8,12 +8,3 @@ export const endInFuture = (control: AbstractControl) => { } return null; }; - -/** Check that date in unix ms is in the future */ -export function isFuture( - control: AbstractControl, -): { [key: string]: boolean } | null { - return control.value && isBefore(control.value, new Date()) - ? { is_future: true } - : null; -} diff --git a/libs/events/src/tests/calendar.fn.spec.ts b/libs/events/src/tests/calendar.fn.spec.ts index 356272c721f..5f37d575687 100644 --- a/libs/events/src/tests/calendar.fn.spec.ts +++ b/libs/events/src/tests/calendar.fn.spec.ts @@ -1,7 +1,6 @@ import { queryCalendarAvailability, queryCalendars, - querySpaceCalendarAvailability, querySpaceFreeBusy, } from '../lib/calendar.fn'; @@ -38,21 +37,6 @@ describe('Calendar API Methods', () => { ); }); }); - describe('querySpaceAvailability', () => { - it('should GET space availability from API', async () => { - vi.mocked(ts_client.get).mockResolvedValue([ - { resource: { bookable: true } }, - ] as any); - const list = await querySpaceCalendarAvailability({ - period_start: 1, - period_end: 2, - }); - expect(list).toHaveLength(1); - expect(ts_client.get).toHaveBeenCalledWith( - `/api/staff/v1/calendars/availability?period_start=1&period_end=2`, - ); - }); - }); describe('querySpaceFreeBusy', () => { it('should GET space availability status from API', async () => { vi.mocked(ts_client.get).mockResolvedValue([ diff --git a/libs/events/src/tests/calendar.service.spec.ts b/libs/events/src/tests/calendar.service.spec.ts index e7e47132589..390ce55869d 100644 --- a/libs/events/src/tests/calendar.service.spec.ts +++ b/libs/events/src/tests/calendar.service.spec.ts @@ -3,14 +3,12 @@ import { createServiceFactory, SpectatorService, } from '@ngneat/spectator/vitest'; -import { getUnixTime, startOfDay } from 'date-fns'; import { MockProvider } from 'ng-mocks'; import { Calendar, OrganisationService, SettingsService, - Space, } from '@placeos/common'; import * as ts_client from '@placeos/ts-client'; import { CalendarService } from '../lib/calendar.service'; @@ -70,41 +68,4 @@ describe('CalendarService', () => { expect(spectator.service.calendar_list()).toHaveLength(1); expect(ts_client.get).toHaveBeenCalledTimes(2); }); - - it('should allow getting free busy', async () => { - vi.mocked(ts_client.get).mockResolvedValue([ - { - resource: { - id: 'sys-1', - email: 'sys-1@place.tech', - bookable: true, - }, - }, - ] as any); - const list = await spectator.service.getFreeBusyDate(1, 'CAL-1'); - expect(list).toHaveLength(1); - expect(list[0]).toBeInstanceOf(Space); - expect(ts_client.get).toHaveBeenCalledWith( - expect.stringContaining( - `/api/staff/v1/calendars/free_busy?period_start=${getUnixTime( - startOfDay(1), - )}`, - ), - ); - }); - - it('should allow checking space availability', async () => { - vi.mocked(ts_client.get).mockResolvedValue([] as any); - const is_free = await spectator.service.checkSpacesAvailability( - ['sys-1'], - 1, - 2, - ); - expect(is_free).toBeTruthy(); - expect(ts_client.get).toHaveBeenCalledWith( - expect.stringContaining( - `/api/staff/v1/calendars/availability?period_start=1&period_end=2&system_ids=sys-1`, - ), - ); - }); }); diff --git a/libs/events/src/tests/events.fn.spec.ts b/libs/events/src/tests/events.fn.spec.ts index ae64a9ed1b1..953f4f73e15 100644 --- a/libs/events/src/tests/events.fn.spec.ts +++ b/libs/events/src/tests/events.fn.spec.ts @@ -1,13 +1,10 @@ import { CalendarEvent, GuestUser, Space, VERSION } from '@placeos/common'; import { - approveEvent, checkinEventGuest, MAX_EVENT_PAGES, queryAllEvents, - queryEventGuests, queryEventListOrThrow, queryEvents, - rejectEvent, saveEvent, showEvent, } from '../lib/events.fn'; @@ -215,50 +212,6 @@ describe('Event API Methods', () => { }); }); - describe('approveEvent', () => { - it('should allow calling POST request for approving an event', async () => { - const spy = vi.spyOn(ts_client, 'post'); - expect(spy).not.toHaveBeenCalled(); - spy.mockResolvedValue({} as any); - const event = await approveEvent('1', 'sys-1'); - expect(event).toBeInstanceOf(CalendarEvent); - expect(ts_client.post).toHaveBeenCalledWith( - `/api/staff/v1/events/1/approve?system_id=sys-1`, - '', - ); - spy.mockReset(); - }); - }); - - describe('rejectEvent', () => { - it('should allow calling POST request for rejecting an event', async () => { - const spy = vi.spyOn(ts_client, 'post'); - expect(spy).not.toHaveBeenCalled(); - spy.mockResolvedValue({} as any); - const event = await rejectEvent('1', 'sys-1'); - expect(event).toBeInstanceOf(CalendarEvent); - expect(ts_client.post).toHaveBeenCalledWith( - `/api/staff/v1/events/1/reject?system_id=sys-1`, - '', - ); - spy.mockReset(); - }); - }); - - describe('queryEventGuests', () => { - it('should allow calling POST request for querying guest in an event', async () => { - const spy = vi.spyOn(ts_client, 'get'); - expect(spy).not.toHaveBeenCalled(); - spy.mockResolvedValue([{}] as any); - const guests = await queryEventGuests('1'); - expect(guests[0]).toBeInstanceOf(GuestUser); - expect(ts_client.get).toHaveBeenCalledWith( - `/api/staff/v1/events/1/guests`, - ); - spy.mockReset(); - }); - }); - describe('checkinEventGuests', () => { it('should allow calling POST request for checking in an event guest', async () => { const spy = vi.spyOn(ts_client, 'post'); diff --git a/libs/events/src/tests/helpers.spec.ts b/libs/events/src/tests/helpers.spec.ts index 5dee28386cf..021905fbdf3 100644 --- a/libs/events/src/tests/helpers.spec.ts +++ b/libs/events/src/tests/helpers.spec.ts @@ -1,39 +1,7 @@ import { addMinutes } from 'date-fns'; -import { - eventStatus, - getFreeTimeSlots, - getNextFreeTimeSlot, -} from '../lib/helpers'; +import { getFreeTimeSlots, getNextFreeTimeSlot } from '../lib/helpers'; describe('CalendarEvent[Methods]', () => { - describe('eventStatus', () => { - it('should get correct event status', () => { - expect(eventStatus({ resources: [] })).toBe('approved'); - expect( - eventStatus({ resources: [{ response_status: 'accepted' }] }), - ).toBe('approved'); - expect( - eventStatus({ resources: [{ response_status: 'tentative' }] }), - ).toBe('tentative'); - expect( - eventStatus({ - resources: [{ response_status: 'needsAction' }], - }), - ).toBe('tentative'); - expect( - eventStatus({ resources: [{ response_status: 'blah' }] }), - ).toBe('declined'); - expect( - eventStatus({ - resources: [ - { response_status: 'blah' }, - { response_status: 'tentative' }, - ], - }), - ).toBe('tentative'); - }); - }); - describe('getFreeTimeSlots', () => { it('should return at least one block', () => { expect(getFreeTimeSlots([])).toHaveLength(1); diff --git a/libs/events/src/tests/validators.spec.ts b/libs/events/src/tests/validators.spec.ts index 56b4b5f45cd..04380603f78 100644 --- a/libs/events/src/tests/validators.spec.ts +++ b/libs/events/src/tests/validators.spec.ts @@ -1,6 +1,6 @@ import { FormControl, FormGroup } from '@angular/forms'; import { addDays, subDays } from 'date-fns'; -import { endInFuture, isFuture } from '../lib/validators'; +import { endInFuture } from '../lib/validators'; describe('Event Validators', () => { describe('endInFuture', () => { @@ -22,15 +22,4 @@ describe('Event Validators', () => { }); }); }); - describe('isFuture', () => { - it('should handle times in the future', () => { - const field = new FormControl(addDays(new Date(), 2).valueOf()); - expect(isFuture(field)).toBeNull(); - }); - - it('should handle times in the past', () => { - const field = new FormControl(subDays(new Date(), 2).valueOf()); - expect(isFuture(field)).toEqual({ is_future: true }); - }); - }); }); diff --git a/libs/explore/src/tests/explore-desks.service.spec.ts b/libs/explore/src/tests/explore-desks.service.spec.ts index a6cba590a44..938bce3cd12 100644 --- a/libs/explore/src/tests/explore-desks.service.spec.ts +++ b/libs/explore/src/tests/explore-desks.service.spec.ts @@ -10,7 +10,6 @@ import { import { generateBookingForm } from 'libs/bookings/src/lib/booking-form.model'; import { BookingFormService } from 'libs/bookings/src/lib/booking-form.service'; -import { DesksService } from 'libs/bookings/src/lib/desk.service'; import { ExploreDesksService } from '../lib/explore-desks.service'; import { ExploreStateService } from '../lib/explore-state.service'; @@ -41,7 +40,6 @@ describe('ExploreDesksService', () => { buildings: [], }), MockProvider(SettingsService, { get: vi.fn() }), - MockProvider(DesksService, {}), MockProvider(BookingFormService, {}), MockProvider(MatDialog, { open: vi.fn() }), ], diff --git a/libs/form-fields/src/lib/recurrence-field.component.ts b/libs/form-fields/src/lib/recurrence-field.component.ts index 382b3c48763..0138a163ae6 100644 --- a/libs/form-fields/src/lib/recurrence-field.component.ts +++ b/libs/form-fields/src/lib/recurrence-field.component.ts @@ -23,6 +23,7 @@ import { fromEventRecurrence, NO_RECURR, Recurrence, + RecurrenceDetails, recurrenceEndDate, recurrenceInstanceCount, toBookingRecurrence, @@ -34,7 +35,6 @@ import { CommonModule } from '@angular/common'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; -import { RecurrenceDetails } from 'libs/events/src/lib/event.interfaces'; import { RecurrenceModalComponent } from './recurrence-modal.component'; @Component({ From 167e4e7a446cc2ac627dab7b89f7b8c0475b8465 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:02 +1100 Subject: [PATCH 05/17] refactor(mocks): remove unused mock endpoints and data helpers Removes the signage staff API mock handlers (no app calls those paths), unused mock data helpers, src/mocks.ts, and a few unused map-editor exports. --- .fallowrc.json | 1 - libs/map-editor/src/lib/ai/panel-icons.ts | 5 +- libs/map-editor/src/lib/canvas/icons.ts | 2 - libs/map-editor/src/lib/constants.ts | 10 - libs/map-editor/src/lib/ids.ts | 4 - libs/mocks/src/lib/api/assets.data.ts | 27 -- libs/mocks/src/lib/api/catering.data.ts | 13 - libs/mocks/src/lib/api/events.data.ts | 2 - libs/mocks/src/lib/api/signage.mock.ts | 415 ---------------------- libs/mocks/src/lib/api/spaces.data.ts | 32 -- libs/mocks/src/lib/api/surveys.mock.ts | 7 - libs/mocks/src/lib/api/users.data.ts | 15 - libs/mocks/src/lib/api/zone.data.ts | 71 ---- libs/mocks/src/mocks.ts | 25 -- 14 files changed, 2 insertions(+), 627 deletions(-) delete mode 100644 libs/mocks/src/mocks.ts diff --git a/.fallowrc.json b/.fallowrc.json index a043b78688d..449346bcb8d 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -10,7 +10,6 @@ "libs/**/src/lib/**/*.ts", "libs/**/src/test-setup.ts", "libs/mocks/src/empty.ts", - "libs/mocks/src/mocks.ts", "shared/assets/**", "shared/styles/**", "tools/**/*.js", diff --git a/libs/map-editor/src/lib/ai/panel-icons.ts b/libs/map-editor/src/lib/ai/panel-icons.ts index 0875c503ab2..31374f386de 100644 --- a/libs/map-editor/src/lib/ai/panel-icons.ts +++ b/libs/map-editor/src/lib/ai/panel-icons.ts @@ -8,7 +8,6 @@ export const PANEL_ICONS = { error: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m1 15h-2v-2h2zm0-4h-2V7h2z', needs: 'm19 15-6 6-1.42-1.42L15.17 16H4V4h2v10h9.17l-3.59-3.58L13 9z', warning: 'M1 21h22L12 2zm12-3h-2v-2h2zm0-4h-2v-4h2z', - question: 'M11 18h2v-2h-2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8m0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4', + question: + 'M11 18h2v-2h-2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8m0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4', } as const; - -export type PanelIcon = keyof typeof PANEL_ICONS; diff --git a/libs/map-editor/src/lib/canvas/icons.ts b/libs/map-editor/src/lib/canvas/icons.ts index 166d924dbf7..1c8843b140b 100644 --- a/libs/map-editor/src/lib/canvas/icons.ts +++ b/libs/map-editor/src/lib/canvas/icons.ts @@ -40,5 +40,3 @@ export const EDITOR_ICONS = { sync_problem: 'M3 12c0 2.21.91 4.2 2.36 5.64L3 20h6v-6l-2.24 2.24C5.68 15.15 5 13.66 5 12c0-2.61 1.67-4.83 4-5.65V4.26C5.55 5.15 3 8.27 3 12m8 5h2v-2h-2zM21 4h-6v6l2.24-2.24C18.32 8.85 19 10.34 19 12c0 2.61-1.67 4.83-4 5.65v2.09c3.45-.89 6-4.01 6-7.74 0-2.21-.91-4.2-2.36-5.64zm-10 9h2V7h-2z', } as const; - -export type EditorIcon = keyof typeof EDITOR_ICONS; diff --git a/libs/map-editor/src/lib/constants.ts b/libs/map-editor/src/lib/constants.ts index 848df150748..64f2a277bc1 100644 --- a/libs/map-editor/src/lib/constants.ts +++ b/libs/map-editor/src/lib/constants.ts @@ -32,16 +32,6 @@ export const ATTR_ALLOW_LIST: ReadonlySet = new Set([ /** Properties `set_style_rule` declarations may carry. */ export const STYLE_PROPERTY_ALLOW_LIST: ReadonlySet = ATTR_ALLOW_LIST; -/** Attributes that are never set through `set_attr`, whatever the value. */ -export const ATTR_DENY_LIST: ReadonlySet = new Set([ - 'id', - 'transform', - 'href', - 'xlink:href', - 'style', - 'src', -]); - const DATA_ATTR = /^data-[a-z][a-z0-9_-]*$/i; /** Names `set_attr` and `insert_shape` may write: the allow-list, or `data-*` outside the editor prefix. */ diff --git a/libs/map-editor/src/lib/ids.ts b/libs/map-editor/src/lib/ids.ts index aec4e87f70d..42aa3e3715e 100644 --- a/libs/map-editor/src/lib/ids.ts +++ b/libs/map-editor/src/lib/ids.ts @@ -1,9 +1,5 @@ import { ID_PATTERN, REFERENCE_ATTRS } from './constants'; -export function isValidId(id: string): boolean { - return ID_PATTERN.test(id); -} - export function idIssue(id: string): string | null { if (!id) return 'id is empty'; if (/^[^A-Za-z]/.test(id)) return `id "${id}" must start with a letter`; diff --git a/libs/mocks/src/lib/api/assets.data.ts b/libs/mocks/src/lib/api/assets.data.ts index dedfcebd5fa..d64d99cb9d7 100644 --- a/libs/mocks/src/lib/api/assets.data.ts +++ b/libs/mocks/src/lib/api/assets.data.ts @@ -608,33 +608,6 @@ export const MOCK_PURCHASE_ORDERS = Array(30) }; }); -// Helper functions for asset management -export const getAssetsByCategory = (categoryId: string) => - (MOCK_ASSETS as any).filter((asset) => asset.category_id === categoryId); - -export const getAssetsByStatus = (status: string) => - (MOCK_ASSETS as any).filter((asset) => asset.status === status); - -export const getAssetsByLocation = (building: string, floor?: number) => - (MOCK_ASSETS as any).filter( - (asset) => - asset.location?.building === building && - (!floor || asset.location?.floor === floor), - ); - -export const getAvailableAssets = () => - (MOCK_ASSETS as any).filter((asset) => asset.status === 'Available'); - -export const getAssetsRequiringMaintenance = () => - MOCK_ASSETS.filter((asset) => { - const schedule = asset.other_data?.maintenance_schedule; - if (!schedule) return false; - return ( - schedule.next_service <= - getUnixTime(new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)) - ); // Due within 7 days - }); - const MOCK_PARKING_ASSETS_CACHE: Record = {}; /** Every zone a mock level is in: org, region, building and level */ diff --git a/libs/mocks/src/lib/api/catering.data.ts b/libs/mocks/src/lib/api/catering.data.ts index 10405b20936..da67e0fe4d1 100644 --- a/libs/mocks/src/lib/api/catering.data.ts +++ b/libs/mocks/src/lib/api/catering.data.ts @@ -28,19 +28,6 @@ export const DIETARY_RESTRICTIONS = [ { id: 'paleo', name: 'Paleo', icon: '🥩' }, ]; -export const CATERING_CATEGORIES = [ - { id: 'beverages', name: 'Beverages', order: 1 }, - { id: 'breakfast', name: 'Breakfast', order: 2 }, - { id: 'pastries', name: 'Pastries & Baked Goods', order: 3 }, - { id: 'salads', name: 'Salads', order: 4 }, - { id: 'sandwiches', name: 'Sandwiches & Wraps', order: 5 }, - { id: 'hot-meals', name: 'Hot Meals', order: 6 }, - { id: 'appetizers', name: 'Appetizers', order: 7 }, - { id: 'desserts', name: 'Desserts', order: 8 }, - { id: 'platters', name: 'Platters & Sharing', order: 9 }, - { id: 'snacks', name: 'Snacks', order: 10 }, -]; - export const MOCK_MENU = [ // Beverages { diff --git a/libs/mocks/src/lib/api/events.data.ts b/libs/mocks/src/lib/api/events.data.ts index 52e6db9e831..ada2421a845 100644 --- a/libs/mocks/src/lib/api/events.data.ts +++ b/libs/mocks/src/lib/api/events.data.ts @@ -472,5 +472,3 @@ export const MOCK_EVENTS = (() => { return uniqueEvents.sort((a, b) => a.event_start - b.event_start); })(); - -export const event_spaces = MOCK_SPACES.map((space) => space.id); diff --git a/libs/mocks/src/lib/api/signage.mock.ts b/libs/mocks/src/lib/api/signage.mock.ts index b4f7e58ada3..ae6bd292f0d 100644 --- a/libs/mocks/src/lib/api/signage.mock.ts +++ b/libs/mocks/src/lib/api/signage.mock.ts @@ -695,82 +695,6 @@ function generateMockPlaylists(displays, media) { return playlists; } -// Generate Mock Triggers -function generateMockTriggers() { - return [ - { - id: 'trigger-001', - name: 'Emergency Alert System', - description: 'Automatically broadcast emergency content', - type: 'emergency', - conditions: { - api_endpoint: '/api/emergency/status', - trigger_value: 'active', - check_interval: 30, // seconds - }, - actions: { - activate_playlist: 'playlist-emergency', - interrupt_all: true, - volume_override: 100, - priority: 10, - }, - active: true, - last_triggered: null, - }, - { - id: 'trigger-002', - name: 'Meeting Room Booking Update', - description: 'Update meeting room displays when bookings change', - type: 'calendar_sync', - conditions: { - calendar_change: true, - check_interval: 300, // 5 minutes - rooms_only: true, - }, - actions: { - refresh_content: true, - update_schedule_display: true, - }, - active: true, - last_triggered: getUnixTime(subDays(Date.now(), 0.5)), - }, - { - id: 'trigger-003', - name: 'Weather Content Update', - description: 'Update weather displays every 30 minutes', - type: 'scheduled', - conditions: { - schedule: '*/30 * * * *', // Every 30 minutes - weather_api: 'https://api.weather.com/current', - }, - actions: { - update_weather_content: true, - refresh_transport_info: true, - }, - active: true, - last_triggered: getUnixTime(subDays(Date.now(), 0.02)), // ~30 min ago - }, - { - id: 'trigger-004', - name: 'After Hours Display Schedule', - description: 'Switch to minimal content outside business hours', - type: 'time_based', - conditions: { - time_range: { start: '22:00', end: '06:00' }, - weekends: true, - holidays: true, - }, - actions: { - activate_screensaver: true, - reduce_brightness: 30, - minimal_content: true, - }, - active: true, - last_triggered: getUnixTime(subDays(Date.now(), 1)), - }, - ]; -} - // Create all mock data const MOCK_DISPLAYS = generateMockDisplays(); const MOCK_MEDIA = generateMockMedia(); @@ -801,7 +725,6 @@ const MOCK_PLUGINS = [ params: {}, }, ]; -const MOCK_TRIGGERS = generateMockTriggers(); const SIGNAGE_GROUPS: Array<{ group: any; permissions: number }> = [ { @@ -1623,341 +1546,6 @@ export function registerMockSignage() { callback: () => ({}), }); - // Displays - registerMockEndpoint({ - path: '/api/staff/v1/signage-displays', - metadata: {}, - method: 'GET', - callback: (request) => { - let displays = MOCK_DISPLAYS; - - if (request.query_params?.building_id) { - displays = displays.filter( - (d) => d.building_id === request.query_params.building_id, - ); - } - - if (request.query_params?.status) { - displays = displays.filter( - (d) => d.status === request.query_params.status, - ); - } - - if (request.query_params?.zone_id) { - displays = displays.filter( - (d) => d.zone_id === request.query_params.zone_id, - ); - } - - return { - data: displays, - meta: { - total: displays.length, - online: displays.filter((d) => d.status === 'online') - .length, - offline: displays.filter((d) => d.status === 'offline') - .length, - error: displays.filter((d) => d.status === 'error').length, - }, - }; - }, - }); - - // Display by ID - registerMockEndpoint({ - path: '/api/staff/v1/signage/displays/:id', - metadata: {}, - method: 'GET', - callback: (request) => { - const display = MOCK_DISPLAYS.find( - (d) => d.id === request.route_params.id, - ); - if (!display) throw { status: 404, message: 'Display not found' }; - return display; - }, - }); - - // Media content - registerMockEndpoint({ - path: '/api/staff/v1/signage/media', - metadata: {}, - method: 'GET', - callback: (request) => { - let media = MOCK_MEDIA; - - if (request.query_params?.category) { - media = media.filter( - (m) => m.category === request.query_params.category, - ); - } - - if (request.query_params?.status) { - media = media.filter( - (m) => m.status === request.query_params.status, - ); - } - - if (request.query_params?.type) { - media = media.filter( - (m) => m.type === request.query_params.type, - ); - } - - // Pagination - const page = parseInt(request.query_params?.page) || 1; - const limit = parseInt(request.query_params?.limit) || 20; - const offset = (page - 1) * limit; - - return { - data: media.slice(offset, offset + limit), - meta: { - page, - limit, - total: media.length, - pages: Math.ceil(media.length / limit), - categories: CONTENT_CATEGORIES, - }, - }; - }, - }); - - // Playlists - registerMockEndpoint({ - path: '/api/staff/v1/signage/playlists', - metadata: {}, - method: 'GET', - callback: (request) => { - let playlists = MOCK_PLAYLISTS; - - if (request.query_params?.building_id) { - playlists = playlists.filter((p) => - p.target.zones.includes(request.query_params.building_id), - ); - } - - if (request.query_params?.status) { - playlists = playlists.filter( - (p) => p.status === request.query_params.status, - ); - } - - return { - data: playlists, - meta: { - total: playlists.length, - active: playlists.filter((p) => p.status === 'active') - .length, - standby: playlists.filter((p) => p.status === 'standby') - .length, - }, - }; - }, - }); - - // Playlist by ID - registerMockEndpoint({ - path: '/api/staff/v1/signage/playlists/:id', - metadata: {}, - method: 'GET', - callback: (request) => { - const playlist = MOCK_PLAYLISTS.find( - (p) => p.id === request.route_params.id, - ); - if (!playlist) throw { status: 404, message: 'Playlist not found' }; - - // Include full media details for playlist items - const playlistWithMedia = { - ...playlist, - items: playlist.items.map((item) => ({ - ...item, - media: MOCK_MEDIA.find((m) => m.id === item.media_id), - })), - }; - - return playlistWithMedia; - }, - }); - - // Triggers - registerMockEndpoint({ - path: '/api/staff/v1/signage/triggers', - metadata: {}, - method: 'GET', - callback: (request) => { - let triggers = MOCK_TRIGGERS; - - if (request.query_params?.type) { - triggers = triggers.filter( - (t) => t.type === request.query_params.type, - ); - } - - if (request.query_params?.active !== undefined) { - const isActive = request.query_params.active === 'true'; - triggers = triggers.filter((t) => t.active === isActive); - } - - return { - data: triggers, - meta: { - total: triggers.length, - active: triggers.filter((t) => t.active).length, - inactive: triggers.filter((t) => !t.active).length, - }, - }; - }, - }); - - // Display content (current playing) - registerMockEndpoint({ - path: '/api/staff/v1/signage/displays/:id/content', - metadata: {}, - method: 'GET', - callback: (request) => { - const display = MOCK_DISPLAYS.find( - (d) => d.id === request.route_params.id, - ); - if (!display) throw { status: 404, message: 'Display not found' }; - - // Find active playlist for this display - const activePlaylist = MOCK_PLAYLISTS.find( - (p) => - p.status === 'active' && - p.target.displays.includes(display.id), - ); - - if (!activePlaylist) { - return { message: 'No active content for this display' }; - } - - const currentItem = - activePlaylist.items[ - predictableRandomInt(activePlaylist.items.length) - ]; - const media = MOCK_MEDIA.find((m) => m.id === currentItem.media_id); - - return { - display_id: display.id, - current_content: { - playlist: activePlaylist, - item: currentItem, - media: media, - started_at: new Date().toISOString(), - remaining_time: media?.file.duration_seconds || 15, - }, - next_content: - activePlaylist.items[ - (activePlaylist.items.indexOf(currentItem) + 1) % - activePlaylist.items.length - ], - }; - }, - }); - - // Analytics - registerMockEndpoint({ - path: '/api/staff/v1/signage-analytics', - metadata: {}, - method: 'GET', - callback: (request) => { - const timeframe = request.query_params?.timeframe || '7d'; - const building_id = request.query_params?.building_id; - - let displays = MOCK_DISPLAYS; - if (building_id) { - displays = displays.filter( - (d) => d.building_id === building_id, - ); - } - - return { - timeframe, - summary: { - total_displays: displays.length, - online_displays: displays.filter( - (d) => d.status === 'online', - ).length, - total_impressions: - displays.reduce( - (sum, d) => sum + d.analytics.daily_views, - 0, - ) * - (timeframe === '7d' ? 7 : timeframe === '30d' ? 30 : 1), - average_uptime: '99.2%', - content_items_served: - MOCK_MEDIA.length * displays.length * 24, - }, - performance: displays.map((display) => ({ - display_id: display.id, - display_name: display.name, - uptime: `${100 - predictableRandomInt(5)}%`, - impressions: - display.analytics.daily_views * - (timeframe === '7d' ? 7 : timeframe === '30d' ? 30 : 1), - engagement: display.analytics.content_engagement, - errors: display.analytics.error_count_24h, - })), - content_performance: MOCK_MEDIA.slice(0, 10).map((media) => ({ - media_id: media.id, - media_name: media.name, - impressions: media.performance.impressions, - engagement_rate: media.performance.engagement_rate, - average_view_time: media.performance.average_view_time, - })), - health_alerts: displays - .filter((d) => d.status !== 'online') - .map((display) => ({ - display_id: display.id, - display_name: display.name, - alert_type: display.status, - message: - display.status === 'offline' - ? 'Display is not responding' - : display.status === 'error' - ? 'Hardware error detected' - : 'Maintenance mode active', - timestamp: new Date().toISOString(), - })), - }; - }, - }); - - // Control endpoints - registerMockEndpoint({ - path: '/api/staff/v1/signage-displays/:id/control', - metadata: {}, - method: 'POST', - callback: (request) => { - const display = MOCK_DISPLAYS.find( - (d) => d.id === request.route_params.id, - ); - if (!display) throw { status: 404, message: 'Display not found' }; - - const action = request.body?.action; - const validActions = [ - 'power_on', - 'power_off', - 'restart', - 'refresh_content', - 'update_brightness', - 'update_volume', - ]; - - if (!validActions.includes(action)) { - throw { status: 400, message: 'Invalid action' }; - } - - // Simulate action execution - return { - display_id: display.id, - action: action, - status: 'success', - message: `Action ${action} executed successfully`, - timestamp: new Date().toISOString(), - }; - }, - }); - registerMockSignageImageGen(); } @@ -2167,6 +1755,3 @@ function registerMockSignageImageGen() { }, }); } - -// Export mock data for testing -export { MOCK_DISPLAYS, MOCK_MEDIA, MOCK_PLAYLISTS, MOCK_TRIGGERS }; diff --git a/libs/mocks/src/lib/api/spaces.data.ts b/libs/mocks/src/lib/api/spaces.data.ts index 9160f369021..735203987c0 100644 --- a/libs/mocks/src/lib/api/spaces.data.ts +++ b/libs/mocks/src/lib/api/spaces.data.ts @@ -446,35 +446,3 @@ export const MOCK_SPACES = rawSpaces.map((space) => ], }), ); - -// Helper functions for space management -export const getSpacesByType = (type: string) => - MOCK_SPACES.filter((space) => space.type === type); - -export const getSpacesByBuilding = (buildingId: string) => - MOCK_SPACES.filter((space) => space.building_id === buildingId); - -export const getSpacesByCapacity = ( - minCapacity: number, - maxCapacity?: number, -) => - MOCK_SPACES.filter((space) => { - const capacity = space.capacity || 0; - return ( - capacity >= minCapacity && (!maxCapacity || capacity <= maxCapacity) - ); - }); - -export const getBookableSpaces = () => - MOCK_SPACES.filter((space) => space.bookable !== false); - -export const getSpacesByFeature = (feature: string) => - MOCK_SPACES.filter( - (space) => space.features && space.features.includes(feature), - ); - -export const getAccessibleSpaces = () => - MOCK_SPACES.filter((space) => space.accessibility?.wheelchair_accessible); - -export const getSpacesRequiringApproval = () => - MOCK_SPACES.filter((space) => space.booking_rules?.approval_required); diff --git a/libs/mocks/src/lib/api/surveys.mock.ts b/libs/mocks/src/lib/api/surveys.mock.ts index b362394d119..d37b796c48d 100644 --- a/libs/mocks/src/lib/api/surveys.mock.ts +++ b/libs/mocks/src/lib/api/surveys.mock.ts @@ -774,10 +774,3 @@ export function registerMockSurveys() { }, }); } - -// Re-export for mocks.ts compatibility -export const SURVEY_MOCKS = { - MOCK_SURVEYS, - MOCK_QUESTIONS, - MOCK_ANSWERS, -}; diff --git a/libs/mocks/src/lib/api/users.data.ts b/libs/mocks/src/lib/api/users.data.ts index c520a956c6a..22f2a428389 100644 --- a/libs/mocks/src/lib/api/users.data.ts +++ b/libs/mocks/src/lib/api/users.data.ts @@ -824,18 +824,3 @@ export const MOCK_GUESTS = VISITOR_PROFILES.map((profile, i) => { }, }; }); - -// Helper function to get random staff member by department -export const getStaffByDepartment = (department: string) => - MOCK_STAFF.filter( - (staff) => staff.department?.toLowerCase() === department.toLowerCase(), - ); - -// Helper function to get managers -export const getManagers = () => - MOCK_STAFF.filter( - (staff) => - staff.groups?.includes('manager') || - staff.title?.toLowerCase().includes('manager') || - staff.title?.toLowerCase().includes('lead'), - ); diff --git a/libs/mocks/src/lib/api/zone.data.ts b/libs/mocks/src/lib/api/zone.data.ts index 2e4546b9a45..b82cde2cabe 100644 --- a/libs/mocks/src/lib/api/zone.data.ts +++ b/libs/mocks/src/lib/api/zone.data.ts @@ -506,74 +506,3 @@ export const MOCK_LEVELS = MOCK_BUILDINGS.map((bld) => createMockLevel(level, idx, bld, level.split('-')[1]), ), ).reduce((prev, current) => prev.concat(current), []); - -// Helper functions for zone management -export const getBuildingById = (buildingId: string) => - MOCK_BUILDINGS.find((building) => building.id === buildingId); - -export const getLevelsByBuilding = (buildingId: string) => - MOCK_LEVELS.filter((level) => level.parent_id === buildingId); - -export const getBuildingsByRegion = (regionId: string) => - MOCK_BUILDINGS.filter((building) => building.parent_id === regionId); - -export const getRegionsByOrg = (orgId: string) => - MOCK_REGIONS.filter((region) => region.parent_id === orgId); - -export const getBuildingsByTag = (tag: string) => - MOCK_BUILDINGS.filter((building) => building.tags.includes(tag)); - -export const getBuildingCapacity = (buildingId: string) => { - const building = getBuildingById(buildingId); - return building ? building.capacity : 0; -}; - -export const getTotalOrganizationCapacity = () => - MOCK_BUILDINGS.reduce((total, building) => total + building.capacity, 0); - -export const getBuildingsByLocation = (state?: string) => - state - ? MOCK_BUILDINGS.filter((building) => building.address.state === state) - : MOCK_BUILDINGS; - -export const getLevelsByType = (type: string) => - MOCK_LEVELS.filter((level) => level.type === type); - -export const getBuildingStatistics = () => ({ - total_orgs: MOCK_ORGS.length, - total_regions: MOCK_REGIONS.length, - total_buildings: MOCK_BUILDINGS.length, - total_levels: MOCK_LEVELS.length, - total_capacity: getTotalOrganizationCapacity(), - average_capacity: Math.floor( - getTotalOrganizationCapacity() / MOCK_BUILDINGS.length, - ), - buildings_by_region: MOCK_BUILDINGS.reduce((acc, building) => { - const region = building.parent_id; - acc[region] = (acc[region] || 0) + 1; - return acc; - }, {}), - buildings_by_state: MOCK_BUILDINGS.reduce((acc, building) => { - const state = building.address.state; - acc[state] = (acc[state] || 0) + 1; - return acc; - }, {}), - sustainability_ratings: MOCK_BUILDINGS.map( - (b) => b.building_details.energy_rating, - ), - total_parking_spaces: MOCK_BUILDINGS.reduce( - (total, b) => total + b.features.parking_spaces, - 0, - ), - total_ev_charging: MOCK_BUILDINGS.reduce( - (total, b) => total + b.features.ev_charging, - 0, - ), - levels_by_type: MOCK_LEVELS.reduce((acc, level) => { - const type = level.type; - acc[type] = (acc[type] || 0) + 1; - return acc; - }, {}), - parking_levels: MOCK_LEVELS.filter((level) => level.type === 'parking') - .length, -}); diff --git a/libs/mocks/src/mocks.ts b/libs/mocks/src/mocks.ts deleted file mode 100644 index d764aa091f2..00000000000 --- a/libs/mocks/src/mocks.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { log } from '@placeos/common'; - -import { registerMockAssets } from './lib/api/assets.mock'; -import { registerMockBookings } from './lib/api/bookings.mock'; -import { registerMockCalendars } from './lib/api/calendars.mock'; -import { registerMockEvents } from './lib/api/events.mock'; -import { registerMockSignage } from './lib/api/signage.mock'; -import { registerMockSurveys } from './lib/api/surveys.mock'; -import { registerMockSystems } from './lib/api/systems.mock'; -import { registerMockUsers } from './lib/api/users.mock'; -import { registerMockZones } from './lib/api/zones.mock'; - -export default { - registerMockAssets, - registerMockBookings, - registerMockCalendars, - registerMockEvents, - registerMockSignage, - registerMockSurveys, - registerMockSystems, - registerMockUsers, - registerMockZones, -}; - -log('MOCKS', 'Mocks included with application.'); From 52ee6adca269f85ce367b7239f953fc58694ae09 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:02 +1100 Subject: [PATCH 06/17] refactor(libs): share one helper for toggling favourites Fourteen components wrote their own "add the ID or remove it" code before saving a favourites setting. They now use toggleListItem() from @placeos/common. --- .../src/app/calendar/room-search.component.ts | 6 ++--- .../desk-flow-select-list.component.ts | 6 ++--- .../meeting-flow-space-list.component.ts | 6 ++--- .../src/lib/asset-list-field.component.ts | 18 ++++---------- .../asset-select-modal.component.ts | 24 ++++++++----------- .../src/lib/desk-list-field.component.ts | 19 ++++----------- .../desk-select-modal.component.ts | 12 ++++++---- .../locker-select-modal.component.ts | 13 +++++----- .../parking-select-modal.component.ts | 13 +++++----- .../lib/parking-space-list-field.component.ts | 19 ++++----------- .../src/lib/catering-list-field.component.ts | 18 ++++---------- .../catering-select-modal.component.ts | 18 ++++---------- libs/common/src/lib/general.ts | 10 ++++++++ libs/common/src/tests/general.spec.ts | 10 ++++++++ .../space-select-modal.component.ts | 6 ++--- .../src/lib/space-list-field.component.ts | 7 ++---- 16 files changed, 86 insertions(+), 119 deletions(-) diff --git a/apps/outlook-addin/src/app/calendar/room-search.component.ts b/apps/outlook-addin/src/app/calendar/room-search.component.ts index 91b1ab86f87..acb1a2d82c1 100644 --- a/apps/outlook-addin/src/app/calendar/room-search.component.ts +++ b/apps/outlook-addin/src/app/calendar/room-search.component.ts @@ -22,6 +22,7 @@ import { settingSignal, SettingsService, Space, + toggleListItem, } from '@placeos/common'; import { AuthenticatedImageDirective, @@ -528,10 +529,7 @@ export class RoomSearchComponent { } public toggleFavourite(space: Space) { - const list = this.favourites(); - const next = list.includes(space.id) - ? list.filter((_) => _ !== space.id) - : [...list, space.id]; + const next = toggleListItem(this.favourites(), space.id); this._favourites.set(next); this._settings.saveUserSetting('favourite_spaces', next); } diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-list.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-list.component.ts index e8fa0d7cbc4..7a6e6c568df 100644 --- a/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-list.component.ts +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow-select-list.component.ts @@ -17,6 +17,7 @@ import { settingSignal, SettingsService, Space, + toggleListItem, } from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; @@ -365,10 +366,7 @@ export class DeskFlowSelectListComponent { ); public toggleFavourite(item: Space) { - const existing = this.favourites(); - const updated = existing.find((id) => item.id === id) - ? existing.filter((id) => id !== item.id) - : [...existing, item.id]; + const updated = toggleListItem(this.favourites(), item.id); // Optimistically update UI this.favourites.set(updated); diff --git a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-list.component.ts b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-list.component.ts index 71122dfc37d..1db5dda152d 100644 --- a/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-list.component.ts +++ b/apps/workplace/src/app/book/meeting-flow-new/meeting-flow-space-list.component.ts @@ -16,6 +16,7 @@ import { settingSignal, SettingsService, Space, + toggleListItem, } from '@placeos/common'; import { IconComponent, LevelPipe, TranslatePipe } from '@placeos/components'; import { EventFormService } from '@placeos/events'; @@ -359,10 +360,7 @@ export class MeetingFlowSpaceListComponent { ); public toggleFavourite(space: Space) { - const existing = this.favourites(); - const updated = existing.find((id) => space.id === id) - ? existing.filter((id) => id !== space.id) - : [...existing, space.id]; + const updated = toggleListItem(this.favourites(), space.id); // Optimistically update UI this.favourites.set(updated); diff --git a/libs/assets/src/lib/asset-list-field.component.ts b/libs/assets/src/lib/asset-list-field.component.ts index d1fa82023b2..99f4ba41277 100644 --- a/libs/assets/src/lib/asset-list-field.component.ts +++ b/libs/assets/src/lib/asset-list-field.component.ts @@ -23,6 +23,7 @@ import { randomString, SettingsService, Space, + toggleListItem, } from '@placeos/common'; import { endOfDay, startOfDay } from 'date-fns'; @@ -492,18 +493,9 @@ export class AssetListFieldComponent implements ControlValueAccessor { } public toggleFavourite(asset: AssetItem) { - const fav_list = this.favorites(); - const new_state = !fav_list.includes(asset.id); - if (new_state) { - this._settings.saveUserSetting('favourite_assets', [ - ...fav_list, - asset.id, - ]); - } else { - this._settings.saveUserSetting( - 'favourite_assets', - fav_list.filter((_) => _ !== asset.id), - ); - } + this._settings.saveUserSetting( + 'favourite_assets', + toggleListItem(this.favorites(), asset.id), + ); } } diff --git a/libs/assets/src/lib/asset-select-modal/asset-select-modal.component.ts b/libs/assets/src/lib/asset-select-modal/asset-select-modal.component.ts index 2de11d94d25..1961516b9eb 100644 --- a/libs/assets/src/lib/asset-select-modal/asset-select-modal.component.ts +++ b/libs/assets/src/lib/asset-select-modal/asset-select-modal.component.ts @@ -1,7 +1,12 @@ import { Component, computed, inject, Signal, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { AssetGroup, isMobileSafari, SettingsService } from '@placeos/common'; +import { + AssetGroup, + isMobileSafari, + SettingsService, + toggleListItem, +} from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -213,18 +218,9 @@ export class AssetSelectModalComponent { public toggleFavourite(asset: AssetGroup | null) { if (!asset) return; - const fav_list = this.favorites(); - const new_state = !fav_list.includes(asset.id); - if (new_state) { - this._settings.saveUserSetting('favourite_assets', [ - ...fav_list, - asset.id, - ]); - } else { - this._settings.saveUserSetting( - 'favourite_assets', - fav_list.filter((_) => _ !== asset.id), - ); - } + this._settings.saveUserSetting( + 'favourite_assets', + toggleListItem(this.favorites(), asset.id), + ); } } diff --git a/libs/bookings/src/lib/desk-list-field.component.ts b/libs/bookings/src/lib/desk-list-field.component.ts index f5d7a5de60e..0e264561b54 100644 --- a/libs/bookings/src/lib/desk-list-field.component.ts +++ b/libs/bookings/src/lib/desk-list-field.component.ts @@ -3,7 +3,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatRippleModule } from '@angular/material/core'; -import { SETTING_KEYS } from '@placeos/common'; +import { SETTING_KEYS, toggleListItem } from '@placeos/common'; import { SettingsService } from 'libs/common/src/lib/settings.service'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -192,18 +192,9 @@ export class DeskListFieldComponent implements ControlValueAccessor { public toggleFavourite(space: BookingAsset) { if (!space?.id) return; - const fav_list = this.favorites() || EMPTY_FAVS; - const new_state = !fav_list.includes(space.id); - if (new_state) { - this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_DESKS, [ - ...fav_list, - space.id, - ]); - } else { - this._settings.saveUserSetting( - SETTING_KEYS.FAVORITE_DESKS, - fav_list.filter((_) => _ !== space.id), - ); - } + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_DESKS, + toggleListItem(this.favorites() || EMPTY_FAVS, space.id), + ); } } diff --git a/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts b/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts index cb8d6bdb1ad..3413e051786 100644 --- a/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts +++ b/libs/bookings/src/lib/desk-select-modal/desk-select-modal.component.ts @@ -7,7 +7,12 @@ import { } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { isMobileSafari, SETTING_KEYS, SettingsService } from '@placeos/common'; +import { + isMobileSafari, + SETTING_KEYS, + SettingsService, + toggleListItem, +} from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -298,10 +303,7 @@ export class DeskSelectModalComponent { const fav_list = this._settings.get(SETTING_KEYS.FAVORITE_DESKS) || this.favorites(); - const new_state = !fav_list.includes(item.id); - const next_favs = new_state - ? [...fav_list, item.id] - : fav_list.filter((_) => _ !== item.id); + const next_favs = toggleListItem(fav_list, item.id); this.favorites.set(next_favs); this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_DESKS, next_favs); } diff --git a/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts b/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts index 806a9db5862..192f1422fdb 100644 --- a/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts +++ b/libs/bookings/src/lib/locker-select-modal/locker-select-modal.component.ts @@ -7,7 +7,12 @@ import { } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { isMobileSafari, SETTING_KEYS, SettingsService } from '@placeos/common'; +import { + isMobileSafari, + SETTING_KEYS, + SettingsService, + toggleListItem, +} from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -254,11 +259,7 @@ export class LockerSelectModalComponent { public toggleFavourite(item: BookingAsset | null) { if (!item?.id) return; - const fav_list = this.favorites(); - const new_state = !fav_list.includes(item.id); - const next_favs = new_state - ? [...fav_list, item.id] - : fav_list.filter((_) => _ !== item.id); + const next_favs = toggleListItem(this.favorites(), item.id); this.favorites.set(next_favs); this._settings.saveUserSetting( SETTING_KEYS.FAVORITE_LOCKERS, diff --git a/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts b/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts index f266b0b6799..e3403e7bad4 100644 --- a/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts +++ b/libs/bookings/src/lib/parking-select-modal/parking-select-modal.component.ts @@ -7,7 +7,12 @@ import { } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { isMobileSafari, SETTING_KEYS, SettingsService } from '@placeos/common'; +import { + isMobileSafari, + SETTING_KEYS, + SettingsService, + toggleListItem, +} from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -238,11 +243,7 @@ export class ParkingSelectModalComponent { public toggleFavourite(item: BookingAsset | null) { if (!item?.id) return; - const fav_list = this.favorites(); - const new_state = !fav_list.includes(item.id); - const next_favs = new_state - ? [...fav_list, item.id] - : fav_list.filter((_) => _ !== item.id); + const next_favs = toggleListItem(this.favorites(), item.id); this.favorites.set(next_favs); this._settings.saveUserSetting( SETTING_KEYS.FAVORITE_PARKING_SPACES, diff --git a/libs/bookings/src/lib/parking-space-list-field.component.ts b/libs/bookings/src/lib/parking-space-list-field.component.ts index 2bf3d420a2a..6305295b76b 100644 --- a/libs/bookings/src/lib/parking-space-list-field.component.ts +++ b/libs/bookings/src/lib/parking-space-list-field.component.ts @@ -3,7 +3,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatRippleModule } from '@angular/material/core'; -import { SETTING_KEYS } from '@placeos/common'; +import { SETTING_KEYS, toggleListItem } from '@placeos/common'; import { SettingsService } from 'libs/common/src/lib/settings.service'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -206,18 +206,9 @@ export class ParkingSpaceListFieldComponent implements ControlValueAccessor { public readonly setDisabledState = (s: boolean) => this.disabled.set(s); public toggleFavourite(space: BookingAsset) { - const fav_list = this.favorites() || EMPTY_FAVS; - const new_state = !fav_list.includes(space.id); - if (new_state) { - this._settings.saveUserSetting( - SETTING_KEYS.FAVORITE_PARKING_SPACES, - [...fav_list, space.id], - ); - } else { - this._settings.saveUserSetting( - SETTING_KEYS.FAVORITE_PARKING_SPACES, - fav_list.filter((_) => _ !== space.id), - ); - } + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_PARKING_SPACES, + toggleListItem(this.favorites() || EMPTY_FAVS, space.id), + ); } } diff --git a/libs/catering/src/lib/catering-list-field.component.ts b/libs/catering/src/lib/catering-list-field.component.ts index 6f9cb36ed74..7e814c63007 100644 --- a/libs/catering/src/lib/catering-list-field.component.ts +++ b/libs/catering/src/lib/catering-list-field.component.ts @@ -24,6 +24,7 @@ import { SETTING_KEYS, SettingsService, Space, + toggleListItem, } from '@placeos/common'; import { endOfDay, startOfDay } from 'date-fns'; @@ -608,19 +609,10 @@ export class CateringListFieldComponent } public toggleFavourite(cateringitem: CateringItem) { - const fav_list = this.favorites(); - const new_state = !fav_list.includes(cateringitem.id); - if (new_state) { - this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_CATERING, [ - ...fav_list, - cateringitem.id, - ]); - } else { - this._settings.saveUserSetting( - SETTING_KEYS.FAVORITE_CATERING, - fav_list.filter((_) => _ !== cateringitem.id), - ); - } + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_CATERING, + toggleListItem(this.favorites(), cateringitem.id), + ); } private syncExpandedOrders(orders: CateringOrder[]) { diff --git a/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts b/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts index eafe73ab792..576137f6685 100644 --- a/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts +++ b/libs/catering/src/lib/catering-select-modal/catering-select-modal.component.ts @@ -6,6 +6,7 @@ import { OrganisationService, SETTING_KEYS, SettingsService, + toggleListItem, } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -274,18 +275,9 @@ export class CateringSelectModalComponent { } public toggleFavourite(item: string) { - const fav_list = this.favorites(); - const new_state = !fav_list.includes(item); - if (new_state) { - this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_CATERING, [ - ...fav_list, - item, - ]); - } else { - this._settings.saveUserSetting( - SETTING_KEYS.FAVORITE_CATERING, - fav_list.filter((_) => _ !== item), - ); - } + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_CATERING, + toggleListItem(this.favorites(), item), + ); } } diff --git a/libs/common/src/lib/general.ts b/libs/common/src/lib/general.ts index 1cec5dbcdfd..fd65c88a1d6 100644 --- a/libs/common/src/lib/general.ts +++ b/libs/common/src/lib/general.ts @@ -139,6 +139,16 @@ export function getItemWithKeys(keys: string[], map: HashMap) { return null; } +/** + * Add the item to the list, or remove it if the list already has it. + * Returns a new array, e.g. for toggling an ID in a favourites setting. + */ +export function toggleListItem(list: readonly T[], item: T): T[] { + return list.includes(item) + ? list.filter((_) => _ !== item) + : [...list, item]; +} + /** * Remove duplicates from the given array * @param array List of items to remove duplicates from diff --git a/libs/common/src/tests/general.spec.ts b/libs/common/src/tests/general.spec.ts index f9518061aaf..7a15eecbb02 100644 --- a/libs/common/src/tests/general.spec.ts +++ b/libs/common/src/tests/general.spec.ts @@ -18,11 +18,21 @@ import { responseErrorMessage, setupFormTimeSync, timePeriodsIntersect, + toggleListItem, withTimeout, } from '../lib/general'; import * as notifications from '../lib/notifications'; describe('General Methods', () => { + describe('toggleListItem', () => { + it('should add a missing item and remove a present one', () => { + const list = ['a', 'b']; + expect(toggleListItem(list, 'c')).toEqual(['a', 'b', 'c']); + expect(toggleListItem(list, 'a')).toEqual(['b']); + expect(list).toEqual(['a', 'b']); + }); + }); + describe('withTimeout', () => { it('should reject work that does not settle before its deadline', async () => { vi.useFakeTimers(); diff --git a/libs/events/src/lib/space-select-modal/space-select-modal.component.ts b/libs/events/src/lib/space-select-modal/space-select-modal.component.ts index 7210cefa519..72c18634553 100644 --- a/libs/events/src/lib/space-select-modal/space-select-modal.component.ts +++ b/libs/events/src/lib/space-select-modal/space-select-modal.component.ts @@ -14,6 +14,7 @@ import { settingSignal, SettingsService, Space, + toggleListItem, } from '@placeos/common'; import { EventFormOptions, EventFormService } from '../event-form.service'; @@ -371,10 +372,7 @@ export class SpaceSelectModalComponent { public toggleFavourite(item: Space | null) { if (!item?.id) return; - const fav_list = this.favorites(); - const next_favs = fav_list.includes(item.id) - ? fav_list.filter((_) => _ !== item.id) - : [...fav_list, item.id]; + const next_favs = toggleListItem(this.favorites(), item.id); this.favorites.set(next_favs); this._settings.saveUserSetting(SETTING_KEYS.FAVORITE_ROOMS, next_favs); } diff --git a/libs/form-fields/src/lib/space-list-field.component.ts b/libs/form-fields/src/lib/space-list-field.component.ts index 8f28525c52c..712173f7cc8 100644 --- a/libs/form-fields/src/lib/space-list-field.component.ts +++ b/libs/form-fields/src/lib/space-list-field.component.ts @@ -23,6 +23,7 @@ import { SETTING_KEYS, SettingsService, Space, + toggleListItem, } from '@placeos/common'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -300,11 +301,7 @@ export class SpaceListFieldComponent public toggleFavourite(space: Space) { if (!space?.id) return; - const fav_list = this.favorites; - const new_state = !fav_list.includes(space.id); - const updated = new_state - ? [...fav_list, space.id] - : fav_list.filter((_) => _ !== space.id); + const updated = toggleListItem(this.favorites, space.id); // Optimistically update cache for instant UI update this._favorites_cache = updated; From 8fbe8871f9110e4c2e0d0be70e1d231f148d0eac Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:02 +1100 Subject: [PATCH 07/17] refactor(assets): share the hidden asset category and type lookup The desk, locker, parking and catering asset files each had a copy of the code that finds or creates their hidden asset category and type. assets.fn.ts now has one cached version. It retries after a create collision and shares the category and type lists, so fewer duplicate requests go out. A failed list request now rejects and is not cached. Before, it was treated as an empty list, which could create a duplicate type and hide existing resources until reload. --- libs/assets/src/lib/assets.fn.spec.ts | 45 +++ libs/assets/src/lib/assets.fn.ts | 122 ++++++++ .../assets/src/lib/catering-assets.fn.spec.ts | 23 +- libs/assets/src/lib/catering-assets.fn.ts | 95 ++----- libs/assets/src/lib/desk-assets.fn.ts | 98 ++----- libs/assets/src/lib/locker-assets.fn.ts | 134 +-------- libs/assets/src/lib/parking-assets.fn.ts | 260 ++---------------- 7 files changed, 276 insertions(+), 501 deletions(-) diff --git a/libs/assets/src/lib/assets.fn.spec.ts b/libs/assets/src/lib/assets.fn.spec.ts index f5bb70c1b0d..fa57ba79741 100644 --- a/libs/assets/src/lib/assets.fn.spec.ts +++ b/libs/assets/src/lib/assets.fn.spec.ts @@ -64,6 +64,51 @@ describe('[Assets]', () => { }); }); + describe('resolveHiddenAssetTypeId', () => { + it('should share the hidden category list across types', async () => { + const { assets_fn } = await load_modules(); + vi.mocked(ts_client.queryAssetCategories).mockReturnValue( + response([{ id: 'cat-1', name: '_LOCKERS_' }]), + ); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + response([ + { id: 'type-1', name: '_LOCKERS_' }, + { id: 'type-2', name: '_LOCKER_BANKS_' }, + ]), + ); + + const ids = await Promise.all([ + assets_fn.resolveHiddenAssetTypeId('_LOCKERS_', '_LOCKERS_'), + assets_fn.resolveHiddenAssetTypeId( + '_LOCKERS_', + '_LOCKER_BANKS_', + ), + ]); + + expect(ids).toEqual(['type-1', 'type-2']); + expect(ts_client.queryAssetCategories).toHaveBeenCalledTimes(1); + expect(ts_client.queryAssetTypes).toHaveBeenCalledTimes(1); + }); + + it('should not create a type when the type list fails to load', async () => { + const { assets_fn } = await load_modules(); + vi.mocked(ts_client.queryAssetCategories).mockReturnValue( + response([{ id: 'cat-1', name: '_DESKS_' }]), + ); + vi.mocked(ts_client.queryAssetTypes) + .mockRejectedValueOnce(new Error('offline')) + .mockReturnValue(response([{ id: 'type-1', name: '_DESKS_' }])); + + await expect( + assets_fn.resolveHiddenAssetTypeId('_DESKS_', '_DESKS_'), + ).rejects.toThrow('offline'); + expect(ts_client.addAssetType).not.toHaveBeenCalled(); + expect( + await assets_fn.resolveHiddenAssetTypeId('_DESKS_', '_DESKS_'), + ).toBe('type-1'); + }); + }); + describe('queryAssetCategories', () => { it('should pass through when explicitly querying hidden categories', async () => { const { assets_fn } = await load_modules(); diff --git a/libs/assets/src/lib/assets.fn.ts b/libs/assets/src/lib/assets.fn.ts index 4e689d78e1b..4f7935a1b92 100644 --- a/libs/assets/src/lib/assets.fn.ts +++ b/libs/assets/src/lib/assets.fn.ts @@ -52,6 +52,128 @@ export function findOldestByName(list: any[], name: string = '') { .sort((a, b) => (a.created_at || 0) - (b.created_at || 0))[0]; } +// Hidden categories and types store app resources (desks, lockers, parking, +// catering) as assets. The lookups below are shared so each list is only +// requested once. A failed list request rejects and is not cached, so a +// network error never causes a duplicate category or type to be created. +let _hidden_categories: Promise | null = null; +const _category_types = new Map>(); +const _hidden_type_ids = new Map>(); + +function queryHiddenCategories() { + if (!_hidden_categories) { + const request = queryAssetCategoriesAPI({ + hidden: true, + limit: 500, + }).then((_) => _.data); + request.catch(() => { + if (_hidden_categories === request) _hidden_categories = null; + }); + _hidden_categories = request; + } + return _hidden_categories; +} + +function queryCategoryTypes(category_id: string) { + if (!_category_types.has(category_id)) { + const request = queryAssetTypesAPI({ category_id, limit: 500 }).then( + (_) => _.data, + ); + request.catch(() => { + if (_category_types.get(category_id) === request) { + _category_types.delete(category_id); + } + }); + _category_types.set(category_id, request); + } + return _category_types.get(category_id); +} + +/** + * Find the hidden asset category with the given name, or create it. + * When the create collides with another client, the list is read again. + */ +export async function ensureHiddenCategory(name: string) { + let category = findOldestByName(await queryHiddenCategories(), name); + if (category) return category; + _hidden_categories = null; + category = findOldestByName(await queryHiddenCategories(), name); + if (category) return category; + try { + category = await saveAssetCategory({ name, hidden: true }); + _hidden_categories = null; + return category; + } catch (error) { + _hidden_categories = null; + category = findOldestByName(await queryHiddenCategories(), name); + if (category) return category; + throw error; + } +} + +/** + * Find the asset type with the given name in the category, or create it. + * When the create collides with another client, the list is read again. + */ +export async function ensureAssetType(category_id: string, name: string) { + let type = findOldestByName(await queryCategoryTypes(category_id), name); + if (type) return type; + try { + type = await saveAssetType({ name, brand: 'PlaceOS', category_id }); + _category_types.delete(category_id); + return type; + } catch (error) { + _category_types.delete(category_id); + type = findOldestByName(await queryCategoryTypes(category_id), name); + if (type) return type; + throw error; + } +} + +/** + * Resolve the ID of a hidden asset type, creating the category and type when + * missing. The ID is cached per type. A failed lookup is not cached. + */ +export function resolveHiddenAssetTypeId( + category_name: string, + type_name: string, +): Promise { + const key = `${category_name}/${type_name}`; + if (!_hidden_type_ids.has(key)) { + const request = ensureHiddenCategory(category_name) + .then((category) => ensureAssetType(category.id, type_name)) + .then((type) => type.id as string); + request.catch(() => _hidden_type_ids.delete(key)); + _hidden_type_ids.set(key, request); + } + return _hidden_type_ids.get(key); +} + +/** Query the assets of one type across the zones, flattening the results */ +export async function queryTypeAssetsForZones( + type_id: string, + zone_ids: string[], +): Promise { + const results = await Promise.all( + zone_ids.map((zone_id) => + queryAllAssetPages({ zone_id, type_id }).then((_) => _.data), + ), + ); + return flatten(results); +} + +/** + * Query the assets of one type within a building or region. These assets list + * every zone they are in (org, region, building and level) in `zones`. + */ +export async function queryTypeAssetsInZone( + type_id: string, + zone_id: string, +): Promise { + const response = await queryAllAssetPages({ zones: zone_id, type_id }); + return response.data; +} + function filter_hidden_items(response: T): T { return { ...response, diff --git a/libs/assets/src/lib/catering-assets.fn.spec.ts b/libs/assets/src/lib/catering-assets.fn.spec.ts index 4bbe1d883fd..a2fd2f73b18 100644 --- a/libs/assets/src/lib/catering-assets.fn.spec.ts +++ b/libs/assets/src/lib/catering-assets.fn.spec.ts @@ -155,6 +155,25 @@ describe('[Catering Assets]', () => { expect(items[0].unit_price).toBe(400); }); + it('should retry the catering category after a failed load', async () => { + const { catering_assets } = await load_modules(); + vi.mocked(ts_client.queryAssetCategories) + .mockRejectedValueOnce(new Error('offline')) + .mockReturnValue(response([{ id: 'cat-1', name: '_CATERING_' }])); + vi.mocked(ts_client.queryAssetTypes).mockReturnValue( + response([{ id: 'type-1', name: 'CATERING:Acme Catering' }]), + ); + vi.mocked(ts_client.queryAssets).mockReturnValue( + response([{ id: 'asset-1', name: 'Coffee' }]), + ); + + expect(await catering_assets.queryCateringItems('bld-1')).toEqual([]); + const items = await catering_assets.queryCateringItems('bld-1'); + + expect(items.map((_) => _.id)).toEqual(['asset-1']); + expect(ts_client.addAssetCategory).not.toHaveBeenCalled(); + }); + it('should create an asset when a supplied item id does not exist', async () => { const { catering_assets } = await load_modules(); vi.mocked(ts_client.queryAssetCategories).mockReturnValue( @@ -179,7 +198,9 @@ describe('[Catering Assets]', () => { expect(ts_client.updateAsset).toHaveBeenCalledTimes(1); expect(ts_client.addAsset).toHaveBeenCalledTimes(1); - expect(vi.mocked(ts_client.addAsset).mock.calls[0][0].id).toBeUndefined(); + expect( + vi.mocked(ts_client.addAsset).mock.calls[0][0].id, + ).toBeUndefined(); expect(item.id).toBe('asset-1'); expect(item.caterer).toBe('standalone'); }); diff --git a/libs/assets/src/lib/catering-assets.fn.ts b/libs/assets/src/lib/catering-assets.fn.ts index cd7f2d4c29f..0dca3f22d80 100644 --- a/libs/assets/src/lib/catering-assets.fn.ts +++ b/libs/assets/src/lib/catering-assets.fn.ts @@ -1,16 +1,15 @@ import { CateringItem, flatten } from '@placeos/common'; import { PlaceAsset, - queryAssetCategories, queryAssets, queryAssetTypes, removeAsset, } from '@placeos/ts-client'; import { + ensureAssetType, + ensureHiddenCategory, findOldestByName, saveAsset, - saveAssetCategory, - saveAssetType, } from './assets.fn'; const CATERING_CATEGORY_NAME = '_CATERING_'; @@ -18,56 +17,17 @@ const CATERING_TYPE_PREFIX = 'CATERING:'; const STANDALONE_CATERER_NAME = '_STANDALONE_'; const STANDALONE_CATERER_LABEL = 'standalone'; -let _catering_category_id: string | null = null; let _catering_category_id_promise: Promise | null = null; -let _hidden_categories_promise: Promise | null = null; let _catering_types_promise: Promise | null = null; function normalise_name(name: string = '') { return name.trim().toLowerCase(); } -function reset_hidden_categories_cache() { - _hidden_categories_promise = null; -} - function reset_catering_types_cache() { _catering_types_promise = null; } -async function query_hidden_categories() { - if (!_hidden_categories_promise) { - _hidden_categories_promise = queryAssetCategories({ - hidden: true, - limit: 500, - }) - .then((_) => _.data) - .catch(() => []); - } - return _hidden_categories_promise; -} - -async function ensure_hidden_category(name: string) { - let category = findOldestByName(await query_hidden_categories(), name); - if (category) return category; - reset_hidden_categories_cache(); - category = findOldestByName(await query_hidden_categories(), name); - if (category) return category; - try { - category = await saveAssetCategory({ - name, - hidden: true, - }); - reset_hidden_categories_cache(); - return category; - } catch (error) { - reset_hidden_categories_cache(); - category = findOldestByName(await query_hidden_categories(), name); - if (category) return category; - throw error; - } -} - export function toCateringTypeName(caterer: string = '') { const value = `${caterer || ''}`.trim(); const name = @@ -90,25 +50,35 @@ export function isCateringTypeName(type_name: string = '') { return type_name.startsWith(CATERING_TYPE_PREFIX); } +/** Resolve the hidden catering category. A failed lookup is not cached. */ export function resolveCateringCategoryId(): Promise { - if (_catering_category_id) return Promise.resolve(_catering_category_id); if (!_catering_category_id_promise) { - _catering_category_id_promise = ensure_hidden_category( - CATERING_CATEGORY_NAME, - ).then((category) => { - _catering_category_id = category.id; - return category.id; + const request = ensureHiddenCategory(CATERING_CATEGORY_NAME).then( + (category) => category.id as string, + ); + request.catch(() => { + if (_catering_category_id_promise === request) { + _catering_category_id_promise = null; + } }); + _catering_category_id_promise = request; } return _catering_category_id_promise; } +/** Prefixed catering types. A failed load resolves empty and is not cached. */ function query_catering_types(): Promise { if (!_catering_types_promise) { - _catering_types_promise = resolveCateringCategoryId() + const request = resolveCateringCategoryId() .then((category_id) => queryAssetTypes({ category_id, limit: 500 })) .then((_) => _.data.filter((type) => isCateringTypeName(type.name))) - .catch(() => []); + .catch(() => { + if (_catering_types_promise === request) { + _catering_types_promise = null; + } + return []; + }); + _catering_types_promise = request; } return _catering_types_promise; } @@ -117,25 +87,14 @@ export async function resolveCateringTypeId( caterer: string = '', ): Promise { const type_name = toCateringTypeName(caterer); - const types = await query_catering_types(); - const type = findOldestByName(types, type_name); + const type = findOldestByName(await query_catering_types(), type_name); if (type) return type.id; - try { - const category_id = await resolveCateringCategoryId(); - const type = await saveAssetType({ - name: type_name, - brand: 'PlaceOS', - category_id, - }); - reset_catering_types_cache(); - return type.id; - } catch (error) { - reset_catering_types_cache(); - const types = await query_catering_types(); - const type = findOldestByName(types, type_name); - if (type) return type.id; - throw error; - } + const created = await ensureAssetType( + await resolveCateringCategoryId(), + type_name, + ); + reset_catering_types_cache(); + return created.id; } export function toCateringItem(asset: PlaceAsset, caterer: string) { diff --git a/libs/assets/src/lib/desk-assets.fn.ts b/libs/assets/src/lib/desk-assets.fn.ts index e95c13cd55e..e636aeb27e3 100644 --- a/libs/assets/src/lib/desk-assets.fn.ts +++ b/libs/assets/src/lib/desk-assets.fn.ts @@ -1,17 +1,10 @@ -import { Desk, flatten } from '@placeos/common'; +import { Desk } from '@placeos/common'; +import { PlaceAsset, PlaceZone, removeAsset } from '@placeos/ts-client'; import { - PlaceAsset, - PlaceZone, - queryAssetCategories, - queryAssetTypes, - removeAsset, -} from '@placeos/ts-client'; -import { - findOldestByName, - queryAllAssetPages, + queryTypeAssetsForZones, + queryTypeAssetsInZone, + resolveHiddenAssetTypeId, saveAsset, - saveAssetCategory, - saveAssetType, } from './assets.fn'; const DESK_CATEGORY_NAME = '_DESKS_'; @@ -20,63 +13,22 @@ const DESK_TYPE_NAME = '_DESKS_'; type DeskZone = Pick & Partial>; -let _desk_type_id: string | null = null; -let _desk_type_id_promise: Promise | null = null; - -async function bootstrap_desk_type(): Promise { - let category = findOldestByName( - await queryAssetCategories({ hidden: true, limit: 500 }).then( - (response) => response.data, - ), - DESK_CATEGORY_NAME, - ); - if (!category) { - category = await saveAssetCategory({ - name: DESK_CATEGORY_NAME, - hidden: true, - }); - } - let type = findOldestByName( - await queryAssetTypes({ - category_id: category.id, - limit: 500, - }).then((response) => response.data), - DESK_TYPE_NAME, - ); - if (!type) { - type = await saveAssetType({ - name: DESK_TYPE_NAME, - brand: 'PlaceOS', - category_id: category.id, - }); - } - return type.id; -} - /** Resolve the hidden asset type used for desk resources. */ export function resolveDeskTypeId(): Promise { - if (_desk_type_id) return Promise.resolve(_desk_type_id); - if (!_desk_type_id_promise) { - _desk_type_id_promise = bootstrap_desk_type().then( - (id) => { - _desk_type_id = id; - return id; - }, - (error) => { - // allow a later call to retry, e.g. after a network error - _desk_type_id_promise = null; - throw error; - }, - ); - } - return _desk_type_id_promise; + return resolveHiddenAssetTypeId(DESK_CATEGORY_NAME, DESK_TYPE_NAME); } /** Query desk assets for one level. */ export async function queryDeskAssets(zone_id: string): Promise { - const type_id = await resolveDeskTypeId(); - const response = await queryAllAssetPages({ zone_id, type_id }); - return response.data; + return queryDeskAssetsForZones([zone_id]); +} + +/** Query desk assets for multiple levels. */ +export async function queryDeskAssetsForZones( + zone_ids: string[], +): Promise { + if (!zone_ids.length) return []; + return queryTypeAssetsForZones(await resolveDeskTypeId(), zone_ids); } /** @@ -88,25 +40,7 @@ export async function queryDeskAssetsInZone( ): Promise { // an empty `zones` filter is not a scope, e.g. the building is not loaded if (!zone_id) return []; - const type_id = await resolveDeskTypeId(); - const response = await queryAllAssetPages({ zones: zone_id, type_id }); - return response.data; -} - -/** Query desk assets for multiple levels. */ -export async function queryDeskAssetsForZones( - zone_ids: string[], -): Promise { - if (!zone_ids.length) return []; - const type_id = await resolveDeskTypeId(); - const results = await Promise.all( - zone_ids.map((zone_id) => - queryAllAssetPages({ zone_id, type_id }).then( - (response) => response.data, - ), - ), - ); - return flatten(results); + return queryTypeAssetsInZone(await resolveDeskTypeId(), zone_id); } /** diff --git a/libs/assets/src/lib/locker-assets.fn.ts b/libs/assets/src/lib/locker-assets.fn.ts index 063583b4dec..5af71090b10 100644 --- a/libs/assets/src/lib/locker-assets.fn.ts +++ b/libs/assets/src/lib/locker-assets.fn.ts @@ -1,125 +1,31 @@ -import { flatten } from '@placeos/common'; +import { PlaceAsset, removeAsset } from '@placeos/ts-client'; import { - PlaceAsset, - queryAssetCategories, - queryAssetTypes, - removeAsset, -} from '@placeos/ts-client'; -import { - findOldestByName, - queryAllAssetPages, + queryTypeAssetsForZones, + queryTypeAssetsInZone, + resolveHiddenAssetTypeId, saveAsset, - saveAssetCategory, - saveAssetType, } from './assets.fn'; const LOCKER_CATEGORY_NAME = '_LOCKERS_'; const LOCKER_BANK_TYPE_NAME = '_LOCKER_BANKS_'; const LOCKER_TYPE_NAME = '_LOCKERS_'; -let _locker_bank_type_id: string | null = null; -let _locker_bank_type_id_promise: Promise | null = null; -let _locker_type_id: string | null = null; -let _locker_type_id_promise: Promise | null = null; -let _hidden_categories_promise: Promise | null = null; -const _types_for_category_promises = new Map>(); - -async function query_hidden_categories() { - if (!_hidden_categories_promise) { - _hidden_categories_promise = queryAssetCategories({ - hidden: true, - limit: 500, - }) - .then((_) => _.data) - .catch(() => []); - } - return _hidden_categories_promise; -} - -async function query_types_for_category(category_id: string) { - if (!_types_for_category_promises.has(category_id)) { - _types_for_category_promises.set( - category_id, - queryAssetTypes({ category_id, limit: 500 }) - .then((_) => _.data) - .catch(() => []), - ); - } - return _types_for_category_promises.get(category_id); -} - -async function ensure_hidden_category(name: string) { - let category = findOldestByName(await query_hidden_categories(), name); - if (category) return category; - _hidden_categories_promise = null; - category = findOldestByName(await query_hidden_categories(), name); - if (category) return category; - const created = await saveAssetCategory({ - name, - hidden: true, - }); - _hidden_categories_promise = null; - return created; -} - -async function ensure_type(category_id: string, name: string) { - let type = findOldestByName( - await query_types_for_category(category_id), - name, - ); - if (type) return type; - const created = await saveAssetType({ - name, - brand: 'PlaceOS', - category_id, - }); - _types_for_category_promises.delete(category_id); - return created; -} - -async function bootstrap_locker_type(type_name: string) { - const category = await ensure_hidden_category(LOCKER_CATEGORY_NAME); - const type = await ensure_type(category.id, type_name); - return type.id; -} - export function resolveLockerBankTypeId(): Promise { - if (_locker_bank_type_id) return Promise.resolve(_locker_bank_type_id); - if (!_locker_bank_type_id_promise) { - _locker_bank_type_id_promise = bootstrap_locker_type( - LOCKER_BANK_TYPE_NAME, - ).then((id) => { - _locker_bank_type_id = id; - return id; - }); - } - return _locker_bank_type_id_promise; + return resolveHiddenAssetTypeId( + LOCKER_CATEGORY_NAME, + LOCKER_BANK_TYPE_NAME, + ); } export function resolveLockerTypeId(): Promise { - if (_locker_type_id) return Promise.resolve(_locker_type_id); - if (!_locker_type_id_promise) { - _locker_type_id_promise = bootstrap_locker_type(LOCKER_TYPE_NAME).then( - (id) => { - _locker_type_id = id; - return id; - }, - ); - } - return _locker_type_id_promise; + return resolveHiddenAssetTypeId(LOCKER_CATEGORY_NAME, LOCKER_TYPE_NAME); } export async function queryLockerBankAssetsForZones( zone_ids: string[], ): Promise { if (!zone_ids?.length) return []; - const type_id = await resolveLockerBankTypeId(); - const results = await Promise.all( - zone_ids.map((zone_id) => - queryAllAssetPages({ zone_id, type_id }).then((_) => _.data), - ), - ); - return flatten(results); + return queryTypeAssetsForZones(await resolveLockerBankTypeId(), zone_ids); } /** Query the locker banks within a building or region, by their `zones` */ @@ -128,9 +34,7 @@ export async function queryLockerBankAssetsInZone( ): Promise { // an empty `zones` filter is not a scope, e.g. the building is not loaded if (!zone_id) return []; - const type_id = await resolveLockerBankTypeId(); - const response = await queryAllAssetPages({ zones: zone_id, type_id }); - return response.data; + return queryTypeAssetsInZone(await resolveLockerBankTypeId(), zone_id); } export async function saveLockerBankAsset( @@ -147,22 +51,14 @@ export function deleteLockerBankAsset(id: string) { export async function queryLockerAssets( zone_id: string, ): Promise { - const type_id = await resolveLockerTypeId(); - const response = await queryAllAssetPages({ zone_id, type_id }); - return response.data; + return queryTypeAssetsForZones(await resolveLockerTypeId(), [zone_id]); } export async function queryLockerAssetsForZones( zone_ids: string[], ): Promise { if (!zone_ids?.length) return []; - const type_id = await resolveLockerTypeId(); - const results = await Promise.all( - zone_ids.map((zone_id) => - queryAllAssetPages({ zone_id, type_id }).then((_) => _.data), - ), - ); - return flatten(results); + return queryTypeAssetsForZones(await resolveLockerTypeId(), zone_ids); } /** Query the lockers within a building or region, by their `zones` */ @@ -171,9 +67,7 @@ export async function queryLockerAssetsInZone( ): Promise { // an empty `zones` filter is not a scope, e.g. the building is not loaded if (!zone_id) return []; - const type_id = await resolveLockerTypeId(); - const response = await queryAllAssetPages({ zones: zone_id, type_id }); - return response.data; + return queryTypeAssetsInZone(await resolveLockerTypeId(), zone_id); } export async function saveLockerAsset( diff --git a/libs/assets/src/lib/parking-assets.fn.ts b/libs/assets/src/lib/parking-assets.fn.ts index a245f6d28bc..142470abad0 100644 --- a/libs/assets/src/lib/parking-assets.fn.ts +++ b/libs/assets/src/lib/parking-assets.fn.ts @@ -1,190 +1,24 @@ -import { flatten } from '@placeos/common'; +import { PlaceAsset, removeAsset } from '@placeos/ts-client'; import { - PlaceAsset, - queryAssetCategories, - queryAssetTypes, - removeAsset, -} from '@placeos/ts-client'; -import { - findOldestByName, - queryAllAssetPages, + queryTypeAssetsForZones, + queryTypeAssetsInZone, + resolveHiddenAssetTypeId, saveAsset, - saveAssetCategory, - saveAssetType, } from './assets.fn'; const PARKING_CATEGORY_NAME = '_PARKING_'; const PARKING_TYPE_NAME = '_PARKING_SPACES_'; -let _parking_type_id: string | null = null; -let _parking_type_id_promise: Promise | null = null; -let _hidden_categories_promise: Promise | null = null; -const _types_for_category_promises = new Map>(); - -function normalise_name(name: string = '') { - return name.trim().toLowerCase(); -} - -async function query_hidden_categories() { - if (!_hidden_categories_promise) { - _hidden_categories_promise = queryAssetCategories({ - hidden: true, - limit: 500, - }) - .then((_) => _.data) - .catch(() => []); - } - return _hidden_categories_promise; -} - -async function query_types_for_category(category_id: string) { - if (!_types_for_category_promises.has(category_id)) { - _types_for_category_promises.set( - category_id, - queryAssetTypes({ category_id, limit: 500 }) - .then((_) => _.data) - .catch(() => []), - ); - } - return _types_for_category_promises.get(category_id); -} - -function reset_hidden_categories_cache() { - _hidden_categories_promise = null; -} - -function reset_types_cache(category_ids: string[]) { - category_ids.forEach((category_id) => - _types_for_category_promises.delete(category_id), - ); -} - -async function query_types_for_categories(category_ids: string[]) { - const list = await Promise.all( - category_ids.map((category_id) => - query_types_for_category(category_id), - ), - ); - return list.flat(); -} - -async function ensure_hidden_category(name: string) { - let category = findOldestByName(await query_hidden_categories(), name); - if (category) return category; - reset_hidden_categories_cache(); - category = findOldestByName(await query_hidden_categories(), name); - if (category) return category; - try { - const category = await saveAssetCategory({ - name, - hidden: true, - }); - reset_hidden_categories_cache(); - return category; - } catch (error) { - reset_hidden_categories_cache(); - category = findOldestByName(await query_hidden_categories(), name); - if (category) return category; - throw error; - } -} - -async function move_type_to_category( - type: any, - category_id: string, - name: string, -) { - if ( - type.category_id === category_id && - normalise_name(type.name) === normalise_name(name) - ) { - return type; - } - try { - const updated_type = await saveAssetType({ - id: type.id, - name, - brand: type.brand || 'PlaceOS', - category_id, - }); - reset_types_cache([category_id]); - return updated_type; - } catch (error) { - reset_types_cache([category_id]); - const types = await query_types_for_category(category_id); - const existing_type = findOldestByName(types, name); - if (existing_type) return existing_type; - throw error; - } -} - -async function ensure_type( - category_id: string, - name: string, - legacy_category_ids: string[] = [], -) { - let type = findOldestByName( - await query_types_for_categories([ - category_id, - ...legacy_category_ids.filter((_) => _ !== category_id), - ]), - name, - ); - if (type) return move_type_to_category(type, category_id, name); - try { - const type = await saveAssetType({ - name, - brand: 'PlaceOS', - category_id, - }); - reset_types_cache([category_id]); - return type; - } catch (error) { - reset_types_cache([category_id, ...legacy_category_ids]); - type = findOldestByName( - await query_types_for_categories([ - category_id, - ...legacy_category_ids.filter((_) => _ !== category_id), - ]), - name, - ); - if (type) return move_type_to_category(type, category_id, name); - throw error; - } -} - -async function bootstrap_asset_type(type_name: string) { - const category = await ensure_hidden_category(PARKING_CATEGORY_NAME); - const type = await ensure_type(category.id, type_name); - return type.id; -} - -/** - * Ensures the shared _PARKING_ category (hidden) and _PARKING_SPACES_ type - * exist, migrating legacy parking categories when needed. - */ +/** Resolve the hidden _PARKING_SPACES_ asset type in the _PARKING_ category */ export function resolveParkingTypeId(): Promise { - if (_parking_type_id) return Promise.resolve(_parking_type_id); - if (!_parking_type_id_promise) { - _parking_type_id_promise = _bootstrapParkingType().then((id) => { - _parking_type_id = id; - return id; - }); - } - return _parking_type_id_promise; -} - -async function _bootstrapParkingType(): Promise { - return bootstrap_asset_type(PARKING_TYPE_NAME); + return resolveHiddenAssetTypeId(PARKING_CATEGORY_NAME, PARKING_TYPE_NAME); } /** Query parking spaces for a single zone */ export async function queryParkingSpaces( zone_id: string, ): Promise { - const type_id = await resolveParkingTypeId(); - const response = await queryAllAssetPages({ zone_id, type_id }); - return response.data; + return queryTypeAssetsForZones(await resolveParkingTypeId(), [zone_id]); } /** Query parking spaces across multiple zones, flattening results */ @@ -192,13 +26,7 @@ export async function queryParkingSpacesForZones( zone_ids: string[], ): Promise { if (!zone_ids?.length) return []; - const type_id = await resolveParkingTypeId(); - const results = await Promise.all( - zone_ids.map((zone_id) => - queryAllAssetPages({ zone_id, type_id }).then((_) => _.data), - ), - ); - return flatten(results); + return queryTypeAssetsForZones(await resolveParkingTypeId(), zone_ids); } /** @@ -210,9 +38,7 @@ export async function queryParkingSpacesInZone( ): Promise { // an empty `zones` filter is not a scope, e.g. the building is not loaded if (!zone_id) return []; - const type_id = await resolveParkingTypeId(); - const response = await queryAllAssetPages({ zones: zone_id, type_id }); - return response.data; + return queryTypeAssetsInZone(await resolveParkingTypeId(), zone_id); } /** Save (create or update) a parking space asset */ @@ -243,28 +69,12 @@ export interface ParkingUser { const PARKING_USER_TYPE_NAME = '_PARKING_USERS_'; -let _parking_user_type_id: string | null = null; -let _parking_user_type_id_promise: Promise | null = null; - -/** - * Ensures the shared _PARKING_ category (hidden) and _PARKING_USERS_ type - * exist, migrating legacy parking categories when needed. - */ +/** Resolve the hidden _PARKING_USERS_ asset type in the _PARKING_ category */ export function resolveParkingUserTypeId(): Promise { - if (_parking_user_type_id) return Promise.resolve(_parking_user_type_id); - if (!_parking_user_type_id_promise) { - _parking_user_type_id_promise = _bootstrapParkingUserType().then( - (id) => { - _parking_user_type_id = id; - return id; - }, - ); - } - return _parking_user_type_id_promise; -} - -async function _bootstrapParkingUserType(): Promise { - return bootstrap_asset_type(PARKING_USER_TYPE_NAME); + return resolveHiddenAssetTypeId( + PARKING_CATEGORY_NAME, + PARKING_USER_TYPE_NAME, + ); } /** Convert a PlaceAsset to a ParkingUser */ @@ -311,9 +121,11 @@ export function fromParkingUser( export async function queryParkingUsers( zone_id: string, ): Promise { - const type_id = await resolveParkingUserTypeId(); - const assets = await queryAllAssetPages({ zone_id, type_id }); - return assets.data.map(toParkingUser); + const assets = await queryTypeAssetsForZones( + await resolveParkingUserTypeId(), + [zone_id], + ); + return assets.map(toParkingUser); } /** Save (create or update) a parking user asset */ @@ -342,29 +154,15 @@ export interface ParkingFleetVehicle { const PARKING_FLEET_TYPE_NAME = '_PARKING_FLEET_VEHICLES_'; -let _parking_fleet_type_id: string | null = null; -let _parking_fleet_type_id_promise: Promise | null = null; - /** - * Ensures the shared _PARKING_ category (hidden) and - * _PARKING_FLEET_VEHICLES_ type exist, migrating legacy parking categories - * when needed. + * Resolve the hidden _PARKING_FLEET_VEHICLES_ asset type in the _PARKING_ + * category */ export function resolveParkingFleetTypeId(): Promise { - if (_parking_fleet_type_id) return Promise.resolve(_parking_fleet_type_id); - if (!_parking_fleet_type_id_promise) { - _parking_fleet_type_id_promise = _bootstrapParkingFleetType().then( - (id) => { - _parking_fleet_type_id = id; - return id; - }, - ); - } - return _parking_fleet_type_id_promise; -} - -async function _bootstrapParkingFleetType(): Promise { - return bootstrap_asset_type(PARKING_FLEET_TYPE_NAME); + return resolveHiddenAssetTypeId( + PARKING_CATEGORY_NAME, + PARKING_FLEET_TYPE_NAME, + ); } /** Convert a PlaceAsset to a ParkingFleetVehicle */ @@ -403,9 +201,11 @@ export function fromParkingFleetVehicle( export async function queryParkingFleetVehicles( zone_id: string, ): Promise { - const type_id = await resolveParkingFleetTypeId(); - const assets = await queryAllAssetPages({ zone_id, type_id }); - return assets.data.map(toParkingFleetVehicle); + const assets = await queryTypeAssetsForZones( + await resolveParkingFleetTypeId(), + [zone_id], + ); + return assets.map(toParkingFleetVehicle); } /** Save (create or update) a fleet vehicle asset */ From 6f4288ff28237a13d3649a521da888247d85f30a Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:03 +1100 Subject: [PATCH 08/17] refactor(form-fields): share the upload helper and merge compact-counter upload-file-field and upload-list-field had the same upload method. It is now uploadAttachment(), which passes the File straight to uploadFiles, so the blob-util dependency is removed. compact-counter was a copy of a-counter with another layout. It is now the `compact` input of a-counter. --- bun.lock | 3 - .../src/lib/compact-counter.component.ts | 181 ------------------ libs/form-fields/src/lib/counter.component.ts | 90 ++++++--- .../src/lib/recurrence-modal.component.ts | 14 +- libs/form-fields/src/lib/upload-attachment.ts | 32 ++++ .../src/lib/upload-file-field.component.ts | 37 +--- .../src/lib/upload-list-field.component.ts | 36 +--- .../tests/compact-counter.component.spec.ts | 87 --------- .../src/tests/counter.component.spec.ts | 10 + package.json | 1 - 10 files changed, 119 insertions(+), 372 deletions(-) delete mode 100644 libs/form-fields/src/lib/compact-counter.component.ts create mode 100644 libs/form-fields/src/lib/upload-attachment.ts delete mode 100644 libs/form-fields/src/tests/compact-counter.component.spec.ts diff --git a/bun.lock b/bun.lock index 69eb8e8d621..fbebd8a264b 100644 --- a/bun.lock +++ b/bun.lock @@ -70,7 +70,6 @@ "@vitest/coverage-v8": "^4.0.8", "angular-eslint": "22.2.0", "autoprefixer": "^10.4.16", - "blob-util": "^2.0.2", "chart.js": "^4.5.0", "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", @@ -1486,8 +1485,6 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "blob-util": ["blob-util@2.0.2", "", {}, "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], diff --git a/libs/form-fields/src/lib/compact-counter.component.ts b/libs/form-fields/src/lib/compact-counter.component.ts deleted file mode 100644 index b3ccf2bfe68..00000000000 --- a/libs/form-fields/src/lib/compact-counter.component.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Component, forwardRef, input, model, signal } from '@angular/core'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; - -@Component({ - selector: 'compact-counter', - template: ` -
-
- @if (!focused()) { - - {{ - (render_fn() ? render_fn()(value()) : value()) || - '0' - }} - - } - -
-
- - -
-
- `, - styles: [ - ` - [counter].disabled { - @apply pointer-events-none opacity-30; - } - `, - ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - /* istanbul ignore next */ - useExisting: forwardRef(() => CompactCounterComponent), - multi: true, - }, - ], - imports: [FormsModule, IconComponent, TranslatePipe], -}) -export class CompactCounterComponent implements ControlValueAccessor { - /** Size of a single step */ - public readonly step = input(1); - /** Maximum amount for the counter */ - public readonly max = input(999); - /** Minimum amount for the counter */ - public readonly min = input(0); - /** Custom function for rendering the counter value */ - public readonly render_fn = input<(v: number) => string>(undefined); - /** Whether form control is disabled */ - public readonly disabled = model(undefined); - /** Current value of the counter */ - public readonly value = signal(undefined); - /** Whether shift key is being held by the user */ - public readonly shift_key = signal(false); - /** Whether control key is being held by the user */ - public readonly ctrl_key = signal(false); - public readonly focused = signal(false); - - /** Form control on change handler */ - private _onChange: (_: number) => void; - /** Form control on touch handler */ - private _onTouch: (_: number) => void; - - /** - * Add the `step` to the current value - */ - public add() { - let value = this.value(); - if (!value) { - value = this.min() || 0; - } - const step = this.ctrl_key() - ? 100 * this.step() - : this.shift_key() - ? 10 * this.step() - : this.step() || 1; - value += step; - if (value > this.max()) { - value = this.max() || 10; - } - this.setValue(value); - } - - /** Remove the `step` from the current value */ - public remove() { - let value = this.value(); - if (!value) { - value = this.min() || 0; - } - const step = this.ctrl_key() - ? 100 * this.step() - : this.shift_key() - ? 10 * this.step() - : this.step() || 1; - value -= step; - if (value < this.min()) { - value = this.min() || 0; - } - this.setValue(value); - } - - /** - * Update the form field value - * @param new_value New value to set on the form field - */ - public setValue(new_value: number): void { - if (new_value < this.min()) new_value = this.min(); - if (new_value > this.max()) new_value = this.max(); - if ((new_value / this.step()) % 1 !== 0) { - new_value = - Math.round(new_value * (1 / this.step())) / (1 / this.step()); - } - this.value.set(new_value); - /* istanbul ignore else */ - if (this._onChange) { - this._onChange(new_value); - } - } - - /** - * Update local value when form control value is changed - * @param value The new value for the component - */ - public writeValue(value: number) { - this.value.set(value); - } - - public readonly registerOnChange = (fn) => (this._onChange = fn); - public readonly registerOnTouched = (fn) => (this._onTouch = fn); - public readonly setDisabledState = (d) => this.disabled.set(d); -} diff --git a/libs/form-fields/src/lib/counter.component.ts b/libs/form-fields/src/lib/counter.component.ts index ef75398ed58..2f92f822b38 100644 --- a/libs/form-fields/src/lib/counter.component.ts +++ b/libs/form-fields/src/lib/counter.component.ts @@ -13,6 +13,7 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe';
- + @if (!compact()) { + + }
@if (!focused()) { - + {{ (render_fn() ? render_fn()(value()) : value()) || '0' @@ -55,18 +62,47 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; (blur)="setValue(+value()); focused.set(false)" />
- + @if (compact()) { +
+ + +
+ } @else { + + }
`, styles: [ @@ -97,6 +133,8 @@ export class CounterComponent implements ControlValueAccessor { public readonly render_fn = input<(v: number) => string>(undefined); /** ID of the editable input, so that a `
- + > {{ 'FORM.RECURRENCE_ENDS_AFTER' | translate }} - + >
@@ -172,7 +174,7 @@ import { DateFieldComponent } from './date-field.component'; MatRadioModule, MatSelectModule, MatDialogModule, - CompactCounterComponent, + CounterComponent, DateFieldComponent, TranslatePipe, ], diff --git a/libs/form-fields/src/lib/upload-attachment.ts b/libs/form-fields/src/lib/upload-attachment.ts new file mode 100644 index 00000000000..e7ed9ca5659 --- /dev/null +++ b/libs/form-fields/src/lib/upload-attachment.ts @@ -0,0 +1,32 @@ +import { uploadFiles } from '@placeos/cloud-uploads'; +import { Attachment, randomInt } from '@placeos/common'; +import { takeWhile } from 'rxjs/operators'; + +/** + * Upload a file for an attachment field. `on_change` receives the attachment + * details at the start and on each progress update. A failed upload has a + * progress of -1. + */ +export function uploadAttachment( + file: File, + on_change: (item: Attachment) => void, +) { + const upload = uploadFiles([file], { file_name: file.name })[0]; + const upload_details: Attachment = { + id: `${randomInt(9999_9999_9999)}`, + name: file.name, + progress: 0, + size: file.size, + created_at: Date.now(), + url: '', + }; + upload.status + .pipe(takeWhile((_) => _.status !== 'complete', true)) + .subscribe((state) => { + if (upload.access_url) upload_details.url = upload.access_url; + upload_details.progress = state.progress; + if (state.status === 'error') upload_details.progress = -1; + on_change(upload_details); + }); + on_change(upload_details); +} diff --git a/libs/form-fields/src/lib/upload-file-field.component.ts b/libs/form-fields/src/lib/upload-file-field.component.ts index f7b51b4efbc..e9f38797fed 100644 --- a/libs/form-fields/src/lib/upload-file-field.component.ts +++ b/libs/form-fields/src/lib/upload-file-field.component.ts @@ -1,13 +1,11 @@ import { Component, forwardRef, signal } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { Attachment, randomInt } from '@placeos/common'; -import { takeWhile } from 'rxjs/operators'; +import { Attachment } from '@placeos/common'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { uploadFiles } from '@placeos/cloud-uploads'; -import * as blobUtil from 'blob-util'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { uploadAttachment } from './upload-attachment'; @Component({ selector: 'upload-file', @@ -115,35 +113,6 @@ export class UploadFileFieldComponent implements ControlValueAccessor { /* istanbul ignore else */ if (!element?.files?.length) return; const files: FileList = element.files; - this._uploadFile(files[0], (item) => this.setValue(item)); - } - - private _uploadFile(file: File, on_change: (item: Attachment) => void) { - const fileReader = new FileReader(); - fileReader.addEventListener('loadend', (e: any) => { - const arrayBuffer = e.target.result; - const blob = blobUtil.arrayBufferToBlob(arrayBuffer, file.type); - const upload_list = uploadFiles([blob], { file_name: file.name }); - const upload = upload_list[0]; - const upload_details: Attachment = { - id: `${randomInt(9999_9999_9999)}`, - name: file.name, - progress: 0, - size: file.size, - created_at: Date.now(), - url: '', - }; - upload.status - .pipe(takeWhile((_) => _.status !== 'complete', true)) - .subscribe((state) => { - if (upload.access_url) - upload_details.url = upload.access_url; - upload_details.progress = state.progress; - if (state.status === 'error') upload_details.progress = -1; - on_change(upload_details); - }); - on_change(upload_details); - }); - fileReader.readAsArrayBuffer(file); + uploadAttachment(files[0], (item) => this.setValue(item)); } } diff --git a/libs/form-fields/src/lib/upload-list-field.component.ts b/libs/form-fields/src/lib/upload-list-field.component.ts index d2dd4c14e29..e719272d374 100644 --- a/libs/form-fields/src/lib/upload-list-field.component.ts +++ b/libs/form-fields/src/lib/upload-list-field.component.ts @@ -2,14 +2,11 @@ import { Component, forwardRef, signal } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { uploadFiles } from '@placeos/cloud-uploads'; import { Attachment, randomInt } from '@placeos/common'; -import { takeWhile } from 'rxjs/operators'; - -import * as blobUtil from 'blob-util'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; +import { uploadAttachment } from './upload-attachment'; @Component({ selector: 'upload-list', @@ -164,36 +161,7 @@ export class UploadListFieldComponent implements ControlValueAccessor { this.setValue(list); }; for (let i = 0; i < files.length; i++) { - this._uploadFile(files[i], on_change); + uploadAttachment(files[i], on_change); } } - - private _uploadFile(file: File, on_change: (item: Attachment) => void) { - const fileReader = new FileReader(); - fileReader.addEventListener('loadend', (e: any) => { - const arrayBuffer = e.target.result; - const blob = blobUtil.arrayBufferToBlob(arrayBuffer, file.type); - const upload_list = uploadFiles([blob], { file_name: file.name }); - const upload = upload_list[0]; - const upload_details: Attachment = { - id: `${randomInt(9999_9999_9999)}`, - name: file.name, - progress: 0, - size: file.size, - created_at: Date.now(), - url: '', - }; - upload.status - .pipe(takeWhile((_) => _.status !== 'complete', true)) - .subscribe((state) => { - if (upload.access_url) - upload_details.url = upload.access_url; - upload_details.progress = state.progress; - if (state.status === 'error') upload_details.progress = -1; - on_change(upload_details); - }); - on_change(upload_details); - }); - fileReader.readAsArrayBuffer(file); - } } diff --git a/libs/form-fields/src/tests/compact-counter.component.spec.ts b/libs/form-fields/src/tests/compact-counter.component.spec.ts deleted file mode 100644 index 044d8ea6c52..00000000000 --- a/libs/form-fields/src/tests/compact-counter.component.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { FormsModule } from '@angular/forms'; -import { createComponentFactory, Spectator } from '@ngneat/spectator/vitest'; -import { MockComponent } from 'ng-mocks'; - -import { IconComponent } from 'libs/components/src/lib/icon.component'; -import { CompactCounterComponent } from '../lib/compact-counter.component'; - -describe('CompactCounterComponent', () => { - let spectator: Spectator; - const createComponent = createComponentFactory({ - component: CompactCounterComponent, - declarations: [MockComponent(IconComponent)], - imports: [FormsModule], - }); - - beforeEach(() => { - spectator = createComponent(); - spectator.component.value.set(0); - spectator.detectChanges(); - }); - - it('should emit the value when a step is added', () => { - const on_change = vi.fn(); - spectator.component.registerOnChange(on_change); - spectator.click('button[increase]'); - expect(spectator.component.value()).toBe(1); - expect(on_change).toHaveBeenCalledWith(1); - }); - - it('should emit the value when a step is removed', () => { - const on_change = vi.fn(); - spectator.component.value.set(5); - spectator.detectChanges(); - spectator.component.registerOnChange(on_change); - spectator.click('button[decrease]'); - expect(spectator.component.value()).toBe(4); - expect(on_change).toHaveBeenCalledWith(4); - }); - - it('should not allow the value to exceed the bounds', () => { - spectator.setInput({ max: 3, min: 1 }); - spectator.component.value.set(3); - spectator.component.add(); - expect(spectator.component.value()).toBe(3); - spectator.component.value.set(1); - spectator.component.remove(); - expect(spectator.component.value()).toBe(1); - }); - - it('should apply modifier keys to the step size', () => { - spectator.setInput({ max: 1000 }); - spectator.component.value.set(5); - spectator.component.shift_key.set(true); - spectator.component.add(); - expect(spectator.component.value()).toBe(15); - spectator.component.ctrl_key.set(true); - spectator.component.add(); - expect(spectator.component.value()).toBe(115); - }); - - it('should snap values to the configured step', () => { - spectator.setInput({ step: 5, max: 100 }); - spectator.component.setValue(12); - expect(spectator.component.value()).toBe(10); - }); - - it('should render the value using the custom render function', () => { - spectator.setInput({ render_fn: (v: number) => `#${v}` }); - spectator.component.value.set(7); - spectator.component.focused.set(false); - spectator.detectChanges(); - expect(spectator.query('[value] span')).toContainText('#7'); - }); - - it('should write external values into the field', () => { - spectator.component.writeValue(42); - spectator.detectChanges(); - expect(spectator.component.value()).toBe(42); - expect(spectator.query('[value] span')).toContainText('42'); - }); - - it('should reflect the disabled state', () => { - spectator.component.setDisabledState(true); - spectator.detectChanges(); - expect(spectator.query('[counter]')).toHaveClass('disabled'); - }); -}); diff --git a/libs/form-fields/src/tests/counter.component.spec.ts b/libs/form-fields/src/tests/counter.component.spec.ts index 82dcab06b6d..79aa58f8a5b 100644 --- a/libs/form-fields/src/tests/counter.component.spec.ts +++ b/libs/form-fields/src/tests/counter.component.spec.ts @@ -84,4 +84,14 @@ describe('CounterComponent', () => { spectator.setInput({ input_id: 'capacity' }); expect('[value] input').toHaveAttribute('id', 'capacity'); }); + + it('should stack both step buttons after the value when compact', () => { + spectator.setInput({ compact: true }); + const buttons = spectator.queryAll('[counter] > div:last-child button'); + expect(buttons.map((_) => _.hasAttribute('increase'))).toEqual([ + true, + false, + ]); + expect(spectator.query('[counter] > button')).toBeNull(); + }); }); diff --git a/package.json b/package.json index ce222eb2f43..d6c3222a751 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,6 @@ "@vitest/coverage-v8": "^4.0.8", "angular-eslint": "22.2.0", "autoprefixer": "^10.4.16", - "blob-util": "^2.0.2", "chart.js": "^4.5.0", "date-fns": "^4.4.0", "date-fns-tz": "^3.2.0", From 705c396b19f8f0d441a8943f972c58b4358c789f Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:03 +1100 Subject: [PATCH 09/17] refactor(mocks): build asset CRUD mocks from one helper The 16 show/create/update/delete handlers in assets.mock.ts now come from one helper. Also removes the empty metadata fields from mock endpoints and merges the identical display and microphone modules. --- libs/mocks/src/lib/api/assets.mock.ts | 317 +++++--------------- libs/mocks/src/lib/api/bookings.mock.ts | 14 - libs/mocks/src/lib/api/calendars.mock.ts | 3 - libs/mocks/src/lib/api/events.mock.ts | 6 - libs/mocks/src/lib/api/signage.mock.ts | 52 ---- libs/mocks/src/lib/api/surveys.mock.ts | 7 - libs/mocks/src/lib/api/systems.mock.ts | 2 - libs/mocks/src/lib/api/users.mock.ts | 7 - libs/mocks/src/lib/api/zones.mock.ts | 8 - libs/mocks/src/lib/realtime/display.ts | 21 -- libs/mocks/src/lib/realtime/microphone.ts | 23 -- libs/mocks/src/lib/realtime/volume.ts | 15 + libs/mocks/src/lib/systems-bindings.mock.ts | 9 +- libs/mocks/src/tests/assets.mock.spec.ts | 42 +++ 14 files changed, 136 insertions(+), 390 deletions(-) delete mode 100644 libs/mocks/src/lib/realtime/display.ts delete mode 100644 libs/mocks/src/lib/realtime/microphone.ts create mode 100644 libs/mocks/src/lib/realtime/volume.ts create mode 100644 libs/mocks/src/tests/assets.mock.spec.ts diff --git a/libs/mocks/src/lib/api/assets.mock.ts b/libs/mocks/src/lib/api/assets.mock.ts index 9f7d34b7630..edf2d290432 100644 --- a/libs/mocks/src/lib/api/assets.mock.ts +++ b/libs/mocks/src/lib/api/assets.mock.ts @@ -13,230 +13,114 @@ import { MOCK_LEVELS } from './zone.data'; const BASE_PATH = '/api/engine/v2'; -const update = (dataset) => (id, data) => { - const index = dataset.findIndex((e) => e.id === id); - if (index < 0) - throw { - status: 404, - message: `Unable to find booking with ID ${id}`, - }; - const new_event = { ...data }; - dataset.splice(index, 1, new_event); - return new_event; -}; - -export function registerMockAssets() { - registerMockEndpoint({ - path: `${BASE_PATH}/asset_categories`, - metadata: {}, - method: 'GET', - callback: (req) => { - let results = MOCK_CATEGORIES; - if (req.query_params?.hidden !== undefined) { - const hidden = String(req.query_params.hidden) === 'true'; - results = results.filter((c) => !!c.hidden === hidden); - } - return results; - }, - }); - +/** + * Register the show, create, update and delete endpoints for a mock dataset. + * The list endpoint differs per resource, so callers register it themselves. + */ +function registerCrudEndpoints( + path: string, + dataset: any[], + id_prefix: string, + label: string, +) { + const indexOf = (id: string) => { + const index = dataset.findIndex((e) => e.id === id); + if (index < 0) + throw { + status: 404, + message: `Unable to find ${label} with ID ${id}`, + }; + return index; + }; registerMockEndpoint({ - path: `${BASE_PATH}/asset_categories/:id`, - metadata: {}, + path: `${path}/:id`, method: 'GET', - callback: (_) => { - const event = MOCK_CATEGORIES.find( - (e) => e.id === _.route_params.id, - ); - if (!event) - throw { - status: 404, - message: `Unable to find booking with ID ${_.route_params.id}`, - }; - return event; - }, + callback: (req) => dataset[indexOf(req.route_params.id)], }); - registerMockEndpoint({ - path: `${BASE_PATH}/asset_categories`, - metadata: {}, + path, method: 'POST', - callback: (request) => { - const new_event = { - ...request.body, - id: `-category-${predictableRandomInt(999)}`, + callback: (req) => { + const item = { + ...req.body, + id: `${id_prefix}${predictableRandomInt(999)}`, }; - MOCK_CATEGORIES.push(new_event); - return new_event; + dataset.push(item); + return item; }, }); - registerMockEndpoint({ - path: `${BASE_PATH}/asset_categories/:id`, - metadata: {}, + path: `${path}/:id`, method: 'PUT', - callback: (req) => - update(MOCK_CATEGORIES)(req.route_params.id, { ...req.body }), + callback: (req) => { + const item = { ...req.body }; + dataset.splice(indexOf(req.route_params.id), 1, item); + return item; + }, }); - registerMockEndpoint({ - path: `${BASE_PATH}/asset_categories/:id`, - metadata: {}, + path: `${path}/:id`, method: 'DELETE', callback: (req) => { - const index = MOCK_CATEGORIES.findIndex( - (e) => e.id === req.route_params.id, - ); - if (index < 0) - throw { - status: 404, - message: `Unable to find booking with ID ${req.route_params.id}`, - }; - MOCK_CATEGORIES.splice(index, 1); - return; + dataset.splice(indexOf(req.route_params.id), 1); }, }); +} +export function registerMockAssets() { registerMockEndpoint({ - path: `${BASE_PATH}/asset_types`, - metadata: {}, + path: `${BASE_PATH}/asset_categories`, method: 'GET', callback: (req) => { - let results = MOCK_PRODUCTS; - if (req.query_params?.category_id) { - results = results.filter( - (p) => p.category_id === req.query_params.category_id, - ); + let results = MOCK_CATEGORIES; + if (req.query_params?.hidden !== undefined) { + const hidden = String(req.query_params.hidden) === 'true'; + results = results.filter((c) => !!c.hidden === hidden); } return results; }, }); - - registerMockEndpoint({ - path: `${BASE_PATH}/asset_types/:id`, - metadata: {}, - method: 'GET', - callback: (_) => { - const event = MOCK_PRODUCTS.find((e) => e.id === _.route_params.id); - if (!event) - throw { - status: 404, - message: `Unable to find booking with ID ${_.route_params.id}`, - }; - return event; - }, - }); + registerCrudEndpoints( + `${BASE_PATH}/asset_categories`, + MOCK_CATEGORIES, + '-category-', + 'asset category', + ); registerMockEndpoint({ path: `${BASE_PATH}/asset_types`, - metadata: {}, - method: 'POST', - callback: (request) => { - const new_event = { - ...request.body, - id: `-product-${predictableRandomInt(999)}`, - }; - MOCK_PRODUCTS.push(new_event); - return new_event; - }, - }); - - registerMockEndpoint({ - path: `${BASE_PATH}/asset_types/:id`, - metadata: {}, - method: 'PUT', - callback: (req) => - update(MOCK_PRODUCTS)(req.route_params.id, { ...req.body }), - }); - - registerMockEndpoint({ - path: `${BASE_PATH}/asset_types/:id`, - metadata: {}, - method: 'DELETE', + method: 'GET', callback: (req) => { - const index = MOCK_PRODUCTS.findIndex( - (e) => e.id === req.route_params.id, - ); - if (index < 0) - throw { - status: 404, - message: `Unable to find booking with ID ${req.route_params.id}`, - }; - MOCK_PRODUCTS.splice(index, 1); - return; + let results = MOCK_PRODUCTS; + if (req.query_params?.category_id) { + results = results.filter( + (p) => p.category_id === req.query_params.category_id, + ); + } + return results; }, }); + registerCrudEndpoints( + `${BASE_PATH}/asset_types`, + MOCK_PRODUCTS, + '-product-', + 'asset type', + ); registerMockEndpoint({ path: `${BASE_PATH}/asset_purchase_orders`, - metadata: {}, method: 'GET', - callback: (_) => { - const events = MOCK_PURCHASE_ORDERS; - return events; - }, - }); - - registerMockEndpoint({ - path: `${BASE_PATH}/asset_purchase_orders/:id`, - metadata: {}, - method: 'GET', - callback: (_) => { - const event = MOCK_PURCHASE_ORDERS.find( - (e) => e.id === _.route_params.id, - ); - if (!event) - throw { - status: 404, - message: `Unable to find booking with ID ${_.route_params.id}`, - }; - return event; - }, - }); - - registerMockEndpoint({ - path: `${BASE_PATH}/asset_purchase_orders`, - metadata: {}, - method: 'POST', - callback: (request) => { - const new_event = { - ...request.body, - id: `-purchase-order-${predictableRandomInt(999)}`, - }; - MOCK_PURCHASE_ORDERS.push(new_event); - return new_event; - }, - }); - - registerMockEndpoint({ - path: `${BASE_PATH}/asset_purchase_orders/:id`, - metadata: {}, - method: 'PUT', - callback: (req) => - update(MOCK_PURCHASE_ORDERS)(req.route_params.id, { ...req.body }), - }); - - registerMockEndpoint({ - path: `${BASE_PATH}/asset_purchase_orders/:id`, - metadata: {}, - method: 'DELETE', - callback: (req) => { - const index = MOCK_PURCHASE_ORDERS.findIndex( - (e) => e.id === req.route_params.id, - ); - if (index < 0) - throw { - status: 404, - message: `Unable to find purchase order with ID ${req.route_params.id}`, - }; - MOCK_PURCHASE_ORDERS.splice(index, 1); - return; - }, + callback: () => MOCK_PURCHASE_ORDERS, }); + registerCrudEndpoints( + `${BASE_PATH}/asset_purchase_orders`, + MOCK_PURCHASE_ORDERS, + '-purchase-order-', + 'purchase order', + ); registerMockEndpoint({ path: `${BASE_PATH}/assets`, - metadata: {}, method: 'GET', callback: (req) => { const type_id = req.query_params?.type_id; @@ -276,59 +160,10 @@ export function registerMockAssets() { return results; }, }); - - registerMockEndpoint({ - path: `${BASE_PATH}/assets/:id`, - metadata: {}, - method: 'GET', - callback: (_) => { - const event = MOCK_ASSETS.find((e) => e.id === _.route_params.id); - if (!event) - throw { - status: 404, - message: `Unable to find booking with ID ${_.route_params.id}`, - }; - return event; - }, - }); - - registerMockEndpoint({ - path: `${BASE_PATH}/assets`, - metadata: {}, - method: 'POST', - callback: (request) => { - const new_event = { - ...request.body, - id: `-asset-${predictableRandomInt(999)}`, - }; - MOCK_ASSETS.push(new_event); - return new_event; - }, - }); - - registerMockEndpoint({ - path: `${BASE_PATH}/assets/:id`, - metadata: {}, - method: 'PUT', - callback: (req) => - update(MOCK_ASSETS)(req.route_params.id, { ...req.body }), - }); - - registerMockEndpoint({ - path: `${BASE_PATH}/assets/:id`, - metadata: {}, - method: 'DELETE', - callback: (req) => { - const index = MOCK_ASSETS.findIndex( - (e) => e.id === req.route_params.id, - ); - if (index < 0) - throw { - status: 404, - message: `Unable to find booking with ID ${req.route_params.id}`, - }; - MOCK_ASSETS.splice(index, 1); - return; - }, - }); + registerCrudEndpoints( + `${BASE_PATH}/assets`, + MOCK_ASSETS, + '-asset-', + 'asset', + ); } diff --git a/libs/mocks/src/lib/api/bookings.mock.ts b/libs/mocks/src/lib/api/bookings.mock.ts index e41cd346126..6fe0afcc36b 100644 --- a/libs/mocks/src/lib/api/bookings.mock.ts +++ b/libs/mocks/src/lib/api/bookings.mock.ts @@ -11,7 +11,6 @@ const ALL_BOOKINGS = [...MOCK_BOOKINGS, ...MOCK_CATERING_BOOKINGS]; export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings', - metadata: {}, method: 'GET', callback: (_) => { let events = ALL_BOOKINGS; @@ -88,7 +87,6 @@ export function registerMockBookings() { // Debug endpoint to show booking distribution by user registerMockEndpoint({ path: '/api/debug/bookings/distribution', - metadata: {}, method: 'GET', callback: (_) => { const distribution = {}; @@ -124,7 +122,6 @@ export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings/:id', - metadata: {}, method: 'GET', callback: (_) => { const event = ALL_BOOKINGS.find( @@ -141,7 +138,6 @@ export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings/:id/guests/:email', - metadata: {}, method: 'POST', callback: (_) => { const event = ALL_BOOKINGS.find( @@ -165,7 +161,6 @@ export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings/:id/guests/:email', - metadata: {}, method: 'DELETE', callback: (_) => { const { id, email } = _.route_params; @@ -191,7 +186,6 @@ export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings/:id/guests/:email/checkin', - metadata: {}, method: 'POST', callback: (_) => { const { id, email } = _.route_params; @@ -211,7 +205,6 @@ export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings', - metadata: {}, method: 'POST', callback: (request) => { const new_event = { @@ -237,14 +230,12 @@ export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings/:id', - metadata: {}, method: 'PATCH', callback: (req) => updateBooking(req.route_params.id, req.body), }); registerMockEndpoint({ path: '/api/staff/v1/bookings/:id/approve', - metadata: {}, method: 'POST', callback: (req) => { const booking = ALL_BOOKINGS.find( @@ -263,7 +254,6 @@ export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings/:id/reject', - metadata: {}, method: 'POST', callback: (req) => { const booking = ALL_BOOKINGS.find( @@ -282,7 +272,6 @@ export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings/:id/check_in', - metadata: {}, method: 'POST', callback: (req) => { const booking = ALL_BOOKINGS.find( @@ -308,7 +297,6 @@ export function registerMockBookings() { // Update induction status endpoint for visitor-kiosk registerMockEndpoint({ path: '/api/staff/v1/bookings/:id/update_induction', - metadata: {}, method: 'POST', callback: (req) => { const booking = ALL_BOOKINGS.find( @@ -327,14 +315,12 @@ export function registerMockBookings() { registerMockEndpoint({ path: '/api/staff/v1/bookings/:id', - metadata: {}, method: 'PUT', callback: (req) => updateBooking(req.route_params.id, req.body), }); registerMockEndpoint({ path: '/api/staff/v1/bookings/:id', - metadata: {}, method: 'DELETE', callback: (req) => { const booking = ALL_BOOKINGS.find( diff --git a/libs/mocks/src/lib/api/calendars.mock.ts b/libs/mocks/src/lib/api/calendars.mock.ts index 5ba48ecf55f..dc4f218c4f4 100644 --- a/libs/mocks/src/lib/api/calendars.mock.ts +++ b/libs/mocks/src/lib/api/calendars.mock.ts @@ -9,7 +9,6 @@ import { ACTIVE_USER, MOCK_STAFF } from './users.data'; export function registerMockCalendars() { registerMockEndpoint({ path: '/api/staff/v1/calendars', - metadata: {}, method: 'GET', callback: (_request) => { const user = MOCK_STAFF[predictableRandomInt(MOCK_STAFF.length)]; @@ -89,7 +88,6 @@ export function registerMockCalendars() { registerMockEndpoint({ path: '/api/staff/v1/calendars/availability', - metadata: {}, method: 'GET', callback: (request) => handleSpaceAvailability(request).map((_) => ({ @@ -99,7 +97,6 @@ export function registerMockCalendars() { registerMockEndpoint({ path: '/api/staff/v1/calendars/free_busy', - metadata: {}, method: 'GET', callback: (request) => handleSpaceAvailability(request).map((_) => ({ diff --git a/libs/mocks/src/lib/api/events.mock.ts b/libs/mocks/src/lib/api/events.mock.ts index 87add1652d5..296d259661a 100644 --- a/libs/mocks/src/lib/api/events.mock.ts +++ b/libs/mocks/src/lib/api/events.mock.ts @@ -5,7 +5,6 @@ import { ACTIVE_USER, MOCK_STAFF } from './users.data'; export function registerMockEvents() { registerMockEndpoint({ path: '/api/staff/v1/events', - metadata: {}, method: 'GET', callback: (_) => { let events = MOCK_EVENTS; @@ -41,7 +40,6 @@ export function registerMockEvents() { registerMockEndpoint({ path: '/api/staff/v1/events', - metadata: {}, method: 'POST', callback: (request) => { const new_event = { @@ -72,7 +70,6 @@ export function registerMockEvents() { registerMockEndpoint({ path: '/api/staff/v1/events/:id', - metadata: {}, method: 'GET', callback: (request) => { const item = MOCK_EVENTS.find( @@ -87,7 +84,6 @@ export function registerMockEvents() { registerMockEndpoint({ path: '/api/staff/v1/events/:id', - metadata: {}, method: 'DELETE', callback: (request) => { const index = MOCK_EVENTS.findIndex( @@ -110,7 +106,6 @@ export function registerMockEvents() { registerMockEndpoint({ path: '/api/staff/v1/events/:id', - metadata: {}, method: 'PATCH', callback: (request) => { const index = MOCK_EVENTS.findIndex( @@ -131,7 +126,6 @@ export function registerMockEvents() { registerMockEndpoint({ path: '/api/staff/v1/events/:id/guests/:email/checkin', - metadata: {}, method: 'POST', callback: (request) => { const event = MOCK_EVENTS.find( diff --git a/libs/mocks/src/lib/api/signage.mock.ts b/libs/mocks/src/lib/api/signage.mock.ts index ae6bd292f0d..87ec565cc59 100644 --- a/libs/mocks/src/lib/api/signage.mock.ts +++ b/libs/mocks/src/lib/api/signage.mock.ts @@ -985,7 +985,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/groups/current', - metadata: {}, method: 'GET', callback: (request) => { if (request.query_params?.subsystem === 'signage') { @@ -997,14 +996,12 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/groups', - metadata: {}, method: 'GET', callback: (request) => listSignageMockGroups(request.query_params), }); registerMockEndpoint({ path: '/api/engine/v2/groups', - metadata: {}, method: 'POST', callback: (request) => { const group = { @@ -1021,7 +1018,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/groups/:id', - metadata: {}, method: 'PATCH', callback: (request) => { const item = SIGNAGE_GROUPS.find( @@ -1039,7 +1035,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/groups/:id', - metadata: {}, method: 'GET', callback: (request) => { const item = SIGNAGE_GROUPS.find( @@ -1053,7 +1048,6 @@ export function registerMockSignage() { // Effective features: each group's own flags merged over its ancestors' registerMockEndpoint({ path: '/api/engine/v2/groups/:id/features', - metadata: {}, method: 'GET', callback: (request) => { const groups = SIGNAGE_GROUPS.map(({ group }) => group); @@ -1082,7 +1076,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/groups/:id', - metadata: {}, method: 'DELETE', callback: (request) => { const index = SIGNAGE_GROUPS.findIndex( @@ -1095,7 +1088,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/staff/v1/groups', - metadata: {}, method: 'GET', callback: (request) => { const q = (request.query_params?.q || '').toLowerCase(); @@ -1110,7 +1102,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/group_users', - metadata: {}, method: 'GET', callback: (request) => SIGNAGE_GROUP_USERS.filter( @@ -1120,7 +1111,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/group_users', - metadata: {}, method: 'POST', callback: (request) => { const user = MOCK_STAFF.find( @@ -1148,7 +1138,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/group_users/:user_id/:group_id', - metadata: {}, method: 'PATCH', callback: (request) => { const user_id = decodeURIComponent(request.route_params.user_id); @@ -1166,7 +1155,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/group_users/:user_id/:group_id', - metadata: {}, method: 'DELETE', callback: (request) => { const user_id = decodeURIComponent(request.route_params.user_id); @@ -1181,7 +1169,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/group_zones', - metadata: {}, method: 'GET', callback: (request) => SIGNAGE_GROUP_ZONES.filter( @@ -1191,7 +1178,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/group_zones', - metadata: {}, method: 'POST', callback: (request) => { const zone = MOCK_ZONES.find( @@ -1212,7 +1198,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/group_zones/:group_id/:zone_id', - metadata: {}, method: 'PATCH', callback: (request) => { const group_id = decodeURIComponent(request.route_params.group_id); @@ -1230,7 +1215,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/group_zones/:group_id/:zone_id', - metadata: {}, method: 'DELETE', callback: (request) => { const group_id = decodeURIComponent(request.route_params.group_id); @@ -1245,7 +1229,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/media', - metadata: {}, method: 'GET', callback: (request) => filterByGroup(MOCK_MEDIA, request.query_params?.group_id).map( @@ -1255,7 +1238,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/media/tags', - metadata: {}, method: 'GET', callback: (request) => [ ...new Set( @@ -1268,7 +1250,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/media/tag_counts', - metadata: {}, method: 'GET', callback: (request) => { const counts: Record = {}; @@ -1287,7 +1268,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/media', - metadata: {}, method: 'POST', callback: (request) => ({ ...request.body, @@ -1299,7 +1279,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/media/:id', - metadata: {}, method: 'GET', callback: (request) => ({ ...toEngineMedia( @@ -1311,7 +1290,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/media/:id', - metadata: {}, method: 'PATCH', callback: (request) => ({ ...toEngineMedia( @@ -1324,14 +1302,12 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/media/:id', - metadata: {}, method: 'DELETE', callback: () => ({}), }); registerMockEndpoint({ path: '/api/engine/v2/signage/media/:id/thumbnail', - metadata: {}, method: 'GET', callback: () => ({}), }); @@ -1339,7 +1315,6 @@ export function registerMockSignage() { // Webpage and plugin thumbnails start from a server side screenshot registerMockEndpoint({ path: '/api/engine/v2/uploads/screenshot', - metadata: {}, method: 'POST', callback: (request) => ({ id: `upload-screenshot-${Date.now()}`, @@ -1351,28 +1326,24 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/uploads/:id', - metadata: {}, method: 'DELETE', callback: () => ({}), }); registerMockEndpoint({ path: '/api/engine/v2/signage/media/share', - metadata: {}, method: 'POST', callback: () => ({}), }); registerMockEndpoint({ path: '/api/engine/v2/signage/plugins', - metadata: {}, method: 'GET', callback: () => MOCK_PLUGINS, }); registerMockEndpoint({ path: '/api/engine/v2/signage/plugins/:id', - metadata: {}, method: 'GET', callback: (request) => MOCK_PLUGINS.find( @@ -1382,7 +1353,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists', - metadata: {}, method: 'GET', callback: (request) => filterByGroup(MOCK_PLAYLISTS, request.query_params?.group_id).map( @@ -1392,7 +1362,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/approvers', - metadata: {}, method: 'GET', callback: (request) => SIGNAGE_GROUP_USERS.filter( @@ -1405,7 +1374,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists', - metadata: {}, method: 'POST', callback: (request) => ({ ...request.body, @@ -1417,7 +1385,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id', - metadata: {}, method: 'GET', callback: (request) => ({ ...toEnginePlaylist( @@ -1434,7 +1401,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id', - metadata: {}, method: 'PATCH', callback: (request) => ({ ...toEnginePlaylist( @@ -1449,14 +1415,12 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id', - metadata: {}, method: 'DELETE', callback: () => ({}), }); registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id/media', - metadata: {}, method: 'GET', callback: (request) => playlistMediaResponse(request.route_params.id, false), @@ -1464,7 +1428,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id/media', - metadata: {}, method: 'POST', callback: (request) => ({ ...playlistMediaResponse(request.route_params.id, false), @@ -1475,7 +1438,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id/media/schedule', - metadata: {}, method: 'POST', callback: (request) => ({ ...playlistMediaResponse(request.route_params.id, false), @@ -1491,7 +1453,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id/media/schedule/:item_id', - metadata: {}, method: 'PATCH', callback: (request) => ({ id: request.route_params.item_id, @@ -1502,7 +1463,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id/media/revisions', - metadata: {}, method: 'GET', callback: (request) => [ playlistMediaResponse(request.route_params.id, false), @@ -1512,7 +1472,6 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id/media/approve', - metadata: {}, method: 'POST', callback: (request) => playlistMediaResponse(request.route_params.id, true), @@ -1520,28 +1479,24 @@ export function registerMockSignage() { registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/:id/media/request_approval', - metadata: {}, method: 'POST', callback: () => ({}), }); registerMockEndpoint({ path: '/api/engine/v2/signage/playlists/share', - metadata: {}, method: 'POST', callback: () => ({}), }); registerMockEndpoint({ path: '/api/engine/v2/signage/:id', - metadata: {}, method: 'GET', callback: (request) => signageDisplay(request.route_params.id), }); registerMockEndpoint({ path: '/api/engine/v2/signage/:id/metrics', - metadata: {}, method: 'POST', callback: () => ({}), }); @@ -1659,7 +1614,6 @@ function registerMockSignageImageGen() { registerMockEndpoint({ path: '/api/engine/v2/signage/ai/capabilities', - metadata: {}, method: 'GET', callback: () => ({ enabled: true, @@ -1695,28 +1649,24 @@ function registerMockSignageImageGen() { registerMockEndpoint({ path: '/api/engine/v2/signage/ai/generate', - metadata: {}, method: 'POST', callback: (request) => makeJob(request.body || {}, 'generate'), }); registerMockEndpoint({ path: '/api/engine/v2/signage/ai/edit', - metadata: {}, method: 'POST', callback: (request) => makeJob(request.body || {}, 'edit'), }); registerMockEndpoint({ path: '/api/engine/v2/signage/ai/jobs', - metadata: {}, method: 'GET', callback: () => Object.values(IMAGE_GEN_JOBS), }); registerMockEndpoint({ path: '/api/engine/v2/signage/ai/jobs/:id', - metadata: {}, method: 'GET', callback: (request) => { const job = IMAGE_GEN_JOBS[request.route_params.id]; @@ -1727,7 +1677,6 @@ function registerMockSignageImageGen() { registerMockEndpoint({ path: '/api/engine/v2/signage/ai/jobs/:id/cancel', - metadata: {}, method: 'POST', callback: (request) => { const job = IMAGE_GEN_JOBS[request.route_params.id]; @@ -1742,7 +1691,6 @@ function registerMockSignageImageGen() { registerMockEndpoint({ path: '/api/engine/v2/signage/ai/jobs/:id/claim', - metadata: {}, method: 'POST', callback: (request) => { const job = IMAGE_GEN_JOBS[request.route_params.id]; diff --git a/libs/mocks/src/lib/api/surveys.mock.ts b/libs/mocks/src/lib/api/surveys.mock.ts index d37b796c48d..f5b20554a00 100644 --- a/libs/mocks/src/lib/api/surveys.mock.ts +++ b/libs/mocks/src/lib/api/surveys.mock.ts @@ -579,7 +579,6 @@ export const MOCK_ANSWERS: SurveyAnswer[] = [ export function registerMockSurveys() { registerMockEndpoint({ path: '/api/staff/v1/surveys', - metadata: {}, method: 'GET', callback: (request) => { const { query_params } = request; @@ -612,7 +611,6 @@ export function registerMockSurveys() { registerMockEndpoint({ path: '/api/staff/v1/surveys/questions', - metadata: {}, method: 'GET', callback: (request) => { const { query_params } = request; @@ -655,7 +653,6 @@ export function registerMockSurveys() { registerMockEndpoint({ path: '/api/staff/v1/surveys/questions/:id', - metadata: {}, method: 'GET', delay: 50, delay_variance: 10, @@ -675,7 +672,6 @@ export function registerMockSurveys() { registerMockEndpoint({ path: '/api/staff/v1/surveys/answers', - metadata: {}, method: 'GET', callback: (request) => { const { query_params } = request; @@ -710,7 +706,6 @@ export function registerMockSurveys() { registerMockEndpoint({ path: '/api/staff/v1/surveys/answers/:id', - metadata: {}, method: 'GET', callback: (request) => { const answerId = parseInt(request.route_params?.id as string); @@ -726,7 +721,6 @@ export function registerMockSurveys() { registerMockEndpoint({ path: '/api/staff/v1/surveys/:id', - metadata: {}, method: 'GET', delay: 50, delay_variance: 10, @@ -745,7 +739,6 @@ export function registerMockSurveys() { // POST endpoint for submitting survey answers registerMockEndpoint({ path: '/api/staff/v1/surveys/answers', - metadata: {}, method: 'POST', delay: 50, delay_variance: 10, diff --git a/libs/mocks/src/lib/api/systems.mock.ts b/libs/mocks/src/lib/api/systems.mock.ts index 954fa58437c..6180b84fc4a 100644 --- a/libs/mocks/src/lib/api/systems.mock.ts +++ b/libs/mocks/src/lib/api/systems.mock.ts @@ -8,7 +8,6 @@ export function registerMockSystems() { registerMockEndpoint({ path: '/api/engine/v2/systems', - metadata: {}, method: 'GET', callback: (request) => { const systems = request.query_params?.zone_id @@ -31,7 +30,6 @@ export function registerMockSystems() { registerMockEndpoint({ path: '/api/engine/v2/systems/:id', - metadata: {}, method: 'GET', callback: (request) => { const space = MOCK_SPACES.find( diff --git a/libs/mocks/src/lib/api/users.mock.ts b/libs/mocks/src/lib/api/users.mock.ts index fa8f3271f10..765c6f100d6 100644 --- a/libs/mocks/src/lib/api/users.mock.ts +++ b/libs/mocks/src/lib/api/users.mock.ts @@ -11,7 +11,6 @@ import { MOCK_EVENTS } from './events.data'; export function registerMockUsers() { registerMockEndpoint({ path: '/api/engine/v2/users', - metadata: {}, method: 'GET', callback: (request) => { const search = (request.query_params.q || '').toLowerCase(); @@ -28,7 +27,6 @@ export function registerMockUsers() { registerMockEndpoint({ path: '/api/engine/v2/users/:id', - metadata: {}, method: 'GET', callback: (request) => { if (request.route_params.id === 'current') { @@ -53,7 +51,6 @@ export function registerMockUsers() { registerMockEndpoint({ path: '/api/staff/v1/people', - metadata: {}, method: 'GET', callback: (request) => { if (request.query_params.q) { @@ -71,7 +68,6 @@ export function registerMockUsers() { registerMockEndpoint({ path: '/api/staff/v1/people/:id', - metadata: {}, method: 'GET', callback: (request) => { const id = decodeURIComponent(request.route_params.id); @@ -88,7 +84,6 @@ export function registerMockUsers() { registerMockEndpoint({ path: '/api/staff/v1/guests', - metadata: {}, method: 'GET', callback: (request) => { if (request.query_params.q) { @@ -105,7 +100,6 @@ export function registerMockUsers() { registerMockEndpoint({ path: '/api/staff/v1/guests/:email', - metadata: {}, method: 'GET', callback: (request) => { const email = decodeURIComponent(request.route_params.email); @@ -117,7 +111,6 @@ export function registerMockUsers() { registerMockEndpoint({ path: '/api/staff/v1/guests/:email/meetings', - metadata: {}, method: 'GET', callback: (request) => { if (request.route_params.email) { diff --git a/libs/mocks/src/lib/api/zones.mock.ts b/libs/mocks/src/lib/api/zones.mock.ts index d1176a912cb..75d6077e0b6 100644 --- a/libs/mocks/src/lib/api/zones.mock.ts +++ b/libs/mocks/src/lib/api/zones.mock.ts @@ -59,7 +59,6 @@ export function registerMockZones() { registerMockEndpoint({ path: '/api/engine/v2/zones', - metadata: {}, method: 'GET', callback: (request) => { let zones = all_zones(); @@ -82,7 +81,6 @@ export function registerMockZones() { registerMockEndpoint({ path: '/api/engine/v2/zones/:id', - metadata: {}, method: 'GET', callback: (request) => { const zone = all_zones().find( @@ -100,21 +98,18 @@ export function registerMockZones() { registerMockEndpoint({ path: '/api/engine/v2/settings', - metadata: {}, method: 'GET', callback: (_request) => [], }); registerMockEndpoint({ path: '/api/engine/v2/settings/:id', - metadata: {}, method: 'GET', callback: (_request) => ({}), }); registerMockEndpoint({ path: '/api/engine/v2/metadata/:id', - metadata: {}, method: 'GET', callback: (request) => { if (request.query_params.name === 'bindings') { @@ -239,7 +234,6 @@ export function registerMockZones() { registerMockEndpoint({ path: '/api/engine/v2/metadata/:id', - metadata: {}, method: 'PATCH', callback: (request) => { if (!MOCK_METADATA[request.route_params.id]) @@ -252,7 +246,6 @@ export function registerMockZones() { registerMockEndpoint({ path: '/api/engine/v2/metadata/:id', - metadata: {}, method: 'PUT', callback: (request) => { if (!MOCK_METADATA[request.route_params.id]) @@ -322,7 +315,6 @@ export function registerMockZones() { registerMockEndpoint({ path: '/api/engine/v2/metadata/:id/children', - metadata: {}, method: 'GET', callback: (request) => { const zone = MOCK_BUILDINGS.find( diff --git a/libs/mocks/src/lib/realtime/display.ts b/libs/mocks/src/lib/realtime/display.ts deleted file mode 100644 index 2824829a3f0..00000000000 --- a/libs/mocks/src/lib/realtime/display.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { HashMap, randomInt } from '@placeos/common'; - -class DisplayModule { - mute: boolean; - volume: number; - - $mute(state: boolean = true) { - this.mute = state; - } - - $volume(value: number) { - this.volume = value; - } - - constructor(_) { - this.volume = randomInt(100); - } -} - -export const createDisplayModule = (space: HashMap, overrides: HashMap = {}) => - new DisplayModule({ ...space, ...overrides }); diff --git a/libs/mocks/src/lib/realtime/microphone.ts b/libs/mocks/src/lib/realtime/microphone.ts deleted file mode 100644 index 31c4277a3da..00000000000 --- a/libs/mocks/src/lib/realtime/microphone.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { HashMap, randomInt } from '@placeos/common'; - -class MicrophoneModule { - mute: boolean; - volume: number; - - $mute(state: boolean = true) { - this.mute = state; - } - - $volume(value: number) { - this.volume = value; - } - - constructor(_) { - this.volume = randomInt(100); - } -} - -export const createMicrophoneModule = ( - space: HashMap, - overrides: HashMap = {}, -) => new MicrophoneModule({ ...space, ...overrides }); diff --git a/libs/mocks/src/lib/realtime/volume.ts b/libs/mocks/src/lib/realtime/volume.ts new file mode 100644 index 00000000000..621b8f23060 --- /dev/null +++ b/libs/mocks/src/lib/realtime/volume.ts @@ -0,0 +1,15 @@ +import { randomInt } from '@placeos/common'; + +/** Mock for modules with mute and volume controls (Display and Microphone) */ +export class VolumeModule { + mute: boolean; + volume = randomInt(100); + + $mute(state: boolean = true) { + this.mute = state; + } + + $volume(value: number) { + this.volume = value; + } +} diff --git a/libs/mocks/src/lib/systems-bindings.mock.ts b/libs/mocks/src/lib/systems-bindings.mock.ts index db2dad7086b..d0396ee5857 100644 --- a/libs/mocks/src/lib/systems-bindings.mock.ts +++ b/libs/mocks/src/lib/systems-bindings.mock.ts @@ -9,13 +9,12 @@ import { createAreaManagementModule, MockLocationServicesModule, } from './realtime/desks'; -import { createDisplayModule } from './realtime/display'; import { createBookingsModule } from './realtime/events'; import { createLockerLocationsModule } from './realtime/locker-locations'; import { createMeetingPushModule } from './realtime/meeting'; -import { createMicrophoneModule } from './realtime/microphone'; import { createPaymentsModule } from './realtime/payments'; import { createVideoConferenceModule } from './realtime/video-conference'; +import { VolumeModule } from './realtime/volume'; export function createSystem(space: HashMap) { registerSystem(space.id, { @@ -25,10 +24,8 @@ export function createSystem(space: HashMap) { AreaManagement: [createAreaManagementModule(space)], LocationServices: [new MockLocationServicesModule()], Camera: new Array(10).fill(0).map((_) => createCameraModule(space)), - Display: new Array(10).fill(0).map((_) => createDisplayModule(space)), - Microphone: new Array(10) - .fill(0) - .map((_) => createMicrophoneModule(space)), + Display: new Array(10).fill(0).map(() => new VolumeModule()), + Microphone: new Array(10).fill(0).map(() => new VolumeModule()), Capture: [createCaptureModule(space)], MeetingPush: [createMeetingPushModule()], VidConf: [createVideoConferenceModule()], diff --git a/libs/mocks/src/tests/assets.mock.spec.ts b/libs/mocks/src/tests/assets.mock.spec.ts new file mode 100644 index 00000000000..b12040d39f5 --- /dev/null +++ b/libs/mocks/src/tests/assets.mock.spec.ts @@ -0,0 +1,42 @@ +import { registerMockEndpoint } from '@placeos/ts-client'; + +import { registerMockAssets } from '../lib/api/assets.mock'; + +vi.mock('@placeos/ts-client'); + +describe('registerMockAssets', () => { + /** Callback registered for the given method + path */ + function handler(method: string, path: string) { + const match = vi + .mocked(registerMockEndpoint) + .mock.calls.map(([options]) => options) + .find((o) => o.method === method && o.path === path); + if (!match) throw new Error(`No handler for ${method} ${path}`); + return (req: any) => match.callback(req); + } + + beforeEach(() => { + vi.mocked(registerMockEndpoint).mockClear(); + registerMockAssets(); + }); + + it('should create, show, update and delete an asset type', () => { + const path = '/api/engine/v2/asset_types'; + const created = handler('POST', path)({ body: { name: 'Chair' } }); + const route_params = { id: created.id }; + + expect(handler('GET', `${path}/:id`)({ route_params })).toBe(created); + const updated = handler( + 'PUT', + `${path}/:id`, + )({ + route_params, + body: { ...created, name: 'Desk' }, + }); + expect(handler('GET', `${path}/:id`)({ route_params })).toBe(updated); + handler('DELETE', `${path}/:id`)({ route_params }); + expect(() => + handler('GET', `${path}/:id`)({ route_params }), + ).toThrowError(); + }); +}); From c0d78dd2c6b819f1d02a074a8d63d9cb511d435c Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:03 +1100 Subject: [PATCH 10/17] refactor(libs): share the zone rules loader and app version details Catering and assets each had the same cached zone rules loader, and bookings and events each had the same app name and version stamp. Each is now one helper. Also removes padString, which copied padLength, and simplifies padLength and flatten with native methods. --- libs/assets/src/lib/asset.utilities.ts | 23 ++++---------- libs/bookings/src/lib/bookings.fn.ts | 16 ++-------- libs/catering/src/lib/utilities.ts | 23 ++++---------- libs/common/src/lib/general.ts | 30 ++----------------- libs/common/src/lib/settings.service.ts | 11 +++++++ ...ttached-resource-config-modal.component.ts | 23 ++++++++++++++ libs/events/src/lib/events.fn.ts | 16 ++-------- libs/mocks/src/lib/api/assets.data.ts | 4 +-- libs/mocks/src/lib/api/bookings.data.ts | 4 +-- libs/mocks/src/lib/api/zones.mock.ts | 10 +++---- 10 files changed, 62 insertions(+), 98 deletions(-) diff --git a/libs/assets/src/lib/asset.utilities.ts b/libs/assets/src/lib/asset.utilities.ts index b7a4602b6f8..2843937ea74 100644 --- a/libs/assets/src/lib/asset.utilities.ts +++ b/libs/assets/src/lib/asset.utilities.ts @@ -9,9 +9,11 @@ import { SignalFormRef, stringToMinutes, } from '@placeos/common'; -import { showMetadata } from '@placeos/ts-client'; import { isAfter, isBefore, setHours, subHours } from 'date-fns'; -import { AttachedResourceRuleset } from 'libs/components/src/lib/attached-resource-config-modal.component'; +import { + AttachedResourceRuleset, + zoneRulesetLoader, +} from 'libs/components/src/lib/attached-resource-config-modal.component'; export function generateAssetCategoryForm( category: Partial = {}, @@ -122,21 +124,8 @@ export function generateAssetForm( return { model, form: ref_form }; } -const RULE_REQUESTS: Record> = {}; - -export function getAssetRulesForZone(zone_id: string, fresh: boolean = false) { - if (!zone_id) return Promise.resolve([] as AttachedResourceRuleset[]); - if (!RULE_REQUESTS[zone_id] || fresh) - RULE_REQUESTS[zone_id] = showMetadata(zone_id, 'assets_config') - .then( - (_) => - (_.details instanceof Array - ? _.details - : []) as AttachedResourceRuleset[], - ) - .catch(() => [] as AttachedResourceRuleset[]); - return RULE_REQUESTS[zone_id]; -} +/** Asset rulesets for the zone, cached per zone */ +export const getAssetRulesForZone = zoneRulesetLoader('assets_config'); export function assetAvailable( item: AssetGroup, diff --git a/libs/bookings/src/lib/bookings.fn.ts b/libs/bookings/src/lib/bookings.fn.ts index 00f9f7464d0..091db86effd 100644 --- a/libs/bookings/src/lib/bookings.fn.ts +++ b/libs/bookings/src/lib/bookings.fn.ts @@ -9,6 +9,7 @@ import { } from '@placeos/ts-client'; import { + appDetails, BookableResource, Booking, BookingClash, @@ -120,18 +121,8 @@ export interface BookingsQueryParams { const BOOKINGS_ENDPOINT = `/api/staff/v1/bookings`; -const APP_VERSION = VERSION.raw || VERSION.version || VERSION.hash; - -function appName() { - return ( - setting('app.name') || - setting('app.short_name') || - 'PlaceOS' - ); -} - function bookingUtmSource() { - return `${appName()}_${VERSION.hash}_${currentUser().email || ''}`; + return `${appDetails().app_name}_${VERSION.hash}_${currentUser().email || ''}`; } function withAppVersion(data: NewBookingData): NewBookingData { @@ -143,8 +134,7 @@ function withAppVersion(data: NewBookingData): NewBookingData { ...booking_data, extension_data: { ...(booking_data.extension_data || {}), - app_name: appName(), - app_version: APP_VERSION, + ...appDetails(), }, }; } diff --git a/libs/catering/src/lib/utilities.ts b/libs/catering/src/lib/utilities.ts index 9aecb4cc9ac..3633b6963f3 100644 --- a/libs/catering/src/lib/utilities.ts +++ b/libs/catering/src/lib/utilities.ts @@ -1,23 +1,12 @@ import { CalendarEvent, CateringItem, stringToMinutes } from '@placeos/common'; -import { AttachedResourceRuleset } from '@placeos/components'; -import { showMetadata } from '@placeos/ts-client'; +import { + AttachedResourceRuleset, + zoneRulesetLoader, +} from '@placeos/components'; import { isAfter, isBefore, setHours, subMinutes } from 'date-fns'; -const RULE_REQUESTS: Record> = {}; - -export function getCateringRulesForZone(zone_id: string, fresh = false) { - if (!zone_id) return Promise.resolve([] as AttachedResourceRuleset[]); - if (!RULE_REQUESTS[zone_id] || fresh) - RULE_REQUESTS[zone_id] = showMetadata(zone_id, 'catering_config') - .then( - (_) => - (_.details instanceof Array - ? _.details - : []) as AttachedResourceRuleset[], - ) - .catch(() => [] as AttachedResourceRuleset[]); - return RULE_REQUESTS[zone_id]; -} +/** Catering rulesets for the zone, cached per zone */ +export const getCateringRulesForZone = zoneRulesetLoader('catering_config'); export function cateringItemAvailable( item: CateringItem, diff --git a/libs/common/src/lib/general.ts b/libs/common/src/lib/general.ts index fd65c88a1d6..75dc7f40238 100644 --- a/libs/common/src/lib/general.ts +++ b/libs/common/src/lib/general.ts @@ -119,9 +119,7 @@ export function padLength( length = 2, character = '0', ): string { - let str = `${value}`; - while (str.length < length) str = `${character}${str}`; - return str; + return `${value}`.padStart(length, character); } /** @@ -173,17 +171,6 @@ export function randomInt(ceil: number, floor = 0) { return Math.floor(Math.random() * (ceil - floor)) + floor; } -/** - * Pad string to the given length - * @param str Base string - * @param length Length to pad the string - */ -export function padString(str: string | number, length = 5) { - str = `${str}`; - while (str.length < length) str = `0${str}`; - return str; -} - /** * Generate a random string * @param length Length of the string @@ -364,20 +351,7 @@ export function parseJWT(token: string) { * @param an_array Array to flatten */ export function flatten(an_array: any[]): T[] { - const stack = [...an_array]; - const res = []; - while (stack.length) { - // pop value from stack - const next = stack.pop(); - if (Array.isArray(next)) { - // push back array items, won't modify the original input - stack.push(...next); - } else { - res.push(next); - } - } - // reverse to restore input order - return res.reverse(); + return an_array.flat(Infinity) as T[]; } /** diff --git a/libs/common/src/lib/settings.service.ts b/libs/common/src/lib/settings.service.ts index 72945e1ecea..ce7b9f67b0c 100644 --- a/libs/common/src/lib/settings.service.ts +++ b/libs/common/src/lib/settings.service.ts @@ -74,6 +74,17 @@ export function setting(key: string): T | undefined { return _service ? _service.get(key) : undefined; } +/** Name and version of the running app, as stored on saved bookings and events */ +export function appDetails() { + return { + app_name: + setting('app.name') || + setting('app.short_name') || + 'PlaceOS', + app_version: VERSION.raw || VERSION.version || VERSION.hash, + }; +} + export function settingSignal( key: string, default_value: T = undefined, diff --git a/libs/components/src/lib/attached-resource-config-modal.component.ts b/libs/components/src/lib/attached-resource-config-modal.component.ts index b8939020576..70849a5be00 100644 --- a/libs/components/src/lib/attached-resource-config-modal.component.ts +++ b/libs/components/src/lib/attached-resource-config-modal.component.ts @@ -9,6 +9,7 @@ import { import { MatRippleModule } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { AsyncHandler } from '@placeos/common'; +import { showMetadata } from '@placeos/ts-client'; import { FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -34,6 +35,28 @@ export interface AttachedResourceRuleset { rules: AttachedResourceRule[]; } +/** + * Make a cached loader for the rulesets stored in a zone metadata field. + * The loader resolves an empty list when the zone has no valid rules. Pass + * `fresh` to skip the cache. + */ +export function zoneRulesetLoader(metadata_name: string) { + const requests: Record> = {}; + return (zone_id: string, fresh = false) => { + if (!zone_id) return Promise.resolve([] as AttachedResourceRuleset[]); + if (!requests[zone_id] || fresh) { + requests[zone_id] = showMetadata(zone_id, metadata_name) + .then((_) => + _.details instanceof Array + ? (_.details as AttachedResourceRuleset[]) + : [], + ) + .catch(() => [] as AttachedResourceRuleset[]); + } + return requests[zone_id]; + }; +} + export const RULE_TYPES: Identity[] = [ { id: 'after_hour', name: 'After Hour of Day' }, { id: 'before_hour', name: 'Before Hour of Day' }, diff --git a/libs/events/src/lib/events.fn.ts b/libs/events/src/lib/events.fn.ts index e31e44113af..cb812befc04 100644 --- a/libs/events/src/lib/events.fn.ts +++ b/libs/events/src/lib/events.fn.ts @@ -1,11 +1,10 @@ import { + appDetails, BookingClash, CalendarEvent, EventExtensionData, GuestUser, - setting, toQueryString, - VERSION, } from '@placeos/common'; import { del, @@ -79,16 +78,6 @@ export interface CalendarEventShowParams { const EVENTS_ENDPOINT = `/api/staff/v1/events`; -const APP_VERSION = VERSION.raw || VERSION.version || VERSION.hash; - -function appName() { - return ( - setting('app.name') || - setting('app.short_name') || - 'PlaceOS' - ); -} - function withAppVersion }>( data: T, ): T { @@ -96,8 +85,7 @@ function withAppVersion }>( ...data, extension_data: { ...(data.extension_data || {}), - app_name: appName(), - app_version: APP_VERSION, + ...appDetails(), }, }; } diff --git a/libs/mocks/src/lib/api/assets.data.ts b/libs/mocks/src/lib/api/assets.data.ts index d64d99cb9d7..3cdb3f7b38e 100644 --- a/libs/mocks/src/lib/api/assets.data.ts +++ b/libs/mocks/src/lib/api/assets.data.ts @@ -1,4 +1,4 @@ -import { padString, predictableRandomInt } from '@placeos/common'; +import { padLength, predictableRandomInt } from '@placeos/common'; import { getUnixTime, subDays, subMonths } from 'date-fns'; import { MOCK_STAFF } from './users.data'; import { MOCK_BUILDINGS, MOCK_LEVELS, MOCK_REGIONS } from './zone.data'; @@ -628,7 +628,7 @@ export function generateMockParkingAssets(zone_id: string) { MOCK_PARKING_ASSETS_CACHE[zone_id] = new Array(18 * 6) .fill(0) .map((_, idx) => { - const position = padString( + const position = padLength( (idx % 18) + Math.floor(idx / 18) * 100, 3, ); diff --git a/libs/mocks/src/lib/api/bookings.data.ts b/libs/mocks/src/lib/api/bookings.data.ts index 7fb98db824d..2ba333a385e 100644 --- a/libs/mocks/src/lib/api/bookings.data.ts +++ b/libs/mocks/src/lib/api/bookings.data.ts @@ -1,6 +1,6 @@ import { capitalizeFirstLetter, - padString, + padLength, predictableRandomInt, randomString, } from '@placeos/common'; @@ -35,7 +35,7 @@ const generateBookingForDay = ( const approver = MOCK_STAFF[predictableRandomInt(MOCK_STAFF.length)]; const guest = MOCK_GUESTS[predictableRandomInt(MOCK_GUESTS.length)]; const asset_count = predictableRandomInt(3, 1); - const position = padString(predictableRandomInt(999) + 1, 3); + const position = padLength(predictableRandomInt(999) + 1, 3); const base_time = setHours( addDays(startOfDay(Date.now()), day - 15), predictableRandomInt(10, 7), diff --git a/libs/mocks/src/lib/api/zones.mock.ts b/libs/mocks/src/lib/api/zones.mock.ts index 75d6077e0b6..60ae3289a34 100644 --- a/libs/mocks/src/lib/api/zones.mock.ts +++ b/libs/mocks/src/lib/api/zones.mock.ts @@ -1,4 +1,4 @@ -import { padString, predictableRandomInt } from '@placeos/common'; +import { padLength, predictableRandomInt } from '@placeos/common'; import { registerMockEndpoint } from '@placeos/ts-client'; import { MOCK_MENU } from './catering.data'; import { MOCK_STAFF } from './users.data'; @@ -262,8 +262,8 @@ export function registerMockZones() { return { desks: { details: new Array(30).fill(0).map((_, idx) => ({ - id: `table-${padString(id, 2)}.${padString(idx + 1, 3)}`, - name: `Desk ${id}.${padString(idx + 1, 3)}`, + id: `table-${padLength(id, 2)}.${padLength(idx + 1, 3)}`, + name: `Desk ${id}.${padLength(idx + 1, 3)}`, bookable: predictableRandomInt(9999) % 4 !== 0, groups: predictableRandomInt(9999) % 4 === 0 ? ['test-1'] : [], @@ -280,7 +280,7 @@ export function registerMockZones() { LOCKERS[id] = { lockers: { details: new Array(6).fill(0).map((_, idx) => { - const position = padString(idx + 1, 2); + const position = padLength(idx + 1, 2); const assignee = predictableRandomInt(9999) % 4 === 0 ? MOCK_STAFF[ @@ -294,7 +294,7 @@ export function registerMockZones() { name: `Bank ${position}`, height: 3, lockers: new Array(18).fill(0).map((_, idx2) => ({ - id: `locker-${position}.${padString( + id: `locker-${position}.${padLength( idx2 + 1, 3, )}`, From 9a105502897a60b2f11f7df12080f598605107d8 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:03 +1100 Subject: [PATCH 11/17] refactor(libs): add provideValueAccessor for form field providers Replaces 29 copies of the NG_VALUE_ACCESSOR provider block with provideValueAccessor(() => Component) from @placeos/common. --- .../app-settings/upload-button.component.ts | 16 +++++-------- .../dashboards/cron-input-field.component.ts | 19 ++++----------- .../system-search-field.component.ts | 15 +++--------- .../schedule-mobile-calendar.component.ts | 11 ++++----- .../src/lib/asset-list-field.component.ts | 13 +++------- .../src/lib/desk-list-field.component.ts | 18 +++++++------- .../src/lib/locker-list-field.component.ts | 13 ++++------ .../lib/parking-space-list-field.component.ts | 18 +++++++------- .../src/lib/catering-list-field.component.ts | 13 +++------- libs/common/src/index.ts | 1 + libs/common/src/lib/value-accessor.ts | 16 +++++++++++++ .../src/lib/schema-form.component.ts | 11 ++------- .../src/lib/settings-toggle.component.ts | 17 ++++--------- libs/form-fields/src/lib/counter.component.ts | 18 ++++---------- .../src/lib/cron-input-field.component.ts | 18 +++----------- .../src/lib/date-calendar.component.ts | 17 ++++++------- .../src/lib/date-field.component.ts | 17 +++---------- .../src/lib/duration-field.component.ts | 8 ++----- .../src/lib/host-select-field.component.ts | 23 ++++-------------- .../src/lib/image-field.component.ts | 12 +++------- .../src/lib/image-list-field.component.ts | 11 ++------- .../src/lib/item-list-field.component.ts | 17 +++---------- .../src/lib/recurrence-field.component.ts | 17 +++---------- .../src/lib/rich-text-input.component.ts | 19 +++++++-------- .../src/lib/space-list-field.component.ts | 24 ++++--------------- .../src/lib/time-field.component.ts | 12 +++------- .../src/lib/upload-file-field.component.ts | 14 ++++------- .../src/lib/upload-list-field.component.ts | 14 ++++------- .../src/lib/user-list-field.component.ts | 16 +++---------- .../src/lib/user-search-field.component.ts | 23 +++++++----------- .../src/lib/card-input-field.component.ts | 13 ++-------- 31 files changed, 137 insertions(+), 337 deletions(-) create mode 100644 libs/common/src/lib/value-accessor.ts diff --git a/apps/concierge/src/app/ui/app-settings/upload-button.component.ts b/apps/concierge/src/app/ui/app-settings/upload-button.component.ts index 4183e573e17..c7ff1d5eaf1 100644 --- a/apps/concierge/src/app/ui/app-settings/upload-button.component.ts +++ b/apps/concierge/src/app/ui/app-settings/upload-button.component.ts @@ -1,16 +1,18 @@ import { Component, effect, - forwardRef, inject, Injector, input, signal, } from '@angular/core'; -import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { notifyError, UploadsService } from '@placeos/common'; +import { + notifyError, + provideValueAccessor, + UploadsService, +} from '@placeos/common'; import { IconComponent, TranslatePipe } from '@placeos/components'; @Component({ @@ -56,13 +58,7 @@ import { IconComponent, TranslatePipe } from '@placeos/components'; MatRippleModule, TranslatePipe, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UploadButtonComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => UploadButtonComponent)], }) export class UploadButtonComponent { private _uploads = inject(UploadsService); diff --git a/apps/stagehand/src/app/dashboards/cron-input-field.component.ts b/apps/stagehand/src/app/dashboards/cron-input-field.component.ts index 9a179b10d2a..22780ffa003 100644 --- a/apps/stagehand/src/app/dashboards/cron-input-field.component.ts +++ b/apps/stagehand/src/app/dashboards/cron-input-field.component.ts @@ -1,14 +1,7 @@ -import { - Component, - effect, - forwardRef, - signal, -} from '@angular/core'; -import { - ControlValueAccessor, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { Component, effect, signal } from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; import { form, FormField, pattern } from '@angular/forms/signals'; +import { provideValueAccessor } from '@placeos/common'; const VALID_INPUT = [ '0', @@ -83,11 +76,7 @@ function listPattern(fieldPattern) { `, styles: [``], providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CronInputFieldComponent), - multi: true, - }, + provideValueAccessor(() => CronInputFieldComponent), ], imports: [FormField], }) diff --git a/apps/stagehand/src/app/dashboards/system-search-field.component.ts b/apps/stagehand/src/app/dashboards/system-search-field.component.ts index c9ed4a69ec5..18fd45ceea8 100644 --- a/apps/stagehand/src/app/dashboards/system-search-field.component.ts +++ b/apps/stagehand/src/app/dashboards/system-search-field.component.ts @@ -2,7 +2,6 @@ import { Component, ElementRef, effect, - forwardRef, inject, input, model, @@ -12,11 +11,7 @@ import { SimpleChanges, viewChild, } from '@angular/core'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { ControlValueAccessor, FormsModule } from '@angular/forms'; import { PlaceSystem, querySystems } from '@placeos/ts-client'; import { CommonModule } from '@angular/common'; @@ -24,7 +19,7 @@ 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 { AsyncHandler } from '@placeos/common'; +import { AsyncHandler, provideValueAccessor } from '@placeos/common'; import { inGroupScope } from '../support-groups'; import { SupportGroupsService } from '../support-groups.service'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -170,11 +165,7 @@ import { IconComponent } from 'libs/components/src/lib/icon.component'; `, ], providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SystemSearchFieldComponent), - multi: true, - }, + provideValueAccessor(() => SystemSearchFieldComponent), ], imports: [ CommonModule, diff --git a/apps/workplace/src/app/schedule/schedule-mobile-calendar.component.ts b/apps/workplace/src/app/schedule/schedule-mobile-calendar.component.ts index d625428eeda..905a92f6904 100644 --- a/apps/workplace/src/app/schedule/schedule-mobile-calendar.component.ts +++ b/apps/workplace/src/app/schedule/schedule-mobile-calendar.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; -import { Component, forwardRef, OnInit, signal } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Component, OnInit, signal } from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; import { MatRipple } from '@angular/material/core'; import { IconComponent } from '@placeos/components'; import { @@ -13,6 +13,7 @@ import { startOfMonth, startOfWeek, } from 'date-fns'; +import { provideValueAccessor } from '@placeos/common'; @Component({ selector: 'schedule-mobile-calendar', @@ -74,11 +75,7 @@ import { `, styles: [``], providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ScheduleMobileCalendarComponent), - multi: true, - }, + provideValueAccessor(() => ScheduleMobileCalendarComponent), ], imports: [CommonModule, MatRipple, IconComponent], }) diff --git a/libs/assets/src/lib/asset-list-field.component.ts b/libs/assets/src/lib/asset-list-field.component.ts index 99f4ba41277..1f79db11321 100644 --- a/libs/assets/src/lib/asset-list-field.component.ts +++ b/libs/assets/src/lib/asset-list-field.component.ts @@ -3,14 +3,13 @@ import { Component, computed, effect, - forwardRef, inject, input, Signal, signal, untracked, } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -19,6 +18,7 @@ import { AssetRequest, i18n, notifyError, + provideValueAccessor, randomInt, randomString, SettingsService, @@ -241,14 +241,7 @@ const EMPTY_FAVS: string[] = []; } `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - /* istanbul ignore next */ - useExisting: forwardRef(() => AssetListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => AssetListFieldComponent)], imports: [ CommonModule, IconComponent, diff --git a/libs/bookings/src/lib/desk-list-field.component.ts b/libs/bookings/src/lib/desk-list-field.component.ts index 0e264561b54..357b4bf4f16 100644 --- a/libs/bookings/src/lib/desk-list-field.component.ts +++ b/libs/bookings/src/lib/desk-list-field.component.ts @@ -1,9 +1,13 @@ -import { Component, forwardRef, inject, signal } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Component, inject, signal } from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatRippleModule } from '@angular/material/core'; -import { SETTING_KEYS, toggleListItem } from '@placeos/common'; +import { + provideValueAccessor, + SETTING_KEYS, + toggleListItem, +} from '@placeos/common'; import { SettingsService } from 'libs/common/src/lib/settings.service'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -114,13 +118,7 @@ const EMPTY_FAVS: string[] = [];
`, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeskListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => DeskListFieldComponent)], imports: [ IconComponent, TranslatePipe, diff --git a/libs/bookings/src/lib/locker-list-field.component.ts b/libs/bookings/src/lib/locker-list-field.component.ts index 55246df883e..41ccdd24723 100644 --- a/libs/bookings/src/lib/locker-list-field.component.ts +++ b/libs/bookings/src/lib/locker-list-field.component.ts @@ -1,8 +1,9 @@ -import { Component, forwardRef, inject, signal } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Component, inject, signal } from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatRippleModule } from '@angular/material/core'; +import { provideValueAccessor } from '@placeos/common'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { LevelPipe } from 'libs/components/src/lib/level.pipe'; @@ -109,13 +110,7 @@ import { LockerSelectModalComponent } from './locker-select-modal/locker-select- `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => LockerListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => LockerListFieldComponent)], imports: [ IconComponent, TranslatePipe, diff --git a/libs/bookings/src/lib/parking-space-list-field.component.ts b/libs/bookings/src/lib/parking-space-list-field.component.ts index 6305295b76b..d46616a5faa 100644 --- a/libs/bookings/src/lib/parking-space-list-field.component.ts +++ b/libs/bookings/src/lib/parking-space-list-field.component.ts @@ -1,9 +1,13 @@ -import { Component, forwardRef, inject, input, signal } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Component, inject, input, signal } from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { MatRippleModule } from '@angular/material/core'; -import { SETTING_KEYS, toggleListItem } from '@placeos/common'; +import { + provideValueAccessor, + SETTING_KEYS, + toggleListItem, +} from '@placeos/common'; import { SettingsService } from 'libs/common/src/lib/settings.service'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -129,13 +133,7 @@ const EMPTY_FAVS: string[] = []; `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ParkingSpaceListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => ParkingSpaceListFieldComponent)], imports: [ TranslatePipe, IconComponent, diff --git a/libs/catering/src/lib/catering-list-field.component.ts b/libs/catering/src/lib/catering-list-field.component.ts index 7e814c63007..7c3f323f52b 100644 --- a/libs/catering/src/lib/catering-list-field.component.ts +++ b/libs/catering/src/lib/catering-list-field.component.ts @@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common'; import { Component, computed, - forwardRef, inject, input, OnChanges, @@ -10,7 +9,7 @@ import { signal, SimpleChanges, } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatMenuModule } from '@angular/material/menu'; @@ -20,6 +19,7 @@ import { CateringOrder, i18n, OrganisationService, + provideValueAccessor, randomString, SETTING_KEYS, SettingsService, @@ -319,14 +319,7 @@ const EMPTY_FAVS = [];
`, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - /* istanbul ignore next */ - useExisting: forwardRef(() => CateringListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => CateringListFieldComponent)], imports: [ CommonModule, IconComponent, diff --git a/libs/common/src/index.ts b/libs/common/src/index.ts index 66d5be718ce..2b58b3a49ea 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -26,6 +26,7 @@ export * from './lib/token-handoff'; export * from './lib/uploads.service'; export * from './lib/user-group-sync'; export * from './lib/user-idle-time.service'; +export * from './lib/value-accessor'; export { userSignal } from './lib/user-state'; export * from './lib/types/asset-request.class'; diff --git a/libs/common/src/lib/value-accessor.ts b/libs/common/src/lib/value-accessor.ts new file mode 100644 index 00000000000..98911acb428 --- /dev/null +++ b/libs/common/src/lib/value-accessor.ts @@ -0,0 +1,16 @@ +import { forwardRef, type Provider, type Type } from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; + +/** + * Provider that registers a component as its own form control value + * accessor. Pass the class in an arrow function, as the class is not + * defined yet when its decorator runs: + * `providers: [provideValueAccessor(() => MyFieldComponent)]` + */ +export function provideValueAccessor(component: () => Type): Provider { + return { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(component), + multi: true, + }; +} diff --git a/libs/components/src/lib/schema-form.component.ts b/libs/components/src/lib/schema-form.component.ts index 941c94ad76e..0623a965157 100644 --- a/libs/components/src/lib/schema-form.component.ts +++ b/libs/components/src/lib/schema-form.component.ts @@ -2,7 +2,6 @@ import { Component, computed, effect, - forwardRef, input, OnDestroy, signal, @@ -11,13 +10,13 @@ import { ControlValueAccessor, FormControl, FormGroup, - NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators, } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; +import { provideValueAccessor } from '@placeos/common'; import { Subscription } from 'rxjs'; import { SettingsToggleComponent } from './settings-toggle.component'; @@ -195,13 +194,7 @@ export function buildFormFromFields( } `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SchemaFormComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => SchemaFormComponent)], imports: [ ReactiveFormsModule, MatFormFieldModule, diff --git a/libs/components/src/lib/settings-toggle.component.ts b/libs/components/src/lib/settings-toggle.component.ts index 908dbc567cf..6c83bee6ae8 100644 --- a/libs/components/src/lib/settings-toggle.component.ts +++ b/libs/components/src/lib/settings-toggle.component.ts @@ -1,11 +1,8 @@ -import { Component, forwardRef, input, signal } from '@angular/core'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { Component, input, signal } from '@angular/core'; +import { ControlValueAccessor, FormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { provideValueAccessor } from '@placeos/common'; import { IconComponent } from './icon.component'; @Component({ @@ -89,13 +86,7 @@ import { IconComponent } from './icon.component'; } `, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SettingsToggleComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => SettingsToggleComponent)], imports: [MatCheckboxModule, FormsModule, IconComponent, MatTooltipModule], }) export class SettingsToggleComponent implements ControlValueAccessor { diff --git a/libs/form-fields/src/lib/counter.component.ts b/libs/form-fields/src/lib/counter.component.ts index 2f92f822b38..577cf4a86f8 100644 --- a/libs/form-fields/src/lib/counter.component.ts +++ b/libs/form-fields/src/lib/counter.component.ts @@ -1,9 +1,6 @@ -import { Component, forwardRef, input, model, signal } from '@angular/core'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { Component, input, model, signal } from '@angular/core'; +import { ControlValueAccessor, FormsModule } from '@angular/forms'; +import { provideValueAccessor } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -112,14 +109,7 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; } `, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - /* istanbul ignore next */ - useExisting: forwardRef(() => CounterComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => CounterComponent)], imports: [FormsModule, IconComponent, TranslatePipe], }) export class CounterComponent implements ControlValueAccessor { diff --git a/libs/form-fields/src/lib/cron-input-field.component.ts b/libs/form-fields/src/lib/cron-input-field.component.ts index 4ff2ab7f66e..8e634bf2771 100644 --- a/libs/form-fields/src/lib/cron-input-field.component.ts +++ b/libs/form-fields/src/lib/cron-input-field.component.ts @@ -1,19 +1,13 @@ -import { - Component, - DestroyRef, - forwardRef, - inject, - signal, -} from '@angular/core'; +import { Component, DestroyRef, inject, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ControlValueAccessor, FormControl, FormGroup, - NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators, } from '@angular/forms'; +import { provideValueAccessor } from '@placeos/common'; const DEFAULT_CRON = '* * * * *'; const ALLOWED_KEYS = new Set([ @@ -160,13 +154,7 @@ function control(pattern: string) { `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CronInputFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => CronInputFieldComponent)], imports: [ReactiveFormsModule], }) export class CronInputFieldComponent implements ControlValueAccessor { diff --git a/libs/form-fields/src/lib/date-calendar.component.ts b/libs/form-fields/src/lib/date-calendar.component.ts index c413256f8e4..1070f5cdc3b 100644 --- a/libs/form-fields/src/lib/date-calendar.component.ts +++ b/libs/form-fields/src/lib/date-calendar.component.ts @@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common'; import { Component, computed, - forwardRef, inject, input, OnChanges, @@ -10,9 +9,13 @@ import { signal, SimpleChanges, } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; -import { AsyncHandler, SettingsService } from '@placeos/common'; +import { + AsyncHandler, + provideValueAccessor, + SettingsService, +} from '@placeos/common'; import { addDays, addMonths, @@ -111,13 +114,7 @@ interface DateItem { `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DateCalendarComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => DateCalendarComponent)], imports: [CommonModule, IconComponent, MatRippleModule, TranslatePipe], }) export class DateCalendarComponent diff --git a/libs/form-fields/src/lib/date-field.component.ts b/libs/form-fields/src/lib/date-field.component.ts index 7ee60f878ea..79ff4bd14ab 100644 --- a/libs/form-fields/src/lib/date-field.component.ts +++ b/libs/form-fields/src/lib/date-field.component.ts @@ -2,7 +2,6 @@ import { CommonModule, DatePipe } from '@angular/common'; import { Component, computed, - forwardRef, inject, Injector, input, @@ -11,17 +10,13 @@ import { signal, viewChild, } from '@angular/core'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, - NgControl, -} from '@angular/forms'; +import { ControlValueAccessor, FormsModule, NgControl } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { getTimeInTimezone, getTimezoneOffsetString, markUserDateChange, + provideValueAccessor, setTimeInTimezone, } from '@placeos/common'; import { addYears, endOfDay, set, startOfDay, startOfMinute } from 'date-fns'; @@ -129,13 +124,7 @@ export enum TimezoneDiffRange { } `, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DateFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => DateFieldComponent)], imports: [ CommonModule, FormsModule, diff --git a/libs/form-fields/src/lib/duration-field.component.ts b/libs/form-fields/src/lib/duration-field.component.ts index 8c3e26fb15e..f3b06f6192b 100644 --- a/libs/form-fields/src/lib/duration-field.component.ts +++ b/libs/form-fields/src/lib/duration-field.component.ts @@ -14,7 +14,6 @@ import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, - NG_VALUE_ACCESSOR, ValidationErrors, Validator, } from '@angular/forms'; @@ -24,6 +23,7 @@ import { formatDuration, getTimeInTimezone, getTimezoneOffsetString, + provideValueAccessor, } from '@placeos/common'; import { addMinutes, differenceInMinutes, format, set } from 'date-fns'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -182,11 +182,7 @@ export interface DurationOption { `, ], providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DurationFieldComponent), - multi: true, - }, + provideValueAccessor(() => DurationFieldComponent), { provide: NG_VALIDATORS, useExisting: forwardRef(() => DurationFieldComponent), diff --git a/libs/form-fields/src/lib/host-select-field.component.ts b/libs/form-fields/src/lib/host-select-field.component.ts index 00db3c87b9d..d0b72a7e34e 100644 --- a/libs/form-fields/src/lib/host-select-field.component.ts +++ b/libs/form-fields/src/lib/host-select-field.component.ts @@ -1,21 +1,12 @@ import { CommonModule } from '@angular/common'; -import { - Component, - computed, - forwardRef, - resource, - signal, -} from '@angular/core'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { Component, computed, resource, signal } from '@angular/core'; +import { ControlValueAccessor, FormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { Calendar, currentUser, + provideValueAccessor, StaffUser, unique, User, @@ -52,13 +43,7 @@ import { queryCalendars } from 'libs/events/src/lib/calendar.fn'; `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => HostSelectFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => HostSelectFieldComponent)], imports: [MatFormFieldModule, MatSelectModule, CommonModule, FormsModule], }) export class HostSelectFieldComponent implements ControlValueAccessor { diff --git a/libs/form-fields/src/lib/image-field.component.ts b/libs/form-fields/src/lib/image-field.component.ts index 6b5e006b5b8..86c7a418e5b 100644 --- a/libs/form-fields/src/lib/image-field.component.ts +++ b/libs/form-fields/src/lib/image-field.component.ts @@ -2,18 +2,18 @@ import { Clipboard } from '@angular/cdk/clipboard'; import { Component, ElementRef, - forwardRef, inject, signal, viewChild, } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { AsyncHandler, notifyError, notifyInfo, + provideValueAccessor, UploadsService, } from '@placeos/common'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; @@ -117,13 +117,7 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ImageFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => ImageFieldComponent)], imports: [ MatRippleModule, MatTooltipModule, diff --git a/libs/form-fields/src/lib/image-list-field.component.ts b/libs/form-fields/src/lib/image-list-field.component.ts index c92b177ee60..08aa135cb64 100644 --- a/libs/form-fields/src/lib/image-list-field.component.ts +++ b/libs/form-fields/src/lib/image-list-field.component.ts @@ -6,13 +6,11 @@ import { computed, effect, ElementRef, - forwardRef, inject, Injector, signal, viewChild, } from '@angular/core'; -import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; import { Upload } from '@placeos/cloud-uploads'; @@ -24,6 +22,7 @@ import { AsyncHandler, notifyError, notifyInfo, + provideValueAccessor, unique, UploadsService, } from '@placeos/common'; @@ -228,13 +227,7 @@ export interface UploadDetails { } `, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ImageListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => ImageListFieldComponent)], imports: [ MatFormFieldModule, MatChipsModule, diff --git a/libs/form-fields/src/lib/item-list-field.component.ts b/libs/form-fields/src/lib/item-list-field.component.ts index ab350b1712f..c6bb06c1795 100644 --- a/libs/form-fields/src/lib/item-list-field.component.ts +++ b/libs/form-fields/src/lib/item-list-field.component.ts @@ -4,22 +4,18 @@ import { Component, computed, ElementRef, - forwardRef, input, signal, viewChild, } from '@angular/core'; -import { - ControlValueAccessor, - FormControl, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { ControlValueAccessor, FormControl } from '@angular/forms'; import { MatAutocompleteModule, MatAutocompleteTrigger, } from '@angular/material/autocomplete'; import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { provideValueAccessor } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; interface ChipListControl { @@ -121,14 +117,7 @@ export function uniqueChipItems(items: T[]): T[] { `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - /* istanbul ignore next */ - useExisting: forwardRef(() => ItemListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => ItemListFieldComponent)], imports: [ MatFormFieldModule, MatChipsModule, diff --git a/libs/form-fields/src/lib/recurrence-field.component.ts b/libs/form-fields/src/lib/recurrence-field.component.ts index 0138a163ae6..e05ad78ef90 100644 --- a/libs/form-fields/src/lib/recurrence-field.component.ts +++ b/libs/form-fields/src/lib/recurrence-field.component.ts @@ -2,18 +2,13 @@ import { Component, computed, effect, - forwardRef, inject, input, output, signal, untracked, } from '@angular/core'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { ControlValueAccessor, FormsModule } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { BookingRecurrence, @@ -22,6 +17,7 @@ import { fromBookingRecurrence, fromEventRecurrence, NO_RECURR, + provideValueAccessor, Recurrence, RecurrenceDetails, recurrenceEndDate, @@ -139,14 +135,7 @@ import { RecurrenceModalComponent } from './recurrence-modal.component'; } `, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - /* istanbul ignore next */ - useExisting: forwardRef(() => RecurrenceFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => RecurrenceFieldComponent)], imports: [ MatFormFieldModule, MatSelectModule, diff --git a/libs/form-fields/src/lib/rich-text-input.component.ts b/libs/form-fields/src/lib/rich-text-input.component.ts index 6eb06926749..6a03c498ae8 100644 --- a/libs/form-fields/src/lib/rich-text-input.component.ts +++ b/libs/form-fields/src/lib/rich-text-input.component.ts @@ -2,7 +2,6 @@ import { AfterViewInit, Component, ElementRef, - forwardRef, inject, input, NgZone, @@ -12,9 +11,14 @@ import { SimpleChanges, viewChild, } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor } from '@angular/forms'; import { DomSanitizer } from '@angular/platform-browser'; -import { AsyncHandler, notifyError, UploadsService } from '@placeos/common'; +import { + AsyncHandler, + notifyError, + provideValueAccessor, + UploadsService, +} from '@placeos/common'; import { apiKey, token } from '@placeos/ts-client'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -190,14 +194,7 @@ import type Squire from 'squire-rte'; } `, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - /* istanbul ignore next */ - useExisting: forwardRef(() => RichTextInputComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => RichTextInputComponent)], imports: [IconComponent, TranslatePipe], }) export class RichTextInputComponent diff --git a/libs/form-fields/src/lib/space-list-field.component.ts b/libs/form-fields/src/lib/space-list-field.component.ts index 712173f7cc8..a906a170b1e 100644 --- a/libs/form-fields/src/lib/space-list-field.component.ts +++ b/libs/form-fields/src/lib/space-list-field.component.ts @@ -1,16 +1,5 @@ -import { - Component, - OnDestroy, - forwardRef, - inject, - input, - signal, -} from '@angular/core'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { Component, OnDestroy, inject, input, signal } from '@angular/core'; +import { ControlValueAccessor, FormsModule } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog, @@ -23,6 +12,7 @@ import { SETTING_KEYS, SettingsService, Space, + provideValueAccessor, toggleListItem, } from '@placeos/common'; import { AuthenticatedImageDirective } from 'libs/components/src/lib/authenticated-image.directive'; @@ -196,13 +186,7 @@ const EMPTY_FAVS: string[] = []; `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SpaceListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => SpaceListFieldComponent)], imports: [ MatRadioModule, FormsModule, diff --git a/libs/form-fields/src/lib/time-field.component.ts b/libs/form-fields/src/lib/time-field.component.ts index 7d19cec42d0..0879f954094 100644 --- a/libs/form-fields/src/lib/time-field.component.ts +++ b/libs/form-fields/src/lib/time-field.component.ts @@ -3,7 +3,6 @@ import { AfterViewInit, Component, computed, - forwardRef, input, model, OnChanges, @@ -12,7 +11,7 @@ import { SimpleChanges, viewChild, } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ControlValueAccessor } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { @@ -22,6 +21,7 @@ import { getTimeInTimezone, getTimezoneOffsetString, markUserDateChange, + provideValueAccessor, setTimeInTimezone, startOfDayInTimezone, } from '@placeos/common'; @@ -127,13 +127,7 @@ function toHourOfDay(value: unknown): number | null { } `, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => TimeFieldComponent)], imports: [CommonModule, MatMenuModule, MatFormFieldModule, IconComponent], }) export class TimeFieldComponent diff --git a/libs/form-fields/src/lib/upload-file-field.component.ts b/libs/form-fields/src/lib/upload-file-field.component.ts index e9f38797fed..2ece500cb11 100644 --- a/libs/form-fields/src/lib/upload-file-field.component.ts +++ b/libs/form-fields/src/lib/upload-file-field.component.ts @@ -1,6 +1,6 @@ -import { Component, forwardRef, signal } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { Attachment } from '@placeos/common'; +import { Component, signal } from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; +import { Attachment, provideValueAccessor } from '@placeos/common'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -72,13 +72,7 @@ import { uploadAttachment } from './upload-attachment'; `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UploadFileFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => UploadFileFieldComponent)], imports: [MatProgressSpinnerModule, IconComponent, TranslatePipe], }) export class UploadFileFieldComponent implements ControlValueAccessor { diff --git a/libs/form-fields/src/lib/upload-list-field.component.ts b/libs/form-fields/src/lib/upload-list-field.component.ts index e719272d374..51e454ee68a 100644 --- a/libs/form-fields/src/lib/upload-list-field.component.ts +++ b/libs/form-fields/src/lib/upload-list-field.component.ts @@ -1,8 +1,8 @@ -import { Component, forwardRef, signal } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { Component, signal } from '@angular/core'; +import { ControlValueAccessor } from '@angular/forms'; import { MatRippleModule } from '@angular/material/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { Attachment, randomInt } from '@placeos/common'; +import { Attachment, provideValueAccessor, randomInt } from '@placeos/common'; import { IconComponent } from 'libs/components/src/lib/icon.component'; import { TranslatePipe } from 'libs/components/src/lib/translate.pipe'; @@ -94,13 +94,7 @@ import { uploadAttachment } from './upload-attachment'; `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UploadListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => UploadListFieldComponent)], imports: [ MatProgressSpinnerModule, IconComponent, diff --git a/libs/form-fields/src/lib/user-list-field.component.ts b/libs/form-fields/src/lib/user-list-field.component.ts index b28af82c320..a70b2752ca7 100644 --- a/libs/form-fields/src/lib/user-list-field.component.ts +++ b/libs/form-fields/src/lib/user-list-field.component.ts @@ -4,7 +4,6 @@ import { computed, debounced, ElementRef, - forwardRef, inject, input, model, @@ -14,11 +13,7 @@ import { viewChild, } from '@angular/core'; import { outputToObservable } from '@angular/core/rxjs-interop'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { ControlValueAccessor, FormsModule } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { AsyncHandler, @@ -26,6 +21,7 @@ import { currentUser, downloadFile, notifyError, + provideValueAccessor, SettingsService, unique, } from '@placeos/common'; @@ -309,13 +305,7 @@ const DENIED_FILE_TYPES = [ `, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UserListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => UserListFieldComponent)], imports: [ CommonModule, MatFormFieldModule, diff --git a/libs/form-fields/src/lib/user-search-field.component.ts b/libs/form-fields/src/lib/user-search-field.component.ts index e385eca471f..caa511f4609 100644 --- a/libs/form-fields/src/lib/user-search-field.component.ts +++ b/libs/form-fields/src/lib/user-search-field.component.ts @@ -4,18 +4,13 @@ import { computed, debounced, ElementRef, - forwardRef, input, model, resource, signal, viewChild, } from '@angular/core'; -import { - ControlValueAccessor, - FormsModule, - NG_VALUE_ACCESSOR, -} from '@angular/forms'; +import { ControlValueAccessor, FormsModule } from '@angular/forms'; import { MatAutocompleteModule, MatAutocompleteTrigger, @@ -24,7 +19,13 @@ 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, EMPTY_USER, settingSignal, User } from '@placeos/common'; +import { + AsyncHandler, + EMPTY_USER, + provideValueAccessor, + settingSignal, + User, +} from '@placeos/common'; import { authority, queryUsers, showUser } from '@placeos/ts-client'; import { IconComponent } from 'libs/components/src/lib/icon.component'; @@ -146,13 +147,7 @@ import { searchStaff } from 'libs/users/src/lib/staff.fn'; } `, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UserSearchFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => UserSearchFieldComponent)], imports: [ CommonModule, FormsModule, diff --git a/libs/payments/src/lib/card-input-field.component.ts b/libs/payments/src/lib/card-input-field.component.ts index 56ab373c7a5..285b5893ec1 100644 --- a/libs/payments/src/lib/card-input-field.component.ts +++ b/libs/payments/src/lib/card-input-field.component.ts @@ -3,7 +3,6 @@ import { Component, computed, ElementRef, - forwardRef, OnInit, signal, viewChild, @@ -12,14 +11,13 @@ import { ControlValueAccessor, FormControl, FormGroup, - NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators, } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; -import { AsyncHandler } from '@placeos/common'; +import { AsyncHandler, provideValueAccessor } from '@placeos/common'; import { addYears, setMonth } from 'date-fns'; export interface PaymentCardDetails { @@ -136,14 +134,7 @@ const DATE_PIPE = new DatePipe('en-us', ''); } `, ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - /* istanbul ignore next */ - useExisting: forwardRef(() => CardInputFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => CardInputFieldComponent)], imports: [ MatFormFieldModule, MatInputModule, From 6418c725b384a984fa88d07a40e7d9c9d7fa8d51 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Fri, 30 Oct 2026 20:04:03 +1100 Subject: [PATCH 12/17] refactor(common): build the time zone list from the browser Replaces the 983-line hand-written time zone table with Intl.supportedValuesOf('timeZone'), plus UTC and the Etc/GMT fixed-offset zones. Deprecated aliases such as Asia/Saigon are no longer suggested, but stored values still show and save. --- libs/common/src/lib/timezones.ts | 1006 +---------------------- libs/common/src/tests/timezones.spec.ts | 10 + 2 files changed, 36 insertions(+), 980 deletions(-) create mode 100644 libs/common/src/tests/timezones.spec.ts diff --git a/libs/common/src/lib/timezones.ts b/libs/common/src/lib/timezones.ts index ce07b19099e..b98c9040794 100644 --- a/libs/common/src/lib/timezones.ts +++ b/libs/common/src/lib/timezones.ts @@ -1,983 +1,29 @@ -import { flatten, unique } from './general'; +type IntlWithValues = typeof Intl & { + supportedValuesOf?: (key: 'timeZone') => string[]; +}; -export const TIMEZONES = [ - { - value: 'Dateline Standard Time', - abbr: 'DST', - offset: -12, - utc: ['Etc/GMT+12'], - }, - { - value: 'UTC-11', - abbr: 'U', - offset: -11, - utc: [ - 'Etc/GMT+11', - 'Pacific/Midway', - 'Pacific/Niue', - 'Pacific/Pago_Pago', - ], - }, - { - value: 'Hawaiian Standard Time', - abbr: 'HST', - offset: -10, - utc: [ - 'Etc/GMT+10', - 'Pacific/Honolulu', - 'Pacific/Johnston', - 'Pacific/Rarotonga', - 'Pacific/Tahiti', - ], - }, - { - value: 'Alaskan Standard Time', - abbr: 'AKDT', - offset: -8, - utc: [ - 'America/Anchorage', - 'America/Juneau', - 'America/Nome', - 'America/Sitka', - 'America/Yakutat', - ], - }, - { - value: 'Pacific Standard Time (Mexico)', - abbr: 'PDT', - offset: -7, - utc: ['America/Santa_Isabel'], - }, - { - value: 'Pacific Daylight Time', - abbr: 'PDT', - offset: -7, - text: '(UTC-07:00) Pacific Time (US & Canada)', - utc: [ - 'America/Dawson', - 'America/Los_Angeles', - 'America/Tijuana', - 'America/Vancouver', - 'America/Whitehorse', - ], - }, - { - value: 'Pacific Standard Time', - abbr: 'PST', - offset: -8, - text: '(UTC-08:00) Pacific Time (US & Canada)', - utc: [ - 'America/Dawson', - 'America/Los_Angeles', - 'America/Tijuana', - 'America/Vancouver', - 'America/Whitehorse', - 'PST8PDT', - ], - }, - { - value: 'US Mountain Standard Time', - abbr: 'UMST', - offset: -7, - utc: [ - 'America/Creston', - 'America/Dawson_Creek', - 'America/Hermosillo', - 'America/Phoenix', - 'Etc/GMT+7', - ], - }, - { - value: 'Mountain Standard Time (Mexico)', - abbr: 'MDT', - offset: -6, - utc: ['America/Chihuahua', 'America/Mazatlan'], - }, - { - value: 'Mountain Standard Time', - abbr: 'MDT', - offset: -6, - text: '(UTC-07:00) Mountain Time (US & Canada)', - utc: [ - 'America/Boise', - 'America/Cambridge_Bay', - 'America/Denver', - 'America/Edmonton', - 'America/Inuvik', - 'America/Ojinaga', - 'America/Yellowknife', - 'MST7MDT', - ], - }, - { - value: 'Central America Standard Time', - abbr: 'CAST', - offset: -6, - utc: [ - 'America/Belize', - 'America/Costa_Rica', - 'America/El_Salvador', - 'America/Guatemala', - 'America/Managua', - 'America/Tegucigalpa', - 'Etc/GMT+6', - 'Pacific/Galapagos', - ], - }, - { - value: 'Central Standard Time', - abbr: 'CDT', - offset: -5, - text: '(UTC-06:00) Central Time (US & Canada)', - utc: [ - 'America/Chicago', - 'America/Indiana/Knox', - 'America/Indiana/Tell_City', - 'America/Matamoros', - 'America/Menominee', - 'America/North_Dakota/Beulah', - 'America/North_Dakota/Center', - 'America/North_Dakota/New_Salem', - 'America/Rainy_River', - 'America/Rankin_Inlet', - 'America/Resolute', - 'America/Winnipeg', - 'CST6CDT', - ], - }, - { - value: 'Central Standard Time (Mexico)', - abbr: 'CDT', - offset: -5, - utc: [ - 'America/Bahia_Banderas', - 'America/Cancun', - 'America/Merida', - 'America/Mexico_City', - 'America/Monterrey', - ], - }, - { - value: 'Canada Central Standard Time', - abbr: 'CCST', - offset: -6, - utc: ['America/Regina', 'America/Swift_Current'], - }, - { - value: 'SA Pacific Standard Time', - abbr: 'SPST', - offset: -5, - utc: [ - 'America/Bogota', - 'America/Cayman', - 'America/Coral_Harbour', - 'America/Eirunepe', - 'America/Guayaquil', - 'America/Jamaica', - 'America/Lima', - 'America/Panama', - 'America/Rio_Branco', - 'Etc/GMT+5', - ], - }, - { - value: 'Eastern Standard Time', - abbr: 'EDT', - offset: -4, - text: '(UTC-05:00) Eastern Time (US & Canada)', - utc: [ - 'America/Detroit', - 'America/Havana', - 'America/Indiana/Petersburg', - 'America/Indiana/Vincennes', - 'America/Indiana/Winamac', - 'America/Iqaluit', - 'America/Kentucky/Monticello', - 'America/Louisville', - 'America/Montreal', - 'America/Nassau', - 'America/New_York', - 'America/Nipigon', - 'America/Pangnirtung', - 'America/Port-au-Prince', - 'America/Thunder_Bay', - 'America/Toronto', - 'EST5EDT', - ], - }, - { - value: 'US Eastern Standard Time', - abbr: 'UEDT', - offset: -4, - utc: [ - 'America/Indiana/Marengo', - 'America/Indiana/Vevay', - 'America/Indianapolis', - ], - }, - { - value: 'Venezuela Standard Time', - abbr: 'VST', - offset: -4.5, - utc: ['America/Caracas'], - }, - { - value: 'Paraguay Standard Time', - abbr: 'PYT', - offset: -4, - utc: ['America/Asuncion'], - }, - { - value: 'Atlantic Standard Time', - abbr: 'ADT', - offset: -3, - utc: [ - 'America/Glace_Bay', - 'America/Goose_Bay', - 'America/Halifax', - 'America/Moncton', - 'America/Thule', - 'Atlantic/Bermuda', - ], - }, - { - value: 'Central Brazilian Standard Time', - abbr: 'CBST', - offset: -4, - utc: ['America/Campo_Grande', 'America/Cuiaba'], - }, - { - value: 'SA Western Standard Time', - abbr: 'SWST', - offset: -4, - utc: [ - 'America/Anguilla', - 'America/Antigua', - 'America/Aruba', - 'America/Barbados', - 'America/Blanc-Sablon', - 'America/Boa_Vista', - 'America/Curacao', - 'America/Dominica', - 'America/Grand_Turk', - 'America/Grenada', - 'America/Guadeloupe', - 'America/Guyana', - 'America/Kralendijk', - 'America/La_Paz', - 'America/Lower_Princes', - 'America/Manaus', - 'America/Marigot', - 'America/Martinique', - 'America/Montserrat', - 'America/Port_of_Spain', - 'America/Porto_Velho', - 'America/Puerto_Rico', - 'America/Santo_Domingo', - 'America/St_Barthelemy', - 'America/St_Kitts', - 'America/St_Lucia', - 'America/St_Thomas', - 'America/St_Vincent', - 'America/Tortola', - 'Etc/GMT+4', - ], - }, - { - value: 'Pacific SA Standard Time', - abbr: 'PSST', - offset: -4, - utc: ['America/Santiago', 'Antarctica/Palmer'], - }, - { - value: 'Newfoundland Standard Time', - abbr: 'NDT', - offset: -2.5, - utc: ['America/St_Johns'], - }, - { - value: 'E. South America Standard Time', - abbr: 'ESAST', - offset: -3, - utc: ['America/Sao_Paulo'], - }, - { - value: 'Argentina Standard Time', - abbr: 'AST', - offset: -3, - utc: [ - 'America/Argentina/La_Rioja', - 'America/Argentina/Rio_Gallegos', - 'America/Argentina/Salta', - 'America/Argentina/San_Juan', - 'America/Argentina/San_Luis', - 'America/Argentina/Tucuman', - 'America/Argentina/Ushuaia', - 'America/Buenos_Aires', - 'America/Catamarca', - 'America/Cordoba', - 'America/Jujuy', - 'America/Mendoza', - ], - }, - { - value: 'SA Eastern Standard Time', - abbr: 'SEST', - offset: -3, - utc: [ - 'America/Araguaina', - 'America/Belem', - 'America/Cayenne', - 'America/Fortaleza', - 'America/Maceio', - 'America/Paramaribo', - 'America/Recife', - 'America/Santarem', - 'Antarctica/Rothera', - 'Atlantic/Stanley', - 'Etc/GMT+3', - ], - }, - { - value: 'Greenland Standard Time', - abbr: 'GDT', - offset: -3, - utc: ['America/Godthab'], - }, - { - value: 'Montevideo Standard Time', - abbr: 'MST', - offset: -3, - utc: ['America/Montevideo'], - }, - { - value: 'Bahia Standard Time', - abbr: 'BST', - offset: -3, - utc: ['America/Bahia'], - }, - { - value: 'UTC-02', - abbr: 'U', - offset: -2, - utc: ['America/Noronha', 'Atlantic/South_Georgia', 'Etc/GMT+2'], - }, - { - value: 'Mid-Atlantic Standard Time', - abbr: 'MDT', - offset: -1, - utc: [], - }, - { - value: 'Azores Standard Time', - abbr: 'ADT', - utc: ['America/Scoresbysund', 'Atlantic/Azores'], - }, - { - value: 'Cape Verde Standard Time', - abbr: 'CVST', - offset: -1, - text: '(UTC-01:00) Cape Verde Is.', - utc: ['Atlantic/Cape_Verde', 'Etc/GMT+1'], - }, - { - value: 'Morocco Standard Time', - abbr: 'MDT', - utc: ['Africa/Casablanca', 'Africa/El_Aaiun'], - }, - { - value: 'UTC', - abbr: 'UTC', - utc: ['America/Danmarkshavn', 'Etc/GMT'], - }, - { - value: 'GMT Standard Time', - abbr: 'GMT', - utc: [ - 'Europe/Isle_of_Man', - 'Europe/Guernsey', - 'Europe/Jersey', - 'Europe/London', - ], - }, - { - value: 'British Summer Time', - abbr: 'BST', - utc: [ - 'Europe/Isle_of_Man', - 'Europe/Guernsey', - 'Europe/Jersey', - 'Europe/London', - ], - }, - { - value: 'GMT Standard Time', - abbr: 'GDT', - utc: [ - 'Atlantic/Canary', - 'Atlantic/Faeroe', - 'Atlantic/Madeira', - 'Europe/Dublin', - 'Europe/Lisbon', - ], - }, - { - value: 'Greenwich Standard Time', - abbr: 'GST', - utc: [ - 'Africa/Abidjan', - 'Africa/Accra', - 'Africa/Bamako', - 'Africa/Banjul', - 'Africa/Bissau', - 'Africa/Conakry', - 'Africa/Dakar', - 'Africa/Freetown', - 'Africa/Lome', - 'Africa/Monrovia', - 'Africa/Nouakchott', - 'Africa/Ouagadougou', - 'Africa/Sao_Tome', - 'Atlantic/Reykjavik', - 'Atlantic/St_Helena', - ], - }, - { - value: 'W. Europe Standard Time', - abbr: 'WEDT', - utc: [ - 'Arctic/Longyearbyen', - 'Europe/Amsterdam', - 'Europe/Andorra', - 'Europe/Berlin', - 'Europe/Busingen', - 'Europe/Gibraltar', - 'Europe/Luxembourg', - 'Europe/Malta', - 'Europe/Monaco', - 'Europe/Oslo', - 'Europe/Rome', - 'Europe/San_Marino', - 'Europe/Stockholm', - 'Europe/Vaduz', - 'Europe/Vatican', - 'Europe/Vienna', - 'Europe/Zurich', - ], - }, - { - value: 'Central Europe Standard Time', - abbr: 'CEDT', - utc: [ - 'Europe/Belgrade', - 'Europe/Bratislava', - 'Europe/Budapest', - 'Europe/Ljubljana', - 'Europe/Podgorica', - 'Europe/Prague', - 'Europe/Tirane', - ], - }, - { - value: 'Romance Standard Time', - abbr: 'RDT', - utc: [ - 'Africa/Ceuta', - 'Europe/Brussels', - 'Europe/Copenhagen', - 'Europe/Madrid', - 'Europe/Paris', - ], - }, - { - value: 'Central European Standard Time', - abbr: 'CEDT', - utc: [ - 'Europe/Sarajevo', - 'Europe/Skopje', - 'Europe/Warsaw', - 'Europe/Zagreb', - ], - }, - { - value: 'W. Central Africa Standard Time', - abbr: 'WCAST', - utc: [ - 'Africa/Algiers', - 'Africa/Bangui', - 'Africa/Brazzaville', - 'Africa/Douala', - 'Africa/Kinshasa', - 'Africa/Lagos', - 'Africa/Libreville', - 'Africa/Luanda', - 'Africa/Malabo', - 'Africa/Ndjamena', - 'Africa/Niamey', - 'Africa/Porto-Novo', - 'Africa/Tunis', - 'Etc/GMT-1', - ], - }, - { - value: 'Namibia Standard Time', - abbr: 'NST', - utc: ['Africa/Windhoek'], - }, - { - value: 'GTB Standard Time', - abbr: 'GDT', - utc: [ - 'Asia/Nicosia', - 'Europe/Athens', - 'Europe/Bucharest', - 'Europe/Chisinau', - ], - }, - { - value: 'Middle East Standard Time', - abbr: 'MEDT', - utc: ['Asia/Beirut'], - }, - { - value: 'Egypt Standard Time', - abbr: 'EST', - utc: ['Africa/Cairo'], - }, - { - value: 'Syria Standard Time', - abbr: 'SDT', - utc: ['Asia/Damascus'], - }, - { - value: 'E. Europe Standard Time', - abbr: 'EEDT', - text: '(UTC+02:00) E. Europe', - utc: [ - 'Asia/Nicosia', - 'Europe/Athens', - 'Europe/Bucharest', - 'Europe/Chisinau', - 'Europe/Helsinki', - 'Europe/Kiev', - 'Europe/Mariehamn', - 'Europe/Nicosia', - 'Europe/Riga', - 'Europe/Sofia', - 'Europe/Tallinn', - 'Europe/Uzhgorod', - 'Europe/Vilnius', - 'Europe/Zaporozhye', - ], - }, - { - value: 'South Africa Standard Time', - abbr: 'SAST', - utc: [ - 'Africa/Blantyre', - 'Africa/Bujumbura', - 'Africa/Gaborone', - 'Africa/Harare', - 'Africa/Johannesburg', - 'Africa/Kigali', - 'Africa/Lubumbashi', - 'Africa/Lusaka', - 'Africa/Maputo', - 'Africa/Maseru', - 'Africa/Mbabane', - 'Etc/GMT-2', - ], - }, - { - value: 'FLE Standard Time', - abbr: 'FDT', - utc: [ - 'Europe/Helsinki', - 'Europe/Kiev', - 'Europe/Mariehamn', - 'Europe/Riga', - 'Europe/Sofia', - 'Europe/Tallinn', - 'Europe/Uzhgorod', - 'Europe/Vilnius', - 'Europe/Zaporozhye', - ], - }, - { - value: 'Turkey Standard Time', - abbr: 'TDT', - utc: ['Europe/Istanbul'], - }, - { - value: 'Israel Standard Time', - abbr: 'JDT', - utc: ['Asia/Jerusalem'], - }, - { - value: 'Libya Standard Time', - abbr: 'LST', - utc: ['Africa/Tripoli'], - }, - { - value: 'Jordan Standard Time', - abbr: 'JST', - utc: ['Asia/Amman'], - }, - { - value: 'Arabic Standard Time', - abbr: 'AST', - utc: ['Asia/Baghdad'], - }, - { - value: 'Kaliningrad Standard Time', - abbr: 'KST', - utc: ['Europe/Kaliningrad'], - }, - { - value: 'Arab Standard Time', - abbr: 'AST', - utc: [ - 'Asia/Aden', - 'Asia/Bahrain', - 'Asia/Kuwait', - 'Asia/Qatar', - 'Asia/Riyadh', - ], - }, - { - value: 'E. Africa Standard Time', - abbr: 'EAST', - utc: [ - 'Africa/Addis_Ababa', - 'Africa/Asmera', - 'Africa/Dar_es_Salaam', - 'Africa/Djibouti', - 'Africa/Juba', - 'Africa/Kampala', - 'Africa/Khartoum', - 'Africa/Mogadishu', - 'Africa/Nairobi', - 'Antarctica/Syowa', - 'Etc/GMT-3', - 'Indian/Antananarivo', - 'Indian/Comoro', - 'Indian/Mayotte', - ], - }, - { - value: 'Moscow Standard Time', - abbr: 'MSK', - text: '(UTC+03:00) Moscow, St. Petersburg, Volgograd, Minsk', - utc: [ - 'Europe/Kirov', - 'Europe/Moscow', - 'Europe/Simferopol', - 'Europe/Volgograd', - 'Europe/Minsk', - ], - }, - { - value: 'Samara Time', - abbr: 'SAMT', - utc: ['Europe/Astrakhan', 'Europe/Samara', 'Europe/Ulyanovsk'], - }, - { - value: 'Iran Standard Time', - abbr: 'IDT', - utc: ['Asia/Tehran'], - }, - { - value: 'Arabian Standard Time', - abbr: 'AST', - utc: ['Asia/Dubai', 'Asia/Muscat', 'Etc/GMT-4'], - }, - { - value: 'Azerbaijan Standard Time', - abbr: 'ADT', - utc: ['Asia/Baku'], - }, - { - value: 'Mauritius Standard Time', - abbr: 'MST', - utc: ['Indian/Mahe', 'Indian/Mauritius', 'Indian/Reunion'], - }, - { - value: 'Georgian Standard Time', - abbr: 'GET', - utc: ['Asia/Tbilisi'], - }, - { - value: 'Caucasus Standard Time', - abbr: 'CST', - utc: ['Asia/Yerevan'], - }, - { - value: 'Afghanistan Standard Time', - abbr: 'AST', - utc: ['Asia/Kabul'], - }, - { - value: 'West Asia Standard Time', - abbr: 'WAST', - utc: [ - 'Antarctica/Mawson', - 'Asia/Aqtau', - 'Asia/Aqtobe', - 'Asia/Ashgabat', - 'Asia/Dushanbe', - 'Asia/Oral', - 'Asia/Samarkand', - 'Asia/Tashkent', - 'Etc/GMT-5', - 'Indian/Kerguelen', - 'Indian/Maldives', - ], - }, - { - value: 'Yekaterinburg Time', - abbr: 'YEKT', - utc: ['Asia/Yekaterinburg'], - }, - { - value: 'Pakistan Standard Time', - abbr: 'PKT', - utc: ['Asia/Karachi'], - }, - { - value: 'India Standard Time', - abbr: 'IST', - utc: ['Asia/Kolkata'], - }, - { - value: 'Sri Lanka Standard Time', - abbr: 'SLST', - utc: ['Asia/Colombo'], - }, - { - value: 'Nepal Standard Time', - abbr: 'NST', - utc: ['Asia/Kathmandu'], - }, - { - value: 'Central Asia Standard Time', - abbr: 'CAST', - utc: [ - 'Antarctica/Vostok', - 'Asia/Almaty', - 'Asia/Bishkek', - 'Asia/Qyzylorda', - 'Asia/Urumqi', - 'Etc/GMT-6', - 'Indian/Chagos', - ], - }, - { - value: 'Bangladesh Standard Time', - abbr: 'BST', - utc: ['Asia/Dhaka', 'Asia/Thimphu'], - }, - { - value: 'Myanmar Standard Time', - abbr: 'MST', - utc: ['Asia/Rangoon', 'Indian/Cocos'], - }, - { - value: 'SE Asia Standard Time', - abbr: 'SAST', - utc: [ - 'Antarctica/Davis', - 'Asia/Bangkok', - 'Asia/Hovd', - 'Asia/Jakarta', - 'Asia/Phnom_Penh', - 'Asia/Pontianak', - 'Asia/Saigon', - 'Asia/Vientiane', - 'Etc/GMT-7', - 'Indian/Christmas', - ], - }, - { - value: 'N. Central Asia Standard Time', - abbr: 'NCAST', - utc: ['Asia/Novokuznetsk', 'Asia/Novosibirsk', 'Asia/Omsk'], - }, - { - value: 'China Standard Time', - abbr: 'CST', - utc: ['Asia/Hong_Kong', 'Asia/Macau', 'Asia/Shanghai'], - }, - { - value: 'North Asia Standard Time', - abbr: 'NAST', - utc: ['Asia/Krasnoyarsk'], - }, - { - value: 'Singapore Standard Time', - abbr: 'MPST', - utc: [ - 'Asia/Brunei', - 'Asia/Kuala_Lumpur', - 'Asia/Kuching', - 'Asia/Makassar', - 'Asia/Manila', - 'Asia/Singapore', - 'Etc/GMT-8', - ], - }, - { - value: 'W. Australia Standard Time', - abbr: 'WAST', - utc: ['Antarctica/Casey', 'Australia/Perth'], - }, - { - value: 'Taipei Standard Time', - abbr: 'TST', - utc: ['Asia/Taipei'], - }, - { - value: 'Ulaanbaatar Standard Time', - abbr: 'UST', - utc: ['Asia/Choibalsan', 'Asia/Ulaanbaatar'], - }, - { - value: 'North Asia East Standard Time', - abbr: 'NAEST', - utc: ['Asia/Irkutsk'], - }, - { - value: 'Japan Standard Time', - abbr: 'JST', - utc: [ - 'Asia/Dili', - 'Asia/Jayapura', - 'Asia/Tokyo', - 'Etc/GMT-9', - 'Pacific/Palau', - ], - }, - { - value: 'Korea Standard Time', - abbr: 'KST', - utc: ['Asia/Pyongyang', 'Asia/Seoul'], - }, - { - value: 'Cen. Australia Standard Time', - abbr: 'CAST', - utc: ['Australia/Adelaide', 'Australia/Broken_Hill'], - }, - { - value: 'AUS Central Standard Time', - abbr: 'ACST', - utc: ['Australia/Darwin'], - }, - { - value: 'E. Australia Standard Time', - abbr: 'EAST', - utc: ['Australia/Brisbane', 'Australia/Lindeman'], - }, - { - value: 'AUS Eastern Standard Time', - abbr: 'AEST', - utc: ['Australia/Melbourne', 'Australia/Sydney'], - }, - { - value: 'West Pacific Standard Time', - abbr: 'WPST', - utc: [ - 'Antarctica/DumontDUrville', - 'Etc/GMT-10', - 'Pacific/Guam', - 'Pacific/Port_Moresby', - 'Pacific/Saipan', - 'Pacific/Truk', - ], - }, - { - value: 'Tasmania Standard Time', - abbr: 'TST', - utc: ['Australia/Currie', 'Australia/Hobart'], - }, - { - value: 'Yakutsk Standard Time', - abbr: 'YST', - utc: ['Asia/Chita', 'Asia/Khandyga', 'Asia/Yakutsk'], - }, - { - value: 'Central Pacific Standard Time', - abbr: 'CPST', - text: '(UTC+11:00) Solomon Is., New Caledonia', - utc: [ - 'Antarctica/Macquarie', - 'Etc/GMT-11', - 'Pacific/Efate', - 'Pacific/Guadalcanal', - 'Pacific/Kosrae', - 'Pacific/Noumea', - 'Pacific/Ponape', - ], - }, - { - value: 'Vladivostok Standard Time', - abbr: 'VST', - utc: ['Asia/Sakhalin', 'Asia/Ust-Nera', 'Asia/Vladivostok'], - }, - { - value: 'New Zealand Standard Time', - abbr: 'NZST', - utc: ['Antarctica/McMurdo', 'Pacific/Auckland'], - }, - { - value: 'UTC+12', - abbr: 'U', - utc: [ - 'Etc/GMT-12', - 'Pacific/Funafuti', - 'Pacific/Kwajalein', - 'Pacific/Majuro', - 'Pacific/Nauru', - 'Pacific/Tarawa', - 'Pacific/Wake', - 'Pacific/Wallis', - ], - }, - { - value: 'Fiji Standard Time', - abbr: 'FST', - utc: ['Pacific/Fiji'], - }, - { - value: 'Magadan Standard Time', - abbr: 'MST', - utc: [ - 'Asia/Anadyr', - 'Asia/Kamchatka', - 'Asia/Magadan', - 'Asia/Srednekolymsk', - ], - }, - { - value: 'Kamchatka Standard Time', - abbr: 'KDT', - utc: ['Asia/Kamchatka'], - }, - { - value: 'Tonga Standard Time', - abbr: 'TST', - utc: [ - 'Etc/GMT-13', - 'Pacific/Enderbury', - 'Pacific/Fakaofo', - 'Pacific/Tongatapu', - ], - }, - { - value: 'Samoa Standard Time', - abbr: 'SST', - utc: ['Pacific/Apia'], - }, +/** + * Fixed-offset zones, which `Intl.supportedValuesOf` leaves out. The sign is + * inverted by IANA convention: `Etc/GMT-10` is UTC+10. + */ +const FIXED_OFFSET_ZONES = [ + ...Array.from({ length: 12 }, (_, i) => `Etc/GMT+${i + 1}`), + ...Array.from({ length: 14 }, (_, i) => `Etc/GMT-${i + 1}`), ]; -export const TIMEZONES_IANA = unique( - flatten(TIMEZONES.map((_) => _.utc)), -).sort(); +/** + * Sorted IANA time zone names that the browser supports, plus UTC (some + * browsers leave it out) and the fixed-offset zones. Browsers without + * `Intl.supportedValuesOf` get the local zone in place of the full list. + */ +function supportedTimezones(): string[] { + const list = (Intl as IntlWithValues).supportedValuesOf?.('timeZone') || [ + Intl.DateTimeFormat().resolvedOptions().timeZone, + ]; + return [ + ...new Set(['UTC', ...list.filter(Boolean), ...FIXED_OFFSET_ZONES]), + ].sort(); +} + +/** IANA time zone names offered in time zone pickers */ +export const TIMEZONES_IANA = supportedTimezones(); diff --git a/libs/common/src/tests/timezones.spec.ts b/libs/common/src/tests/timezones.spec.ts new file mode 100644 index 00000000000..3abbecfd516 --- /dev/null +++ b/libs/common/src/tests/timezones.spec.ts @@ -0,0 +1,10 @@ +import { TIMEZONES_IANA } from '../lib/timezones'; + +describe('TIMEZONES_IANA', () => { + it('should offer UTC and the fixed-offset zones with the browser list', () => { + expect(TIMEZONES_IANA).toContain('UTC'); + expect(TIMEZONES_IANA).toContain('Etc/GMT-10'); + expect(TIMEZONES_IANA).toContain('Australia/Sydney'); + expect(TIMEZONES_IANA).toEqual([...TIMEZONES_IANA].sort()); + }); +}); From 56c290b1a8b84fd20b2d3a4a573a70b3a9ee40bf Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 12:04:01 +1100 Subject: [PATCH 13/17] fix(common): use current IANA names in the time zone list Chrome and Safari return legacy names such as Asia/Calcutta from Intl.supportedValuesOf. Map them to the current IANA names so that searches for Kolkata, Kyiv or Ho Chi Minh find a match. --- libs/common/src/lib/timezones.ts | 35 ++++++++++++++++++++++++- libs/common/src/tests/timezones.spec.ts | 7 +++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/libs/common/src/lib/timezones.ts b/libs/common/src/lib/timezones.ts index b98c9040794..844f5b21459 100644 --- a/libs/common/src/lib/timezones.ts +++ b/libs/common/src/lib/timezones.ts @@ -11,17 +11,50 @@ const FIXED_OFFSET_ZONES = [ ...Array.from({ length: 14 }, (_, i) => `Etc/GMT-${i + 1}`), ]; +/** + * Legacy names that Chrome and Safari return in place of the current IANA + * names. Firefox already returns the current names. + */ +const LEGACY_ZONE_NAMES: Record = { + 'Africa/Asmera': 'Africa/Asmara', + 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires', + 'America/Catamarca': 'America/Argentina/Catamarca', + 'America/Coral_Harbour': 'America/Atikokan', + 'America/Cordoba': 'America/Argentina/Cordoba', + 'America/Godthab': 'America/Nuuk', + 'America/Indianapolis': 'America/Indiana/Indianapolis', + 'America/Jujuy': 'America/Argentina/Jujuy', + 'America/Louisville': 'America/Kentucky/Louisville', + 'America/Mendoza': 'America/Argentina/Mendoza', + 'Asia/Calcutta': 'Asia/Kolkata', + 'Asia/Katmandu': 'Asia/Kathmandu', + 'Asia/Rangoon': 'Asia/Yangon', + 'Asia/Saigon': 'Asia/Ho_Chi_Minh', + 'Atlantic/Faeroe': 'Atlantic/Faroe', + 'Europe/Kiev': 'Europe/Kyiv', + 'Pacific/Enderbury': 'Pacific/Kanton', + 'Pacific/Ponape': 'Pacific/Pohnpei', + 'Pacific/Truk': 'Pacific/Chuuk', +}; + /** * Sorted IANA time zone names that the browser supports, plus UTC (some * browsers leave it out) and the fixed-offset zones. Browsers without * `Intl.supportedValuesOf` get the local zone in place of the full list. + * Legacy names are changed to their current IANA names. */ function supportedTimezones(): string[] { const list = (Intl as IntlWithValues).supportedValuesOf?.('timeZone') || [ Intl.DateTimeFormat().resolvedOptions().timeZone, ]; return [ - ...new Set(['UTC', ...list.filter(Boolean), ...FIXED_OFFSET_ZONES]), + ...new Set([ + 'UTC', + ...list + .filter(Boolean) + .map((zone) => LEGACY_ZONE_NAMES[zone] ?? zone), + ...FIXED_OFFSET_ZONES, + ]), ].sort(); } diff --git a/libs/common/src/tests/timezones.spec.ts b/libs/common/src/tests/timezones.spec.ts index 3abbecfd516..8b266ab37f6 100644 --- a/libs/common/src/tests/timezones.spec.ts +++ b/libs/common/src/tests/timezones.spec.ts @@ -7,4 +7,11 @@ describe('TIMEZONES_IANA', () => { expect(TIMEZONES_IANA).toContain('Australia/Sydney'); expect(TIMEZONES_IANA).toEqual([...TIMEZONES_IANA].sort()); }); + + it('should use current IANA names in place of legacy names', () => { + expect(TIMEZONES_IANA).toContain('Asia/Kolkata'); + expect(TIMEZONES_IANA).toContain('Europe/Kyiv'); + expect(TIMEZONES_IANA).not.toContain('Asia/Calcutta'); + expect(TIMEZONES_IANA).not.toContain('Europe/Kiev'); + }); }); From df5d0d4ecb08e47d5e27edfeb95097721691d07c Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 12:04:01 +1100 Subject: [PATCH 14/17] docs(e2e): name the current locker bank query in the seed comment --- e2e/support/concierge/locker.seed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/support/concierge/locker.seed.ts b/e2e/support/concierge/locker.seed.ts index bec381f3ed5..a2dee31a176 100644 --- a/e2e/support/concierge/locker.seed.ts +++ b/e2e/support/concierge/locker.seed.ts @@ -13,7 +13,7 @@ * Both type names come from `libs/assets/src/lib/locker-assets.fn.ts`, and the * category is HIDDEN — unlike the equipment seeder, where a hidden category * would make the assets invisible. Lockers are read through - * `queryLockerBankAssets` / `queryLockerAssets`, which resolve the type id + * `queryLockerBankAssetsForZones` / `queryLockerAssets`, which resolve the type id * themselves rather than going through the visible-asset filter, so hidden is * correct here and copying the equipment seeder's `hidden: false` would put * locker banks into the general asset list. From c1061fac6c8dddbdb7ee38fe9d62b39edd6e3088 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 12:50:24 +1100 Subject: [PATCH 15/17] refactor(concierge): remove the unused desks topbar No template renders desks-topbar. Only its own spec and a mock in the desks component spec referenced it. --- .../e2e/local/concierge-desks.spec.ts | 5 +- .../src/app/desks/desks-topbar.component.ts | 336 ------------------ .../desks/desks-topbar.component.spec.ts | 111 ------ .../src/tests/desks/desks.component.spec.ts | 2 - e2e/support/concierge/desks.page.ts | 7 +- 5 files changed, 5 insertions(+), 456 deletions(-) delete mode 100644 apps/concierge/src/app/desks/desks-topbar.component.ts delete mode 100644 apps/concierge/src/tests/desks/desks-topbar.component.spec.ts diff --git a/apps/concierge/e2e/local/concierge-desks.spec.ts b/apps/concierge/e2e/local/concierge-desks.spec.ts index 539614e381a..d848c51a9c2 100644 --- a/apps/concierge/e2e/local/concierge-desks.spec.ts +++ b/apps/concierge/e2e/local/concierge-desks.spec.ts @@ -23,9 +23,8 @@ * * 2. **The listing shows one day, and `?date=` does not work on this route.** * `DesksComponent` reads only `zone_ids` from the query string and returns - * early without it; the `date` parameter is handled by - * `desks-topbar.component.ts`, which is referenced only by unit tests and is - * not rendered here. So these specs book for TODAY. + * early without it; nothing on this route reads the `date` parameter. So + * these specs book for TODAY. * * ## Why every assertion ends at the backend * diff --git a/apps/concierge/src/app/desks/desks-topbar.component.ts b/apps/concierge/src/app/desks/desks-topbar.component.ts deleted file mode 100644 index 48a546a9c12..00000000000 --- a/apps/concierge/src/app/desks/desks-topbar.component.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { - Component, - computed, - effect, - inject, - OnInit, - signal, - untracked, -} from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; - -import { FormsModule } from '@angular/forms'; -import { MatRippleModule } from '@angular/material/core'; -import { MatDialog } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatSelectModule } from '@angular/material/select'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { showBooking } from '@placeos/bookings'; -import { - AsyncHandler, - csvToJson, - Desk, - loadTextFileFromInputEvent, - notifyError, - notifyInfo, - OrganisationService, - randomInt, - SettingsService, -} from '@placeos/common'; -import { IconComponent, TranslatePipe } from '@placeos/components'; -import { DateOptionsComponent } from '@placeos/form-fields'; -import { BookingRulesModalComponent } from '../ui/booking-rules-modal.component'; -import { SearchbarComponent } from '../ui/searchbar.component'; -import { DeskBookModalComponent } from './desk-book-modal.component'; -import { DesksStateService } from './desks-state.service'; - -@Component({ - selector: 'desks-topbar', - template: ` -
- - - @if (!is_map()) { - - {{ 'COMMON.LEVEL_ALL' | translate }} - - } - @for (level of levels(); track level) { - - {{ level.display_name || level.name }} - - } - - - @if (!manage()) { - - } - @if (manage()) { - - } - @if (manage()) { - - - } - @if (manage()) { - - } - @if (manage()) { - - } -
- - -
- `, - styles: [ - ` - mat-form-field { - height: 3.25em; - width: 8em; - } - `, - ], - imports: [ - DateOptionsComponent, - SearchbarComponent, - MatRippleModule, - MatTooltipModule, - IconComponent, - MatFormFieldModule, - MatSelectModule, - TranslatePipe, - FormsModule, - ], -}) -export class DesksTopbarComponent extends AsyncHandler implements OnInit { - private _desks = inject(DesksStateService); - private _org = inject(OrganisationService); - private _route = inject(ActivatedRoute); - private _router = inject(Router); - private _dialog = inject(MatDialog); - private _settings = inject(SettingsService); - - /** List of levels for the active building */ - public readonly all_levels = this._org.active_levels; - /** List of levels with bookable desk resources */ - public readonly bookable_levels = this._desks.levels; - /** List of levels to show for the current view */ - public readonly levels = computed(() => - this.manage() ? this.all_levels() : this.bookable_levels(), - ); - /** List of levels for the active building */ - public readonly filters = this._desks.filters; - - public manage = signal(false); - public is_map = signal(false); - /** Set filtered date */ - public readonly setDate = (date) => this._desks.setFilters({ date }); - public readonly setFilters = (o) => this._desks.setFilters(o); - /** Update active zones for desks */ - public readonly updateZones = (zones: string[]) => { - this._router.navigate([], { - relativeTo: this._route, - queryParams: { zone_ids: zones.join(',') }, - queryParamsHandling: 'merge', - }); - this._desks.setFilters({ zones }); - }; - - constructor() { - super(); - // Snap the active zone selection to the available levels once the - // organisation data has initialised. - effect(() => { - if (!this._org.initialised()) return; - const levels = this._org.active_levels(); - untracked(() => { - const filters = this.filters(); - const zones = - filters?.zones?.filter( - (zone) => - levels.find((lvl) => lvl.id === zone) || - zone === 'All', - ) || []; - if (!zones.length && levels.length) zones.push('All'); - this.updateZones(zones); - }); - }); - } - - public async ngOnInit() { - await this._org.waitUntilInitialised(); - this.subscription( - 'route.query', - this._route.queryParamMap.subscribe((params) => { - if (params.has('zone_ids')) { - const zones = params.get('zone_ids').split(','); - if (zones.length) { - this.updateZones(zones); - const level = this._org.levelWithID(zones); - if (!level) return; - this._org.building = this._org.buildings.find( - (bld) => bld.id === level.parent_id, - ); - } - } - if (params.has('date')) { - this.setDate(new Date(+params.get('date'))); - } - if (params.has('approve')) { - this.approve(params.get('approve')); - } else if (params.has('reject')) { - this.reject(params.get('reject')); - } - this.manage.set(this._router.url?.includes('manage')); - this.is_map.set(this._router.url?.includes('map')); - }), - ); - this.subscription( - 'router.events', - this._router.events.subscribe(() => { - this.manage.set(this._router.url?.includes('manage')); - this.is_map.set(this._router.url?.includes('map')); - }), - ); - this.manage.set(this._router.url?.includes('manage')); - this.is_map.set(this._router.url?.includes('map')); - } - - public newDesk() { - // desk assets are saved straight away, collect the details first - if (this._settings.get('app.desks.use_assets')) { - this._desks.editDesk(); - return; - } - const desk = new Desk({ id: `desk-${randomInt(999_999)}` }); - desk.staff_name = `[NEW_DESK]`; - this._desks.addDesks([desk]); - notifyInfo('New desk added to local data.', undefined, () => - notifyInfo('Make sure to save the new desk before using it.'), - ); - } - - public newDeskBooking() { - this._dialog.open(DeskBookModalComponent, {}); - } - - public manageRestrictions() { - this._dialog.open(BookingRulesModalComponent, { - data: { type: 'desk' }, - }); - } - - public async loadCSVData(event: InputEvent) { - const data = await loadTextFileFromInputEvent(event).catch(([m, e]) => { - notifyError(m); - throw e; - }); - try { - const list = csvToJson(data) || []; - this._desks.addDesks( - list.map((_) => ({ - ..._, - id: _.id || `desk-${randomInt(999_999)}`, - })), - ); - } catch (e) { - console.error(e); - } - } - - public downloadTemplate() { - this._desks.downloadDesksCSV(); - } - - /** - * Aprrove a desk booking - * @param id Booking ID to approve - */ - private async approve(id: string) { - const booking = await showBooking(id); - if (booking) { - this._desks.approveDesk(booking); - } - } - - /** - * Reject a desk booking - * @param id Booking ID to reject - */ - private async reject(id: string) { - const booking = await showBooking(id); - if (booking) { - this._desks.rejectDesk(booking); - } - } -} diff --git a/apps/concierge/src/tests/desks/desks-topbar.component.spec.ts b/apps/concierge/src/tests/desks/desks-topbar.component.spec.ts deleted file mode 100644 index 43e00b768a2..00000000000 --- a/apps/concierge/src/tests/desks/desks-topbar.component.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatSelectModule } from '@angular/material/select'; -import { - createRoutingFactory, - SpectatorRouting, -} from '@ngneat/spectator/vitest'; -import { OrganisationService, SettingsService } from '@placeos/common'; -import { MockComponent, MockProvider } from 'ng-mocks'; -import { of, timer } from 'rxjs'; - -import { DateOptionsComponent } from '@placeos/form-fields'; -import { DesksStateService } from '../../app/desks/desks-state.service'; -import { DesksTopbarComponent } from '../../app/desks/desks-topbar.component'; -import { SearchbarComponent } from '../../app/ui/searchbar.component'; - -import { MatDialog } from '@angular/material/dialog'; - -vi.mock('@placeos/ts-client', { spy: true }); - -import * as ts_client from '@placeos/ts-client'; - -describe('DesksTopbarComponent', () => { - let spectator: SpectatorRouting; - const createComponent = createRoutingFactory({ - component: DesksTopbarComponent, - declarations: [ - MockComponent(SearchbarComponent), - MockComponent(DateOptionsComponent), - ], - providers: [ - MockProvider(DesksStateService, { - filters: signal({}), - levels: signal([]), - setFilters: vi.fn(), - approveDesk: vi.fn(), - rejectDesk: vi.fn(), - addDesks: vi.fn(), - editDesk: vi.fn(), - }), - MockProvider(SettingsService, { get: vi.fn() }), - MockProvider(OrganisationService, { - active_levels: signal([]), - initialised: signal(true), - levelWithID: vi.fn(), - buildings: [], - }), - MockProvider(MatDialog, { - open: vi.fn(() => ({ afterClosed: vi.fn(() => of()) }) as any), - }), - ], - imports: [ - MatFormFieldModule, - MatSelectModule, - MatProgressBarModule, - FormsModule, - ], - }); - - beforeEach(() => { - // showBooking() reads via ts-client `get`; resolve a booking so the - // approve/reject flow reaches the state service. - vi.mocked(ts_client.get).mockResolvedValue({ id: 'bkn-123' } as any); - spectator = createComponent(); - }); - - it('should collect the details of a new desk asset before saving it', () => { - const desks = spectator.inject(DesksStateService); - vi.mocked(spectator.inject(SettingsService).get).mockImplementation( - (key: string) => - key === 'app.desks.use_assets' ? true : undefined, - ); - - spectator.component.newDesk(); - - expect(desks.editDesk).toHaveBeenCalledWith(); - expect(desks.addDesks).not.toHaveBeenCalled(); - }); - - it('should handle zone_ids query param', async () => { - vi.spyOn(spectator.component, 'updateZones'); - expect(spectator.component.updateZones).not.toHaveBeenCalled(); - spectator.setRouteQueryParam('zone_ids', 'zone-1234,zone-2345'); - spectator.detectChanges(); - await timer(5).toPromise(); - expect(spectator.component.updateZones).toHaveBeenCalledWith([ - 'zone-1234', - 'zone-2345', - ]); - }); - - it('should handle approve query param', async () => { - spectator.setRouteQueryParam('approve', 'bkn-123'); - spectator.detectChanges(); - await timer(5).toPromise(); - expect( - spectator.inject(DesksStateService).approveDesk, - ).toHaveBeenCalled(); - }); - - it('should handle reject query param', async () => { - spectator.setRouteQueryParam('reject', 'bkn-123'); - spectator.detectChanges(); - await timer(5).toPromise(); - expect( - spectator.inject(DesksStateService).rejectDesk, - ).toHaveBeenCalled(); - }); -}); diff --git a/apps/concierge/src/tests/desks/desks.component.spec.ts b/apps/concierge/src/tests/desks/desks.component.spec.ts index 42fa4142562..a34f570e058 100644 --- a/apps/concierge/src/tests/desks/desks.component.spec.ts +++ b/apps/concierge/src/tests/desks/desks.component.spec.ts @@ -17,7 +17,6 @@ import { DeskQrItem, DesksStateService, } from '../../app/desks/desks-state.service'; -import { DesksTopbarComponent } from '../../app/desks/desks-topbar.component'; import { DesksComponent } from '../../app/desks/desks.component'; import { ApplicationSidebarComponent } from '../../app/ui/app-sidebar.component'; import { SearchbarComponent } from '../../app/ui/searchbar.component'; @@ -52,7 +51,6 @@ describe('DesksComponent', () => { component: DesksComponent, declarations: [ MockComponent(ApplicationSidebarComponent), - MockComponent(DesksTopbarComponent), MockComponent(IconComponent), MockComponent(SearchbarComponent), ], diff --git a/e2e/support/concierge/desks.page.ts b/e2e/support/concierge/desks.page.ts index c732599141d..add4a18de2f 100644 --- a/e2e/support/concierge/desks.page.ts +++ b/e2e/support/concierge/desks.page.ts @@ -19,10 +19,9 @@ * The listing queries a single local day, defaulting to today. `?date=` is * NOT honoured on this route: the component actually rendered * (`DesksComponent`) reads only `zone_ids` from the query and returns early - * without it. The `date` parameter is handled by `desks-topbar.component.ts`, - * which is referenced only by unit tests and is not on this route. So the specs - * book for TODAY, and the date is stepped with the arrow controls when a test - * needs another day. + * without it, and nothing on this route reads the `date` parameter. So the + * specs book for TODAY, and the date is stepped with the arrow controls when a + * test needs another day. * * ## It filters by the BUILDING zone * From c299951d106798407855d972f5346f48fe5d17c3 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 12:50:24 +1100 Subject: [PATCH 16/17] refactor(concierge): share the desk CSV import The desks view and the desk manager parsed the CSV and gave new rows an id in two copies. DesksStateService.importDesksCsv now does both. --- .../src/app/desks/desks-manage.component.ts | 10 +--------- apps/concierge/src/app/desks/desks-state.service.ts | 12 ++++++++++++ apps/concierge/src/app/desks/desks.component.ts | 10 +--------- .../src/tests/desks/desks-state.service.spec.ts | 11 ++++++----- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/apps/concierge/src/app/desks/desks-manage.component.ts b/apps/concierge/src/app/desks/desks-manage.component.ts index 281deb7d347..c6e9888fb4a 100644 --- a/apps/concierge/src/app/desks/desks-manage.component.ts +++ b/apps/concierge/src/app/desks/desks-manage.component.ts @@ -7,14 +7,12 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTooltipModule } from '@angular/material/tooltip'; import { AsyncHandler, - csvToJson, Desk, i18n, loadTextFileFromInputEvent, notifyError, notifySuccess, OrganisationService, - randomInt, settingSignal, SettingsService, } from '@placeos/common'; @@ -386,13 +384,7 @@ export class DesksManageComponent extends AsyncHandler { throw e; }); try { - const list = csvToJson(data) || []; - this._state.addDesks( - list.map((_) => ({ - ..._, - id: _.id || `desk-${randomInt(999_999)}`, - })), - ); + this._state.importDesksCsv(data); } catch (e) { console.error(e); } diff --git a/apps/concierge/src/app/desks/desks-state.service.ts b/apps/concierge/src/app/desks/desks-state.service.ts index e7cd3319ae6..ce87b4262a0 100644 --- a/apps/concierge/src/app/desks/desks-state.service.ts +++ b/apps/concierge/src/app/desks/desks-state.service.ts @@ -35,6 +35,7 @@ import { AsyncHandler, Booking, BuildingLevel, + csvToJson, Desk, downloadFile, getTimezoneDifferenceInHours, @@ -510,6 +511,17 @@ export class DesksStateService extends AsyncHandler { downloadFile('desks.csv', jsonToCsv(rows)); } + /** Save the desks in CSV text. Rows without an `id` get a new one. */ + public importDesksCsv(csv: string) { + const rows: Partial[] = csvToJson(csv) || []; + return this.addDesks( + rows.map((row) => ({ + ...row, + id: row.id || `desk-${randomInt(999_999)}`, + })), + ); + } + /** * Save new or imported desks. Pass imported CSV rows as plain objects, not * `Desk` instances, so only the row's own columns update an existing desk. diff --git a/apps/concierge/src/app/desks/desks.component.ts b/apps/concierge/src/app/desks/desks.component.ts index eed6b2e0003..cf84661cd03 100644 --- a/apps/concierge/src/app/desks/desks.component.ts +++ b/apps/concierge/src/app/desks/desks.component.ts @@ -19,13 +19,11 @@ import { import { AsyncHandler, BuildingLevel, - csvToJson, downloadFile, jsonToCsv, loadTextFileFromInputEvent, notifyError, OrganisationService, - randomInt, settingSignal, SettingsService, } from '@placeos/common'; @@ -530,13 +528,7 @@ export class DesksComponent extends AsyncHandler implements OnInit, OnDestroy { throw e; }); try { - const list = csvToJson(data) || []; - this._state.addDesks( - list.map((_) => ({ - ..._, - id: _.id || `desk-${randomInt(999_999)}`, - })), - ); + this._state.importDesksCsv(data); } catch (e) { console.error(e); } diff --git a/apps/concierge/src/tests/desks/desks-state.service.spec.ts b/apps/concierge/src/tests/desks/desks-state.service.spec.ts index 932ad8f23ab..fb6905f06aa 100644 --- a/apps/concierge/src/tests/desks/desks-state.service.spec.ts +++ b/apps/concierge/src/tests/desks/desks-state.service.spec.ts @@ -300,11 +300,11 @@ describe('DesksStateService', () => { vi.mocked(ts_client_mod.addAsset).mockResolvedValue({} as any); spectator.service.setFilters({ zones: ['level-1'] }); - // a metadata era CSV row, identified by the original desk id - await spectator.service.addDesks([ - { id: 'desk-6.06.1', name: 'New Name', zone_id: 'level-2' } as any, - { id: 'desk-new', name: 'Brand New' }, - ]); + // a metadata era CSV row, identified by the original desk id, and a + // new row without an id + await spectator.service.importDesksCsv( + 'id,name,zone_id\ndesk-6.06.1,New Name,level-2\n,Brand New,', + ); expect(ts_client_mod.updateAsset).toHaveBeenCalledTimes(1); expect(ts_client_mod.updateAsset).toHaveBeenCalledWith( @@ -324,6 +324,7 @@ describe('DesksStateService', () => { expect.objectContaining({ zone_id: 'level-1', identifier: 'Brand New', + map_id: expect.stringMatching(/^desk-\d+$/), }), ); }); From 096614173282ec1f83e08ef07c094e0d3456ae7b Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 2 Nov 2026 12:50:24 +1100 Subject: [PATCH 17/17] refactor(assets): share the desk asset lookup by legacy id The code flow and the desk flow both queried the org's desk assets and matched the legacy id. queryDeskAssetByLegacyId now does this. queryAllAssetPages is private again, because only assets.fn.ts uses it. --- apps/workplace/src/app/book/code-flow.component.ts | 13 +++++-------- .../app/book/desk-flow-new/desk-flow.component.ts | 10 ++++------ libs/assets/src/lib/assets.fn.ts | 2 +- libs/assets/src/lib/desk-assets.fn.ts | 13 +++++++++++++ 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/workplace/src/app/book/code-flow.component.ts b/apps/workplace/src/app/book/code-flow.component.ts index 7e0dbe851e2..725c22e3997 100644 --- a/apps/workplace/src/app/book/code-flow.component.ts +++ b/apps/workplace/src/app/book/code-flow.component.ts @@ -10,11 +10,7 @@ import { viewChild, } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { - deskFromAsset, - findDeskByLegacyId, - queryDeskAssetsInZone, -} from '@placeos/assets'; +import { queryDeskAssetByLegacyId } from '@placeos/assets'; import { BookingFormService, queryBookings, @@ -355,9 +351,10 @@ export class BookCodeFlowComponent if (!this._settings.get('app.desks.use_assets')) return code; const org_id = this._org.organisation?.id; if (!org_id) return code; - const assets = await queryDeskAssetsInZone(org_id).catch(() => []); - const desks = assets.map((asset) => deskFromAsset(asset)); - return findDeskByLegacyId(desks, code)?.id || code; + const asset = await queryDeskAssetByLegacyId(org_id, code).catch( + () => undefined, + ); + return asset?.id || code; } private async _checkinEvent(space_id: string, email?: string) { diff --git a/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts index 49c0b1752c9..99733277916 100644 --- a/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts +++ b/apps/workplace/src/app/book/desk-flow-new/desk-flow.component.ts @@ -12,7 +12,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { deskFromAsset, findDeskByLegacyId, - queryDeskAssetsInZone, + queryDeskAssetByLegacyId, } from '@placeos/assets'; import { BookingAsset, @@ -374,12 +374,10 @@ export class DeskFlowNewComponent extends AsyncHandler implements OnInit { private async _findDeskAssetFromBuildings( asset_id: string, ): Promise { - const assets = await queryDeskAssetsInZone( + const asset = await queryDeskAssetByLegacyId( this._org.organisation?.id, - ).catch(() => []); - const desks = assets.map((asset) => deskFromAsset(asset)); - const asset = - assets[desks.indexOf(findDeskByLegacyId(desks, asset_id))]; + asset_id, + ).catch(() => undefined); if (!asset) return null; await this._org.loadBuildingsForZones([asset.zones || []]); const level = (this._org.levels || []).find( diff --git a/libs/assets/src/lib/assets.fn.ts b/libs/assets/src/lib/assets.fn.ts index 4f7935a1b92..0063eee94bd 100644 --- a/libs/assets/src/lib/assets.fn.ts +++ b/libs/assets/src/lib/assets.fn.ts @@ -255,7 +255,7 @@ const MAX_ASSET_PAGES = 100; * Query assets, following pagination until every page is loaded. * `zones` is a comma separated list, assets must be in every zone listed. */ -export async function queryAllAssetPages( +async function queryAllAssetPages( query: Parameters[0] & { zones?: string } = {}, ) { let response = await queryAssetsAPI({ diff --git a/libs/assets/src/lib/desk-assets.fn.ts b/libs/assets/src/lib/desk-assets.fn.ts index e636aeb27e3..d1f6ac34861 100644 --- a/libs/assets/src/lib/desk-assets.fn.ts +++ b/libs/assets/src/lib/desk-assets.fn.ts @@ -59,6 +59,19 @@ export function findDeskByLegacyId( ); } +/** + * Find the desk asset within a building, region or org for an id from a link, + * check-in code or favourite. Matches like `findDeskByLegacyId`. + */ +export async function queryDeskAssetByLegacyId( + zone_id: string, + id: string, +): Promise { + const assets = await queryDeskAssetsInZone(zone_id); + const desks = assets.map((asset) => deskFromAsset(asset)); + return assets[desks.indexOf(findDeskByLegacyId(desks, id))]; +} + /** Fields with a dedicated place on the asset or desk, everything else is kept in `other_data` */ const DESK_ASSET_KEYS = new Set([ 'id',