feat: error message for invalid variables in custom event name (#7426)
* feat: add custom validate name util * refactor: separate custom event type modal into a different component * feat: add validation to zod * chore: add i18n key * feat: add dynamic imports * fix: padding * Omit cache-hit exit 1, assuming it'll fail regardless --------- Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
co-authored by
Alex van Andel
CarinaWolli
parent
8a9b985760
commit
3d41c64d81
@@ -0,0 +1,144 @@
|
||||
import type { FC } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { FormProvider } from "react-hook-form";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import { validateCustomEventName } from "@calcom/core/event";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogClose, DialogFooter, DialogContent, TextField } from "@calcom/ui";
|
||||
|
||||
interface FormValues {
|
||||
customEventName: string;
|
||||
}
|
||||
|
||||
interface CustomEventTypeModalFormProps {
|
||||
placeHolder: string;
|
||||
close: () => void;
|
||||
setValue: (value: string) => void;
|
||||
event: EventNameObjectType;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
|
||||
const { t } = useLocale();
|
||||
const { placeHolder, close, setValue, event } = props;
|
||||
const { register, handleSubmit, watch, getValues } = useFormContext<FormValues>();
|
||||
const onSubmit: SubmitHandler<FormValues> = (data) => {
|
||||
setValue(data.customEventName);
|
||||
close();
|
||||
};
|
||||
|
||||
// const customEventName = watch("customEventName");
|
||||
const previewText = getEventName({ ...event, eventName: watch("customEventName") });
|
||||
const placeHolder_ = watch("customEventName") === "" ? previewText : placeHolder;
|
||||
|
||||
return (
|
||||
<form
|
||||
id="custom-event-name"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const isEmpty = getValues("customEventName") === "";
|
||||
if (isEmpty) {
|
||||
setValue("");
|
||||
}
|
||||
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}>
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
placeholder={placeHolder_}
|
||||
{...register("customEventName", {
|
||||
validate: (value) => validateCustomEventName(value, t("invalid_event_name_variables")),
|
||||
})}
|
||||
className="mb-0"
|
||||
/>
|
||||
<div className="pt-6 text-sm">
|
||||
<div className="mb-6 rounded-md bg-gray-100 p-2">
|
||||
<h1 className="mb-2 ml-1 font-medium text-gray-900">{t("available_variables")}</h1>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Event type title}`}</p>
|
||||
<p className="text-gray-900">{t("event_name_info")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Organiser}`}</p>
|
||||
<p className="text-gray-900">{t("your_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Scheduler}`}</p>
|
||||
<p className="text-gray-900">{t("scheduler_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-1 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Location}`}</p>
|
||||
<p className="text-gray-900">{t("location_info")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||
<div
|
||||
className="flex h-[212px] w-full rounded-md border-y bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: "url(/calendar-preview.svg)",
|
||||
}}>
|
||||
<div className="m-auto flex items-center justify-center self-stretch">
|
||||
<div className="mt-3 ml-11 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black bg-gray-100 text-[12px] leading-3">
|
||||
<p className="overflow-hidden text-ellipsis p-1.5 font-medium text-gray-900">{previewText}</p>
|
||||
<p className="ml-1.5 text-[10px] font-normal text-gray-600">8 - 10 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface CustomEventTypeModalProps {
|
||||
placeHolder: string;
|
||||
defaultValue: string;
|
||||
close: () => void;
|
||||
setValue: (value: string) => void;
|
||||
event: EventNameObjectType;
|
||||
}
|
||||
|
||||
const CustomEventTypeModal: FC<CustomEventTypeModalProps> = (props) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { defaultValue, placeHolder, close, setValue, event } = props;
|
||||
|
||||
const methods = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
customEventName: defaultValue,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={close}>
|
||||
<DialogContent
|
||||
title={t("custom_event_name")}
|
||||
description={t("custom_event_name_description")}
|
||||
type="creation"
|
||||
enableOverflow>
|
||||
<FormProvider {...methods}>
|
||||
<CustomEventTypeModalForm
|
||||
event={event}
|
||||
close={close}
|
||||
setValue={setValue}
|
||||
placeHolder={placeHolder}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</FormProvider>
|
||||
<DialogFooter>
|
||||
<DialogClose>{t("cancel")}</DialogClose>
|
||||
|
||||
<Button form="custom-event-name" type="submit" color="primary">
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomEventTypeModal;
|
||||
@@ -1,3 +1,4 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -12,24 +13,13 @@ import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { APP_NAME, CAL_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Label,
|
||||
SettingsToggle,
|
||||
showToast,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui";
|
||||
import { Badge, Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip } from "@calcom/ui";
|
||||
import { FiEdit, FiCopy } from "@calcom/ui/components/icon";
|
||||
|
||||
import RequiresConfirmationController from "./RequiresConfirmationController";
|
||||
|
||||
const CustomEventTypeModal = dynamic(() => import("@components/eventtype/CustomEventTypeModal"));
|
||||
|
||||
const generateHashedLink = (id: number) => {
|
||||
const translator = short();
|
||||
const seed = `${id}:${new Date().getTime()}`;
|
||||
@@ -53,21 +43,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
host: eventType.users[0]?.name || "Nameless",
|
||||
t,
|
||||
};
|
||||
const [previewText, setPreviewText] = useState(getEventName(eventNameObject));
|
||||
|
||||
const [requiresConfirmation, setRequiresConfirmation] = useState(eventType.requiresConfirmation);
|
||||
const placeholderHashedLink = `${CAL_URL}/d/${hashedUrl}/${eventType.slug}`;
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
|
||||
const replaceEventNamePlaceholder = (eventNameObject: EventNameObjectType, previewEventName: string) =>
|
||||
previewEventName
|
||||
.replace("{Event type title}", eventNameObject.eventType)
|
||||
.replace("{Scheduler}", eventNameObject.attendeeName)
|
||||
.replace("{Organiser}", eventNameObject.host);
|
||||
|
||||
const changePreviewText = (eventNameObject: EventNameObjectType, previewEventName: string) => {
|
||||
setPreviewText(replaceEventNamePlaceholder(eventNameObject, previewEventName));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(eventType.users[0]?.id ?? team?.id));
|
||||
}, [eventType.users, hashedUrl, team?.id]);
|
||||
@@ -88,8 +68,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
);
|
||||
};
|
||||
|
||||
const eventNamePlaceholder = replaceEventNamePlaceholder(eventNameObject, t("meeting_with_user"));
|
||||
const eventNamePlaceholder = getEventName({
|
||||
...eventNameObject,
|
||||
eventName: formMethods.watch("eventName"),
|
||||
});
|
||||
|
||||
const closeEventNameTip = () => setShowEventNameTip(false);
|
||||
|
||||
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
||||
return (
|
||||
<div className="flex flex-col space-y-8">
|
||||
{/**
|
||||
@@ -132,14 +118,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
type="text"
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName", {
|
||||
onChange: (e) => {
|
||||
if (!e.target.value || !formMethods.getValues("eventName")) {
|
||||
return setPreviewText(getEventName(eventNameObject));
|
||||
}
|
||||
changePreviewText(eventNameObject, e.target.value);
|
||||
},
|
||||
})}
|
||||
{...formMethods.register("eventName")}
|
||||
addOnSuffix={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -148,6 +127,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
color="minimal"
|
||||
className="hover:stroke-3 min-w-fit px-0 hover:bg-transparent hover:text-black"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}
|
||||
aria-label="edit custom name"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -325,73 +305,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
/>
|
||||
|
||||
{showEventNameTip && (
|
||||
<Dialog open={showEventNameTip} onOpenChange={setShowEventNameTip}>
|
||||
<DialogContent
|
||||
title={t("custom_event_name")}
|
||||
description={t("custom_event_name_description")}
|
||||
type="creation"
|
||||
enableOverflow>
|
||||
<TextField
|
||||
label={t("event_name_in_calendar")}
|
||||
type="text"
|
||||
placeholder={eventNamePlaceholder}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
{...formMethods.register("eventName", {
|
||||
onChange: (e) => {
|
||||
if (!e.target.value || !formMethods.getValues("eventName")) {
|
||||
return setPreviewText(getEventName(eventNameObject));
|
||||
}
|
||||
changePreviewText(eventNameObject, e.target.value);
|
||||
},
|
||||
})}
|
||||
className="mb-0"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<div className="mb-6 rounded-md bg-gray-100 p-2">
|
||||
<h1 className="mb-2 ml-1 font-medium text-gray-900">{t("available_variables")}</h1>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Event type title}`}</p>
|
||||
<p className="text-gray-900">{t("event_name_info")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Organiser}`}</p>
|
||||
<p className="text-gray-900">{t("your_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-2.5 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Scheduler}`}</p>
|
||||
<p className="text-gray-900">{t("scheduler_full_name")}</p>
|
||||
</div>
|
||||
<div className="mb-1 flex font-normal">
|
||||
<p className="ml-1 mr-5 w-28 text-gray-400">{`{Location}`}</p>
|
||||
<p className="text-gray-900">{t("location_info")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-2 text-[14px] font-medium leading-4">{t("preview")}</h1>
|
||||
<div
|
||||
className="flex h-[212px] w-full rounded-md border-y bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: "url(/calendar-preview.svg)",
|
||||
}}>
|
||||
<div className="m-auto flex items-center justify-center self-stretch">
|
||||
<div className="mt-3 ml-11 box-border h-[110px] w-[120px] flex-col items-start gap-1 rounded-md border border-solid border-black bg-gray-100 text-[12px] leading-3">
|
||||
<p className="overflow-hidden text-ellipsis p-1.5 font-medium text-gray-900">
|
||||
{previewText}
|
||||
</p>
|
||||
<p className="ml-1.5 text-[10px] font-normal text-gray-600">8 - 10 AM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose onClick={() => formMethods.setValue("eventName", eventType.eventName ?? "")}>
|
||||
{t("cancel")}
|
||||
</DialogClose>
|
||||
<Button color="primary" onClick={() => setShowEventNameTip(false)}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CustomEventTypeModal
|
||||
close={closeEventNameTip}
|
||||
setValue={setEventName}
|
||||
defaultValue={eventType.eventName || ""}
|
||||
placeHolder={eventNamePlaceholder}
|
||||
event={eventNameObject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { validateCustomEventName } from "@calcom/core/event";
|
||||
import type { EventLocationType } from "@calcom/core/location";
|
||||
import { validateBookingLimitOrder } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
@@ -218,6 +219,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
.object({
|
||||
// Length if string, is converted to a number or it can be a number
|
||||
// Make it optional because it's not submitted from all tabs of the page
|
||||
eventName: z
|
||||
.string()
|
||||
.refine((val) => validateCustomEventName(val, t("invalid_event_name_variables")) === true, {
|
||||
message: t("invalid_event_name_variables"),
|
||||
})
|
||||
.optional(),
|
||||
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||
bookingFields: eventTypeBookingFields,
|
||||
})
|
||||
|
||||
@@ -1635,8 +1635,8 @@
|
||||
"this_will_be_the_placeholder": "This will be the placeholder",
|
||||
"verification_code": "Verification code",
|
||||
"verify": "Verify",
|
||||
"invalid_event_name_variables": "There is an invalid variable in your event name",
|
||||
"select_all":"Select All",
|
||||
"default_conferncing_bulk_title":"Bulk update existing event types",
|
||||
"default_conferncing_bulk_description":"Update the locations for the selected event types"
|
||||
|
||||
}
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
import { TFunction } from "next-i18next";
|
||||
import type { TFunction } from "next-i18next";
|
||||
|
||||
import { guessEventLocationType } from "@calcom/app-store/locations";
|
||||
|
||||
@@ -43,3 +43,17 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
|
||||
.replace("{HOST/ATTENDEE}", forAttendeeView ? eventNameObj.host : eventNameObj.attendeeName)
|
||||
);
|
||||
}
|
||||
|
||||
export const validateCustomEventName = (value: string, message: string) => {
|
||||
const validVariables = ["{Event type title}", "{Organiser}", "{Scheduler}", "{Location}"];
|
||||
const matches = value.match(/\{([^}]+)\}/g);
|
||||
if (matches?.length) {
|
||||
for (const item of matches) {
|
||||
if (!validVariables.includes(item)) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user