2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,275 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WorkflowActions } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import {
Button,
CheckboxField,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
EmailField,
Form,
Icon,
Input,
Label,
PhoneInput,
Select,
Tooltip,
} from "@calcom/ui";
import { WORKFLOW_ACTIONS } from "../lib/constants";
import { onlyLettersNumbersSpaces } from "../pages/workflow";
interface IAddActionDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
addAction: (
action: WorkflowActions,
sendTo?: string,
numberRequired?: boolean,
senderId?: string,
senderName?: string
) => void;
}
interface ISelectActionOption {
label: string;
value: WorkflowActions;
}
type AddActionFormValues = {
action: WorkflowActions;
sendTo?: string;
numberRequired?: boolean;
senderId?: string;
senderName?: string;
};
export const AddActionDialog = (props: IAddActionDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, addAction } = props;
const [isPhoneNumberNeeded, setIsPhoneNumberNeeded] = useState(false);
const [isSenderIdNeeded, setIsSenderIdNeeded] = useState(false);
const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(false);
const { data: actionOptions } = trpc.viewer.workflows.getWorkflowActionOptions.useQuery();
const formSchema = z.object({
action: z.enum(WORKFLOW_ACTIONS),
sendTo: z
.string()
.refine((val) => isValidPhoneNumber(val) || val.includes("@"))
.optional(),
numberRequired: z.boolean().optional(),
senderId: z
.string()
.refine((val) => onlyLettersNumbersSpaces(val))
.nullable(),
senderName: z.string().nullable(),
});
const form = useForm<AddActionFormValues>({
mode: "onSubmit",
defaultValues: {
action: WorkflowActions.EMAIL_HOST,
senderId: SENDER_ID,
senderName: SENDER_NAME,
},
resolver: zodResolver(formSchema),
});
const handleSelectAction = (newValue: ISelectActionOption | null) => {
if (newValue) {
form.setValue("action", newValue.value);
if (newValue.value === WorkflowActions.SMS_NUMBER) {
setIsPhoneNumberNeeded(true);
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
form.resetField("senderId", { defaultValue: SENDER_ID });
} else if (newValue.value === WorkflowActions.EMAIL_ADDRESS) {
setIsEmailAddressNeeded(true);
setIsSenderIdNeeded(false);
setIsPhoneNumberNeeded(false);
} else if (newValue.value === WorkflowActions.SMS_ATTENDEE) {
setIsSenderIdNeeded(true);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
form.resetField("senderId", { defaultValue: SENDER_ID });
} else if (newValue.value === WorkflowActions.WHATSAPP_NUMBER) {
setIsSenderIdNeeded(false);
setIsPhoneNumberNeeded(true);
setIsEmailAddressNeeded(false);
} else {
setIsSenderIdNeeded(false);
setIsEmailAddressNeeded(false);
setIsPhoneNumberNeeded(false);
}
form.unregister("sendTo");
form.unregister("numberRequired");
form.clearErrors("action");
form.clearErrors("sendTo");
}
};
if (!actionOptions) return null;
const canRequirePhoneNumber = (workflowStep: string) => {
return (
WorkflowActions.SMS_ATTENDEE === workflowStep || WorkflowActions.WHATSAPP_ATTENDEE === workflowStep
);
};
const showSender = (action: string) => {
return (
!isSenderIdNeeded &&
!(WorkflowActions.WHATSAPP_NUMBER === action || WorkflowActions.WHATSAPP_ATTENDEE === action)
);
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow type="creation" title={t("add_action")}>
<div className="-mt-3 space-x-3">
<Form
form={form}
handleSubmit={(values) => {
addAction(
values.action,
values.sendTo,
values.numberRequired,
values.senderId,
values.senderName
);
form.unregister("sendTo");
form.unregister("action");
form.unregister("numberRequired");
setIsOpenDialog(false);
setIsPhoneNumberNeeded(false);
setIsEmailAddressNeeded(false);
setIsSenderIdNeeded(false);
}}>
<div className="space-y-1">
<Label htmlFor="label">{t("action")}:</Label>
<Controller
name="action"
control={form.control}
render={() => {
return (
<Select
isSearchable={false}
className="text-sm"
menuPlacement="bottom"
defaultValue={actionOptions[0]}
onChange={handleSelectAction}
options={actionOptions.map((option) => ({
...option,
}))}
isOptionDisabled={(option: {
label: string;
value: WorkflowActions;
needsTeamsUpgrade: boolean;
}) => option.needsTeamsUpgrade}
/>
);
}}
/>
{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">{t("phone_number")}</Label>
<div className="mb-5 mt-1">
<Controller
control={form.control}
name="sendTo"
render={({ field: { value, onChange } }) => (
<PhoneInput
className="rounded-md"
placeholder={t("enter_phone_number")}
id="sendTo"
required
value={value}
onChange={onChange}
/>
)}
/>
{form.formState.errors.sendTo && (
<p className="mt-1 text-sm text-red-500">{form.formState.errors.sendTo.message}</p>
)}
</div>
</div>
)}
{isEmailAddressNeeded && (
<div className="mt-5">
<EmailField required label={t("email_address")} {...form.register("sendTo")} />
</div>
)}
{isSenderIdNeeded && (
<>
<div className="mt-5">
<div className="flex items-center">
<Label>{t("sender_id")}</Label>
<Tooltip content={t("sender_id_info")}>
<span>
<Icon name="info" className="mb-2 ml-2 mr-1 mt-0.5 h-4 w-4 text-gray-500" />
</span>
</Tooltip>
</div>
<Input type="text" placeholder={SENDER_ID} maxLength={11} {...form.register(`senderId`)} />
</div>
{form.formState.errors && form.formState?.errors?.senderId && (
<p className="mt-1 text-xs text-red-500">{t("sender_id_error_message")}</p>
)}
</>
)}
{showSender(form.getValues("action")) && (
<div className="mt-5">
<Label>{t("sender_name")}</Label>
<Input type="text" placeholder={SENDER_NAME} {...form.register(`senderName`)} />
</div>
)}
{canRequirePhoneNumber(form.getValues("action")) && (
<div className="mt-5">
<Controller
name="numberRequired"
control={form.control}
render={() => (
<CheckboxField
defaultChecked={form.getValues("numberRequired") || false}
description={t("make_phone_number_required")}
onChange={(e) => form.setValue("numberRequired", e.target.checked)}
/>
)}
/>
</div>
)}
<DialogFooter showDivider className="mt-12">
<DialogClose
onClick={() => {
setIsOpenDialog(false);
form.unregister("sendTo");
form.unregister("action");
form.unregister("numberRequired");
setIsPhoneNumberNeeded(false);
setIsEmailAddressNeeded(false);
setIsSenderIdNeeded(false);
}}
/>
<Button type="submit">{t("add")}</Button>
</DialogFooter>
</Form>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,56 @@
import type { 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, Dialog, showToast } from "@calcom/ui";
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.useUtils();
const deleteMutation = trpc.viewer.workflows.delete.useMutation({
onSuccess: async () => {
await utils.viewer.workflows.filteredList.invalidate();
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);
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: You are not authorized to delete this workflow`;
showToast(message, "error");
}
},
});
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<ConfirmationDialogContent
isPending={deleteMutation.isPending}
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,99 @@
import { useRouter } from "next/navigation";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import type { IconName } from "@calcom/ui";
import { CreateButtonWithTeamsList, EmptyScreen as ClassicEmptyScreen, Icon, showToast } from "@calcom/ui";
type WorkflowExampleType = {
Icon: IconName;
text: string;
};
function WorkflowExample(props: WorkflowExampleType) {
const { Icon: iconName, text } = props;
return (
<div className="border-subtle mx-2 my-2 max-h-24 max-w-[600px] rounded-md border border-solid p-6">
<div className="flex ">
<div className="flex items-center justify-center">
<div className="bg-emphasis dark:bg-default mr-4 flex h-10 w-10 items-center justify-center rounded-full">
<Icon name={iconName} className="text-default h-6 w-6 stroke-[2px]" />
</div>
</div>
<div className="m-auto w-full flex-grow items-center justify-center ">
<div className="text-semibold text-emphasis line-clamp-2 w-full text-sm font-medium">{text}</div>
</div>
</div>
</div>
);
}
export default function EmptyScreen(props: { isFilteredView: boolean }) {
const { t } = useLocale();
const router = useRouter();
const createMutation = trpc.viewer.workflows.create.useMutation({
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 authorized to create this workflow`;
showToast(message, "error");
}
},
});
const workflowsExamples = [
{ icon: "smartphone", text: t("workflow_example_1") },
{ icon: "smartphone", text: t("workflow_example_2") },
{ icon: "mail", text: t("workflow_example_3") },
{ icon: "mail", text: t("workflow_example_4") },
{ icon: "mail", text: t("workflow_example_5") },
{ icon: "smartphone", text: t("workflow_example_6") },
] as const;
// new workflow example when 'after meetings ends' trigger is implemented: Send custom thank you email to attendee after event (Smile icon),
if (props.isFilteredView) {
return <ClassicEmptyScreen Icon="zap" headline={t("no_workflows")} description={t("change_filter")} />;
}
return (
<>
<div className="min-h-80 flex w-full flex-col items-center justify-center rounded-md ">
<div className="bg-emphasis flex h-[72px] w-[72px] items-center justify-center rounded-full">
<Icon name="zap" className="dark:text-default inline-block h-10 w-10 stroke-[1.3px]" />
</div>
<div className="max-w-[420px] text-center">
<h2 className="text-semibold font-cal mt-6 text-xl dark:text-gray-300">{t("workflows")}</h2>
<p className="text-default mt-3 line-clamp-2 text-sm font-normal leading-6 dark:text-gray-300">
{t("no_workflows_description")}
</p>
<div className="mt-8 ">
<CreateButtonWithTeamsList
subtitle={t("new_workflow_subtitle").toUpperCase()}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
buttonText={t("create_workflow")}
isPending={createMutation.isPending}
includeOrg={true}
/>
</div>
</div>
</div>
<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,313 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { WorkflowActions } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen, Icon, showToast, Switch, Tooltip } from "@calcom/ui";
import LicenseRequired from "../../common/components/LicenseRequired";
import { getActionIcon } from "../lib/getActionIcon";
import SkeletonLoader from "./SkeletonLoaderEventWorkflowsTab";
import type { WorkflowType } from "./WorkflowListPage";
type PartialWorkflowType = Pick<WorkflowType, "name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly">;
type ItemProps = {
workflow: PartialWorkflowType;
eventType: {
id: number;
title: string;
requiresConfirmation: boolean;
};
isChildrenManagedEventType: boolean;
isActive: boolean;
};
const WorkflowListItem = (props: ItemProps) => {
const { workflow, eventType, isActive } = props;
const { t } = useLocale();
const [activeEventTypeIds, setActiveEventTypeIds] = useState(
workflow.activeOn?.map((active) => {
if (active.eventType) {
return active.eventType.id;
}
}) ?? []
);
const utils = trpc.useUtils();
const activateEventTypeMutation = trpc.viewer.workflows.activateEventType.useMutation({
onSuccess: async () => {
const offOn = isActive ? "off" : "on";
await utils.viewer.workflows.getAllActiveWorkflows.invalidate();
await utils.viewer.eventTypes.get.invalidate({ id: eventType.id });
showToast(
t("workflow_turned_on_successfully", {
workflowName: workflow.name,
offOn,
}),
"success"
);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
showToast(
t("unauthorized_workflow_error_message", {
errorCode: err.data.code,
}),
"error"
);
}
},
});
const sendTo: Set<string> = new Set();
workflow.steps.forEach((step) => {
switch (step.action) {
case WorkflowActions.EMAIL_HOST:
sendTo.add(t("organizer"));
break;
case WorkflowActions.EMAIL_ATTENDEE:
case WorkflowActions.SMS_ATTENDEE:
case WorkflowActions.WHATSAPP_ATTENDEE:
sendTo.add(t("attendee_name_variable"));
break;
case WorkflowActions.SMS_NUMBER:
case WorkflowActions.WHATSAPP_NUMBER:
case WorkflowActions.EMAIL_ADDRESS:
sendTo.add(step.sendTo || "");
break;
}
});
return (
<div className="border-subtle w-full overflow-hidden rounded-md border p-6 px-3 md:p-6">
<div className="flex items-center ">
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
{getActionIcon(
workflow.steps,
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
)}
</div>
<div className=" grow">
<div
className={classNames(
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
workflow.name && isActive ? "text-emphasis" : "text-subtle"
)}>
{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(
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
isActive ? "text-default" : "text-muted"
)}>
<span className="mr-1">{t("to")}:</span>
{Array.from(sendTo).map((sendToPerson, index) => {
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
})}
</div>
</>
</div>
{!workflow.readOnly && (
<div className="flex-none">
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
<Button type="button" color="minimal" className="mr-4">
<div className="hidden ltr:mr-2 rtl:ml-2 sm:block">{t("edit")}</div>
<Icon name="external-link" className="text-default -mt-[2px] h-4 w-4 stroke-2" />
</Button>
</Link>
</div>
)}
<Tooltip
content={
t(
workflow.readOnly && props.isChildrenManagedEventType
? "locked_by_team_admin"
: isActive
? "turn_off"
: "turn_on"
) as string
}>
<div className="flex items-center ltr:mr-2 rtl:ml-2">
{workflow.readOnly && props.isChildrenManagedEventType && (
<Icon name="lock" className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
)}
<Switch
checked={isActive}
disabled={workflow.readOnly}
onCheckedChange={() => {
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
}}
/>
</div>
</Tooltip>
</div>
</div>
);
};
type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
type Props = {
eventType: EventTypeSetup;
workflows: PartialWorkflowType[];
};
function EventWorkflowsTab(props: Props) {
const { workflows, eventType } = props;
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
eventType,
translate: t,
formMethods,
});
const workflowsDisableProps = shouldLockDisableProps("workflows", { simple: true });
const lockedText = workflowsDisableProps.isLocked ? "locked" : "unlocked";
const { data, isPending } = trpc.viewer.workflows.list.useQuery({
teamId: eventType.team?.id,
userId: !isChildrenManagedEventType ? eventType.userId || undefined : undefined,
});
const router = useRouter();
const [sortedWorkflows, setSortedWorkflows] = useState<Array<WorkflowType>>([]);
useEffect(() => {
if (data?.workflows) {
const allActiveWorkflows = workflows.map((workflowOnEventType) => {
const dataWf = data.workflows.find((wf) => wf.id === workflowOnEventType.id);
return {
...workflowOnEventType,
readOnly: isChildrenManagedEventType && dataWf?.teamId ? true : dataWf?.readOnly ?? false,
} as WorkflowType;
});
const disabledWorkflows = data.workflows.filter(
(workflow) =>
(!workflow.teamId || eventType.teamId === workflow.teamId) &&
!workflows
.map((workflow) => {
return workflow.id;
})
.includes(workflow.id)
);
const allSortedWorkflows =
workflowsDisableProps.isLocked && !isManagedEventType
? allActiveWorkflows
: allActiveWorkflows.concat(disabledWorkflows);
setSortedWorkflows(allSortedWorkflows);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPending]);
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
await router.replace(`/workflows/${workflow.id}?eventTypeId=${eventType.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}: ${t("error_workflow_unauthorized_create")}`;
showToast(message, "error");
}
},
});
return (
<LicenseRequired>
{!isPending ? (
<>
{(isManagedEventType || isChildrenManagedEventType) && (
<Alert
severity={workflowsDisableProps.isLocked ? "neutral" : "green"}
className="mb-2"
title={
<Trans i18nKey={`${lockedText}_${isManagedEventType ? "for_members" : "by_team_admins"}`}>
{lockedText[0].toUpperCase()}
{lockedText.slice(1)} {isManagedEventType ? "for members" : "by team admins"}
</Trans>
}
actions={<div className="flex h-full items-center">{workflowsDisableProps.LockedIcon}</div>}
message={
<Trans
i18nKey={`workflows_${lockedText}_${
isManagedEventType ? "for_members" : "by_team_admins"
}_description`}>
{isManagedEventType ? "Members" : "You"}{" "}
{workflowsDisableProps.isLocked
? "will be able to see the active workflows but will not be able to edit any workflow settings"
: "will be able to see the active workflow and will be able to edit any workflow settings"}
</Trans>
}
/>
)}
{data?.workflows && sortedWorkflows.length > 0 ? (
<div>
<div className="space-y-4">
{sortedWorkflows.map((workflow) => {
return (
<WorkflowListItem
key={workflow.id}
workflow={workflow}
eventType={props.eventType}
isChildrenManagedEventType
isActive={!!workflows.find((activeWorkflow) => activeWorkflow.id === workflow.id)}
/>
);
})}
</div>
</div>
) : (
<div className="pt-2 before:border-0">
<EmptyScreen
Icon="zap"
headline={t("workflows")}
description={t("no_workflows_description")}
buttonRaw={
<Button
disabled={workflowsDisableProps.isLocked && !isManagedEventType}
target="_blank"
color="secondary"
onClick={() => createMutation.mutate({ teamId: eventType.team?.id })}
loading={createMutation.isPending}>
{t("create_workflow")}
</Button>
}
/>
</div>
)}
</>
) : (
<SkeletonLoader />
)}
</LicenseRequired>
);
}
export default EventWorkflowsTab;

