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/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-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-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/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/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/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+$/), }), ); }); 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/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/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/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-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/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/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/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/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/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/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/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/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 * 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. 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-list-field.component.ts b/libs/assets/src/lib/asset-list-field.component.ts index d1fa82023b2..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,10 +18,12 @@ import { AssetRequest, i18n, notifyError, + provideValueAccessor, randomInt, randomString, SettingsService, Space, + toggleListItem, } from '@placeos/common'; import { endOfDay, startOfDay } from 'date-fns'; @@ -240,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, @@ -492,18 +486,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-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..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,13 +1,17 @@ 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'; 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 +53,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(); - 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-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/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/assets/src/lib/assets.fn.spec.ts b/libs/assets/src/lib/assets.fn.spec.ts index 059096654a7..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(); @@ -326,42 +371,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..0063eee94bd 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, @@ -53,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, @@ -134,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({ @@ -291,22 +412,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.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 96f308889cd..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) { @@ -216,16 +175,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/desk-assets.fn.ts b/libs/assets/src/lib/desk-assets.fn.ts index e95c13cd55e..d1f6ac34861 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); } /** @@ -125,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', 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..5af71090b10 100644 --- a/libs/assets/src/lib/locker-assets.fn.ts +++ b/libs/assets/src/lib/locker-assets.fn.ts @@ -1,133 +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; -} - -export async function queryLockerBankAssets( - zone_id: string, -): Promise { - const type_id = await resolveLockerBankTypeId(); - const response = await queryAllAssetPages({ zone_id, type_id }); - return response.data; + 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` */ @@ -136,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( @@ -155,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` */ @@ -179,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 */ 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..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, @@ -17,7 +18,6 @@ import { currentUser, flatten, fromBookingRecurrence, - GuestUser, isRecurrenceInstanceDate, readAttendeeFlags, setting, @@ -121,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 { @@ -144,8 +134,7 @@ function withAppVersion(data: NewBookingData): NewBookingData { ...booking_data, extension_data: { ...(booking_data.extension_data || {}), - app_name: appName(), - app_version: APP_VERSION, + ...appDetails(), }, }; } @@ -607,87 +596,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 +668,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 +695,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..357b4bf4f16 100644 --- a/libs/bookings/src/lib/desk-list-field.component.ts +++ b/libs/bookings/src/lib/desk-list-field.component.ts @@ -1,11 +1,13 @@ -import { Component, forwardRef, inject, input, 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 { FormsModule } from '@angular/forms'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; -import { SETTING_KEYS } 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'; @@ -24,31 +26,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 }} - - } -
-
- }
@@ -141,19 +118,11 @@ const EMPTY_FAVS: string[] = [];
`, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeskListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => DeskListFieldComponent)], imports: [ IconComponent, TranslatePipe, MatRippleModule, - MatCheckboxModule, - FormsModule, AuthenticatedImageDirective, ], }) @@ -161,11 +130,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,29 +188,11 @@ 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; - 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-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-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/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..41ccdd24723 100644 --- a/libs/bookings/src/lib/locker-list-field.component.ts +++ b/libs/bookings/src/lib/locker-list-field.component.ts @@ -1,12 +1,9 @@ -import { Component, forwardRef, inject, input, 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 { 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 { 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'; @@ -14,8 +11,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 +20,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 }} - - } -
-
- }
@@ -142,42 +110,25 @@ const EMPTY_FAVS: string[] = [];
`, styles: [``], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => LockerListFieldComponent), - multi: true, - }, - ], + providers: [provideValueAccessor(() => LockerListFieldComponent)], imports: [ IconComponent, 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 +173,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/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-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/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..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 } 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, @@ -206,18 +204,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/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/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-list-field.component.ts b/libs/catering/src/lib/catering-list-field.component.ts index 1512029d9f5..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,9 +19,12 @@ import { CateringOrder, i18n, OrganisationService, + provideValueAccessor, randomString, + SETTING_KEYS, SettingsService, Space, + toggleListItem, } from '@placeos/common'; import { endOfDay, startOfDay } from 'date-fns'; @@ -317,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, @@ -383,7 +378,7 @@ export class CateringListFieldComponent public readonly favorites = computed(() => { return ( this._settings.signal( - 'favourite_menu_items', + SETTING_KEYS.FAVORITE_CATERING, EMPTY_FAVS, true, )() || EMPTY_FAVS @@ -607,19 +602,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('favourite_menu_items', [ - ...fav_list, - cateringitem.id, - ]); - } else { - this._settings.saveUserSetting( - 'favourite_menu_items', - 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-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 377a354b26e..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'; @@ -54,7 +55,6 @@ const EMPTY_FAVS: string[] = []; > { return ( this._settings.signal( - SETTING_KEYS.FAVORITE_DESKS, + SETTING_KEYS.FAVORITE_CATERING, EMPTY_FAVS, true, )() || EMPTY_FAVS ); }); - public readonly selected_keys = computed(() => - 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; @@ -283,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_DESKS, [ - ...fav_list, - item, - ]); - } else { - this._settings.saveUserSetting( - SETTING_KEYS.FAVORITE_DESKS, - fav_list.filter((_) => _ !== item), - ); - } + this._settings.saveUserSetting( + SETTING_KEYS.FAVORITE_CATERING, + toggleListItem(this.favorites(), item), + ); } } 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/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/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/common/src/index.ts b/libs/common/src/index.ts index 69b86bb345d..2b58b3a49ea 100644 --- a/libs/common/src/index.ts +++ b/libs/common/src/index.ts @@ -1,11 +1,9 @@ export * from './lib/booking-rules'; export * from './lib/common'; export * from './lib/currency-options'; -export * from './lib/feature-available.guard'; export * from './lib/external-catering.service'; export * from './lib/fixed-device-helpers'; export * from './lib/google-analytics.service'; -export * from './lib/keep-alive.service'; export * from './lib/lazy-route-reload'; export * from './lib/locale-data'; export * from './lib/locale.service'; @@ -14,7 +12,6 @@ export * from './lib/mapspeople.service'; export * from './lib/org/asset-zones'; export * from './lib/org/building.utilities'; export * from './lib/org/organisation.service'; -export * from './lib/org/organisation.utilities'; export * from './lib/placeos.service'; export * from './lib/public-mode'; export * from './lib/recurrence'; @@ -29,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/common.ts b/libs/common/src/lib/common.ts index c675836b6a2..41838fdbacc 100644 --- a/libs/common/src/lib/common.ts +++ b/libs/common/src/lib/common.ts @@ -4,7 +4,6 @@ export { MapsPeopleService } from './mapspeople.service'; export { PushNotificationService } from './push-notification.service'; export { SettingsService, setting } from './settings.service'; export { VERSION } from './version'; -export { VorlonService } from './vorlon.service'; export * from './api'; export * from './application'; @@ -16,6 +15,5 @@ export * from './notifications'; export * from './placeos'; export * from './settings'; export * from './signal.utilities'; -export * from './spec-helpers'; export * from './types'; export * from './user-state'; diff --git a/libs/common/src/lib/constants.ts b/libs/common/src/lib/constants.ts index c4dd898d260..b941f3a676e 100644 --- a/libs/common/src/lib/constants.ts +++ b/libs/common/src/lib/constants.ts @@ -35,10 +35,3 @@ export const SETTING_KEYS = { export const SECONDS = 1000; export const MINUTES = 60 * SECONDS; -export const HOURS = 60 * MINUTES; -export const DAYS = 24 * HOURS; - -export const SECOND = 1000; -export const MINUTE = 60 * SECONDS; -export const HOUR = 60 * MINUTES; -export const DAY = 24 * HOURS; diff --git a/libs/common/src/lib/feature-available.guard.ts b/libs/common/src/lib/feature-available.guard.ts deleted file mode 100644 index d4fbbfa07cd..00000000000 --- a/libs/common/src/lib/feature-available.guard.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { Router, UrlTree } from '@angular/router'; -import { first } from 'rxjs/operators'; - -import { OrganisationService } from './org/organisation.service'; -import { SettingsService } from './settings.service'; - -export abstract class PLACEOS_APP_ACCESS { - public readonly group: string; -} - -@Injectable({ - providedIn: 'root', -}) -export class FeatureAvailableGuard { - private _router = inject(Router); - private _settings = inject(SettingsService); - private _org = inject(OrganisationService); - - public async canActivate(): Promise { - return this.checkFeature(); - } - - public async canLoad(): Promise { - return this.checkFeature(); - } - - private async checkFeature() { - await this._org.waitUntilInitialised(); - await this._settings.initialised.pipe(first((_) => _)).toPromise(); - const features = this._settings.get('app.disabled_features') || []; - const can_activate = !features.find((_) => - this._router.url.includes(_), - ); - if (!can_activate) { - this._router.navigate(['/']); - } - return !!can_activate; - } -} diff --git a/libs/common/src/lib/general.ts b/libs/common/src/lib/general.ts index bee751a820f..75dc7f40238 100644 --- a/libs/common/src/lib/general.ts +++ b/libs/common/src/lib/general.ts @@ -6,7 +6,6 @@ import { type WritableSignal, } from '@angular/core'; import type { AbstractControl, FormGroup } from '@angular/forms'; -import { Signal as TSSignal } from '@placeos/ts-client'; import { addDays, addHours, @@ -19,14 +18,7 @@ import { startOfDay, } from 'date-fns'; import { fromZonedTime, toZonedTime } from 'date-fns-tz'; -import { - first, - lastValueFrom, - map, - Observable, - Subscription, - take, -} from 'rxjs'; +import { first, lastValueFrom, Observable, Subscription, take } from 'rxjs'; import { i18n, i18nAvailable } from './locale.service'; import { notifyWarn } from './notifications'; import { endOfDayInTimezone, startOfDayInTimezone } from './timezone-helpers'; @@ -127,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); } /** @@ -147,6 +137,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 @@ -171,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 @@ -362,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[]; } /** @@ -436,23 +412,6 @@ function sfc32(a, b, c, d) { }; } -/** Get time format string for locale */ -export function timeFormatString(): string { - return is24HourTime() ? 'HH:mm' : 'h:mm a'; -} - -/** Whether locale string is displayed in 24 hour time */ -export function is24HourTime(): boolean { - const date = new Date(); - const localeString = date - .toLocaleTimeString( - document.querySelector('html').getAttribute('lang') || - navigator.language, - ) - .toLowerCase(); - return localeString.indexOf('am') < 0 && localeString.indexOf('pm') < 0; -} - /** * Structural `instanceof FormGroup` check, so this file does not load * `@angular/forms` into every app's initial bundle. A `FormArray` has an @@ -500,25 +459,6 @@ export function capitalizeFirstLetter(word: string): string { return `${word[0].toUpperCase()}${word.substring(1)}`; } -export function cleanArray( - array: any[], - removal_items: unknown[] = [undefined, null, ''], -) { - return array.filter((_) => !removal_items.includes(_)); -} - -export function addStringKey(value: string, key: string) { - return removeStringKey(value, key) + `[${key}]`; -} - -export function removeStringKey(value: string, key: string) { - const key_value = `[${key}]`; - while (value.includes(key_value)) { - value = value.replace(`[${key}]`, ''); - } - return value; -} - export function isMobileSafari() { return ( [ @@ -534,10 +474,6 @@ export function isMobileSafari() { ); } -export function isNestedFrame() { - return window.location !== window.parent.location; -} - // Calculate the distance between geographical cooridinates export function calculateDistance( lat1: number, @@ -752,21 +688,6 @@ export function withTimeout( }); } -/** - * Convert a ts-client signal into an rxjs observable. - * Emits the signal's current value on subscription, like a BehaviorSubject. - * @param signal Signal to convert - */ -export function observableFromSignal(signal: TSSignal): Observable { - if (typeof signal !== 'function' && (signal as any)?.subscribe) { - return signal as unknown as Observable; - } - return new Observable((observer) => { - observer.next(signal()); - return signal.subscribe((value) => observer.next(value)); - }); -} - export interface BookableHoursRange { /** Earliest allowed booking hour (0–24). For example, `8` means 8:00 AM. */ start: number; @@ -939,17 +860,6 @@ export function isWithinBookableHours( return minutes >= start * 60 && minutes < end * 60; } -export function mapLastValueFrom( - obs: Observable, - map_fn: (value: any) => T, -): Promise { - return obs - ? map - ? lastValueFrom(obs.pipe(map(map_fn))) - : lastValueFrom(obs) - : Promise.resolve(null); -} - // --------------------------------------------------------------------------- // Form time synchronisation // --------------------------------------------------------------------------- diff --git a/libs/common/src/lib/keep-alive.service.ts b/libs/common/src/lib/keep-alive.service.ts deleted file mode 100644 index 4968d816d40..00000000000 --- a/libs/common/src/lib/keep-alive.service.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Injectable, signal } from '@angular/core'; -import { getModule } from '@placeos/ts-client'; -import { AsyncHandler } from './async-handler.class'; - -@Injectable({ - providedIn: 'root', -}) -export class KeepAliveService extends AsyncHandler { - private _system_id = signal(''); - private _delay = 3 * 60 * 1000; - - constructor() { - super(); - } - - public setSystem(id: string) { - this._system_id.set(id); - if (id) this._bindTo(id, 'poll'); - } - - public setReloadDelay(delay: number) { - this._delay = delay; - } - - /** List to binding */ - private _bindTo(id: string, name: string, mod = 'SystemHealth') { - const module = getModule(id, mod).variable(name); - this.subscription(`bind:${name}`, module.bind()); - this.subscription( - `listen:${name}`, - module - .listen() - .subscribe(() => - this.timeout('poll', () => location.reload(), this._delay), - ), - ); - } -} diff --git a/libs/common/src/lib/org/organisation.utilities.ts b/libs/common/src/lib/org/organisation.utilities.ts deleted file mode 100644 index a13911e5fe2..00000000000 --- a/libs/common/src/lib/org/organisation.utilities.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { generateMockBuilding } from './building.utilities'; - -let ORG_COUNT = 0; - -export function generateMockOrganisation(): Record { - return { - id: `zone_org-${ORG_COUNT++}`, - name: `Organisation ${ORG_COUNT}`, - buildings: Array(3) - .fill(0) - .map(() => generateMockBuilding()), - settings: { - test: { - nested: { - org: true, - }, - }, - }, - }; -} diff --git a/libs/common/src/lib/settings.interfaces.ts b/libs/common/src/lib/settings.interfaces.ts deleted file mode 100644 index 99cc6da0a4a..00000000000 --- a/libs/common/src/lib/settings.interfaces.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { HashMap, Identity } from './types'; - -export interface SettingsOptions { - /** Whether to add debugging messages to the console */ - debug: boolean; - /** Settings for initialising composer service */ - composer: ComposerSettings; - /** Settings for the application */ - app: ApplicationSettings; - /** Whether to use mock systems and services */ - mock?: boolean; -} - -export interface ComposerSettings { - /** Host name of the ACAEngine server */ - domain: string; - /** Port number used on the ACAEngine server */ - port: string; - /** Route that the root of the application lies */ - route: string; - /** Protocol used by the ACAEngine server */ - protocol: 'http:' | 'https:' | ''; - /** Whether login is handled locally inside the application */ - local_login?: boolean; - /** Whether composer should use the settings domain instead of the origin */ - use_domain?: boolean; -} - -export interface ApplicationSettings { - /** Name of the application */ - title: string; - /** Description of the application */ - description: string; - /** Short name of the application */ - short_name: string; - /** Logo to use on dark background */ - logo_dark: ApplicationIcon; - /** Logo to use on light background */ - logo_light?: ApplicationIcon; - /** Generalised settings for the application */ - general: GeneralAppSettings; - /** Settings for the home page */ - home: HomePageSettings; - /** Settings for the Help/Support page */ - help: HelpPageSettings; - /** Settings for making bookings */ - booking: BookingFlowSettings; - /** Settings for configuring the display of spaces on the UI */ - space_display: SpaceDisplaySettings; - /** Settings for configuring user search */ - directory: UserDirectorySettings; - /** Settings for the mapping sections */ - explore: ExploreMapSettings; - /** Settings for Google Analytics */ - analytics: AnalyticsSettings; - /** Settings for Schedule page */ - schedule: any; - /** Colour for thin environment identifier bars shown at the top and bottom of the app */ - environment_bar?: string; -} - -export interface GeneralAppSettings { - /** Settings for the overlay menu */ - menu: OverlayMenuSettings; -} - -export interface OverlayMenuSettings { - /** List of available links on the overlay menu */ - items: ApplicationLink[]; - /** Copyright display for the application */ - copyright?: string; - /** Display position of the overlay menu */ - position: 'right' | 'middle' | 'left'; -} - -export interface HomePageSettings { - /** List of tiles to display on the home page */ - tiles: ApplicationLink[]; - /** Background CSS for home page */ - background?: string; - /** List of tiles to display on the footer */ - footer?: ApplicationLink[]; -} - -export interface HelpPageSettings { - /** List of tiles to display on the help page */ - tiles: ApplicationLink[]; - /** Background CSS for help page */ - background?: string; - /** Number of columns to display the tiles */ - columns?: number; -} - -export type AvailableBookingFields = - | 'id' - | 'space_list' - | 'title' - | 'date' - | 'duration' - | 'organiser' - | 'attendees' - | 'title' - | 'type' - | 'catering' - | 'recurrence' - | 'body' - | 'configuration'; - -export type AvailableMapBookingFields = - | 'id' - | 'title' - | 'organiser' - | 'date' - | 'duration' - | 'notes' - | 'head_count'; - -export interface BookingFlowSettings { - /** List of available booking types */ - booking_types: Identity[]; - /** List of LOS group */ - los_groups: HashMap; - /** Fields to show on the booking form */ - show_fields: AvailableBookingFields[]; - /** Whether notes and booking descriptions should use HTML */ - html_body: boolean; - /** Whether booking flow should allow the user to book multiple spaces in one booking */ - multiple_spaces: boolean; - /** Default start time in hours for desk booking */ - desk_start: number; - /** Defaut breakdown in minutes */ - breakdown: number; - - capacity_filters: Identity[]; - [name: string]: any; -} - -export interface SpaceDisplaySettings { - /** Show images for space UI elements */ - show_images: boolean; -} - -export interface UserDirectorySettings { - /** Whether to show user avatars */ - show_avatars: boolean; - /** Minimum character length of search string to need before starting a user search */ - min_search_length?: number; -} - -export interface ExploreMapSettings { - /** Mapping for statuses for elements on the map with the keys in the format `-`. May also be used for legend items with matching keys */ - colors: HashMap; - /** Whether user is allowed to change from the default building on the view */ - can_select_building: boolean; - /** Whether legend group names should be shown */ - show_legend_group_names: boolean; - /** Mapping of groups of lists of map legend items */ - legend: HashMap; -} - -export interface AnalyticsSettings { - /** Setting to enable google analytics */ - enabled: boolean; - /** Google analytics tracking id */ - tracking_id: string; -} - -/** Metadata for creating legend items for a map */ -export type MapLegendItem = - | MapLegendColourItem - | MapLegendIconItem - | BasicMapLegendItem; - -/** Metadata for creating legend items for a map */ -export interface BasicMapLegendItem { - /** ID of the item */ - key: string; - /** Display name of the legend item */ - name: string; - /** Colour of the legend item */ - color?: string; - /** Icon of the legend item */ - icon?: ApplicationIcon; -} - -/** Metadata for creating legend items with an icon for a map */ -export interface MapLegendIconItem extends BasicMapLegendItem { - icon: ApplicationIcon; -} - -/** Metadata for creating legend items with a colour for a map */ -export interface MapLegendColourItem extends BasicMapLegendItem { - color: string; -} - -export interface AppLink { - /** Name of the tile */ - name: string; - /** Application route the tile will navigate */ - route?: string; - /** External link the tile will navigate */ - link?: string; - /** Query parameters to add to the route being navigated to */ - query_params?: HashMap; - /** Icon associated with the tile */ - icon?: ApplicationIcon; - /** List of sub-links */ - children?: ApplicationLink[]; - /** Image URL to display with the link */ - background?: string; -} - -export interface ApplicationLinkInternal extends AppLink { - route: string; -} - -export interface ApplicationLinkExternal extends AppLink { - link: string; -} - -/** Metadata for linking to internal or external URLs */ -export type ApplicationLink = ApplicationLinkExternal | ApplicationLinkInternal; - -export interface ApplicationIcon { - /** Type of icon */ - type: 'img' | 'icon'; - /** URL to the image used for the icon */ - src?: string; - /** CSS class to add to icon element */ - class?: string; - /** Contents to add to icon element */ - content?: string; - /** Background color for icon */ - background?: string; -} 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/common/src/lib/shorten.fn.ts b/libs/common/src/lib/shorten.fn.ts index 55b5f128497..a07d84f3737 100644 --- a/libs/common/src/lib/shorten.fn.ts +++ b/libs/common/src/lib/shorten.fn.ts @@ -1,13 +1,4 @@ -import { - apiKey, - del, - get, - patch, - post, - put, - query, - token, -} from '@placeos/ts-client'; +import { apiKey, del, post, put, token } from '@placeos/ts-client'; const ENDPOINT = '/api/engine/v2/short_url'; @@ -31,22 +22,6 @@ export interface ShortURL { updated_at: number; } -export async function queryShortURLs( - q: Record = {}, -): Promise { - const { data } = await query({ - query_params: q, - fn: (item) => item as ShortURL, - endpoint: ENDPOINT, - path: '', - }); - return data; -} - -export async function showShortURL(id: string): Promise { - return (await get(`${ENDPOINT}/${id}`)) as ShortURL; -} - const QR_STORE = new Map(); export async function getShortUrlQRCode( @@ -85,13 +60,6 @@ export function saveShortURL(data: ShortURL): Promise { return data.id ? updateShortURL(data.id, data) : createShortURL(data); } -export async function patchShortURL( - id: string, - data: Partial, -): Promise { - return (await patch(`${ENDPOINT}/${id}`, data)) as ShortURL; -} - export async function deleteShortURL(id: string): Promise { await del(`${ENDPOINT}/${id}`); } diff --git a/libs/common/src/lib/spec-helpers.ts b/libs/common/src/lib/spec-helpers.ts deleted file mode 100644 index 3bd422b5b6b..00000000000 --- a/libs/common/src/lib/spec-helpers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Type } from '@angular/core'; -import { Spectator } from '@ngneat/spectator/jest'; - -export async function whenChangesStable>( - spectator: Spectator, -) { - spectator.detectChanges(); - await spectator.fixture.whenStable(); - spectator.detectChanges(); -} diff --git a/libs/common/src/lib/timezone-helpers.ts b/libs/common/src/lib/timezone-helpers.ts index fc1a63b7913..66ff91104a5 100644 --- a/libs/common/src/lib/timezone-helpers.ts +++ b/libs/common/src/lib/timezone-helpers.ts @@ -20,15 +20,6 @@ export function localToTimezone( return addMilliseconds(date, offset_diff).valueOf(); } -export function timezoneToLocal( - date: Date | number, - tz: string = LOCAL_TIMEZONE, -) { - const offset_diff = - getTimezoneOffset(LOCAL_TIMEZONE) - getTimezoneOffset(tz); - return addMilliseconds(date, offset_diff).valueOf(); -} - export function startOfDayInTimezone( date: Date | number, tz: string = LOCAL_TIMEZONE, diff --git a/libs/common/src/lib/timezones.ts b/libs/common/src/lib/timezones.ts index ce07b19099e..844f5b21459 100644 --- a/libs/common/src/lib/timezones.ts +++ b/libs/common/src/lib/timezones.ts @@ -1,983 +1,62 @@ -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(); +/** + * 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) + .map((zone) => LEGACY_ZONE_NAMES[zone] ?? zone), + ...FIXED_OFFSET_ZONES, + ]), + ].sort(); +} + +/** IANA time zone names offered in time zone pickers */ +export const TIMEZONES_IANA = supportedTimezones(); 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/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/common/src/lib/vorlon.service.ts b/libs/common/src/lib/vorlon.service.ts deleted file mode 100644 index ab7d1d0b53f..00000000000 --- a/libs/common/src/lib/vorlon.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Injectable, inject } from '@angular/core'; -import { getModule } from '@placeos/ts-client'; - -import { AsyncHandler } from './async-handler.class'; -import { SettingsService } from './settings.service'; - -@Injectable({ - providedIn: 'root', -}) -export class VorlonService extends AsyncHandler { - private _settings = inject(SettingsService); - - /** DOM Element containing the Vorlon Script */ - private _script: HTMLScriptElement; - - constructor() { - super(); - this.load(); - } - - private async load() { - const system = this._settings.get('app.vorlon.system'); - if (system) { - const module = getModule(system, 'Vorlon'); - if (module) { - const binding = module.variable('enabled'); - this.subscription( - 'binding_value', - binding.bindThenSubscribe((state) => { - state - ? this.injectVorlonScript() - : this.removeVorlonScript(); - }), - ); - } - } - } - - private injectVorlonScript() { - this.removeVorlonScript(); - this._script = document.createElement('script'); - const url = this._settings.get('app.vorlon.url'); - this._script.src = url || `./assets/vorlon.js`; - document.head.appendChild(this._script); - } - - private removeVorlonScript() { - if (this._script) { - this._script.parentNode.removeChild(this._script); - delete this._script; - this._script = null; - } - } -} diff --git a/libs/common/src/tests/feature-available.guard.spec.ts b/libs/common/src/tests/feature-available.guard.spec.ts deleted file mode 100644 index 96aa10474e2..00000000000 --- a/libs/common/src/tests/feature-available.guard.spec.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Router } from '@angular/router'; -import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest'; - -import { OrganisationService } from '../lib/org/organisation.service'; -import { SettingsService } from '../lib/settings.service'; -import { FeatureAvailableGuard } from '../lib/feature-available.guard'; -import { createSettingsServiceMock } from './test-helpers'; - -describe('FeatureAvailableGuard', () => { - let spectator: SpectatorService; - const settings = createSettingsServiceMock(); - const router = { url: '/catering/orders', navigate: vi.fn() }; - - const createService = createServiceFactory({ - service: FeatureAvailableGuard, - providers: [ - { provide: Router, useValue: router }, - { provide: SettingsService, useValue: settings }, - { - provide: OrganisationService, - useValue: { waitUntilInitialised: vi.fn(async () => null) }, - }, - ], - }); - - beforeEach(() => { - router.navigate.mockClear(); - settings.get.mockReturnValue([]); - spectator = createService(); - }); - - it('should allow routes that are not disabled', async () => { - settings.get.mockReturnValue(['visitors']); - await expect(spectator.service.canActivate()).resolves.toBe(true); - expect(router.navigate).not.toHaveBeenCalled(); - }); - - it('should allow routes when no features are disabled', async () => { - settings.get.mockReturnValue(undefined); - await expect(spectator.service.canLoad()).resolves.toBe(true); - }); - - it('should block and redirect disabled features', async () => { - settings.get.mockReturnValue(['catering']); - await expect(spectator.service.canActivate()).resolves.toBe(false); - expect(router.navigate).toHaveBeenCalledWith(['/']); - }); -}); 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/common/src/tests/timezones.spec.ts b/libs/common/src/tests/timezones.spec.ts new file mode 100644 index 00000000000..8b266ab37f6 --- /dev/null +++ b/libs/common/src/tests/timezones.spec.ts @@ -0,0 +1,17 @@ +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()); + }); + + 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'); + }); +}); 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/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/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/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/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/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..cb812befc04 100644 --- a/libs/events/src/lib/events.fn.ts +++ b/libs/events/src/lib/events.fn.ts @@ -1,10 +1,10 @@ import { + appDetails, BookingClash, CalendarEvent, + EventExtensionData, GuestUser, - setting, toQueryString, - VERSION, } from '@placeos/common'; import { del, @@ -18,7 +18,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 */ @@ -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(), }, }; } @@ -305,42 +293,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 +312,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 +432,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/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/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/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'); 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/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 deleted file mode 100644 index 64e675f4e87..00000000000 --- a/libs/form-fields/src/lib/compact-counter.component.ts +++ /dev/null @@ -1,182 +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 9c5e6554082..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'; @@ -13,6 +10,7 @@ import { TranslatePipe } from 'libs/components/src/lib/translate.pipe';
- + @if (!compact()) { + + }
@if (!focused()) { - + {{ (render_fn() ? render_fn()(value()) : value()) || '0' @@ -53,21 +57,49 @@ 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 (compact()) { +
+ + +
+ } @else { + + }
`, styles: [ @@ -77,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 { @@ -98,6 +123,8 @@ export class CounterComponent implements ControlValueAccessor { public readonly render_fn = input<(v: number) => string>(undefined); /** ID of the editable input, so that a `