From b8ba95dbb06f99372aa6fdfc3238da0ab9af6fd5 Mon Sep 17 00:00:00 2001 From: Vamshi Maskuri <117595548+varshith257@users.noreply.github.com> Date: Tue, 30 Jul 2024 17:51:27 +0530 Subject: [PATCH] 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 Co-authored-by: Hariom Co-authored-by: supalarry --- .../manage-booking-questions.e2e.ts | 159 ++++++++++++++++++ apps/web/public/static/locales/en/common.json | 2 + .../features/form-builder/FormBuilder.tsx | 70 +++++++- .../form-builder/FormBuilderField.tsx | 2 + packages/features/form-builder/fieldTypes.ts | 4 + packages/features/form-builder/schema.ts | 54 +++++- 6 files changed, 288 insertions(+), 3 deletions(-) diff --git a/apps/web/playwright/manage-booking-questions.e2e.ts b/apps/web/playwright/manage-booking-questions.e2e.ts index 988f35a12d..12e3a624e9 100644 --- a/apps/web/playwright/manage-booking-questions.e2e.ts +++ b/apps/web/playwright/manage-booking-questions.e2e.ts @@ -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 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 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(); + }); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 0c16386739..992194227d 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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.", diff --git a/packages/features/form-builder/FormBuilder.tsx b/packages/features/form-builder/FormBuilder.tsx index edba85db87..14fbfca75a 100644 --- a/packages/features/form-builder/FormBuilder.tsx +++ b/packages/features/form-builder/FormBuilder.tsx @@ -41,6 +41,10 @@ type RhfFormFields = RhfForm["fields"]; type RhfFormField = RhfFormFields[number]; +function getCurrentFieldType(fieldForm: UseFormReturn) { + 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 ? ( ) : null} + + {!!fieldType?.supportsLengthCheck ? ( + + ) : null} + ; + containerClassName?: string; +} & React.HTMLAttributes) { + const { t } = useLocale(); + const fieldType = getCurrentFieldType(fieldForm); + if (!fieldType.supportsLengthCheck) { + return null; + } + const supportsLengthCheck = fieldType.supportsLengthCheck; + const maxAllowedMaxLength = supportsLengthCheck.maxLength; + + return ( +
+ { + 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} + /> + { + 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} + /> +
+ ); +} + /** * Shows the label of the field, taking into account the current variant selected */ diff --git a/packages/features/form-builder/FormBuilderField.tsx b/packages/features/form-builder/FormBuilderField.tsx index 26b9960ef2..e3699dd642 100644 --- a/packages/features/form-builder/FormBuilderField.tsx +++ b/packages/features/form-builder/FormBuilderField.tsx @@ -230,6 +230,8 @@ export const ComponentForField = ({ , " 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", diff --git a/packages/features/form-builder/schema.ts b/packages/features/form-builder/schema.ts index 859095c296..f94d145b93 100644 --- a/packages/features/form-builder/schema.ts +++ b/packages/features/form-builder/schema.ts @@ -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; - response: any; + response: string; isPartialSchema: boolean; }) => unknown; /** @@ -243,7 +263,7 @@ export const fieldTypesSchemaMap: Partial< */ superRefine: (data: { field: z.infer; - 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; + } + }, + }, }; /**