@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.
Files changed (33) hide show
  1. package/dist/contentful/index.d.ts +200 -2
  2. package/dist/contentful/index.esm.js +5 -5
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +4 -4
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +2 -2
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.esm.js +2 -2
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +2 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles.css +1 -1
  13. package/package.json +1 -1
  14. package/src/components/collapse/index.test.tsx +20 -0
  15. package/src/components/collapse/index.tsx +4 -0
  16. package/src/contentful/blocks/dynamic-form/DynamicForm.stories.tsx +154 -0
  17. package/src/contentful/blocks/dynamic-form/conditional-fields.test.tsx +108 -0
  18. package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +103 -0
  19. package/src/contentful/blocks/dynamic-form/constants.ts +28 -0
  20. package/src/contentful/blocks/dynamic-form/context.ts +35 -0
  21. package/src/contentful/blocks/dynamic-form/field-map.test.tsx +421 -0
  22. package/src/contentful/blocks/dynamic-form/field-map.tsx +486 -0
  23. package/src/contentful/blocks/dynamic-form/form-field.test.tsx +319 -0
  24. package/src/contentful/blocks/dynamic-form/form-field.tsx +224 -0
  25. package/src/contentful/blocks/dynamic-form/index.test.tsx +355 -0
  26. package/src/contentful/blocks/dynamic-form/index.tsx +552 -0
  27. package/src/contentful/blocks/dynamic-form/render.test.tsx +116 -0
  28. package/src/contentful/blocks/dynamic-form/render.tsx +172 -0
  29. package/src/contentful/blocks/dynamic-form/types.ts +211 -0
  30. package/src/contentful/blocks/dynamic-form/utils.test.ts +238 -0
  31. package/src/contentful/blocks/dynamic-form/utils.ts +255 -0
  32. package/src/contentful/blocks/fiber-form/index.tsx +1 -0
  33. package/src/contentful/index.ts +20 -0
