Merge branch 'main' into fix/after-meeting-ends-migration

This commit is contained in:
kodiakhq[bot] 2022-08-31 19:47:01 +00:00 committed by GitHub
commit 53e4f74d23
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 2470 additions and 117 deletions

View File

@ -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

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/workflows/components/v2/EventWorkflowsTab";

View File

@ -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/",

View File

@ -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,

View File

@ -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,
},

View File

@ -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) => {

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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);

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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,
},
},
},
},
},
});

View File

@ -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",
};
};

View File

@ -0,0 +1 @@
export { default } from "@calcom/features/ee/workflows/pages/v2/index";

View File

@ -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";

View File

@ -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,
});

View File

@ -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",

View File

@ -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",

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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({

View File

@ -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,
},

View File

@ -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: {

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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.
*/

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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();

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
)}
</>
);
}

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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")}
/>
</>
);
}

View File

@ -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 />
)}
</>
);
}

View File

@ -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 <></>;
}

View File

@ -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 <></>;
}

View File

@ -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 };
});
}

View File

@ -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)

View File

@ -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()

View File

@ -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;

View File

@ -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;

View File

@ -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 });
}

View File

@ -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");

View File

@ -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,
},
});
}
},
});

View File

@ -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 {

View File

@ -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,

View File

@ -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`}
/>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>}

View File

@ -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,
}}
/>
);
}