@windstream/react-shared-components 0.2.55 → 0.2.57
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/contentful/index.d.ts +200 -2
- package/dist/contentful/index.esm.js +5 -5
- package/dist/contentful/index.esm.js.map +1 -1
- package/dist/contentful/index.js +4 -4
- package/dist/contentful/index.js.map +1 -1
- package/dist/core.d.ts +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.esm.js +2 -2
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/collapse/index.test.tsx +20 -0
- package/src/components/collapse/index.tsx +4 -0
- package/src/contentful/blocks/dynamic-form/DynamicForm.stories.tsx +154 -0
- package/src/contentful/blocks/dynamic-form/conditional-fields.test.tsx +108 -0
- package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +103 -0
- package/src/contentful/blocks/dynamic-form/constants.ts +28 -0
- package/src/contentful/blocks/dynamic-form/context.ts +35 -0
- package/src/contentful/blocks/dynamic-form/field-map.test.tsx +421 -0
- package/src/contentful/blocks/dynamic-form/field-map.tsx +486 -0
- package/src/contentful/blocks/dynamic-form/form-field.test.tsx +319 -0
- package/src/contentful/blocks/dynamic-form/form-field.tsx +224 -0
- package/src/contentful/blocks/dynamic-form/index.test.tsx +355 -0
- package/src/contentful/blocks/dynamic-form/index.tsx +552 -0
- package/src/contentful/blocks/dynamic-form/render.test.tsx +116 -0
- package/src/contentful/blocks/dynamic-form/render.tsx +172 -0
- package/src/contentful/blocks/dynamic-form/types.ts +211 -0
- package/src/contentful/blocks/dynamic-form/utils.test.ts +238 -0
- package/src/contentful/blocks/dynamic-form/utils.ts +255 -0
- package/src/contentful/blocks/fiber-form/index.tsx +1 -0
- package/src/contentful/index.ts +20 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { constants, DEFAULT_VALIDATION_ERROR_TEXT, Regex } from "./constants";
|
|
11
|
+
import { FormContext, FormContextType } from "./context";
|
|
12
|
+
import { dynamicRenderingWithGroupings } from "./render";
|
|
13
|
+
import {
|
|
14
|
+
DynamicFormItem,
|
|
15
|
+
DynamicFormPayload,
|
|
16
|
+
DynamicFormProps,
|
|
17
|
+
FormAction,
|
|
18
|
+
SubmitContext,
|
|
19
|
+
SubmitHandler,
|
|
20
|
+
} from "./types";
|
|
21
|
+
import {
|
|
22
|
+
arrayForFieldValidations,
|
|
23
|
+
buildSftpMap,
|
|
24
|
+
filterActionsForEmailTemplate,
|
|
25
|
+
formPayloadCreation,
|
|
26
|
+
generateSubmissionId,
|
|
27
|
+
getInvalidFields,
|
|
28
|
+
shouldShowPopup,
|
|
29
|
+
} from "./utils";
|
|
30
|
+
|
|
31
|
+
import { Button } from "@shared/components/button";
|
|
32
|
+
import { Spinner } from "@shared/components/spinner";
|
|
33
|
+
import { Text } from "@shared/components/text";
|
|
34
|
+
import { cx } from "@shared/utils";
|
|
35
|
+
|
|
36
|
+
class FormValidationError extends Error {
|
|
37
|
+
invalidFields: string[];
|
|
38
|
+
constructor(message: string, invalidFields: string[]) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "FormValidationError";
|
|
41
|
+
this.invalidFields = invalidFields;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class FormSubmissionError extends Error {
|
|
46
|
+
errorActions: FormAction[];
|
|
47
|
+
isCompleteFailure: boolean;
|
|
48
|
+
constructor(
|
|
49
|
+
message: string,
|
|
50
|
+
errorActions: FormAction[],
|
|
51
|
+
isCompleteFailure: boolean
|
|
52
|
+
) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "FormSubmissionError";
|
|
55
|
+
this.errorActions = errorActions;
|
|
56
|
+
this.isCompleteFailure = isCompleteFailure;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
61
|
+
|
|
62
|
+
const formatSubmittedOn = (date: Date): string => {
|
|
63
|
+
const month = pad(date.getMonth() + 1);
|
|
64
|
+
const day = pad(date.getDate());
|
|
65
|
+
const year = date.getFullYear();
|
|
66
|
+
let hours = date.getHours();
|
|
67
|
+
const minutes = pad(date.getMinutes());
|
|
68
|
+
const ampm = hours >= 12 ? "PM" : "AM";
|
|
69
|
+
hours = hours % 12 || 12;
|
|
70
|
+
return `${month}/${day}/${year} ${hours}:${minutes} ${ampm}`;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const validateTNandIP = (value: any) => {
|
|
74
|
+
const trimmed = value?.trim();
|
|
75
|
+
if (!trimmed) return value;
|
|
76
|
+
if (Regex.phone.test(trimmed)) {
|
|
77
|
+
let digits = trimmed.replace(/\D/g, "");
|
|
78
|
+
if (digits.length === 11 && digits.startsWith("1")) {
|
|
79
|
+
digits = digits.slice(1);
|
|
80
|
+
}
|
|
81
|
+
return digits;
|
|
82
|
+
}
|
|
83
|
+
if (Regex.ipv4.test(trimmed) || Regex.ipv6.test(trimmed)) {
|
|
84
|
+
return trimmed;
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const DynamicForm: React.FC<DynamicFormProps> = props => {
|
|
90
|
+
const {
|
|
91
|
+
anchorId,
|
|
92
|
+
title,
|
|
93
|
+
subTitle,
|
|
94
|
+
formHeading,
|
|
95
|
+
description,
|
|
96
|
+
disclaimer,
|
|
97
|
+
successMessage,
|
|
98
|
+
errorMessage: errorNode,
|
|
99
|
+
formClassName,
|
|
100
|
+
fields,
|
|
101
|
+
formSubmitOptions = [],
|
|
102
|
+
submitLabel = "Submit",
|
|
103
|
+
ctaClassName,
|
|
104
|
+
validationErrorText = DEFAULT_VALIDATION_ERROR_TEXT,
|
|
105
|
+
formName,
|
|
106
|
+
emailSubject,
|
|
107
|
+
emailTemplateName,
|
|
108
|
+
toEmail,
|
|
109
|
+
segmentEventId,
|
|
110
|
+
onEmailSubmit,
|
|
111
|
+
onSmtpSubmit,
|
|
112
|
+
onSmsSubmit,
|
|
113
|
+
actionHandlers,
|
|
114
|
+
buildPayload,
|
|
115
|
+
onSubmitSuccess,
|
|
116
|
+
onTrack,
|
|
117
|
+
onError,
|
|
118
|
+
fieldComponentOverrides,
|
|
119
|
+
resolveExistingDataSource,
|
|
120
|
+
} = props;
|
|
121
|
+
|
|
122
|
+
const submissionIdRef = useRef<string>(generateSubmissionId());
|
|
123
|
+
const successRef = useRef<HTMLDivElement>(null);
|
|
124
|
+
|
|
125
|
+
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
126
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
127
|
+
const [submitted, setSubmitted] = useState(false);
|
|
128
|
+
const [hasError, setHasError] = useState(false);
|
|
129
|
+
const [submitError, setSubmitError] = useState(false);
|
|
130
|
+
const [submitErrorFields, setSubmitErrorFields] = useState<string[]>([]);
|
|
131
|
+
const [validationMessage, setValidationMessage] = useState("");
|
|
132
|
+
const [selectedState, setSelectedState] = useState<any>(null);
|
|
133
|
+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
134
|
+
const [files, setFiles] = useState<File[]>([]);
|
|
135
|
+
|
|
136
|
+
const updateFiles = useCallback((nextFiles: File[]) => {
|
|
137
|
+
setFiles(nextFiles);
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
const addFieldError = useCallback((fieldId: string, message: string) => {
|
|
141
|
+
setFieldErrors(prev => ({ ...prev, [fieldId]: message }));
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
const clearFieldError = useCallback((fieldId: string) => {
|
|
145
|
+
setFieldErrors(prev => {
|
|
146
|
+
const next = { ...prev };
|
|
147
|
+
delete next[fieldId];
|
|
148
|
+
return next;
|
|
149
|
+
});
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
const groupedValidationGroups = useMemo(() => {
|
|
153
|
+
return fields.reduce((acc: Record<string, any[]>, item) => {
|
|
154
|
+
if (item.itemType !== "formField") return acc;
|
|
155
|
+
const group = item.validationGroupId;
|
|
156
|
+
if (group != undefined && group != null) {
|
|
157
|
+
if (!acc[group]) acc[group] = [];
|
|
158
|
+
acc[group].push(item);
|
|
159
|
+
}
|
|
160
|
+
return acc;
|
|
161
|
+
}, {});
|
|
162
|
+
}, [fields]);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (submitted && typeof window !== "undefined" && successRef.current) {
|
|
166
|
+
const navbarHeight = 140;
|
|
167
|
+
const offsetPosition = successRef.current.offsetTop - navbarHeight;
|
|
168
|
+
window.scrollTo({ top: offsetPosition, behavior: "smooth" });
|
|
169
|
+
}
|
|
170
|
+
}, [submitted]);
|
|
171
|
+
|
|
172
|
+
const contextValue: FormContextType = useMemo(
|
|
173
|
+
() => ({
|
|
174
|
+
fieldErrors,
|
|
175
|
+
setFieldError: addFieldError,
|
|
176
|
+
clearFieldError,
|
|
177
|
+
hasError,
|
|
178
|
+
setHasError,
|
|
179
|
+
selectedState,
|
|
180
|
+
setSelectedState,
|
|
181
|
+
files,
|
|
182
|
+
setFiles: updateFiles,
|
|
183
|
+
resolveExistingDataSource,
|
|
184
|
+
fieldComponentOverrides,
|
|
185
|
+
onError,
|
|
186
|
+
}),
|
|
187
|
+
[
|
|
188
|
+
fieldErrors,
|
|
189
|
+
addFieldError,
|
|
190
|
+
clearFieldError,
|
|
191
|
+
hasError,
|
|
192
|
+
selectedState,
|
|
193
|
+
files,
|
|
194
|
+
updateFiles,
|
|
195
|
+
resolveExistingDataSource,
|
|
196
|
+
fieldComponentOverrides,
|
|
197
|
+
onError,
|
|
198
|
+
]
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const applyPresetValidations = (): Record<string, any> => {
|
|
202
|
+
const updatedData = { ...formData };
|
|
203
|
+
fields.forEach(item => {
|
|
204
|
+
if (item.itemType !== "formField") return;
|
|
205
|
+
if (
|
|
206
|
+
item.applyPresetValidations &&
|
|
207
|
+
item.selectPresetValidationsToApply === "TN-IP-Validation"
|
|
208
|
+
) {
|
|
209
|
+
updatedData[item.fieldId] = validateTNandIP(updatedData[item.fieldId]);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
setFormData(updatedData);
|
|
213
|
+
return updatedData;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const handlerMap: Partial<Record<FormAction, SubmitHandler>> = useMemo(
|
|
217
|
+
() => ({
|
|
218
|
+
email: onEmailSubmit,
|
|
219
|
+
sftp: onSmtpSubmit,
|
|
220
|
+
sms: onSmsSubmit,
|
|
221
|
+
...(actionHandlers || {}),
|
|
222
|
+
}),
|
|
223
|
+
[onEmailSubmit, onSmtpSubmit, onSmsSubmit, actionHandlers]
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const runActions = async (
|
|
227
|
+
actions: FormAction[],
|
|
228
|
+
payload: DynamicFormPayload,
|
|
229
|
+
baseContext: Omit<SubmitContext, "action" | "options">
|
|
230
|
+
): Promise<{ error: boolean; errorActions: FormAction[] }> => {
|
|
231
|
+
const errorActions: FormAction[] = [];
|
|
232
|
+
const formDataString = JSON.stringify({ ...payload.formData });
|
|
233
|
+
|
|
234
|
+
let body: DynamicFormPayload | FormData;
|
|
235
|
+
if (payload.files && payload.files.length > 0) {
|
|
236
|
+
const fd = new FormData();
|
|
237
|
+
Object.keys(payload).forEach(key => {
|
|
238
|
+
if (key === "files" || key === "formData") return;
|
|
239
|
+
fd.append(key, (payload as any)[key]);
|
|
240
|
+
});
|
|
241
|
+
fd.append("formData", formDataString);
|
|
242
|
+
payload.files.forEach(file => fd.append("files", file));
|
|
243
|
+
body = fd;
|
|
244
|
+
} else {
|
|
245
|
+
body = { ...payload, formData: formDataString } as any;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const effectiveActions = filterActionsForEmailTemplate(
|
|
249
|
+
actions,
|
|
250
|
+
payload.emailTemplate
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
await Promise.all(
|
|
254
|
+
effectiveActions.map(async action => {
|
|
255
|
+
const handler = handlerMap[action];
|
|
256
|
+
const options =
|
|
257
|
+
body instanceof FormData
|
|
258
|
+
? { headers: { "Content-Type": undefined } }
|
|
259
|
+
: {};
|
|
260
|
+
if (handler) {
|
|
261
|
+
try {
|
|
262
|
+
await handler(body, { ...baseContext, action, options });
|
|
263
|
+
onTrack?.(`${segmentEventId}_${action}_success`, {
|
|
264
|
+
...payload,
|
|
265
|
+
eventSuccess: true,
|
|
266
|
+
});
|
|
267
|
+
} catch (error) {
|
|
268
|
+
errorActions.push(action);
|
|
269
|
+
onTrack?.(`${segmentEventId}_${action}_failure`, {
|
|
270
|
+
...payload,
|
|
271
|
+
eventSuccess: false,
|
|
272
|
+
});
|
|
273
|
+
onError?.({
|
|
274
|
+
type: "action_failure",
|
|
275
|
+
error,
|
|
276
|
+
metadata: { action, submissionId: submissionIdRef.current },
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
errorActions.push(action);
|
|
281
|
+
onTrack?.(`${segmentEventId}_${action}_failure`, {
|
|
282
|
+
...payload,
|
|
283
|
+
eventSuccess: false,
|
|
284
|
+
});
|
|
285
|
+
onError?.({
|
|
286
|
+
type: "unsupported_action",
|
|
287
|
+
error: new Error(`Action "${action}" is not supported`),
|
|
288
|
+
metadata: { action },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return { error: errorActions.length > 0, errorActions };
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const handleSubmit = async (event: any): Promise<void> => {
|
|
298
|
+
event.preventDefault();
|
|
299
|
+
setIsLoading(true);
|
|
300
|
+
setValidationMessage("");
|
|
301
|
+
|
|
302
|
+
const newFormData = applyPresetValidations();
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const extractedArray = arrayForFieldValidations(fields, false, newFormData);
|
|
306
|
+
const sftpMap = buildSftpMap(fields, newFormData);
|
|
307
|
+
const invalidFields = getInvalidFields(extractedArray, newFormData);
|
|
308
|
+
setSubmitErrorFields(invalidFields);
|
|
309
|
+
|
|
310
|
+
Object.keys(groupedValidationGroups).forEach(groupId => {
|
|
311
|
+
const groupFields = groupedValidationGroups[groupId];
|
|
312
|
+
const fieldIds = groupFields.map(item => item.fieldId);
|
|
313
|
+
const invalidCase = groupFields.filter(field => {
|
|
314
|
+
if (field.validationType === constants.ONE_REQUIRED_FIELD) {
|
|
315
|
+
return (
|
|
316
|
+
newFormData[field.fieldId] === "" ||
|
|
317
|
+
newFormData[field.fieldId] === undefined
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
});
|
|
322
|
+
if (
|
|
323
|
+
invalidCase.length === groupFields.length &&
|
|
324
|
+
groupFields.length > 0
|
|
325
|
+
) {
|
|
326
|
+
setSubmitErrorFields(prev => [...prev, ...fieldIds]);
|
|
327
|
+
throw new FormValidationError(
|
|
328
|
+
constants.REQUIRED_FIELDS_MISSING,
|
|
329
|
+
fieldIds
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (invalidFields.length !== 0 || Object.keys(fieldErrors).length !== 0) {
|
|
335
|
+
throw new FormValidationError(
|
|
336
|
+
constants.REQUIRED_FIELDS_MISSING,
|
|
337
|
+
invalidFields
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const formPayload = formPayloadCreation(fields, newFormData);
|
|
342
|
+
const basePayload: DynamicFormPayload = {
|
|
343
|
+
submissionId: submissionIdRef.current,
|
|
344
|
+
formName,
|
|
345
|
+
toEmail,
|
|
346
|
+
subject: emailSubject,
|
|
347
|
+
emailTemplate: emailTemplateName,
|
|
348
|
+
submittedOn: formatSubmittedOn(new Date()),
|
|
349
|
+
formSubmitOptions,
|
|
350
|
+
formData: formPayload,
|
|
351
|
+
files,
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const payload = buildPayload
|
|
355
|
+
? buildPayload(basePayload, newFormData)
|
|
356
|
+
: basePayload;
|
|
357
|
+
|
|
358
|
+
onTrack?.(segmentEventId, payload);
|
|
359
|
+
|
|
360
|
+
const actions = payload.formSubmitOptions || [];
|
|
361
|
+
const hasPost = actions.includes("post");
|
|
362
|
+
const serverActions = actions.filter(a => a !== "post");
|
|
363
|
+
|
|
364
|
+
const res = await runActions(serverActions, payload, {
|
|
365
|
+
payload,
|
|
366
|
+
formValues: newFormData,
|
|
367
|
+
sftpMap,
|
|
368
|
+
formSubmitOptions: actions,
|
|
369
|
+
files,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
if (res.error && serverActions.length === res.errorActions.length) {
|
|
373
|
+
throw new FormSubmissionError(
|
|
374
|
+
"All form actions failed",
|
|
375
|
+
res.errorActions,
|
|
376
|
+
true
|
|
377
|
+
);
|
|
378
|
+
} else if (res.error) {
|
|
379
|
+
throw new FormSubmissionError(
|
|
380
|
+
"Some form actions failed",
|
|
381
|
+
res.errorActions,
|
|
382
|
+
false
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
setIsLoading(false);
|
|
387
|
+
setSubmitted(true);
|
|
388
|
+
|
|
389
|
+
if (hasPost) {
|
|
390
|
+
const postHandler = handlerMap.post;
|
|
391
|
+
if (postHandler) {
|
|
392
|
+
await postHandler({ ...payload } as any, {
|
|
393
|
+
payload,
|
|
394
|
+
formValues: newFormData,
|
|
395
|
+
sftpMap,
|
|
396
|
+
formSubmitOptions: actions,
|
|
397
|
+
files,
|
|
398
|
+
action: "post",
|
|
399
|
+
options: {},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (shouldShowPopup(fields, newFormData)) {
|
|
405
|
+
onSubmitSuccess?.(newFormData, { shouldShowPopup: true });
|
|
406
|
+
} else {
|
|
407
|
+
onSubmitSuccess?.(newFormData, { shouldShowPopup: false });
|
|
408
|
+
}
|
|
409
|
+
} catch (error) {
|
|
410
|
+
let errorType = "unknown";
|
|
411
|
+
if (error instanceof FormValidationError) {
|
|
412
|
+
errorType = "validation";
|
|
413
|
+
setValidationMessage(validationErrorText);
|
|
414
|
+
} else if (error instanceof FormSubmissionError) {
|
|
415
|
+
errorType = error.isCompleteFailure
|
|
416
|
+
? "complete_submission_failure"
|
|
417
|
+
: "partial_submission_failure";
|
|
418
|
+
if (error.isCompleteFailure) {
|
|
419
|
+
setSubmitted(false);
|
|
420
|
+
setSubmitError(true);
|
|
421
|
+
} else {
|
|
422
|
+
setSubmitted(true);
|
|
423
|
+
setSubmitError(false);
|
|
424
|
+
}
|
|
425
|
+
} else {
|
|
426
|
+
errorType = "unexpected";
|
|
427
|
+
setValidationMessage(
|
|
428
|
+
"An error occurred while submitting the form. Please try again later."
|
|
429
|
+
);
|
|
430
|
+
setSubmitError(true);
|
|
431
|
+
setSubmitted(false);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
onError?.({
|
|
435
|
+
type: errorType,
|
|
436
|
+
error,
|
|
437
|
+
metadata: {
|
|
438
|
+
formName,
|
|
439
|
+
submissionId: submissionIdRef.current,
|
|
440
|
+
formSubmitOptions,
|
|
441
|
+
segmentEventId,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
setIsLoading(false);
|
|
446
|
+
} finally {
|
|
447
|
+
setIsLoading(false);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
if (submitted) {
|
|
452
|
+
return (
|
|
453
|
+
<div
|
|
454
|
+
ref={successRef}
|
|
455
|
+
className="mx-auto min-h-screen w-full px-4 sm:px-6 lg:px-8"
|
|
456
|
+
>
|
|
457
|
+
<div className="max-w-2xl mx-auto py-4 sm:py-6 lg:py-8">
|
|
458
|
+
{successMessage}
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (submitError) {
|
|
465
|
+
return (
|
|
466
|
+
<div className="mx-auto min-h-screen w-full px-4 sm:px-6 lg:px-8">
|
|
467
|
+
<div className="max-w-2xl mx-auto py-4 sm:py-6 lg:py-8">
|
|
468
|
+
{errorNode}
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return (
|
|
475
|
+
<section id={anchorId} aria-labelledby={`${anchorId}-title`}>
|
|
476
|
+
<div className="mx-auto min-h-screen max-w-[800px] px-5 py-8 md:px-5 md:py-20">
|
|
477
|
+
<div className="pb-8 sm:pb-6 lg:pb-4">
|
|
478
|
+
<div className="mb-8 lg:mb-16">
|
|
479
|
+
{title && (
|
|
480
|
+
<Text as="h2" className="heading2 text-center">
|
|
481
|
+
{title}
|
|
482
|
+
</Text>
|
|
483
|
+
)}
|
|
484
|
+
{subTitle && (
|
|
485
|
+
<Text as="h3" className="subheading3 mt-3 text-center">
|
|
486
|
+
{subTitle}
|
|
487
|
+
</Text>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
{description ? (
|
|
491
|
+
<div className="text-center">{description}</div>
|
|
492
|
+
) : null}
|
|
493
|
+
{formHeading ? (
|
|
494
|
+
<div className="mb-6 w-full px-2 sm:px-4">
|
|
495
|
+
<Text
|
|
496
|
+
as="div"
|
|
497
|
+
className="body3 mb-2 text-center text-xl sm:mb-4 md:text-left"
|
|
498
|
+
>
|
|
499
|
+
{formHeading}
|
|
500
|
+
</Text>
|
|
501
|
+
</div>
|
|
502
|
+
) : null}
|
|
503
|
+
<div className="mt-8 md:mt-16">
|
|
504
|
+
<FormContext.Provider value={contextValue}>
|
|
505
|
+
<form
|
|
506
|
+
onSubmit={handleSubmit}
|
|
507
|
+
className={cx(
|
|
508
|
+
"flex w-full flex-col gap-5 md:gap-8",
|
|
509
|
+
formClassName
|
|
510
|
+
)}
|
|
511
|
+
>
|
|
512
|
+
{dynamicRenderingWithGroupings(
|
|
513
|
+
isLoading,
|
|
514
|
+
fields as DynamicFormItem[],
|
|
515
|
+
formData,
|
|
516
|
+
setFormData,
|
|
517
|
+
submitErrorFields
|
|
518
|
+
)}
|
|
519
|
+
{validationMessage && (
|
|
520
|
+
<Text as="div" className="footnote text-text-critical">
|
|
521
|
+
{validationMessage}
|
|
522
|
+
</Text>
|
|
523
|
+
)}
|
|
524
|
+
<div className="flex justify-center">
|
|
525
|
+
<Button
|
|
526
|
+
type="submit"
|
|
527
|
+
disabled={hasError || isLoading}
|
|
528
|
+
aria-label="Submit Form"
|
|
529
|
+
className={cx(
|
|
530
|
+
"button-submit mt-3 inline-flex h-[64px] w-full items-center justify-center gap-2 whitespace-nowrap rounded-full bg-bg-fill-brand px-[60px] text-base font-semibold text-text-brand-on-bg-fill transition hover:bg-bg-fill-brand-hover disabled:bg-bg-fill-brand-disabled md:mt-8 md:w-auto",
|
|
531
|
+
ctaClassName
|
|
532
|
+
)}
|
|
533
|
+
>
|
|
534
|
+
{isLoading && <Spinner />}
|
|
535
|
+
{submitLabel}
|
|
536
|
+
</Button>
|
|
537
|
+
</div>
|
|
538
|
+
</form>
|
|
539
|
+
</FormContext.Provider>
|
|
540
|
+
</div>
|
|
541
|
+
{disclaimer && (
|
|
542
|
+
<Text as="div" className="footnote m-2">
|
|
543
|
+
{disclaimer}
|
|
544
|
+
</Text>
|
|
545
|
+
)}
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
</section>
|
|
549
|
+
);
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
export default DynamicForm;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { constants } from "./constants";
|
|
3
|
+
import { FormContext, FormContextType } from "./context";
|
|
4
|
+
import { dynamicRenderingWithGroupings } from "./render";
|
|
5
|
+
import { DynamicFormItem } from "./types";
|
|
6
|
+
|
|
7
|
+
import { render, screen } from "@testing-library/react";
|
|
8
|
+
|
|
9
|
+
const context: FormContextType = {
|
|
10
|
+
fieldErrors: {},
|
|
11
|
+
setFieldError: jest.fn(),
|
|
12
|
+
clearFieldError: jest.fn(),
|
|
13
|
+
hasError: false,
|
|
14
|
+
setHasError: jest.fn(),
|
|
15
|
+
selectedState: null,
|
|
16
|
+
setSelectedState: jest.fn(),
|
|
17
|
+
files: [],
|
|
18
|
+
setFiles: jest.fn(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const renderGroups = (fields: DynamicFormItem[]) =>
|
|
22
|
+
render(
|
|
23
|
+
<FormContext.Provider value={context}>
|
|
24
|
+
{dynamicRenderingWithGroupings(false, fields, {}, jest.fn(), [])}
|
|
25
|
+
</FormContext.Provider>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const field = (
|
|
29
|
+
overrides: Partial<Extract<DynamicFormItem, { itemType: "formField" }>>
|
|
30
|
+
): DynamicFormItem => ({
|
|
31
|
+
itemType: "formField",
|
|
32
|
+
fieldId: "f",
|
|
33
|
+
fieldType: "Text",
|
|
34
|
+
displayName: "Field",
|
|
35
|
+
...overrides,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("dynamicRenderingWithGroupings", () => {
|
|
39
|
+
it("renders a single full-width field", () => {
|
|
40
|
+
renderGroups([field({ fieldId: "a", displayName: "Full Field" })]);
|
|
41
|
+
expect(screen.getByText("Full Field")).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("renders two half-width fields as a double group", () => {
|
|
45
|
+
renderGroups([
|
|
46
|
+
field({ fieldId: "a", displayName: "Left", fieldWidth: false }),
|
|
47
|
+
field({ fieldId: "b", displayName: "Right", fieldWidth: false }),
|
|
48
|
+
]);
|
|
49
|
+
expect(screen.getByText("Left")).toBeInTheDocument();
|
|
50
|
+
expect(screen.getByText("Right")).toBeInTheDocument();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders a custom pass-through item", () => {
|
|
54
|
+
renderGroups([
|
|
55
|
+
{ itemType: "custom", content: <div>Custom Content</div> },
|
|
56
|
+
]);
|
|
57
|
+
expect(screen.getByText("Custom Content")).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("renders a conditional item", () => {
|
|
61
|
+
renderGroups([
|
|
62
|
+
{
|
|
63
|
+
itemType: "conditional",
|
|
64
|
+
fieldId: "cond",
|
|
65
|
+
displayName: "Conditional?",
|
|
66
|
+
dataSource: [{ label: "Yes", value: "yes" }],
|
|
67
|
+
formFieldsToShow: [],
|
|
68
|
+
},
|
|
69
|
+
]);
|
|
70
|
+
expect(screen.getByText("Conditional?")).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders a validation group with single and double subgroups and a required marker", () => {
|
|
74
|
+
const { container } = renderGroups([
|
|
75
|
+
field({
|
|
76
|
+
fieldId: "g1",
|
|
77
|
+
displayName: "Group A",
|
|
78
|
+
validationGroupId: "grp",
|
|
79
|
+
validationNote: "Provide at least one",
|
|
80
|
+
validationType: constants.ONE_REQUIRED_FIELD,
|
|
81
|
+
fieldWidth: false,
|
|
82
|
+
}),
|
|
83
|
+
field({
|
|
84
|
+
fieldId: "g2",
|
|
85
|
+
displayName: "Group B",
|
|
86
|
+
validationGroupId: "grp",
|
|
87
|
+
fieldWidth: false,
|
|
88
|
+
}),
|
|
89
|
+
field({
|
|
90
|
+
fieldId: "g3",
|
|
91
|
+
displayName: "Group C",
|
|
92
|
+
validationGroupId: "grp",
|
|
93
|
+
fieldWidth: true,
|
|
94
|
+
}),
|
|
95
|
+
]);
|
|
96
|
+
expect(screen.getByText("Provide at least one")).toBeInTheDocument();
|
|
97
|
+
expect(screen.getByText("Group A")).toBeInTheDocument();
|
|
98
|
+
expect(screen.getByText("Group C")).toBeInTheDocument();
|
|
99
|
+
expect(container.querySelector(".text-text-critical")).toHaveTextContent(
|
|
100
|
+
"*"
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renders a validation group without a required marker for other validation types", () => {
|
|
105
|
+
renderGroups([
|
|
106
|
+
field({
|
|
107
|
+
fieldId: "g1",
|
|
108
|
+
displayName: "Group Only",
|
|
109
|
+
validationGroupId: "grp2",
|
|
110
|
+
validationNote: "Optional group note",
|
|
111
|
+
validationType: "Some Other Type",
|
|
112
|
+
}),
|
|
113
|
+
]);
|
|
114
|
+
expect(screen.getByText("Optional group note")).toBeInTheDocument();
|
|
115
|
+
});
|
|
116
|
+
});
|