@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,172 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import ConditionalFormFields from "./conditional-fields";
5
+ import { constants } from "./constants";
6
+ import FormFieldElement from "./form-field";
7
+ import { DynamicFormItem } from "./types";
8
+ import { groupFieldsForLayout } from "./utils";
9
+
10
+ import { Text } from "@shared/components/text";
11
+
12
+ type FormValues = Record<string, any>;
13
+ type SetFormData = (updater: (prev: FormValues) => FormValues) => void;
14
+
15
+ const renderItem = (
16
+ item: DynamicFormItem,
17
+ key: string,
18
+ isLoading: boolean,
19
+ formData: FormValues,
20
+ setFormData: SetFormData,
21
+ submitErrorFields: string[],
22
+ isGrouped = false
23
+ ): React.ReactNode => {
24
+ if (item.itemType === "formField") {
25
+ return (
26
+ <FormFieldElement
27
+ key={key}
28
+ loading={isLoading}
29
+ {...item}
30
+ formData={formData}
31
+ setFormData={setFormData}
32
+ submitErrorFields={submitErrorFields}
33
+ isGrouped={isGrouped}
34
+ />
35
+ );
36
+ }
37
+ if (item.itemType === "conditional") {
38
+ return (
39
+ <ConditionalFormFields
40
+ key={key}
41
+ {...item}
42
+ formData={formData}
43
+ setFormData={setFormData}
44
+ submitErrorFields={submitErrorFields}
45
+ />
46
+ );
47
+ }
48
+ return <React.Fragment key={key}>{item.content}</React.Fragment>;
49
+ };
50
+
51
+ export const dynamicRenderingWithGroupings = (
52
+ isLoading: boolean,
53
+ fields: DynamicFormItem[],
54
+ formData: FormValues,
55
+ setFormData: SetFormData,
56
+ submitErrorFields: string[]
57
+ ): React.ReactNode[] => {
58
+ return groupFieldsForLayout(fields).map((group, groupIndex) => {
59
+ if (group.type === "validationGroup") {
60
+ return (
61
+ <div
62
+ key={`validation-group-${groupIndex}`}
63
+ className="mb-0 w-full rounded-b-xl rounded-t-xl border border-border-secondary bg-bg-surface-secondary px-5 pb-5 pt-1"
64
+ >
65
+ {group.validationNote && (
66
+ <Text as="div" className="validation-label body3 mb-3 mt-4 sm:mt-5">
67
+ {group.validationNote}
68
+ {group.validationType === constants.ONE_REQUIRED_FIELD ? (
69
+ <span className="text-text-critical">*</span>
70
+ ) : null}
71
+ </Text>
72
+ )}
73
+ {group.subGroups.map((subGroup: any, subGroupIndex: number) => {
74
+ if (subGroup.type === "single") {
75
+ return (
76
+ <div key={`subgroup-${subGroupIndex}`} className="mb-0 w-full">
77
+ {renderItem(
78
+ subGroup.fields[0],
79
+ `subgroup-field-${subGroupIndex}`,
80
+ isLoading,
81
+ formData,
82
+ setFormData,
83
+ submitErrorFields,
84
+ true
85
+ )}
86
+ </div>
87
+ );
88
+ }
89
+ return (
90
+ <div
91
+ key={`subgroup-${subGroupIndex}`}
92
+ className="mb-0 flex w-full flex-col gap-5 lg:flex-row"
93
+ >
94
+ <div className="w-full lg:flex-1">
95
+ {renderItem(
96
+ subGroup.fields[0],
97
+ `subgroup-field-${subGroupIndex}-0`,
98
+ isLoading,
99
+ formData,
100
+ setFormData,
101
+ submitErrorFields,
102
+ true
103
+ )}
104
+ </div>
105
+ <div className="w-full lg:flex-1">
106
+ {renderItem(
107
+ subGroup.fields[1],
108
+ `subgroup-field-${subGroupIndex}-1`,
109
+ isLoading,
110
+ formData,
111
+ setFormData,
112
+ submitErrorFields,
113
+ true
114
+ )}
115
+ </div>
116
+ </div>
117
+ );
118
+ })}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ if (group.type === "single") {
124
+ const field = group.fields[0] as DynamicFormItem;
125
+ const className =
126
+ field.itemType === "formField"
127
+ ? `${field.fieldClassName?.split("|")[0] ?? ""} mb-0 w-full`
128
+ : "w-full";
129
+ return (
130
+ <div key={`group-${groupIndex}`} className={className}>
131
+ {renderItem(
132
+ field,
133
+ `group-field-${groupIndex}`,
134
+ isLoading,
135
+ formData,
136
+ setFormData,
137
+ submitErrorFields
138
+ )}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ // double
144
+ return (
145
+ <div
146
+ key={`group-${groupIndex}`}
147
+ className="mb-0 flex w-full flex-col gap-5 lg:flex-row"
148
+ >
149
+ <div className="w-full lg:flex-1">
150
+ {renderItem(
151
+ group.fields[0],
152
+ `group-field-${groupIndex}-0`,
153
+ isLoading,
154
+ formData,
155
+ setFormData,
156
+ submitErrorFields
157
+ )}
158
+ </div>
159
+ <div className="w-full lg:flex-1">
160
+ {renderItem(
161
+ group.fields[1],
162
+ `group-field-${groupIndex}-1`,
163
+ isLoading,
164
+ formData,
165
+ setFormData,
166
+ submitErrorFields
167
+ )}
168
+ </div>
169
+ </div>
170
+ );
171
+ });
172
+ };
@@ -0,0 +1,211 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * The set of submit actions the DynamicForm understands. Each maps to an
5
+ * injected async handler provided by the consuming application. The package
6
+ * owns the UI, validation, payload building and orchestration; the app owns
7
+ * the actual transport (HTTP calls, redirects, etc.).
8
+ */
9
+ export type FormAction = "email" | "sftp" | "sms" | "voice" | "post";
10
+
11
+ export type DynamicFormFieldType =
12
+ | "Text"
13
+ | "Checkbox"
14
+ | "Radio"
15
+ | "Dropdown"
16
+ | "Datepicker"
17
+ | "Imageupload";
18
+
19
+ export interface DynamicFormFieldOption {
20
+ label: string;
21
+ value: string;
22
+ /** When true, selecting this option should trigger the success popup. */
23
+ popup?: boolean;
24
+ [key: string]: any;
25
+ }
26
+
27
+ /**
28
+ * A single, Contentful-agnostic form field descriptor. The consuming app is
29
+ * responsible for transforming its CMS entries into this plain shape.
30
+ */
31
+ export interface DynamicFormFieldConfig {
32
+ fieldId: string;
33
+ fieldType: DynamicFormFieldType;
34
+ displayName?: string;
35
+ requiredField?: boolean;
36
+ textInputType?: "phone" | "email" | "multiline" | string;
37
+ errorMessage?: string;
38
+ dataSource?: DynamicFormFieldOption[] | null;
39
+ existingDataSource?: string;
40
+ note?: string;
41
+ noteClassName?: string;
42
+ fieldClassName?: string;
43
+ /** true => full width, false => half width, undefined => full width. */
44
+ fieldWidth?: boolean;
45
+ validationGroupId?: string;
46
+ validationType?: string;
47
+ validationNote?: string;
48
+ formSection?: string;
49
+ sftpFieldId?: string;
50
+ applyRegularExpression?: boolean;
51
+ regexPattern?: string;
52
+ applyPresetValidations?: boolean;
53
+ selectPresetValidationsToApply?: string;
54
+ }
55
+
56
+ export interface ConditionalFieldConfig {
57
+ fieldId: string;
58
+ displayName?: string;
59
+ requiredField?: boolean;
60
+ dataSource?: DynamicFormFieldOption[] | null;
61
+ note?: string;
62
+ formFieldsToShow: DynamicFormItem[];
63
+ }
64
+
65
+ /**
66
+ * A flat list item rendered by the form. Either a form field, a conditional
67
+ * field group, or an arbitrary pre-rendered node (e.g. a nested CMS block that
68
+ * the app rendered itself and passes through).
69
+ */
70
+ export type DynamicFormItem =
71
+ | ({ itemType: "formField" } & DynamicFormFieldConfig)
72
+ | ({ itemType: "conditional" } & ConditionalFieldConfig)
73
+ | { itemType: "custom"; fieldId?: string; content: React.ReactNode };
74
+
75
+ export interface DynamicFormPayload {
76
+ submissionId: string;
77
+ formName?: string;
78
+ subject?: string;
79
+ emailTemplate?: string;
80
+ submittedOn: string;
81
+ formSubmitOptions: FormAction[];
82
+ /** Grouped form data, keyed by form section. */
83
+ formData: Record<string, any>;
84
+ files: File[];
85
+ toEmail?: string;
86
+ toPhone?: string;
87
+ toSms?: string;
88
+ [key: string]: any;
89
+ }
90
+
91
+ export interface SubmitContext {
92
+ /** The structured payload (as built by the form, before transport encoding). */
93
+ payload: DynamicFormPayload;
94
+ /** The raw flat form values keyed by fieldId. */
95
+ formValues: Record<string, any>;
96
+ /** Map of fieldId -> sftpFieldId for SMTP/SFTP payload mapping. */
97
+ sftpMap: Record<string, string>;
98
+ formSubmitOptions: FormAction[];
99
+ files: File[];
100
+ /** The action currently being executed. */
101
+ action: FormAction;
102
+ /** Transport hints (e.g. for FormData uploads). */
103
+ options: Record<string, any>;
104
+ }
105
+
106
+ /**
107
+ * Injected transport for a single action. `body` is the ready-to-send payload
108
+ * (a plain object with a stringified `formData`, or a `FormData` instance when
109
+ * files are present). Handlers that need to build their own body (e.g. SMTP)
110
+ * can use `context.formValues` and `context.sftpMap` instead.
111
+ */
112
+ export type SubmitHandler = (
113
+ body: DynamicFormPayload | FormData,
114
+ context: SubmitContext
115
+ ) => Promise<any> | any;
116
+
117
+ export interface FieldRenderProps {
118
+ loading?: boolean;
119
+ displayName?: string;
120
+ requiredField?: boolean;
121
+ formData: Record<string, any>;
122
+ fieldId?: string;
123
+ textInputType?: string;
124
+ handleInputChange: (event: any) => void;
125
+ dataSource?: DynamicFormFieldOption[] | null;
126
+ error: { fieldId?: string; errorMessage: string };
127
+ errorClass: string;
128
+ hasError: boolean;
129
+ existingDataSource?: string;
130
+ isConsentCheckbox?: boolean;
131
+ [key: string]: any;
132
+ }
133
+
134
+ /**
135
+ * Resolves the options for a field that references an app-owned data source
136
+ * (e.g. "State List" / "City List"). Injected by the app so the package stays
137
+ * transport-agnostic.
138
+ */
139
+ export type ExistingDataSourceResolver = (
140
+ name: string,
141
+ ctx: { selectedState?: any }
142
+ ) => Promise<DynamicFormFieldOption[]>;
143
+
144
+ export interface DynamicFormProps {
145
+ anchorId?: string;
146
+ title?: string;
147
+ subTitle?: string;
148
+ formHeading?: string;
149
+ /** Pre-rendered rich text supplied by the app. */
150
+ description?: React.ReactNode;
151
+ disclaimer?: React.ReactNode;
152
+ successMessage?: React.ReactNode;
153
+ errorMessage?: React.ReactNode;
154
+ formClassName?: string;
155
+ fields: DynamicFormItem[];
156
+ formSubmitOptions?: FormAction[];
157
+
158
+ /** CTA */
159
+ submitLabel?: string;
160
+ ctaClassName?: string;
161
+
162
+ /** Copy overrides */
163
+ validationErrorText?: string;
164
+
165
+ /** Payload metadata */
166
+ formName?: string;
167
+ emailSubject?: string;
168
+ emailTemplateName?: string;
169
+ toEmail?: string;
170
+ segmentEventId?: string;
171
+
172
+ /** Injected per-action async submit handlers. */
173
+ onEmailSubmit?: SubmitHandler;
174
+ onSmtpSubmit?: SubmitHandler;
175
+ onSmsSubmit?: SubmitHandler;
176
+ /** Optional additional handlers for extra actions (voice/post/custom). */
177
+ actionHandlers?: Partial<Record<FormAction, SubmitHandler>>;
178
+
179
+ /**
180
+ * Allows the app to mutate the base payload before actions run
181
+ * (e.g. custom email routing, non-prod rerouting, adding toPhone/toSms).
182
+ */
183
+ buildPayload?: (
184
+ basePayload: DynamicFormPayload,
185
+ formValues: Record<string, any>
186
+ ) => DynamicFormPayload;
187
+
188
+ /** Called after a successful (or partial) submission. */
189
+ onSubmitSuccess?: (
190
+ formValues: Record<string, any>,
191
+ meta: { shouldShowPopup: boolean }
192
+ ) => void;
193
+
194
+ /** Injected analytics + logging. */
195
+ onTrack?: (eventId: string | undefined, payload: any) => void;
196
+ onError?: (info: {
197
+ type: string;
198
+ error: unknown;
199
+ metadata?: Record<string, any>;
200
+ }) => void;
201
+
202
+ /** Whether the current environment is production (affects nothing in the
203
+ * base component today but reserved for parity with app behavior). */
204
+ isProduction?: boolean;
205
+
206
+ /** Field customization + async data. */
207
+ fieldComponentOverrides?: Partial<
208
+ Record<DynamicFormFieldType, React.FC<FieldRenderProps>>
209
+ >;
210
+ resolveExistingDataSource?: ExistingDataSourceResolver;
211
+ }
@@ -0,0 +1,238 @@
1
+ import { DynamicFormItem } from "./types";
2
+ import {
3
+ arrayForFieldValidations,
4
+ buildSftpMap,
5
+ filterActionsForEmailTemplate,
6
+ formPayloadCreation,
7
+ generateSubmissionId,
8
+ getInvalidFields,
9
+ groupFieldsForLayout,
10
+ processFields,
11
+ shouldShowPopup,
12
+ } from "./utils";
13
+
14
+ const textField = (
15
+ overrides: Partial<Extract<DynamicFormItem, { itemType: "formField" }>> = {}
16
+ ): DynamicFormItem => ({
17
+ itemType: "formField",
18
+ fieldId: "firstName",
19
+ fieldType: "Text",
20
+ requiredField: true,
21
+ ...overrides,
22
+ });
23
+
24
+ describe("dynamic-form/utils", () => {
25
+ describe("generateSubmissionId", () => {
26
+ it("returns a non-empty string", () => {
27
+ const id = generateSubmissionId();
28
+ expect(typeof id).toBe("string");
29
+ expect(id.length).toBeGreaterThan(0);
30
+ });
31
+ });
32
+
33
+ describe("processFields", () => {
34
+ it("expands conditional fields when trigger value is 'yes'", () => {
35
+ const items: DynamicFormItem[] = [
36
+ {
37
+ itemType: "conditional",
38
+ fieldId: "hasAccount",
39
+ formFieldsToShow: [textField({ fieldId: "accountId" })],
40
+ },
41
+ ];
42
+ expect(processFields(items, { hasAccount: "yes" })).toHaveLength(2);
43
+ expect(processFields(items, { hasAccount: "no" })).toHaveLength(1);
44
+ });
45
+ });
46
+
47
+ describe("getInvalidFields", () => {
48
+ it("flags missing required fields", () => {
49
+ const arr = arrayForFieldValidations([textField()], false, {});
50
+ expect(getInvalidFields(arr, {})).toEqual(["firstName"]);
51
+ expect(getInvalidFields(arr, { firstName: "Jane" })).toEqual([]);
52
+ });
53
+
54
+ it("treats an unchecked consent checkbox as invalid", () => {
55
+ const arr = arrayForFieldValidations(
56
+ [
57
+ textField({
58
+ fieldId: "consent",
59
+ fieldType: "Checkbox",
60
+ dataSource: null,
61
+ }),
62
+ ],
63
+ false,
64
+ {}
65
+ );
66
+ expect(getInvalidFields(arr, { consent: false })).toEqual(["consent"]);
67
+ expect(getInvalidFields(arr, { consent: true })).toEqual([]);
68
+ });
69
+ });
70
+
71
+ describe("buildSftpMap", () => {
72
+ it("maps fieldId to sftpFieldId", () => {
73
+ const map = buildSftpMap(
74
+ [textField({ fieldId: "firstName", sftpFieldId: "FNAME" })],
75
+ {}
76
+ );
77
+ expect(map).toEqual({ firstName: "FNAME" });
78
+ });
79
+ });
80
+
81
+ describe("formPayloadCreation", () => {
82
+ it("groups by section when all fields declare one", () => {
83
+ const items = [
84
+ textField({ fieldId: "a", formSection: "s1" }),
85
+ textField({ fieldId: "b", formSection: "s1" }),
86
+ ];
87
+ const payload = formPayloadCreation(items, { a: "1", b: "2" });
88
+ expect(payload).toEqual({ s1: [{ a: "1" }, { b: "2" }] });
89
+ });
90
+
91
+ it("falls back to form1 when no section is present", () => {
92
+ const payload = formPayloadCreation([textField({ fieldId: "a" })], {
93
+ a: "1",
94
+ });
95
+ expect(payload).toEqual({ form1: { a: "1" } });
96
+ });
97
+ });
98
+
99
+ describe("filterActionsForEmailTemplate", () => {
100
+ it("drops email action when no template is provided", () => {
101
+ expect(
102
+ filterActionsForEmailTemplate(["email", "sms"], undefined)
103
+ ).toEqual(["sms"]);
104
+ });
105
+
106
+ it("keeps email action when a template is provided", () => {
107
+ expect(
108
+ filterActionsForEmailTemplate(["email", "sms"], "welcome")
109
+ ).toEqual(["email", "sms"]);
110
+ });
111
+ });
112
+
113
+ describe("shouldShowPopup", () => {
114
+ it("returns true when the selected option is flagged for popup", () => {
115
+ const items: DynamicFormItem[] = [
116
+ textField({
117
+ fieldId: "topic",
118
+ fieldType: "Dropdown",
119
+ dataSource: [
120
+ { label: "A", value: "a" },
121
+ { label: "B", value: "b", popup: true },
122
+ ],
123
+ }),
124
+ ];
125
+ expect(shouldShowPopup(items, { topic: "b" })).toBe(true);
126
+ expect(shouldShowPopup(items, { topic: "a" })).toBe(false);
127
+ });
128
+ });
129
+
130
+ describe("groupFieldsForLayout", () => {
131
+ it("pairs two consecutive half-width fields into a double group", () => {
132
+ const groups = groupFieldsForLayout([
133
+ textField({ fieldId: "a", fieldWidth: false }),
134
+ textField({ fieldId: "b", fieldWidth: false }),
135
+ ]);
136
+ expect(groups).toHaveLength(1);
137
+ expect(groups[0].type).toBe("double");
138
+ });
139
+
140
+ it("keeps a lone half-width field as a single group", () => {
141
+ const groups = groupFieldsForLayout([
142
+ textField({ fieldId: "a", fieldWidth: false }),
143
+ textField({ fieldId: "b", fieldWidth: true }),
144
+ ]);
145
+ expect(groups[0].type).toBe("single");
146
+ });
147
+
148
+ it("does not pair a half-width field with a following grouped field", () => {
149
+ const groups = groupFieldsForLayout([
150
+ textField({ fieldId: "a", fieldWidth: false }),
151
+ textField({
152
+ fieldId: "b",
153
+ fieldWidth: false,
154
+ validationGroupId: "grp",
155
+ validationType: "At least One Required",
156
+ }),
157
+ ]);
158
+ expect(groups[0].type).toBe("single");
159
+ expect(groups[1].type).toBe("validationGroup");
160
+ });
161
+
162
+ it("wraps a non-form item as its own single group", () => {
163
+ const groups = groupFieldsForLayout([
164
+ { itemType: "custom", content: null },
165
+ textField({ fieldId: "a" }),
166
+ ]);
167
+ expect(groups[0].type).toBe("single");
168
+ });
169
+
170
+ it("builds double subgroups inside a validation group", () => {
171
+ const groups = groupFieldsForLayout([
172
+ textField({
173
+ fieldId: "a",
174
+ fieldWidth: false,
175
+ validationGroupId: "grp",
176
+ }),
177
+ textField({
178
+ fieldId: "b",
179
+ fieldWidth: false,
180
+ validationGroupId: "grp",
181
+ }),
182
+ textField({
183
+ fieldId: "c",
184
+ fieldWidth: true,
185
+ validationGroupId: "grp",
186
+ }),
187
+ ]);
188
+ expect(groups[0].type).toBe("validationGroup");
189
+ expect(groups[0].subGroups[0].type).toBe("double");
190
+ expect(groups[0].subGroups[1].type).toBe("single");
191
+ });
192
+ });
193
+
194
+ describe("shouldShowPopup edge cases", () => {
195
+ it("ignores fields without an array data source", () => {
196
+ const items: DynamicFormItem[] = [
197
+ textField({ fieldId: "x", dataSource: null }),
198
+ ];
199
+ expect(shouldShowPopup(items, { x: "anything" })).toBe(false);
200
+ });
201
+
202
+ it("returns false when the selected option has no popup flag", () => {
203
+ const items: DynamicFormItem[] = [
204
+ textField({
205
+ fieldId: "topic",
206
+ dataSource: [{ label: "A", value: "a" }],
207
+ }),
208
+ ];
209
+ expect(shouldShowPopup(items, { topic: "a" })).toBe(false);
210
+ });
211
+
212
+ it("ignores conditional items", () => {
213
+ const items: DynamicFormItem[] = [
214
+ {
215
+ itemType: "conditional",
216
+ fieldId: "cond",
217
+ formFieldsToShow: [],
218
+ },
219
+ ];
220
+ expect(shouldShowPopup(items, {})).toBe(false);
221
+ });
222
+ });
223
+
224
+ describe("generateSubmissionId fallback", () => {
225
+ it("uses the RFC4122 fallback when crypto.randomUUID is unavailable", () => {
226
+ const originalRandomUUID = (crypto as any).randomUUID;
227
+ (crypto as any).randomUUID = undefined;
228
+ try {
229
+ const id = generateSubmissionId();
230
+ expect(id).toMatch(
231
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
232
+ );
233
+ } finally {
234
+ (crypto as any).randomUUID = originalRandomUUID;
235
+ }
236
+ });
237
+ });
238
+ });