first commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user