@windstream/react-shared-components 0.2.54 → 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.
Files changed (27) 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/index.d.ts +2 -2
  7. package/dist/styles.css +1 -1
  8. package/package.json +1 -1
  9. package/src/contentful/blocks/dynamic-form/DynamicForm.stories.tsx +154 -0
  10. package/src/contentful/blocks/dynamic-form/conditional-fields.test.tsx +108 -0
  11. package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +103 -0
  12. package/src/contentful/blocks/dynamic-form/constants.ts +28 -0
  13. package/src/contentful/blocks/dynamic-form/context.ts +35 -0
  14. package/src/contentful/blocks/dynamic-form/field-map.test.tsx +421 -0
  15. package/src/contentful/blocks/dynamic-form/field-map.tsx +486 -0
  16. package/src/contentful/blocks/dynamic-form/form-field.test.tsx +319 -0
  17. package/src/contentful/blocks/dynamic-form/form-field.tsx +224 -0
  18. package/src/contentful/blocks/dynamic-form/index.test.tsx +355 -0
  19. package/src/contentful/blocks/dynamic-form/index.tsx +552 -0
  20. package/src/contentful/blocks/dynamic-form/render.test.tsx +116 -0
  21. package/src/contentful/blocks/dynamic-form/render.tsx +172 -0
  22. package/src/contentful/blocks/dynamic-form/types.ts +211 -0
  23. package/src/contentful/blocks/dynamic-form/utils.test.ts +238 -0
  24. package/src/contentful/blocks/dynamic-form/utils.ts +255 -0
  25. package/src/contentful/blocks/primary-hero/index.test.tsx +28 -3
  26. package/src/contentful/blocks/primary-hero/index.tsx +5 -1
  27. package/src/contentful/index.ts +20 -0
