@windstream/react-shared-components 0.2.55 → 0.2.56

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.
@@ -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
+ });