View File

@@ -0,0 +1,23 @@
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
function SkeletonLoader() {
return (
<SkeletonContainer>
<div className="ml-2 mt-10 md:flex">
<div className="mr-6 flex flex-col md:flex-none">
<SkeletonText className="h-4 w-28" />
<SkeletonText className="mb-6 mt-2 h-8 w-full md:w-64" />
<SkeletonText className="h-4 w-28" />
<SkeletonText className="mt-2 h-8 w-full md:w-64" />
<SkeletonText className="mt-8 hidden h-0.5 w-full md:block" />
<SkeletonText className="mb-6 mt-8 h-8 w-40" />
</div>
<div className="hidden flex-grow md:flex">
<SkeletonText className="h-64 w-full" />
</div>
</div>
</SkeletonContainer>
);
}
export default SkeletonLoader;

View File

@@ -0,0 +1,37 @@
import { SkeletonAvatar, SkeletonText } from "@calcom/ui";
function SkeletonLoader() {
return (
<ul className="bg-default divide-subtle animate-pulse sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
</ul>
);
}
export default SkeletonLoader;
function SkeletonItem() {
return (
<li className="border-subtle group mb-4 flex h-[90px] w-full items-center justify-between rounded-md border px-4 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div className="flex">
<SkeletonAvatar className="h-10 w-10" />
<div className="ml-4 mt-1 flex flex-col space-y-1">
<SkeletonText className="h-5 w-20 sm:w-24" />
<div className="flex">
<SkeletonText className="h-4 w-16 ltr:mr-2 rtl:ml-2 sm:w-28" />
</div>
</div>
</div>
</div>
<div className="mt-0 flex flex-shrink-0 sm:ml-5">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-8 w-8 sm:w-16" />
<SkeletonText className="h-8 w-8 sm:w-16" />
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,38 @@
import { Icon, SkeletonText } from "@calcom/ui";
function SkeletonLoader() {
return (
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
);
}
export default SkeletonLoader;
function SkeletonItem() {
return (
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div className="flex">
<div className="flex flex-col space-y-2">
<SkeletonText className="h-4 w-16 sm:w-24" />
<div className="flex">
<Icon name="bell" className="mr-1.5 mt-0.5 inline h-4 w-4 text-gray-200" />
<SkeletonText className="h-4 w-16 ltr:mr-2 rtl:ml-2 sm:w-28" />
<Icon name="link" className="mr-1.5 mt-0.5 inline h-4 w-4 text-gray-200" />
<SkeletonText className="h-4 w-28 sm:w-36" />
</div>
</div>
</div>
</div>
<div className="mt-0 flex flex-shrink-0 sm:ml-5">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-8 w-8 sm:w-16" />
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TimeUnit } from "@calcom/prisma/enums";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Icon,
TextField,
} from "@calcom/ui";
import type { FormValues } from "../pages/workflow";
const TIME_UNITS = [TimeUnit.DAY, TimeUnit.HOUR, TimeUnit.MINUTE] as const;
type Props = {
form: UseFormReturn<FormValues>;
disabled: boolean;
};
const TimeUnitAddonSuffix = ({
DropdownItems,
timeUnitOptions,
form,
}: {
form: UseFormReturn<FormValues>;
DropdownItems: JSX.Element;
timeUnitOptions: { [x: string]: string };
}) => {
// because isDropdownOpen already triggers a render cycle we can use getValues()
// instead of watch() function
const timeUnit = form.getValues("timeUnit");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
return (
<Dropdown onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger asChild>
<button className="flex items-center">
<div className="mr-1 w-3/5">{timeUnit ? timeUnitOptions[timeUnit] : "undefined"}</div>
<div className="w-1/4 pt-1">
{isDropdownOpen ? <Icon name="chevron-up" /> : <Icon name="chevron-down" />}
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>{DropdownItems}</DropdownMenuContent>
</Dropdown>
);
};
export const TimeTimeUnitInput = (props: Props) => {
const { form } = props;
const { t } = useLocale();
const timeUnitOptions = TIME_UNITS.reduce((acc, option) => {
acc[option] = t(`${option.toLowerCase()}_timeUnit`);
return acc;
}, {} as { [x: string]: string });
return (
<div className="flex">
<div className="grow">
<TextField
type="number"
min="1"
label=""
disabled={props.disabled}
defaultValue={form.getValues("time") || 24}
className="-mt-2 rounded-r-none text-sm focus:ring-0"
{...form.register("time", { valueAsNumber: true })}
addOnSuffix={
<TimeUnitAddonSuffix
form={form}
timeUnitOptions={timeUnitOptions}
DropdownItems={
<>
{TIME_UNITS.map((timeUnit, index) => (
<DropdownMenuItem key={index} className="outline-none">
<DropdownItem
key={index}
type="button"
onClick={() => {
form.setValue("timeUnit", timeUnit);
}}>
{timeUnitOptions[timeUnit]}
</DropdownItem>
</DropdownMenuItem>
))}
</>
}
/>
}
/>
</div>
</div>
);
};

View File

@@ -0,0 +1,233 @@
import { useRouter, useSearchParams } from "next/navigation";
import type { Dispatch, SetStateAction } from "react";
import { useState, useEffect } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller } from "react-hook-form";
import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WorkflowActions } from "@calcom/prisma/enums";
import { WorkflowTemplates } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
import { Button, Icon, Label, MultiSelectCheckboxes, TextField, CheckboxField, InfoBadge } from "@calcom/ui";
import { isSMSAction, isWhatsappAction } from "../lib/actionHelperFunctions";
import type { FormValues } from "../pages/workflow";
import { AddActionDialog } from "./AddActionDialog";
import { DeleteDialog } from "./DeleteDialog";
import WorkflowStepContainer from "./WorkflowStepContainer";
type User = RouterOutputs["viewer"]["me"];
interface Props {
form: UseFormReturn<FormValues>;
workflowId: number;
selectedOptions: Option[];
setSelectedOptions: Dispatch<SetStateAction<Option[]>>;
teamId?: number;
user: User;
readOnly: boolean;
isOrg: boolean;
allOptions: Option[];
}
export default function WorkflowDetailsPage(props: Props) {
const { form, workflowId, selectedOptions, setSelectedOptions, teamId, isOrg, allOptions } = props;
const { t } = useLocale();
const router = useRouter();
const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false);
const [reload, setReload] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const searchParams = useSearchParams();
const eventTypeId = searchParams?.get("eventTypeId");
useEffect(() => {
const matchingOption = allOptions.find((option) => option.value === eventTypeId);
if (matchingOption && !selectedOptions.find((option) => option.value === eventTypeId)) {
const newOptions = [...selectedOptions, matchingOption];
setSelectedOptions(newOptions);
form.setValue("activeOn", newOptions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypeId]);
const addAction = (
action: WorkflowActions,
sendTo?: string,
numberRequired?: boolean,
sender?: string,
senderName?: 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: isWhatsappAction(action) ? WorkflowTemplates.REMINDER : WorkflowTemplates.CUSTOM,
numberRequired: numberRequired || false,
sender: isSMSAction(action) ? sender || SENDER_ID : SENDER_ID,
senderName: !isSMSAction(action) ? senderName || SENDER_NAME : SENDER_NAME,
numberVerificationPending: false,
includeCalendarEvent: false,
};
steps?.push(step);
form.setValue("steps", steps);
};
return (
<>
<div className="z-1 my-8 sm:my-0 md:flex">
<div className="pl-2 pr-3 md:sticky md:top-6 md:h-0 md:pl-0">
<div className="mb-5">
<TextField
data-testid="workflow-name"
disabled={props.readOnly}
label={`${t("workflow_name")}:`}
type="text"
{...form.register("name")}
/>
</div>
{isOrg ? (
<div className="flex">
<Label>{t("which_team_apply")}</Label>
<div className="-mt-0.5">
<InfoBadge content={t("team_select_info")} />
</div>
</div>
) : (
<Label>{t("which_event_type_apply")}</Label>
)}
<Controller
name="activeOn"
control={form.control}
render={() => {
return (
<MultiSelectCheckboxes
options={allOptions}
isDisabled={props.readOnly || form.getValues("selectAll")}
className="w-full md:w-64"
setSelected={setSelectedOptions}
selected={form.getValues("selectAll") ? allOptions : selectedOptions}
setValue={(s: Option[]) => {
form.setValue("activeOn", s);
}}
countText={isOrg ? "count_team" : "nr_event_type"}
/>
);
}}
/>
<div className="mt-3">
<Controller
name="selectAll"
render={({ field: { value, onChange } }) => (
<CheckboxField
description={isOrg ? t("apply_to_all_teams") : t("apply_to_all_event_types")}
disabled={props.readOnly}
onChange={(e) => {
onChange(e);
if (e.target.value) {
setSelectedOptions(allOptions);
form.setValue("activeOn", allOptions);
}
}}
checked={value}
/>
)}
/>
</div>
<div className="md:border-subtle my-7 border-transparent md:border-t" />
{!props.readOnly && (
<Button
type="button"
StartIcon="trash-2"
color="destructive"
className="border"
onClick={() => setDeleteDialogOpen(true)}>
{t("delete_workflow")}
</Button>
)}
<div className="border-subtle my-7 border-t md:border-none" />
</div>
{/* Workflow Trigger Event & Steps */}
<div className="bg-muted border-subtle w-full rounded-md border p-3 py-5 md:ml-3 md:p-8">
{form.getValues("trigger") && (
<div>
<WorkflowStepContainer
form={form}
user={props.user}
teamId={teamId}
readOnly={props.readOnly}
/>
</div>
)}
{form.getValues("steps") && (
<>
{form.getValues("steps")?.map((step) => {
return (
<WorkflowStepContainer
key={step.id}
form={form}
user={props.user}
step={step}
reload={reload}
setReload={setReload}
teamId={teamId}
readOnly={props.readOnly}
/>
);
})}
</>
)}
{!props.readOnly && (
<>
<div className="my-3 flex justify-center">
<Icon name="arrow-down" className="text-subtle stroke-[1.5px] text-3xl" />
</div>
<div className="flex justify-center">
<Button
type="button"
onClick={() => setIsAddActionDialogOpen(true)}
color="secondary"
className="bg-default">
{t("add_action")}
</Button>
</div>
</>
)}
</div>
</div>
<AddActionDialog
isOpenDialog={isAddActionDialogOpen}
setIsOpenDialog={setIsAddActionDialogOpen}
addAction={addAction}
/>
<DeleteDialog
isOpenDialog={deleteDialogOpen}
setIsOpenDialog={setDeleteDialogOpen}
workflowId={workflowId}
additionalFunction={async () => router.push("/workflows")}
/>
</>
);
}

