@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.596-2034.5685.1",
3
+ "version": "0.0.596-2034.5686.1",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,12 +1,7 @@
1
- import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import {
4
- BookingForm,
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
- mount({ fieldRows: [[{ name: 'firstName' }], [{ name: 'company' }]] });
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
- mount({
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 = mount({ consent });
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
- mount({ consent, initialValues: { hostConsent: true } });
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, render, screen, waitFor } from '@testing-library/react';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import { BookingForm } from '../components/meeting-scheduler/booking-form';
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
- type Submit = (payload: Record<string, unknown>) => Promise<void>;
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 = new Set((fieldRows ?? []).flat().map(slot => slot.name));
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 ? 'md:w-60' : undefined}
509
+ className={footerNote ? SUBMIT_WIDE_CLASS : undefined}
513
510
  >
514
- {submitLabel ?? 'Confirm Booking'}
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="flex flex-wrap items-center justify-between gap-[var(--spacing-system-m)]">
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="flex">{submitButton}</div>
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
- {fieldRows ? (
665
- // The host's rows through the SAME grid + column rule as the loaded form,
666
- // normalised the way the form normalises what it can know without the
667
- // link: a name placed twice once, and the built-ins no row names appended.
668
- // (Undeclared questions cannot be dropped here — declaring them is what
669
- // the availability the skeleton stands in for will tell us.)
670
- skeletonRows(fieldRows).map(row => (
671
- <div key={row.map(s => s.name).join('|')} className={ROW_GRID}>
672
- {row.map((slot, slotIndex) => (
673
- <Skeleton key={slot.name} className={cn(FIELD_SKELETON_CLASS, slotColumnClass(row, slot, slotIndex))} />
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
- // The details-first footer: note beside a 240px button, wrapping on a phone.
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="h-12 w-full md:w-60" />
676
+ <Skeleton className={cn('h-12 w-full', SUBMIT_WIDE_CLASS)} />
693
677
  </div>
694
678
  ) : (
695
- <div className="flex justify-end">
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
- /** The skeleton's half of the form's row normalisation — see `BookingFormSkeleton`. */
704
- function skeletonRows(fieldRows: BookingFieldRow[]): BookingFieldRow[] {
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 => row.filter(slot => !seen.has(slot.name) && Boolean(seen.add(slot.name))))
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 unplacedBuiltIns = BUILT_IN_BOOKING_FIELDS.filter(f => !seen.has(f.name)).map(f => [{ name: f.name }]);
710
- return [...rows, ...unplacedBuiltIns];
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: 'Confirm Booking' },
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,