@flamingo-stack/openframe-frontend-core 0.0.664-snapshot.20260924171334 → 0.0.664
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/components/meeting-scheduler/booking-form.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +35 -177
- 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 +9 -151
- package/dist/components/meeting-scheduler/index.js.map +1 -1
- package/dist/schemas/meeting-booking-schema.cjs +6 -107
- package/dist/schemas/meeting-booking-schema.cjs.map +1 -1
- package/dist/schemas/meeting-booking-schema.d.ts +19 -105
- package/dist/schemas/meeting-booking-schema.d.ts.map +1 -1
- package/dist/schemas/meeting-booking-schema.js +7 -99
- package/dist/schemas/meeting-booking-schema.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/meeting-booking-schema.test.ts +2 -2
- package/src/__tests__/meeting-scheduler-details-first.test.tsx +0 -33
- package/src/components/meeting-scheduler/booking-form.tsx +4 -67
- package/src/components/meeting-scheduler/index.tsx +6 -9
- package/src/schemas/meeting-booking-schema.ts +25 -231
- package/src/utils/__tests__/hubspot-meetings-convention.test.ts +6 -15
- package/src/__tests__/meeting-form-field-types.test.tsx +0 -240
|
@@ -86,25 +86,12 @@ export interface SchedulingLinksPayload {
|
|
|
86
86
|
fetchedAt: string;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/**
|
|
90
|
-
* One declared question as the host forwards it. `type` is HubSpot's
|
|
91
|
-
* `fieldType` (the control HubSpot draws: `phonenumber`, `booleancheckbox`…)
|
|
92
|
-
* VERBATIM, and `dataType` its `type` (the stored value's kind: `string`,
|
|
93
|
-
* `enumeration`, `phone_number`…). Neither is a closed set: the widget resolves
|
|
94
|
-
* ANY pair to a control through `resolveFormFieldControl`, so a type HubSpot
|
|
95
|
-
* adds tomorrow renders as the nearest control instead of taking the form down.
|
|
96
|
-
*/
|
|
97
89
|
export interface MeetingFormField {
|
|
98
90
|
name: string;
|
|
99
91
|
label: string;
|
|
100
92
|
type: string;
|
|
101
|
-
/** HubSpot's property data type — the fallback when `type` is unknown. Optional: older hosts omit it. */
|
|
102
|
-
dataType?: string;
|
|
103
93
|
required: boolean;
|
|
104
|
-
/** Option VALUES (what is submitted). */
|
|
105
94
|
options?: string[];
|
|
106
|
-
/** Option value → display label, when HubSpot's label differs from the value. */
|
|
107
|
-
optionLabels?: Record<string, string>;
|
|
108
95
|
}
|
|
109
96
|
|
|
110
97
|
/**
|
|
@@ -171,25 +158,22 @@ export type MeetingBookingErrorCode = (typeof MEETING_BOOKING_ERROR_CODES)[numbe
|
|
|
171
158
|
// ---------------------------------------------------------------------------
|
|
172
159
|
|
|
173
160
|
/**
|
|
174
|
-
* THE registry of
|
|
175
|
-
* and the ONLY place a
|
|
176
|
-
* `SupportedFormFieldType` is its key union,
|
|
177
|
-
* keys,
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
161
|
+
* THE registry of HubSpot question types the native form supports — one entry
|
|
162
|
+
* per `fieldType`, and the ONLY place a type is declared. Everything else
|
|
163
|
+
* derives from it: `SupportedFormFieldType` is its key union,
|
|
164
|
+
* `SUPPORTED_FORM_FIELD_TYPES` its keys, `FORM_FIELD_TYPES_WITH_OPTIONS` the
|
|
165
|
+
* entries that carry `options`, and `makeBookingSchema` maps each answer
|
|
166
|
+
* through the entry's validator. The widget's control table
|
|
167
|
+
* (`booking-form.tsx`) is a `Record` over the same key union, so a type added
|
|
168
|
+
* here without a control is a COMPILE error, not a silent gap.
|
|
181
169
|
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
* its data type, then a generic fallback), so the open set of HubSpot types
|
|
185
|
-
* maps onto this closed set of controls and no type is ever "unsupported".
|
|
186
|
-
* The one thing that still falls back to HubSpot's own page is a REQUIRED
|
|
187
|
-
* question no control can answer (a file upload) — see `blocksNativeBooking`.
|
|
170
|
+
* Fail-closed: a `fieldType` with no entry makes the link "not natively
|
|
171
|
+
* bookable" and the card falls back to the HubSpot escape hatch.
|
|
188
172
|
*
|
|
189
|
-
* Every string
|
|
190
|
-
*
|
|
191
|
-
* a decimal literal
|
|
192
|
-
*
|
|
173
|
+
* Every string type rides the wire as a STRING (HubSpot's book endpoint takes
|
|
174
|
+
* `{ name, value: string }`); `checkbox` is the one boolean. `number` is a
|
|
175
|
+
* Number property validated as a decimal literal — what `<input type="number">`
|
|
176
|
+
* emits and what the property stores.
|
|
193
177
|
*/
|
|
194
178
|
export interface FormFieldTypeSpec {
|
|
195
179
|
/** The answer's wire shape, which also decides how required/optional wraps it. */
|
|
@@ -215,32 +199,6 @@ const optionValidator: FormFieldTypeSpec['validator'] = (field, base) =>
|
|
|
215
199
|
/** The wire shape of a number answer — what the form canonicalises TO and the validator checks. */
|
|
216
200
|
export const DECIMAL_LITERAL_RE = /^-?\d+(\.\d+)?$/;
|
|
217
201
|
|
|
218
|
-
/** HubSpot's separator for a multi-value enumeration (`a;b;c`). */
|
|
219
|
-
export const MULTI_VALUE_SEPARATOR = ';';
|
|
220
|
-
|
|
221
|
-
/** Split a multiselect answer into its values (empty string → none). */
|
|
222
|
-
export const splitMultiValue = (value: unknown): string[] =>
|
|
223
|
-
typeof value === 'string' && value ? value.split(MULTI_VALUE_SEPARATOR) : [];
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* A phone answer: digits with the punctuation people actually type (`+1 (415)
|
|
227
|
-
* 555-2671`, `020 7946 0958 ext. 12`). Deliberately a SHAPE check, not a
|
|
228
|
-
* numbering-plan check — HubSpot stores the string as typed, and rejecting a
|
|
229
|
-
* valid number we failed to parse is worse than accepting an odd one.
|
|
230
|
-
*/
|
|
231
|
-
export const PHONE_RE = /^\+?[\d\s().\-/]+(?:\s*(?:x|ext\.?)\s*\d+)?$/i;
|
|
232
|
-
const PHONE_MIN_DIGITS = 5;
|
|
233
|
-
const PHONE_MAX_DIGITS = 20;
|
|
234
|
-
const phoneDigits = (v: string) => v.replace(/\D/g, '').length;
|
|
235
|
-
|
|
236
|
-
/** A date answer as `<input type="date">` emits it. */
|
|
237
|
-
export const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
238
|
-
const isRealIsoDate = (v: string) => {
|
|
239
|
-
if (!ISO_DATE_RE.test(v)) return false;
|
|
240
|
-
const d = new Date(`${v}T00:00:00Z`);
|
|
241
|
-
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
|
|
242
|
-
};
|
|
243
|
-
|
|
244
202
|
export const FORM_FIELD_TYPES = {
|
|
245
203
|
text: {
|
|
246
204
|
kind: 'string',
|
|
@@ -264,33 +222,8 @@ export const FORM_FIELD_TYPES = {
|
|
|
264
222
|
.max(32, { message: `${field.label} is too long` })
|
|
265
223
|
.regex(DECIMAL_LITERAL_RE, { message: `${field.label} must be a number` }),
|
|
266
224
|
},
|
|
267
|
-
phone: {
|
|
268
|
-
kind: 'string',
|
|
269
|
-
hasOptions: false,
|
|
270
|
-
validator: (field, base) =>
|
|
271
|
-
base
|
|
272
|
-
.max(40, { message: `${field.label} is too long` })
|
|
273
|
-
.refine(
|
|
274
|
-
v => !v || (PHONE_RE.test(v) && phoneDigits(v) >= PHONE_MIN_DIGITS && phoneDigits(v) <= PHONE_MAX_DIGITS),
|
|
275
|
-
{ message: `Please enter a valid phone number for ${field.label}` },
|
|
276
|
-
),
|
|
277
|
-
placeholder: field => `Enter ${field.label}`,
|
|
278
|
-
},
|
|
279
|
-
date: {
|
|
280
|
-
kind: 'string',
|
|
281
|
-
hasOptions: false,
|
|
282
|
-
validator: (field, base) => base.refine(v => !v || isRealIsoDate(v), { message: `${field.label} must be a date` }),
|
|
283
|
-
},
|
|
284
225
|
select: { kind: 'string', hasOptions: true, validator: optionValidator },
|
|
285
226
|
radio: { kind: 'string', hasOptions: true, validator: optionValidator },
|
|
286
|
-
multiselect: {
|
|
287
|
-
kind: 'string',
|
|
288
|
-
hasOptions: true,
|
|
289
|
-
validator: (field, base) =>
|
|
290
|
-
base.refine(v => splitMultiValue(v).every(part => (field.options ?? []).includes(part)), {
|
|
291
|
-
message: `Please choose valid options for ${field.label}`,
|
|
292
|
-
}),
|
|
293
|
-
},
|
|
294
227
|
checkbox: { kind: 'boolean', hasOptions: false, validator: () => z.boolean() },
|
|
295
228
|
} as const satisfies Record<string, FormFieldTypeSpec>;
|
|
296
229
|
|
|
@@ -298,158 +231,22 @@ export type SupportedFormFieldType = keyof typeof FORM_FIELD_TYPES;
|
|
|
298
231
|
|
|
299
232
|
export const SUPPORTED_FORM_FIELD_TYPES = Object.keys(FORM_FIELD_TYPES) as readonly SupportedFormFieldType[];
|
|
300
233
|
|
|
301
|
-
|
|
302
|
-
// HubSpot type → control resolution (open set in, closed set out)
|
|
303
|
-
// ---------------------------------------------------------------------------
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* What a HubSpot question resolves to: a drawable control, `display` (a
|
|
307
|
-
* read-only block — `html`, `calculation_*` — never rendered, never
|
|
308
|
-
* submitted) or `unanswerable` (nothing the booking POST can carry, e.g. a
|
|
309
|
-
* file upload).
|
|
310
|
-
*/
|
|
311
|
-
export type FormFieldResolution = SupportedFormFieldType | 'display' | 'unanswerable';
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* HubSpot `fieldType` spellings → resolution. Keys are lower-cased. A spelling
|
|
315
|
-
* missing here is NOT an error: resolution falls through to the data type and
|
|
316
|
-
* then to a generic control. Add an entry only to draw a BETTER control for a
|
|
317
|
-
* type, never to make one "work".
|
|
318
|
-
*
|
|
319
|
-
* `checkbox` is absent on purpose: in HubSpot it is "multiple checkboxes" (an
|
|
320
|
-
* enumeration with options), while older links sent it for a single yes/no —
|
|
321
|
-
* `resolveFormFieldControl` decides by whether options came with it.
|
|
322
|
-
*/
|
|
323
|
-
const FIELD_TYPE_CONTROLS: Readonly<Record<string, FormFieldResolution>> = {
|
|
324
|
-
text: 'text',
|
|
325
|
-
textarea: 'textarea',
|
|
326
|
-
number: 'number',
|
|
327
|
-
select: 'select',
|
|
328
|
-
radio: 'radio',
|
|
329
|
-
booleancheckbox: 'checkbox',
|
|
330
|
-
phonenumber: 'phone',
|
|
331
|
-
date: 'date',
|
|
332
|
-
html: 'display',
|
|
333
|
-
file: 'unanswerable',
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
/** HubSpot property data types (`type`) → resolution, used when `fieldType` is unknown. */
|
|
337
|
-
const DATA_TYPE_CONTROLS: Readonly<Record<string, FormFieldResolution>> = {
|
|
338
|
-
string: 'text',
|
|
339
|
-
number: 'number',
|
|
340
|
-
enumeration: 'select',
|
|
341
|
-
bool: 'checkbox',
|
|
342
|
-
date: 'date',
|
|
343
|
-
datetime: 'date',
|
|
344
|
-
phone_number: 'phone',
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
const hasDeclaredOptions = (field: MeetingFormField) => (field.options?.length ?? 0) > 0;
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* THE one mapping from a HubSpot question to what the form does with it.
|
|
351
|
-
* Total: every input yields a resolution, so an unrecognised type can never
|
|
352
|
-
* make a link unbookable — at worst it is a text box. An option control with
|
|
353
|
-
* no options degrades to text (nothing to pick from, but still answerable).
|
|
354
|
-
*/
|
|
355
|
-
export function resolveFormFieldControl(field: MeetingFormField): FormFieldResolution {
|
|
356
|
-
const fieldType = (field.type ?? '').toLowerCase();
|
|
357
|
-
const withOptions = hasDeclaredOptions(field);
|
|
358
|
-
const usable = (r: FormFieldResolution | undefined): FormFieldResolution | undefined => {
|
|
359
|
-
if (!r || r === 'display' || r === 'unanswerable') return r;
|
|
360
|
-
return FORM_FIELD_TYPES[r].hasOptions && !withOptions ? 'text' : r;
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
if (fieldType === 'checkbox') return withOptions ? 'multiselect' : 'checkbox';
|
|
364
|
-
if (fieldType.startsWith('calculation')) return 'display';
|
|
365
|
-
// A control name resolves to itself, so resolution is IDEMPOTENT: the form
|
|
366
|
-
// resolves once to draw, then hands the resolved fields to the schema
|
|
367
|
-
// factory, which resolves again (the server rebuild resolves raw ones).
|
|
368
|
-
const asControl = Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, fieldType)
|
|
369
|
-
? (fieldType as SupportedFormFieldType)
|
|
370
|
-
: undefined;
|
|
371
|
-
return (
|
|
372
|
-
usable(FIELD_TYPE_CONTROLS[fieldType] ?? asControl) ??
|
|
373
|
-
usable(DATA_TYPE_CONTROLS[(field.dataType ?? '').toLowerCase()]) ??
|
|
374
|
-
(withOptions ? 'select' : 'text')
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* @deprecated Kept for hosts built against the closed six-type registry (they
|
|
380
|
-
* asked this to decide whether to forward `options`). Answers for any HubSpot
|
|
381
|
-
* type through the resolver: true when the type resolves to an option control
|
|
382
|
-
* once options are present. New hosts forward options whenever HubSpot sends
|
|
383
|
-
* them and do not need this.
|
|
384
|
-
*/
|
|
385
|
-
export function formFieldTypeHasOptions(type: string): boolean {
|
|
386
|
-
const control = resolveFormFieldControl({ name: '', label: '', type, required: false, options: ['_'] });
|
|
387
|
-
return control !== 'display' && control !== 'unanswerable' && FORM_FIELD_TYPES[control].hasOptions;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/** @deprecated The option controls (see `formFieldTypeHasOptions`). */
|
|
234
|
+
/** The types whose `options` the host must forward (select, radio). */
|
|
391
235
|
export const FORM_FIELD_TYPES_WITH_OPTIONS: readonly SupportedFormFieldType[] = SUPPORTED_FORM_FIELD_TYPES.filter(
|
|
392
236
|
type => FORM_FIELD_TYPES[type].hasOptions,
|
|
393
237
|
);
|
|
394
238
|
|
|
395
|
-
/**
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
* the false case: the form still works, but a better control may be one table
|
|
399
|
-
* entry away.
|
|
400
|
-
*/
|
|
401
|
-
export function isRecognisedFormFieldType(type: string): boolean {
|
|
402
|
-
const t = (type ?? '').toLowerCase();
|
|
403
|
-
return (
|
|
404
|
-
t === 'checkbox' ||
|
|
405
|
-
t.startsWith('calculation') ||
|
|
406
|
-
Object.prototype.hasOwnProperty.call(FIELD_TYPE_CONTROLS, t) ||
|
|
407
|
-
Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, t)
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/** A declared question after resolution: `type` is the CONTROL, `hubspotType` what HubSpot sent. */
|
|
412
|
-
export type SupportedMeetingFormField = MeetingFormField & { type: SupportedFormFieldType; hubspotType?: string };
|
|
413
|
-
|
|
414
|
-
/** One question → its drawable form, or null when the form does not draw it (display / unanswerable). */
|
|
415
|
-
export function normalizeFormField(
|
|
416
|
-
field: MeetingFormField & { hubspotType?: string },
|
|
417
|
-
): SupportedMeetingFormField | null {
|
|
418
|
-
const control = resolveFormFieldControl(field);
|
|
419
|
-
if (control === 'display' || control === 'unanswerable') return null;
|
|
420
|
-
// A control that takes no options drops them (a `booleancheckbox` arrives
|
|
421
|
-
// with HubSpot's `true`/`false` pair), which also keeps a re-resolution of
|
|
422
|
-
// the result on the same control.
|
|
423
|
-
const normalized: SupportedMeetingFormField = {
|
|
424
|
-
...field,
|
|
425
|
-
type: control,
|
|
426
|
-
hubspotType: field.hubspotType ?? field.type,
|
|
427
|
-
};
|
|
428
|
-
if (!FORM_FIELD_TYPES[control].hasOptions) {
|
|
429
|
-
delete normalized.options;
|
|
430
|
-
delete normalized.optionLabels;
|
|
431
|
-
}
|
|
432
|
-
return normalized;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** The questions the native form draws and validates, in declared order. */
|
|
436
|
-
export function normalizeFormFields(fields: readonly MeetingFormField[]): SupportedMeetingFormField[] {
|
|
437
|
-
return fields.flatMap(f => normalizeFormField(f) ?? []);
|
|
239
|
+
/** Whether a raw HubSpot field type is one whose answers come from declared options. */
|
|
240
|
+
export function formFieldTypeHasOptions(type: string): boolean {
|
|
241
|
+
return (FORM_FIELD_TYPES_WITH_OPTIONS as readonly string[]).includes(type);
|
|
438
242
|
}
|
|
439
243
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
return normalizeFormField(field) !== null;
|
|
244
|
+
export function isSupportedFormField(field: MeetingFormField): field is SupportedMeetingFormField {
|
|
245
|
+
return Object.prototype.hasOwnProperty.call(FORM_FIELD_TYPES, field.type);
|
|
443
246
|
}
|
|
444
247
|
|
|
445
|
-
/**
|
|
446
|
-
|
|
447
|
-
* question no control can answer. An optional one is simply left out — the
|
|
448
|
-
* visitor can still book, and HubSpot does not require it.
|
|
449
|
-
*/
|
|
450
|
-
export function blocksNativeBooking(field: MeetingFormField): boolean {
|
|
451
|
-
return field.required && resolveFormFieldControl(field) === 'unanswerable';
|
|
452
|
-
}
|
|
248
|
+
/** A declared question whose `type` is in the registry. */
|
|
249
|
+
export type SupportedMeetingFormField = MeetingFormField & { type: SupportedFormFieldType };
|
|
453
250
|
|
|
454
251
|
/**
|
|
455
252
|
* The scheduler's fixed identity fields. HubSpot's book endpoint takes them
|
|
@@ -584,11 +381,8 @@ function buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.Zod
|
|
|
584
381
|
slot: { startTimeMs: TStart; durationMs: TDuration; timezone: TZone },
|
|
585
382
|
) {
|
|
586
383
|
const answers: Record<string, z.ZodTypeAny> = {};
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
// Display blocks and unanswerable optional questions carry no answer.
|
|
590
|
-
const drawn = normalizeFormFields(formFields);
|
|
591
|
-
for (const field of drawn) {
|
|
384
|
+
for (const field of formFields) {
|
|
385
|
+
if (!isSupportedFormField(field)) continue; // unsupported types are fail-closed at render time
|
|
592
386
|
const spec: FormFieldTypeSpec = FORM_FIELD_TYPES[field.type];
|
|
593
387
|
// required/optional wrapping follows the answer's wire KIND, not its type:
|
|
594
388
|
// a boolean is true-or-absent, a string is non-empty-or-empty. For strings
|
|
@@ -613,7 +407,7 @@ function buildBookingSchema<TStart extends z.ZodTypeAny, TDuration extends z.Zod
|
|
|
613
407
|
// omitting the `formFields` key entirely would skip every per-question
|
|
614
408
|
// rule. When the link declares at least one required supported question,
|
|
615
409
|
// the object itself is required.
|
|
616
|
-
const hasRequiredAnswers =
|
|
410
|
+
const hasRequiredAnswers = formFields.some(f => isSupportedFormField(f) && f.required);
|
|
617
411
|
const answersObject = z.object(answers);
|
|
618
412
|
|
|
619
413
|
return (
|
|
@@ -100,23 +100,14 @@ describe('makeBookingSchema × SUPPORTED_FORM_FIELD_TYPES', () => {
|
|
|
100
100
|
label: `Question (${type})`,
|
|
101
101
|
type,
|
|
102
102
|
required: true,
|
|
103
|
-
options: type === 'select' || type === 'radio'
|
|
103
|
+
options: type === 'select' || type === 'radio' ? ['a', 'b'] : undefined,
|
|
104
104
|
};
|
|
105
105
|
expect(isSupportedFormField(field)).toBe(true);
|
|
106
106
|
const schema = makeBookingSchema([field], null);
|
|
107
|
-
// One well-formed answer per
|
|
108
|
-
// pickers,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
checkbox: true,
|
|
112
|
-
select: 'a',
|
|
113
|
-
radio: 'a',
|
|
114
|
-
multiselect: 'a;b',
|
|
115
|
-
number: '42',
|
|
116
|
-
phone: '+1 415 555 2671',
|
|
117
|
-
date: '2026-09-24',
|
|
118
|
-
};
|
|
119
|
-
const answer = ANSWERS[type] ?? 'hello';
|
|
107
|
+
// One well-formed answer per type: a boolean for the box, an option for the
|
|
108
|
+
// pickers, a decimal literal for a Number property, free text otherwise.
|
|
109
|
+
const answer =
|
|
110
|
+
type === 'checkbox' ? true : type === 'select' || type === 'radio' ? 'a' : type === 'number' ? '42' : 'hello';
|
|
120
111
|
const ok = schema.safeParse({ ...base, formFields: { [field.name]: answer } });
|
|
121
112
|
expect(ok.success).toBe(true);
|
|
122
113
|
const missing = schema.safeParse({ ...base, formFields: {} });
|
|
@@ -124,7 +115,7 @@ describe('makeBookingSchema × SUPPORTED_FORM_FIELD_TYPES', () => {
|
|
|
124
115
|
}
|
|
125
116
|
});
|
|
126
117
|
|
|
127
|
-
it('
|
|
118
|
+
it('unsupported types are excluded from validation (fail-closed happens at render)', () => {
|
|
128
119
|
const field: MeetingFormField = { name: 'q_file', label: 'Upload', type: 'file', required: true };
|
|
129
120
|
expect(isSupportedFormField(field)).toBe(false);
|
|
130
121
|
const schema = makeBookingSchema([field], null);
|
|
@@ -1,240 +0,0 @@
|
|
|
1
|
-
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
4
|
-
DECIMAL_LITERAL_RE,
|
|
5
|
-
FORM_FIELD_TYPES,
|
|
6
|
-
FORM_FIELD_TYPES_WITH_OPTIONS,
|
|
7
|
-
ISO_DATE_RE,
|
|
8
|
-
MULTI_VALUE_SEPARATOR,
|
|
9
|
-
PHONE_RE,
|
|
10
|
-
SUPPORTED_FORM_FIELD_TYPES,
|
|
11
|
-
blocksNativeBooking,
|
|
12
|
-
formFieldTypeHasOptions,
|
|
13
|
-
normalizeFormFields,
|
|
14
|
-
splitMultiValue,
|
|
15
|
-
type FormFieldResolution,
|
|
16
|
-
type FormFieldTypeSpec,
|
|
17
|
-
type SupportedMeetingFormField,
|
|
18
|
-
isRecognisedFormFieldType,
|
|
19
|
-
makeBookingSchema,
|
|
20
|
-
normalizeFormField,
|
|
21
|
-
resolveFormFieldControl,
|
|
22
|
-
type MeetingFormField,
|
|
23
|
-
} from '../schemas/meeting-booking-schema';
|
|
24
|
-
import { BOOKING_BASE, availabilityWith, fillIdentity, mountBookingForm, typeInto } from './fixtures/meeting-booking';
|
|
25
|
-
|
|
26
|
-
const q = (type: string, extra: Partial<MeetingFormField> = {}): MeetingFormField => ({
|
|
27
|
-
name: `q_${type}`,
|
|
28
|
-
label: `Q ${type}`,
|
|
29
|
-
type,
|
|
30
|
-
required: false,
|
|
31
|
-
...extra,
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
describe('resolveFormFieldControl — every HubSpot type lands on a control', () => {
|
|
35
|
-
// Every (type, fieldType) pair a HubSpot portal's contact properties carry
|
|
36
|
-
// (measured on the Flamingo portal, 2026-09-24), plus the ones no property
|
|
37
|
-
// uses yet. The resolver is TOTAL: none of these may make a link unbookable.
|
|
38
|
-
it.each([
|
|
39
|
-
['text', 'string', undefined, 'text'],
|
|
40
|
-
['textarea', 'string', undefined, 'textarea'],
|
|
41
|
-
['number', 'number', undefined, 'number'],
|
|
42
|
-
['phonenumber', 'string', undefined, 'phone'],
|
|
43
|
-
['phonenumber', 'phone_number', undefined, 'phone'],
|
|
44
|
-
['booleancheckbox', 'bool', undefined, 'checkbox'],
|
|
45
|
-
['booleancheckbox', 'enumeration', ['true', 'false'], 'checkbox'],
|
|
46
|
-
['checkbox', 'enumeration', ['a', 'b'], 'multiselect'],
|
|
47
|
-
['checkbox', undefined, undefined, 'checkbox'],
|
|
48
|
-
['select', 'enumeration', ['a'], 'select'],
|
|
49
|
-
['select', 'enumeration', undefined, 'text'],
|
|
50
|
-
['radio', 'enumeration', ['a'], 'radio'],
|
|
51
|
-
['date', 'date', undefined, 'date'],
|
|
52
|
-
['date', 'datetime', undefined, 'date'],
|
|
53
|
-
['html', 'string', undefined, 'display'],
|
|
54
|
-
['calculation_equation', 'number', undefined, 'display'],
|
|
55
|
-
['calculation_rollup', 'datetime', undefined, 'display'],
|
|
56
|
-
['file', 'string', undefined, 'unanswerable'],
|
|
57
|
-
// Types HubSpot has not shipped (yet): the data type decides, else text.
|
|
58
|
-
['hologram', 'phone_number', undefined, 'phone'],
|
|
59
|
-
['hologram', 'bool', undefined, 'checkbox'],
|
|
60
|
-
['hologram', 'enumeration', ['a'], 'select'],
|
|
61
|
-
['hologram', undefined, ['a'], 'select'],
|
|
62
|
-
['hologram', undefined, undefined, 'text'],
|
|
63
|
-
['PhoneNumber', undefined, undefined, 'phone'],
|
|
64
|
-
] as const)('%s / %s (options: %s) → %s', (type, dataType, options, expected) => {
|
|
65
|
-
expect(resolveFormFieldControl(q(type, { dataType, options: options ? [...options] : undefined }))).toBe(expected);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('is idempotent: a normalized field resolves to the same control again', () => {
|
|
69
|
-
const fields = [
|
|
70
|
-
q('phonenumber'),
|
|
71
|
-
q('checkbox', { options: ['a', 'b'] }),
|
|
72
|
-
q('booleancheckbox'),
|
|
73
|
-
q('booleancheckbox', { dataType: 'enumeration', options: ['true', 'false'] }),
|
|
74
|
-
q('date'),
|
|
75
|
-
q('select', { options: ['a'] }),
|
|
76
|
-
q('hologram'),
|
|
77
|
-
];
|
|
78
|
-
for (const f of fields) {
|
|
79
|
-
const once = normalizeFormField(f);
|
|
80
|
-
expect(once && normalizeFormField(once)).toEqual(once);
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('tells a host which types were guessed rather than mapped', () => {
|
|
85
|
-
for (const t of [
|
|
86
|
-
'phonenumber',
|
|
87
|
-
'booleancheckbox',
|
|
88
|
-
'checkbox',
|
|
89
|
-
'calculation_equation',
|
|
90
|
-
'file',
|
|
91
|
-
'html',
|
|
92
|
-
'multiselect',
|
|
93
|
-
]) {
|
|
94
|
-
expect(isRecognisedFormFieldType(t)).toBe(true);
|
|
95
|
-
}
|
|
96
|
-
expect(isRecognisedFormFieldType('hologram')).toBe(false);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('only a REQUIRED unanswerable question blocks native booking', () => {
|
|
100
|
-
expect(blocksNativeBooking(q('file', { required: true }))).toBe(true);
|
|
101
|
-
expect(blocksNativeBooking(q('file'))).toBe(false);
|
|
102
|
-
expect(blocksNativeBooking(q('phonenumber', { required: true }))).toBe(false);
|
|
103
|
-
expect(blocksNativeBooking(q('hologram', { required: true }))).toBe(false);
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
describe('validators for the new controls', () => {
|
|
108
|
-
const parse = (field: MeetingFormField, value: unknown) =>
|
|
109
|
-
makeBookingSchema([field], null).safeParse({ ...BOOKING_BASE, formFields: { [field.name]: value } }).success;
|
|
110
|
-
|
|
111
|
-
it('phone accepts what people type and rejects what is not a number', () => {
|
|
112
|
-
const phone = q('phonenumber', { required: true });
|
|
113
|
-
for (const ok of ['+1 (415) 555-2671', '020 7946 0958', '+972-54-123-4567', '415.555.2671 ext. 12']) {
|
|
114
|
-
expect(parse(phone, ok)).toBe(true);
|
|
115
|
-
}
|
|
116
|
-
for (const bad of ['', 'call me', '123', '+1 415 555 2671 999 999 999 999']) {
|
|
117
|
-
expect(parse(phone, bad)).toBe(false);
|
|
118
|
-
}
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('date accepts a real ISO day only', () => {
|
|
122
|
-
const date = q('date', { required: true });
|
|
123
|
-
expect(parse(date, '2026-02-28')).toBe(true);
|
|
124
|
-
expect(parse(date, '2026-02-30')).toBe(false);
|
|
125
|
-
expect(parse(date, '28/02/2026')).toBe(false);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('multiselect is a ;-joined subset of the declared options', () => {
|
|
129
|
-
const multi = q('checkbox', { required: true, options: ['rmm', 'psa', 'backup'] });
|
|
130
|
-
expect(parse(multi, 'rmm;backup')).toBe(true);
|
|
131
|
-
expect(parse(multi, 'rmm;crm')).toBe(false);
|
|
132
|
-
expect(parse(multi, '')).toBe(false);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('an unknown type validates as text rather than being dropped', () => {
|
|
136
|
-
const unknown = q('hologram', { required: true });
|
|
137
|
-
expect(parse(unknown, 'anything')).toBe(true);
|
|
138
|
-
expect(parse(unknown, '')).toBe(false);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('display blocks carry no answer and never make the form required', () => {
|
|
142
|
-
const html = q('html', { required: true });
|
|
143
|
-
expect(makeBookingSchema([html], null).safeParse({ ...BOOKING_BASE }).success).toBe(true);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
describe('BookingForm — a link with a phone question (the reported outage)', () => {
|
|
148
|
-
it('renders every question and submits HubSpot-shaped values', async () => {
|
|
149
|
-
const onSubmit = mountBookingForm(
|
|
150
|
-
availabilityWith([
|
|
151
|
-
q('phonenumber', { name: 'phone', label: 'Phone number', dataType: 'string', required: true }),
|
|
152
|
-
q('checkbox', {
|
|
153
|
-
name: 'tools',
|
|
154
|
-
label: 'Tools',
|
|
155
|
-
dataType: 'enumeration',
|
|
156
|
-
options: ['rmm', 'psa'],
|
|
157
|
-
optionLabels: { rmm: 'RMM', psa: 'PSA' },
|
|
158
|
-
}),
|
|
159
|
-
q('hologram', { name: 'mystery', label: 'Mystery' }),
|
|
160
|
-
q('html', { name: 'blurb', label: 'Some rich text' }),
|
|
161
|
-
]),
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
typeInto(screen.getByLabelText(/^Phone number/), ' +1 415 555 2671 ');
|
|
165
|
-
fireEvent.click(screen.getByLabelText('PSA'));
|
|
166
|
-
fireEvent.click(screen.getByLabelText('RMM'));
|
|
167
|
-
typeInto(screen.getByLabelText(/^Mystery/), 'still answerable');
|
|
168
|
-
expect(screen.queryByText('Some rich text')).not.toBeInTheDocument();
|
|
169
|
-
fillIdentity();
|
|
170
|
-
fireEvent.click(screen.getByRole('button', { name: 'Confirm Booking' }));
|
|
171
|
-
|
|
172
|
-
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
|
173
|
-
// Options are joined in DECLARED order, whatever order they were ticked in.
|
|
174
|
-
expect(onSubmit.mock.calls[0][0].formFields).toEqual({
|
|
175
|
-
phone: '+1 415 555 2671',
|
|
176
|
-
tools: 'rmm;psa',
|
|
177
|
-
mystery: 'still answerable',
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('blocks submit on a malformed phone number', async () => {
|
|
182
|
-
const onSubmit = mountBookingForm(
|
|
183
|
-
availabilityWith([q('phonenumber', { name: 'phone', label: 'Phone', required: true })]),
|
|
184
|
-
);
|
|
185
|
-
typeInto(screen.getByLabelText(/^Phone/), 'call me maybe');
|
|
186
|
-
fillIdentity();
|
|
187
|
-
fireEvent.click(screen.getByRole('button', { name: 'Confirm Booking' }));
|
|
188
|
-
expect(await screen.findByText('Please enter a valid phone number for Phone')).toBeInTheDocument();
|
|
189
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
describe('registry exports', () => {
|
|
194
|
-
it('every control declares a spec with a validator, and options only where it picks from them', () => {
|
|
195
|
-
for (const type of SUPPORTED_FORM_FIELD_TYPES) {
|
|
196
|
-
const spec: FormFieldTypeSpec = FORM_FIELD_TYPES[type];
|
|
197
|
-
expect(typeof spec.validator).toBe('function');
|
|
198
|
-
}
|
|
199
|
-
expect([...FORM_FIELD_TYPES_WITH_OPTIONS].sort()).toEqual(['multiselect', 'radio', 'select']);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('the wire-shape patterns accept exactly their shapes', () => {
|
|
203
|
-
expect(DECIMAL_LITERAL_RE.test('-12.5')).toBe(true);
|
|
204
|
-
expect(DECIMAL_LITERAL_RE.test('1e3')).toBe(false);
|
|
205
|
-
expect(PHONE_RE.test('+1 (415) 555-2671 x 12')).toBe(true);
|
|
206
|
-
expect(PHONE_RE.test('five five five')).toBe(false);
|
|
207
|
-
expect(ISO_DATE_RE.test('2026-09-24')).toBe(true);
|
|
208
|
-
expect(ISO_DATE_RE.test('24/09/2026')).toBe(false);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('splitMultiValue reads the ;-joined wire value', () => {
|
|
212
|
-
expect(MULTI_VALUE_SEPARATOR).toBe(';');
|
|
213
|
-
expect(splitMultiValue('a;b')).toEqual(['a', 'b']);
|
|
214
|
-
expect(splitMultiValue('')).toEqual([]);
|
|
215
|
-
expect(splitMultiValue(undefined)).toEqual([]);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it('normalizeFormFields keeps declared order, drops what is not drawn, and records the HubSpot type', () => {
|
|
219
|
-
const drawn: SupportedMeetingFormField[] = normalizeFormFields([
|
|
220
|
-
q('phonenumber'),
|
|
221
|
-
q('html'),
|
|
222
|
-
q('file'),
|
|
223
|
-
q('booleancheckbox', { options: ['true', 'false'] }),
|
|
224
|
-
]);
|
|
225
|
-
expect(drawn.map(f => [f.type, f.hubspotType])).toEqual([
|
|
226
|
-
['phone', 'phonenumber'],
|
|
227
|
-
['checkbox', 'booleancheckbox'],
|
|
228
|
-
]);
|
|
229
|
-
expect(drawn[1].options).toBeUndefined();
|
|
230
|
-
const hidden: FormFieldResolution[] = ['display', 'unanswerable'];
|
|
231
|
-
expect(hidden).toContain(resolveFormFieldControl(q('html')));
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it('formFieldTypeHasOptions (deprecated) still answers for hosts built on the old registry', () => {
|
|
235
|
-
for (const t of ['select', 'radio', 'checkbox', 'hologram']) expect(formFieldTypeHasOptions(t)).toBe(true);
|
|
236
|
-
for (const t of ['text', 'number', 'phonenumber', 'booleancheckbox', 'html', 'file']) {
|
|
237
|
-
expect(formFieldTypeHasOptions(t)).toBe(false);
|
|
238
|
-
}
|
|
239
|
-
});
|
|
240
|
-
});
|