From 6c901321ab4e90457e6af17a654b1c41aff68685 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Mon, 12 Oct 2026 16:58:55 +1100 Subject: [PATCH 1/3] fix(bookings): keep desk form choices across the initialisation reset (PPT-2643) The desk form accepts input before initialisation finishes. The reset that follows discarded each choice made in a control that has no [formField] binding: "Require locker", start time, recurrence, and the group selection. "Require locker" also showed as ticked on each new form, because its default value was an empty object. - Default secondary_resource to an empty string and type it as string. - Add setUserValues, which marks the changed fields dirty so the existing edit replay keeps them. - Keep the group selection across the reset. - Move the end time with a replayed start time or duration, so the time sync does not discard the duration. - Do not replay edits on the form of a different booking type. --- .../e2e/local/desk-initialisation.spec.ts | 6 +- .../desk-flow/desk-form-details.component.ts | 19 +-- .../desk-form-details.component.spec.ts | 13 +- e2e/support/desk/GROUP_DESK.md | 6 +- e2e/support/desk/desk-form.page.ts | 7 +- e2e/support/desk/desk-group.page.ts | 2 +- e2e/support/flows.ts | 6 +- libs/bookings/src/lib/booking-form.model.ts | 6 +- libs/bookings/src/lib/booking-form.service.ts | 117 ++++++++++++++---- .../src/test/booking-form.service.spec.ts | 109 ++++++++++++++-- 10 files changed, 233 insertions(+), 58 deletions(-) diff --git a/apps/workplace/e2e/local/desk-initialisation.spec.ts b/apps/workplace/e2e/local/desk-initialisation.spec.ts index 2e2168dc5fd..14ffde9db28 100644 --- a/apps/workplace/e2e/local/desk-initialisation.spec.ts +++ b/apps/workplace/e2e/local/desk-initialisation.spec.ts @@ -70,7 +70,9 @@ test.describe('desk form initialisation', () => { const title = `DESK-21 distinctive ${Date.now()}`; await form.title.fill(title); - await form.setChecked(form.requireLocker, false); + // The box starts clear, so a tick is the change that the + // initialisation render can lose. + await form.setChecked(form.requireLocker, true); await testInfo.attach('desk-form-before-initialisation-release', { body: await staffPage.screenshot(), contentType: 'image/png', @@ -127,7 +129,7 @@ test.describe('desk form initialisation', () => { await expect( form.requireLocker, 'the Require locker value was lost after desk-form initialisation', - ).not.toBeChecked({ timeout: 30_000 }); + ).toBeChecked({ timeout: 30_000 }); await testInfo.attach('desk-form-after-initialisation-release', { body: await staffPage.screenshot(), contentType: 'image/png', 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 5a8acd3166b..2ad65228f7a 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 @@ -161,10 +161,7 @@ import { addDays, endOfDay } from 'date-fns'; name="start-time" [ngModel]="model().date" (ngModelChange)=" - model.update((m) => ({ - ...m, - date: $event, - })) + setUserValues({ date: $event }) " [ngModelOptions]="{ standalone: true }" [range]="bookable_hours" @@ -223,12 +220,11 @@ import { addDays, endOfDay } from 'date-fns'; @@ -395,6 +391,11 @@ export class NewDeskFormDetailsComponent public readonly setFeature = (f, e) => this._state.setFeature(f, e); + /** Apply a user change from a control that has no `[formField]` binding. */ + public readonly setUserValues: BookingFormService['setUserValues'] = ( + value, + ) => this._state.setUserValues(value); + public get max_duration() { return ( this._settings.get('app.desks.max_duration') || @@ -517,10 +518,10 @@ export class NewDeskFormDetailsComponent } public onRecurrenceChange(recurrence: BookingRecurrence) { - this.model.update((m) => ({ ...m, ...recurrence })); + this.setUserValues(recurrence); } public onFirstInstanceChange(date: number) { - this.model.update((m) => ({ ...m, date })); + this.setUserValues({ date }); } } diff --git a/apps/workplace/src/tests/book/desk-flow/desk-form-details.component.spec.ts b/apps/workplace/src/tests/book/desk-flow/desk-form-details.component.spec.ts index e8d29231325..b487dd15488 100644 --- a/apps/workplace/src/tests/book/desk-flow/desk-form-details.component.spec.ts +++ b/apps/workplace/src/tests/book/desk-flow/desk-form-details.component.spec.ts @@ -10,6 +10,7 @@ describe('NewDeskFormDetailsComponent', () => { let model: ReturnType>>; let settings: Record; let set_options: any; + let set_user_values: ReturnType; const createComponent = createComponentFactory({ component: NewDeskFormDetailsComponent, @@ -26,6 +27,7 @@ describe('NewDeskFormDetailsComponent', () => { beforeEach(() => { settings = {}; set_options = vi.fn(); + set_user_values = vi.fn(); model = signal>({ id: '', date: Date.now(), @@ -42,6 +44,7 @@ describe('NewDeskFormDetailsComponent', () => { options: signal({ type: 'desk', group: false }), features: signal([]), setOptions: set_options, + setUserValues: set_user_values, setFeature: vi.fn(), model, get auto_allocation() { @@ -62,17 +65,19 @@ describe('NewDeskFormDetailsComponent', () => { expect(set_options).toHaveBeenCalledWith({ group: true }); }); - it('should merge recurrence changes into the model', () => { + it('should send recurrence changes to the service as user edits', () => { spectator.component.onRecurrenceChange({ recurrence_type: 'weekly', }); - expect(model().recurrence_type).toBe('weekly'); + expect(set_user_values).toHaveBeenCalledWith({ + recurrence_type: 'weekly', + }); }); - it('should update the model date on first-instance change', () => { + it('should send the first-instance date to the service as a user edit', () => { const date = new Date('2026-05-01T00:00:00.000Z').valueOf(); spectator.component.onFirstInstanceChange(date); - expect(model().date).toBe(date); + expect(set_user_values).toHaveBeenCalledWith({ date }); }); it('should apply the selected desk to resources when the form changes', () => { diff --git a/e2e/support/desk/GROUP_DESK.md b/e2e/support/desk/GROUP_DESK.md index a98a2c68e2e..20c8bf33dad 100644 --- a/e2e/support/desk/GROUP_DESK.md +++ b/e2e/support/desk/GROUP_DESK.md @@ -33,9 +33,9 @@ stack port. The config rejects non-local backends. The selection runs six group tests and one backend preflight. Results are in `reports/e2e/workplace/index.html`. -The group page reselects Group after date initialisation, which can reset the -form (REG-10). This suite does not claim to cover that separate initialisation -regression. It waits for actual directory results rather than selecting the +The group page selects Group again after date initialisation as a safeguard. +Form initialisation keeps a Group selection since the PPT-2643 fix. This suite +does not cover form initialisation. It waits for actual directory results rather than selecting the similarly labelled external-user option. Group selection uses the existing English button label. It does not require the diff --git a/e2e/support/desk/desk-form.page.ts b/e2e/support/desk/desk-form.page.ts index 490e7be5b01..a7e81da34a9 100644 --- a/e2e/support/desk/desk-form.page.ts +++ b/e2e/support/desk/desk-form.page.ts @@ -20,7 +20,7 @@ * ## The form is rebuilt while it initialises (REG-10 / PPT-2643) * * When async init completes the form is rebuilt and defaults are restored — - * title back to "Booking", All Day back off, Require locker back ON. It is a + * title back to "Booking", All Day back off. It is a * RACE, not a step: on a warm run it lands before anything is typed. Every * setter here is therefore used inside a converging block by its callers, the * same workaround `bookDeskViaUI` uses, and for the same reason it means these @@ -82,9 +82,8 @@ export class DeskForm { /** * The "Require locker" checkbox. * - * It defaults to CHECKED and no lockers are seeded, which makes the form - * unsatisfiable with no error shown — the single most confusing thing about - * this form. Every spec has to clear it. + * It defaults to unchecked. Specs clear it as a safeguard, because no + * lockers are seeded. * * Matched on label text, which breaks the no-visible-text rule and is * inherited from the dev's `setCheckbox` helper: these two checkboxes carry diff --git a/e2e/support/desk/desk-group.page.ts b/e2e/support/desk/desk-group.page.ts index 2d41b10b911..3b6f92e16b9 100644 --- a/e2e/support/desk/desk-group.page.ts +++ b/e2e/support/desk/desk-group.page.ts @@ -20,7 +20,7 @@ export class DeskGroupPage { await this.form.open(); await this.form.groupModeButton.click(); await this.form.pickDate(slot.date_ms); - // Date/init can rebuild the form and restore single mode (REG-10). + // Safeguard: this sets Group mode again. It does not toggle the mode. await this.form.groupModeButton.click(); await expect(this.form.root.locator('a-user-list-field')).toBeVisible(); await this.form.setChecked(this.form.requireLocker, false); diff --git a/e2e/support/flows.ts b/e2e/support/flows.ts index 0458db09faf..8f2615e9395 100644 --- a/e2e/support/flows.ts +++ b/e2e/support/flows.ts @@ -63,8 +63,8 @@ export async function bookDeskViaUI( // re-affirmed after desk selection, because the form can be rebuilt underneath // us — see the converging block below. // - // - "Require locker" defaults to CHECKED and no lockers are seeded, so leaving - // it on makes the form unsatisfiable, with no error shown. + // - "Require locker" defaults to unchecked. It is cleared here as a + // safeguard, because no lockers are seeded. // - "All Day" replaces the default slot, which is the next 5-minute boundary. // That default makes the booking implicitly time-sensitive: a slow run // crosses the boundary, the start time falls into the past, and the form @@ -97,7 +97,7 @@ export async function bookDeskViaUI( // // The form is rebuilt when its async initialisation (org data, settings, // resource lists) completes, and that rebuild restores defaults — title back - // to "Booking", All Day back off, Require locker back on. Crucially it is a + // to "Booking", All Day back off. Crucially it is a // RACE, not a step: on a warm run it lands before we touch anything and // nothing is lost, on a cold one it lands mid-flow and silently discards our // input. Locally that showed up as a booking created under the wrong title; in diff --git a/libs/bookings/src/lib/booking-form.model.ts b/libs/bookings/src/lib/booking-form.model.ts index e3970ba90d7..add3ae704ec 100644 --- a/libs/bookings/src/lib/booking-form.model.ts +++ b/libs/bookings/src/lib/booking-form.model.ts @@ -60,7 +60,7 @@ export interface BookingFormValue { booked_by: User; booked_by_id: string; booked_by_email: string; - secondary_resource: any; + secondary_resource: string; location: string; attendance_type: string; phone?: string; @@ -156,10 +156,12 @@ export function bookingFormValue( booked_by: currentUser(), booked_by_id: booking.booked_by_id || '', booked_by_email: booking.booked_by_email || '', + // Empty when no secondary resource is requested. The desk forms show + // "Require locker" as ticked for any truthy value. secondary_resource: extension_data.other_asset_type || extension_data.secondary_resource || - {}, + '', location: extension_data.location || '', attendance_type: extension_data.attendance_type || 'ANY', phone: extension_data.phone || '', diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index 3b3fd23622c..e2ce4d34e53 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -160,6 +160,17 @@ type GroupContainerForm = Partial & { extension_data?: Record; }; +/** Edits the user made on a booking form before a form reset. */ +interface UserEdits { + /** Booking type of the form at that time. Not set before the first + * initialisation of the form. */ + type?: BookingType; + /** Values of the fields that the user changed. */ + values: Partial; + /** Group selection. Options have no dirty flags to read. */ + options: Pick; +} + /** A booking type, or `undefined` for the blank `' '` placeholder that * `new Booking()` sets when no type is given. */ function knownBookingType( @@ -222,10 +233,10 @@ export class BookingFormService extends AsyncHandler { public readonly view = signal('form'); /** - * Edits the user made while `newForm` was waiting on the current user, - * carried across the deferred re-entry so the reset does not discard them. + * Edits the user made before a form reset, carried across the reset so + * that it does not discard them. */ - private _pending_user_edits: Partial | null = null; + private _pending_user_edits: UserEdits | null = null; /** Apply a partial patch to the booking form model. */ private _patch(value: Partial, _opts?: unknown) { @@ -237,8 +248,10 @@ export class BookingFormService extends AsyncHandler { * * Reads the signal-forms dirty flags rather than diffing against a * default. Programmatic writes (`_patch`, `model.set`) do not mark a field - * dirty, so this returns genuine user input and nothing else — which is - * what makes it safe to replay over a freshly loaded booking. + * dirty, so this returns user input — which is what makes it safe to + * replay over a freshly loaded booking. One exception: the recurrence + * field adjusts a recurrence when the date changes, and that write goes + * through `setUserValues`. */ private _userEditedValues(): Partial { const form = this.form; @@ -268,11 +281,68 @@ export class BookingFormService extends AsyncHandler { * capture with an empty one on the second call. */ private _captureUserEdits() { - const edits = { - ...(this._pending_user_edits || {}), - ...this._userEditedValues(), - }; - this._pending_user_edits = Object.keys(edits).length ? edits : null; + const pending = this._pending_user_edits; + const values = { ...pending?.values, ...this._userEditedValues() }; + // The first capture gives the type and the options. A reset that ran + // after it can have changed both. + const { group, members } = this._options(); + const edits: UserEdits = pending + ? { ...pending, values } + : { + type: knownBookingType(untracked(this.model).booking_type), + values, + options: group || members?.length ? { group, members } : {}, + }; + const has_edits = + Object.keys(values).length || Object.keys(edits.options).length; + this._pending_user_edits = has_edits ? edits : null; + } + + /** + * The stashed edits that apply to a reset for the given booking type. + * + * Edits made on the form of a different booking type do not apply. A + * recurrence or a group selection must not move to a form that cannot + * show it. + * + * Limit: the type comes from the last initialised form. Thus an edit is + * lost when the user makes it on a form of a new type before that form + * initialises. The desk flow has such a window of 300 ms after in-app + * navigation from a different booking type. + */ + private _userEditsFor(type?: BookingType): UserEdits | null { + const edits = this._pending_user_edits; + if (edits?.type && type && edits.type !== type) return null; + return edits; + } + + /** + * Re-apply the user's edits over the booking that a reset loaded. + * + * A replayed start time or duration moves the end time with it. If the + * end time stays at the value of the loaded booking, the time sync + * calculates the duration from it and discards the user's value. + */ + private _replayUserEdits(edits: UserEdits | null) { + if (!edits) return; + this._patch(edits.values); + const { date, duration, all_day } = this.model(); + const window_changed = + 'date' in edits.values || 'duration' in edits.values; + if (all_day || !window_changed) return; + this._patch({ date_end: addMinutes(date, duration).valueOf() }); + } + + /** + * Apply a change the user made in a control that has no `[formField]` + * binding. + * + * Marks each changed field dirty, as `[formField]` does, so + * `_userEditedValues` keeps the change across a form reset. + */ + public setUserValues(value: Partial) { + this._patch(value); + for (const key of Object.keys(value)) this.form[key]?.().markAsDirty(); } private _syncAssetOptions() { @@ -752,7 +822,7 @@ export class BookingFormService extends AsyncHandler { }); return; } - const user_edits = this._pending_user_edits; + const user_edits = this._userEditsFor(type); this._pending_user_edits = null; // Never apply an existing booking's edit state to a different type // (e.g. editing parking then opening the desk form). @@ -795,9 +865,7 @@ export class BookingFormService extends AsyncHandler { // the time-sync window; and before `_syncWindowIfUnchanged`, which // compares against `initial_date`/`initial_duration` and so leaves a // user-changed window alone of its own accord. - if (user_edits && Object.keys(user_edits).length) { - this._patch(user_edits, { emitEvent: false }); - } + this._replayUserEdits(user_edits); // Give an untyped form the flow type, so validators that depend on // the type (e.g. parking plate number) run for callers that do not // set it themselves. @@ -823,7 +891,12 @@ export class BookingFormService extends AsyncHandler { booking.duration, ); this._booking.set(new Booking(booking)); - this._options.set({ type: this._options().type }); + // A new form clears the options, except a group selection that the + // user made before this reset. + this._options.set({ + type: this._options().type, + ...user_edits?.options, + }); // Persist immediately to avoid edit-flow races where the consumer // reloads from session before async form change events occur. this.storeForm(); @@ -1009,12 +1082,12 @@ export class BookingFormService extends AsyncHandler { // already entered. Capture before `form().reset()` clears the dirty // flags `_userEditedValues` reads. this._captureUserEdits(); - const user_edits = this._pending_user_edits; + const user_edits = this._userEditsFor(expected_type); // Flows call `loadForm(type)` and then `newForm(type)` in the same tick - // (desk-flow.component.ts:62 and :65, and the locker/parking - // equivalents). Leave the capture in place so that second reset replays - // it too, and release it at the end of the tick, where it can no longer - // reach an unrelated form. + // (desk-flow.component.ts, and the locker/parking equivalents). Leave + // the capture in place so that second reset replays it too, and + // release it at the end of the tick, where it can no longer reach an + // unrelated form. queueMicrotask(() => (this._pending_user_edits = null)); this._startNetwork(); this._calendar.loadCalendars(); @@ -1057,9 +1130,7 @@ export class BookingFormService extends AsyncHandler { // Re-apply the user's own edits over the loaded booking, before // `applyDurationSettings` so a restored `all_day` still drives the // time-sync window — same ordering as `newForm`. - if (user_edits && Object.keys(user_edits).length) { - this._patch(user_edits, { emitEvent: false }); - } + this._replayUserEdits(user_edits); // Give an untyped booking the flow type. Otherwise the flow sees a // type mismatch and replaces the booking with a new form. if (expected_type) { diff --git a/libs/bookings/src/test/booking-form.service.spec.ts b/libs/bookings/src/test/booking-form.service.spec.ts index d2d01b2ab87..2155e617821 100644 --- a/libs/bookings/src/test/booking-form.service.spec.ts +++ b/libs/bookings/src/test/booking-form.service.spec.ts @@ -3533,16 +3533,111 @@ describe('BookingFormService', () => { expect(spectator.service.model().all_day).toBe(true); }); - it('carries a typed title between booking forms, deliberately', () => { - // Switching desk -> parking without leaving the booking area does not - // reset the form, so the user's own typing follows them. Pinned - // rather than left to chance: only fields they actually edited move, - // `isCrossTypeEdit` still discards the previous booking's identity, - // and leaving the booking section entirely calls `clearForm()`. + it('does not request a locker on a new desk form', () => { + spectator.service.loadForm('desk'); + spectator.service.newForm('desk'); + + expect(spectator.service.model().secondary_resource).toBe(''); + }); + + it('keeps a "Require locker" choice made before the flow initialises the form', () => { + spectator.service.setUserValues({ secondary_resource: 'locker' }); + + spectator.service.loadForm('desk'); + spectator.service.newForm('desk'); + + expect(spectator.service.model().secondary_resource).toBe('locker'); + }); + + it('keeps a cleared "Require locker" over a stored locker request', () => { + sessionStorage.setItem( + 'PLACEOS.booking_form', + JSON.stringify({ secondary_resource: 'locker' }), + ); + spectator.service.setUserValues({ secondary_resource: '' }); + + spectator.service.loadForm('desk'); + + expect(spectator.service.model().secondary_resource).toBe(''); + }); + + it('keeps a start time chosen before the flow initialises the form', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 9, 12, 9, 0)); + try { + const date = new Date(2026, 9, 12, 11, 0).valueOf(); + spectator.service.setUserValues({ date }); + // Let the time sync set the end time for the user's start. + TestBed.tick(); + + spectator.service.loadForm('desk'); + spectator.service.newForm('desk'); + // The delayed window sync must not replace the user's time. + vi.runAllTimers(); + TestBed.tick(); + + const model = spectator.service.model(); + expect(model.date).toBe(date); + // The end time moves with the start time. The end time of + // the new booking is before the user's start, and the time + // sync would reduce the duration to the minimum. + expect(model.duration).toBe(60); + expect(model.date_end).toBe(date + 60 * 60 * 1000); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps a duration chosen before the flow initialises the form', () => { + userEdits('duration', 120); + // Let the time sync set the end time for the user's duration. + TestBed.tick(); + + spectator.service.loadForm('desk'); + spectator.service.newForm('desk'); + TestBed.tick(); + + expect(spectator.service.model().duration).toBe(120); + }); + + it('keeps a recurrence chosen before the flow initialises the form', () => { + spectator.service.setUserValues({ + recurrence_type: 'weekly', + recurrence_interval: 2, + }); + + spectator.service.loadForm('desk'); + spectator.service.newForm('desk'); + + const { recurrence_type, recurrence_interval } = + spectator.service.model(); + expect(recurrence_type).toBe('weekly'); + expect(recurrence_interval).toBe(2); + }); + + it('keeps a group selection made before the flow initialises the form', () => { + const members = [new StaffUser({ email: 'jo@example.com' })]; + spectator.service.setOptions({ group: true, members }); + + spectator.service.loadForm('desk'); + spectator.service.newForm('desk'); + + expect(spectator.service.options().group).toBe(true); + expect(spectator.service.options().members).toEqual(members); + }); + + it('does not move edits to the form of a different booking type', () => { + // A parking form cannot show a desk recurrence or a group + // selection, so edits stay with the booking type of their form. + spectator.service.newForm('desk'); userEdits('title', 'Desk title'); + spectator.service.setOptions({ group: true }); + spectator.service.loadForm('parking'); spectator.service.newForm('parking'); - expect(spectator.service.model().title).toBe('Desk title'); + + expect(spectator.service.model().title).not.toBe('Desk title'); + expect(spectator.service.options().group).toBeFalsy(); }); it('does not carry those edits into a later unrelated form', () => { From 23767c845841ae1168e3ccf8f5daa5064051fe38 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 13 Oct 2026 11:04:27 +1100 Subject: [PATCH 2/3] fix(bookings): keep edits on non-desk forms after a form clear (PPT-2643) The edit stash compared its booking type with the type in the form model. The landing page clears the form and gives the model the type `desk`, so the reset of a visitor form discarded the user's input. - The stash has no type. A locker request, a recurrence, and a group selection replay only on a desk form. All other edits replay on each form, as before this branch. - `requiresLocker` accepts only a text value. Saved desk bookings that hold `secondary_resource: {}` no longer show "Require locker" ticked. - `removeBooking` has a typed query. With `any`, the type of `removeEvent | removeBooking` in the workplace app depended on the check order, and this change made the workplace build fail. - `setUserValues` reads the form fields with typed keys. - New specs for the "Require locker" and start time bindings, a reset on an initialised desk form, and the visitor form case. --- .../src/app/desks/desk-form.component.ts | 25 +++++---- .../desk-flow/desk-flow-confirm.component.ts | 7 ++- .../desk-flow/desk-form-details.component.ts | 7 ++- .../desk-flow-confirm.component.spec.ts | 11 ++++ .../desk-form-details.component.spec.ts | 44 ++++++++++++++- libs/bookings/src/lib/booking-form.model.ts | 16 +++++- libs/bookings/src/lib/booking-form.service.ts | 56 ++++++++++--------- libs/bookings/src/lib/bookings.fn.ts | 7 ++- .../src/test/booking-form.service.spec.ts | 48 ++++++++++++++-- 9 files changed, 173 insertions(+), 48 deletions(-) diff --git a/apps/outlook-addin/src/app/desks/desk-form.component.ts b/apps/outlook-addin/src/app/desks/desk-form.component.ts index 97c9a6ce507..4d8ef21e47b 100644 --- a/apps/outlook-addin/src/app/desks/desk-form.component.ts +++ b/apps/outlook-addin/src/app/desks/desk-form.component.ts @@ -1,16 +1,15 @@ -import { - Component, - inject, - Injector, - OnInit, -} from '@angular/core'; +import { Component, computed, inject, Injector, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FormField } from '@angular/forms/signals'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { AssetListFieldComponent } from '@placeos/assets'; -import { BookingFormService, DeskListFieldComponent } from '@placeos/bookings'; +import { + BookingFormService, + DeskListFieldComponent, + requiresLocker, +} from '@placeos/bookings'; import { AsyncHandler, Desk, @@ -67,7 +66,10 @@ import { name="start-time" [ngModel]="model().date" (ngModelChange)=" - model.update((m) => ({ ...m, date: $event })) + model.update((m) => ({ + ...m, + date: $event, + })) " [ngModelOptions]="{ standalone: true }" > @@ -96,9 +98,7 @@ import {
{ + settings['app.desks.can_book_lockers'] = true; + spectator.setInput('form', (() => ({})) as any); + spectator.detectChanges(); + + const checkbox = spectator.debugElement + .queryAll(By.css('mat-checkbox')) + .find((_) => _.nativeElement.textContent.includes('LOCKER')); + checkbox.triggerEventHandler('ngModelChange', true); + + expect(set_user_values).toHaveBeenCalledWith({ + secondary_resource: 'locker', + }); + }); + + it('should send a start time change to the service as a user edit', () => { + spectator.setInput('form', (() => ({})) as any); + spectator.detectChanges(); + + const date = new Date('2026-05-01T02:00:00.000Z').valueOf(); + spectator.debugElement + .query(By.css('a-time-field')) + .triggerEventHandler('ngModelChange', date); + + expect(set_user_values).toHaveBeenCalledWith({ date }); + }); + it('should apply the selected desk to resources when the form changes', () => { spectator.component.selected_desk = { id: 'desk-5' } as any; spectator.component.ngOnChanges({ diff --git a/libs/bookings/src/lib/booking-form.model.ts b/libs/bookings/src/lib/booking-form.model.ts index add3ae704ec..6f3787e1d67 100644 --- a/libs/bookings/src/lib/booking-form.model.ts +++ b/libs/bookings/src/lib/booking-form.model.ts @@ -60,7 +60,8 @@ export interface BookingFormValue { booked_by: User; booked_by_id: string; booked_by_email: string; - secondary_resource: string; + /** Read this value with `requiresLocker`. */ + secondary_resource: unknown; location: string; attendance_type: string; phone?: string; @@ -91,6 +92,16 @@ export interface BookingFormValue { is_assgined: boolean; } +/** + * Whether a `secondary_resource` form value is a request for a locker. + * + * Only a text value is a request. Some saved desk bookings hold `{}`, which + * an earlier default of the form wrote. That value is not a request. + */ +export function requiresLocker(secondary_resource: unknown): boolean { + return typeof secondary_resource === 'string' && !!secondary_resource; +} + export function bookingAttachments(booking: Booking = new Booking()): string[] { booking = booking || new Booking(); const extension_data = booking.extension_data || {}; @@ -156,8 +167,7 @@ export function bookingFormValue( booked_by: currentUser(), booked_by_id: booking.booked_by_id || '', booked_by_email: booking.booked_by_email || '', - // Empty when no secondary resource is requested. The desk forms show - // "Require locker" as ticked for any truthy value. + // Empty when no secondary resource is requested. secondary_resource: extension_data.other_asset_type || extension_data.secondary_resource || diff --git a/libs/bookings/src/lib/booking-form.service.ts b/libs/bookings/src/lib/booking-form.service.ts index e2ce4d34e53..5fd163c312f 100644 --- a/libs/bookings/src/lib/booking-form.service.ts +++ b/libs/bookings/src/lib/booking-form.service.ts @@ -162,15 +162,24 @@ type GroupContainerForm = Partial & { /** Edits the user made on a booking form before a form reset. */ interface UserEdits { - /** Booking type of the form at that time. Not set before the first - * initialisation of the form. */ - type?: BookingType; /** Values of the fields that the user changed. */ values: Partial; /** Group selection. Options have no dirty flags to read. */ options: Pick; } +/** Fields that only the desk form lets the user change. */ +const DESK_ONLY_FIELDS: (keyof BookingFormValue)[] = [ + 'secondary_resource', + 'recurrence_type', + 'recurrence_days', + 'recurrence_nth_of_month', + 'recurrence_interval', + 'recurrence_end', + 'recurrence_instances', + 'recurrence_custom', +]; + /** A booking type, or `undefined` for the blank `' '` placeholder that * `new Booking()` sets when no type is given. */ function knownBookingType( @@ -283,37 +292,32 @@ export class BookingFormService extends AsyncHandler { private _captureUserEdits() { const pending = this._pending_user_edits; const values = { ...pending?.values, ...this._userEditedValues() }; - // The first capture gives the type and the options. A reset that ran - // after it can have changed both. + // The first capture gives the options. A reset that ran after it has + // cleared them. const { group, members } = this._options(); - const edits: UserEdits = pending - ? { ...pending, values } - : { - type: knownBookingType(untracked(this.model).booking_type), - values, - options: group || members?.length ? { group, members } : {}, - }; + const options = + pending?.options ?? + (group || members?.length ? { group, members } : {}); const has_edits = - Object.keys(values).length || Object.keys(edits.options).length; - this._pending_user_edits = has_edits ? edits : null; + Object.keys(values).length || Object.keys(options).length; + this._pending_user_edits = has_edits ? { values, options } : null; } /** * The stashed edits that apply to a reset for the given booking type. * - * Edits made on the form of a different booking type do not apply. A - * recurrence or a group selection must not move to a form that cannot - * show it. - * - * Limit: the type comes from the last initialised form. Thus an edit is - * lost when the user makes it on a form of a new type before that form - * initialises. The desk flow has such a window of 300 ms after in-app - * navigation from a different booking type. + * A change from one booking flow to a different one does not reset the + * form, so the stash can hold edits from the form of a different booking + * type. A locker request, a recurrence, and a group selection apply only + * to a desk form, because no other form shows them. All other edits + * apply to each form. */ private _userEditsFor(type?: BookingType): UserEdits | null { const edits = this._pending_user_edits; - if (edits?.type && type && edits.type !== type) return null; - return edits; + if (!edits || type === 'desk') return edits; + const values = { ...edits.values }; + for (const key of DESK_ONLY_FIELDS) delete values[key]; + return { values, options: {} }; } /** @@ -342,7 +346,9 @@ export class BookingFormService extends AsyncHandler { */ public setUserValues(value: Partial) { this._patch(value); - for (const key of Object.keys(value)) this.form[key]?.().markAsDirty(); + for (const key of Object.keys(value) as (keyof BookingFormValue)[]) { + this.form[key]?.().markAsDirty(); + } } private _syncAssetOptions() { diff --git a/libs/bookings/src/lib/bookings.fn.ts b/libs/bookings/src/lib/bookings.fn.ts index e2ace568bb6..ee8ec4a0188 100644 --- a/libs/bookings/src/lib/bookings.fn.ts +++ b/libs/bookings/src/lib/bookings.fn.ts @@ -361,8 +361,13 @@ export const saveBooking = async ( /** * Remove booking from the database * @param id ID of the booking to remove + * @param q Set `instance` and `start_time` to remove only one instance of a + * recurring booking */ -export function removeBooking(id: string, q: any = {}): Promise { +export function removeBooking( + id: string, + q: { instance?: boolean; start_time?: number } = {}, +): Promise { if (q.instance) { return removeBookingInstance(id, q.start_time); } diff --git a/libs/bookings/src/test/booking-form.service.spec.ts b/libs/bookings/src/test/booking-form.service.spec.ts index 2155e617821..520772f0972 100644 --- a/libs/bookings/src/test/booking-form.service.spec.ts +++ b/libs/bookings/src/test/booking-form.service.spec.ts @@ -3626,17 +3626,57 @@ describe('BookingFormService', () => { expect(spectator.service.options().members).toEqual(members); }); - it('does not move edits to the form of a different booking type', () => { - // A parking form cannot show a desk recurrence or a group - // selection, so edits stay with the booking type of their form. + it('keeps a choice made on a desk form that a reset already initialised', () => { + // The usual path in the app: the landing page clears the form, + // which gives the model the type `desk`. + spectator.service.clearForm(); + spectator.service.setUserValues({ secondary_resource: 'locker' }); + + spectator.service.loadForm('desk'); spectator.service.newForm('desk'); + + expect(spectator.service.model().secondary_resource).toBe('locker'); + }); + + it('keeps input on a visitor form after the landing page cleared the form', () => { + spectator.service.clearForm(); + userEdits('title', 'Visitor title'); + + spectator.service.loadForm('visitor'); + spectator.service.newForm('visitor'); + + expect(spectator.service.model().title).toBe('Visitor title'); + }); + + it('carries a typed title between booking forms, deliberately', () => { + // Switching desk -> parking without leaving the booking area does not + // reset the form, so the user's own typing follows them. Pinned + // rather than left to chance: only fields they actually edited move, + // `isCrossTypeEdit` still discards the previous booking's identity, + // and leaving the booking section entirely calls `clearForm()`. userEdits('title', 'Desk title'); + spectator.service.loadForm('parking'); + spectator.service.newForm('parking'); + expect(spectator.service.model().title).toBe('Desk title'); + }); + + it('does not move desk choices to the form of a different booking type', () => { + // No other form shows a locker request, a recurrence, or a group + // selection. + spectator.service.newForm('desk'); + spectator.service.setUserValues({ + secondary_resource: 'locker', + recurrence_type: 'weekly', + }); spectator.service.setOptions({ group: true }); spectator.service.loadForm('parking'); spectator.service.newForm('parking'); - expect(spectator.service.model().title).not.toBe('Desk title'); + const { secondary_resource, recurrence_type } = + spectator.service.model(); + expect(secondary_resource).toBe(''); + expect(recurrence_type).toBe('none'); expect(spectator.service.options().group).toBeFalsy(); }); From 9c434986cc2faca22591cbd53e1400056c92cc17 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Tue, 13 Oct 2026 12:13:54 +1100 Subject: [PATCH 3/3] test(e2e): make DESK-21 hold the desk form reset (PPT-2643) The spec delayed an organisation metadata reload, but that reload does not reset the form. The reset came before the edits, and the spec passed with or without the fix. The spec now holds the 300 ms settle timer of the desk flow, edits the title and "Require locker", and then releases the timer. It fails when the flow does not start the timer, and when the replay of the "Require locker" edit is removed. REG-10 in E2E_USER_STORIES.md and the desk form page object describe the new cover. --- E2E_USER_STORIES.md | 9 +- .../e2e/local/desk-initialisation.spec.ts | 208 +++++++----------- e2e/support/desk/desk-form.page.ts | 4 +- 3 files changed, 92 insertions(+), 129 deletions(-) diff --git a/E2E_USER_STORIES.md b/E2E_USER_STORIES.md index e6a98ff3619..dc2c5d07451 100644 --- a/E2E_USER_STORIES.md +++ b/E2E_USER_STORIES.md @@ -299,7 +299,7 @@ task that found it, so the row can be traced. | REG-07 | P2 | Level selection does not persist once the selector is hidden/disabled. | "Fix level selections persisting when selector is disabled/hidden" | todo | | REG-08 | P1 | An authority with a **relative** `login_url` still reaches a usable login page. | Found 2026-07-30, this suite | **blocked** — currently worked around in `seed.ts`; ts-client resolves a relative `login_url` against the authority host **without its port**, so any non-443 deployment dead-ends. Needs a ts-client/init fix before a spec can assert the good behaviour. | | REG-09 | P1 | Concurrent `POST /bookings` do not 500. | **[PPT-2642](https://acaprojects.atlassian.net/browse/PPT-2642)** | **fixed at source, not yet in the image this suite runs against.** Root cause was in pg-orm: crystal-db clears a connection's transaction flag only *after* the COMMIT it issues, so a COMMIT that fails — the serialization failures a booking burst produces — leaves it set, and the one place connections return to the pool could not see it. The connection went back to the pool with its `BEGIN` still open; reads kept working inside the orphaned transaction while every write returned 500 until restart. Fixed in [pg-orm#19](https://github.com/spider-gazelle/pg-orm/pull/19) (v2.2.4) and picked up by staff-api in #386. Measured with images differing only by that version: control stranded a connection in 3 of 4 bursts and ended unable to write; fixed, 0 of 11 and still writable. **`e2e/stack` pulls `placeos/staff-api:latest`, which is a release image — so this suite keeps running the old behaviour until the next platform release.** **Corrected 2026-09-14: this suite now DOES burst concurrent bookings.** The line that used to sit here — "harmless for now, nothing here bursts concurrent bookings" — stopped being true when the group-invite specs (VIS-03/VIS-04) landed: a group invite posts the container and every member at once, which is exactly the burst. Measured with `e2e/support/repro/vis-b6-burst.ts` (no browser, 4 concurrent POSTs x 12 rounds): 13x 201, 29x 400, 6x 500, clean in the early rounds and degrading after. Before a rollback-and-retry was added to the visitor flows the group specs failed about one run in three; with it, 10 of 10 runs clean and the recovery fired twice. That retry is a **workaround living in `e2e/support/visitor/visitor.flows.ts`**, to be deleted once the staff-api image carries pg-orm v2.2.4 — it is also why the suite being green must not be read as REG-09 being gone from the image. Reproducers: `e2e/support/repro/reg09-concurrent-bookings.ts` and `vis-b6-burst.ts`. | -| REG-10 | P1 | The booking form does not discard user input while it is still initialising. | **[PPT-2643](https://acaprojects.atlassian.net/browse/PPT-2643)** | **blocked** — the form is rebuilt when async init completes and restores defaults (title, All Day, Require locker), silently dropping anything typed before that. A real user can hit this; they would just see their title or options revert. `bookDeskViaUI` converges on the state to work around it, which means **the suite no longer detects it** — hence this row. Investigated 2026-08-05 against #478 (`a0360486`): **the bug is still live**, established by reading the code rather than by running this suite, and fixed in **PR #479**. `newForm`'s protected branch is never taken by the flows — the current user is restored from cache ~50ms after bootstrap, while `NewDeskFlowComponent.ngOnInit` calls `loadForm` then `newForm` only after org init plus a 300ms settle — and `loadForm` had no capture at all. The shipped e2e suite meanwhile is stable at 6 consecutive full runs, 8/8, `--retries=0`, which is precisely the problem: **it passes either way**. Removing the block could not be shown to be safe *or* unsafe from here: the race needs initialisation to be slow relative to typing, and this machine wins it every time. Two failed attempts to prove otherwise, both recorded so nobody repeats them: (1) a synthetic "type during init, assert it survives" spec passed with *and* without the fix, even with the API responses held to widen the window; (2) removing the block appeared to prove the bug survived — it did not. That red was a Playwright **strict-mode violation**, not a reverted value: opening the desk-select modal puts a second "All Day" checkbox in the DOM (`desk-filters`, bound to the same field), so an unscoped locator matched two elements and threw, with both checked. Scoping the locator to `desk-flow-form` then broke it a second way, because `setCheckbox` silently returns when its locator matches nothing, turning a narrower scope into a no-op and a genuinely invalid form. Both experiments were reverted. **The block stays and this row stays blocked even once #479 lands** — not because the app is unfixed, but because this suite cannot tell either way on fast hardware. The guard for REG-10 is the unit specs in `libs/bookings/src/test/booking-form.service.spec.ts`; unblocking this row needs artificial slowness (throttled CPU), not another e2e attempt. | +| REG-10 | P1 | The booking form does not discard user input while it is still initialising. | **[PPT-2643](https://acaprojects.atlassian.net/browse/PPT-2643)** | **partial** — `desk-initialisation.spec.ts` (DESK-21) covers the desk form. It holds the 300 ms settle timer of the desk flow, so its edits to the title and "Require locker" come before the form reset on any hardware. It fails when the replay of the "Require locker" edit is removed. No spec covers the other forms, and the flows below still converge on the form state. History: the form is rebuilt when async init completes and restores defaults (title, All Day, Require locker), silently dropping anything typed before that. A real user can hit this; they would just see their title or options revert. `bookDeskViaUI` converges on the state to work around it, which means **the suite no longer detects it** — hence this row. Investigated 2026-08-05 against #478 (`a0360486`): **the bug is still live**, established by reading the code rather than by running this suite, and fixed in **PR #479**. `newForm`'s protected branch is never taken by the flows — the current user is restored from cache ~50ms after bootstrap, while `NewDeskFlowComponent.ngOnInit` calls `loadForm` then `newForm` only after org init plus a 300ms settle — and `loadForm` had no capture at all. The shipped e2e suite meanwhile is stable at 6 consecutive full runs, 8/8, `--retries=0`, which is precisely the problem: **it passes either way**. Removing the block could not be shown to be safe *or* unsafe from here: the race needs initialisation to be slow relative to typing, and this machine wins it every time. Two failed attempts to prove otherwise, both recorded so nobody repeats them: (1) a synthetic "type during init, assert it survives" spec passed with *and* without the fix, even with the API responses held to widen the window; (2) removing the block appeared to prove the bug survived — it did not. That red was a Playwright **strict-mode violation**, not a reverted value: opening the desk-select modal puts a second "All Day" checkbox in the DOM (`desk-filters`, bound to the same field), so an unscoped locator matched two elements and threw, with both checked. Scoping the locator to `desk-flow-form` then broke it a second way, because `setCheckbox` silently returns when its locator matches nothing, turning a narrower scope into a no-op and a genuinely invalid form. Both experiments were reverted. **The block stays and this row stays blocked even once #479 lands** — not because the app is unfixed, but because this suite cannot tell either way on fast hardware. The guard for REG-10 is the unit specs in `libs/bookings/src/test/booking-form.service.spec.ts`; unblocking this row needs artificial slowness (throttled CPU), not another e2e attempt. | ## 4. Platform & configuration @@ -327,7 +327,7 @@ Config gaps caused several production incidents, and they are invisible to UI sp or through `use_bookings`?** Nobody has answered it. If the answer is "the calendar", the green rows in §1b guard a path those customers never take. This needs a product answer, not more specs. -- **Nine rows are blocked on product fixes, not on test effort** (REG-08, REG-09, REG-10, +- **Eight rows are blocked on product fixes, not on test effort** (REG-08, REG-09, VIS-15, ROOM-11, ROOM-13, ROOM-14, ROOM-21, ROOM-22). All were found by this suite. Leaving them visible here is the point — a blocked row is coverage information, a deleted row is not. - **One row is blocked by the environment rather than by a bug** (AUTH-E2E-08), and it says @@ -355,8 +355,9 @@ Config gaps caused several production incidents, and they are invisible to UI sp `reg09-client-abort.ts` (which ruled out client disconnects) and `vis-b6-burst.ts` (the same burst with no browser at all, which is what proved the visitor 400s were REG-09 rather than a form bug). -- **REG-10 is invisible to this suite by design.** `bookDeskViaUI` converges on the form state, - so nothing here will catch it regressing. PPT-2643's fix landed in #478; whether it is +- **REG-10 is invisible to the booking flows of this suite by design.** `bookDeskViaUI` converges + on the form state, so no flow will catch it regressing. Only `desk-initialisation.spec.ts` + (DESK-21) detects it, and only for the desk form: it holds the settle timer of the desk flow. PPT-2643's fix landed in #478; whether it is *complete* is genuinely unresolved, and the honest summary is that this suite cannot answer it on hardware this fast. Deleting the converging block passes serially and fails about one full parallel run in three, on the symptom rather than on the mechanism. diff --git a/apps/workplace/e2e/local/desk-initialisation.spec.ts b/apps/workplace/e2e/local/desk-initialisation.spec.ts index 14ffde9db28..98dced442fe 100644 --- a/apps/workplace/e2e/local/desk-initialisation.spec.ts +++ b/apps/workplace/e2e/local/desk-initialisation.spec.ts @@ -1,142 +1,102 @@ -import type { Request } from '@playwright/test'; /** * DESK-21 — values entered before desk-form initialisation completes survive - * the initialisation render. + * the form reset of the initialisation. */ import { DeskForm } from '../../../../e2e/support/desk/desk-form.page'; import { expect, test } from '../../../../e2e/support/fixtures'; +/** State of the held settle timer of the desk flow. */ +interface DeskInitHold { + /** True after the desk flow started its settle timer. */ + held: boolean; + /** Ends the settle timer. The form reset follows. */ + release: () => void; +} + +type HoldWindow = Window & { desk_init_hold: DeskInitHold }; + test.describe('desk form initialisation', () => { - test('preserves values entered while organisation data is pending', async ({ + test('preserves values entered before the form reset', async ({ staffPage, }, testInfo) => { - let release_metadata!: () => void; - const metadata_released = new Promise((resolve) => { - release_metadata = resolve; + // The desk flow shows its form immediately. It resets the form after + // the organisation data loads and a settle timer of 300 ms ends. On + // fast hardware the reset comes before the first edit, and the test + // then passes with or without the fault. Hold the settle timer, so + // the edits below always come before the reset. + await staffPage.addInitScript(() => { + const native = window.setTimeout; + const hold: DeskInitHold = { held: false, release: () => null }; + (window as unknown as HoldWindow).desk_init_hold = hold; + window.setTimeout = (( + fn: (...args: unknown[]) => void, + ms?: number, + ...args: unknown[] + ) => { + const is_settle_timer = + ms === 300 && + !hold.held && + /NewDeskFlowComponent\b/.test(new Error().stack || ''); + if (!is_settle_timer) return native(fn, ms, ...args); + hold.held = true; + hold.release = () => fn(...args); + return 0; + }) as typeof window.setTimeout; }); - let metadata_seen!: () => void; - const metadata_requested = new Promise((resolve) => { - metadata_seen = resolve; - }); - let delayed_metadata: Request | undefined; - await staffPage.route('**/api/engine/v2/metadata**', async (route) => { - if (route.request().method() !== 'GET') return route.continue(); - if (delayed_metadata) return route.continue(); - delayed_metadata = route.request(); - metadata_seen(); - await metadata_released; - await route.continue().catch(() => undefined); + const form = new DeskForm(staffPage); + await form.open(); + // If the timer is not found, the reset is not held and the test + // cannot find the fault. Fail here and do not pass without a check. + await expect + .poll( + () => + staffPage.evaluate( + () => + (window as unknown as HoldWindow).desk_init_hold + .held, + ), + { + message: 'the desk flow did not start its settle timer', + timeout: 30_000, + }, + ) + .toBe(true); + + const title = `DESK-21 distinctive ${Date.now()}`; + await form.title.fill(title); + // The box starts clear, so a tick is the change that the form reset + // can lose. `setChecked` does nothing when the box is not in the + // page, so make sure that the tick is there. + await form.setChecked(form.requireLocker, true); + await expect(form.requireLocker).toBeChecked(); + await testInfo.attach('desk-form-before-reset', { + body: await staffPage.screenshot(), + contentType: 'image/png', }); - const form = new DeskForm(staffPage); - try { - await form.open(); - const refresh_started = await staffPage.evaluate(() => { - const org = ( - window as unknown as { - app?: { - org?: { reloadMetadata?: () => Promise }; - }; - } - ).app?.org; - if (!org?.reloadMetadata) return false; - // Keep the actual reload completion, including application state - // updates, rather than only knowing that the request started. - ( - window as unknown as { - desk_test_reload: Promise; - } - ).desk_test_reload = org.reloadMetadata().then( - () => null, - (error) => String(error), - ); - return true; - }); - test.skip( - !refresh_started, - 'the local debug organisation refresh is unavailable', - ); - const request_started = await Promise.race([ - metadata_requested.then(() => true), - new Promise((resolve) => - setTimeout(() => resolve(false), 15_000), + // The reset runs in the microtask after the timer callback. Two + // animation frames let Angular render the result. + await staffPage.evaluate(async () => { + (window as unknown as HoldWindow).desk_init_hold.release(); + await new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(() => resolve()), ), - ]); - test.skip( - !request_started, - 'the metadata refresh did not issue a request', ); + }); - const title = `DESK-21 distinctive ${Date.now()}`; - await form.title.fill(title); - // The box starts clear, so a tick is the change that the - // initialisation render can lose. - await form.setChecked(form.requireLocker, true); - await testInfo.attach('desk-form-before-initialisation-release', { - body: await staffPage.screenshot(), - contentType: 'image/png', - }); - - const response_ready = staffPage.waitForResponse( - (response) => response.request() === delayed_metadata, - ); - release_metadata(); - const response = await response_ready; - expect(response.ok(), 'delayed metadata request must succeed').toBe( - true, - ); - expect( - await response.finished(), - 'metadata body must finish loading', - ).toBeNull(); - const reload_error = await staffPage.evaluate(async () => { - const state = window as unknown as { - desk_test_reload: Promise; - }; - return await state.desk_test_reload; - }); - expect( - reload_error, - 'organisation metadata reload must finish', - ).toBeNull(); - // Let Angular consume the refreshed state and render it before - // inspecting values. This barrier is independent of expected values. - await staffPage.evaluate(async () => { - const win = window as unknown as { - getAllAngularTestabilities?: () => Array<{ - whenStable: (done: () => void) => void; - }>; - }; - await Promise.all( - (win.getAllAngularTestabilities?.() ?? []).map( - (testability) => - new Promise((resolve) => - testability.whenStable(resolve), - ), - ), - ); - await new Promise((resolve) => - requestAnimationFrame(() => - requestAnimationFrame(() => resolve()), - ), - ); - }); - await expect( - form.title, - 'the title was lost after desk-form initialisation', - ).toHaveValue(title, { timeout: 30_000 }); - await expect( - form.requireLocker, - 'the Require locker value was lost after desk-form initialisation', - ).toBeChecked({ timeout: 30_000 }); - await testInfo.attach('desk-form-after-initialisation-release', { - body: await staffPage.screenshot(), - contentType: 'image/png', - }); - } finally { - release_metadata(); - await staffPage.unroute('**/api/engine/v2/metadata**'); - } + await expect( + form.title, + 'the title was lost after the desk form reset', + ).toHaveValue(title); + await expect( + form.requireLocker, + 'the Require locker value was lost after the desk form reset', + ).toBeChecked(); + await testInfo.attach('desk-form-after-reset', { + body: await staffPage.screenshot(), + contentType: 'image/png', + }); }); }); diff --git a/e2e/support/desk/desk-form.page.ts b/e2e/support/desk/desk-form.page.ts index a7e81da34a9..0734d09d90d 100644 --- a/e2e/support/desk/desk-form.page.ts +++ b/e2e/support/desk/desk-form.page.ts @@ -24,7 +24,9 @@ * RACE, not a step: on a warm run it lands before anything is typed. Every * setter here is therefore used inside a converging block by its callers, the * same workaround `bookDeskViaUI` uses, and for the same reason it means these - * specs cannot DETECT that bug (REG-10 has its own row saying so). + * specs cannot DETECT that bug (REG-10 has its own row saying so). The one + * exception is `desk-initialisation.spec.ts`, which holds the settle timer of + * the desk flow so that its edits come before the rebuild. */ import { Locator, Page, expect } from '@playwright/test';