@flamingo-stack/openframe-frontend-core 0.0.596-2034.5685.1 → 0.0.596-2034.5686.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/fixtures/meeting-booking.d.ts +4 -0
- package/dist/__tests__/fixtures/meeting-booking.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/booking-form.d.ts +2 -0
- package/dist/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +29 -38
- package/dist/components/meeting-scheduler/index.cjs.map +1 -1
- package/dist/components/meeting-scheduler/index.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.js +29 -38
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/booking-form-field-rows.test.tsx +8 -25
- package/src/__tests__/booking-form-number-field.test.tsx +5 -17
- package/src/__tests__/fixtures/{meeting-booking.ts → meeting-booking.tsx} +12 -1
- package/src/components/meeting-scheduler/booking-form.tsx +60 -53
- package/src/components/meeting-scheduler/index.tsx +2 -2
package/package.json
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import { fireEvent,
|
|
2
|
-
import { describe, expect, it
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
type BookingFieldRow,
|
|
6
|
-
type BookingFormConsent,
|
|
7
|
-
evenSpan,
|
|
8
|
-
} from '../components/meeting-scheduler/booking-form';
|
|
9
|
-
import { availabilityWith, bookingFormBaseProps, fillIdentity, typeInto } from './fixtures/meeting-booking';
|
|
1
|
+
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { type BookingFormConsent, evenSpan } from '../components/meeting-scheduler/booking-form';
|
|
4
|
+
import { availabilityWith, fillIdentity, mountBookingForm, typeInto } from './fixtures/meeting-booking';
|
|
10
5
|
|
|
11
6
|
// The consent row's checkbox measures itself; jsdom has no ResizeObserver.
|
|
12
7
|
class ResizeObserverStub {
|
|
@@ -24,28 +19,16 @@ const availability = availabilityWith([
|
|
|
24
19
|
const CONSENT_ERROR = 'Please agree to the Privacy Policy to continue.';
|
|
25
20
|
const consent: BookingFormConsent = { label: 'I agree to the Privacy Policy', errorMessage: CONSENT_ERROR };
|
|
26
21
|
|
|
27
|
-
type Submit = (payload: Record<string, unknown>) => Promise<void>;
|
|
28
|
-
|
|
29
|
-
function mount(props: {
|
|
30
|
-
fieldRows?: BookingFieldRow[];
|
|
31
|
-
consent?: BookingFormConsent;
|
|
32
|
-
initialValues?: Record<string, unknown>;
|
|
33
|
-
}) {
|
|
34
|
-
const onSubmit = vi.fn<Submit>(() => Promise.resolve());
|
|
35
|
-
render(<BookingForm {...bookingFormBaseProps()} availability={availability} onSubmit={onSubmit} {...props} />);
|
|
36
|
-
return onSubmit;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
22
|
describe('BookingForm — host fieldRows', () => {
|
|
40
23
|
it('appends built-ins and declared questions no row names, so nothing required goes invisible', () => {
|
|
41
|
-
|
|
24
|
+
mountBookingForm(availability, { fieldRows: [[{ name: 'firstName' }], [{ name: 'company' }]] });
|
|
42
25
|
expect(screen.getByLabelText(/^Last Name/)).toBeInTheDocument();
|
|
43
26
|
expect(screen.getByLabelText(/^Email/)).toBeInTheDocument();
|
|
44
27
|
expect(screen.getByLabelText(/^Job Title/)).toBeInTheDocument();
|
|
45
28
|
});
|
|
46
29
|
|
|
47
30
|
it('renders a name placed twice once and drops a row that resolves to nothing', () => {
|
|
48
|
-
|
|
31
|
+
mountBookingForm(availability, {
|
|
49
32
|
fieldRows: [
|
|
50
33
|
[{ name: 'email' }, { name: 'email' }],
|
|
51
34
|
[{ name: 'not_declared_yet' }],
|
|
@@ -66,7 +49,7 @@ describe('BookingForm — host fieldRows', () => {
|
|
|
66
49
|
|
|
67
50
|
describe('BookingForm — host consent row', () => {
|
|
68
51
|
it('refuses to submit unticked, names why, and carries the tick as hostConsent once ticked', async () => {
|
|
69
|
-
const onSubmit =
|
|
52
|
+
const onSubmit = mountBookingForm(availability, { consent });
|
|
70
53
|
fillIdentity();
|
|
71
54
|
typeInto(screen.getByLabelText(/^Company Name/), 'Acme');
|
|
72
55
|
fireEvent.click(screen.getByRole('button', { name: 'Confirm Booking' }));
|
|
@@ -80,7 +63,7 @@ describe('BookingForm — host consent row', () => {
|
|
|
80
63
|
});
|
|
81
64
|
|
|
82
65
|
it('restores the tick from initialValues (the details-first stash) on a remount', () => {
|
|
83
|
-
|
|
66
|
+
mountBookingForm(availability, { consent, initialValues: { hostConsent: true } });
|
|
84
67
|
expect(screen.getByRole('checkbox', { name: /I agree to the Privacy Policy/ })).toHaveAttribute(
|
|
85
68
|
'aria-checked',
|
|
86
69
|
'true',
|
|
@@ -1,25 +1,13 @@
|
|
|
1
|
-
import { fireEvent,
|
|
2
|
-
import { describe, expect, it
|
|
3
|
-
import {
|
|
4
|
-
import { availabilityWith, bookingFormBaseProps, fillIdentity, typeInto } from './fixtures/meeting-booking';
|
|
1
|
+
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { availabilityWith, fillIdentity, mountBookingForm, typeInto } from './fixtures/meeting-booking';
|
|
5
4
|
|
|
6
5
|
const availability = availabilityWith([
|
|
7
6
|
{ name: 'number_of_endpoints', label: 'Number of endpoints', type: 'number', required: true },
|
|
8
7
|
]);
|
|
9
8
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function mount(onSubmit = vi.fn<Submit>(() => Promise.resolve())) {
|
|
13
|
-
render(
|
|
14
|
-
<BookingForm
|
|
15
|
-
{...bookingFormBaseProps()}
|
|
16
|
-
timezone="America/New_York"
|
|
17
|
-
availability={availability}
|
|
18
|
-
onSubmit={onSubmit}
|
|
19
|
-
/>,
|
|
20
|
-
);
|
|
21
|
-
return onSubmit;
|
|
22
|
-
}
|
|
9
|
+
/** The form in a zone that is not UTC, so the summary line's formatting is exercised. */
|
|
10
|
+
const mount = () => mountBookingForm(availability, { timezone: 'America/New_York' });
|
|
23
11
|
|
|
24
12
|
describe('BookingForm — a HubSpot Number question', () => {
|
|
25
13
|
it('renders as a numeric input under the property label', () => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { fireEvent, screen } from '@testing-library/react';
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
2
|
import { createRef } from 'react';
|
|
3
|
+
import { vi } from 'vitest';
|
|
4
|
+
import { BookingForm, type BookingFormProps } from '../../components/meeting-scheduler/booking-form';
|
|
3
5
|
import type { MeetingAvailability, MeetingFormField } from '../../schemas/meeting-booking-schema';
|
|
4
6
|
|
|
5
7
|
/** One well-formed slot-first payload minus the questions — the base every
|
|
@@ -48,3 +50,12 @@ export const fillIdentity = () => {
|
|
|
48
50
|
typeInto(screen.getByLabelText(/^First Name/), 'Ada');
|
|
49
51
|
typeInto(screen.getByLabelText(/^Last Name/), 'Lovelace');
|
|
50
52
|
};
|
|
53
|
+
|
|
54
|
+
export type BookingFormSubmit = (payload: Record<string, unknown>) => Promise<void>;
|
|
55
|
+
|
|
56
|
+
/** Mount a `BookingForm` over `availability` with the base props; returns the submit spy. */
|
|
57
|
+
export function mountBookingForm(availability: MeetingAvailability, props: Partial<BookingFormProps> = {}) {
|
|
58
|
+
const onSubmit = vi.fn<BookingFormSubmit>(() => Promise.resolve());
|
|
59
|
+
render(<BookingForm {...bookingFormBaseProps()} availability={availability} onSubmit={onSubmit} {...props} />);
|
|
60
|
+
return onSubmit;
|
|
61
|
+
}
|
|
@@ -100,6 +100,19 @@ const SPAN_CLASS = {
|
|
|
100
100
|
/** One field's skeleton: label + control footprint. */
|
|
101
101
|
const FIELD_SKELETON_CLASS = 'h-[4.75rem] w-full';
|
|
102
102
|
|
|
103
|
+
/** The submit copy every standalone `BookingForm` gets; the slot-first preset reads the same constant. */
|
|
104
|
+
export const DEFAULT_SUBMIT_LABEL = 'Confirm Booking';
|
|
105
|
+
|
|
106
|
+
/** The footer with a note (details-first): note beside the button, wrapping on a phone. */
|
|
107
|
+
const FOOTER_ROW_CLASS = 'flex flex-wrap items-center justify-between gap-[var(--spacing-system-m)]';
|
|
108
|
+
/** The bare footer: the button stays LEFT, where the form's reading order ends. */
|
|
109
|
+
const FOOTER_BARE_CLASS = 'flex';
|
|
110
|
+
/** The submit's width when a note sits beside it. */
|
|
111
|
+
const SUBMIT_WIDE_CLASS = 'md:w-60';
|
|
112
|
+
|
|
113
|
+
/** The built-in layout when a host passes no rows — what the skeleton draws for it. */
|
|
114
|
+
const DEFAULT_FIELD_ROWS: BookingFieldRow[] = [[{ name: 'email' }], [{ name: 'firstName' }, { name: 'lastName' }]];
|
|
115
|
+
|
|
103
116
|
/** The even split of four columns over `count` slots; a remainder goes to the
|
|
104
117
|
* leading slots (three slots → 2/1/1), so a row never leaves a trailing gap. */
|
|
105
118
|
export const evenSpan = (count: number, index: number): BookingFieldSpan => {
|
|
@@ -482,25 +495,9 @@ export function BookingForm({
|
|
|
482
495
|
* HubSpot nor a built-in the layout forgot can go invisible: the schema still
|
|
483
496
|
* requires the identity trio, and a required field with no control is a
|
|
484
497
|
* submit that dies silently. */
|
|
485
|
-
const placedNames =
|
|
486
|
-
const unplacedBuiltIns = fieldRows ? BUILT_IN_BOOKING_FIELDS.filter(f => !placedNames.has(f.name)) : [];
|
|
498
|
+
const { rows: placedRows, unplacedBuiltIns, named: placedNames } = normalizeFieldRows(fieldRows ?? [], slotResolves);
|
|
487
499
|
const unplacedFields = fieldRows ? supportedFields.filter(f => !placedNames.has(f.name)) : [];
|
|
488
500
|
|
|
489
|
-
/** Rows whose every slot names a question the link has not declared are
|
|
490
|
-
* DROPPED, not rendered empty: an empty grid still eats one form gap, so a
|
|
491
|
-
* layout written ahead of the HubSpot config would print blank bands. A name
|
|
492
|
-
* placed twice renders once (its first slot) — one registered control per
|
|
493
|
-
* field. */
|
|
494
|
-
const seen = new Set<string>();
|
|
495
|
-
const placedRows = (fieldRows ?? [])
|
|
496
|
-
.map(row => {
|
|
497
|
-
const kept = row.filter(slot => slotResolves(slot.name) && !seen.has(slot.name) && Boolean(seen.add(slot.name)));
|
|
498
|
-
// Explicit spans were written for the FULL row; once a slot drops out they
|
|
499
|
-
// would leave a trailing gap, so a shortened row falls back to the even split.
|
|
500
|
-
return kept.length === row.length ? kept : kept.map((slot): BookingFieldSlot => ({ name: slot.name }));
|
|
501
|
-
})
|
|
502
|
-
.filter(row => row.length > 0);
|
|
503
|
-
|
|
504
501
|
const submitButton = (
|
|
505
502
|
<Button
|
|
506
503
|
type="submit"
|
|
@@ -509,9 +506,9 @@ export function BookingForm({
|
|
|
509
506
|
// The details-first footer draws a 240px action beside its note
|
|
510
507
|
// (`4904:117335`); the bare slot-first row keeps the button at its natural
|
|
511
508
|
// width, as it always has.
|
|
512
|
-
className={footerNote ?
|
|
509
|
+
className={footerNote ? SUBMIT_WIDE_CLASS : undefined}
|
|
513
510
|
>
|
|
514
|
-
{submitLabel ??
|
|
511
|
+
{submitLabel ?? DEFAULT_SUBMIT_LABEL}
|
|
515
512
|
</Button>
|
|
516
513
|
);
|
|
517
514
|
|
|
@@ -638,12 +635,12 @@ export function BookingForm({
|
|
|
638
635
|
the bare `flex` row it has always been, or the submit slides from the
|
|
639
636
|
left edge to the right on every existing slot-first booking. */}
|
|
640
637
|
{footerNote ? (
|
|
641
|
-
<div className=
|
|
638
|
+
<div className={FOOTER_ROW_CLASS}>
|
|
642
639
|
<p className="text-ods-text-secondary text-h6">{footerNote}</p>
|
|
643
640
|
{submitButton}
|
|
644
641
|
</div>
|
|
645
642
|
) : (
|
|
646
|
-
<div className=
|
|
643
|
+
<div className={FOOTER_BARE_CLASS}>{submitButton}</div>
|
|
647
644
|
)}
|
|
648
645
|
</form>
|
|
649
646
|
);
|
|
@@ -661,38 +658,25 @@ export function BookingForm({
|
|
|
661
658
|
export function BookingFormSkeleton({ fieldRows, footerNote }: { fieldRows?: BookingFieldRow[]; footerNote?: string }) {
|
|
662
659
|
return (
|
|
663
660
|
<div className={cn('flex-1', FORM_STACK)}>
|
|
664
|
-
{
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
</div>
|
|
676
|
-
))
|
|
677
|
-
) : (
|
|
678
|
-
<>
|
|
679
|
-
<div className="grid grid-cols-1 gap-[var(--spacing-system-m)] md:grid-cols-2">
|
|
680
|
-
<Skeleton className={FIELD_SKELETON_CLASS} />
|
|
681
|
-
<Skeleton className={FIELD_SKELETON_CLASS} />
|
|
682
|
-
</div>
|
|
683
|
-
<Skeleton className={FIELD_SKELETON_CLASS} />
|
|
684
|
-
<Skeleton className="h-[7.75rem] w-full" />
|
|
685
|
-
</>
|
|
686
|
-
)}
|
|
661
|
+
{/* The host's rows (or the built-in layout) through the SAME grid, column
|
|
662
|
+
rule and normalisation as the loaded form — see `normalizeFieldRows`. */}
|
|
663
|
+
{skeletonRows(fieldRows ?? DEFAULT_FIELD_ROWS).map(row => (
|
|
664
|
+
<div key={row.map(s => s.name).join('|')} className={ROW_GRID}>
|
|
665
|
+
{row.map((slot, slotIndex) => (
|
|
666
|
+
<Skeleton key={slot.name} className={cn(FIELD_SKELETON_CLASS, slotColumnClass(row, slot, slotIndex))} />
|
|
667
|
+
))}
|
|
668
|
+
</div>
|
|
669
|
+
))}
|
|
670
|
+
{/* Without rows there is no telling how many questions the link declares: one long answer stands for them. */}
|
|
671
|
+
{!fieldRows && <Skeleton className="h-[7.75rem] w-full" />}
|
|
687
672
|
<Skeleton className="h-[4.25rem] w-full" />
|
|
688
673
|
{footerNote ? (
|
|
689
|
-
|
|
690
|
-
<div className="flex flex-wrap items-center justify-between gap-[var(--spacing-system-m)]">
|
|
674
|
+
<div className={FOOTER_ROW_CLASS}>
|
|
691
675
|
<Skeleton className="h-5 min-w-40 flex-1" />
|
|
692
|
-
<Skeleton className=
|
|
676
|
+
<Skeleton className={cn('h-12 w-full', SUBMIT_WIDE_CLASS)} />
|
|
693
677
|
</div>
|
|
694
678
|
) : (
|
|
695
|
-
<div className=
|
|
679
|
+
<div className={FOOTER_BARE_CLASS}>
|
|
696
680
|
<Skeleton className="h-12 w-40" />
|
|
697
681
|
</div>
|
|
698
682
|
)}
|
|
@@ -700,12 +684,35 @@ export function BookingFormSkeleton({ fieldRows, footerNote }: { fieldRows?: Boo
|
|
|
700
684
|
);
|
|
701
685
|
}
|
|
702
686
|
|
|
703
|
-
/**
|
|
704
|
-
|
|
687
|
+
/**
|
|
688
|
+
* The ONE normalisation of host rows, read by the form and its skeleton.
|
|
689
|
+
* - A slot whose name `resolves` to nothing is dropped, and a row left empty is
|
|
690
|
+
* dropped with it: an empty grid still eats one form gap, so a layout written
|
|
691
|
+
* ahead of the HubSpot config would print blank bands.
|
|
692
|
+
* - A name placed twice renders once (its first slot) — one control per field.
|
|
693
|
+
* - Explicit spans were written for the FULL row; once a slot drops out they
|
|
694
|
+
* would leave a trailing gap, so a shortened row falls back to the even split.
|
|
695
|
+
* - Built-ins no row names come back as `unplacedBuiltIns`: the schema still
|
|
696
|
+
* requires the identity trio, and a required field with no control is a
|
|
697
|
+
* submit that dies silently.
|
|
698
|
+
* The skeleton has no availability yet, so it passes no `resolves` and keeps
|
|
699
|
+
* every declared-looking name; that is the one swap it cannot rule out.
|
|
700
|
+
*/
|
|
701
|
+
function normalizeFieldRows(fieldRows: BookingFieldRow[], resolves: (name: string) => boolean = () => true) {
|
|
705
702
|
const seen = new Set<string>();
|
|
706
703
|
const rows = fieldRows
|
|
707
|
-
.map(row =>
|
|
704
|
+
.map(row => {
|
|
705
|
+
const kept = row.filter(slot => resolves(slot.name) && !seen.has(slot.name) && Boolean(seen.add(slot.name)));
|
|
706
|
+
return kept.length === row.length ? kept : kept.map((slot): BookingFieldSlot => ({ name: slot.name }));
|
|
707
|
+
})
|
|
708
708
|
.filter(row => row.length > 0);
|
|
709
|
-
const
|
|
710
|
-
|
|
709
|
+
const named = new Set(fieldRows.flat().map(slot => slot.name));
|
|
710
|
+
const unplacedBuiltIns = BUILT_IN_BOOKING_FIELDS.filter(f => !named.has(f.name));
|
|
711
|
+
return { rows, unplacedBuiltIns, named };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** The skeleton's rows: the normalised host rows, then the built-ins none of them named. */
|
|
715
|
+
function skeletonRows(fieldRows: BookingFieldRow[]): BookingFieldRow[] {
|
|
716
|
+
const { rows, unplacedBuiltIns } = normalizeFieldRows(fieldRows);
|
|
717
|
+
return [...rows, ...unplacedBuiltIns.map(f => [{ name: f.name }])];
|
|
711
718
|
}
|
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
import { cn } from '../../utils/cn';
|
|
56
56
|
import { formatDurationCompact } from '../../utils/format';
|
|
57
57
|
import { Alert, AlertDescription, Button } from '../ui';
|
|
58
|
-
import { BookingForm, BookingFormSkeleton, type BookingFormProps } from './booking-form';
|
|
58
|
+
import { BookingForm, BookingFormSkeleton, DEFAULT_SUBMIT_LABEL, type BookingFormProps } from './booking-form';
|
|
59
59
|
import { Confirmation } from './confirmation';
|
|
60
60
|
import { SchedulerContextPanel, ContextPanelSkeleton } from './context-panel';
|
|
61
61
|
import { SlotPicker, SlotPickerSkeleton, dayKeyInZone } from './slot-picker';
|
|
@@ -271,7 +271,7 @@ export const SCHEDULER_FLOW_PRESETS: Record<
|
|
|
271
271
|
SchedulerFlow,
|
|
272
272
|
{ initialStep: Exclude<Step, 'confirmed'>; height: string; submitLabel: string; footerNote?: string }
|
|
273
273
|
> = {
|
|
274
|
-
'slot-first': { initialStep: 'slot', height: MEETING_SCHEDULER_H, submitLabel:
|
|
274
|
+
'slot-first': { initialStep: 'slot', height: MEETING_SCHEDULER_H, submitLabel: DEFAULT_SUBMIT_LABEL },
|
|
275
275
|
'details-first': {
|
|
276
276
|
initialStep: 'details',
|
|
277
277
|
height: MEETING_SCHEDULER_DETAILS_FIRST_H,
|