feat: Add 'Disable on Prefill' option for fields (#15715)

* feat:add prefilcheck check box in the field creation

* chore:formatted the li8 translations

* feat:add e2e test case for this feature

* refactor:reanmed the key from prefilledCheck to disableOnPrefill and moved the readOnly logic to formbuilderField

* chore:fixed the type check

* fix:if prefilled has error. do not disabble it

* chore:removed the e2e tests for prefill in e2e

* chore:revert back to old changes

* chore:remove unwanted code frome2e

* Support name field as well

* refactor:handled the feedback changes. still unit test cases pending

* feat: add unit tests for  disable field on prefill logic

* chore: resolved the type check and typos

* chore:renamed the files and type clean up

* Apply suggestions from code review

* chore:renaming test files and moving the disableonprefill hook to formbuilderFeild.

* refactor: add unit test for serach params and form values check and minor code formatting and file name changes

* fix: validating the input value partially at the intialising phase. so that form response values and  from ui values are same and making sure invalid data not sent to backend on submit.

* chore:fix the type check issue

* fix: field value valdiation at initilisation and add the  unit test cases for that change

* chore:add comments to the tests

* chore:resolve type check

* chore: moved the tests to the formBuilder

* Simplify codebase by ensuring select and multiselect changes the value when no option is found

* Relocate and refactor tests

* handle special cases

* Dont disable for dirty fields

* Update schema.prisma

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
This commit is contained in:
Rajesh Jonnalagadda
2024-08-18 17:34:59 +00:00
committed by GitHub
co-authored by Anik Dhabal Babu Hariom
parent a51524e673
commit fc592a17ac
17 changed files with 1098 additions and 11 deletions
@@ -177,6 +177,10 @@ export class BookingField_2024_04_15 {
@ValidateNested({ each: true })
@Type(() => Source)
sources?: Source[];
@IsOptional()
@IsBoolean()
disableOnPrefill?: boolean;
}
export class RecurringEvent_2024_04_15 {
@@ -87,6 +87,10 @@ class BookingField {
@Type(() => Source)
@IsOptional()
sources?: Source[];
@IsOptional()
@IsBoolean()
disableOnPrefill?: boolean;
}
class Organization {
@@ -2561,5 +2561,6 @@
"number_of_options":"{{count}} options",
"reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host",
"reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled",
"disable_input_if_prefilled": "Disable input if the URL identifier is prefilled",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -47,7 +47,7 @@ export default function FormInputFields(props: Props) {
</label>
</div>
<Component
value={response[field.id]?.value}
value={response[field.id]?.value ?? ""}
placeholder={field.placeholder ?? ""}
// required property isn't accepted by query-builder types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -0,0 +1,103 @@
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import widgets from "./widgets";
const { SelectWidget, MultiSelectWidget } = widgets;
// Mock the dynamic import of Select component
vi.mock("next/dynamic", () => ({
__esModule: true,
default: () => {
return function MockSelect({
options,
onChange,
value,
isMulti,
}: {
options: { value: string; label: string }[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange: (value: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
isMulti: boolean;
}) {
return (
<select
data-testid="mock-select"
multiple={isMulti}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value={isMulti ? value.map((v: any) => v.value) : value?.value}
onChange={(e) => {
const selectedOptions = Array.from(e.target.selectedOptions, (option) => ({
value: option.value,
label: option.text,
}));
onChange(isMulti ? selectedOptions : selectedOptions[0]);
}}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
};
},
}));
describe("Select Widgets", () => {
describe("SelectWidget", () => {
const listValues = [
{ title: "Option 1", value: "1" },
{ title: "Option 2", value: "2" },
{ title: "Option 3", value: "3" },
];
it("should handle render the value in option correctly", () => {
const setValue = vi.fn();
render(<SelectWidget value="2" setValue={setValue} listValues={listValues} />);
const select = screen.getByTestId("mock-select");
expect(select).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(3);
expect(setValue).not.toHaveBeenCalled();
});
it("should handle a value that is not in the list and reset the value to empty string", () => {
const setValue = vi.fn();
render(<SelectWidget value="4" setValue={setValue} listValues={listValues} />);
const select = screen.getByTestId("mock-select");
expect(select).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(3);
expect(setValue).toHaveBeenCalledWith("");
});
});
describe("MultiSelectWidget", () => {
const listValues = [
{ title: "Option 1", value: "1" },
{ title: "Option 2", value: "2" },
{ title: "Option 3", value: "3" },
];
it("renders options correctly", () => {
const setValue = vi.fn();
render(<MultiSelectWidget value={[]} setValue={setValue} listValues={listValues} />);
const select = screen.getByTestId("mock-select");
expect(select).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(3);
expect(setValue).not.toHaveBeenCalled();
});
it("sets value to empty array when no options match", () => {
const setValue = vi.fn();
render(<MultiSelectWidget value={["4", "5"]} setValue={setValue} listValues={listValues} />);
expect(setValue).toHaveBeenCalledWith([]);
});
});
});
@@ -150,8 +150,6 @@ const MultiSelectWidget = ({
value,
...remainingProps
}: SelectLikeComponentPropsRAQB<string[]>) => {
//TODO: Use Select here.
//TODO: Let's set listValue itself as label and value instead of using title.
if (!listValues) {
return null;
}
@@ -164,6 +162,14 @@ const MultiSelectWidget = ({
const optionsFromList = selectItems.filter((item) => value?.includes(item.value));
// If no value could be found in the list, then we set the value to undefined.
// This is to update the value back to the source that we couldn't set it. This is important otherwise the outside party thinks that the value is set but it is not.
// Do it only when it is not already empty, this is to avoid infinite state updates
// NOTE: value is some times sent as undefined even though the type will tell you that it can't be
if (optionsFromList.length === 0 && value?.length) {
setValue([]);
}
return (
<Select
aria-label="multi-select-dropdown"
@@ -192,6 +198,13 @@ function SelectWidget({ listValues, setValue, value, ...remainingProps }: Select
});
const optionFromList = selectItems.find((item) => item.value === value);
// If the value is not in the list, then we set the value to undefined.
// This is to update the value back to the source that we couldn't set it. This is important otherwise the outside party thinks that the value is set but it is not.
// Do it only when it is not already empty string, this is to avoid infinite state updates
if (!optionFromList && value) {
setValue("");
}
return (
<Select
aria-label="select-dropdown"
@@ -68,7 +68,7 @@ export const getEmbedIframe = async ({
}, hardTimeout);
});
},
!process.env.CI ? 150000 : 15000
!process.env.CI ? 15000 : 15000
);
if (!iframeReady) {
return null;
@@ -223,6 +223,7 @@ export const Components: Record<FieldType, Component> = {
props.setValue(val);
}}
{...props}
disabled={props.readOnly}
/>
);
},
@@ -364,9 +365,10 @@ export const Components: Record<FieldType, Component> = {
},
radio: {
propsType: propsTypes.radio,
factory: ({ setValue, name, value, options }) => {
factory: ({ setValue, name, value, options, readOnly }) => {
return (
<Group
disabled={readOnly}
value={value}
onValueChange={(e) => {
setValue(e);
@@ -11,6 +11,7 @@ import {
Badge,
BooleanToggleGroupField,
Button,
CheckboxField,
Dialog,
DialogClose,
DialogContent,
@@ -491,6 +492,10 @@ function FieldEditDialog({
}
label={t("identifier")}
/>
<CheckboxField
description={t("disable_input_if_prefilled")}
{...fieldForm.register("disableOnPrefill", { setValueAs: Boolean })}
/>
<InputField
{...fieldForm.register("label")}
// System fields have a defaultLabel, so there a label is not required
@@ -727,6 +732,11 @@ function VariantFields({
label={t("identifier")}
/>
<CheckboxField
description={t("disable_input_if_prefilled")}
{...fieldForm.register("disableOnPrefill", { setValueAs: Boolean })}
/>
<ul
className={classNames(
!isSimpleVariant ? "border-subtle divide-subtle mt-2 divide-y rounded-md border" : ""
@@ -11,6 +11,10 @@ import { Components, isValidValueProp } from "./Components";
import { fieldTypesConfigMap } from "./fieldTypes";
import { fieldsThatSupportLabelAsSafeHtml } from "./fieldsThatSupportLabelAsSafeHtml";
import type { fieldsSchema } from "./schema";
import {
useShouldBeDisabledDueToPrefill,
getFieldNameFromErrorMessage,
} from "./useShouldBeDisabledDueToPrefill";
import { getTranslatedConfig as getTranslatedVariantsConfig } from "./utils/variantsConfig";
type RhfForm = {
@@ -59,6 +63,7 @@ export const FormBuilderField = ({
t
);
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
return (
<div data-fob-field-name={field.name} className={classNames(className, hidden ? "hidden" : "")}>
<Controller
@@ -71,7 +76,7 @@ export const FormBuilderField = ({
<ComponentForField
field={{ ...field, label, placeholder, hidden }}
value={value}
readOnly={readOnly}
readOnly={readOnly || shouldBeDisabled}
setValue={(val: unknown) => {
onChange(val);
}}
@@ -84,7 +89,7 @@ export const FormBuilderField = ({
render={({ message }: { message: string | undefined }) => {
message = message || "";
// If the error comes due to parsing the `responses` object(which can have error for any field), we need to identify the field that has the error from the message
const name = message.replace(/\{([^}]+)\}.*/, "$1");
const name = getFieldNameFromErrorMessage(message);
const isResponsesErrorForThisField = name === field.name;
// If the error comes for the specific property of responses(Possible for system fields), then also we would go ahead and show the error
if (!isResponsesErrorForThisField && !error) {
@@ -0,0 +1,77 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { render } from "@testing-library/react";
import React from "react";
import type { ReactNode } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { vi } from "vitest";
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
import { mockProps, questionUtils, setMockIntersectionObserver, setMockMatchMedia } from "./testUtils";
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
const renderComponent = () => {
const Wrapper = ({ children }: { children: ReactNode }) => {
const methods = useForm();
return (
<TooltipProvider>
<FormProvider {...methods}>{children}</FormProvider>
</TooltipProvider>
);
};
return render(<FormBuilder {...mockProps} />, { wrapper: Wrapper });
};
const questionTypes = [
{ questionType: "email", label: "Email Field" },
{ questionType: "phone", label: "Phone Field" },
{ questionType: "address", label: "Address Field" },
{ questionType: "text", label: "Short Text Field" },
{ questionType: "number", label: "Number Field" },
{ questionType: "textarea", label: "Long Text Field" },
{ questionType: "select", label: "Select Field" },
{ questionType: "multiselect", label: "MultiSelect Field" },
{ questionType: "multiemail", label: "Multiple Emails Field" },
{ questionType: "checkbox", label: "CheckBox Group Field" },
{ questionType: "radio", label: "Radio Group Field" },
{ questionType: "boolean", label: "Checkbox Field" },
];
describe("Form Builder - Add and Edit", () => {
beforeAll(() => {
setMockMatchMedia();
setMockIntersectionObserver();
});
beforeEach(() => {
renderComponent();
});
for (const { questionType, label } of questionTypes) {
it(`should successfully create or edit a ${label} question type in the form builder, with the option to disable the field on prefill`, async () => {
const defaultIdentifier = `${questionType}-id`;
const newIdentifier = `${defaultIdentifier}-edited`;
// Add a question
await questionUtils.addQuestion({
questionType,
identifier: defaultIdentifier,
label,
disableOnPrefill: true,
});
questionUtils.expectedValueForDisablePreFillCheckox(true);
// Edit the question
await questionUtils.editQuestion({
identifier: newIdentifier,
existingQuestionId: defaultIdentifier,
disableOnPrefill: false,
});
questionUtils.expectedValueForDisablePreFillCheckox(false);
});
}
});
@@ -0,0 +1,184 @@
import { fireEvent, waitFor, screen } from "@testing-library/react";
import { vi } from "vitest";
import type { z } from "zod";
import type { fieldsSchema } from "@calcom/features/form-builder/schema";
type RhfForm = {
fields: z.infer<typeof fieldsSchema>;
};
type RhfFormFields = RhfForm["fields"];
export type RhfFormField = RhfFormFields[number];
export type FieldType = RhfFormField["type"];
export interface QuestionProps {
questionType: string;
identifier: string;
label: string;
disableOnPrefill?: boolean;
}
export interface FormBuilderProps {
formProp: string;
title: string;
description: string;
addFieldLabel: string;
disabled: boolean;
LockedIcon: false | JSX.Element;
dataStore: {
options: Record<string, { label: string; value: string; inputPlaceholder?: string }[]>;
};
disableOnPrefill?: boolean;
}
export interface FormBuildFieldProps {
field: Omit<RhfFormField, "editable" | "label"> & {
// Label is optional because radioInput doesn't have a label
label?: string;
value: string;
};
readOnly: boolean;
}
export const mockProps: FormBuilderProps = {
formProp: "formProp",
title: "FormBuilder Title",
description: "FormBuilder Description",
addFieldLabel: "Add Field",
disabled: false,
LockedIcon: false,
dataStore: { options: {} },
disableOnPrefill: false,
};
export const mockFieldProps: FormBuildFieldProps = {
field: {
type: "text",
name: "test-text-n",
variant: "text",
placeholder: "text-test-p",
required: false,
label: "text-test-l",
disableOnPrefill: false,
value: "",
},
readOnly: false,
};
export const setMockMatchMedia = () => {
Object.defineProperty(window, "matchMedia", {
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
};
export const setMockIntersectionObserver = () => {
Object.defineProperty(window, "IntersectionObserver", {
value: vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
takeRecords: vi.fn(() => []),
})),
});
};
export const clickDisablePrefillBasedOnUserInput = (disableOnPrefill?: boolean) => {
if (typeof disableOnPrefill === "boolean") {
// Get the checkbox element
const checkbox = getDisablePrefillCheckbox(disableOnPrefill) as HTMLInputElement;
// Determine if the checkbox's current state differs from the `disableOnPrefill` prop
const isChecked = checkbox ? checkbox.checked : false;
// Click the checkbox only if its current state differs from the `disableOnPrefill` value
if (isChecked !== disableOnPrefill) {
fireEvent.click(checkbox);
}
}
};
export const getDisablePrefillCheckbox = (disableOnPrefill?: boolean) => {
if (typeof disableOnPrefill === "boolean") {
const checkbox = screen.getByRole("checkbox", { name: /disable_input_if_prefilled/i });
return checkbox;
}
};
export const checkForDisablePreFillCheckox = (disableOnPrefill?: boolean) => {
const checkbox = getDisablePrefillCheckbox(disableOnPrefill);
expect(checkbox).toBeInTheDocument();
};
export const questionUtils = {
addQuestion: async (props: QuestionProps) => {
fireEvent.click(screen.getByTestId("add-field"));
checkForDisablePreFillCheckox(props?.disableOnPrefill);
fireEvent.keyDown(screen.getByTestId("test-field-type"), { key: "ArrowDown", code: "ArrowDown" });
fireEvent.click(screen.getByTestId(`select-option-${props.questionType}`));
fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: props.identifier } });
fireEvent.change(screen.getAllByRole("textbox")[1], { target: { value: props.label } });
clickDisablePrefillBasedOnUserInput(props?.disableOnPrefill);
fireEvent.click(screen.getByTestId("field-add-save"));
await waitFor(() => {
expect(screen.queryByTestId(`field-${props.identifier}`)).toBeInTheDocument();
});
},
editQuestion: async (props: {
identifier: string;
existingQuestionId: string;
disableOnPrefill?: boolean;
}) => {
fireEvent.click(screen.getByTestId("edit-field-action"));
checkForDisablePreFillCheckox(props?.disableOnPrefill);
fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: props.identifier } });
clickDisablePrefillBasedOnUserInput(props?.disableOnPrefill);
fireEvent.click(screen.getByTestId("field-add-save"));
await waitFor(() => {
expect(screen.queryByTestId(`field-${props.identifier}`)).toBeInTheDocument();
expect(screen.queryByTestId(`field-${props.existingQuestionId}`)).not.toBeInTheDocument();
});
},
deleteQuestion: async (existingQuestionId: string) => {
expect(screen.queryByTestId(`field-${existingQuestionId}`)).toBeInTheDocument();
fireEvent.click(screen.getByTestId("delete-field-action"));
await waitFor(() => {
expect(screen.queryByTestId(`field-${existingQuestionId}`)).not.toBeInTheDocument();
});
},
hideQuestion: async () => {
fireEvent.click(screen.getByTestId("toggle-field"));
await waitFor(() => {
expect(screen.queryByText(/hidden/i)).toBeInTheDocument();
});
},
requiredAndOptionalQuestion: async () => {
expect(screen.queryByTestId("required")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("edit-field-action"));
fireEvent.click(screen.getAllByRole("radio")[1]);
fireEvent.click(screen.getByTestId("field-add-save"));
await waitFor(() => {
expect(screen.getByTestId("optional")).toBeInTheDocument();
});
},
expectedValueForDisablePreFillCheckox: async (expectedValue: boolean) => {
fireEvent.click(screen.getByTestId("edit-field-action"));
const checkbox = getDisablePrefillCheckbox(expectedValue) as HTMLInputElement;
await expect(checkbox?.checked).toBe(expectedValue);
},
};
+3 -2
View File
@@ -238,6 +238,7 @@ export const fieldSchema = baseFieldSchema.merge(
})
)
.optional(),
disableOnPrefill: z.boolean().default(false).optional(),
})
);
@@ -329,12 +330,12 @@ export const fieldTypesSchemaMap: Partial<
}
const valueIdentified = response as unknown as Record<string, string>;
if (subField.required) {
if (!isPartialSchema && !valueIdentified[subField.name])
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) });
if (!schema.safeParse(valueIdentified[subField.name]).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });
return;
}
if (!isPartialSchema && !valueIdentified[subField.name])
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) });
}
});
},
@@ -0,0 +1,554 @@
import type { Control } from "react-hook-form";
import { useFormContext } from "react-hook-form";
import { describe, expect, vi, beforeEach, test } from "vitest";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { useShouldBeDisabledDueToPrefill } from "./useShouldBeDisabledDueToPrefill";
vi.mock("@calcom/lib/hooks/useRouterQuery", () => ({
useRouterQuery: vi.fn(),
}));
// Mock `react-hook-form` module
vi.mock("react-hook-form", async () => {
const actual: any = await vi.importActual("react-hook-form");
return {
...actual,
useFormContext: vi.fn(),
};
});
const defaultField = {
name: "test-field",
type: "text" as const,
};
type FormState = {
dirtyFields: {
responses: Record<string, boolean>;
};
};
const buildFormState = (formState?: FormState) => {
formState = formState || {
dirtyFields: {
responses: {},
},
};
return {
...formState,
};
};
const buildFormStateWithErrorForField = ({
fieldName,
formState,
}: {
fieldName: string;
formState?: FormState;
}) => {
return {
...buildFormState(formState),
errors: {
responses: {
message: `{${fieldName}}Some error in the field`,
},
},
};
};
const buildFormStateWithNoErrors = (formState?: FormState) => {
return buildFormState(formState);
};
const getMockFormContext = ({
formState,
responses,
}: {
formState: FormState;
responses: Record<string, string | string[]>;
}) => {
return {
formState,
control: {} as Control,
getValues: () => ({
responses,
}),
};
};
function mockFormContext({ formState, responses }: { formState: any; responses: any }) {
(vi.mocked(useFormContext) as any).mockReturnValue(getMockFormContext({ formState, responses }));
}
function mockScenario({
formState,
responses,
searchParams,
}: {
formState: any;
responses: any;
searchParams: any;
}) {
mockFormContext({ formState, responses });
vi.mocked(useRouterQuery).mockReturnValue(searchParams);
}
describe("useShouldBeDisabledDueToPrefill", () => {
beforeEach(() => {
// Reset mocks before each test
vi.resetAllMocks();
});
describe("When disableOnPrefill is true", () => {
test("should return `false` for a field if there is an error for the field", () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithErrorForField({ fieldName: field.name }),
responses: {
[field.name]: "TestValue",
},
searchParams: {
[field.name]: "TestValue",
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
test("should return `true` for a field if there is an error but not for that field", () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithErrorForField({ fieldName: "some-other-field" }),
responses: {
[field.name]: "TestValue",
},
searchParams: {
[field.name]: "TestValue",
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
test("should return `true` for a field if there are no errors", () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: "TestValue",
},
searchParams: {
[field.name]: "TestValue",
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
test("should return `false` when the responses and searchParams both are empty(handles the undefined === undefined case)", () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {},
searchParams: {},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
describe("Special handling of radioInput and variantsConfig type fields", () => {
describe("radioInput type field", () => {
test(`should return true for radioInput type field when the searchParams value is set and response is also set even though it doesn't match`, () => {
const field = {
...defaultField,
type: "radioInput" as const,
disableOnPrefill: true,
optionsInputs: {
phone: {
type: "phone" as const,
},
},
};
mockScenario({
formState: buildFormStateWithNoErrors(),
searchParams: {
[field.name]: "TestValue1",
},
responses: {
[field.name]: {
value: "phone",
optionValue: "TestValue2",
},
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
test(`should return false for radioInput type field when the searchParams value is set and response is also set but it isn't valid`, () => {
const field = {
...defaultField,
type: "radioInput" as const,
disableOnPrefill: true,
optionsInputs: {
phone: {
type: "phone" as const,
},
},
};
mockScenario({
formState: buildFormStateWithNoErrors(),
searchParams: {
[field.name]: "TestValue1",
},
responses: {
[field.name]: "TestValue2",
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
test(`should return false for radioInput type field when the searchParams value is set and response is also set but it radioInput value isn't completely filled(optionsInputs case)`, () => {
const field = {
...defaultField,
type: "radioInput" as const,
disableOnPrefill: true,
optionsInputs: {
phone: {
type: "phone" as const,
},
},
};
mockScenario({
formState: buildFormStateWithNoErrors(),
searchParams: {
[field.name]: "TestValue1",
},
responses: {
[field.name]: {
value: "phone",
// Not filled
optionValue: "",
},
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
test(`should return false for radioInput type field when the searchParams value is set and response is also set but it radioInput value isn't completely filled`, () => {
const field = {
...defaultField,
type: "radioInput" as const,
disableOnPrefill: true,
optionsInputs: {
not_phone: {
type: "phone" as const,
},
},
};
mockScenario({
formState: buildFormStateWithNoErrors(),
searchParams: {
[field.name]: "TestValue1",
},
responses: {
[field.name]: {
value: "phone",
// Not filled but doesn't matter because phone type doesn't need optionValue as per the optionsInputs
optionValue: "",
},
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
test(`should return false for radioInput type field if some other field is set in the searchParams`, () => {
const field = {
...defaultField,
type: "radioInput" as const,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
searchParams: {
["some-other-field"]: "TestValue1",
},
responses: {},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
});
test(`should return true for field with variantsConfig when the searchParams value is set and response is also set even though it doesn't match`, () => {
const field = {
...defaultField,
type: "text" as const,
variantsConfig: {
variants: {
TestValue1: {
fields: [],
},
},
},
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
searchParams: {
[field.name]: "TestValue1",
},
responses: {
[field.name]: "TestValue2",
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
});
describe("for array type value", () => {
test(`should return 'true' when there is complete match of values b/w the searchParams and the responses prefilled`, () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: ["TestValue1", "TestValue2"],
},
searchParams: {
[field.name]: ["TestValue1", "TestValue2"],
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
test(`should return 'true' even when searchParams value partially exists in the responses`, () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: ["TestValue1", "TestValue2"],
},
searchParams: {
[field.name]: ["TestValue1"],
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
test(`should return 'true' even when responses value partially exists in the searchParams`, () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: ["TestValue1"],
},
searchParams: {
[field.name]: ["TestValue1", "TestValue2"],
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
test(`should return 'false' when there is a mismatch`, () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: ["TestValue3"],
},
searchParams: {
[field.name]: ["TestValue1", "TestValue2"],
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
});
describe("for number type value", () => {
test(`should return 'true' when number type value is prefilled`, () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: 123,
},
searchParams: {
[field.name]: 123,
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
test(`should return 'true' when when the searchParams value is a string and the responses value is a number`, () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: "123",
},
searchParams: {
[field.name]: 123,
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
test(`should return 'true' when when the searchParams value is a number and the responses value is a string`, () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: 123,
},
searchParams: {
[field.name]: "123",
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(true);
});
});
test("should return `false` for a field even if there are no errors and it is prefilled but the field is dirty", () => {
const field = {
...defaultField,
disableOnPrefill: true,
};
mockScenario({
formState: buildFormStateWithNoErrors({
dirtyFields: {
responses: {
[field.name]: true,
},
},
}),
responses: {
[field.name]: "TestValue",
},
searchParams: {
[field.name]: "TestValue",
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
});
describe("When disableOnPrefill is false", () => {
test(`should return 'false' for the field`, () => {
const field = {
...defaultField,
disableOnPrefill: false,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {},
searchParams: {},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
test(`should return 'false' for the field even when the field is prefilled`, () => {
const field = {
...defaultField,
disableOnPrefill: false,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: "TestValue",
},
searchParams: {
[field.name]: "TestValue",
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
test(`should return 'false' for field having variantsConfig even when the field is prefilled`, () => {
const field = {
...defaultField,
variantsConfig: {
variants: {
TestValue1: {
fields: [],
},
TestValue2: {
fields: [],
},
},
},
disableOnPrefill: false,
};
mockScenario({
formState: buildFormStateWithNoErrors(),
responses: {
[field.name]: "TestValue1",
},
searchParams: {
[field.name]: "TestValue",
},
});
const shouldBeDisabled = useShouldBeDisabledDueToPrefill(field);
expect(shouldBeDisabled).toBe(false);
});
});
});
@@ -0,0 +1,118 @@
import { useFormContext } from "react-hook-form";
import type { z } from "zod";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import type { fieldsSchema } from "./schema";
type RhfForm = {
fields: z.infer<typeof fieldsSchema>;
};
type RhfFormFields = RhfForm["fields"];
type RhfFormField = RhfFormFields[number];
type FieldProps = Pick<
RhfFormField,
"name" | "type" | "disableOnPrefill" | "variantsConfig" | "optionsInputs"
>;
export const getFieldNameFromErrorMessage = (errorMessage: string): string => {
const name = errorMessage?.replace(/\{([^}]+)\}.*/, "$1");
return name;
};
function toArray(value: string[] | string): string[] {
return Array.isArray(value) ? value : [value];
}
function intersected(arr1: string[], arr2: string[]): boolean {
return !!arr1.find((value) => arr2.find((innerValue) => innerValue?.toString() == value?.toString()));
}
function isEqual(searchParamValue: string | string[], formValue: string[] | string): boolean {
if (typeof formValue === "string") {
return searchParamValue?.toString() == formValue?.toString();
}
const formValueToArray = toArray(formValue as string | string[]);
const urlValueToArray = toArray(searchParamValue);
return intersected(formValueToArray, urlValueToArray);
}
export const useShouldBeDisabledDueToPrefill = (field: FieldProps): boolean => {
const { getValues, formState } = useFormContext();
const toPrefillValues = useRouterQuery();
if (!field.disableOnPrefill) {
return false;
}
// If the field is dirty, it means that some change has been made to the input by user, so we shouldn't disable it anymore
// It avoids a scenario like this
// 1. Input Prefilled and thus disabled
// 2. User submits the form but error is shown for that field(as it was invalid value)
// 3. Field is enabled back to correct the value
// 3. User tries to correct the value and enters the previous value(partially typed) but input suddenly gets disabled
if (formState.dirtyFields?.responses?.[field.name]) {
return false;
}
const prefilledValues = getValues()?.responses || {};
// radioInput and variantsConfig type fields have complex transformations, so we can't directly compare them with search params
// e.g. name = John Doe in search params will be transformed to {firstName: John, lastName: Doe} and we can't directly compare it with toPrefill value
const shouldMatchPrefilledValue = field.type !== "radioInput" && !field.variantsConfig;
const errorMessage = (formState?.errors?.responses?.message || "") as string;
const fieldNameThatHasError = getFieldNameFromErrorMessage(errorMessage);
if (fieldNameThatHasError === field.name) {
return false;
}
const searchParamValue = toPrefillValues[field.name];
const prefilledValue = prefilledValues[field.name];
if (!shouldMatchPrefilledValue) {
// In this case we just ensure they have some values
if (isValueSet(searchParamValue) && isValueSet(prefilledValue)) {
if (field.type === "radioInput") {
return hasValidRadioInputValue({ value: prefilledValue, optionsInputs: field.optionsInputs });
}
return true;
}
}
if (!isValueSet(prefilledValue)) {
return false;
}
if (!toPrefillValues) {
// If there are no toPrefillValues, nothing can be prefilled and thus nothing should be disabled
return false;
}
// If the specified prefill value is filled, then we disable the field. If it is changed due to some reason, then we don't disable the field
if (searchParamValue == prefilledValue?.toString()) {
return true;
}
return isEqual(searchParamValue, prefilledValue);
function isValueSet(value: string | string[] | null | undefined): boolean {
return value !== null && value !== undefined && value !== "";
}
function hasValidRadioInputValue({
value,
optionsInputs,
}: {
value: { optionValue: string; value: string };
optionsInputs: Record<string, unknown> | undefined;
}): boolean {
const requiresOptionInputField = optionsInputs?.[value.value];
if (!requiresOptionInputField) {
return !!value.value;
}
return !!value.value && !!value.optionValue;
}
};
+1 -1
View File
@@ -1,4 +1,4 @@
// This is your Prisma schema file,
// This is your Prisma Schema file
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
+12 -1
View File
@@ -108,7 +108,7 @@ const workspaces = packagedEmbedTestsOnly
name: "@calcom/features",
include: ["packages/features/**/*.{test,spec}.tsx"],
environment: "jsdom",
setupFiles: ["setupVitest.ts"],
setupFiles: ["setupVitest.ts", "packages/ui/components/test-setup.ts"],
},
},
@@ -129,6 +129,17 @@ const workspaces = packagedEmbedTestsOnly
setupFiles: ["packages/ui/components/test-setup.ts"],
},
},
{
test: {
globals: true,
name: "@calcom/routing-forms/widgets",
include: [
"packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.test.tsx",
],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],
},
},
{
test: {
globals: true,