View File

@@ -0,0 +1,331 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { Membership, Workflow } from "@prisma/client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
ArrowButton,
Avatar,
Badge,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Icon,
Tooltip,
} from "@calcom/ui";
import { getActionIcon } from "../lib/getActionIcon";
import type { WorkflowStep } from "../lib/types";
import { DeleteDialog } from "./DeleteDialog";
export type WorkflowType = Workflow & {
team: {
id: number;
name: string;
members: Membership[];
slug: string | null;
logo?: string | null;
} | null;
steps: WorkflowStep[];
activeOnTeams?: {
team: {
id: number;
name?: string | null;
};
}[];
activeOn?: {
eventType: {
id: number;
title: string;
parentId: number | null;
_count: {
children: number;
};
};
}[];
readOnly?: boolean;
isOrg?: boolean;
};
interface Props {
workflows: WorkflowType[] | undefined;
}
export default function WorkflowListPage({ workflows }: Props) {
const { t } = useLocale();
const utils = trpc.useUtils();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0);
const [parent] = useAutoAnimate<HTMLUListElement>();
const router = useRouter();
const mutation = trpc.viewer.workflowOrder.useMutation({
onError: async (err) => {
console.error(err.message);
await utils.viewer.workflows.filteredList.cancel();
await utils.viewer.workflows.filteredList.invalidate();
},
onSettled: () => {
utils.viewer.workflows.filteredList.invalidate();
},
});
async function moveWorkflow(index: number, increment: 1 | -1) {
const types = workflows!;
const newList = [...types];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
await utils.viewer.appRoutingForms.forms.cancel();
mutation.mutate({
ids: newList?.map((type) => type.id),
});
}
return (
<>
{workflows && workflows.length > 0 ? (
<div className="bg-default border-subtle overflow-hidden rounded-md border sm:mx-0">
<ul className="divide-subtle !static w-full divide-y" data-testid="workflow-list" ref={parent}>
{workflows.map((workflow, index) => {
const firstItem = workflows[0];
const lastItem = workflows[workflows.length - 1];
const dataTestId = `workflow-${workflow.name.toLowerCase().replaceAll(" ", "-")}`;
return (
<li
key={workflow.id}
data-testid={dataTestId}
className="group flex w-full max-w-full items-center justify-between overflow-hidden">
{!(firstItem && firstItem.id === workflow.id) && (
<ArrowButton onClick={() => moveWorkflow(index, -1)} arrowDirection="up" />
)}
{!(lastItem && lastItem.id === workflow.id) && (
<ArrowButton onClick={() => moveWorkflow(index, 1)} arrowDirection="down" />
)}
<div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 sm:px-6">
<Link href={`/workflows/${workflow.id}`} className="flex-grow cursor-pointer">
<div className="rtl:space-x-reverse">
<div className="flex">
<div
className={classNames(
"max-w-56 text-emphasis truncate text-sm font-medium leading-6 md:max-w-max",
workflow.name ? "text-emphasis" : "text-subtle"
)}>
{workflow.name
? workflow.name
: workflow.steps[0]
? `Untitled (${`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`
.charAt(0)
.toUpperCase()}${`${t(
`${workflow.steps[0].action.toLowerCase()}_action`
)}`.slice(1)})`
: "Untitled"}
</div>
<div>
{workflow.readOnly && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
</Badge>
)}
</div>
</div>
<ul className="mt-1 flex flex-wrap space-x-2 sm:flex-nowrap ">
<li>
<Badge variant="gray">
<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>
</Badge>
</li>
<li>
<Badge variant="gray">
{/*active on all badge */}
{workflow.isActiveOnAll ? (
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{workflow.isOrg ? t("active_on_all_teams") : t("active_on_all_event_types")}
</div>
) : workflow.activeOn && workflow.activeOn.length > 0 ? (
//active on event types badge
<Tooltip
content={workflow.activeOn
.filter((wf) => (workflow.teamId ? wf.eventType.parentId === null : true))
.map((activeOn, key) => (
<p key={key}>
{activeOn.eventType.title}
{activeOn.eventType._count.children > 0
? ` (+${activeOn.eventType._count.children})`
: ""}
</p>
))}>
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_event_types", {
count: workflow.activeOn.filter((wf) =>
workflow.teamId ? wf.eventType.parentId === null : true
).length,
})}
</div>
</Tooltip>
) : workflow.activeOnTeams && workflow.activeOnTeams.length > 0 ? (
//active on teams badge
<Tooltip
content={workflow.activeOnTeams.map((activeOn, key) => (
<p key={key}>{activeOn.team.name}</p>
))}>
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_teams", {
count: workflow.activeOnTeams?.length,
})}
</div>
</Tooltip>
) : (
// active on no teams or event types
<div>
<Icon name="link" className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{workflow.isOrg ? t("no_active_teams") : t("no_active_event_types")}
</div>
)}
</Badge>
</li>
<div className="block md:hidden">
{workflow.team?.name && (
<li>
<Badge variant="gray">
<>{workflow.team.name}</>
</Badge>
</li>
)}
</div>
</ul>
</div>
</Link>
<div>
<div className="hidden md:block">
{workflow.team?.name && (
<Badge className="mr-4 mt-1 p-[1px] px-2" variant="gray">
<Avatar
alt={workflow.team?.name || ""}
href={
workflow.team?.id
? `/settings/teams/${workflow.team?.id}/profile`
: "/settings/my-account/profile"
}
imageSrc={getPlaceholderAvatar(
workflow?.team.logo,
workflow.team?.name as string
)}
size="xxs"
className="mt-[3px] inline-flex justify-center"
/>
<div>{workflow.team.name}</div>
</Badge>
)}
</div>
</div>
<div className="flex flex-shrink-0">
<div className="hidden sm:block">
<ButtonGroup combined>
<Tooltip content={t("edit") as string}>
<Button
type="button"
color="secondary"
variant="icon"
StartIcon="pencil"
disabled={workflow.readOnly}
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}
data-testid="edit-button"
/>
</Tooltip>
<Tooltip content={t("delete") as string}>
<Button
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}
color="secondary"
variant="icon"
disabled={workflow.readOnly}
StartIcon="trash-2"
data-testid="delete-button"
/>
</Tooltip>
</ButtonGroup>
</div>
{!workflow.readOnly && (
<div className="block sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" color="minimal" variant="icon" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="pencil"
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon="trash-2"
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
</div>
</li>
);
})}
</ul>
<DeleteDialog
isOpenDialog={deleteDialogOpen}
setIsOpenDialog={setDeleteDialogOpen}
workflowId={workflowToDeleteId}
additionalFunction={async () => {
await utils.viewer.workflows.filteredList.invalidate();
}}
/>
</div>
) : (
<></>
)}
</>
);
}

File diff suppressed because it is too large Load Diff