Team Workflows (#7038)

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Carina Wollendorfer
2023-02-27 12:54:43 +05:30
committed by GitHub
co-authored by Hariom Balhara CarinaWolli zomars Peer Richelsen
parent c20835a4c8
commit 0ec71e52ef
27 changed files with 1219 additions and 476 deletions
@@ -1,4 +1,4 @@
import { Dispatch, SetStateAction } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
@@ -30,6 +30,10 @@ export const DeleteDialog = (props: IDeleteDialog) => {
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");
}
},
});
@@ -1,9 +1,11 @@
import React from "react";
import { useRouter } from "next/router";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SVGComponent } from "@calcom/types/SVGComponent";
import { Button } from "@calcom/ui";
import { FiSmartphone, FiMail, FiPlus } from "@calcom/ui/components/icon";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import { CreateButton, showToast, EmptyScreen as ClassicEmptyScreen } from "@calcom/ui";
import { FiSmartphone, FiMail, FiZap } from "@calcom/ui/components/icon";
type WorkflowExampleType = {
Icon: SVGComponent;
@@ -31,24 +33,33 @@ function WorkflowExample(props: WorkflowExampleType) {
);
}
export default function EmptyScreen({
IconHeading,
headline,
description,
buttonText,
buttonOnClick,
isLoading,
showExampleWorkflows,
}: {
IconHeading: SVGComponent;
headline: string;
description: string | React.ReactElement;
buttonText?: string;
buttonOnClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
isLoading: boolean;
showExampleWorkflows: boolean;
export default function EmptyScreen(props: {
profileOptions: {
label: string | null;
image?: string | null;
teamId: number | null | undefined;
}[];
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: FiSmartphone, text: t("workflow_example_1") },
@@ -60,38 +71,39 @@ export default function EmptyScreen({
];
// new workflow example when 'after meetings ends' trigger is implemented: Send custom thank you email to attendee after event (FiSmile icon),
if (props.isFilteredView) {
return <ClassicEmptyScreen Icon={FiZap} 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="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" />
<FiZap 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>
<h2 className="text-semibold font-cal mt-6 text-xl dark:text-gray-300">{t("workflows")}</h2>
<p className="line-clamp-2 mt-3 text-sm font-normal leading-6 text-gray-700 dark:text-gray-300">
{description}
{t("no_workflows_description")}
</p>
{buttonOnClick && buttonText && (
<Button
type="button"
StartIcon={FiPlus}
onClick={(e) => buttonOnClick(e)}
loading={isLoading}
className="mx-auto 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 className="mt-8 ">
<CreateButton
subtitle={t("new_workflow_subtitle").toUpperCase()}
options={props.profileOptions}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
buttonText={t("create_workflow")}
isLoading={createMutation.isLoading}
/>
</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>
</>
);
}
@@ -13,7 +13,7 @@ import { FiExternalLink, FiZap } from "@calcom/ui/components/icon";
import LicenseRequired from "../../common/components/v2/LicenseRequired";
import { getActionIcon } from "../lib/getActionIcon";
import SkeletonLoader from "./SkeletonLoaderEventWorkflowsTab";
import { WorkflowType } from "./WorkflowListPage";
import type { WorkflowType } from "./WorkflowListPage";
type ItemProps = {
workflow: WorkflowType;
@@ -65,6 +65,11 @@ const WorkflowListItem = (props: ItemProps) => {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
// TODO: Add missing translation
const message = `${err.data.code}: You are not authorized to enable or disable this workflow`;
showToast(message, "error");
}
},
});
@@ -148,14 +153,21 @@ type Props = {
eventType: {
id: number;
title: string;
userId: number | null;
team: {
id?: number;
} | null;
};
workflows: WorkflowType[];
};
function EventWorkflowsTab(props: Props) {
const { workflows } = props;
const { workflows, eventType } = props;
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.workflows.list.useQuery();
const { data, isLoading } = trpc.viewer.workflows.list.useQuery({
teamId: eventType.team?.id,
userId: eventType.userId || undefined,
});
const router = useRouter();
const [sortedWorkflows, setSortedWorkflows] = useState<Array<WorkflowType>>([]);
@@ -176,7 +188,7 @@ function EventWorkflowsTab(props: Props) {
}
}, [isLoading]);
const createMutation = trpc.viewer.workflows.createV2.useMutation({
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
await router.replace("/workflows/" + workflow.id);
},
@@ -212,7 +224,7 @@ function EventWorkflowsTab(props: Props) {
<Button
target="_blank"
color="secondary"
onClick={() => createMutation.mutate()}
onClick={() => createMutation.mutate({ teamId: eventType.team?.id })}
loading={createMutation.isLoading}>
{t("create_workflow")}
</Button>
@@ -1,7 +1,10 @@
import { WorkflowActions, WorkflowTemplates } from "@prisma/client";
import type { WorkflowActions } from "@prisma/client";
import { WorkflowTemplates } from "@prisma/client";
import { useRouter } from "next/router";
import { Dispatch, SetStateAction, useMemo, useState } from "react";
import { Controller, UseFormReturn } from "react-hook-form";
import type { Dispatch, SetStateAction } from "react";
import { useMemo, useState } 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";
@@ -21,10 +24,12 @@ interface Props {
workflowId: number;
selectedEventTypes: Option[];
setSelectedEventTypes: Dispatch<SetStateAction<Option[]>>;
teamId?: number;
isMixedEventType: boolean;
}
export default function WorkflowDetailsPage(props: Props) {
const { form, workflowId, selectedEventTypes, setSelectedEventTypes } = props;
const { form, workflowId, selectedEventTypes, setSelectedEventTypes, teamId, isMixedEventType } = props;
const { t } = useLocale();
const router = useRouter();
@@ -36,19 +41,32 @@ export default function WorkflowDetailsPage(props: Props) {
const eventTypeOptions = useMemo(
() =>
data?.eventTypeGroups.reduce(
(options, group) => [
data?.eventTypeGroups.reduce((options, group) => {
/** only show event types that belong to team or user */
if (!(!teamId && !group.teamId) || teamId !== group.teamId) return options;
return [
...options,
...group.eventTypes.map((eventType) => ({
value: String(eventType.id),
label: eventType.title,
})),
],
[] as Option[]
) || [],
];
}, [] as Option[]) || [],
[data]
);
let allEventTypeOptions = eventTypeOptions;
const distinctEventTypes = new Set();
if (!teamId && isMixedEventType) {
allEventTypeOptions = [...eventTypeOptions, ...selectedEventTypes];
allEventTypeOptions = allEventTypeOptions.filter((option) => {
const duplicate = distinctEventTypes.has(option.value);
distinctEventTypes.add(option.value);
return !duplicate;
});
}
const addAction = (
action: WorkflowActions,
sendTo?: string,
@@ -101,7 +119,7 @@ export default function WorkflowDetailsPage(props: Props) {
render={() => {
return (
<MultiSelectCheckboxes
options={eventTypeOptions}
options={allEventTypeOptions}
isLoading={isLoading}
className="w-full md:w-64"
setSelected={setSelectedEventTypes}
@@ -129,7 +147,7 @@ export default function WorkflowDetailsPage(props: Props) {
<div className="w-full rounded-md border border-gray-200 bg-gray-50 p-3 py-5 md:ml-3 md:p-8">
{form.getValues("trigger") && (
<div>
<WorkflowStepContainer form={form} />
<WorkflowStepContainer form={form} teamId={teamId} />
</div>
)}
{form.getValues("steps") && (
@@ -142,6 +160,7 @@ export default function WorkflowDetailsPage(props: Props) {
step={step}
reload={reload}
setReload={setReload}
teamId={teamId}
/>
);
})}
@@ -1,11 +1,11 @@
import { Workflow, WorkflowStep } from "@prisma/client";
import type { Workflow, WorkflowStep, Membership } from "@prisma/client";
import { useSession } from "next-auth/react";
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 { trpc } from "@calcom/trpc/react";
import {
Button,
@@ -15,50 +15,22 @@ import {
DropdownMenuItem,
DropdownItem,
DropdownMenuTrigger,
showToast,
Tooltip,
Badge,
} from "@calcom/ui";
import { FiEdit2, FiLink, FiMoreHorizontal, FiTrash2, FiZap } from "@calcom/ui/components/icon";
import { FiEdit2, FiLink, FiMoreHorizontal, FiTrash2 } from "@calcom/ui/components/icon";
import { getActionIcon } from "../lib/getActionIcon";
import { DeleteDialog } from "./DeleteDialog";
import EmptyScreen from "./EmptyScreen";
const CreateEmptyWorkflowView = () => {
const { t } = useLocale();
const router = useRouter();
const createMutation = trpc.viewer.workflows.createV2.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 able to create this workflow`;
showToast(message, "error");
}
},
});
return (
<EmptyScreen
buttonText={t("create_workflow")}
buttonOnClick={() => createMutation.mutate()}
IconHeading={FiZap}
headline={t("workflows")}
description={t("no_workflows_description")}
isLoading={createMutation.isLoading}
showExampleWorkflows={true}
/>
);
};
export type WorkflowType = Workflow & {
team: {
id: number;
name: string;
members: Membership[];
slug: string | null;
} | null;
steps: WorkflowStep[];
activeOn: {
eventType: {
@@ -66,16 +38,24 @@ export type WorkflowType = Workflow & {
title: string;
};
}[];
readOnly?: boolean;
};
interface Props {
workflows: WorkflowType[] | undefined;
profileOptions: {
image?: string | null;
label: string | null;
teamId: number | null | undefined;
}[];
hasNoWorkflows?: boolean;
}
export default function WorkflowListPage({ workflows }: Props) {
export default function WorkflowListPage({ workflows, profileOptions, hasNoWorkflows }: Props) {
const { t } = useLocale();
const utils = trpc.useContext();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0);
const router = useRouter();
const session = useSession();
return (
<>
@@ -87,57 +67,79 @@ export default function WorkflowListPage({ workflows }: Props) {
<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} 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-gray-500"
)}>
{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 className="flex">
<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-gray-500"
)}>
{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-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>
<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 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 ? (
<Tooltip
content={workflow.activeOn.map((activeOn, key) => (
<p key={key}>{activeOn.eventType.title}</p>
))}>
<li>
<Badge variant="gray">
{workflow.activeOn && workflow.activeOn.length > 0 ? (
<Tooltip
content={workflow.activeOn.map((activeOn, key) => (
<p key={key}>{activeOn.eventType.title}</p>
))}>
<div>
<FiLink className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_event_types", { count: workflow.activeOn.length })}
</div>
</Tooltip>
) : (
<div>
<FiLink className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_event_types", { count: workflow.activeOn.length })}
{t("no_active_event_types")}
</div>
</Tooltip>
) : (
<div>
<FiLink className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("no_active_event_types")}
</div>
)}
)}
</Badge>
</li>
{workflow.teamId && (
<li>
<Badge variant="gray">
<>{workflow.team?.name}</>
</Badge>
</li>
)}
</ul>
</div>
</Link>
<div className="flex flex-shrink-0">
<div className="hidden sm:block">
<ButtonGroup combined>
@@ -147,6 +149,7 @@ export default function WorkflowListPage({ workflows }: Props) {
color="secondary"
variant="icon"
StartIcon={FiEdit2}
disabled={workflow.readOnly}
onClick={async () => await router.replace("/workflows/" + workflow.id)}
/>
</Tooltip>
@@ -158,40 +161,48 @@ export default function WorkflowListPage({ workflows }: Props) {
}}
color="secondary"
variant="icon"
disabled={workflow.readOnly}
StartIcon={FiTrash2}
/>
</Tooltip>
</ButtonGroup>
</div>
<div className="block sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" color="minimal" variant="icon" StartIcon={FiMoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
{!workflow.readOnly && (
<div className="block sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
type="button"
StartIcon={FiEdit2}
onClick={async () => await router.replace("/workflows/" + workflow.id)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon={FiTrash2}
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
color="minimal"
variant="icon"
StartIcon={FiMoreHorizontal}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon={FiEdit2}
onClick={async () => await router.replace("/workflows/" + workflow.id)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon={FiTrash2}
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
</div>
</li>
@@ -207,7 +218,7 @@ export default function WorkflowListPage({ workflows }: Props) {
/>
</div>
) : (
<CreateEmptyWorkflowView />
<EmptyScreen profileOptions={profileOptions} isFilteredView={!hasNoWorkflows} />
)}
</>
);
@@ -1,12 +1,9 @@
import {
TimeUnit,
WorkflowActions,
WorkflowStep,
WorkflowTemplates,
WorkflowTriggerEvents,
} from "@prisma/client";
import { Dispatch, SetStateAction, useRef, useState } from "react";
import { Controller, UseFormReturn } from "react-hook-form";
import type { WorkflowStep } from "@prisma/client";
import { TimeUnit, WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@prisma/client";
import type { Dispatch, SetStateAction } from "react";
import { useRef, useState, useEffect } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller } from "react-hook-form";
import "react-phone-number-input/style.css";
import { classNames } from "@calcom/lib";
@@ -51,17 +48,20 @@ type WorkflowStepProps = {
form: UseFormReturn<FormValues>;
reload?: boolean;
setReload?: Dispatch<SetStateAction<boolean>>;
teamId?: number;
};
export default function WorkflowStepContainer(props: WorkflowStepProps) {
const { t, i18n } = useLocale();
const { t } = useLocale();
const utils = trpc.useContext();
const { step, form, reload, setReload } = props;
const { data: _verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery();
const verifiedNumbers = _verifiedNumbers?.map((number) => number.phoneNumber);
const { step, form, reload, setReload, teamId } = props;
const { data: _verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(
{ teamId },
{ enabled: !!teamId }
);
const verifiedNumbers = _verifiedNumbers?.map((number) => number.phoneNumber) || [];
const [isAdditionalInputsDialogOpen, setIsAdditionalInputsDialogOpen] = useState(false);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [verificationCode, setVerificationCode] = useState("");
@@ -75,6 +75,13 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
: false
);
useEffect(() => {
setNumberVerified(
!!step &&
!!verifiedNumbers.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`))
);
}, [verifiedNumbers.length]);
const [isEmailAddressNeeded, setIsEmailAddressNeeded] = useState(
step?.action === WorkflowActions.EMAIL_ADDRESS ? true : false
);
@@ -116,9 +123,8 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
const refReminderBody = useRef<HTMLTextAreaElement | null>(null);
const [numberVerified, setNumberVerified] = useState(
verifiedNumbers && step
? !!verifiedNumbers.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`))
: false
step &&
!!verifiedNumbers.find((number) => number === form.getValues(`steps.${step.stepNumber - 1}.sendTo`))
);
const addVariableBody = (variable: string) => {
@@ -451,11 +457,18 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
verifyPhoneNumberMutation.mutate({
phoneNumber: form.getValues(`steps.${step.stepNumber - 1}.sendTo`) || "",
code: verificationCode,
teamId,
});
}}>
Verify
{t("verify")}
</Button>
</div>
{form.formState.errors.steps &&
form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && (
<p className="mt-1 text-xs text-red-500">
{form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""}
</p>
)}
</>
)}
</div>
@@ -54,6 +54,7 @@ export const scheduleWorkflowReminders = async (
step.template,
step.sender || SENDER_ID,
workflow.userId,
workflow.teamId,
step.numberVerificationPending
);
} else if (
@@ -124,6 +125,7 @@ export const sendCancelledReminders = async (
step.template,
step.sender || SENDER_ID,
workflow.userId,
workflow.teamId,
step.numberVerificationPending
);
} else if (
@@ -53,7 +53,8 @@ export const scheduleSMSReminder = async (
workflowStepId: number,
template: WorkflowTemplates,
sender: string,
userId: number,
userId?: number | null,
teamId?: number | null,
isVerificationPending = false
) => {
const { startTime, endTime } = evt;
@@ -69,7 +70,10 @@ export const scheduleSMSReminder = async (
async function getIsNumberVerified() {
if (action === WorkflowActions.SMS_ATTENDEE) return true;
const verifiedNumber = await prisma.verifiedNumber.findFirst({
where: { userId, phoneNumber: reminderPhone || "" },
where: {
OR: [{ userId }, { teamId }],
phoneNumber: reminderPhone || "",
},
});
if (!!verifiedNumber) return true;
return isVerificationPending;
@@ -6,13 +6,21 @@ export const sendVerificationCode = async (phoneNumber: string) => {
return twilio.sendVerificationCode(phoneNumber);
};
export const verifyPhoneNumber = async (phoneNumber: string, code: string, userId: number) => {
export const verifyPhoneNumber = async (
phoneNumber: string,
code: string,
userId?: number,
teamId?: number
) => {
if (!userId && !teamId) return true;
const verificationStatus = await twilio.verifyNumber(phoneNumber, code);
if (verificationStatus === "approved") {
await prisma.verifiedNumber.create({
data: {
userId,
teamId,
phoneNumber,
},
});
+214 -14
View File
@@ -1,26 +1,34 @@
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import type { Dispatch, SetStateAction } from "react";
import { useState, useEffect } from "react";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { trpc } from "@calcom/trpc/react";
import { Button, showToast } from "@calcom/ui";
import { FiPlus } from "@calcom/ui/components/icon";
import { AnimatedPopover, Avatar, CreateButton, showToast } from "@calcom/ui";
import LicenseRequired from "../../common/components/v2/LicenseRequired";
import SkeletonLoader from "../components/SkeletonLoaderList";
import type { WorkflowType } from "../components/WorkflowListPage";
import WorkflowList from "../components/WorkflowListPage";
function WorkflowsPage() {
const { t } = useLocale();
const session = useSession();
const router = useRouter();
const [checkedFilterItems, setCheckedFilterItems] = useState<{ userId: number | null; teamIds: number[] }>({
userId: session.data?.user.id || null,
teamIds: [],
});
const { data, isLoading } = trpc.viewer.workflows.list.useQuery();
const { data: allWorkflowsData, isLoading } = trpc.viewer.workflows.list.useQuery();
const createMutation = trpc.viewer.workflows.createV2.useMutation({
const [filteredWorkflows, setFilteredWorkflows] = useState<WorkflowType[]>([]);
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
await router.replace("/workflows/" + workflow.id);
},
@@ -37,20 +45,66 @@ function WorkflowsPage() {
},
});
const query = trpc.viewer.workflows.getByViewer.useQuery();
useEffect(() => {
const allWorkflows = allWorkflowsData?.workflows;
if (allWorkflows && allWorkflows.length > 0) {
const filtered = allWorkflows.filter((workflow) => {
if (checkedFilterItems.teamIds.includes(workflow.teamId || 0)) return workflow;
if (!workflow.teamId) {
if (!!workflow.userId && workflow.userId === checkedFilterItems.userId) return workflow;
}
});
setFilteredWorkflows(filtered);
} else {
setFilteredWorkflows([]);
}
}, [checkedFilterItems, allWorkflowsData]);
useEffect(() => {
if (session.status !== "loading" && !query.isLoading) {
if (!query.data) return;
setCheckedFilterItems({
userId: session.data?.user.id || null,
teamIds: query.data.profiles
.map((profile) => {
if (!!profile.teamId) {
return profile.teamId;
}
})
.filter((teamId): teamId is number => !!teamId),
});
}
}, [session.status, query.isLoading, allWorkflowsData]);
if (!query.data) return null;
const profileOptions = query.data.profiles
.filter((profile) => !profile.readOnly)
.map((profile) => {
return { teamId: profile.teamId, label: profile.name || profile.slug, image: profile.image };
});
return (
<Shell
heading={t("workflows")}
title={t("workflows")}
subtitle={t("workflows_to_automate_notifications")}
CTA={
session.data?.hasValidLicense && data?.workflows && data?.workflows.length > 0 ? (
<Button
variant="fab"
StartIcon={FiPlus}
onClick={() => createMutation.mutate()}
loading={createMutation.isLoading}>
{t("new")}
</Button>
query.data.profiles.length === 1 &&
session.data?.hasValidLicense &&
allWorkflowsData?.workflows &&
allWorkflowsData?.workflows.length > 0 ? (
<CreateButton
subtitle={t("new_workflow_subtitle").toUpperCase()}
options={profileOptions}
createFunction={(teamId?: number) => {
createMutation.mutate({ teamId });
}}
isLoading={createMutation.isLoading}
disableMobileButton={true}
/>
) : (
<></>
)
@@ -60,7 +114,31 @@ function WorkflowsPage() {
<SkeletonLoader />
) : (
<>
<WorkflowList workflows={data?.workflows} />
{query.data.profiles.length > 1 &&
allWorkflowsData?.workflows &&
allWorkflowsData.workflows.length > 0 && (
<div className="mb-4 flex">
<Filter
profiles={query.data.profiles}
checked={checkedFilterItems}
setChecked={setCheckedFilterItems}
/>
<div className="ml-auto">
<CreateButton
subtitle={t("new_workflow_subtitle").toUpperCase()}
options={profileOptions}
createFunction={(teamId?: number) => createMutation.mutate({ teamId })}
isLoading={createMutation.isLoading}
disableMobileButton={true}
/>
</div>
</div>
)}
<WorkflowList
workflows={filteredWorkflows}
profileOptions={profileOptions}
hasNoWorkflows={!allWorkflowsData?.workflows || allWorkflowsData?.workflows.length === 0}
/>
</>
)}
</LicenseRequired>
@@ -68,4 +146,126 @@ function WorkflowsPage() {
);
}
const Filter = (props: {
profiles: {
readOnly?: boolean | undefined;
slug: string | null;
name: string | null;
teamId: number | null | undefined;
}[];
checked: {
userId: number | null;
teamIds: number[];
};
setChecked: Dispatch<
SetStateAction<{
userId: number | null;
teamIds: number[];
}>
>;
}) => {
const session = useSession();
const userId = session.data?.user.id || 0;
const userName = session.data?.user.name || "";
const teams = props.profiles.filter((profile) => !!profile.teamId);
const { checked, setChecked } = props;
const [noFilter, setNoFilter] = useState(true);
return (
<div className={classNames("-mb-2", noFilter ? "w-16" : "w-[100px]")}>
<AnimatedPopover text={noFilter ? "All" : "Filtered"}>
<div className="item-center flex px-4 py-[6px] focus-within:bg-gray-100 hover:cursor-pointer hover:bg-gray-50">
<Avatar
imageSrc=""
size="sm"
alt={`${userName} Avatar`}
gravatarFallbackMd5="fallback"
className="self-center"
asChild
/>
<label
htmlFor="yourWorkflows"
className="ml-2 mr-auto self-center truncate text-sm font-medium text-gray-700">
{userName}
</label>
<input
id="yourWorkflows"
type="checkbox"
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
checked={!!checked.userId}
onChange={(e) => {
if (e.target.checked) {
setChecked({ userId: userId, teamIds: checked.teamIds });
if (checked.teamIds.length === teams.length) {
setNoFilter(true);
}
} else if (!e.target.checked) {
setChecked({ userId: null, teamIds: checked.teamIds });
setNoFilter(false);
}
}}
/>
</div>
{teams.map((profile) => (
<div
className="item-center flex px-4 py-[6px] focus-within:bg-gray-100 hover:cursor-pointer hover:bg-gray-50"
key={`${profile.teamId || 0}`}>
<Avatar
imageSrc=""
size="sm"
alt={`${profile.slug} Avatar`}
gravatarFallbackMd5="fallback"
className="self-center"
asChild
/>
<label
htmlFor={profile.slug || ""}
className="ml-2 mr-auto select-none self-center truncate text-sm font-medium text-gray-700 hover:cursor-pointer">
{profile.slug}
</label>
<input
id={profile.slug || ""}
name={profile.slug || ""}
type="checkbox"
checked={checked.teamIds?.includes(profile.teamId || 0)}
onChange={(e) => {
if (e.target.checked) {
const updatedChecked = checked;
updatedChecked.teamIds.push(profile.teamId || 0);
setChecked({ userId: checked.userId, teamIds: [...updatedChecked.teamIds] });
if (checked.userId && updatedChecked.teamIds.length === teams.length) {
setNoFilter(true);
} else {
setNoFilter(false);
}
} else if (!e.target.checked) {
const index = checked.teamIds.indexOf(profile.teamId || 0);
if (index !== -1) {
const updatedChecked = checked;
updatedChecked.teamIds.splice(index, 1);
setChecked({ userId: checked.userId, teamIds: [...updatedChecked.teamIds] });
if (checked.userId && updatedChecked.teamIds.length === teams.length) {
setNoFilter(true);
} else {
setNoFilter(false);
}
}
}
}}
className="text-primary-600 focus:ring-primary-500 inline-flex h-4 w-4 place-self-center justify-self-end rounded border-gray-300 "
/>
</div>
))}
</AnimatedPopover>
</div>
);
};
export default WorkflowsPage;
@@ -1,10 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { WorkflowStep } from "@prisma/client";
import {
TimeUnit,
WorkflowActions,
WorkflowStep,
WorkflowTemplates,
WorkflowTriggerEvents,
MembershipRole,
} from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useSession } from "next-auth/react";
@@ -21,7 +22,7 @@ import { HttpError } from "@calcom/lib/http-error";
import { stringOrNumber } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui";
import { Alert, Button, Form, showToast } from "@calcom/ui";
import { Alert, Button, Form, showToast, Badge } from "@calcom/ui";
import LicenseRequired from "../../common/components/v2/LicenseRequired";
import SkeletonLoader from "../components/SkeletonLoaderEdit";
@@ -87,6 +88,7 @@ function WorkflowPage() {
const [selectedEventTypes, setSelectedEventTypes] = useState<Option[]>([]);
const [isAllDataLoaded, setIsAllDataLoaded] = useState(false);
const [isMixedEventType, setIsMixedEventType] = useState(false); //for old event types before team workflows existed
const form = useForm<FormValues>({
mode: "onBlur",
@@ -108,10 +110,22 @@ function WorkflowPage() {
}
);
const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery();
const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery(
{ teamId: workflow?.team?.id },
{
enabled: !!workflow?.id,
}
);
const readOnly =
workflow?.team?.members?.find((member) => member.userId === session.data?.user.id)?.role ===
MembershipRole.MEMBER;
useEffect(() => {
if (workflow && !isLoading) {
if (workflow.userId && workflow.activeOn.find((active) => !!active.eventType.teamId)) {
setIsMixedEventType(true);
}
setSelectedEventTypes(
workflow.activeOn.map((active) => ({
value: String(active.eventType.id),
@@ -247,14 +261,23 @@ function WorkflowPage() {
title={workflow && workflow.name ? workflow.name : "Untitled"}
CTA={
<div>
<Button type="submit">{t("save")}</Button>
<Button type="submit" disabled={readOnly}>
{t("save")}
</Button>
</div>
}
heading={
session.data?.hasValidLicense &&
isAllDataLoaded && (
<div className={classNames(workflow && !workflow.name ? "text-gray-400" : "")}>
{workflow && workflow.name ? workflow.name : "untitled"}
<div className="flex">
<div className={classNames(workflow && !workflow.name ? "text-gray-400" : "")}>
{workflow && workflow.name ? workflow.name : "untitled"}
</div>
{workflow && workflow.team && (
<Badge className="mt-1 ml-4" variant="gray">
{workflow.team.slug}
</Badge>
)}
</div>
)
}>
@@ -268,6 +291,8 @@ function WorkflowPage() {
workflowId={+workflowId}
selectedEventTypes={selectedEventTypes}
setSelectedEventTypes={setSelectedEventTypes}
teamId={workflow ? workflow.teamId || undefined : undefined}
isMixedEventType={isMixedEventType}
/>
</>
) : (