feat: min max chars for long text (#15691)
* feat: min max chars for long text * refactor: remove console log * refactor: revert redundant change * refactor: review changes * fix: min max validation * fix: fix typo * refactor: update comments * refactor: review changes * refactor: review changes * test: Add test * Make maxLength more configurable per field type * Reduce maxLength to 1000 * Update update-event-type.input.ts * revert changes in main * Update update-event-type.input.ts * refactor get-event-type-public.output.ts * add e2e tests * add min max char common.json * Update FormBuilder.tsx * refactor changes * Update fieldTypes.ts * Update schema.ts * add min max length in api-request * Update booking-fields.input.ts * fix e2e tests * move min-max-char tests to manage-booking-questions.e2e.ts * remove test * reorder imports to fix lint * fix lint * fix e2e tests * Abstract out SupportsLengthcheck type Field * Revert newline change * Improve the schema validation a bit and fix undefined in the message issue * revert: v2 API changes --------- Co-authored-by: Ayush Mhetre <mhetreayush1719@gmail.com> Co-authored-by: Hariom <hariombalhara@gmail.com> Co-authored-by: supalarry <laurisskraucis@gmail.com>
This commit is contained in:
co-authored by
Ayush Mhetre
Hariom
supalarry
parent
40df4cf511
commit
b8ba95dbb0
@@ -3,11 +3,13 @@ import { expect } from "@playwright/test";
|
||||
import type { createUsersFixture } from "playwright/fixtures/users";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { createNewEventType } from "./lib/testUtils";
|
||||
import { createHttpServer, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
function getLabelLocator(field: Locator) {
|
||||
@@ -716,3 +718,160 @@ async function expectWebhookToBeCalled(
|
||||
|
||||
expect(body).toMatchObject(expectedBody);
|
||||
}
|
||||
|
||||
test.describe("Text area min and max characters text", () => {
|
||||
test("Create a new event", async ({ page, users }) => {
|
||||
const eventTitle = `Min Max Characters Test`;
|
||||
const fieldType = fieldTypesConfigMap["textarea"];
|
||||
const MAX_LENGTH = fieldType?.supportsLengthCheck?.maxLength;
|
||||
|
||||
// We create a new event type
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/event-types");
|
||||
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await page.waitForSelector(`text=${eventTitle}`);
|
||||
|
||||
// Click on the event type
|
||||
await page.click(`text=${eventTitle}`);
|
||||
await page.waitForSelector(`text=${eventTitle}`);
|
||||
|
||||
// goto the advanced tab
|
||||
await page.click('[href$="tabName=advanced"]');
|
||||
const insertQuestion = async (questionName: string) => {
|
||||
await page.click('[data-testid="add-field"]');
|
||||
const locatorForSelect = page.locator("[id=test-field-type]").nth(0);
|
||||
await locatorForSelect.click();
|
||||
await locatorForSelect.locator(`text="Long Text"`).click();
|
||||
|
||||
await page.fill('[name="name"]', questionName);
|
||||
await page.fill('[name="label"]', questionName);
|
||||
await page.fill('[name="placeholder"]', questionName);
|
||||
};
|
||||
|
||||
const saveQuestion = async () => {
|
||||
await page.click('[data-testid="field-add-save"]');
|
||||
};
|
||||
const cancelQuestion = async () => {
|
||||
await page.click('[data-testid="dialog-rejection"]');
|
||||
};
|
||||
const minLengthSelector = '[name="minLength"]';
|
||||
const maxLengthSelector = '[name="maxLength"]';
|
||||
const minInput = await page.locator(minLengthSelector);
|
||||
const maxInput = await page.locator(maxLengthSelector);
|
||||
let questionName;
|
||||
await test.step("Add a new field with no min and max characters", async () => {
|
||||
// Add a new field with no min and max characters
|
||||
questionName = "Text area without min & max";
|
||||
await insertQuestion(questionName);
|
||||
await saveQuestion();
|
||||
});
|
||||
|
||||
await test.step("Add a new field with min characters only", async () => {
|
||||
// Add a new field with min characters only
|
||||
questionName = "Text area with min = 5";
|
||||
await insertQuestion(questionName);
|
||||
await page.fill(minLengthSelector, "5");
|
||||
await saveQuestion();
|
||||
});
|
||||
await test.step("Add a new field with max characters only", async () => {
|
||||
// Add a new field with max characters only
|
||||
questionName = "Text area with max = 10";
|
||||
await insertQuestion(questionName);
|
||||
await page.fill(maxLengthSelector, "10");
|
||||
await saveQuestion();
|
||||
});
|
||||
|
||||
await test.step("Add a new field with min and max characters where min < max", async () => {
|
||||
// Add a new field with min and max characters where min < max
|
||||
questionName = "Text area with min = 5 & max = 10";
|
||||
await insertQuestion(questionName);
|
||||
await page.fill(minLengthSelector, "5");
|
||||
await page.fill(maxLengthSelector, "10");
|
||||
await saveQuestion();
|
||||
});
|
||||
|
||||
await test.step("Add a new field with min and max characters where min > max", async () => {
|
||||
// Add a new field with min and max characters where min > max
|
||||
questionName = "Text area with min = 10 & max = 5";
|
||||
await insertQuestion(questionName);
|
||||
await page.fill(minLengthSelector, "10");
|
||||
await page.fill(maxLengthSelector, "5");
|
||||
await saveQuestion();
|
||||
});
|
||||
|
||||
await test.step("Try with different inputs and check for validation", async () => {
|
||||
// Expect the native <input> element to show an error message
|
||||
|
||||
let validationMessage = await minInput?.evaluate((input: any) => input?.validationMessage as string);
|
||||
expect(validationMessage?.toString()).toBe("Value must be less than or equal to 5.");
|
||||
|
||||
await page.fill(minLengthSelector, "0");
|
||||
await page.fill(maxLengthSelector, "100000");
|
||||
await saveQuestion();
|
||||
// Expect the native <input> element to show an error message
|
||||
|
||||
validationMessage = await maxInput?.evaluate((input: any) => input?.validationMessage as string);
|
||||
|
||||
expect(validationMessage?.toString()).toBe(
|
||||
`Value must be less than or equal to ${MAX_LENGTH || 1000}.`
|
||||
);
|
||||
await cancelQuestion();
|
||||
// Save the event type
|
||||
await page.locator("[data-testid=update-eventtype]").click();
|
||||
|
||||
// Get the url of data-testid="preview-button"
|
||||
const previewButton = await page.locator('[data-testid="preview-button"]');
|
||||
const previewButtonHref = (await previewButton.getAttribute("href")) ?? "";
|
||||
await page.goto(previewButtonHref);
|
||||
|
||||
// wait until the button with data-testid="time" is visible
|
||||
await page.locator('[data-testid="time"]').isVisible();
|
||||
|
||||
// Get first button with data-testid="time"
|
||||
const timeButton = page.locator('[data-testid="time"]').first();
|
||||
await timeButton.click();
|
||||
|
||||
await page.locator('text="Additional notes"');
|
||||
// Form fields:
|
||||
const textAreaWithoutMinMax = page.locator('[name="Text-area-without-min---max"]');
|
||||
const textAreaWithMin5 = page.locator('[name="Text-area-with-min---5"]');
|
||||
const textAreaWithMax10 = page.locator('[name="Text-area-with-max---10"]');
|
||||
const textAreaWithMin5Max10 = page.locator('[name="Text-area-with-min---5---max---10"]');
|
||||
|
||||
// Get button with data-testid="confirm-book-button"
|
||||
const submitForm = async () => await page.locator('[data-testid="confirm-book-button"]').click();
|
||||
await textAreaWithoutMinMax.fill("1234567890");
|
||||
await textAreaWithMin5.fill("1234");
|
||||
await textAreaWithMax10.fill("12345678901");
|
||||
await textAreaWithMin5Max10.fill("1234");
|
||||
await submitForm();
|
||||
// Expect the text: Min. 5 characters to be visible
|
||||
expect(await page.locator(`text=Min. 5 characters required`).isVisible()).toBe(true);
|
||||
|
||||
// update the text area with min 5 to have 5 characters
|
||||
await textAreaWithMin5.fill("12345");
|
||||
await submitForm();
|
||||
|
||||
// Expect the text: Min. 5 characters to still be visible because textAreaWithMin5Max10 has less than 5 characters
|
||||
expect(await page.locator(`text=Min. 5 characters required`).isVisible()).toBe(true);
|
||||
|
||||
// Expect the text: Max. 10 characters to be visible and have value 1234567890
|
||||
expect(await textAreaWithMax10.inputValue()).toBe("1234567890");
|
||||
await submitForm();
|
||||
|
||||
// update the text area with min 5 and max 10 to have 6 characters
|
||||
await textAreaWithMin5Max10.fill("123456");
|
||||
await submitForm();
|
||||
|
||||
// Expect the text: Max. 5 characters to be hidden
|
||||
expect(await page.locator(`text=Min. 5 characters required`).isVisible()).toBe(false);
|
||||
|
||||
await expect(page.locator('text="This meeting is scheduled"')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"second_other": "{{count}} seconds",
|
||||
"upgrade_now": "Upgrade now",
|
||||
"accept_invitation": "Accept Invitation",
|
||||
"max_characters": "Max. Characters",
|
||||
"min_characters": "Min. Characters",
|
||||
"calcom_explained": "{{appName}} provides scheduling infrastructure for absolutely everyone.",
|
||||
"calcom_explained_new_user": "Finish setting up your {{appName}} account! You’re just a few steps away from solving all your scheduling problems.",
|
||||
"have_any_questions": "Have questions? We're here to help.",
|
||||
|
||||
@@ -41,6 +41,10 @@ type RhfFormFields = RhfForm["fields"];
|
||||
|
||||
type RhfFormField = RhfFormFields[number];
|
||||
|
||||
function getCurrentFieldType(fieldForm: UseFormReturn<RhfFormField>) {
|
||||
return fieldTypesConfigMap[fieldForm.watch("type") || "text"];
|
||||
}
|
||||
|
||||
/**
|
||||
* It works with a react-hook-form only.
|
||||
* `formProp` specifies the name of the property in the react-hook-form that has the fields. This is where fields would be updated.
|
||||
@@ -433,7 +437,7 @@ function FieldEditDialog({
|
||||
fieldForm.setValue("variantsConfig", variantsConfig);
|
||||
}, [fieldForm]);
|
||||
const isFieldEditMode = !!dialog.data;
|
||||
const fieldType = fieldTypesConfigMap[fieldForm.watch("type") || "text"];
|
||||
const fieldType = getCurrentFieldType(fieldForm);
|
||||
|
||||
const variantsConfig = fieldForm.watch("variantsConfig");
|
||||
|
||||
@@ -493,6 +497,7 @@ function FieldEditDialog({
|
||||
containerClassName="mt-6"
|
||||
label={t("label")}
|
||||
/>
|
||||
|
||||
{fieldType?.isTextType ? (
|
||||
<InputField
|
||||
{...fieldForm.register("placeholder")}
|
||||
@@ -509,6 +514,11 @@ function FieldEditDialog({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!!fieldType?.supportsLengthCheck ? (
|
||||
<FieldWithLengthCheckSupport containerClassName="mt-6" fieldForm={fieldForm} />
|
||||
) : null}
|
||||
|
||||
<Controller
|
||||
name="required"
|
||||
control={fieldForm.control}
|
||||
@@ -550,6 +560,64 @@ function FieldEditDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function FieldWithLengthCheckSupport({
|
||||
fieldForm,
|
||||
containerClassName = "",
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
fieldForm: UseFormReturn<RhfFormField>;
|
||||
containerClassName?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const { t } = useLocale();
|
||||
const fieldType = getCurrentFieldType(fieldForm);
|
||||
if (!fieldType.supportsLengthCheck) {
|
||||
return null;
|
||||
}
|
||||
const supportsLengthCheck = fieldType.supportsLengthCheck;
|
||||
const maxAllowedMaxLength = supportsLengthCheck.maxLength;
|
||||
|
||||
return (
|
||||
<div className={classNames("grid grid-cols-2 gap-4", className)} {...rest}>
|
||||
<InputField
|
||||
{...fieldForm.register("minLength", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
defaultValue={0}
|
||||
containerClassName={containerClassName}
|
||||
label={t("min_characters")}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
fieldForm.setValue("minLength", parseInt(e.target.value ?? 0));
|
||||
// Ensure that maxLength field adjusts its restrictions
|
||||
fieldForm.trigger("maxLength");
|
||||
}}
|
||||
min={0}
|
||||
max={fieldForm.getValues("maxLength") || maxAllowedMaxLength}
|
||||
/>
|
||||
<InputField
|
||||
{...fieldForm.register("maxLength", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
defaultValue={maxAllowedMaxLength}
|
||||
containerClassName={containerClassName}
|
||||
label={t("max_characters")}
|
||||
type="number"
|
||||
onChange={(e) => {
|
||||
if (!supportsLengthCheck) {
|
||||
return;
|
||||
}
|
||||
fieldForm.setValue("maxLength", parseInt(e.target.value ?? maxAllowedMaxLength));
|
||||
// Ensure that minLength field adjusts its restrictions
|
||||
fieldForm.trigger("minLength");
|
||||
}}
|
||||
min={fieldForm.getValues("minLength") || 0}
|
||||
max={maxAllowedMaxLength}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the label of the field, taking into account the current variant selected
|
||||
*/
|
||||
|
||||
@@ -230,6 +230,8 @@ export const ComponentForField = ({
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
<componentConfig.factory
|
||||
placeholder={field.placeholder}
|
||||
minLength={field.minLength}
|
||||
maxLength={field.maxLength}
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
readOnly={readOnly}
|
||||
|
||||
@@ -104,6 +104,10 @@ const configMap: Record<FieldType, Omit<z.infer<typeof fieldTypeConfigSchema>, "
|
||||
label: "Long Text",
|
||||
value: "textarea",
|
||||
isTextType: true,
|
||||
supportsLengthCheck: {
|
||||
// Keep it as small as possible. It is easier to change to a higher value but coming back to a lower value(due to any reason) would be problematic for users who have saved higher value.
|
||||
maxLength: 1000,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
label: "Select",
|
||||
|
||||
@@ -83,6 +83,21 @@ const baseFieldSchema = z.object({
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
|
||||
/**
|
||||
* It is the minimum number of characters that can be entered in the field.
|
||||
* It is used for types with `supportsLengthCheck= true`.
|
||||
* @default 0
|
||||
* @requires supportsLengthCheck = true
|
||||
*/
|
||||
minLength: z.number().optional(),
|
||||
|
||||
/**
|
||||
* It is the maximum number of characters that can be entered in the field.
|
||||
* It is used for types with `supportsLengthCheck= true`.
|
||||
* @requires supportsLengthCheck = true
|
||||
*/
|
||||
maxLength: z.number().optional(),
|
||||
});
|
||||
|
||||
export const variantsConfigSchema = z.object({
|
||||
@@ -116,6 +131,11 @@ export const fieldTypeConfigSchema = z
|
||||
isTextType: z.boolean().default(false).optional(),
|
||||
systemOnly: z.boolean().default(false).optional(),
|
||||
needsOptions: z.boolean().default(false).optional(),
|
||||
supportsLengthCheck: z
|
||||
.object({
|
||||
maxLength: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
propsType: z.enum([
|
||||
"text",
|
||||
"textList",
|
||||
@@ -233,7 +253,7 @@ export const fieldTypesSchemaMap: Partial<
|
||||
*/
|
||||
preprocess: (data: {
|
||||
field: z.infer<typeof fieldSchema>;
|
||||
response: any;
|
||||
response: string;
|
||||
isPartialSchema: boolean;
|
||||
}) => unknown;
|
||||
/**
|
||||
@@ -243,7 +263,7 @@ export const fieldTypesSchemaMap: Partial<
|
||||
*/
|
||||
superRefine: (data: {
|
||||
field: z.infer<typeof fieldSchema>;
|
||||
response: any;
|
||||
response: string;
|
||||
isPartialSchema: boolean;
|
||||
ctx: z.RefinementCtx;
|
||||
m: (key: string) => string;
|
||||
@@ -318,6 +338,36 @@ export const fieldTypesSchemaMap: Partial<
|
||||
});
|
||||
},
|
||||
},
|
||||
textarea: {
|
||||
preprocess: ({ response }) => {
|
||||
return response.trim();
|
||||
},
|
||||
superRefine: ({ field, response, ctx, m }) => {
|
||||
const fieldTypeConfig = fieldTypesConfigMap[field.type];
|
||||
const value = response ?? "";
|
||||
const maxLength = field.maxLength ?? fieldTypeConfig.supportsLengthCheck?.maxLength;
|
||||
const minLength = field.minLength ?? 0;
|
||||
if (!maxLength) {
|
||||
throw new Error("maxLength must be there for textarea field");
|
||||
}
|
||||
const hasExceededMaxLength = value.length > maxLength;
|
||||
const hasNotReachedMinLength = value.length < minLength;
|
||||
if (hasExceededMaxLength) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: m(`Max. ${maxLength} characters allowed`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hasNotReachedMinLength) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: m(`Min. ${minLength} characters required`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user