@@ -0,0 +1,319 @@
1
+ import React from "react";
2
+ import { FormContext, FormContextType } from "./context";
3
+ import FormFieldElement, { FormFieldElementProps } from "./form-field";
4
+ import { FieldRenderProps } from "./types";
5
+
6
+ import { fireEvent, render, screen } from "@testing-library/react";
7
+
8
+ const makeContext = (
9
+ overrides: Partial<FormContextType> = {}
10
+ ): FormContextType => ({
11
+ fieldErrors: {},
12
+ setFieldError: jest.fn(),
13
+ clearFieldError: jest.fn(),
14
+ hasError: false,
15
+ setHasError: jest.fn(),
16
+ selectedState: null,
17
+ setSelectedState: jest.fn(),
18
+ files: [],
19
+ setFiles: jest.fn(),
20
+ ...overrides,
21
+ });
22
+
23
+ type FieldProps = Partial<FormFieldElementProps> & {
24
+ fieldId: string;
25
+ fieldType: FormFieldElementProps["fieldType"];
26
+ };
27
+
28
+ /**
29
+ * Renders a FormFieldElement whose inner field component is swapped for a
30
+ * button so the field's `handleInputChange` can be invoked with a controlled
31
+ * event, exercising every branch of the change handler deterministically.
32
+ */
33
+ const setup = (opts: {
34
+ field: FieldProps;
35
+ ctx?: Partial<FormContextType>;
36
+ event?: any;
37
+ }) => {
38
+ const setFormData = jest.fn();
39
+ const Trigger: React.FC<FieldRenderProps> = props => (
40
+ <button
41
+ data-testid="trigger"
42
+ onClick={() => props.handleInputChange(opts.event)}
43
+ >
44
+ go
45
+ </button>
46
+ );
47
+ const context = makeContext({
48
+ ...opts.ctx,
49
+ fieldComponentOverrides: { [opts.field.fieldType]: Trigger },
50
+ });
51
+ const utils = render(
52
+ <FormContext.Provider value={context}>
53
+ <FormFieldElement
54
+ formData={{}}
55
+ setFormData={setFormData}
56
+ submitErrorFields={[]}
57
+ {...(opts.field as FormFieldElementProps)}
58
+ />
59
+ </FormContext.Provider>
60
+ );
61
+ return { ...utils, setFormData, context };
62
+ };
63
+
64
+ const trigger = () => fireEvent.click(screen.getByTestId("trigger"));
65
+
66
+ describe("FormFieldElement", () => {
67
+ it("renders nothing for an unknown field type", () => {
68
+ const { container } = render(
69
+ <FormFieldElement
70
+ formData={{}}
71
+ setFormData={jest.fn()}
72
+ submitErrorFields={[]}
73
+ fieldId="x"
74
+ fieldType={"Nope" as any}
75
+ />
76
+ );
77
+ expect(container).toBeEmptyDOMElement();
78
+ });
79
+
80
+ it("renders a display label for a standard field", () => {
81
+ render(
82
+ <FormFieldElement
83
+ formData={{}}
84
+ setFormData={jest.fn()}
85
+ submitErrorFields={[]}
86
+ fieldId="firstName"
87
+ fieldType="Text"
88
+ displayName="First Name"
89
+ requiredField
90
+ />
91
+ );
92
+ expect(screen.getByText("First Name")).toBeInTheDocument();
93
+ });
94
+
95
+ it("renders the grouping instruction label for a grouped validation field", () => {
96
+ render(
97
+ <FormFieldElement
98
+ formData={{}}
99
+ setFormData={jest.fn()}
100
+ submitErrorFields={[]}
101
+ fieldId="g1"
102
+ fieldType="Text"
103
+ displayName="Group Field"
104
+ validationGroupId="grp"
105
+ validationType="At least One Required"
106
+ validationNote="Provide one of these"
107
+ />
108
+ );
109
+ expect(screen.getByText("Provide one of these")).toBeInTheDocument();
110
+ });
111
+
112
+ it("renders a note when provided", () => {
113
+ render(
114
+ <FormFieldElement
115
+ formData={{}}
116
+ setFormData={jest.fn()}
117
+ submitErrorFields={[]}
118
+ fieldId="firstName"
119
+ fieldType="Text"
120
+ displayName="First Name"
121
+ note="Helpful hint"
122
+ />
123
+ );
124
+ expect(screen.getByText("Helpful hint")).toBeInTheDocument();
125
+ });
126
+
127
+ it("applies an error border class for fields in the submit-error set", () => {
128
+ const { container } = render(
129
+ <FormContext.Provider value={makeContext()}>
130
+ <FormFieldElement
131
+ formData={{}}
132
+ setFormData={jest.fn()}
133
+ submitErrorFields={["Dropdown"]}
134
+ fieldId="Dropdown"
135
+ fieldType="Text"
136
+ displayName="Dropdown"
137
+ />
138
+ </FormContext.Provider>
139
+ );
140
+ expect(container.querySelector(".rounded-xl")).toBeInTheDocument();
141
+ });
142
+
143
+ describe("handleInputChange", () => {
144
+ it("stores the raw value for a Checkbox field", () => {
145
+ const { setFormData, context } = setup({
146
+ field: { fieldId: "f1", fieldType: "Checkbox", dataSource: [] },
147
+ event: "a,b",
148
+ });
149
+ trigger();
150
+ expect(context.clearFieldError).toHaveBeenCalledWith("f1");
151
+ expect(context.setHasError).toHaveBeenCalledWith(false);
152
+ expect(setFormData.mock.calls[0][0]({})).toEqual({ f1: "a,b" });
153
+ });
154
+
155
+ it("stores the value for a Radio field", () => {
156
+ const { setFormData } = setup({
157
+ field: { fieldId: "f1", fieldType: "Radio" },
158
+ event: "yes",
159
+ });
160
+ trigger();
161
+ expect(setFormData.mock.calls[0][0]({})).toEqual({ f1: "yes" });
162
+ });
163
+
164
+ it("stores the option value for a Dropdown field", () => {
165
+ const { setFormData, context } = setup({
166
+ field: { fieldId: "f1", fieldType: "Dropdown" },
167
+ event: { value: "x" },
168
+ });
169
+ trigger();
170
+ expect(setFormData.mock.calls[0][0]({})).toEqual({ f1: "x" });
171
+ expect(context.setSelectedState).not.toHaveBeenCalled();
172
+ });
173
+
174
+ it("tracks the selected state for a State List Dropdown", () => {
175
+ const { context } = setup({
176
+ field: {
177
+ fieldId: "state",
178
+ fieldType: "Dropdown",
179
+ existingDataSource: "State List",
180
+ },
181
+ event: { value: "TX" },
182
+ });
183
+ trigger();
184
+ expect(context.setSelectedState).toHaveBeenCalledWith({ value: "TX" });
185
+ });
186
+
187
+ it("stores the value for a Datepicker field", () => {
188
+ const { setFormData } = setup({
189
+ field: { fieldId: "when", fieldType: "Datepicker" },
190
+ event: "2026-01-01T09:00",
191
+ });
192
+ trigger();
193
+ expect(setFormData.mock.calls[0][0]({})).toEqual({
194
+ when: "2026-01-01T09:00",
195
+ });
196
+ });
197
+
198
+ it("raises an error for an empty Datepicker value", () => {
199
+ const { context } = setup({
200
+ field: {
201
+ fieldId: "when",
202
+ fieldType: "Datepicker",
203
+ errorMessage: "Pick a date",
204
+ },
205
+ event: "",
206
+ });
207
+ trigger();
208
+ expect(context.setHasError).toHaveBeenCalledWith(true);
209
+ expect(context.setFieldError).toHaveBeenCalledWith("when", "Pick a date");
210
+ expect(screen.getByText("Pick a date")).toBeInTheDocument();
211
+ });
212
+
213
+ it("stores uploaded files under the 'files' key for Imageupload", () => {
214
+ const files = [new File(["x"], "a.png", { type: "image/png" })];
215
+ const { setFormData } = setup({
216
+ field: { fieldId: "img", fieldType: "Imageupload" },
217
+ event: files,
218
+ });
219
+ trigger();
220
+ expect(setFormData.mock.calls[0][0]({})).toEqual({ files });
221
+ });
222
+
223
+ it("stores an empty text value and skips further validation", () => {
224
+ const { setFormData } = setup({
225
+ field: { fieldId: "f1", fieldType: "Text" },
226
+ event: { target: { name: "f1", value: "" } },
227
+ });
228
+ trigger();
229
+ expect(setFormData.mock.calls[0][0]({})).toEqual({ f1: "" });
230
+ });
231
+
232
+ it("formats a phone value and flags an incomplete number", () => {
233
+ const { context } = setup({
234
+ field: {
235
+ fieldId: "phone",
236
+ fieldType: "Text",
237
+ textInputType: "phone",
238
+ errorMessage: "Bad phone",
239
+ },
240
+ event: { target: { name: "phone", value: "123" } },
241
+ });
242
+ trigger();
243
+ expect(context.setHasError).toHaveBeenCalledWith(true);
244
+ expect(context.setFieldError).toHaveBeenCalledWith("phone", "Bad phone");
245
+ });
246
+
247
+ it("accepts a complete phone number without flagging an error", () => {
248
+ const { context } = setup({
249
+ field: {
250
+ fieldId: "phone",
251
+ fieldType: "Text",
252
+ textInputType: "phone",
253
+ errorMessage: "Bad phone",
254
+ },
255
+ event: { target: { name: "phone", value: "1234567890" } },
256
+ });
257
+ trigger();
258
+ expect(context.setFieldError).not.toHaveBeenCalled();
259
+ });
260
+
261
+ it("flags an invalid email value", () => {
262
+ const { context } = setup({
263
+ field: {
264
+ fieldId: "email",
265
+ fieldType: "Text",
266
+ textInputType: "email",
267
+ errorMessage: "Bad email",
268
+ },
269
+ event: { target: { name: "email", value: "not-an-email" } },
270
+ });
271
+ trigger();
272
+ expect(context.setFieldError).toHaveBeenCalledWith("email", "Bad email");
273
+ });
274
+
275
+ it("accepts a valid email value", () => {
276
+ const { context } = setup({
277
+ field: {
278
+ fieldId: "email",
279
+ fieldType: "Text",
280
+ textInputType: "email",
281
+ errorMessage: "Bad email",
282
+ },
283
+ event: { target: { name: "email", value: "user@example.com" } },
284
+ });
285
+ trigger();
286
+ expect(context.setFieldError).not.toHaveBeenCalled();
287
+ });
288
+
289
+ it("flags a value that fails a custom regular expression", () => {
290
+ const { context } = setup({
291
+ field: {
292
+ fieldId: "code",
293
+ fieldType: "Text",
294
+ applyRegularExpression: true,
295
+ regexPattern: "^[0-9]+$",
296
+ errorMessage: "Digits only",
297
+ },
298
+ event: { target: { name: "code", value: "abc" } },
299
+ });
300
+ trigger();
301
+ expect(context.setFieldError).toHaveBeenCalledWith("code", "Digits only");
302
+ });
303
+
304
+ it("accepts a value that matches a custom regular expression", () => {
305
+ const { context } = setup({
306
+ field: {
307
+ fieldId: "code",
308
+ fieldType: "Text",
309
+ applyRegularExpression: true,
310
+ regexPattern: "^[0-9]+$",
311
+ errorMessage: "Digits only",
312
+ },
313
+ event: { target: { name: "code", value: "123" } },
314
+ });
315
+ trigger();
316
+ expect(context.setFieldError).not.toHaveBeenCalled();
317
+ });
318
+ });
319
+ });
@@ -0,0 +1,224 @@
1
+ "use client";
2
+
3
+ import React, { useContext, useState } from "react";
4
+ import { constants, Regex } from "./constants";
5
+ import { FormContext } from "./context";
6
+ import {
7
+ formatFormPhoneNumber,
8
+ FormFieldTypeMap,
9
+ labelCreator,
10
+ } from "./field-map";
11
+ import { DynamicFormFieldConfig, FieldRenderProps } from "./types";
12
+
13
+ import { Text } from "@shared/components/text";
14
+ import { cx } from "@shared/utils";
15
+
16
+ type FieldError = { fieldId?: string; errorMessage: string };
17
+ type FormValues = Record<string, any>;
18
+
19
+ export interface FormFieldElementProps
20
+ extends Omit<DynamicFormFieldConfig, "itemType"> {
21
+ loading?: boolean;
22
+ formData: FormValues;
23
+ setFormData: (updater: (prev: FormValues) => FormValues) => void;
24
+ submitErrorFields: string[];
25
+ isGrouped?: boolean;
26
+ }
27
+
28
+ const validateEmail = (email: string): boolean => {
29
+ const [, domainPart] = email.split("@");
30
+ if (!domainPart || domainPart.includes("..")) return false;
31
+ return Regex.email.test(email);
32
+ };
33
+
34
+ export const FormFieldElement: React.FC<FormFieldElementProps> = props => {
35
+ const context = useContext(FormContext);
36
+ const {
37
+ loading,
38
+ formData,
39
+ setFormData,
40
+ submitErrorFields,
41
+ isGrouped = false,
42
+ fieldId,
43
+ fieldType,
44
+ fieldClassName,
45
+ displayName,
46
+ requiredField,
47
+ textInputType,
48
+ errorMessage,
49
+ dataSource,
50
+ existingDataSource,
51
+ note,
52
+ noteClassName,
53
+ validationNote,
54
+ validationGroupId,
55
+ validationType,
56
+ applyRegularExpression,
57
+ regexPattern,
58
+ } = props;
59
+
60
+ const [error, setError] = useState<FieldError>({
61
+ fieldId: "",
62
+ errorMessage: "",
63
+ });
64
+
65
+ const Override = fieldType
66
+ ? context?.fieldComponentOverrides?.[fieldType]
67
+ : undefined;
68
+ const Component: React.FC<FieldRenderProps> | undefined =
69
+ Override || (fieldType ? FormFieldTypeMap[fieldType] : undefined);
70
+
71
+ if (!Component) {
72
+ return null;
73
+ }
74
+
75
+ const groupingField = validationGroupId && validationType && !isGrouped;
76
+ const isConsentCheckbox =
77
+ fieldType === "Checkbox" && !dataSource && !existingDataSource;
78
+
79
+ const hasError = submitErrorFields.includes(fieldId!);
80
+ let errorClass = "";
81
+ if (hasError) {
82
+ errorClass =
83
+ fieldId === "Datepicker" || fieldId === "Dropdown"
84
+ ? "border border-border-critical border-solid rounded-xl"
85
+ : "border border-border-critical";
86
+ }
87
+
88
+ const setFieldErrorLocal = (id: string | undefined, message: string) => {
89
+ setError({ fieldId: id, errorMessage: message });
90
+ };
91
+
92
+ const updateFormData = (name: string, value: any) => {
93
+ setFormData(prev => ({ ...prev, [name]: value }));
94
+ };
95
+
96
+ const handleInputChange = (event: any): void => {
97
+ context?.clearFieldError(fieldId!);
98
+ setError({ fieldId: "", errorMessage: "" });
99
+ context?.setHasError(false);
100
+
101
+ if (fieldType === "Checkbox" || fieldType === "Radio") {
102
+ updateFormData(fieldId!, event);
103
+ } else if (fieldType === "Dropdown") {
104
+ updateFormData(fieldId!, event?.value);
105
+ if (existingDataSource === "State List") {
106
+ context?.setSelectedState(event);
107
+ }
108
+ } else if (fieldType === "Datepicker") {
109
+ if (event) {
110
+ updateFormData(fieldId!, event);
111
+ } else {
112
+ setFieldErrorLocal(fieldId, errorMessage || "");
113
+ context?.setHasError(true);
114
+ context?.setFieldError(fieldId!, errorMessage || "");
115
+ }
116
+ } else if (fieldType === "Imageupload") {
117
+ const nextFiles = Array.isArray(event) ? event : [];
118
+ context?.setFiles(nextFiles);
119
+ updateFormData(fieldId!, nextFiles);
120
+ return;
121
+ } else {
122
+ const { name, value } = event.target;
123
+ if (!value) {
124
+ updateFormData(name, value);
125
+ return;
126
+ }
127
+ if (textInputType === "phone") {
128
+ const formattedNumber = formatFormPhoneNumber(value);
129
+ const digitsOnly = formattedNumber.replace(/\D/g, "");
130
+ updateFormData(name, digitsOnly);
131
+ event.target.value = digitsOnly;
132
+ if (digitsOnly.length !== 10) {
133
+ setFieldErrorLocal(fieldId, errorMessage || "");
134
+ context?.setHasError(true);
135
+ context?.setFieldError(fieldId!, errorMessage || "");
136
+ }
137
+ return;
138
+ }
139
+ if (textInputType === "email" && !validateEmail(value)) {
140
+ setFieldErrorLocal(fieldId, errorMessage || "");
141
+ context?.setHasError(true);
142
+ context?.setFieldError(fieldId!, errorMessage || "");
143
+ }
144
+ if (applyRegularExpression && regexPattern) {
145
+ try {
146
+ const regex = new RegExp(regexPattern);
147
+ if (!regex.test(value) && value !== "") {
148
+ setFieldErrorLocal(fieldId, errorMessage || "");
149
+ context?.setHasError(true);
150
+ context?.setFieldError(fieldId!, errorMessage || "");
151
+ }
152
+ } catch (error) {
153
+ context?.onError?.({
154
+ type: "invalid_regex_pattern",
155
+ error,
156
+ metadata: { fieldId, regexPattern },
157
+ });
158
+ }
159
+ }
160
+ updateFormData(name, value);
161
+ }
162
+ };
163
+
164
+ return (
165
+ <div
166
+ className={cx(
167
+ groupingField
168
+ ? "groupingField rounded-b-xl rounded-t-xl border border-border-secondary bg-bg-surface-secondary px-5 pb-5 pt-1"
169
+ : "",
170
+ fieldClassName?.split("|")[1] || "w-full"
171
+ )}
172
+ >
173
+ {groupingField && !isGrouped
174
+ ? labelCreator(
175
+ validationNote || constants.PROVIDE_AT_LEAST_ONE,
176
+ true,
177
+ "body2"
178
+ )
179
+ : null}
180
+
181
+ {!isConsentCheckbox && labelCreator(displayName, requiredField)}
182
+
183
+ <div
184
+ className={
185
+ fieldType === "Checkbox" || fieldType === "Radio"
186
+ ? !isConsentCheckbox
187
+ ? "mt-6"
188
+ : ""
189
+ : ""
190
+ }
191
+ >
192
+ <Component
193
+ loading={loading}
194
+ displayName={displayName}
195
+ requiredField={requiredField}
196
+ formData={formData}
197
+ fieldId={fieldId}
198
+ textInputType={textInputType}
199
+ handleInputChange={handleInputChange}
200
+ dataSource={dataSource}
201
+ error={error}
202
+ errorClass={errorClass}
203
+ hasError={hasError}
204
+ existingDataSource={existingDataSource}
205
+ isConsentCheckbox={isConsentCheckbox}
206
+ data-testid="form-component"
207
+ />
208
+ </div>
209
+
210
+ {error.fieldId === fieldId && error.errorMessage && (
211
+ <Text as="div" className="footnote text-text-critical">
212
+ {error.errorMessage}
213
+ </Text>
214
+ )}
215
+ {note && (
216
+ <Text as="div" className={cx("footnote", noteClassName)}>
217
+ {note}
218
+ </Text>
219
+ )}
220
+ </div>
221
+ );
222
+ };
223
+
224
+ export default FormFieldElement;