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