Allow editing workflow templates (#8028)

* add event end time as variable

* add timezone as new variable

* add first version of template prefill

* set template body when template is updated

* set reminder template body and subject when creating workflow

* set email subject when changes templates

* save emailBody and emailsubject for all templates + fix duplicate template text

* add more flexibility for templates

* remove console.log

* fix {ORAGANIZER} and {ATTENDEE} variable

* make sure to always send reminder body and not default template

* fix import

* remove email body text and match variables in templates

* handle translations of formatted variables

* fix email reminder template

* add cancel and reschedule link as variable

* add cancel and reschedule link for scheduled emails/sms

* make sure empty empty body and subject are set for reminder template

* add info message for testing workflow

* fix typo

* add sms template

* add migration to remove reminderBody and emailSubject

* add branding

* code clean up

* add hide branding everywhere

* fix sms reminder template

* set sms reminder template if sms body is empty

* fix custom inputs variables everywhere

* fix variable translations + other small fixes

* fix some type errors

* fix more type errors

* fix everything missing around cron job scheduling

* make sure to always use custom template for sms messages

* fix type error

* code clean up

* rename link to url

* Add debug logs

* Update handleNewBooking.ts

* Add debug logs

* removed unneded responses

* fix booking questions + UI improvements

* remove html email body when changing to sms action

* code clean up + comments

* code clean up

* code clean up

* remove comment

* more clear info message for timezone variable

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: alannnc <alannnc@gmail.com>
This commit is contained in:
Carina Wollendorfer
2023-04-18 11:08:09 +01:00
committed by GitHub
co-authored by CarinaWolli Hariom Balhara zomars alannnc
parent c0bda6257e
commit 0aca769d06
26 changed files with 724 additions and 365 deletions
+14 -5
View File
@@ -1150,13 +1150,13 @@
"current_username": "Current username",
"example_1": "Example 1",
"example_2": "Example 2",
"additional_input_label": "Additional Input Label",
"booking_question_identifier": "Booking Question Identifier",
"company_size": "Company size",
"what_help_needed": "What do you need help with?",
"variable_format": "Variable format",
"webhook_subscriber_url_reserved": "Webhook subscriber url is already defined",
"custom_input_as_variable_info": "Ignore all special characters of the additional input label (use only letters and numbers), use uppercase for all letters and replace whitespaces with underscores.",
"using_additional_inputs_as_variables": "How do I use Additional Inputs as variables?",
"using_booking_questions_as_variables": "How do I use booking questions as variables?",
"download_desktop_app": "Download desktop app",
"set_ping_link": "Set Ping link",
"rate_limit_exceeded": "Rate limit exceeded",
@@ -1382,7 +1382,7 @@
"add_limit": "Add Limit",
"team_name_required": "Team name required",
"show_attendees": "Share attendee information between guests",
"how_additional_inputs_as_variables": "How to use Additional Inputs as Variables",
"how_booking_questions_as_variables": "How to use booking questions as variables?",
"format": "Format",
"uppercase_for_letters": "Use uppercase for all letters",
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
@@ -1397,7 +1397,7 @@
"billing_help_title": "Need anything else?",
"billing_help_description": "If you need any further help with billing, our support team are here to help.",
"billing_help_cta": "Contact support",
"ignore_special_characters": "Ignore special characters in your Additional Input label. Use only letters and numbers",
"ignore_special_characters_booking_questions": "Ignore special characters in your booking question identifier. Use only letters and numbers",
"retry": "Retry",
"fetching_calendars_error": "There was a problem fetching your calendars. Please <1>try again</1> or reach out to customer support.",
"calendar_connection_fail": "Calendar connection failed",
@@ -1705,9 +1705,17 @@
"verification_code": "Verification code",
"can_you_try_again": "Can you try again with a different time?",
"verify": "Verify",
"timezone_variable": "Timezone",
"timezone_info": "The timezone of the person receiving",
"event_end_time_variable": "Event end time",
"event_end_time_info": "The event end time",
"cancel_url_variable": "Cancel URL",
"cancel_url_info": "The URL to cancel the booking",
"reschedule_url_variable": "Reschedule URL",
"reschedule_url_info": "The URL to reschedule the booking",
"invalid_event_name_variables": "There is an invalid variable in your event name",
"select_all": "Select All",
"default_conferencing_bulk_title": "Bulk update existing event types",
"invalid_event_name_variables": "There is an invalid variable in your event name",
"members_default_schedule": "Member's default schedule",
"set_by_admin": "Set by team admin",
"members_default_location": "Member's default location",
@@ -1769,6 +1777,7 @@
"collect_no_show_fee": "Collect no-show fee",
"no_show_fee_charged": "No-show fee charged",
"insights": "Insights",
"testing_workflow_info_message": "When testing this workflow, be aware that Emails and SMS can only be scheduled at least 1 hour in advance",
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
"card_details": "Card details",
@@ -1,4 +1,4 @@
import type { WebhookTriggerEvents, WorkflowReminder, Prisma } from "@prisma/client";
import type { Prisma, WebhookTriggerEvents, WorkflowReminder } from "@prisma/client";
import { BookingStatus, MembershipRole, WorkflowMethods } from "@prisma/client";
import type { NextApiRequest } from "next";
@@ -58,6 +58,7 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
paid: true,
eventType: {
select: {
slug: true,
owner: true,
teamId: true,
recurringEvent: true,
@@ -334,7 +335,11 @@ async function handler(req: CustomRequest) {
await sendCancelledReminders({
workflows: bookingToDelete.eventType?.workflows,
smsReminderNumber: bookingToDelete.smsReminderNumber,
evt,
evt: {
...evt,
...{ eventType: { slug: bookingToDelete.eventType.slug } },
},
hideBranding: !!bookingToDelete.eventType.owner?.hideBranding,
});
}
@@ -1,4 +1,4 @@
import type { PrismaClient, Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
import type { Prisma, PrismaClient, Workflow, WorkflowsOnEventTypes, WorkflowStep } from "@prisma/client";
import { BookingStatus, WebhookTriggerEvents } from "@prisma/client";
import { scheduleTrigger } from "@calcom/app-store/zapier/lib/nodeScheduler";
@@ -10,6 +10,7 @@ import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import logger from "@calcom/lib/logger";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { AdditionalInformation, CalendarEvent } from "@calcom/types/Calendar";
const log = logger.getChildLogger({ prefix: ["[handleConfirmation] book:user"] });
@@ -66,11 +67,24 @@ export async function handleConfirmation(args: {
let updatedBookings: {
scheduledJobs: string[];
id: number;
description: string | null;
location: string | null;
attendees: {
name: string;
email: string;
}[];
startTime: Date;
endTime: Date;
uid: string;
smsReminderNumber: string | null;
metadata: Prisma.JsonValue | null;
customInputs: Prisma.JsonValue;
eventType: {
bookingFields: Prisma.JsonValue | null;
slug: string;
owner: {
hideBranding?: boolean | null;
} | null;
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
steps: WorkflowStep[];
@@ -104,6 +118,13 @@ export async function handleConfirmation(args: {
select: {
eventType: {
select: {
slug: true,
bookingFields: true,
owner: {
select: {
hideBranding: true,
},
},
workflows: {
include: {
workflow: {
@@ -115,15 +136,21 @@ export async function handleConfirmation(args: {
},
},
},
description: true,
attendees: true,
location: true,
uid: true,
startTime: true,
metadata: true,
endTime: true,
smsReminderNumber: true,
customInputs: true,
id: true,
scheduledJobs: true,
},
})
);
const updatedBookingsResult = await Promise.all(updateBookingsPromise);
updatedBookings = updatedBookings.concat(updatedBookingsResult);
} else {
@@ -142,6 +169,13 @@ export async function handleConfirmation(args: {
select: {
eventType: {
select: {
slug: true,
bookingFields: true,
owner: {
select: {
hideBranding: true,
},
},
workflows: {
include: {
workflow: {
@@ -155,8 +189,13 @@ export async function handleConfirmation(args: {
},
uid: true,
startTime: true,
metadata: true,
endTime: true,
smsReminderNumber: true,
description: true,
attendees: true,
location: true,
customInputs: true,
id: true,
scheduledJobs: true,
},
@@ -172,14 +211,21 @@ export async function handleConfirmation(args: {
evtOfBooking.startTime = updatedBookings[index].startTime.toISOString();
evtOfBooking.endTime = updatedBookings[index].endTime.toISOString();
evtOfBooking.uid = updatedBookings[index].uid;
const eventTypeSlug = updatedBookings[index].eventType?.slug || "";
const isFirstBooking = index === 0;
const videoCallUrl =
bookingMetadataSchema.parse(updatedBookings[index].metadata || {})?.videoCallUrl || "";
await scheduleWorkflowReminders({
workflows: updatedBookings[index]?.eventType?.workflows || [],
smsReminderNumber: updatedBookings[index].smsReminderNumber,
calendarEvent: evtOfBooking,
calendarEvent: {
...evtOfBooking,
...{ metadata: { videoCallUrl }, eventType: { slug: eventTypeSlug } },
},
isFirstRecurringEvent: isFirstBooking,
hideBranding: !!updatedBookings[index].eventType?.owner?.hideBranding,
});
}
}
@@ -198,6 +198,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
customInputs: true,
disableGuests: true,
users: userSelect,
slug: true,
team: {
select: {
id: true,
@@ -227,6 +228,11 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
seatsShowAttendees: true,
bookingLimits: true,
durationLimits: true,
owner: {
select: {
hideBranding: true,
},
},
workflows: {
include: {
workflow: {
@@ -800,6 +806,7 @@ async function handler(
id: organizerUser.id,
name: organizerUser.name || "Nameless",
email: organizerUser.email || "Email-less",
username: organizerUser.username || undefined,
timeZone: organizerUser.timeZone,
language: { translate: tOrganizer, locale: organizerUser.locale ?? "en" },
timeFormat: organizerUser.timeFormat === 24 ? TimeFormat.TWENTY_FOUR_HOUR : TimeFormat.TWELVE_HOUR,
@@ -1441,7 +1448,7 @@ async function handler(
await scheduleWorkflowReminders({
workflows: eventType.workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, responses, ...{ metadata } },
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
requiresConfirmation: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
@@ -2058,10 +2065,14 @@ async function handler(
await scheduleWorkflowReminders({
workflows: eventType.workflows,
smsReminderNumber: smsReminderNumber || null,
calendarEvent: { ...evt, responses, ...{ metadata: metadataFromEvent } },
calendarEvent: {
...evt,
...{ metadata: metadataFromEvent, eventType: { slug: eventType.slug } },
},
requiresConfirmation: evt.requiresConfirmation || false,
isRescheduleEvent: !!rescheduleUid,
isFirstRecurringEvent: true,
hideBranding: !!eventType.owner?.hideBranding,
});
} catch (error) {
log.error("Error while scheduling workflow reminders", error);
@@ -5,6 +5,7 @@ import sgMail from "@sendgrid/mail";
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { defaultHandler } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -164,51 +165,63 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
let emailContent = {
emailSubject: reminder.workflowStep.emailSubject || "",
emailBody: {
text: reminder.workflowStep.reminderBody || "",
html: `<body style="white-space: pre-wrap;">${reminder.workflowStep.reminderBody || ""}</body>`,
},
emailBody: `<body style="white-space: pre-wrap;">${reminder.workflowStep.reminderBody || ""}</body>`,
};
switch (reminder.workflowStep.template) {
case WorkflowTemplates.REMINDER:
emailContent = emailReminderTemplate(
reminder.booking.startTime.toISOString() || "",
reminder.booking.endTime.toISOString() || "",
reminder.booking.eventType?.title || "",
timeZone || "",
attendeeName || "",
name || ""
);
break;
case WorkflowTemplates.CUSTOM:
const variables: VariablesType = {
eventName: reminder.booking?.eventType?.title || "",
organizerName: reminder.booking.user?.name || "",
attendeeName: reminder.booking.attendees[0].name,
attendeeEmail: reminder.booking.attendees[0].email,
eventDate: dayjs(reminder.booking.startTime).tz(timeZone),
eventTime: dayjs(reminder.booking.startTime).tz(timeZone),
timeZone: timeZone,
location: reminder.booking.location || "",
additionalNotes: reminder.booking.description,
responses: reminder.booking.responses,
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl,
};
const emailSubject = await customTemplate(
reminder.workflowStep.emailSubject || "",
variables,
locale || ""
);
emailContent.emailSubject = emailSubject.text;
emailContent.emailBody = await customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
locale || ""
);
break;
let emailBodyEmpty = false;
if (reminder.workflowStep.reminderBody) {
const { responses } = getCalEventResponses({
bookingFields: reminder.booking.eventType?.bookingFields ?? null,
booking: reminder.booking,
});
const variables: VariablesType = {
eventName: reminder.booking.eventType?.title || "",
organizerName: reminder.booking.user?.name || "",
attendeeName: reminder.booking.attendees[0].name,
attendeeEmail: reminder.booking.attendees[0].email,
eventDate: dayjs(reminder.booking.startTime).tz(timeZone),
eventEndTime: dayjs(reminder.booking?.endTime).tz(timeZone),
timeZone: timeZone,
location: reminder.booking.location || "",
additionalNotes: reminder.booking.description,
responses: responses,
meetingUrl: bookingMetadataSchema.parse(reminder.booking.metadata || {})?.videoCallUrl,
cancelLink: `/booking/${reminder.booking.uid}?cancel=true`,
rescheduleLink: `/${reminder.booking.user?.username}/${reminder.booking.eventType?.slug}?rescheduleUid=${reminder.booking.uid}`,
};
const emailSubject = customTemplate(
reminder.workflowStep.emailSubject || "",
variables,
locale || "",
!!reminder.booking.user?.hideBranding
).text;
emailContent.emailSubject = emailSubject;
emailContent.emailBody = customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
locale || "",
!!reminder.booking.user?.hideBranding
).html;
emailBodyEmpty =
customTemplate(reminder.workflowStep.reminderBody || "", variables, locale || "").text.length === 0;
} else if (reminder.workflowStep.template === WorkflowTemplates.REMINDER) {
emailContent = emailReminderTemplate(
false,
reminder.workflowStep.action,
reminder.booking.startTime.toISOString() || "",
reminder.booking.endTime.toISOString() || "",
reminder.booking.eventType?.title || "",
timeZone || "",
attendeeName || "",
name || "",
!!reminder.booking.user?.hideBranding
);
}
if (emailContent.emailSubject.length > 0 && emailContent.emailBody.text.length > 0 && sendTo) {
if (emailContent.emailSubject.length > 0 && !emailBodyEmpty && sendTo) {
const batchIdResponse = await client.request({
url: "/v3/mail/batch",
method: "POST",
@@ -224,8 +237,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
name: reminder.workflowStep.sender || "Cal.com",
},
subject: emailContent.emailSubject,
text: emailContent.emailBody.text,
html: emailContent.emailBody.html,
html: emailContent.emailBody,
batchId: batchId,
sendAt: dayjs(reminder.scheduledDate).unix(),
replyTo: reminder.booking.user?.email || senderEmail,
@@ -3,6 +3,7 @@ import { WorkflowActions, WorkflowMethods, WorkflowTemplates } from "@prisma/cli
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { defaultHandler } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -87,38 +88,46 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
: reminder.booking?.user?.locale;
let message: string | null = reminder.workflowStep.reminderBody;
switch (reminder.workflowStep.template) {
case WorkflowTemplates.REMINDER:
message = smsReminderTemplate(
reminder.booking?.startTime.toISOString() || "",
reminder.booking?.eventType?.title || "",
timeZone || "",
attendeeName || "",
userName
);
break;
case WorkflowTemplates.CUSTOM:
const variables: VariablesType = {
eventName: reminder.booking?.eventType?.title,
organizerName: reminder.booking?.user?.name || "",
attendeeName: reminder.booking?.attendees[0].name,
attendeeEmail: reminder.booking?.attendees[0].email,
eventDate: dayjs(reminder.booking?.startTime).tz(timeZone),
eventTime: dayjs(reminder.booking?.startTime).tz(timeZone),
timeZone: timeZone,
location: reminder.booking?.location || "",
additionalNotes: reminder.booking?.description,
responses: reminder.booking?.responses,
meetingUrl: bookingMetadataSchema.parse(reminder.booking?.metadata || {})?.videoCallUrl,
};
const customMessage = await customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
locale || ""
);
message = customMessage.text;
break;
if (reminder.workflowStep.reminderBody) {
const { responses } = getCalEventResponses({
bookingFields: reminder.booking.eventType?.bookingFields ?? null,
booking: reminder.booking,
});
const variables: VariablesType = {
eventName: reminder.booking?.eventType?.title,
organizerName: reminder.booking?.user?.name || "",
attendeeName: reminder.booking?.attendees[0].name,
attendeeEmail: reminder.booking?.attendees[0].email,
eventDate: dayjs(reminder.booking?.startTime).tz(timeZone),
eventEndTime: dayjs(reminder.booking?.endTime).tz(timeZone),
timeZone: timeZone,
location: reminder.booking?.location || "",
additionalNotes: reminder.booking?.description,
responses: responses,
meetingUrl: bookingMetadataSchema.parse(reminder.booking?.metadata || {})?.videoCallUrl,
cancelLink: `/booking/${reminder.booking.uid}?cancel=true`,
rescheduleLink: `/${reminder.booking.user?.username}/${reminder.booking.eventType?.slug}?rescheduleUid=${reminder.booking.uid}`,
};
const customMessage = customTemplate(
reminder.workflowStep.reminderBody || "",
variables,
locale || ""
);
message = customMessage.text;
} else if (reminder.workflowStep.template === WorkflowTemplates.REMINDER) {
message = smsReminderTemplate(
false,
reminder.workflowStep.action,
reminder.booking?.startTime.toISOString() || "",
reminder.booking?.eventType?.title || "",
timeZone || "",
attendeeName || "",
userName
);
}
if (message?.length && message?.length > 0 && sendTo) {
const scheduledSMS = await twilio.scheduleSMS(sendTo, message, reminder.scheduledDate, senderID);
@@ -13,7 +13,7 @@ import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
import { Button, Label, MultiSelectCheckboxes, TextField } from "@calcom/ui";
import { ArrowDown, Trash2 } from "@calcom/ui/components/icon";
import { isSMSAction } from "../lib/isSMSAction";
import { isSMSAction } from "../lib/actionHelperFunctions";
import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog";
import { DeleteDialog } from "./DeleteDialog";
@@ -35,11 +35,13 @@ import {
AddVariablesDropdown,
Input,
} from "@calcom/ui";
import { ArrowDown, MoreHorizontal, Trash2, HelpCircle } from "@calcom/ui/components/icon";
import { ArrowDown, MoreHorizontal, Trash2, HelpCircle, Info } from "@calcom/ui/components/icon";
import { isAttendeeAction, isSMSAction } from "../lib/actionHelperFunctions";
import { DYNAMIC_TEXT_VARIABLES } from "../lib/constants";
import { getWorkflowTemplateOptions, getWorkflowTriggerOptions } from "../lib/getOptions";
import { isSMSAction } from "../lib/isSMSAction";
import emailReminderTemplate from "../lib/reminders/templates/emailReminderTemplate";
import smsReminderTemplate from "../lib/reminders/templates/smsReminderTemplate";
import type { FormValues } from "../pages/workflow";
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
@@ -69,12 +71,14 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
step?.action === WorkflowActions.SMS_NUMBER ? true : false
);
const [updateTemplate, setUpdateTemplate] = useState(false);
const [firstRender, setFirstRender] = useState(true);
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(
step?.action === WorkflowActions.SMS_NUMBER || step?.action === WorkflowActions.SMS_ATTENDEE
? true
: false
);
useEffect(() => {
setNumberVerified(
!!step &&
@@ -86,10 +90,6 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
step?.action === WorkflowActions.EMAIL_ADDRESS ? true : false
);
const [isCustomReminderBodyNeeded, setIsCustomReminderBodyNeeded] = useState(
step?.template === WorkflowTemplates.CUSTOM ? true : false
);
const [isEmailSubjectNeeded, setIsEmailSubjectNeeded] = useState(
step?.action === WorkflowActions.EMAIL_ATTENDEE ||
step?.action === WorkflowActions.EMAIL_HOST ||
@@ -110,6 +110,28 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const triggerOptions = getWorkflowTriggerOptions(t);
const templateOptions = getWorkflowTemplateOptions(t);
if (step && form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.REMINDER) {
if (!form.getValues(`steps.${step.stepNumber - 1}.reminderBody`)) {
if (isSMSAction(form.getValues(`steps.${step.stepNumber - 1}.action`))) {
const smsBody = smsReminderTemplate(true, form.getValues(`steps.${step.stepNumber - 1}.action`));
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, smsBody);
} else {
const reminderBodyTemplate = emailReminderTemplate(
true,
form.getValues(`steps.${step.stepNumber - 1}.action`)
).emailBody;
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, reminderBodyTemplate);
}
}
if (!form.getValues(`steps.${step.stepNumber - 1}.emailSubject`)) {
const subjectTemplate = emailReminderTemplate(
true,
form.getValues(`steps.${step.stepNumber - 1}.action`)
).emailSubject;
form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, subjectTemplate);
}
}
const { ref: emailSubjectFormRef, ...restEmailSubjectForm } = step
? form.register(`steps.${step.stepNumber - 1}.emailSubject`)
: { ref: null, name: "" };
@@ -259,6 +281,10 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<div className="mt-5">
<Label>{showTimeSectionAfter ? t("how_long_after") : t("how_long_before")}</Label>
<TimeTimeUnitInput form={form} />
<div className="mt-1 flex text-gray-500">
<Info className="mr-1 mt-0.5 h-4 w-4" />
<p className="text-sm">{t("testing_workflow_info_message")}</p>
</div>
</div>
)}
</div>
@@ -352,33 +378,71 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(val.value === WorkflowActions.SMS_NUMBER);
setNumberVerified(false);
// email action changes to sms action
if (!isSMSAction(oldValue)) {
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
form.setValue(`steps.${step.stepNumber - 1}.sender`, SENDER_ID);
}
setIsEmailSubjectNeeded(false);
} else {
setIsPhoneNumberNeeded(false);
setIsSenderIdNeeded(false);
setIsEmailAddressNeeded(val.value === WorkflowActions.EMAIL_ADDRESS);
setIsEmailSubjectNeeded(true);
}
if (isSMSAction(oldValue)) {
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
form.setValue(`steps.${step.stepNumber - 1}.senderName`, SENDER_NAME);
if (
form.getValues(`steps.${step.stepNumber - 1}.template`) ===
WorkflowTemplates.REMINDER
) {
if (isSMSAction(val.value) === isSMSAction(oldValue)) {
if (isAttendeeAction(oldValue) !== isAttendeeAction(val.value)) {
const currentReminderBody =
form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || "";
const newReminderBody = currentReminderBody
.replaceAll("{ORGANIZER}", "{PLACEHOLDER}")
.replaceAll("{ATTENDEE}", "{ORGANIZER}")
.replaceAll("{PLACEHOLDER}", "{ATTENDEE}");
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, newReminderBody);
if (!isSMSAction(val.value)) {
const currentEmailSubject =
form.getValues(`steps.${step.stepNumber - 1}.emailSubject`) || "";
const newEmailSubject = isAttendeeAction(val.value)
? currentEmailSubject.replace("{ORGANIZER}", "{ATTENDEE}")
: currentEmailSubject.replace("{ATTENDEE}", "{ORGANIZER}");
form.setValue(
`steps.${step.stepNumber - 1}.emailSubject`,
newEmailSubject || ""
);
}
}
} else {
if (isSMSAction(val.value)) {
form.setValue(
`steps.${step.stepNumber - 1}.reminderBody`,
smsReminderTemplate(true, val.value)
);
} else {
const emailReminderBody = emailReminderTemplate(true, val.value);
form.setValue(
`steps.${step.stepNumber - 1}.reminderBody`,
emailReminderBody.emailBody
);
form.setValue(
`steps.${step.stepNumber - 1}.emailSubject`,
emailReminderBody.emailSubject
);
}
}
}
form.unregister(`steps.${step.stepNumber - 1}.sendTo`);
form.clearErrors(`steps.${step.stepNumber - 1}.sendTo`);
if (
val.value === WorkflowActions.EMAIL_ATTENDEE ||
val.value === WorkflowActions.EMAIL_HOST ||
val.value === WorkflowActions.EMAIL_ADDRESS
) {
setIsEmailSubjectNeeded(true);
} else {
setIsEmailSubjectNeeded(false);
}
form.setValue(`steps.${step.stepNumber - 1}.action`, val.value);
setUpdateTemplate(!updateTemplate);
}
}}
defaultValue={selectedAction}
@@ -478,7 +542,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
)}
</div>
)}
<div className="bg-muted-4 mt-2 rounded-md pt-0">
<div className="bg-muted mt-2 rounded-md p-4 pt-0">
{isSenderIdNeeded ? (
<>
<div className="pt-4">
@@ -528,7 +592,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
</div>
)}
{isEmailAddressNeeded && (
<div className="bg-muted-4 mt-5 rounded-md">
<div className="bg-muted mt-5 rounded-md p-4">
<EmailField
required
label={t("email_address")}
@@ -548,9 +612,37 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
className="text-sm"
onChange={(val) => {
if (val) {
if (val.value === WorkflowTemplates.REMINDER) {
if (isSMSAction(form.getValues(`steps.${step.stepNumber - 1}.action`))) {
form.setValue(
`steps.${step.stepNumber - 1}.reminderBody`,
smsReminderTemplate(
true,
form.getValues(`steps.${step.stepNumber - 1}.action`)
)
);
} else {
form.setValue(
`steps.${step.stepNumber - 1}.reminderBody`,
emailReminderTemplate(
true,
form.getValues(`steps.${step.stepNumber - 1}.action`)
).emailBody
);
form.setValue(
`steps.${step.stepNumber - 1}.emailSubject`,
emailReminderTemplate(
true,
form.getValues(`steps.${step.stepNumber - 1}.action`)
).emailSubject
);
}
} else {
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, "");
form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, "");
}
form.setValue(`steps.${step.stepNumber - 1}.template`, val.value);
const isCustomTemplate = val.value === WorkflowTemplates.CUSTOM;
setIsCustomReminderBodyNeeded(isCustomTemplate);
setUpdateTemplate(!updateTemplate);
}
}}
defaultValue={selectedTemplate}
@@ -560,98 +652,100 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
}}
/>
</div>
{isCustomReminderBodyNeeded && (
<div className="bg-muted-4 mt-2 rounded-md pt-2 md:p-6 md:pt-4">
{isEmailSubjectNeeded && (
<div className="mb-6">
<div className="flex items-center">
<Label className="mb-0 flex-none">{t("subject")}</Label>
<div className="flex-grow text-right">
<AddVariablesDropdown
addVariable={addVariableEmailSubject}
variables={DYNAMIC_TEXT_VARIABLES}
/>
</div>
<div className="bg-muted mt-2 rounded-md pt-2 md:p-6 md:pt-4">
{isEmailSubjectNeeded && (
<div className="mb-6">
<div className="flex items-center">
<Label className="mb-0 flex-none">{t("subject")}</Label>
<div className="flex-grow text-right">
<AddVariablesDropdown
addVariable={addVariableEmailSubject}
variables={DYNAMIC_TEXT_VARIABLES}
/>
</div>
<TextArea
ref={(e) => {
emailSubjectFormRef?.(e);
refEmailSubject.current = e;
}}
rows={1}
className="my-0 focus:ring-transparent"
required
{...restEmailSubjectForm}
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}
</p>
)}
</div>
)}
{step.action !== WorkflowActions.SMS_ATTENDEE &&
step.action !== WorkflowActions.SMS_NUMBER ? (
<>
<div className="mb-2 flex items-center pb-[1.5px]">
<Label className="mb-0 flex-none ">
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
</Label>
</div>
<Editor
getText={() => {
return props.form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || "";
}}
setText={(text: string) => {
props.form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, text);
props.form.clearErrors();
}}
variables={DYNAMIC_TEXT_VARIABLES}
height="200px"
/>
</>
) : (
<>
<div className="flex items-center">
<Label className="mb-0 flex-none">
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
</Label>
<div className="flex-grow text-right">
<AddVariablesDropdown
addVariable={addVariableBody}
variables={DYNAMIC_TEXT_VARIABLES}
/>
</div>
</div>
<TextArea
ref={(e) => {
reminderBodyFormRef?.(e);
refReminderBody.current = e;
}}
className="my-0 h-24"
required
{...restReminderBodyForm}
/>
</>
)}
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody && (
<p className="mt-1 text-sm text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
</p>
)}
<div className="mt-3 ">
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
<div className="text-default mt-2 flex text-sm">
<HelpCircle className="mt-[3px] h-3 w-3 ltr:mr-2 rtl:ml-2" />
<p className="text-left">{t("using_additional_inputs_as_variables")}</p>
</div>
</button>
<TextArea
ref={(e) => {
emailSubjectFormRef?.(e);
refEmailSubject.current = e;
}}
rows={1}
className="my-0 focus:ring-transparent"
required
{...restEmailSubjectForm}
/>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}
</p>
)}
</div>
)}
{step.action !== WorkflowActions.SMS_ATTENDEE &&
step.action !== WorkflowActions.SMS_NUMBER ? (
<>
<div className="mb-2 flex items-center pb-[1.5px]">
<Label className="mb-0 flex-none ">
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
</Label>
</div>
<Editor
getText={() => {
return props.form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || "";
}}
setText={(text: string) => {
props.form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, text);
props.form.clearErrors();
}}
variables={DYNAMIC_TEXT_VARIABLES}
height="200px"
updateTemplate={updateTemplate}
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</>
) : (
<>
<div className="flex items-center">
<Label className="mb-0 flex-none">
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
</Label>
<div className="flex-grow text-right">
<AddVariablesDropdown
addVariable={addVariableBody}
variables={DYNAMIC_TEXT_VARIABLES}
/>
</div>
</div>
<TextArea
ref={(e) => {
reminderBodyFormRef?.(e);
refReminderBody.current = e;
}}
className="my-0 h-24"
required
{...restReminderBodyForm}
/>
</>
)}
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody && (
<p className="mt-1 text-sm text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.reminderBody?.message || ""}
</p>
)}
<div className="mt-3 ">
<button type="button" onClick={() => setIsAdditionalInputsDialogOpen(true)}>
<div className="text-default mt-2 flex text-sm">
<HelpCircle className="mt-[3px] h-3 w-3 ltr:mr-2 rtl:ml-2" />
<p className="text-left">{t("using_booking_questions_as_variables")}</p>
</div>
</button>
</div>
)}
</div>
{/* {form.getValues(`steps.${step.stepNumber - 1}.action`) !== WorkflowActions.SMS_ATTENDEE && (
<Button
type="button"
@@ -754,19 +848,19 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<Dialog open={isAdditionalInputsDialogOpen} onOpenChange={setIsAdditionalInputsDialogOpen}>
<DialogContent type="creation" className="sm:max-w-[610px] md:h-[570px]">
<div className="-m-3 h-[430px] overflow-x-hidden overflow-y-scroll sm:m-0">
<h1 className="w-full text-xl font-semibold ">{t("how_additional_inputs_as_variables")}</h1>
<h1 className="w-full text-xl font-semibold ">{t("how_booking_questions_as_variables")}</h1>
<div className="bg-muted-3 mb-7 mt-7 rounded-md sm:p-4">
<p className="test-sm font-medium">{t("format")}</p>
<ul className="text-emphasis mt-2 ml-5 list-disc">
<li>{t("uppercase_for_letters")}</li>
<li>{t("replace_whitespaces_underscores")}</li>
<li>{t("ignore_special_characters")}</li>
<li>{t("ignore_special_characters_booking_questions")}</li>
</ul>
<div className="mt-6">
<p className="test-sm w-full font-medium">{t("example_1")}</p>
<div className="mt-2 grid grid-cols-12">
<div className="test-sm text-default col-span-5 ltr:mr-2 rtl:ml-2">
{t("additional_input_label")}
{t("booking_question_identifier")}
</div>
<div className="test-sm text-emphasis col-span-7">{t("company_size")}</div>
<div className="test-sm text-default col-span-5 w-full">{t("variable")}</div>
@@ -785,7 +879,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
<p className="test-sm w-full font-medium">{t("example_2")}</p>
<div className="mt-2 grid grid-cols-12">
<div className="test-sm text-default col-span-5 ltr:mr-2 rtl:ml-2">
{t("additional_input_label")}
{t("booking_question_identifier")}
</div>
<div className="test-sm text-emphasis col-span-7">{t("what_help_needed")}</div>
<div className="test-sm text-default col-span-5">{t("variable")}</div>
@@ -3,3 +3,7 @@ import { WorkflowActions } from "@prisma/client";
export function isSMSAction(action: WorkflowActions) {
return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.SMS_NUMBER;
}
export function isAttendeeAction(action: WorkflowActions) {
return action === WorkflowActions.SMS_ATTENDEE || action === WorkflowActions.EMAIL_ATTENDEE;
}
@@ -24,10 +24,16 @@ export const DYNAMIC_TEXT_VARIABLES = [
"event_name",
"event_date",
"event_time",
"event_end_time",
"timezone",
"location",
"organizer_name",
"attendee_name",
"attendee_email",
"additional_notes",
"meeting_url",
"cancel_url",
"reschedule_url",
];
export const FORMATTED_DYNAMIC_TEXT_VARIABLES = ["event_date_", "event_time_", "event_end_time_"];
@@ -1,8 +1,8 @@
import { WorkflowActions } from "@prisma/client";
import { TFunction } from "next-i18next";
import type { TFunction } from "next-i18next";
import { isSMSAction } from "./actionHelperFunctions";
import { TIME_UNIT, WORKFLOW_ACTIONS, WORKFLOW_TEMPLATES, WORKFLOW_TRIGGER_EVENTS } from "./constants";
import { isSMSAction } from "./isSMSAction";
export function getWorkflowActionOptions(t: TFunction, isTeamsPlan?: boolean) {
return WORKFLOW_ACTIONS.filter((action) => action !== WorkflowActions.EMAIL_ADDRESS) //removing EMAIL_ADDRESS for now due to abuse episode
@@ -1,10 +1,12 @@
import type { TimeUnit } from "@prisma/client";
import { WorkflowTriggerEvents, WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@prisma/client";
import { WorkflowTemplates } from "@prisma/client";
import { WorkflowTriggerEvents, WorkflowActions, WorkflowMethods } from "@prisma/client";
import client from "@sendgrid/client";
import type { MailData } from "@sendgrid/helpers/classes/mail";
import sgMail from "@sendgrid/mail";
import dayjs from "@calcom/dayjs";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
@@ -15,6 +17,7 @@ import emailReminderTemplate from "./templates/emailReminderTemplate";
let sendgridAPIKey, senderEmail: string;
const log = logger.getChildLogger({ prefix: ["[emailReminderManager]"] });
if (process.env.SENDGRID_API_KEY) {
sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
senderEmail = process.env.SENDGRID_EMAIL as string;
@@ -36,7 +39,8 @@ export const scheduleEmailReminder = async (
emailBody: string,
workflowStepId: number,
template: WorkflowTemplates,
sender: string
sender: string,
hideBranding?: boolean
) => {
if (action === WorkflowActions.EMAIL_ADDRESS) return;
const { startTime, endTime } = evt;
@@ -51,16 +55,12 @@ export const scheduleEmailReminder = async (
} else if (triggerEvent === WorkflowTriggerEvents.AFTER_EVENT) {
scheduledDate = timeSpan.time && timeUnit ? dayjs(endTime).add(timeSpan.time, timeUnit) : null;
}
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
console.error("Sendgrid credentials are missing from the .env file");
return;
}
const batchIdResponse = await client.request({
url: "/v3/mail/batch",
method: "POST",
});
let name = "";
let attendeeName = "";
let timeZone = "";
@@ -80,42 +80,54 @@ export const scheduleEmailReminder = async (
let emailContent = {
emailSubject,
emailBody: {
text: emailBody,
html: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
},
emailBody: `<body style="white-space: pre-wrap;">${emailBody}</body>`,
};
if (emailBody) {
const variables: VariablesType = {
eventName: evt.title || "",
organizerName: evt.organizer.name,
attendeeName: evt.attendees[0].name,
attendeeEmail: evt.attendees[0].email,
eventDate: dayjs(startTime).tz(timeZone),
eventEndTime: dayjs(endTime).tz(timeZone),
timeZone: timeZone,
location: evt.location,
additionalNotes: evt.additionalNotes,
responses: evt.responses,
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
cancelLink: `/booking/${evt.uid}?cancel=true`,
rescheduleLink: `/${evt.organizer.username}/${evt.eventType.slug}?rescheduleUid=${evt.uid}`,
};
switch (template) {
case WorkflowTemplates.REMINDER:
emailContent = emailReminderTemplate(startTime, endTime, evt.title, timeZone, attendeeName, name);
break;
case WorkflowTemplates.CUSTOM:
const variables: VariablesType = {
eventName: evt.title || "",
organizerName: evt.organizer.name,
attendeeName: evt.attendees[0].name,
attendeeEmail: evt.attendees[0].email,
eventDate: dayjs(startTime).tz(timeZone),
eventTime: dayjs(startTime).tz(timeZone),
timeZone: timeZone,
location: evt.location,
additionalNotes: evt.additionalNotes,
responses: evt.responses,
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
};
const locale =
action === WorkflowActions.EMAIL_ATTENDEE || action === WorkflowActions.SMS_ATTENDEE
? evt.attendees[0].language?.locale
: evt.organizer.language.locale;
const locale =
action === WorkflowActions.EMAIL_ATTENDEE || action === WorkflowActions.SMS_ATTENDEE
? evt.attendees[0].language?.locale
: evt.organizer.language.locale;
const emailSubjectTemplate = await customTemplate(emailSubject, variables, locale);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = await customTemplate(emailBody, variables, locale);
break;
const emailSubjectTemplate = customTemplate(emailSubject, variables, locale);
emailContent.emailSubject = emailSubjectTemplate.text;
emailContent.emailBody = customTemplate(emailBody, variables, locale, hideBranding).html;
} else if (template === WorkflowTemplates.REMINDER) {
emailContent = emailReminderTemplate(
false,
action,
startTime,
endTime,
evt.title,
timeZone,
attendeeName,
name
);
}
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending Email for trigger ${triggerEvent}`, JSON.stringify(emailContent));
const batchIdResponse = await client.request({
url: "/v3/mail/batch",
method: "POST",
});
if (
triggerEvent === WorkflowTriggerEvents.NEW_EVENT ||
triggerEvent === WorkflowTriggerEvents.EVENT_CANCELLED ||
@@ -129,8 +141,7 @@ export const scheduleEmailReminder = async (
name: sender,
},
subject: emailContent.emailSubject,
text: emailContent.emailBody.text,
html: emailContent.emailBody.html,
html: emailContent.emailBody,
batchId: batchIdResponse[1].batch_id,
replyTo: evt.organizer.email,
});
@@ -156,8 +167,7 @@ export const scheduleEmailReminder = async (
name: sender,
},
subject: emailContent.emailSubject,
text: emailContent.emailBody.text,
html: emailContent.emailBody.html,
html: emailContent.emailBody,
batchId: batchIdResponse[1].batch_id,
sendAt: scheduledDate.unix(),
replyTo: evt.organizer.email,
@@ -8,6 +8,11 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
import { scheduleEmailReminder } from "./emailReminderManager";
import { scheduleSMSReminder } from "./smsReminderManager";
type ExtendedCalendarEvent = CalendarEvent & {
metadata?: { videoCallUrl: string | undefined };
eventType: { slug?: string };
};
export interface ScheduleWorkflowRemindersArgs {
workflows: (WorkflowsOnEventTypes & {
workflow: Workflow & {
@@ -15,11 +20,12 @@ export interface ScheduleWorkflowRemindersArgs {
};
})[];
smsReminderNumber: string | null;
calendarEvent: CalendarEvent & { metadata?: { videoCallUrl: string | undefined } };
calendarEvent: ExtendedCalendarEvent;
requiresConfirmation?: boolean;
isRescheduleEvent?: boolean;
isFirstRecurringEvent?: boolean;
emailAttendeeSendToOverride?: string;
hideBranding?: boolean;
}
export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersArgs) => {
@@ -31,6 +37,7 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
isRescheduleEvent = false,
isFirstRecurringEvent = false,
emailAttendeeSendToOverride = "",
hideBranding,
} = args;
if (workflows.length > 0 && !requiresConfirmation) {
for (const workflowReference of workflows) {
@@ -95,7 +102,8 @@ export const scheduleWorkflowReminders = async (args: ScheduleWorkflowRemindersA
step.reminderBody || "",
step.id,
step.template,
step.sender || SENDER_NAME
step.sender || SENDER_NAME,
hideBranding
);
}
}
@@ -111,11 +119,12 @@ export interface SendCancelledRemindersArgs {
};
})[];
smsReminderNumber: string | null;
evt: CalendarEvent;
evt: ExtendedCalendarEvent;
hideBranding?: boolean;
}
export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) => {
const { workflows, smsReminderNumber, evt } = args;
const { workflows, smsReminderNumber, evt, hideBranding } = args;
if (workflows.length > 0) {
for (const workflowRef of workflows) {
@@ -170,7 +179,8 @@ export const sendCancelledReminders = async (args: SendCancelledRemindersArgs) =
step.reminderBody || "",
step.id,
step.template,
step.sender || SENDER_NAME
step.sender || SENDER_NAME,
hideBranding
);
}
}
@@ -2,9 +2,11 @@ import type { TimeUnit } from "@prisma/client";
import { WorkflowTriggerEvents, WorkflowTemplates, WorkflowActions, WorkflowMethods } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { CalEventResponses } from "@calcom/types/Calendar";
import { getSenderId } from "../alphanumericSenderIdSupport";
import * as twilio from "./smsProviders/twilioProvider";
@@ -17,6 +19,7 @@ export enum timeUnitLowerCase {
MINUTE = "minute",
YEAR = "year",
}
const log = logger.getChildLogger({ prefix: ["[smsReminderManager]"] });
export type BookingInfo = {
uid?: string | null;
@@ -26,13 +29,17 @@ export type BookingInfo = {
name: string;
email: string;
timeZone: string;
username?: string;
};
eventType: {
slug?: string;
};
startTime: string;
endTime: string;
title: string;
location?: string | null;
additionalNotes?: string | null;
responses?: Prisma.JsonValue;
responses?: CalEventResponses | null;
metadata?: Prisma.JsonValue;
};
@@ -92,29 +99,32 @@ export const scheduleSMSReminder = async (
? evt.attendees[0].language?.locale
: evt.organizer.language.locale;
switch (template) {
case WorkflowTemplates.REMINDER:
message = smsReminderTemplate(evt.startTime, evt.title, timeZone, attendeeName, name) || message;
break;
case WorkflowTemplates.CUSTOM:
const variables: VariablesType = {
eventName: evt.title,
organizerName: evt.organizer.name,
attendeeName: evt.attendees[0].name,
attendeeEmail: evt.attendees[0].email,
eventDate: dayjs(evt.startTime).tz(timeZone),
eventTime: dayjs(evt.startTime).tz(timeZone),
timeZone: timeZone,
location: evt.location,
additionalNotes: evt.additionalNotes,
responses: evt.responses,
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
};
const customMessage = await customTemplate(message, variables, locale);
message = customMessage.text;
break;
if (message) {
const variables: VariablesType = {
eventName: evt.title,
organizerName: evt.organizer.name,
attendeeName: evt.attendees[0].name,
attendeeEmail: evt.attendees[0].email,
eventDate: dayjs(evt.startTime).tz(timeZone),
eventEndTime: dayjs(evt.endTime).tz(timeZone),
timeZone: timeZone,
location: evt.location,
additionalNotes: evt.additionalNotes,
responses: evt.responses,
meetingUrl: bookingMetadataSchema.parse(evt.metadata || {})?.videoCallUrl,
cancelLink: `/booking/${evt.uid}?cancel=true`,
rescheduleLink: `/${evt.organizer.username}/${evt.eventType.slug}?rescheduleUid=${evt.uid}`,
};
const customMessage = customTemplate(message, variables, locale);
message = customMessage.text;
} else if (template === WorkflowTemplates.REMINDER) {
message =
smsReminderTemplate(false, action, evt.startTime, evt.title, timeZone, attendeeName, name) || message;
}
// Allows debugging generated email content without waiting for sendgrid to send emails
log.debug(`Sending sms for trigger ${triggerEvent}`, message);
if (message.length > 0 && reminderPhone && isNumberVerified) {
//send SMS when event is booked/cancelled/rescheduled
if (
@@ -1,6 +1,7 @@
import { guessEventLocationType } from "@calcom/app-store/locations";
import type { Dayjs } from "@calcom/dayjs";
import type { Prisma } from "@calcom/prisma/client";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import type { CalEventResponses } from "@calcom/types/Calendar";
export type VariablesType = {
eventName?: string;
@@ -8,15 +9,22 @@ export type VariablesType = {
attendeeName?: string;
attendeeEmail?: string;
eventDate?: Dayjs;
eventTime?: Dayjs;
eventEndTime?: Dayjs;
timeZone?: string;
location?: string | null;
additionalNotes?: string | null;
responses?: Prisma.JsonValue;
responses?: CalEventResponses | null;
meetingUrl?: string;
cancelLink?: string;
rescheduleLink?: string;
};
const customTemplate = async (text: string, variables: VariablesType, locale: string) => {
const customTemplate = (
text: string,
variables: VariablesType,
locale: string,
isBrandingDisabled?: boolean
) => {
const translatedDate = new Intl.DateTimeFormat(locale, {
weekday: "long",
month: "long",
@@ -24,13 +32,15 @@ const customTemplate = async (text: string, variables: VariablesType, locale: st
year: "numeric",
}).format(variables.eventDate?.toDate());
const timeWithTimeZone = `${variables.eventTime?.format("HH:mm")} (${variables.timeZone})`;
let locationString = variables.location || "";
if (text.includes("{LOCATION}")) {
locationString = guessEventLocationType(locationString)?.label || locationString;
}
const cancelLink = variables.cancelLink ? `${WEBAPP_URL}${variables.cancelLink}` : "";
const rescheduleLink = variables.rescheduleLink ? `${WEBAPP_URL}${variables.rescheduleLink}` : "";
let dynamicText = text
.replaceAll("{EVENT_NAME}", variables.eventName || "")
.replaceAll("{ORGANIZER}", variables.organizerName || "")
@@ -38,17 +48,36 @@ const customTemplate = async (text: string, variables: VariablesType, locale: st
.replaceAll("{ORGANIZER_NAME}", variables.organizerName || "") //old variable names
.replaceAll("{ATTENDEE_NAME}", variables.attendeeName || "") //old variable names
.replaceAll("{EVENT_DATE}", translatedDate)
.replaceAll("{EVENT_TIME}", timeWithTimeZone)
.replaceAll("{EVENT_TIME}", variables.eventDate?.format("H:mmA") || "")
.replaceAll("{EVENT_END_TIME}", variables.eventEndTime?.format("H:mmA") || "")
.replaceAll("{LOCATION}", locationString)
.replaceAll("{ADDITIONAL_NOTES}", variables.additionalNotes || "")
.replaceAll("{ATTENDEE_EMAIL}", variables.attendeeEmail || "")
.replaceAll("{TIMEZONE}", variables.timeZone || "")
.replaceAll("{CANCEL_URL}", cancelLink)
.replaceAll("{RESCHEDULE_URL}", rescheduleLink)
.replaceAll("{MEETING_URL}", variables.meetingUrl || "");
const customInputvariables = dynamicText.match(/\{(.+?)}/g)?.map((variable) => {
return variable.replace("{", "").replace("}", "");
});
// event date/time with formatting
customInputvariables?.forEach((variable) => {
if (variable.startsWith("EVENT_DATE_") || variable.startsWith("EVENT_TIME_")) {
const dateFormat = variable.substring(11, text.length);
const formattedDate = variables.eventDate?.format(dateFormat);
dynamicText = dynamicText.replace(`{${variable}}`, formattedDate || "");
return;
}
if (variable.startsWith("EVENT_END_TIME_")) {
const dateFormat = variable.substring(15, text.length);
const formattedDate = variables.eventEndTime?.format(dateFormat);
dynamicText = dynamicText.replace(`{${variable}}`, formattedDate || "");
return;
}
if (variables.responses) {
Object.keys(variables.responses).forEach((customInput) => {
const formatedToVariable = customInput
@@ -56,17 +85,24 @@ const customTemplate = async (text: string, variables: VariablesType, locale: st
.trim()
.replaceAll(" ", "_")
.toUpperCase();
if (variable === formatedToVariable && variables.responses) {
if (
variable === formatedToVariable &&
variables.responses &&
variables.responses[customInput as keyof typeof variables.responses].value
) {
dynamicText = dynamicText.replace(
`{${variable}}`,
variables.responses[customInput as keyof typeof variables.responses]
variables.responses[customInput as keyof typeof variables.responses].value.toString()
);
}
});
}
});
const textHtml = `<body style="white-space: pre-wrap;">${dynamicText}</body>`;
const branding = !isBrandingDisabled ? `<br><br>_<br><br>Scheduling by ${APP_NAME}` : "";
const textHtml = `<body style="white-space: pre-wrap;">${dynamicText}${branding}</body>`;
return { text: dynamicText, html: textHtml };
};
@@ -1,43 +1,50 @@
import { WorkflowActions } from "@prisma/client";
import dayjs from "@calcom/dayjs";
import { APP_NAME } from "@calcom/lib/constants";
const emailReminderTemplate = (
startTime: string,
endTime: string,
eventName: string,
timeZone: string,
attendee: string,
name: string
isEditingMode: boolean,
action?: WorkflowActions,
startTime?: string,
endTime?: string,
eventName?: string,
timeZone?: string,
otherPerson?: string,
name?: string,
isBrandingDisabled?: boolean
) => {
const emailSubject = `Reminder: ${eventName} - ${dayjs(startTime)
.tz(timeZone)
.format("ddd, MMM D, YYYY")} ${dayjs(startTime).tz(timeZone).format("H:mmA")}`;
let eventDate = "";
const templateBodyText = `Hi ${name},this is a reminder that your meeting (${eventName}) with ${attendee} is on ${dayjs(
startTime
)
.tz(timeZone)
.format("YYYY MMM D")} at ${dayjs(startTime).tz(timeZone).format("h:mmA")} ${timeZone}.`;
if (isEditingMode) {
endTime = "{EVENT_END_TIME}";
eventName = "{EVENT_NAME}";
timeZone = "{TIMEZONE}";
otherPerson = action === WorkflowActions.EMAIL_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
name = action === WorkflowActions.EMAIL_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
eventDate = "{EVENT_DATE_ddd, MMM D, YYYY H:mmA}";
} else {
eventDate = dayjs(startTime).tz(timeZone).format("ddd, MMM D, YYYY H:mmA");
endTime = dayjs(endTime).tz(timeZone).format("H:mmA");
}
const emailSubject = `Reminder: ${eventName} - ${eventDate}`;
const introHtml = `<body>Hi${
name ? " " + name : ""
},<br><br>This is a reminder about your upcoming event.<br><br>`;
const eventHtml = `<div style="font-weight: bold;">Event:</div>${eventName}<br><br>`;
const eventHtml = `<div><strong class="editor-text-bold">Event:</strong></div>${eventName}<br><br>`;
const dateTimeHtml = `<div style="font-weight: bold;">Date & Time:</div>${dayjs(startTime)
.tz(timeZone)
.format("ddd, MMM D, YYYY H:mmA")} - ${dayjs(endTime)
.tz(timeZone)
.format("H:mmA")} (${timeZone})<br><br>`;
const dateTimeHtml = `<div><strong class="editor-text-bold">Date & Time:</strong></div>${eventDate} - ${endTime} (${timeZone})<br><br>`;
const attendeeHtml = `<div style="font-weight: bold;">Attendees:</div>You & ${attendee}<br><br>`;
const attendeeHtml = `<div><strong class="editor-text-bold">Attendees:</strong></div>You & ${otherPerson}<br><br>`;
const endingHtml = `This reminder was triggered by a Workflow in Cal.<br><br>_<br><br>Scheduling by ${APP_NAME}</body>`;
const branding = !isBrandingDisabled && !isEditingMode ? `<br><br>_<br><br>Scheduling by ${APP_NAME}` : "";
const templateBodyHtml = introHtml + eventHtml + dateTimeHtml + attendeeHtml + endingHtml;
const endingHtml = `This reminder was triggered by a Workflow in Cal.${branding}</body>`;
const emailBody = { text: templateBodyText, html: templateBodyHtml };
const emailBody = introHtml + eventHtml + dateTimeHtml + attendeeHtml + endingHtml;
return { emailSubject, emailBody };
};
@@ -1,23 +1,38 @@
import { WorkflowActions } from "@prisma/client";
import dayjs from "@calcom/dayjs";
const smsReminderTemplate = (
startTime: string,
eventName: string,
timeZone: string,
attendee: string,
isEditingMode: boolean,
action?: WorkflowActions,
startTime?: string,
eventName?: string,
timeZone?: string,
attendee?: string,
name?: string
) => {
const templateOne = `Hi${name ? ` ${name}` : ``}, this is a reminder that your meeting (${eventName}) with
${attendee} is on ${dayjs(startTime).tz(timeZone).format("MMM D")} at ${dayjs(startTime)
.tz(timeZone)
.format("h:mmA")} ${timeZone}`;
let eventDate;
if (isEditingMode) {
eventName = "{EVENT_NAME}";
timeZone = "{TIMEZONE}";
startTime = "{START_TIME_h:mmA}";
eventDate = "{EVENT_DATE_YYYY MMM D}";
attendee = action === WorkflowActions.SMS_ATTENDEE ? "{ORGANIZER}" : "{ATTENDEE}";
name = action === WorkflowActions.SMS_ATTENDEE ? "{ATTENDEE}" : "{ORGANIZER}";
} else {
eventDate = dayjs(startTime).tz(timeZone).format("YYYY MMM D");
startTime = dayjs(startTime).tz(timeZone).format("h:mmA");
}
const templateOne = `Hi${
name ? ` ${name}` : ``
}, this is a reminder that your meeting (${eventName}) with ${attendee} is on ${eventDate} at ${startTime} ${timeZone}.`;
//Twilio recomments message to be no longer than 320 characters
if (templateOne.length <= 320) return templateOne;
const templateTwo = `Hi, this is a reminder that your meeting with ${attendee} is on ${dayjs(startTime)
.tz(timeZone)
.format("MMM D")} at ${dayjs(startTime).tz(timeZone).format("h:mmA")} ${timeZone}`;
const templateTwo = `Hi, this is a reminder that your meeting with ${attendee} is on ${eventDate} at ${startTime} ${timeZone}`;
//Twilio supports up to 1600 characters
if (templateTwo.length <= 1600) return templateTwo;
@@ -1,7 +1,8 @@
import { TFunction } from "next-i18next";
import type { TFunction } from "next-i18next";
import { DYNAMIC_TEXT_VARIABLES } from "./constants";
import { DYNAMIC_TEXT_VARIABLES, FORMATTED_DYNAMIC_TEXT_VARIABLES } from "./constants";
// variables are saved in the db always in english, so here we translate them to the user's language
export function getTranslatedText(text: string, language: { locale: string; t: TFunction }) {
let translatedText = text;
@@ -12,12 +13,31 @@ export function getTranslatedText(text: string, language: { locale: string; t: T
variables?.forEach((variable) => {
const regex = new RegExp(`{${variable}}`, "g"); // .replaceAll is not available here for some reason
const translatedVariable = DYNAMIC_TEXT_VARIABLES.includes(variable.toLowerCase())
let translatedVariable = DYNAMIC_TEXT_VARIABLES.includes(variable.toLowerCase())
? language.t(variable.toLowerCase().concat("_variable")).replace(/ /g, "_").toLocaleUpperCase()
: DYNAMIC_TEXT_VARIABLES.includes(variable.toLowerCase().concat("_name")) //for the old variables names (ORGANIZER_NAME, ATTENDEE_NAME)
? language.t(variable.toLowerCase().concat("_name_variable")).replace(/ /g, "_").toLocaleUpperCase()
: variable;
// this takes care of translating formatted variables (e.g. {EVENT_DATE_DD MM YYYY})
const formattedVarToTranslate = FORMATTED_DYNAMIC_TEXT_VARIABLES.map((formattedVar) => {
if (variable.toLowerCase().startsWith(formattedVar)) return variable;
})[0];
if (formattedVarToTranslate) {
// only translate the variable part not the formatting
const variableName = formattedVarToTranslate
.substring(0, formattedVarToTranslate?.lastIndexOf("_"))
.toLowerCase()
.concat("_variable");
translatedVariable = language
.t(variableName)
.replace(/ /g, "_")
.toLocaleUpperCase()
.concat(formattedVarToTranslate?.substring(formattedVarToTranslate?.lastIndexOf("_")));
}
translatedText = translatedText.replace(regex, `{${translatedVariable}}`);
});
}
@@ -48,6 +68,13 @@ export function translateVariablesToEnglish(text: string, language: { locale: st
return;
}
});
FORMATTED_DYNAMIC_TEXT_VARIABLES.forEach((formattedVar) => {
const translatedVariable = language.t(`${formattedVar}variable`).replace(/ /g, "_").toUpperCase();
if (variable.startsWith(translatedVariable)) {
newText = newText.replace(translatedVariable, formattedVar.slice(0, -1).toUpperCase());
}
});
});
}
@@ -27,7 +27,7 @@ import { Alert, Button, Form, showToast, Badge } from "@calcom/ui";
import LicenseRequired from "../../common/components/v2/LicenseRequired";
import SkeletonLoader from "../components/SkeletonLoaderEdit";
import WorkflowDetailsPage from "../components/WorkflowDetailsPage";
import { isSMSAction } from "../lib/isSMSAction";
import { isSMSAction } from "../lib/actionHelperFunctions";
import { getTranslatedText, translateVariablesToEnglish } from "../lib/variableTranslations";
export type FormValues = {
@@ -204,10 +204,7 @@ function WorkflowPage() {
values.steps.forEach((step) => {
const strippedHtml = step.reminderBody?.replace(/<[^>]+>/g, "") || "";
const isBodyEmpty =
step.template === WorkflowTemplates.CUSTOM &&
!isSMSAction(step.action) &&
strippedHtml.length <= 1;
const isBodyEmpty = !isSMSAction(step.action) && strippedHtml.length <= 1;
if (isBodyEmpty) {
form.setError(`steps.${step.stepNumber - 1}.reminderBody`, {
@@ -0,0 +1,6 @@
-- Until now reminderBody and emailSubject were ignored for the REMINDER template
-- From now on we use the reminderBody and emailSubject also for templates
UPDATE "WorkflowStep"
SET "reminderBody" = NULL, "emailSubject" = NULL
WHERE "template" = 'REMINDER';
@@ -844,6 +844,7 @@ export const bookingsRouter = router({
organizer: {
email: user.email,
name: user.name || "Unnamed",
username: user.username || undefined,
timeZone: user.timeZone,
language: { translate: tOrganizer, locale: user.locale ?? "en" },
},
@@ -10,13 +10,14 @@ import {
} from "@prisma/client";
import { z } from "zod";
import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate";
import {
SMS_REMINDER_NUMBER_FIELD,
getSmsReminderNumberField,
getSmsReminderNumberSource,
} from "@calcom/features/bookings/lib/getBookingFields";
import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage";
// import dayjs from "@calcom/dayjs";
import { isSMSAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import {
WORKFLOW_TEMPLATES,
WORKFLOW_TRIGGER_EVENTS,
@@ -24,7 +25,6 @@ import {
TIME_UNIT,
} from "@calcom/features/ee/workflows/lib/constants";
import { getWorkflowActionOptions } from "@calcom/features/ee/workflows/lib/getOptions";
import { isSMSAction } from "@calcom/features/ee/workflows/lib/isSMSAction";
import {
deleteScheduledEmailReminder,
scheduleEmailReminder,
@@ -359,8 +359,10 @@ export const workflowsRouter = router({
await ctx.prisma.workflowStep.create({
data: {
stepNumber: 1,
action: WorkflowActions.EMAIL_HOST,
action: WorkflowActions.EMAIL_ATTENDEE,
template: WorkflowTemplates.REMINDER,
reminderBody: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailBody,
emailSubject: emailReminderTemplate(true, WorkflowActions.EMAIL_ATTENDEE).emailSubject,
workflowId: workflow.id,
sender: SENDER_NAME,
numberVerificationPending: false,
@@ -652,6 +654,9 @@ export const workflowsRouter = router({
endTime: booking.endTime.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || "" },
eventType: {
slug: booking.eventType?.slug,
},
};
if (
step.action === WorkflowActions.EMAIL_HOST ||
@@ -771,8 +776,8 @@ export const workflowsRouter = router({
: null,
stepNumber: newStep.stepNumber,
workflowId: newStep.workflowId,
reminderBody: newStep.template === WorkflowTemplates.CUSTOM ? newStep.reminderBody : null,
emailSubject: newStep.template === WorkflowTemplates.CUSTOM ? newStep.emailSubject : null,
reminderBody: newStep.reminderBody,
emailSubject: newStep.emailSubject,
template: newStep.template,
numberRequired: newStep.numberRequired,
sender: getSender({
@@ -846,6 +851,9 @@ export const workflowsRouter = router({
endTime: booking.endTime.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || "" },
eventType: {
slug: booking.eventType?.slug,
},
};
if (
newStep.action === WorkflowActions.EMAIL_HOST ||
@@ -973,6 +981,9 @@ export const workflowsRouter = router({
endTime: booking.endTime.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || "" },
eventType: {
slug: booking.eventType?.slug,
},
};
if (
+1 -1
View File
@@ -135,7 +135,7 @@ export type AppsStatus = {
warnings?: string[];
};
type CalEventResponses = Record<
export type CalEventResponses = Record<
string,
{
label: string;
+7
View File
@@ -10,6 +10,7 @@ import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPl
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
import type { Dispatch, SetStateAction } from "react";
import { classNames } from "@calcom/lib";
@@ -33,6 +34,9 @@ export type TextEditorProps = {
height?: string;
placeholder?: string;
disableLists?: boolean;
updateTemplate?: boolean;
firstRender?: boolean;
setFirstRender?: Dispatch<SetStateAction<boolean>>;
editable?: boolean;
};
@@ -69,6 +73,9 @@ export const Editor = (props: TextEditorProps) => {
editable={editable}
excludedToolbarItems={props.excludedToolbarItems}
variables={props.variables}
updateTemplate={props.updateTemplate}
firstRender={props.firstRender}
setFirstRender={props.setFirstRender}
/>
<div
className={classNames("editor-inner scroll-bar", !editable && "bg-muted")}
@@ -32,29 +32,31 @@ export const AddVariablesDropdown = (props: IAddVariablesDropdown) => {
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="h-40 overflow-scroll">
<DropdownMenuContent>
<div className="pt-4 pb-1">
<div className="text-subtle mb-2 px-4 text-left text-xs">
{t("add_dynamic_variables").toLocaleUpperCase()}
</div>
{props.variables.map((variable) => (
<DropdownMenuItem key={variable} className="hover:ring-0">
<button
key={variable}
type="button"
className="w-full px-4 py-2"
onClick={() => props.addVariable(t(`${variable}_variable`))}>
<div className="sm:grid sm:grid-cols-2">
<div className="mr-3 text-left md:col-span-1">
{`{${t(`${variable}_variable`).toUpperCase().replace(/ /g, "_")}}`}
<div className="h-64 overflow-scroll md:h-80">
{props.variables.map((variable) => (
<DropdownMenuItem key={variable} className="hover:ring-0">
<button
key={variable}
type="button"
className="w-full px-4 py-2"
onClick={() => props.addVariable(t(`${variable}_variable`))}>
<div className="sm:grid sm:grid-cols-2">
<div className="mr-3 text-left md:col-span-1">
{`{${t(`${variable}_variable`).toUpperCase().replace(/ /g, "_")}}`}
</div>
<div className="text-default hidden text-left sm:col-span-1 sm:flex">
{t(`${variable}_info`)}
</div>
</div>
<div className="text-default hidden text-left sm:col-span-1 sm:flex">
{t(`${variable}_info`)}
</div>
</div>
</button>
</DropdownMenuItem>
))}
</button>
</DropdownMenuItem>
))}
</div>
</div>
</DropdownMenuContent>
</Dropdown>
@@ -340,23 +340,47 @@ export default function ToolbarPlugin(props: TextEditorProps) {
};
useEffect(() => {
editor.update(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(props.getText(), "text/html");
if (!props.firstRender) {
editor.update(() => {
const root = $getRoot();
if (root) {
editor.update(() => {
const parser = new DOMParser();
// Create a new TextNode
const dom = parser.parseFromString(props.getText(), "text/html");
const nodes = $generateNodesFromDOM(editor, dom);
$getRoot().select();
$insertNodes(nodes);
editor.registerUpdateListener(({ editorState, prevEditorState }) => {
editorState.read(() => {
const textInHtml = $generateHtmlFromNodes(editor).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
props.setText(textInHtml);
});
if (!prevEditorState._selection) editor.blur();
const nodes = $generateNodesFromDOM(editor, dom);
const paragraph = $createParagraphNode();
root.clear().append(paragraph);
paragraph.select();
$insertNodes(nodes);
});
}
});
});
}
}, [props.updateTemplate]);
useEffect(() => {
if (props.setFirstRender) {
props.setFirstRender(false);
editor.update(() => {
const parser = new DOMParser();
const dom = parser.parseFromString(props.getText(), "text/html");
const nodes = $generateNodesFromDOM(editor, dom);
$getRoot().select();
$insertNodes(nodes);
editor.registerUpdateListener(({ editorState, prevEditorState }) => {
editorState.read(() => {
const textInHtml = $generateHtmlFromNodes(editor).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
props.setText(textInHtml);
});
if (!prevEditorState._selection) editor.blur();
});
});
}
}, []);
useEffect(() => {