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:
Vamshi Maskuri
2024-07-30 12:21:27 +00:00
committed by GitHub
co-authored by Ayush Mhetre Hariom supalarry
parent 40df4cf511
commit b8ba95dbb0
6 changed files with 288 additions and 3 deletions
@@ -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! Youre just a few steps away from solving all your scheduling problems.",
"have_any_questions": "Have questions? We're here to help.",
+69 -1
View File
@@ -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",
+52 -2
View File
@@ -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;
}
},
},
};
/**