Merge branch 'main' into fix/after-meeting-ends-migration
This commit is contained in:
commit
53e4f74d23
|
@ -38,6 +38,7 @@ type Props = {
|
|||
team: EventTypeSetupInfered["team"];
|
||||
disableBorder?: boolean;
|
||||
enabledAppsNumber: number;
|
||||
enabledWorkflowsNumber: number;
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
};
|
||||
|
||||
|
@ -48,6 +49,7 @@ function EventTypeSingleLayout({
|
|||
team,
|
||||
disableBorder,
|
||||
enabledAppsNumber,
|
||||
enabledWorkflowsNumber,
|
||||
formMethods,
|
||||
}: Props) {
|
||||
const utils = trpc.useContext();
|
||||
|
@ -113,13 +115,12 @@ function EventTypeSingleLayout({
|
|||
icon: Icon.FiGrid,
|
||||
info: `${enabledAppsNumber} Active`,
|
||||
},
|
||||
// TODO: After V2 workflow page has been completed
|
||||
// {
|
||||
// name: "workflows",
|
||||
// tabName: "workflows",
|
||||
// icon: Icon.FiCloudLightning,
|
||||
// info: `X Active`,
|
||||
// },
|
||||
{
|
||||
name: "workflows",
|
||||
tabName: "workflows",
|
||||
icon: Icon.FiZap,
|
||||
info: `${enabledWorkflowsNumber} Active`,
|
||||
},
|
||||
] as VerticalTabItemProps[];
|
||||
|
||||
// If there is a team put this navigation item within the tabs
|
||||
|
@ -155,7 +156,7 @@ function EventTypeSingleLayout({
|
|||
heading={eventType.title}
|
||||
subtitle={eventType.description || ""}
|
||||
CTA={
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="hidden lg:flex lg:items-center">
|
||||
<p className="pr-2">{t("hide_from_profile")}</p>
|
||||
<Switch
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/workflows/components/v2/EventWorkflowsTab";
|
|
@ -12,6 +12,7 @@ const V2_WHITELIST = [
|
|||
"/availability",
|
||||
"/bookings",
|
||||
"/event-types",
|
||||
"/workflows",
|
||||
// Apps contains trailing slash to prevent app overview from being rendered as v2,
|
||||
// since it doesn't exist yet.
|
||||
"/apps/",
|
||||
|
|
|
@ -112,8 +112,7 @@ function isAvailable(busyTimes: BufferedBusyTimes, time: dayjs.ConfigType, lengt
|
|||
}
|
||||
|
||||
const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
const eventType = await prisma.eventType.findUniqueOrThrow({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
|
@ -662,10 +661,8 @@ async function handler(req: NextApiRequest) {
|
|||
|
||||
if (typeof eventType.price === "number" && eventType.price > 0) {
|
||||
/* Validate if there is any stripe_payment credential for this user */
|
||||
await prisma.credential.findFirst({
|
||||
rejectOnNotFound(err) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing stripe credentials", cause: err });
|
||||
},
|
||||
/* note: removes custom error message about stripe */
|
||||
await prisma.credential.findFirstOrThrow({
|
||||
where: {
|
||||
type: "stripe_payment",
|
||||
userId: organizerUser.id,
|
||||
|
|
|
@ -40,8 +40,7 @@ const rescheduleSchema = z.object({
|
|||
});
|
||||
|
||||
const findUserDataByUserId = async (userId: number) => {
|
||||
return await prisma.user.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
return await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
|
@ -76,7 +75,7 @@ const handler = async (
|
|||
return res.status(501).end();
|
||||
}
|
||||
|
||||
const bookingToReschedule = await prisma.booking.findFirst({
|
||||
const bookingToReschedule = await prisma.booking.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
|
@ -95,7 +94,6 @@ const handler = async (
|
|||
dynamicGroupSlugRef: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
uid: bookingId,
|
||||
NOT: {
|
||||
|
@ -127,14 +125,13 @@ const handler = async (
|
|||
if (bookingToReschedule && user) {
|
||||
let event: Partial<EventType> = {};
|
||||
if (bookingToReschedule.eventTypeId) {
|
||||
event = await prisma.eventType.findFirst({
|
||||
event = await prisma.eventType.findFirstOrThrow({
|
||||
select: {
|
||||
title: true,
|
||||
users: true,
|
||||
schedulingType: true,
|
||||
recurringEvent: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
id: bookingToReschedule.eventTypeId,
|
||||
},
|
||||
|
|
|
@ -107,7 +107,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
throw new HttpError({ statusCode: 404, message: "User not found" });
|
||||
}
|
||||
|
||||
const organizer = await prisma.user.findFirst({
|
||||
const organizer = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: bookingToDelete.userId,
|
||||
},
|
||||
|
@ -117,7 +117,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||
timeZone: true,
|
||||
locale: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
});
|
||||
|
||||
const attendeesListPromises = bookingToDelete.attendees.map(async (attendee) => {
|
||||
|
|
|
@ -15,8 +15,7 @@ type CalendlyEventType = {
|
|||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
const authenticatedUser = await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
const authenticatedUser = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: session?.user.id,
|
||||
},
|
||||
|
|
|
@ -15,8 +15,7 @@ type SavvyCalEventType = {
|
|||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const session = await getSession({ req });
|
||||
const authenticatedUser = await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
const authenticatedUser = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: session?.user.id,
|
||||
},
|
||||
|
|
|
@ -12,13 +12,12 @@ export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
|||
try {
|
||||
const session = await getSession({ req });
|
||||
const userId = session?.user?.id;
|
||||
const user = await prisma.user.findFirst({
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
metadata: true,
|
||||
},
|
||||
where: { id: userId },
|
||||
rejectOnNotFound: true,
|
||||
});
|
||||
const checkPremiumUsernameResult = await checkUsername(intentUsername);
|
||||
|
||||
|
|
|
@ -15,8 +15,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
|
|
|
@ -17,8 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
|
|
|
@ -24,8 +24,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
return res.status(400).json({ message: "No user id provided" });
|
||||
}
|
||||
|
||||
const authenticatedUser = await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
const authenticatedUser = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
},
|
||||
|
|
|
@ -19,8 +19,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
|
||||
if (req.method === "DELETE") {
|
||||
// Get user
|
||||
const user = await prisma.user.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: session.user?.id,
|
||||
},
|
||||
|
|
|
@ -206,8 +206,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||
const id = context.params?.id as string;
|
||||
|
||||
try {
|
||||
const resetPasswordRequest = await prisma.resetPasswordRequest.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
const resetPasswordRequest = await prisma.resetPasswordRequest.findUniqueOrThrow({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
|
|
|
@ -31,6 +31,7 @@ import { EventRecurringTab } from "@components/v2/eventtype/EventRecurringTab";
|
|||
import { EventSetupTab } from "@components/v2/eventtype/EventSetupTab";
|
||||
import { EventTeamTab } from "@components/v2/eventtype/EventTeamTab";
|
||||
import { EventTypeSingleLayout } from "@components/v2/eventtype/EventTypeSingleLayout";
|
||||
import EventWorkflowsTab from "@components/v2/eventtype/EventWorkfowsTab";
|
||||
|
||||
import { getTranslation } from "@server/lib/i18n";
|
||||
|
||||
|
@ -81,7 +82,7 @@ export type FormValues = {
|
|||
|
||||
const querySchema = z.object({
|
||||
tabName: z
|
||||
.enum(["setup", "availability", "apps", "limits", "recurring", "team", "advanced"])
|
||||
.enum(["setup", "availability", "apps", "limits", "recurring", "team", "advanced", "workflows"])
|
||||
.optional()
|
||||
.default("setup"),
|
||||
});
|
||||
|
@ -178,15 +179,22 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
hasGiphyIntegration={props.hasGiphyIntegration}
|
||||
/>
|
||||
),
|
||||
workflows: (
|
||||
<EventWorkflowsTab
|
||||
eventType={eventType}
|
||||
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
|
||||
/>
|
||||
),
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<EventTypeSingleLayout
|
||||
enabledAppsNumber={[props.hasGiphyIntegration, props.hasPaymentIntegration].filter(Boolean).length}
|
||||
enabledWorkflowsNumber={eventType.workflows.length}
|
||||
eventType={eventType}
|
||||
team={team}
|
||||
formMethods={formMethods}
|
||||
disableBorder={tabName === "apps"}
|
||||
disableBorder={tabName === "apps" || tabName === "workflows"}
|
||||
currentUserMembership={props.currentUserMembership}>
|
||||
<Form
|
||||
form={formMethods}
|
||||
|
@ -224,6 +232,16 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
}}
|
||||
className="space-y-6">
|
||||
{tabMap[tabName]}
|
||||
{tabName !== "workflows" && (
|
||||
<div className="mt-4 flex justify-end space-x-2 rtl:space-x-reverse">
|
||||
<Button href="/event-types" color="secondary" tabIndex={-1}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" data-testid="update-eventtype" disabled={updateMutation.isLoading}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</EventTypeSingleLayout>
|
||||
);
|
||||
|
@ -349,6 +367,25 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
currency: true,
|
||||
destinationCalendar: true,
|
||||
seatsPerTimeSlot: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
activeOn: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
export { default } from "@calcom/features/ee/workflows/pages/v2/workflow";
|
||||
|
||||
const querySchema = z.object({
|
||||
workflow: z.string(),
|
||||
});
|
||||
|
||||
export const getStaticProps: GetStaticProps = (ctx) => {
|
||||
const params = querySchema.safeParse(ctx.params);
|
||||
console.log("Built workflow page:", params);
|
||||
if (!params.success) return { notFound: true };
|
||||
|
||||
return {
|
||||
props: {
|
||||
workflow: params.data.workflow,
|
||||
},
|
||||
revalidate: 10, // seconds
|
||||
};
|
||||
};
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return {
|
||||
paths: [],
|
||||
fallback: "blocking",
|
||||
};
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { default } from "@calcom/features/ee/workflows/pages/v2/index";
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `apps/web/pages/v2/workflows/[workflow].tsx`
|
||||
*/
|
||||
import { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
|
|
|
@ -61,8 +61,7 @@ export const createUsersFixture = (page: Page, workerInfo: WorkerInfo) => {
|
|||
length: 30,
|
||||
},
|
||||
});
|
||||
const user = await prisma.user.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: _user.id },
|
||||
include: userIncludes,
|
||||
});
|
||||
|
|
|
@ -969,8 +969,8 @@
|
|||
"web_conference": "Webkonferenz",
|
||||
"number_for_sms_reminders": "Telefonnummer (für SMS-Erinnerungen)",
|
||||
"requires_confirmation": "Erfordert Bestätigung",
|
||||
"nr_event_type_one": "{{count}} | Termin-Typ",
|
||||
"nr_event_type_other": "{{count}} | Ereignis-Typen",
|
||||
"nr_event_type_one": "{{count}} Ereignistyp",
|
||||
"nr_event_type_other": "{{count}} Ereignistypen",
|
||||
"add_action": "Aktion hinzufügen",
|
||||
"set_whereby_link": "Whereby Link festlegen",
|
||||
"invalid_whereby_link": "Bitte geben Sie einen gültigen Whereby Link ein",
|
||||
|
@ -1010,8 +1010,8 @@
|
|||
"new_seat_title": "Jemand hat sich zu einem Ereignis hinzugefügt",
|
||||
"variable": "Variable",
|
||||
"event_name_workflow": "Event Name",
|
||||
"organizer_name_workflow": "Organisator Name",
|
||||
"attendee_name_workflow": "Teilnehmer Name",
|
||||
"organizer_name_workflow": "Organisator",
|
||||
"attendee_name_workflow": "Teilnehmer",
|
||||
"event_date_workflow": "Event Datum",
|
||||
"event_time_workflow": "Event Zeit",
|
||||
"location_workflow": "Ort",
|
||||
|
|
|
@ -941,7 +941,7 @@
|
|||
"sms_attendee_action": "send SMS to attendee",
|
||||
"sms_number_action": "send SMS to a specific number",
|
||||
"workflows": "Workflows",
|
||||
"new_workflow_btn": "New workflow",
|
||||
"new_workflow_btn": "New Workflow",
|
||||
"add_new_workflow": "Add a new workflow",
|
||||
"trigger": "Trigger",
|
||||
"triggers": "Triggers",
|
||||
|
@ -955,9 +955,9 @@
|
|||
"confirm_delete_workflow": "Yes, delete workflow",
|
||||
"workflow_deleted_successfully": "Workflow deleted successfully",
|
||||
"how_long_before": "How long before event starts?",
|
||||
"day_timeUnit": "Days",
|
||||
"hour_timeUnit": "Hours",
|
||||
"minute_timeUnit": "Minutes",
|
||||
"day_timeUnit": "days",
|
||||
"hour_timeUnit": "hours",
|
||||
"minute_timeUnit": "mins",
|
||||
"new_workflow_heading": "Create your first workflow",
|
||||
"new_workflow_description": "Workflows enable you to automate sending reminders and notifications.",
|
||||
"active_on": "Active on",
|
||||
|
@ -978,8 +978,8 @@
|
|||
"web_conference": "Web conference",
|
||||
"number_for_sms_reminders": "Phone number (for SMS reminders)",
|
||||
"requires_confirmation": "Requires confirmation",
|
||||
"nr_event_type_one": "{{count}} Event Type",
|
||||
"nr_event_type_other": "{{count}} Event Types",
|
||||
"nr_event_type_one": "{{count}} event type",
|
||||
"nr_event_type_other": "{{count}} event types",
|
||||
"add_action": "Add action",
|
||||
"set_whereby_link": "Set Whereby link",
|
||||
"invalid_whereby_link": "Please enter a valid Whereby Link",
|
||||
|
@ -991,7 +991,7 @@
|
|||
"add_exchange2013": "Connect Exchange 2013 Server",
|
||||
"add_exchange2016": "Connect Exchange 2016 Server",
|
||||
"custom_template": "Custom template",
|
||||
"email_body": "Body",
|
||||
"email_body": "Email body",
|
||||
"subject": "Subject",
|
||||
"text_message": "Text message",
|
||||
"specific_issue": "Have a specific issue?",
|
||||
|
@ -1019,8 +1019,8 @@
|
|||
"new_seat_title": "Someone has added themselves to an event",
|
||||
"variable": "Variable",
|
||||
"event_name_workflow": "Event name",
|
||||
"organizer_name_workflow": "Organizer name",
|
||||
"attendee_name_workflow": "Attendee name",
|
||||
"organizer_name_workflow": "Organizer",
|
||||
"attendee_name_workflow": "Attendee",
|
||||
"event_date_workflow": "Event date",
|
||||
"event_time_workflow": "Event time",
|
||||
"location_workflow": "Location",
|
||||
|
@ -1038,13 +1038,13 @@
|
|||
"current_username": "Current username",
|
||||
"example_1": "Example 1",
|
||||
"example_2": "Example 2",
|
||||
"additional_input_label": "Additional input label",
|
||||
"additional_input_label": "Additional Input Label",
|
||||
"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 to use additional inputs as variables?",
|
||||
"using_additional_inputs_as_variables": "How do I use Additional Inputs as variables?",
|
||||
"download_desktop_app": "Download desktop app",
|
||||
"set_ping_link": "Set Ping link",
|
||||
"rate_limit_exceeded": "Rate limit exceeded",
|
||||
|
@ -1068,6 +1068,11 @@
|
|||
"select_which_cal":"Select which calendar to add bookings to",
|
||||
"custom_event_name":"Custom event name",
|
||||
"custom_event_name_description":"Create customised event names to display on calendar event",
|
||||
"which_event_type_apply": "Which event type will this apply to?",
|
||||
"no_workflows_description": "Workflows enable simple automation to send notifications & reminders enabling you to build processes around your events.",
|
||||
"create_workflow": "Create a workflow",
|
||||
"do_this": "Do this",
|
||||
"turn_off": "Turn off",
|
||||
"settings_updated_successfully": "Settings updated successfully",
|
||||
"error_updating_settings":"Error updating settings",
|
||||
"personal_cal_url": "My personal Cal URL",
|
||||
|
|
|
@ -10,8 +10,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
if (req.method === "POST") {
|
||||
const { username, password } = req.body;
|
||||
// Get user
|
||||
const user = await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
|
|
|
@ -10,8 +10,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
if (req.method === "POST") {
|
||||
const { username, password, url } = req.body;
|
||||
// Get user
|
||||
const user = await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
|
|
|
@ -19,8 +19,7 @@ const bodySchema = z
|
|||
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = bodySchema.parse(req.body);
|
||||
// Get user
|
||||
const user = await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
|
|
|
@ -19,8 +19,7 @@ const bodySchema = z
|
|||
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const body = bodySchema.parse(req.body);
|
||||
// Get user
|
||||
const user = await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
|
|
|
@ -16,8 +16,7 @@ async function handler(req: NextApiRequest) {
|
|||
|
||||
const { client_id } = await getSlackAppKeys();
|
||||
// Get user
|
||||
await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session.user.id,
|
||||
},
|
||||
|
|
|
@ -43,8 +43,7 @@ export default async function createEvent(req: NextApiRequest, res: NextApiRespo
|
|||
|
||||
// Im sure this query can be made more efficient... The JSON filtering wouldnt work when doing it directly on user.
|
||||
const foundUser = await db.credential
|
||||
.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
.findFirstOrThrow({
|
||||
...WhereCredsEqualsId(user.id),
|
||||
})
|
||||
.user({
|
||||
|
|
|
@ -12,8 +12,7 @@ let base_url = "";
|
|||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method === "GET") {
|
||||
// Get user
|
||||
await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
|
|
|
@ -57,8 +57,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
try {
|
||||
if (event.data.user_id) {
|
||||
const json = { userVitalId: event.data.user_id as string };
|
||||
const credential = await prisma.credential.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
const credential = await prisma.credential.findFirstOrThrow({
|
||||
where: {
|
||||
type: "vital_other",
|
||||
key: {
|
||||
|
|
|
@ -17,7 +17,7 @@ import { getCalendar } from "../../_utils/getCalendar";
|
|||
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username">;
|
||||
|
||||
const Reschedule = async (bookingUid: string, cancellationReason: string) => {
|
||||
const bookingToReschedule = await prisma.booking.findFirst({
|
||||
const bookingToReschedule = await prisma.booking.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
|
@ -42,7 +42,6 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
|
|||
},
|
||||
},
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
NOT: {
|
||||
|
@ -55,13 +54,12 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
|
|||
|
||||
if (bookingToReschedule && bookingToReschedule.eventTypeId && bookingToReschedule.user) {
|
||||
const userOwner = bookingToReschedule.user;
|
||||
const event = await prisma.eventType.findFirst({
|
||||
const event = await prisma.eventType.findFirstOrThrow({
|
||||
select: {
|
||||
title: true,
|
||||
users: true,
|
||||
schedulingType: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
id: bookingToReschedule.eventTypeId,
|
||||
},
|
||||
|
|
|
@ -17,7 +17,7 @@ import { getCalendar } from "../../_utils/getCalendar";
|
|||
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username">;
|
||||
|
||||
const Reschedule = async (bookingUid: string, cancellationReason: string) => {
|
||||
const bookingToReschedule = await prisma.booking.findFirst({
|
||||
const bookingToReschedule = await prisma.booking.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
|
@ -42,7 +42,6 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
|
|||
},
|
||||
},
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
NOT: {
|
||||
|
@ -55,13 +54,12 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
|
|||
|
||||
if (bookingToReschedule && bookingToReschedule.eventTypeId && bookingToReschedule.user) {
|
||||
const userOwner = bookingToReschedule.user;
|
||||
const event = await prisma.eventType.findFirst({
|
||||
const event = await prisma.eventType.findFirstOrThrow({
|
||||
select: {
|
||||
title: true,
|
||||
users: true,
|
||||
schedulingType: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
where: {
|
||||
id: bookingToReschedule.eventTypeId,
|
||||
},
|
||||
|
|
|
@ -9,8 +9,7 @@ import { getZoomAppKeys } from "../lib";
|
|||
|
||||
async function handler(req: NextApiRequest) {
|
||||
// Get user
|
||||
await prisma.user.findFirst({
|
||||
rejectOnNotFound: true,
|
||||
await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: req.session?.user?.id,
|
||||
},
|
||||
|
|
|
@ -95,8 +95,7 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
|
|||
private async getUserById(userId: number) {
|
||||
let resultUser: User | null;
|
||||
try {
|
||||
resultUser = await prisma.user.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
resultUser = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
|
@ -111,8 +110,7 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
|
|||
private async getEventFromEventId(eventTypeId: number) {
|
||||
let resultEventType;
|
||||
try {
|
||||
resultEventType = await prisma.eventType.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
resultEventType = await prisma.eventType.findUniqueOrThrow({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
|
@ -223,8 +221,7 @@ export class CalendarEventBuilder implements ICalendarEventBuilder {
|
|||
public async setUsersFromId(userId: User["id"]) {
|
||||
let resultUser: User | null;
|
||||
try {
|
||||
resultUser = await prisma.user.findUnique({
|
||||
rejectOnNotFound: true,
|
||||
resultUser = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/features/ee/common/components/v2/LicenseRequired.tsx`
|
||||
*/
|
||||
import { useSession } from "next-auth/react";
|
||||
import React, { AriaRole, ComponentType, Fragment } from "react";
|
||||
|
||||
|
@ -13,6 +18,9 @@ type LicenseRequiredProps = {
|
|||
};
|
||||
|
||||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/features/ee/common/components/v2/LicenseRequired.tsx`
|
||||
* This component will only render it's children if the installation has a valid
|
||||
* license.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import { useSession } from "next-auth/react";
|
||||
import React, { AriaRole, ComponentType, Fragment } from "react";
|
||||
|
||||
import { CONSOLE_URL } from "@calcom/lib/constants";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { EmptyScreen } from "@calcom/ui/v2";
|
||||
|
||||
type LicenseRequiredProps = {
|
||||
as?: keyof JSX.IntrinsicElements | "";
|
||||
className?: string;
|
||||
role?: AriaRole | undefined;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* This component will only render it's children if the installation has a valid
|
||||
* license.
|
||||
*/
|
||||
const LicenseRequired = ({ children, as = "", ...rest }: LicenseRequiredProps) => {
|
||||
const session = useSession();
|
||||
const Component = as || Fragment;
|
||||
return (
|
||||
<Component {...rest}>
|
||||
{session.data?.hasValidLicense ? (
|
||||
children
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon={Icon.FiAlertTriangle}
|
||||
headline="This is an enterprise feature"
|
||||
description={
|
||||
<>
|
||||
To enable this feature, get a deployment key at{" "}
|
||||
<a href={CONSOLE_URL} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
Cal.com console
|
||||
</a>{" "}
|
||||
and add it to your .env as <code>CALCOM_LICENSE_KEY</code>. If your team already has a license,
|
||||
please contact{" "}
|
||||
<a href="mailto:peer@cal.com" className="underline">
|
||||
peer@cal.com
|
||||
</a>{" "}
|
||||
for help.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export const withLicenseRequired =
|
||||
<T,>(Component: ComponentType<T>) =>
|
||||
// eslint-disable-next-line react/display-name
|
||||
(hocProps: T) =>
|
||||
(
|
||||
<LicenseRequired>
|
||||
<Component {...hocProps} />
|
||||
</LicenseRequired>
|
||||
);
|
||||
|
||||
export default LicenseRequired;
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/features/ee/workflows/components/v2/AddActionDialog.tsx`
|
||||
*/
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { WorkflowActions } from "@prisma/client";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
|
@ -26,6 +31,11 @@ type AddActionFormValues = {
|
|||
sendTo?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/features/ee/workflows/components/v2/AddActionDialog.tsx`
|
||||
*/
|
||||
export const AddActionDialog = (props: IAddActionDialog) => {
|
||||
const { t } = useLocale();
|
||||
const { isOpenDialog, setIsOpenDialog, addAction } = props;
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/features/ee/workflows/components/v2/AddVariablesDropdown.tsx`
|
||||
*/
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Dropdown, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
|
@ -18,6 +23,11 @@ const variables = [
|
|||
"additional_notes",
|
||||
];
|
||||
|
||||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/features/ee/workflows/components/v2/AddVariablesDropdown.tsx`
|
||||
*/
|
||||
export const AddVariablesDropdown = (props: IAddVariablesDropdown) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
|
@ -38,7 +48,7 @@ export const AddVariablesDropdown = (props: IAddVariablesDropdown) => {
|
|||
key={variable}
|
||||
type="button"
|
||||
className="px-5 py-1"
|
||||
onClick={() => props.addVariable(props.isEmailSubject, variable)}>
|
||||
onClick={() => props.addVariable(props.isEmailSubject, t(`${variable}_workflow`))}>
|
||||
{t(`${variable}_workflow`)}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
|
|
@ -4,7 +4,6 @@ import { useState } from "react";
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { EventType, Workflow, WorkflowsOnEventTypes } from "@calcom/prisma/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, Tooltip } from "@calcom/ui";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
|
@ -13,6 +12,8 @@ import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger }
|
|||
import EmptyScreen from "@calcom/ui/EmptyScreen";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
||||
import { WorkflowType } from "./v2/WorkflowListPage";
|
||||
|
||||
const CreateFirstWorkflowView = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
|
@ -26,11 +27,7 @@ const CreateFirstWorkflowView = () => {
|
|||
};
|
||||
|
||||
interface Props {
|
||||
workflows:
|
||||
| (Workflow & {
|
||||
activeOn: (WorkflowsOnEventTypes & { eventType: EventType })[];
|
||||
})[]
|
||||
| undefined;
|
||||
workflows: WorkflowType[] | undefined;
|
||||
}
|
||||
export default function WorkflowListPage({ workflows }: Props) {
|
||||
const { t } = useLocale();
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { WorkflowActions } from "@prisma/client";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
Form,
|
||||
Select,
|
||||
} from "@calcom/ui/v2";
|
||||
|
||||
import { WORKFLOW_ACTIONS } from "../../lib/constants";
|
||||
import { getWorkflowActionOptions } from "../../lib/getOptions";
|
||||
|
||||
interface IAddActionDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
addAction: (action: WorkflowActions, sendTo?: string) => void;
|
||||
}
|
||||
|
||||
type AddActionFormValues = {
|
||||
action: WorkflowActions;
|
||||
sendTo?: string;
|
||||
};
|
||||
|
||||
export const AddActionDialog = (props: IAddActionDialog) => {
|
||||
const { t } = useLocale();
|
||||
const { isOpenDialog, setIsOpenDialog, addAction } = props;
|
||||
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false);
|
||||
const actionOptions = getWorkflowActionOptions(t);
|
||||
|
||||
const formSchema = z.object({
|
||||
action: z.enum(WORKFLOW_ACTIONS),
|
||||
sendTo: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const form = useForm<AddActionFormValues>({
|
||||
mode: "onSubmit",
|
||||
defaultValues: {
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent type="creation" useOwnActionButtons={true}>
|
||||
<div className="space-x-3 ">
|
||||
<div className="pt-1">
|
||||
<DialogHeader title={t("add_action")} />
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={(values) => {
|
||||
addAction(values.action, values.sendTo);
|
||||
form.unregister("sendTo");
|
||||
form.unregister("action");
|
||||
setIsOpenDialog(false);
|
||||
setIsPhoneNumberNeeded(false);
|
||||
}}>
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="label" className="mt-5 block text-sm font-medium text-gray-700">
|
||||
{t("action")}:
|
||||
</label>
|
||||
<Controller
|
||||
name="action"
|
||||
control={form.control}
|
||||
render={() => {
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
defaultValue={actionOptions[0]}
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
form.setValue("action", val.value);
|
||||
if (val.value === WorkflowActions.SMS_NUMBER) {
|
||||
setIsPhoneNumberNeeded(true);
|
||||
} else {
|
||||
setIsPhoneNumberNeeded(false);
|
||||
form.unregister("sendTo");
|
||||
}
|
||||
form.clearErrors("action");
|
||||
}
|
||||
}}
|
||||
options={actionOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{form.formState.errors.action && (
|
||||
<p className="mt-1 text-sm text-red-500">{form.formState.errors.action.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{isPhoneNumberNeeded && (
|
||||
<div className="mt-5 space-y-1">
|
||||
<label htmlFor="sendTo" className="block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("phone_number")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<PhoneInput<AddActionFormValues>
|
||||
control={form.control}
|
||||
name="sendTo"
|
||||
className="rounded-md"
|
||||
placeholder={t("enter_phone_number")}
|
||||
id="sendTo"
|
||||
required
|
||||
/>
|
||||
{form.formState.errors.sendTo && (
|
||||
<p className="mt-1 text-sm text-red-500">{form.formState.errors.sendTo.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
setIsOpenDialog(false);
|
||||
form.unregister("sendTo");
|
||||
form.unregister("action");
|
||||
setIsPhoneNumberNeeded(false);
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">{t("add")}</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,59 @@
|
|||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/v2/core/Dropdown";
|
||||
|
||||
interface IAddVariablesDropdown {
|
||||
addVariable: (isEmailSubject: boolean, variable: string) => void;
|
||||
isEmailSubject: boolean;
|
||||
}
|
||||
|
||||
const variables = [
|
||||
"event_name",
|
||||
"event_date",
|
||||
"event_time",
|
||||
"location",
|
||||
"organizer_name",
|
||||
"attendee_name",
|
||||
"additional_notes",
|
||||
];
|
||||
|
||||
export const AddVariablesDropdown = (props: IAddVariablesDropdown) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger className="text-sm text-gray-900 focus:bg-transparent focus:ring-transparent focus:ring-offset-0 ">
|
||||
{t("add_variable")}
|
||||
<Icon.FiChevronDown className="ml-1 h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="h-40 overflow-scroll">
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs text-gray-500">{t("add_dynamic_variables").toLocaleUpperCase()}</div>
|
||||
{variables.map((variable) => (
|
||||
<DropdownMenuItem key={variable}>
|
||||
<button
|
||||
key={variable}
|
||||
type="button"
|
||||
className="button w-full py-2"
|
||||
onClick={() => props.addVariable(props.isEmailSubject, t(`${variable}_workflow`))}>
|
||||
<div className="md:grid md:grid-cols-2">
|
||||
<div className="text-left md:col-span-1">
|
||||
{`{${t(`${variable}_workflow`).toUpperCase().replace(" ", "_")}}`}
|
||||
</div>
|
||||
<div className="invisible text-left text-gray-600 md:visible md:col-span-1">
|
||||
{t(`${variable}_info`)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,54 @@
|
|||
import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import ConfirmationDialogContent from "@calcom/ui/ConfirmationDialogContent";
|
||||
import { Dialog } from "@calcom/ui/v2";
|
||||
import { showToast } from "@calcom/ui/v2";
|
||||
|
||||
interface IDeleteDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
workflowId: number;
|
||||
additionalFunction: () => Promise<boolean | void>;
|
||||
}
|
||||
|
||||
export const DeleteDialog = (props: IDeleteDialog) => {
|
||||
const { t } = useLocale();
|
||||
const { isOpenDialog, setIsOpenDialog, workflowId, additionalFunction } = props;
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const deleteMutation = trpc.useMutation("viewer.workflows.delete", {
|
||||
onSuccess: async () => {
|
||||
await utils.invalidateQueries(["viewer.workflows.list"]);
|
||||
additionalFunction();
|
||||
showToast(t("workflow_deleted_successfully"), "success");
|
||||
setIsOpenDialog(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
setIsOpenDialog(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<ConfirmationDialogContent
|
||||
isLoading={deleteMutation.isLoading}
|
||||
variety="danger"
|
||||
title={t("delete_workflow")}
|
||||
confirmBtnText={t("confirm_delete_workflow")}
|
||||
loadingText={t("confirm_delete_workflow")}
|
||||
onConfirm={(e) => {
|
||||
e.preventDefault();
|
||||
deleteMutation.mutate({ id: workflowId });
|
||||
}}>
|
||||
{t("delete_workflow_description")}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,92 @@
|
|||
import React from "react";
|
||||
import { Icon as FeatherIcon } from "react-feather";
|
||||
|
||||
import { SVGComponent } from "@calcom/types/SVGComponent";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/v2";
|
||||
|
||||
const workflowsExamples = [
|
||||
{ icon: Icon.FiMail, text: "Send email reminder 24 hours before event starts to host" },
|
||||
{ icon: Icon.FiSmartphone, text: "Send SMS reminder 1 hour before event starts to host" },
|
||||
{ icon: Icon.FiMail, text: "Send custom email when event is cancelled to host" },
|
||||
{ icon: Icon.FiMail, text: "Send email reminder 24 hours before event starts to attendee" },
|
||||
{ icon: Icon.FiSmartphone, text: "Send SMS reminder 1 hour before event starts to attendee" },
|
||||
{ icon: Icon.FiSmartphone, text: "Send custom SMS when event is rescheduled to attendee" },
|
||||
];
|
||||
|
||||
type WorkflowExampleType = {
|
||||
Icon: FeatherIcon;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function WorkflowExample(props: WorkflowExampleType) {
|
||||
const { Icon, text } = props;
|
||||
|
||||
return (
|
||||
<div className="mx-3 my-3 max-w-[600px] rounded-md border border-solid py-5 pr-5">
|
||||
<div className="flex">
|
||||
<div className="flex w-24 items-center justify-center rounded-sm">
|
||||
<div className="mr-2 ml-2 flex h-[40px] w-[40px] items-center justify-center rounded-full bg-gray-200 dark:bg-white">
|
||||
<Icon className="h-6 w-6 stroke-[1.5px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-full text-sm">{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmptyScreen({
|
||||
IconHeading,
|
||||
headline,
|
||||
description,
|
||||
buttonText,
|
||||
buttonOnClick,
|
||||
isLoading,
|
||||
showExampleWorkflows,
|
||||
}: {
|
||||
IconHeading: SVGComponent | FeatherIcon;
|
||||
headline: string;
|
||||
description: string | React.ReactElement;
|
||||
buttonText?: string;
|
||||
buttonOnClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
isLoading: boolean;
|
||||
showExampleWorkflows: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-80 flex w-full flex-col items-center justify-center rounded-md ">
|
||||
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-200 dark:bg-white">
|
||||
<IconHeading className="inline-block h-10 w-10 stroke-[1.3px] dark:bg-gray-900 dark:text-gray-600" />
|
||||
</div>
|
||||
<div className="max-w-[420px] text-center">
|
||||
<h2 className="text-semibold font-cal mt-6 text-xl dark:text-gray-300">{headline}</h2>
|
||||
<p className="line-clamp-2 mt-3 text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
{buttonOnClick && buttonText && (
|
||||
<Button
|
||||
type="button"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={(e) => buttonOnClick(e)}
|
||||
loading={isLoading}
|
||||
className="mt-8">
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showExampleWorkflows && (
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<div className="grid-cols-none items-center lg:grid lg:grid-cols-3 xl:mx-20">
|
||||
{workflowsExamples.map((example, index) => (
|
||||
<WorkflowExample key={index} Icon={example.icon} text={example.text} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
import { WorkflowActions } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import { Button, Loader, showToast, Switch, Tooltip } from "@calcom/ui/v2";
|
||||
|
||||
import LicenseRequired from "../../../common/components/v2/LicenseRequired";
|
||||
import { getActionIcon } from "../../lib/getActionIcon";
|
||||
import EmptyScreen from "./EmptyScreen";
|
||||
import { WorkflowType } from "./WorkflowListPage";
|
||||
|
||||
type ItemProps = {
|
||||
workflow: WorkflowType;
|
||||
eventType: {
|
||||
id: number;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
|
||||
const WorkflowListItem = (props: ItemProps) => {
|
||||
const { workflow, eventType } = props;
|
||||
const { t } = useLocale();
|
||||
|
||||
const [activeEventTypeIds, setActiveEventTypeIds] = useState(
|
||||
workflow.activeOn.map((active) => {
|
||||
if (active.eventType) {
|
||||
return active.eventType.id;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const isActive = activeEventTypeIds.includes(eventType.id);
|
||||
|
||||
const activateEventTypeMutation = trpc.useMutation("viewer.workflows.activateEventType");
|
||||
|
||||
const sendTo: Set<string> = new Set();
|
||||
|
||||
workflow.steps.forEach((step) => {
|
||||
switch (step.action) {
|
||||
case WorkflowActions.EMAIL_HOST:
|
||||
sendTo.add(t("organizer_name_workflow"));
|
||||
break;
|
||||
case WorkflowActions.EMAIL_ATTENDEE:
|
||||
sendTo.add(t("attendee_name_workflow"));
|
||||
break;
|
||||
case WorkflowActions.SMS_ATTENDEE:
|
||||
sendTo.add(t("attendee_name_workflow"));
|
||||
break;
|
||||
case WorkflowActions.SMS_NUMBER:
|
||||
sendTo.add(step.sendTo || "");
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex w-full items-center rounded-md border border-gray-200 p-4">
|
||||
<div className="mt-[3px] mr-5 flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 p-1 text-xs font-medium">
|
||||
{getActionIcon(
|
||||
workflow.steps,
|
||||
isActive ? "h-7 w-7 stroke-[1.5px] text-gray-700" : "h-7 w-7 stroke-[1.5px] text-gray-400"
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 grow">
|
||||
<div
|
||||
className={classNames(
|
||||
"w-full truncate text-sm font-medium leading-6 text-gray-900 md:max-w-max",
|
||||
workflow.name && isActive ? "text-gray-900" : "text-neutral-500"
|
||||
)}>
|
||||
{workflow.name
|
||||
? workflow.name
|
||||
: "Untitled (" +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.charAt(0).toUpperCase() +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
|
||||
")"}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"mb-1 flex w-fit items-center whitespace-nowrap rounded-sm py-px text-sm",
|
||||
isActive ? "text-gray-600" : "text-gray-400"
|
||||
)}>
|
||||
<span className="mr-1">{t("to")}:</span>
|
||||
{Array.from(sendTo).map((sendToPerson, index) => {
|
||||
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-none sm:mr-3">
|
||||
<Link href={`/workflows/${workflow.id}`} passHref={true}>
|
||||
<a target="_blank">
|
||||
<Button type="button" color="minimal" className="text-sm text-gray-900 hover:bg-transparent">
|
||||
{t("edit")}
|
||||
<Icon.FiExternalLink className="ml-2 -mt-[2px] h-4 w-4 stroke-2 text-gray-600" />
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<Tooltip content={t("turn_off") as string}>
|
||||
<div className="">
|
||||
<Switch
|
||||
checked={isActive}
|
||||
onCheckedChange={() => {
|
||||
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
|
||||
if (activeEventTypeIds.includes(eventType.id)) {
|
||||
const newActiveEventTypeIds = activeEventTypeIds.filter((id) => {
|
||||
return id !== eventType.id;
|
||||
});
|
||||
setActiveEventTypeIds(newActiveEventTypeIds);
|
||||
} else {
|
||||
const newActiveEventTypeIds = activeEventTypeIds;
|
||||
newActiveEventTypeIds.push(eventType.id);
|
||||
setActiveEventTypeIds(newActiveEventTypeIds);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
eventType: {
|
||||
id: number;
|
||||
title: string;
|
||||
};
|
||||
workflows: WorkflowType[];
|
||||
};
|
||||
|
||||
function EventWorkflowsTab(props: Props) {
|
||||
const { workflows } = props;
|
||||
const { t } = useLocale();
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.workflows.list"]);
|
||||
const router = useRouter();
|
||||
const [sortedWorkflows, setSortedWorkflows] = useState<Array<WorkflowType>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.workflows) {
|
||||
const activeWorkflows = workflows.map((workflowOnEventType) => {
|
||||
return workflowOnEventType;
|
||||
});
|
||||
const disabledWorkflows = data.workflows.filter(
|
||||
(workflow) =>
|
||||
!workflows
|
||||
.map((workflow) => {
|
||||
return workflow.id;
|
||||
})
|
||||
.includes(workflow.id)
|
||||
);
|
||||
setSortedWorkflows(activeWorkflows.concat(disabledWorkflows));
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
const createMutation = trpc.useMutation("viewer.workflows.createV2", {
|
||||
onSuccess: async ({ workflow }) => {
|
||||
await router.replace("/workflows/" + workflow.id);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
const message = `${err.data.code}: You are not able to create this workflow`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<LicenseRequired>
|
||||
{!isLoading ? (
|
||||
data?.workflows && data?.workflows.length > 0 ? (
|
||||
<div className="mt-6">
|
||||
{sortedWorkflows.map((workflow) => {
|
||||
return <WorkflowListItem key={workflow.id} workflow={workflow} eventType={props.eventType} />;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
buttonText={t("create_workflow")}
|
||||
buttonOnClick={() => createMutation.mutate()}
|
||||
IconHeading={Icon.FiZap}
|
||||
headline={t("workflows")}
|
||||
description={t("no_workflows_description")}
|
||||
isLoading={createMutation.isLoading}
|
||||
showExampleWorkflows={false}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</LicenseRequired>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventWorkflowsTab;
|
|
@ -0,0 +1,74 @@
|
|||
import { useState } from "react";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
TextField,
|
||||
} from "@calcom/ui/v2";
|
||||
|
||||
import { getWorkflowTimeUnitOptions } from "../../lib/getOptions";
|
||||
import type { FormValues } from "../../pages/v2/workflow";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<FormValues>;
|
||||
};
|
||||
|
||||
export const TimeTimeUnitInput = (props: Props) => {
|
||||
const { form } = props;
|
||||
const { t } = useLocale();
|
||||
const timeUnitOptions = getWorkflowTimeUnitOptions(t);
|
||||
|
||||
const [timeUnit, setTimeUnit] = useState(form.getValues("timeUnit"));
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="grow">
|
||||
<TextField
|
||||
type="number"
|
||||
min="1"
|
||||
label=""
|
||||
defaultValue={form.getValues("time") || 24}
|
||||
className="-mt-2 rounded-r-none text-sm focus:ring-0"
|
||||
{...form.register("time", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="-ml-1 h-9 w-24 rounded-r-md border border-gray-300 bg-gray-50 px-3 py-1 text-sm">
|
||||
<div className="flex">
|
||||
<div className="w-3/4">
|
||||
{timeUnit ? t(`${timeUnit.toLowerCase()}_timeUnit`) : "undefined"}{" "}
|
||||
</div>
|
||||
<div className="w-1/4 pt-1">
|
||||
<Icon.FiChevronDown />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{timeUnitOptions.map((option, index) => (
|
||||
<DropdownMenuItem key={index} className="outline-none">
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className="h-8 w-20 justify-start pl-3 text-left text-sm"
|
||||
onClick={() => {
|
||||
setTimeUnit(option.value);
|
||||
form.setValue("timeUnit", option.value);
|
||||
}}>
|
||||
{option.label}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,162 @@
|
|||
import { ArrowDownIcon } from "@heroicons/react/outline";
|
||||
import { WorkflowActions, WorkflowTemplates } from "@prisma/client";
|
||||
import { useRouter } from "next/router";
|
||||
import { Dispatch, SetStateAction, useMemo, useState } from "react";
|
||||
import { Controller, UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/v2";
|
||||
import { Label, TextField } from "@calcom/ui/v2";
|
||||
import MultiSelectCheckboxes, { Option } from "@calcom/ui/v2/core/form/MultiSelectCheckboxes";
|
||||
|
||||
import type { FormValues } from "../../pages/v2/workflow";
|
||||
import { AddActionDialog } from "./AddActionDialog";
|
||||
import { DeleteDialog } from "./DeleteDialog";
|
||||
import WorkflowStepContainer from "./WorkflowStepContainer";
|
||||
|
||||
interface Props {
|
||||
form: UseFormReturn<FormValues>;
|
||||
workflowId: number;
|
||||
selectedEventTypes: Option[];
|
||||
setSelectedEventTypes: Dispatch<SetStateAction<Option[]>>;
|
||||
}
|
||||
|
||||
export default function WorkflowDetailsPage(props: Props) {
|
||||
const { form, workflowId, selectedEventTypes, setSelectedEventTypes } = props;
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
|
||||
const [reload, setReload] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.eventTypes"]);
|
||||
|
||||
const eventTypeOptions = useMemo(
|
||||
() =>
|
||||
data?.eventTypeGroups.reduce(
|
||||
(options, group) => [
|
||||
...options,
|
||||
...group.eventTypes.map((eventType) => ({
|
||||
value: String(eventType.id),
|
||||
label: eventType.title,
|
||||
})),
|
||||
],
|
||||
[] as Option[]
|
||||
) || [],
|
||||
[data]
|
||||
);
|
||||
|
||||
const addAction = (action: WorkflowActions, sendTo?: string) => {
|
||||
const steps = form.getValues("steps");
|
||||
const id =
|
||||
steps?.length > 0
|
||||
? steps.sort((a, b) => {
|
||||
return a.id - b.id;
|
||||
})[0].id - 1
|
||||
: 0;
|
||||
|
||||
const step = {
|
||||
id: id > 0 ? 0 : id, //id of new steps always <= 0
|
||||
action,
|
||||
stepNumber:
|
||||
steps && steps.length > 0
|
||||
? steps.sort((a, b) => {
|
||||
return a.stepNumber - b.stepNumber;
|
||||
})[steps.length - 1].stepNumber + 1
|
||||
: 1,
|
||||
sendTo: sendTo || null,
|
||||
workflowId: workflowId,
|
||||
reminderBody: null,
|
||||
emailSubject: null,
|
||||
template: WorkflowTemplates.CUSTOM,
|
||||
};
|
||||
steps?.push(step);
|
||||
form.setValue("steps", steps);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:flex">
|
||||
<div className="pl-2 pr-3 md:pl-0">
|
||||
<div className="mb-5">
|
||||
<TextField label={`${t("workflow_name")}:`} type="text" {...form.register("name")} />
|
||||
</div>
|
||||
<Label className="text-sm font-medium">{t("which_event_type_apply")}:</Label>
|
||||
<Controller
|
||||
name="activeOn"
|
||||
control={form.control}
|
||||
render={() => {
|
||||
return (
|
||||
<MultiSelectCheckboxes
|
||||
options={eventTypeOptions}
|
||||
isLoading={isLoading}
|
||||
setSelected={setSelectedEventTypes}
|
||||
selected={selectedEventTypes}
|
||||
setValue={(s: Option[]) => {
|
||||
form.setValue("activeOn", s);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="my-7 border-transparent md:border-t md:border-gray-200" />
|
||||
<Button
|
||||
type="button"
|
||||
StartIcon={Icon.FiTrash2}
|
||||
color="destructive"
|
||||
className="border"
|
||||
onClick={() => setDeleteDialogOpen(true)}>
|
||||
{t("delete_workflow")}
|
||||
</Button>
|
||||
<div className="my-7 border-t border-gray-200 md:border-none" />
|
||||
</div>
|
||||
|
||||
{/* Workflow Trigger Event & Steps */}
|
||||
<div className="w-full rounded-md border bg-gray-100 p-3 py-5 md:ml-3 md:max-h-[calc(100vh-116px)] md:overflow-scroll md:p-8">
|
||||
{form.getValues("trigger") && (
|
||||
<div>
|
||||
<WorkflowStepContainer form={form} />
|
||||
</div>
|
||||
)}
|
||||
{form.getValues("steps") && (
|
||||
<>
|
||||
{form.getValues("steps")?.map((step) => {
|
||||
return (
|
||||
<WorkflowStepContainer
|
||||
key={step.id}
|
||||
form={form}
|
||||
step={step}
|
||||
reload={reload}
|
||||
setReload={setReload}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<div className="my-3 flex justify-center">
|
||||
<Icon.FiArrowDown className="stroke-[1.5px] text-3xl text-gray-500" />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button type="button" onClick={() => setIsAddActionDialogOpen(true)} color="secondary">
|
||||
{t("add_action")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddActionDialog
|
||||
isOpenDialog={isAddActionDialogOpen}
|
||||
setIsOpenDialog={setIsAddActionDialogOpen}
|
||||
addAction={addAction}
|
||||
/>
|
||||
<DeleteDialog
|
||||
isOpenDialog={deleteDialogOpen}
|
||||
setIsOpenDialog={setDeleteDialogOpen}
|
||||
workflowId={workflowId}
|
||||
additionalFunction={async () => await router.push("/workflows")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
import { Workflow, WorkflowStep } from "@prisma/client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button } from "@calcom/ui/v2";
|
||||
|
||||
import { getActionIcon } from "../../lib/getActionIcon";
|
||||
import { DeleteDialog } from "./DeleteDialog";
|
||||
import EmptyScreen from "./EmptyScreen";
|
||||
|
||||
const CreateEmptyWorkflowView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const createMutation = trpc.useMutation("viewer.workflows.createV2", {
|
||||
onSuccess: async ({ workflow }) => {
|
||||
await router.replace("/workflows/" + workflow.id);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
const message = `${err.data.code}: You are not able to create this workflow`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<EmptyScreen
|
||||
buttonText={t("create_workflow")}
|
||||
buttonOnClick={() => createMutation.mutate()}
|
||||
IconHeading={Icon.FiZap}
|
||||
headline={t("workflows")}
|
||||
description={t("no_workflows_description")}
|
||||
isLoading={createMutation.isLoading}
|
||||
showExampleWorkflows={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type WorkflowType = Workflow & {
|
||||
steps: WorkflowStep[];
|
||||
activeOn: {
|
||||
eventType: {
|
||||
id: number;
|
||||
title: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
interface Props {
|
||||
workflows: WorkflowType[] | undefined;
|
||||
}
|
||||
export default function WorkflowListPage({ workflows }: Props) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
{workflows && workflows.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-md border border-gray-200 bg-white sm:mx-0">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{workflows.map((workflow) => (
|
||||
<li key={workflow.id}>
|
||||
<div className="first-line:group flex w-full items-center justify-between p-4 hover:bg-neutral-50 sm:px-6">
|
||||
<Link href={"/workflows/" + workflow.id}>
|
||||
<a className="flex-grow cursor-pointer">
|
||||
<div className="rtl:space-x-reverse">
|
||||
<div
|
||||
className={classNames(
|
||||
"max-w-56 truncate text-sm font-medium leading-6 text-gray-900 md:max-w-max",
|
||||
workflow.name ? "text-gray-900" : "text-neutral-500"
|
||||
)}>
|
||||
{workflow.name
|
||||
? workflow.name
|
||||
: "Untitled (" +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
|
||||
")"}
|
||||
</div>
|
||||
<ul className="mt-2 flex flex-wrap space-x-1 sm:flex-nowrap ">
|
||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
||||
<div>
|
||||
{getActionIcon(workflow.steps)}
|
||||
|
||||
<span className="mr-1">{t("triggers")}</span>
|
||||
{workflow.timeUnit && workflow.time && (
|
||||
<span className="mr-1">
|
||||
{t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })}
|
||||
</span>
|
||||
)}
|
||||
<span>{t(`${workflow.trigger.toLowerCase()}_trigger`)}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li className="mb-1 flex items-center whitespace-nowrap rounded-sm bg-gray-100 px-1 py-px text-xs text-gray-800 dark:bg-gray-900 dark:text-white">
|
||||
{workflow.activeOn && workflow.activeOn.length > 0 ? (
|
||||
<div>
|
||||
<Tooltip
|
||||
content={workflow.activeOn.map((activeOn, key) => (
|
||||
<p key={key}>{activeOn.eventType.title}</p>
|
||||
))}>
|
||||
<>
|
||||
<Icon.FiLink className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{t("active_on_event_types", { count: workflow.activeOn.length })}
|
||||
</>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Icon.FiLink className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
|
||||
{t("no_active_event_types")}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex flex-shrink-0">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
onClick={async () => await router.replace("/workflows/" + workflow.id)}>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiMoreHorizontal}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
setwWorkflowToDeleteId(workflow.id);
|
||||
}}
|
||||
color="destructive"
|
||||
StartIcon={Icon.FiTrash2}>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<DeleteDialog
|
||||
isOpenDialog={deleteDialogOpen}
|
||||
setIsOpenDialog={setDeleteDialogOpen}
|
||||
workflowId={workflowToDeleteId}
|
||||
additionalFunction={async () => {
|
||||
await utils.invalidateQueries(["viewer.workflows.list"]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CreateEmptyWorkflowView />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,548 @@
|
|||
import {
|
||||
TimeUnit,
|
||||
WorkflowActions,
|
||||
WorkflowStep,
|
||||
WorkflowTemplates,
|
||||
WorkflowTriggerEvents,
|
||||
} from "@prisma/client";
|
||||
import { Dispatch, SetStateAction, useRef, useState } from "react";
|
||||
import { Controller, UseFormReturn } from "react-hook-form";
|
||||
import "react-phone-number-input/style.css";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import showToast from "@calcom/lib/notification";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Dialog } from "@calcom/ui/Dialog";
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import PhoneInput from "@calcom/ui/form/PhoneInputLazy";
|
||||
import { Button, DialogClose, DialogContent } from "@calcom/ui/v2";
|
||||
import ConfirmationDialogContent from "@calcom/ui/v2/core/ConfirmationDialogContent";
|
||||
import Select from "@calcom/ui/v2/core/form/Select";
|
||||
import { Label, TextArea } from "@calcom/ui/v2/core/form/fields";
|
||||
|
||||
import { AddVariablesDropdown } from "../../components/v2/AddVariablesDropdown";
|
||||
import {
|
||||
getWorkflowActionOptions,
|
||||
getWorkflowTemplateOptions,
|
||||
getWorkflowTriggerOptions,
|
||||
} from "../../lib/getOptions";
|
||||
import { translateVariablesToEnglish } from "../../lib/variableTranslations";
|
||||
import type { FormValues } from "../../pages/v2/workflow";
|
||||
import { TimeTimeUnitInput } from "./TimeTimeUnitInput";
|
||||
|
||||
type WorkflowStepProps = {
|
||||
step?: WorkflowStep;
|
||||
form: UseFormReturn<FormValues>;
|
||||
reload?: boolean;
|
||||
setReload?: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export default function WorkflowStepContainer(props: WorkflowStepProps) {
|
||||
const { t, i18n } = useLocale();
|
||||
const { step, form, reload, setReload } = props;
|
||||
const [isAdditionalInputsDialogOpen, setIsAdditionalInputsDialogOpen] = useState(false);
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
|
||||
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(
|
||||
step?.action === WorkflowActions.SMS_NUMBER ? 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
|
||||
? true
|
||||
: false
|
||||
);
|
||||
|
||||
const [showTimeSection, setShowTimeSection] = useState(
|
||||
form.getValues("trigger") === WorkflowTriggerEvents.BEFORE_EVENT ? true : false
|
||||
);
|
||||
|
||||
const actionOptions = getWorkflowActionOptions(t);
|
||||
const triggerOptions = getWorkflowTriggerOptions(t);
|
||||
const templateOptions = getWorkflowTemplateOptions(t);
|
||||
|
||||
const { ref: emailSubjectFormRef, ...restEmailSubjectForm } = step
|
||||
? form.register(`steps.${step.stepNumber - 1}.emailSubject`)
|
||||
: { ref: null, name: "" };
|
||||
|
||||
const { ref: reminderBodyFormRef, ...restReminderBodyForm } = step
|
||||
? form.register(`steps.${step.stepNumber - 1}.reminderBody`)
|
||||
: { ref: null, name: "" };
|
||||
|
||||
const refEmailSubject = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const refReminderBody = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const addVariable = (isEmailSubject: boolean, variable: string) => {
|
||||
if (step) {
|
||||
if (isEmailSubject) {
|
||||
const currentEmailSubject = refEmailSubject?.current?.value || "";
|
||||
const cursorPosition = refEmailSubject?.current?.selectionStart || currentEmailSubject.length;
|
||||
const subjectWithAddedVariable = `${currentEmailSubject.substring(0, cursorPosition)}{${variable
|
||||
.toUpperCase()
|
||||
.replace(" ", "_")}}${currentEmailSubject.substring(cursorPosition)}`;
|
||||
form.setValue(`steps.${step.stepNumber - 1}.emailSubject`, subjectWithAddedVariable);
|
||||
} else {
|
||||
const currentMessageBody = refReminderBody?.current?.value || "";
|
||||
const cursorPosition = refReminderBody?.current?.selectionStart || currentMessageBody.length;
|
||||
const messageWithAddedVariable = `${currentMessageBody.substring(0, cursorPosition)}{${variable
|
||||
.toUpperCase()
|
||||
.replace(" ", "_")}}${currentMessageBody.substring(cursorPosition)}`;
|
||||
form.setValue(`steps.${step.stepNumber - 1}.reminderBody`, messageWithAddedVariable);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const testActionMutation = trpc.useMutation("viewer.workflows.testAction", {
|
||||
onSuccess: async () => {
|
||||
showToast(t("notification_sent"), "success");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
//trigger
|
||||
if (!step) {
|
||||
const trigger = form.getValues("trigger");
|
||||
const triggerString = t(`${trigger.toLowerCase()}_trigger`);
|
||||
const timeUnit = form.getValues("timeUnit");
|
||||
|
||||
const selectedTrigger = {
|
||||
label: triggerString.charAt(0).toUpperCase() + triggerString.slice(1),
|
||||
value: trigger,
|
||||
};
|
||||
const selectedTimeUnit = timeUnit
|
||||
? { label: t(`${timeUnit.toLowerCase()}_timeUnit`), value: timeUnit }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-center">
|
||||
<div className="min-w-80 w-full rounded-md border border-gray-200 bg-white p-7">
|
||||
<div className="flex">
|
||||
<div className="mt-[3px] mr-5 flex h-5 w-5 items-center justify-center rounded-full bg-gray-100 p-1 text-xs font-medium">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-bold">{t("trigger")}</div>
|
||||
<div className="text-sm text-gray-600">{t("when_something_happens")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-7 border-t border-gray-200" />
|
||||
<Label className="block text-sm font-medium text-gray-700">{t("when")}</Label>
|
||||
<Controller
|
||||
name="trigger"
|
||||
control={form.control}
|
||||
render={() => {
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
form.setValue("trigger", val.value);
|
||||
if (val.value === WorkflowTriggerEvents.BEFORE_EVENT) {
|
||||
setShowTimeSection(true);
|
||||
form.setValue("time", 24);
|
||||
form.setValue("timeUnit", TimeUnit.HOUR);
|
||||
} else {
|
||||
setShowTimeSection(false);
|
||||
form.unregister("time");
|
||||
form.unregister("timeUnit");
|
||||
}
|
||||
}
|
||||
}}
|
||||
defaultValue={selectedTrigger}
|
||||
options={triggerOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{showTimeSection && (
|
||||
<div className="mt-5 space-y-1">
|
||||
<Label className="block text-sm font-medium text-gray-700">{t("how_long_before")}</Label>
|
||||
<TimeTimeUnitInput form={form} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (step && step.action) {
|
||||
const selectedAction = { label: t(`${step.action.toLowerCase()}_action`), value: step.action };
|
||||
const selectedTemplate = { label: t(`${step.template.toLowerCase()}`), value: step.template };
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="my-3 flex justify-center">
|
||||
<Icon.FiArrowDown className="stroke-[1.5px] text-3xl text-gray-500" />
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="min-w-80 flex w-full rounded-md border border-gray-200 bg-white p-7">
|
||||
<div className="w-full">
|
||||
<div className="flex">
|
||||
<div className="w-full">
|
||||
<div className="flex">
|
||||
<div className="mt-[3px] mr-5 flex h-5 w-5 items-center justify-center rounded-full bg-gray-100 p-1 text-xs">
|
||||
{step.stepNumber + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-bold">{t("action")}</div>
|
||||
<div className="text-sm text-gray-600">{t("action_is_performed")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiMoreHorizontal} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const steps = form.getValues("steps");
|
||||
const updatedSteps = steps
|
||||
?.filter((currStep) => currStep.id !== step.id)
|
||||
.map((s) => {
|
||||
const updatedStep = s;
|
||||
if (step.stepNumber < updatedStep.stepNumber) {
|
||||
updatedStep.stepNumber = updatedStep.stepNumber - 1;
|
||||
}
|
||||
return updatedStep;
|
||||
});
|
||||
form.setValue("steps", updatedSteps);
|
||||
if (setReload) {
|
||||
setReload(!reload);
|
||||
}
|
||||
}}
|
||||
color="secondary"
|
||||
size="base"
|
||||
StartIcon={Icon.FiTrash2}
|
||||
className="w-full rounded-none">
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-7 border-t border-gray-200" />
|
||||
<div>
|
||||
<Label className="block text-sm font-medium text-gray-700">{t("do_this")}</Label>
|
||||
<Controller
|
||||
name={`steps.${step.stepNumber - 1}.action`}
|
||||
control={form.control}
|
||||
render={() => {
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
if (val.value === WorkflowActions.SMS_NUMBER) {
|
||||
setIsPhoneNumberNeeded(true);
|
||||
} else {
|
||||
setIsPhoneNumberNeeded(false);
|
||||
}
|
||||
if (
|
||||
val.value === WorkflowActions.EMAIL_ATTENDEE ||
|
||||
val.value === WorkflowActions.EMAIL_HOST
|
||||
) {
|
||||
setIsEmailSubjectNeeded(true);
|
||||
} else {
|
||||
setIsEmailSubjectNeeded(false);
|
||||
}
|
||||
form.setValue(`steps.${step.stepNumber - 1}.action`, val.value);
|
||||
}
|
||||
}}
|
||||
defaultValue={selectedAction}
|
||||
options={actionOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{form.getValues(`steps.${step.stepNumber - 1}.action`) === WorkflowActions.SMS_ATTENDEE && (
|
||||
<div className="mt-2 flex items-center text-sm text-gray-600">
|
||||
<Icon.FiInfo className="mr-2 h-3 w-3" />
|
||||
<p>{t("attendee_required_enter_number")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isPhoneNumberNeeded && (
|
||||
<div className="mt-5 rounded-md bg-gray-50 p-5">
|
||||
<label
|
||||
htmlFor="sendTo"
|
||||
className="mb-2 block text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("custom_phone_number")}
|
||||
</label>
|
||||
<PhoneInput<FormValues>
|
||||
control={form.control}
|
||||
name={`steps.${step.stepNumber - 1}.sendTo`}
|
||||
placeholder={t("phone_number")}
|
||||
id={`steps.${step.stepNumber - 1}.sendTo`}
|
||||
className="w-full rounded-md"
|
||||
required
|
||||
/>
|
||||
{form.formState.errors.steps &&
|
||||
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-5">
|
||||
<label htmlFor="label" className="mt-5 block text-sm font-medium text-gray-700">
|
||||
{t("message_template")}
|
||||
</label>
|
||||
<Controller
|
||||
name={`steps.${step.stepNumber - 1}.template`}
|
||||
control={form.control}
|
||||
render={() => {
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
className="mt-3 block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
onChange={(val) => {
|
||||
if (val) {
|
||||
form.setValue(`steps.${step.stepNumber - 1}.template`, val.value);
|
||||
const isCustomTemplate = val.value === WorkflowTemplates.CUSTOM;
|
||||
setIsCustomReminderBodyNeeded(isCustomTemplate);
|
||||
}
|
||||
}}
|
||||
defaultValue={selectedTemplate}
|
||||
options={templateOptions}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isCustomReminderBodyNeeded && (
|
||||
<div className="mt-2 rounded-md bg-gray-50 px-5 pb-5">
|
||||
{isEmailSubjectNeeded && (
|
||||
<>
|
||||
<div className="flex">
|
||||
<label className="mt-5 flex-none text-sm font-medium text-gray-700 dark:text-white">
|
||||
{t("email_subject")}
|
||||
</label>
|
||||
<div className="mt-3 -mb-1 flex-grow text-right">
|
||||
<AddVariablesDropdown addVariable={addVariable} isEmailSubject={true} />
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
ref={(e) => {
|
||||
emailSubjectFormRef?.(e);
|
||||
refEmailSubject.current = e;
|
||||
}}
|
||||
required
|
||||
{...restEmailSubjectForm}
|
||||
/>
|
||||
{form.formState.errors.steps &&
|
||||
form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject && (
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
{form.formState?.errors?.steps[step.stepNumber - 1]?.emailSubject?.message || ""}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex">
|
||||
<label className="mt-5 flex-none text-sm font-medium text-gray-700 dark:text-white">
|
||||
{isEmailSubjectNeeded ? t("email_body") : t("text_message")}
|
||||
</label>
|
||||
<div className="mt-3 -mb-1 flex-grow text-right">
|
||||
<AddVariablesDropdown addVariable={addVariable} isEmailSubject={false} />
|
||||
</div>
|
||||
</div>
|
||||
<TextArea
|
||||
ref={(e) => {
|
||||
reminderBodyFormRef?.(e);
|
||||
refReminderBody.current = e;
|
||||
}}
|
||||
className="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="mt-2 flex items-center text-sm text-gray-600">
|
||||
<Icon.FiHelpCircle className="mr-2 h-3 w-3" />
|
||||
<p>{t("using_additional_inputs_as_variables")}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{form.getValues(`steps.${step.stepNumber - 1}.action`) !== WorkflowActions.SMS_ATTENDEE && (
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-7 w-full"
|
||||
onClick={() => {
|
||||
let isEmpty = false;
|
||||
if (!form.getValues(`steps.${step.stepNumber - 1}.sendTo`) && isPhoneNumberNeeded) {
|
||||
form.setError(`steps.${step.stepNumber - 1}.sendTo`, {
|
||||
type: "custom",
|
||||
message: t("no_input"),
|
||||
});
|
||||
isEmpty = true;
|
||||
}
|
||||
if (
|
||||
form.getValues(`steps.${step.stepNumber - 1}.template`) === WorkflowTemplates.CUSTOM
|
||||
) {
|
||||
if (!form.getValues(`steps.${step.stepNumber - 1}.reminderBody`)) {
|
||||
form.setError(`steps.${step.stepNumber - 1}.reminderBody`, {
|
||||
type: "custom",
|
||||
message: t("no_input"),
|
||||
});
|
||||
isEmpty = true;
|
||||
} else if (
|
||||
isEmailSubjectNeeded &&
|
||||
!form.getValues(`steps.${step.stepNumber - 1}.emailSubject`)
|
||||
) {
|
||||
form.setError(`steps.${step.stepNumber - 1}.emailSubject`, {
|
||||
type: "custom",
|
||||
message: t("no_input"),
|
||||
});
|
||||
isEmpty = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPhoneNumberNeeded && !isEmpty) {
|
||||
//translate body and reminder to english
|
||||
const emailSubject = translateVariablesToEnglish(
|
||||
form.getValues(`steps.${step.stepNumber - 1}.emailSubject`) || "",
|
||||
{ locale: i18n.language, t }
|
||||
);
|
||||
const reminderBody = translateVariablesToEnglish(
|
||||
form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || "",
|
||||
{ locale: i18n.language, t }
|
||||
);
|
||||
|
||||
testActionMutation.mutate({
|
||||
action: step.action,
|
||||
emailSubject,
|
||||
reminderBody,
|
||||
template: step.template,
|
||||
});
|
||||
}
|
||||
|
||||
const isNumberValid =
|
||||
form.formState.errors.steps &&
|
||||
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo
|
||||
? false
|
||||
: true;
|
||||
|
||||
if (isPhoneNumberNeeded && isNumberValid && !isEmpty) {
|
||||
setConfirmationDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
color="secondary">
|
||||
<div className="w-full">{t("test_action")}</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={confirmationDialogOpen} onOpenChange={setConfirmationDialogOpen}>
|
||||
<ConfirmationDialogContent
|
||||
variety="warning"
|
||||
title={t("test_workflow_action")}
|
||||
confirmBtnText={t("send_sms")}
|
||||
onConfirm={(e) => {
|
||||
e.preventDefault();
|
||||
const reminderBody = translateVariablesToEnglish(
|
||||
form.getValues(`steps.${step.stepNumber - 1}.reminderBody`) || "",
|
||||
{ locale: i18n.language, t }
|
||||
);
|
||||
|
||||
testActionMutation.mutate({
|
||||
action: step.action,
|
||||
emailSubject: "",
|
||||
reminderBody: reminderBody || "",
|
||||
template: step.template,
|
||||
sendTo: step.sendTo || "",
|
||||
});
|
||||
setConfirmationDialogOpen(false);
|
||||
}}>
|
||||
{t("send_sms_to_number", { number: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) })}
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={isAdditionalInputsDialogOpen} onOpenChange={setIsAdditionalInputsDialogOpen}>
|
||||
<DialogContent useOwnActionButtons type="creation" className="sm:max-w-[600px] md:h-[570px]">
|
||||
<div className="-m-3 h-[440px] overflow-x-hidden overflow-y-scroll sm:m-0">
|
||||
<h1 className="w-full text-xl font-semibold ">{t("how_additional_inputs_as_variables")}</h1>
|
||||
<div className="mt-7 rounded-md bg-gray-50 p-3 sm:p-5">
|
||||
<p className="test-sm font-medium">{t("format")}</p>
|
||||
<ul className="mt-2 ml-5 list-disc text-gray-900">
|
||||
<li>{t("uppercase_for_letters")}</li>
|
||||
<li>{t("replace_whitespaces_underscores")}</li>
|
||||
<li>{t("ingore_special_characters")}</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 col-span-5 mr-2 text-gray-600">{t("additional_input_label")}</div>
|
||||
<div className="test-sm col-span-7 text-gray-900">{t("company_size")}</div>
|
||||
<div className="test-sm col-span-5 w-full text-gray-600">{t("variable")}</div>
|
||||
|
||||
<div className="test-sm col-span-7 break-words text-gray-900">
|
||||
{" "}
|
||||
{`{${t("company_size")
|
||||
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||
.trim()
|
||||
.replace(/ /g, "_")
|
||||
.toUpperCase()}}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<p className="test-sm w-full font-medium">{t("example_2")}</p>
|
||||
<div className="mt-2 grid grid-cols-12">
|
||||
<div className="test-sm col-span-5 mr-2 text-gray-600">{t("additional_input_label")}</div>
|
||||
<div className="test-sm col-span-7 text-gray-900">{t("what_help_needed")}</div>
|
||||
<div className="test-sm col-span-5 text-gray-600">{t("variable")}</div>
|
||||
<div className="test-sm col-span-7 break-words text-gray-900">
|
||||
{" "}
|
||||
{`{${t("what_help_needed")
|
||||
.replace(/[^a-zA-Z0-9 ]/g, "")
|
||||
.trim()
|
||||
.replace(/ /g, "_")
|
||||
.toUpperCase()}}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 -mb-7 flex flex-row-reverse gap-x-2">
|
||||
<DialogClose asChild>
|
||||
<Button color="primary" type="button">
|
||||
{t("close")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { WorkflowActions, WorkflowStep } from "@prisma/client";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
|
||||
export function getActionIcon(steps: WorkflowStep[], className?: string): JSX.Element {
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<Icon.FiZap
|
||||
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (steps.length === 1) {
|
||||
if (steps[0].action === WorkflowActions.SMS_ATTENDEE || steps[0].action === WorkflowActions.SMS_NUMBER) {
|
||||
return (
|
||||
<Icon.FiSmartphone
|
||||
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Icon.FiMail
|
||||
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (steps.length > 1) {
|
||||
let messageType = "";
|
||||
|
||||
for (const step of steps) {
|
||||
if (!messageType) {
|
||||
messageType =
|
||||
step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.SMS_NUMBER
|
||||
? "SMS"
|
||||
: "EMAIL";
|
||||
} else if (messageType !== "MIX") {
|
||||
const newMessageType =
|
||||
step.action === WorkflowActions.SMS_ATTENDEE || step.action === WorkflowActions.SMS_NUMBER
|
||||
? "SMS"
|
||||
: "EMAIL";
|
||||
if (newMessageType !== messageType) {
|
||||
messageType = "MIX";
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
switch (messageType) {
|
||||
case "SMS":
|
||||
return (
|
||||
<Icon.FiSmartphone
|
||||
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
case "EMAIL":
|
||||
return (
|
||||
<Icon.FiMail
|
||||
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
case "MIX":
|
||||
return (
|
||||
<Icon.FiBell
|
||||
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
<Icon.FiZap
|
||||
className={classNames(className ? className : "mr-1.5 inline h-3 w-3")}
|
||||
aria-hidden="true"
|
||||
/>;
|
||||
}
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
|
@ -10,7 +10,9 @@ export function getWorkflowActionOptions(t: TFunction) {
|
|||
|
||||
export function getWorkflowTriggerOptions(t: TFunction) {
|
||||
return WORKFLOW_TRIGGER_EVENTS.map((triggerEvent) => {
|
||||
return { label: t(`${triggerEvent.toLowerCase()}_trigger`), value: triggerEvent };
|
||||
const triggerString = t(`${triggerEvent.toLowerCase()}_trigger`);
|
||||
|
||||
return { label: triggerString.charAt(0).toUpperCase() + triggerString.slice(1), value: triggerEvent };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -24,8 +24,10 @@ const customTemplate = async (text: string, variables: VariablesType, locale: st
|
|||
|
||||
let dynamicText = text
|
||||
.replaceAll("{EVENT_NAME}", variables.eventName || "")
|
||||
.replaceAll("{ORGANIZER_NAME}", variables.organizerName || "")
|
||||
.replaceAll("{ATTENDEE_NAME}", variables.attendeeName || "")
|
||||
.replaceAll("{ORGANIZER}", variables.organizerName || "")
|
||||
.replaceAll("{ATTENDEE}", variables.attendeeName || "")
|
||||
.replaceAll("{ORGANIZER_NAME}", variables.organizerName || "") //old variable names
|
||||
.replaceAll("{ATTENDEE_NAME}", variables.attendeeName || "") //old variable names
|
||||
.replaceAll("{EVENT_DATE}", variables.eventDate?.locale(locale).format("dddd, MMMM D, YYYY") || "")
|
||||
.replaceAll("{EVENT_TIME}", timeWithTimeZone)
|
||||
.replaceAll("{LOCATION}", locationString)
|
||||
|
|
|
@ -20,12 +20,13 @@ 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
|
||||
translatedText = translatedText.replace(
|
||||
regex,
|
||||
originalVariables.includes(variable.toLowerCase().concat("_workflow"))
|
||||
? language.t(variable.toLowerCase().concat("_workflow")).replace(/ /g, "_").toLocaleUpperCase()
|
||||
: variable
|
||||
);
|
||||
const translatedVariable = originalVariables.includes(variable.toLowerCase().concat("_workflow"))
|
||||
? language.t(variable.toLowerCase().concat("_workflow")).replace(/ /g, "_").toLocaleUpperCase()
|
||||
: originalVariables.includes(variable.toLowerCase().concat("_name_workflow")) //for the old variables names (ORGANIZER_NAME, ATTENDEE_NAME)
|
||||
? language.t(variable.toLowerCase().concat("_name_workflow")).replace(/ /g, "_").toLocaleUpperCase()
|
||||
: variable;
|
||||
|
||||
translatedText = translatedText.replace(regex, translatedVariable);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -42,7 +43,11 @@ export function translateVariablesToEnglish(text: string, language: { locale: st
|
|||
|
||||
variables?.forEach((variable) => {
|
||||
originalVariables.forEach((originalVariable) => {
|
||||
if (language.t(originalVariable).replace(/ /, "_").toUpperCase() === variable) {
|
||||
const newVariableName = variable.replace("_NAME", "");
|
||||
if (
|
||||
language.t(originalVariable).replace(/ /, "_").toUpperCase() === variable ||
|
||||
language.t(originalVariable).replace(/ /, "_").toUpperCase() === newVariableName
|
||||
) {
|
||||
newText = newText.replace(
|
||||
variable,
|
||||
language.t(originalVariable, { lng: "en" }).replace(" ", "_").toUpperCase()
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import Loader from "@calcom/ui/Loader";
|
||||
import { Alert, Button, showToast } from "@calcom/ui/v2";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
|
||||
import LicenseRequired from "../../../common/components/v2/LicenseRequired";
|
||||
import WorkflowList from "../../components/v2/WorkflowListPage";
|
||||
|
||||
function WorkflowsPage() {
|
||||
const { t } = useLocale();
|
||||
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const me = useMeQuery();
|
||||
const isFreeUser = me.data?.plan === "FREE";
|
||||
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.workflows.list"]);
|
||||
|
||||
const createMutation = trpc.useMutation("viewer.workflows.createV2", {
|
||||
onSuccess: async ({ workflow }) => {
|
||||
await router.replace("/workflows/" + workflow.id);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
|
||||
if (err.data?.code === "UNAUTHORIZED") {
|
||||
const message = `${err.data.code}: You are not able to create this workflow`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return session.data ? (
|
||||
<Shell
|
||||
heading={data?.workflows.length ? "workflows" : ""}
|
||||
subtitle={data?.workflows.length ? t("workflows_to_automate_notifications") : ""}
|
||||
CTA={
|
||||
session.data?.hasValidLicense && !isFreeUser && data?.workflows && data?.workflows.length > 0 ? (
|
||||
<Button
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => createMutation.mutate()}
|
||||
loading={createMutation.isLoading}>
|
||||
{t("new_workflow_btn")}
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}>
|
||||
<LicenseRequired>
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : (
|
||||
<>
|
||||
{isFreeUser ? (
|
||||
<Alert className="border " severity="warning" title={t("pro_feature_workflows")} />
|
||||
) : (
|
||||
<WorkflowList workflows={data?.workflows} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</LicenseRequired>
|
||||
</Shell>
|
||||
) : (
|
||||
<Loader />
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowsPage;
|
|
@ -0,0 +1,240 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
TimeUnit,
|
||||
WorkflowActions,
|
||||
WorkflowStep,
|
||||
WorkflowTemplates,
|
||||
WorkflowTriggerEvents,
|
||||
} from "@prisma/client";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import Loader from "@calcom/ui/Loader";
|
||||
import { Option } from "@calcom/ui/form/MultiSelectCheckboxes";
|
||||
import { Alert, Button, Form, showToast } from "@calcom/ui/v2";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
|
||||
import LicenseRequired from "../../../common/components/v2/LicenseRequired";
|
||||
import WorkflowDetailsPage from "../../components/v2/WorkflowDetailsPage";
|
||||
import { getTranslatedText } from "../../lib/variableTranslations";
|
||||
import { translateVariablesToEnglish } from "../../lib/variableTranslations";
|
||||
|
||||
export type FormValues = {
|
||||
name: string;
|
||||
activeOn: Option[];
|
||||
steps: WorkflowStep[];
|
||||
trigger: WorkflowTriggerEvents;
|
||||
time?: number;
|
||||
timeUnit?: TimeUnit;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
activeOn: z.object({ value: z.string(), label: z.string() }).array(),
|
||||
trigger: z.nativeEnum(WorkflowTriggerEvents),
|
||||
time: z.number().gte(0).optional(),
|
||||
timeUnit: z.nativeEnum(TimeUnit).optional(),
|
||||
steps: z
|
||||
.object({
|
||||
id: z.number(),
|
||||
stepNumber: z.number(),
|
||||
action: z.nativeEnum(WorkflowActions),
|
||||
workflowId: z.number(),
|
||||
reminderBody: z.string().nullable(),
|
||||
emailSubject: z.string().nullable(),
|
||||
template: z.nativeEnum(WorkflowTemplates),
|
||||
sendTo: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
.nullable(),
|
||||
})
|
||||
.array(),
|
||||
});
|
||||
|
||||
const querySchema = z.object({
|
||||
workflow: stringOrNumber,
|
||||
});
|
||||
|
||||
function WorkflowPage() {
|
||||
const { t, i18n } = useLocale();
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const me = useMeQuery();
|
||||
const isFreeUser = me.data?.plan === "FREE";
|
||||
|
||||
const [selectedEventTypes, setSelectedEventTypes] = useState<Option[]>([]);
|
||||
const [isAllDataLoaded, setIsAllDataLoaded] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
mode: "onBlur",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
const { workflow: workflowId } = router.isReady ? querySchema.parse(router.query) : { workflow: -1 };
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const {
|
||||
data: workflow,
|
||||
isError,
|
||||
error,
|
||||
dataUpdatedAt,
|
||||
} = trpc.useQuery(["viewer.workflows.get", { id: +workflowId }], {
|
||||
enabled: router.isReady && !!workflowId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (workflow && !form.getValues("trigger")) {
|
||||
setSelectedEventTypes(
|
||||
workflow.activeOn.map((active) => ({
|
||||
value: String(active.eventType.id),
|
||||
label: active.eventType.title,
|
||||
})) || []
|
||||
);
|
||||
const activeOn = workflow.activeOn
|
||||
? workflow.activeOn.map((active) => ({
|
||||
value: active.eventType.id.toString(),
|
||||
label: active.eventType.slug,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
//translate dynamic variables into local language
|
||||
const steps = workflow.steps.map((step) => {
|
||||
const updatedStep = step;
|
||||
if (step.reminderBody) {
|
||||
updatedStep.reminderBody = getTranslatedText(step.reminderBody || "", {
|
||||
locale: i18n.language,
|
||||
t,
|
||||
});
|
||||
}
|
||||
if (step.emailSubject) {
|
||||
updatedStep.emailSubject = getTranslatedText(step.emailSubject || "", {
|
||||
locale: i18n.language,
|
||||
t,
|
||||
});
|
||||
}
|
||||
return updatedStep;
|
||||
});
|
||||
|
||||
form.setValue("name", workflow.name);
|
||||
form.setValue("steps", steps);
|
||||
form.setValue("trigger", workflow.trigger);
|
||||
form.setValue("time", workflow.time || undefined);
|
||||
form.setValue("timeUnit", workflow.timeUnit || undefined);
|
||||
form.setValue("activeOn", activeOn || []);
|
||||
setIsAllDataLoaded(true);
|
||||
}
|
||||
}, [dataUpdatedAt]);
|
||||
|
||||
const updateMutation = trpc.useMutation("viewer.workflows.update", {
|
||||
onSuccess: async ({ workflow }) => {
|
||||
if (workflow) {
|
||||
utils.setQueryData(["viewer.workflows.get", { id: +workflow.id }], workflow);
|
||||
|
||||
showToast(
|
||||
t("workflow_updated_successfully", {
|
||||
workflowName: workflow.name,
|
||||
}),
|
||||
"success"
|
||||
);
|
||||
}
|
||||
await router.push("/workflows");
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof HttpError) {
|
||||
const message = `${err.statusCode}: ${err.message}`;
|
||||
showToast(message, "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return session.data ? (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={async (values) => {
|
||||
let activeOnEventTypeIds: number[] = [];
|
||||
|
||||
values.steps.forEach((step) => {
|
||||
if (step.reminderBody) {
|
||||
step.reminderBody = translateVariablesToEnglish(step.reminderBody, { locale: i18n.language, t });
|
||||
}
|
||||
if (step.emailSubject) {
|
||||
step.emailSubject = translateVariablesToEnglish(step.emailSubject, { locale: i18n.language, t });
|
||||
}
|
||||
});
|
||||
|
||||
if (values.activeOn) {
|
||||
activeOnEventTypeIds = values.activeOn.map((option) => {
|
||||
return parseInt(option.value, 10);
|
||||
});
|
||||
}
|
||||
updateMutation.mutate({
|
||||
id: parseInt(router.query.workflow as string, 10),
|
||||
name: values.name,
|
||||
activeOn: activeOnEventTypeIds,
|
||||
steps: values.steps,
|
||||
trigger: values.trigger,
|
||||
time: values.time || null,
|
||||
timeUnit: values.timeUnit || null,
|
||||
});
|
||||
}}>
|
||||
<Shell
|
||||
title="Title"
|
||||
CTA={
|
||||
!isFreeUser && (
|
||||
<div>
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
heading={
|
||||
session.data?.hasValidLicense &&
|
||||
isAllDataLoaded &&
|
||||
!isFreeUser && (
|
||||
<div className={classNames(workflow && !workflow.name ? "text-gray-400" : "")}>
|
||||
{workflow && workflow.name ? workflow.name : "untitled"}
|
||||
</div>
|
||||
)
|
||||
}>
|
||||
<LicenseRequired>
|
||||
{isFreeUser ? (
|
||||
<Alert className="border " severity="warning" title={t("pro_feature_workflows")} />
|
||||
) : (
|
||||
<>
|
||||
{!isError ? (
|
||||
<>
|
||||
{isAllDataLoaded ? (
|
||||
<>
|
||||
<WorkflowDetailsPage
|
||||
form={form}
|
||||
workflowId={+workflowId}
|
||||
selectedEventTypes={selectedEventTypes}
|
||||
setSelectedEventTypes={setSelectedEventTypes}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert severity="error" title="Something went wrong" message={error.message} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</LicenseRequired>
|
||||
</Shell>
|
||||
</Form>
|
||||
) : (
|
||||
<Loader />
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowPage;
|
|
@ -1,4 +1,4 @@
|
|||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import { PrismaClientKnownRequestError, NotFoundError } from "@prisma/client/runtime";
|
||||
import Stripe from "stripe";
|
||||
import { ZodError, ZodIssue } from "zod";
|
||||
|
||||
|
@ -42,6 +42,9 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError {
|
|||
if (cause instanceof PrismaClientKnownRequestError) {
|
||||
return new HttpError({ statusCode: 400, message: cause.message, cause });
|
||||
}
|
||||
if (cause instanceof NotFoundError) {
|
||||
return new HttpError({ statusCode: 404, message: cause.message, cause });
|
||||
}
|
||||
if (cause instanceof Stripe.errors.StripeInvalidRequestError) {
|
||||
return new HttpError({ statusCode: 400, message: cause.message, cause });
|
||||
}
|
||||
|
|
|
@ -96,7 +96,7 @@ export const bookingsRouter = createProtectedRouter()
|
|||
const { booking } = ctx;
|
||||
|
||||
try {
|
||||
const organizer = await ctx.prisma.user.findFirst({
|
||||
const organizer = await ctx.prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: booking.userId || 0,
|
||||
},
|
||||
|
@ -106,7 +106,6 @@ export const bookingsRouter = createProtectedRouter()
|
|||
timeZone: true,
|
||||
locale: true,
|
||||
},
|
||||
rejectOnNotFound: true,
|
||||
});
|
||||
|
||||
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
WorkflowTriggerEvents,
|
||||
BookingStatus,
|
||||
WorkflowMethods,
|
||||
TimeUnit,
|
||||
} from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
|
@ -26,6 +27,7 @@ import {
|
|||
scheduleSMSReminder,
|
||||
} from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
|
@ -40,15 +42,22 @@ export const workflowsRouter = createProtectedRouter()
|
|||
},
|
||||
include: {
|
||||
activeOn: {
|
||||
include: {
|
||||
eventType: true,
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
return { workflows };
|
||||
},
|
||||
})
|
||||
|
@ -126,6 +135,35 @@ export const workflowsRouter = createProtectedRouter()
|
|||
}
|
||||
},
|
||||
})
|
||||
.mutation("createV2", {
|
||||
async resolve({ ctx }) {
|
||||
const userId = ctx.user.id;
|
||||
|
||||
try {
|
||||
const workflow = await ctx.prisma.workflow.create({
|
||||
data: {
|
||||
name: "",
|
||||
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
|
||||
time: 24,
|
||||
timeUnit: TimeUnit.HOUR,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.prisma.workflowStep.create({
|
||||
data: {
|
||||
stepNumber: 1,
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
});
|
||||
return { workflow };
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
})
|
||||
.mutation("delete", {
|
||||
input: z.object({
|
||||
id: z.number(),
|
||||
|
@ -820,4 +858,44 @@ export const workflowsRouter = createProtectedRouter()
|
|||
};
|
||||
}
|
||||
},
|
||||
})
|
||||
.mutation("activateEventType", {
|
||||
input: z.object({
|
||||
eventTypeId: z.number(),
|
||||
workflowId: z.number(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { eventTypeId, workflowId } = input;
|
||||
|
||||
const eventType = await ctx.prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
});
|
||||
|
||||
//check if event type is already active
|
||||
|
||||
const isActive = await ctx.prisma.workflowsOnEventTypes.findFirst({
|
||||
where: {
|
||||
workflowId,
|
||||
eventTypeId,
|
||||
},
|
||||
});
|
||||
|
||||
if (isActive) {
|
||||
await ctx.prisma.workflowsOnEventTypes.deleteMany({
|
||||
where: {
|
||||
workflowId,
|
||||
eventTypeId,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await ctx.prisma.workflowsOnEventTypes.create({
|
||||
data: {
|
||||
workflowId,
|
||||
eventTypeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/ui/v2/core/ConfirmationDialogContent.tsx`
|
||||
*/
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
|
@ -17,6 +22,11 @@ export type ConfirmationDialogContentProps = {
|
|||
variety?: "danger" | "warning" | "success";
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/ui/v2/core/ConfirmationDialogContent.tsx`
|
||||
*/
|
||||
export default function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationDialogContentProps>) {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/ui/v2/core/form/MultiSelectCheckboxes.tsx`
|
||||
*/
|
||||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { components, GroupBase, OptionProps } from "react-select";
|
||||
import { Props } from "react-select";
|
||||
|
@ -62,6 +67,11 @@ const MultiValue = ({ index, getValue }: { index: number; getValue: any }) => {
|
|||
return <>{!index && <div>{t("nr_event_type", { count: getValue().length })}</div>}</>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated file
|
||||
* All new changes should be made to the V2 file in
|
||||
* `/packages/ui/v2/core/form/MultiSelectCheckboxes.tsx`
|
||||
*/
|
||||
export default function MultiSelectCheckboxes({
|
||||
options,
|
||||
isLoading,
|
||||
|
|
|
@ -11,7 +11,7 @@ export type PhoneInputProps<FormValues> = Props<
|
|||
FormValues
|
||||
>;
|
||||
|
||||
function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<FormValues>) {
|
||||
function PhoneInput<FormValues>({ control, name, className, ...rest }: PhoneInputProps<FormValues>) {
|
||||
return (
|
||||
<BasePhoneInput
|
||||
{...rest}
|
||||
|
@ -20,7 +20,7 @@ function PhoneInput<FormValues>({ control, name, ...rest }: PhoneInputProps<Form
|
|||
control={control}
|
||||
countrySelectProps={{ className: "text-black" }}
|
||||
numberInputProps={{ className: "border-0 text-sm focus:ring-0 dark:bg-gray-700" }}
|
||||
className="border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px pl-3 ring-black focus-within:ring-1 disabled:text-gray-500 disabled:opacity-50 dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500"
|
||||
className={`${className} border-1 focus-within:border-brand block w-full rounded-sm border border-gray-300 py-px pl-3 ring-black focus-within:ring-1 disabled:text-gray-500 disabled:opacity-50 dark:border-gray-900 dark:bg-gray-700 dark:text-white dark:selection:bg-green-500 disabled:dark:text-gray-500`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import React, { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Icon } from "@calcom/ui/Icon";
|
||||
import { Button, DialogClose, DialogContent } from "@calcom/ui/v2";
|
||||
|
||||
export type ConfirmationDialogContentProps = {
|
||||
confirmBtn?: ReactNode;
|
||||
confirmBtnText?: string;
|
||||
cancelBtnText?: string;
|
||||
isLoading?: boolean;
|
||||
loadingText?: string;
|
||||
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
title: string;
|
||||
variety?: "danger" | "warning" | "success";
|
||||
};
|
||||
|
||||
export default function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationDialogContentProps>) {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
title,
|
||||
variety,
|
||||
confirmBtn = null,
|
||||
confirmBtnText = t("confirm"),
|
||||
cancelBtnText = t("cancel"),
|
||||
loadingText = t("loading"),
|
||||
isLoading = false,
|
||||
onConfirm,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<DialogContent type="creation" useOwnActionButtons={true}>
|
||||
<div className="flex">
|
||||
{variety && (
|
||||
<div className="mt-0.5 ltr:mr-3">
|
||||
{variety === "danger" && (
|
||||
<div className="mx-auto rounded-full bg-red-100 p-2 text-center">
|
||||
<Icon.FiAlertCircle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "warning" && (
|
||||
<div className="mx-auto rounded-full bg-orange-100 p-2 text-center">
|
||||
<Icon.FiAlertCircle className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
)}
|
||||
{variety === "success" && (
|
||||
<div className="mx-auto rounded-full bg-green-100 p-2 text-center">
|
||||
<Icon.FiCheck className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<DialogPrimitive.Title className="font-cal text-xl text-gray-900">{title}</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description className="text-sm text-neutral-500">
|
||||
{children}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-row-reverse gap-x-2 sm:mt-8">
|
||||
<DialogClose disabled={isLoading} onClick={onConfirm} asChild>
|
||||
{confirmBtn || (
|
||||
<Button color="primary" loading={isLoading}>
|
||||
{isLoading ? loadingText : confirmBtnText}
|
||||
</Button>
|
||||
)}
|
||||
</DialogClose>
|
||||
<DialogClose disabled={isLoading} asChild>
|
||||
<Button color="secondary">{cancelBtnText}</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { ReactNode, useState, MouseEvent } from "react";
|
||||
import React, { ReactNode, useState } from "react";
|
||||
import { Icon } from "react-feather";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
@ -94,7 +94,7 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
|
|||
)}
|
||||
ref={forwardedRef}>
|
||||
{props.type === "creation" && (
|
||||
<div className="pb-8">
|
||||
<div>
|
||||
{props.title && <DialogHeader title={props.title} />}
|
||||
{props.description && <p className="pb-8 text-sm text-gray-500">{props.description}</p>}
|
||||
<div className="flex flex-col gap-6">{children}</div>
|
||||
|
|
|
@ -25,11 +25,11 @@ export default function EmptyScreen({
|
|||
<>
|
||||
<div className="min-h-80 flex w-full flex-col items-center justify-center rounded-md border border-dashed p-7 lg:p-20">
|
||||
<div className="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-gray-200 dark:bg-white">
|
||||
<Icon className="inline-block h-10 w-10 text-white dark:bg-gray-900 dark:text-gray-600" />
|
||||
<Icon className="inline-block h-10 w-10 stroke-[1.3px] dark:bg-gray-900 dark:text-gray-600" />
|
||||
</div>
|
||||
<div className="max-w-[420px] text-center">
|
||||
<h2 className="text-semibold font-cal mt-6 text-xl dark:text-gray-300">{headline}</h2>
|
||||
<p className="line-clamp-2 mt-3 mb-8 text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
|
||||
<p className="mt-3 mb-8 text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
{buttonOnClick && buttonText && <Button onClick={(e) => buttonOnClick(e)}>{buttonText}</Button>}
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
import React, { Dispatch, SetStateAction } from "react";
|
||||
import { components, GroupBase, OptionProps } from "react-select";
|
||||
import { Props } from "react-select";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import Select from "./Select";
|
||||
|
||||
export type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const InputOption: React.FC<OptionProps<any, boolean, GroupBase<any>>> = ({
|
||||
isDisabled,
|
||||
isFocused,
|
||||
isSelected,
|
||||
children,
|
||||
innerProps,
|
||||
...rest
|
||||
}) => {
|
||||
const style = {
|
||||
alignItems: "center",
|
||||
backgroundColor: isFocused ? "rgba(244, 245, 246, var(--tw-bg-opacity))" : "transparent",
|
||||
color: "inherit",
|
||||
display: "flex ",
|
||||
};
|
||||
|
||||
const props = {
|
||||
...innerProps,
|
||||
style,
|
||||
};
|
||||
|
||||
return (
|
||||
<components.Option
|
||||
{...rest}
|
||||
isDisabled={isDisabled}
|
||||
isFocused={isFocused}
|
||||
isSelected={isSelected}
|
||||
innerProps={props}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="text-primary-600 focus:ring-primary-500 mr-2 h-4 w-4 rounded border-gray-300"
|
||||
checked={isSelected}
|
||||
readOnly
|
||||
/>
|
||||
{children}
|
||||
</components.Option>
|
||||
);
|
||||
};
|
||||
|
||||
type MultiSelectionCheckboxesProps = {
|
||||
options: { label: string; value: string }[];
|
||||
setSelected: Dispatch<SetStateAction<Option[]>>;
|
||||
selected: Option[];
|
||||
setValue: (s: Option[]) => unknown;
|
||||
};
|
||||
|
||||
const MultiValue = ({ index, getValue }: { index: number; getValue: any }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return <>{!index && <div>{t("nr_event_type", { count: getValue().length })}</div>}</>;
|
||||
};
|
||||
|
||||
export default function MultiSelectCheckboxes({
|
||||
options,
|
||||
isLoading,
|
||||
selected,
|
||||
setSelected,
|
||||
setValue,
|
||||
}: Omit<Props, "options"> & MultiSelectionCheckboxesProps) {
|
||||
const additonalComponents = { MultiValue };
|
||||
|
||||
return (
|
||||
<Select
|
||||
theme={(theme) => ({
|
||||
...theme,
|
||||
borderRadius: 6,
|
||||
colors: {
|
||||
...theme.colors,
|
||||
primary: "var(--brand-color)",
|
||||
|
||||
primary50: "rgba(209 , 213, 219, var(--tw-bg-opacity))",
|
||||
primary25: "rgba(244, 245, 246, var(--tw-bg-opacity))",
|
||||
},
|
||||
})}
|
||||
value={selected}
|
||||
onChange={(s: any) => {
|
||||
setSelected(s);
|
||||
setValue(s);
|
||||
}}
|
||||
options={options}
|
||||
isMulti
|
||||
className="w-64 text-sm"
|
||||
isSearchable={false}
|
||||
closeMenuOnSelect={false}
|
||||
hideSelectedOptions={false}
|
||||
isLoading={isLoading}
|
||||
components={{
|
||||
...additonalComponents,
|
||||
Option: InputOption,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user