@@ -0,0 +1,108 @@
1
+ import React from "react";
2
+ import ConditionalFormFields from "./conditional-fields";
3
+ import { FormContext, FormContextType } from "./context";
4
+ import { DynamicFormItem } 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
+ const nestedField: DynamicFormItem = {
24
+ itemType: "formField",
25
+ fieldId: "detail",
26
+ fieldType: "Text",
27
+ displayName: "Account Detail",
28
+ };
29
+
30
+ const renderConditional = (
31
+ props: Partial<React.ComponentProps<typeof ConditionalFormFields>> = {},
32
+ ctx: Partial<FormContextType> = {}
33
+ ) => {
34
+ const setFormData = jest.fn();
35
+ const utils = render(
36
+ <FormContext.Provider value={makeContext(ctx)}>
37
+ <ConditionalFormFields
38
+ fieldId="hasAccount"
39
+ displayName="Do you have an account?"
40
+ requiredField
41
+ dataSource={[
42
+ { label: "Yes", value: "yes" },
43
+ { label: "No", value: "no" },
44
+ ]}
45
+ formFieldsToShow={[nestedField]}
46
+ formData={{}}
47
+ setFormData={setFormData}
48
+ submitErrorFields={[]}
49
+ {...props}
50
+ />
51
+ </FormContext.Provider>
52
+ );
53
+ return { ...utils, setFormData };
54
+ };
55
+
56
+ describe("ConditionalFormFields", () => {
57
+ it("renders the label and radio options", () => {
58
+ renderConditional();
59
+ expect(
60
+ screen.getByText("Do you have an account?")
61
+ ).toBeInTheDocument();
62
+ expect(screen.getByText("Yes")).toBeInTheDocument();
63
+ expect(screen.getByText("No")).toBeInTheDocument();
64
+ });
65
+
66
+ it("renders a note when provided", () => {
67
+ renderConditional({ note: "Please answer" });
68
+ expect(screen.getByText("Please answer")).toBeInTheDocument();
69
+ });
70
+
71
+ it("reveals the nested fields when 'yes' is selected", () => {
72
+ const { setFormData } = renderConditional();
73
+ expect(screen.queryByText("Account Detail")).not.toBeInTheDocument();
74
+ fireEvent.click(screen.getByText("Yes"));
75
+ expect(screen.getByText("Account Detail")).toBeInTheDocument();
76
+ expect(setFormData.mock.calls[0][0]({})).toEqual({ hasAccount: "yes" });
77
+ });
78
+
79
+ it("keeps the nested fields hidden when 'no' is selected", () => {
80
+ renderConditional();
81
+ fireEvent.click(screen.getByText("No"));
82
+ expect(screen.queryByText("Account Detail")).not.toBeInTheDocument();
83
+ });
84
+
85
+ it("clears the form error state on change", () => {
86
+ const setHasError = jest.fn();
87
+ renderConditional({}, { setHasError });
88
+ fireEvent.click(screen.getByText("Yes"));
89
+ expect(setHasError).toHaveBeenCalledWith(false);
90
+ });
91
+
92
+ it("defaults formFieldsToShow to an empty list", () => {
93
+ render(
94
+ <FormContext.Provider value={makeContext()}>
95
+ <ConditionalFormFields
96
+ fieldId="hasAccount"
97
+ displayName="Trigger"
98
+ formFieldsToShow={undefined as any}
99
+ formData={{}}
100
+ setFormData={jest.fn()}
101
+ submitErrorFields={[]}
102
+ />
103
+ </FormContext.Provider>
104
+ );
105
+ fireEvent.click(screen.getByText("Trigger"));
106
+ expect(screen.getByText("Trigger")).toBeInTheDocument();
107
+ });
108
+ });
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import React, { useContext, useState } from "react";
4
+ import { FormContext } from "./context";
5
+ import { FormFieldTypeMap, labelCreator } from "./field-map";
6
+ import { dynamicRenderingWithGroupings } from "./render";
7
+ import { ConditionalFieldConfig } from "./types";
8
+
9
+ import { Text } from "@shared/components/text";
10
+
11
+ type FieldError = { fieldId?: string; errorMessage: string };
12
+ type FormValues = Record<string, any>;
13
+
14
+ export interface ConditionalFormFieldsProps extends ConditionalFieldConfig {
15
+ formData: FormValues;
16
+ setFormData: (updater: (prev: FormValues) => FormValues) => void;
17
+ submitErrorFields: string[];
18
+ }
19
+
20
+ const ConditionalFormFields: React.FC<ConditionalFormFieldsProps> = props => {
21
+ const context = useContext(FormContext);
22
+ const {
23
+ formData,
24
+ setFormData,
25
+ submitErrorFields,
26
+ fieldId,
27
+ displayName,
28
+ requiredField,
29
+ formFieldsToShow = [],
30
+ dataSource,
31
+ note,
32
+ } = props;
33
+
34
+ const [showElements, setShowElements] = useState(false);
35
+ const [error, setError] = useState<FieldError>({
36
+ fieldId: "",
37
+ errorMessage: "",
38
+ });
39
+
40
+ const Component = FormFieldTypeMap["Radio"];
41
+ if (!Component) return null;
42
+
43
+ const hasError = submitErrorFields.includes(fieldId);
44
+ let errorClass = "";
45
+ if (hasError) {
46
+ errorClass =
47
+ fieldId === "Datepicker" || fieldId === "Dropdown"
48
+ ? "border border-border-critical border-solid rounded-xl"
49
+ : "border border-border-critical";
50
+ }
51
+
52
+ const handleConditionalInputChange = (event: string): void => {
53
+ setError({ fieldId: "", errorMessage: "" });
54
+ context?.setHasError(false);
55
+ setShowElements(event?.toLowerCase() === "yes");
56
+ setFormData(prev => ({ ...prev, [fieldId]: event }));
57
+ };
58
+
59
+ return (
60
+ <div className="w-full">
61
+ <div className="mb-4">
62
+ {labelCreator(displayName, requiredField)}
63
+ <Component
64
+ displayName={displayName}
65
+ requiredField={requiredField}
66
+ formData={formData}
67
+ fieldId={fieldId}
68
+ handleInputChange={handleConditionalInputChange}
69
+ error={error}
70
+ hasError={hasError}
71
+ dataSource={dataSource}
72
+ errorClass={errorClass}
73
+ />
74
+ {error.fieldId === fieldId && error.errorMessage && (
75
+ <Text as="div" className="footnote mt-1 text-text-critical">
76
+ {error.errorMessage}
77
+ </Text>
78
+ )}
79
+ {note && (
80
+ <Text as="div" className="footnote mt-1">
81
+ {note}
82
+ </Text>
83
+ )}
84
+ </div>
85
+ <div
86
+ className={`transition duration-300 ease-in-out ${
87
+ showElements ? "translate-y-0 opacity-100" : "translate-y-4 opacity-0"
88
+ }`}
89
+ >
90
+ {showElements &&
91
+ dynamicRenderingWithGroupings(
92
+ false,
93
+ formFieldsToShow,
94
+ formData,
95
+ setFormData,
96
+ submitErrorFields
97
+ )}
98
+ </div>
99
+ </div>
100
+ );
101
+ };
102
+
103
+ export default ConditionalFormFields;
@@ -0,0 +1,28 @@
1
+ export const constants = {
2
+ IP_PHONE_NUMBER: "ip_or_phone",
3
+ TARGET_EMAIL: "target_email",
4
+ REQUIRED_FIELDS_MISSING: "Required fields missing",
5
+ PROVIDE_AT_LEAST_ONE: "Please provide at least one",
6
+ ONE_REQUIRED_FIELD: "At least One Required",
7
+ };
8
+
9
+ export const DEFAULT_VALIDATION_ERROR_TEXT =
10
+ "* Please fill all required fields and checkboxes";
11
+
12
+ export const Regex = {
13
+ phone:
14
+ /^(?:\d{10}|\(\d{3}\)\s\d{3}-\d{4}|\d{3}-\d{3}-\d{4}|\+1\s\d{3}-\d{3}-\d{4})$/,
15
+ ipv4: /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/,
16
+ ipv6: /^(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))$/,
17
+ email:
18
+ /^[a-zA-Z0-9._%+-]+@((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{1,}|(?:\d{1,3}\.){3}\d{1,3})$/,
19
+ };
20
+
21
+ export const FormFieldTypes = {
22
+ Imageupload: "Imageupload",
23
+ Datepicker: "Datepicker",
24
+ Checkbox: "Checkbox",
25
+ Dropdown: "Dropdown",
26
+ Radio: "Radio",
27
+ Text: "Text",
28
+ } as const;
@@ -0,0 +1,35 @@
1
+ import { createContext } from "react";
2
+ import type React from "react";
3
+ import {
4
+ DynamicFormFieldType,
5
+ ExistingDataSourceResolver,
6
+ FieldRenderProps,
7
+ } from "./types";
8
+
9
+ export type FormContextType = {
10
+ fieldErrors: Record<string, string>;
11
+ setFieldError: (fieldId: string, errorMessage: string) => void;
12
+ clearFieldError: (fieldId: string) => void;
13
+ hasError: boolean;
14
+ setHasError: (error: boolean) => void;
15
+ selectedState: any;
16
+ setSelectedState: (state: any) => void;
17
+ files: File[];
18
+ setFiles: (files: File[]) => void;
19
+ /** Injected resolver for app-owned data sources (e.g. State/City lists). */
20
+ resolveExistingDataSource?: ExistingDataSourceResolver;
21
+ /** App-provided field renderer overrides. */
22
+ fieldComponentOverrides?: Partial<
23
+ Record<DynamicFormFieldType, React.FC<FieldRenderProps>>
24
+ >;
25
+ /** Optional error reporter (logger) injected by the app. */
26
+ onError?: (info: {
27
+ type: string;
28
+ error: unknown;
29
+ metadata?: Record<string, any>;
30
+ }) => void;
31
+ };
32
+
33
+ export const FormContext = createContext<FormContextType | undefined>(
34
+ undefined
35
+ );
@@ -0,0 +1,421 @@
1
+ import React from "react";
2
+ import { FormContext, FormContextType } from "./context";
3
+ import {
4
+ FormFieldTypeMap,
5
+ formatFormPhoneNumber,
6
+ labelCreator,
7
+ } from "./field-map";
8
+ import { FieldRenderProps } from "./types";
9
+
10
+ import {
11
+ act,
12
+ fireEvent,
13
+ render,
14
+ screen,
15
+ waitFor,
16
+ } from "@testing-library/react";
17
+
18
+ const baseProps = (
19
+ overrides: Partial<FieldRenderProps> = {}
20
+ ): FieldRenderProps => ({
21
+ loading: false,
22
+ displayName: "Field",
23
+ requiredField: false,
24
+ formData: {},
25
+ fieldId: "f1",
26
+ handleInputChange: jest.fn(),
27
+ dataSource: null,
28
+ error: { fieldId: "", errorMessage: "" },
29
+ errorClass: "",
30
+ hasError: false,
31
+ ...overrides,
32
+ });
33
+
34
+ const makeContext = (
35
+ overrides: Partial<FormContextType> = {}
36
+ ): FormContextType => ({
37
+ fieldErrors: {},
38
+ setFieldError: jest.fn(),
39
+ clearFieldError: jest.fn(),
40
+ hasError: false,
41
+ setHasError: jest.fn(),
42
+ selectedState: null,
43
+ setSelectedState: jest.fn(),
44
+ files: [],
45
+ setFiles: jest.fn(),
46
+ ...overrides,
47
+ });
48
+
49
+ const renderWithContext = (
50
+ ui: React.ReactElement,
51
+ ctx: Partial<FormContextType> = {}
52
+ ) =>
53
+ render(
54
+ <FormContext.Provider value={makeContext(ctx)}>{ui}</FormContext.Provider>
55
+ );
56
+
57
+ describe("field-map", () => {
58
+ describe("formatFormPhoneNumber", () => {
59
+ it("returns empty string for undefined/empty", () => {
60
+ expect(formatFormPhoneNumber("")).toBe("");
61
+ expect(formatFormPhoneNumber(undefined as unknown as string)).toBe("");
62
+ });
63
+
64
+ it("formats numbers of varying length", () => {
65
+ expect(formatFormPhoneNumber("12")).toBe("12");
66
+ expect(formatFormPhoneNumber("1234")).toBe("123-4");
67
+ expect(formatFormPhoneNumber("1234567")).toBe("123-456-7");
68
+ expect(formatFormPhoneNumber("1234567890")).toBe("123-456-7890");
69
+ });
70
+
71
+ it("truncates to 10 digits", () => {
72
+ expect(formatFormPhoneNumber("123456789012")).toBe("123-456-7890");
73
+ });
74
+
75
+ it("strips non-digit characters", () => {
76
+ expect(formatFormPhoneNumber("(123) 456-7890")).toBe("123-456-7890");
77
+ });
78
+ });
79
+
80
+ describe("labelCreator", () => {
81
+ it("renders the label without a required marker", () => {
82
+ render(<>{labelCreator("Email")}</>);
83
+ expect(screen.getByText("Email")).toBeInTheDocument();
84
+ });
85
+
86
+ it("renders a required asterisk when required", () => {
87
+ const { container } = render(<>{labelCreator("Email", true)}</>);
88
+ expect(container.querySelector(".text-text-critical")).toHaveTextContent(
89
+ "*"
90
+ );
91
+ });
92
+ });
93
+
94
+ describe("TextInputField", () => {
95
+ const Text = FormFieldTypeMap.Text;
96
+
97
+ it("renders a plain text input and forwards changes", () => {
98
+ const handleInputChange = jest.fn();
99
+ render(
100
+ <Text
101
+ {...baseProps({ formData: { f1: "hello" }, handleInputChange })}
102
+ />
103
+ );
104
+ const input = screen.getByLabelText("Field") as HTMLInputElement;
105
+ expect(input.value).toBe("hello");
106
+ fireEvent.change(input, { target: { value: "world" } });
107
+ expect(handleInputChange).toHaveBeenCalled();
108
+ });
109
+
110
+ it("renders a phone input with a formatted value", () => {
111
+ render(
112
+ <Text
113
+ {...baseProps({
114
+ textInputType: "phone",
115
+ formData: { f1: "1234567890" },
116
+ })}
117
+ />
118
+ );
119
+ const input = screen.getByLabelText("Field") as HTMLInputElement;
120
+ expect(input.value).toBe("123-456-7890");
121
+ expect(input).toHaveAttribute("type", "tel");
122
+ });
123
+
124
+ it("renders an empty phone input when no value present", () => {
125
+ render(<Text {...baseProps({ textInputType: "phone" })} />);
126
+ const input = screen.getByLabelText("Field") as HTMLInputElement;
127
+ expect(input.value).toBe("");
128
+ });
129
+
130
+ it("renders a multiline textarea", () => {
131
+ const handleInputChange = jest.fn();
132
+ render(
133
+ <Text
134
+ {...baseProps({ textInputType: "multiline", handleInputChange })}
135
+ />
136
+ );
137
+ const textarea = screen.getByLabelText("Field");
138
+ expect(textarea.tagName).toBe("TEXTAREA");
139
+ fireEvent.change(textarea, { target: { value: "abc" } });
140
+ expect(handleInputChange).toHaveBeenCalled();
141
+ });
142
+ });
143
+
144
+ describe("ConsentCheckbox", () => {
145
+ const Checkbox = FormFieldTypeMap.Checkbox;
146
+
147
+ it("toggles and reports the new checked state", () => {
148
+ const handleInputChange = jest.fn();
149
+ render(
150
+ <Checkbox
151
+ {...baseProps({ isConsentCheckbox: true, handleInputChange })}
152
+ />
153
+ );
154
+ const checkbox = screen.getByRole("checkbox");
155
+ fireEvent.click(checkbox);
156
+ expect(handleInputChange).toHaveBeenCalledWith(true);
157
+ });
158
+ });
159
+
160
+ describe("CheckboxInputField (multi-select)", () => {
161
+ const Checkbox = FormFieldTypeMap.Checkbox;
162
+ const dataSource = [
163
+ { label: "A", value: "a" },
164
+ { label: "B", value: "b" },
165
+ ];
166
+
167
+ it("adds a value when an unchecked option is clicked", () => {
168
+ const handleInputChange = jest.fn();
169
+ render(
170
+ <Checkbox
171
+ {...baseProps({ dataSource, formData: {}, handleInputChange })}
172
+ />
173
+ );
174
+ fireEvent.click(screen.getAllByRole("checkbox")[0]);
175
+ expect(handleInputChange).toHaveBeenCalledWith("a");
176
+ });
177
+
178
+ it("removes a value when a checked option is clicked", () => {
179
+ const handleInputChange = jest.fn();
180
+ render(
181
+ <Checkbox
182
+ {...baseProps({
183
+ dataSource,
184
+ formData: { f1: "a,b" },
185
+ handleInputChange,
186
+ })}
187
+ />
188
+ );
189
+ fireEvent.click(screen.getAllByRole("checkbox")[0]);
190
+ expect(handleInputChange).toHaveBeenCalledWith("b");
191
+ });
192
+ });
193
+
194
+ describe("DatePickerField", () => {
195
+ const Datepicker = FormFieldTypeMap.Datepicker;
196
+
197
+ it("renders a datetime-local input and forwards the value", () => {
198
+ const handleInputChange = jest.fn();
199
+ render(
200
+ <Datepicker
201
+ {...baseProps({ formData: { f1: "2026-01-01T10:00" }, handleInputChange })}
202
+ />
203
+ );
204
+ const input = screen.getByLabelText("Field") as HTMLInputElement;
205
+ expect(input).toHaveAttribute("type", "datetime-local");
206
+ fireEvent.change(input, { target: { value: "2026-02-02T12:00" } });
207
+ expect(handleInputChange).toHaveBeenCalledWith("2026-02-02T12:00");
208
+ });
209
+ });
210
+
211
+ describe("RadioButtonInputField", () => {
212
+ const Radio = FormFieldTypeMap.Radio;
213
+ const dataSource = [
214
+ { label: "Yes", value: "yes" },
215
+ { label: "No", value: "no" },
216
+ ];
217
+
218
+ it("renders options and reports the selected value", () => {
219
+ const handleInputChange = jest.fn();
220
+ render(
221
+ <Radio
222
+ {...baseProps({ dataSource, formData: { f1: "yes" }, handleInputChange })}
223
+ />
224
+ );
225
+ const radios = screen.getAllByRole("radio");
226
+ expect(radios).toHaveLength(2);
227
+ fireEvent.click(screen.getByText("No"));
228
+ expect(handleInputChange).toHaveBeenCalledWith("no");
229
+ });
230
+ });
231
+
232
+ describe("DropdownInputField", () => {
233
+ const Dropdown = FormFieldTypeMap.Dropdown;
234
+
235
+ it("renders with the provided static data source", () => {
236
+ renderWithContext(
237
+ <Dropdown
238
+ {...baseProps({
239
+ dataSource: [{ label: "One", value: "1" }],
240
+ formData: { f1: "1" },
241
+ })}
242
+ />
243
+ );
244
+ expect(screen.getByText("One")).toBeInTheDocument();
245
+ });
246
+
247
+ it("resolves options from an existing data source", async () => {
248
+ const resolve = jest
249
+ .fn()
250
+ .mockResolvedValue([{ label: "Resolved", value: "r" }]);
251
+ renderWithContext(
252
+ <Dropdown
253
+ {...baseProps({ dataSource: null, existingDataSource: "State List" })}
254
+ />,
255
+ { resolveExistingDataSource: resolve }
256
+ );
257
+ await waitFor(() =>
258
+ expect(resolve).toHaveBeenCalledWith("State List", {
259
+ selectedState: null,
260
+ })
261
+ );
262
+ });
263
+
264
+ it("reports errors from the resolver", async () => {
265
+ const resolve = jest.fn().mockRejectedValue(new Error("nope"));
266
+ const onError = jest.fn();
267
+ renderWithContext(
268
+ <Dropdown
269
+ {...baseProps({ dataSource: null, existingDataSource: "State List" })}
270
+ />,
271
+ { resolveExistingDataSource: resolve, onError }
272
+ );
273
+ await waitFor(() => expect(onError).toHaveBeenCalled());
274
+ });
275
+
276
+ it("skips resolving for the 'City List' source until a state is selected", async () => {
277
+ const handleInputChange = jest.fn();
278
+ renderWithContext(
279
+ <Dropdown
280
+ {...baseProps({
281
+ dataSource: null,
282
+ existingDataSource: "City List",
283
+ handleInputChange,
284
+ })}
285
+ />
286
+ );
287
+ await waitFor(() => expect(handleInputChange).toHaveBeenCalledWith(null));
288
+ });
289
+
290
+ it("fetches cities when a state is already selected", async () => {
291
+ const resolve = jest
292
+ .fn()
293
+ .mockResolvedValue([{ label: "City", value: "c" }]);
294
+ renderWithContext(
295
+ <Dropdown
296
+ {...baseProps({ dataSource: null, existingDataSource: "City List" })}
297
+ />,
298
+ { resolveExistingDataSource: resolve, selectedState: { value: "TX" } }
299
+ );
300
+ await waitFor(() =>
301
+ expect(resolve).toHaveBeenCalledWith("City List", {
302
+ selectedState: { value: "TX" },
303
+ })
304
+ );
305
+ });
306
+ });
307
+
308
+ describe("ImageuploadField", () => {
309
+ const Imageupload = FormFieldTypeMap.Imageupload;
310
+
311
+ beforeAll(() => {
312
+ global.URL.createObjectURL = jest.fn(() => "blob:preview");
313
+ global.URL.revokeObjectURL = jest.fn();
314
+ });
315
+
316
+ const imageFile = (name = "pic.png", size = 100) => {
317
+ const file = new File(["x"], name, { type: "image/png" });
318
+ Object.defineProperty(file, "size", { value: size });
319
+ return file;
320
+ };
321
+
322
+ it("adds an accepted image file and shows its preview", async () => {
323
+ const setFiles = jest.fn();
324
+ const { container } = renderWithContext(
325
+ <Imageupload {...baseProps({})} />,
326
+ { setFiles }
327
+ );
328
+ const input = container.querySelector(
329
+ 'input[type="file"]'
330
+ ) as HTMLInputElement;
331
+ await act(async () => {
332
+ fireEvent.change(input, { target: { files: [imageFile()] } });
333
+ });
334
+ expect(setFiles).toHaveBeenCalled();
335
+ expect(await screen.findByAltText("pic.png")).toBeInTheDocument();
336
+ });
337
+
338
+ it("rejects non-image files", async () => {
339
+ const { container } = renderWithContext(<Imageupload {...baseProps({})} />);
340
+ const input = container.querySelector(
341
+ 'input[type="file"]'
342
+ ) as HTMLInputElement;
343
+ const textFile = new File(["x"], "notes.txt", { type: "text/plain" });
344
+ await act(async () => {
345
+ fireEvent.change(input, { target: { files: [textFile] } });
346
+ });
347
+ expect(
348
+ screen.getByText("Only image files are allowed.")
349
+ ).toBeInTheDocument();
350
+ });
351
+
352
+ it("rejects when more than five files are added", async () => {
353
+ const { container } = renderWithContext(<Imageupload {...baseProps({})} />);
354
+ const input = container.querySelector(
355
+ 'input[type="file"]'
356
+ ) as HTMLInputElement;
357
+ const files = Array.from({ length: 6 }, (_, i) => imageFile(`p${i}.png`));
358
+ await act(async () => {
359
+ fireEvent.change(input, { target: { files } });
360
+ });
361
+ expect(
362
+ screen.getByText("You can only upload a maximum of 5 files.")
363
+ ).toBeInTheDocument();
364
+ });
365
+
366
+ it("rejects when total size exceeds the 20MB limit", async () => {
367
+ const { container } = renderWithContext(<Imageupload {...baseProps({})} />);
368
+ const input = container.querySelector(
369
+ 'input[type="file"]'
370
+ ) as HTMLInputElement;
371
+ const big = imageFile("big.png", 21 * 1024 * 1024);
372
+ await act(async () => {
373
+ fireEvent.change(input, { target: { files: [big] } });
374
+ });
375
+ expect(
376
+ screen.getByText("Total file size should not be more than 20 MB.")
377
+ ).toBeInTheDocument();
378
+ });
379
+
380
+ it("removes a previously added file", async () => {
381
+ const handleInputChange = jest.fn();
382
+ const { container } = renderWithContext(
383
+ <Imageupload {...baseProps({ handleInputChange })} />
384
+ );
385
+ const input = container.querySelector(
386
+ 'input[type="file"]'
387
+ ) as HTMLInputElement;
388
+ await act(async () => {
389
+ fireEvent.change(input, { target: { files: [imageFile()] } });
390
+ });
391
+ const removeButton = await screen.findByRole("button", { name: "Remove" });
392
+ fireEvent.click(removeButton);
393
+ expect(handleInputChange).toHaveBeenCalled();
394
+ });
395
+
396
+ it("accepts files dropped onto the dropzone", async () => {
397
+ const setFiles = jest.fn();
398
+ renderWithContext(<Imageupload {...baseProps({})} />, { setFiles });
399
+ const dropzone = screen.getByText("Drop files here or")
400
+ .parentElement as HTMLElement;
401
+ await act(async () => {
402
+ fireEvent.dragOver(dropzone);
403
+ fireEvent.dragLeave(dropzone);
404
+ fireEvent.drop(dropzone, {
405
+ dataTransfer: { files: [imageFile()], clearData: jest.fn() },
406
+ });
407
+ });
408
+ expect(setFiles).toHaveBeenCalled();
409
+ });
410
+
411
+ it("opens the native file dialog when 'Choose files' is clicked", () => {
412
+ const { container } = renderWithContext(<Imageupload {...baseProps({})} />);
413
+ const input = container.querySelector(
414
+ 'input[type="file"]'
415
+ ) as HTMLInputElement;
416
+ const clickSpy = jest.spyOn(input, "click").mockImplementation(() => {});
417
+ fireEvent.click(screen.getByRole("button", { name: /choose files/i }));
418
+ expect(clickSpy).toHaveBeenCalled();
419
+ });
420
+ });
421
+ });