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:
parent
c20835a4c8
commit
0ec71e52ef
|
@ -5,10 +5,9 @@ import type { FC } from "react";
|
|||
import { useEffect, useState, memo } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
CreateEventTypeButton,
|
||||
EventTypeDescriptionLazy as EventTypeDescription,
|
||||
} from "@calcom/features/eventtypes/components";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
|
||||
import { DuplicateDialog } from "@calcom/features/eventtypes/components/DuplicateDialog";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { APP_NAME, CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
@ -35,6 +34,7 @@ import {
|
|||
showToast,
|
||||
Switch,
|
||||
Tooltip,
|
||||
CreateButton,
|
||||
HorizontalTabs,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
|
@ -134,9 +134,9 @@ const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGrou
|
|||
</small>
|
||||
) : null}
|
||||
{readOnly && (
|
||||
<span className="items-center rounded-sm bg-gray-100 px-1.5 py-0.5 text-xs font-medium text-gray-800 ltr:ml-2 ltr:mr-2 rtl:ml-2">
|
||||
<Badge variant="gray" className="ml-2">
|
||||
{t("readonly")}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<EventTypeDescription
|
||||
|
@ -240,7 +240,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
|||
const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "duplicate-event-type",
|
||||
dialog: "duplicate",
|
||||
title: eventType.title,
|
||||
description: eventType.description,
|
||||
slug: eventType.slug,
|
||||
|
@ -627,17 +627,32 @@ const CreateFirstEventTypeView = () => {
|
|||
};
|
||||
|
||||
const CTA = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const query = trpc.viewer.eventTypes.getByViewer.useQuery();
|
||||
|
||||
if (!query.data) return null;
|
||||
|
||||
return <CreateEventTypeButton canAddEvents={true} options={query.data.profiles} />;
|
||||
const profileOptions = query.data.profiles
|
||||
.filter((profile) => !profile.readOnly)
|
||||
.map((profile) => {
|
||||
return { teamId: profile.teamId, label: profile.name || profile.slug, image: profile.image };
|
||||
});
|
||||
|
||||
return (
|
||||
<CreateButton
|
||||
subtitle={t("create_event_on").toUpperCase()}
|
||||
options={profileOptions}
|
||||
createDialog={CreateEventTypeDialog}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const WithQuery = withQuery(trpc.viewer.eventTypes.getByViewer);
|
||||
|
||||
const EventTypesPage = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
|
||||
|
@ -683,6 +698,7 @@ const EventTypesPage = () => {
|
|||
)}
|
||||
|
||||
<EmbedDialog />
|
||||
{router.query.dialog === "duplicate" && <DuplicateDialog />}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -598,7 +598,6 @@
|
|||
"new_event_type_heading": "Create your first event type",
|
||||
"new_event_type_description": "Event types enable you to share links that show available times on your calendar and allow people to make bookings with you.",
|
||||
"new_event_title": "Add a new event type",
|
||||
"new_event_subtitle": "Create an event type under your name or a team.",
|
||||
"new_team_event": "Add a new team event type",
|
||||
"new_event_description": "Create a new event type for people to book times with.",
|
||||
"event_type_created_successfully": "{{eventTypeTitle}} event type created successfully",
|
||||
|
@ -1527,6 +1526,7 @@
|
|||
"sender_name": "Sender name",
|
||||
"already_invited": "Attendee already invited",
|
||||
"no_recordings_found": "No recordings found",
|
||||
"new_workflow_subtitle": "New workflow for...",
|
||||
"reporting": "Reporting",
|
||||
"reporting_feature": "See all incoming from data and download it as a CSV",
|
||||
"teams_plan_required": "Teams plan required",
|
||||
|
@ -1581,14 +1581,17 @@
|
|||
"email_no_user_step_four":"Join {{teamName}}",
|
||||
"email_no_user_signoff":"Happy Scheduling from the {{appName}} team",
|
||||
"impersonation_user_tip": "You are about to impersonate a user, which means you can make changes on their behalf. Please be careful.",
|
||||
"scheduler": "{Scheduler}",
|
||||
"available_variables": "Available variables",
|
||||
"scheduler": "{Scheduler}",
|
||||
"no_workflows": "No workflows",
|
||||
"change_filter": "Change filter to see your personal and team workflows.",
|
||||
"recommended_next_steps": "Recommended next steps",
|
||||
"create_a_managed_event": "Create a managed event type",
|
||||
"meetings_are_better_with_the_right": "Meetings are better with the right team members there. Invite them now.",
|
||||
"create_a_one_one_template": "Create a one-one one template for an event type and distribute it to multiple members.",
|
||||
"collective_or_roundrobin": "Collective or round-robin",
|
||||
"book_your_team_members": "Book your team members together with collective events or cycle through to get the right person with round-robin.",
|
||||
"create_event_on": "Create event on",
|
||||
"default_app_link_title":"Set a default app link",
|
||||
"default_app_link_description":"Setting a default app link allows all newly created event types to use the app link you set.",
|
||||
"change_default_conferencing_app":"Set as default",
|
||||
|
@ -1619,5 +1622,7 @@
|
|||
"select_a_router": "Select a router",
|
||||
"add_a_new_route": "Add a new Route",
|
||||
"no_responses_yet": "No responses yet",
|
||||
"this_will_be_the_placeholder": "This will be the placeholder"
|
||||
"this_will_be_the_placeholder": "This will be the placeholder",
|
||||
"verification_code": "Verification code",
|
||||
"verify": "Verify"
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { MembershipRole, Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
|
@ -7,7 +8,6 @@ import { useRouter } from "next/router";
|
|||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
@ -5,7 +5,6 @@ import { useRouter } from "next/router";
|
|||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
@ -14,27 +13,16 @@ import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
|||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Form,
|
||||
RadioGroup as RadioArea,
|
||||
showToast,
|
||||
TextAreaField,
|
||||
TextField,
|
||||
} from "@calcom/ui";
|
||||
import { FiPlus } from "@calcom/ui/components/icon";
|
||||
|
||||
import { DuplicateDialog } from "./DuplicateDialog";
|
||||
|
||||
// this describes the uniform data needed to create a new event type on Profile or Team
|
||||
export interface EventTypeParent {
|
||||
|
@ -44,15 +32,6 @@ export interface EventTypeParent {
|
|||
image?: string | null;
|
||||
}
|
||||
|
||||
interface CreateEventTypeBtnProps {
|
||||
// set true for use on the team settings page
|
||||
canAddEvents: boolean;
|
||||
// set true when in use on the team settings page
|
||||
isIndividualTeam?: boolean;
|
||||
// EventTypeParent can be a profile (as first option) or a team for the rest.
|
||||
options: EventTypeParent[];
|
||||
}
|
||||
|
||||
const locationFormSchema = z.array(
|
||||
z.object({
|
||||
locationType: z.string(),
|
||||
|
@ -71,10 +50,7 @@ const querySchema = z.object({
|
|||
teamId: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||
title: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
length: z
|
||||
.union([z.string().transform((val) => +val), z.number()])
|
||||
.optional()
|
||||
.default(15),
|
||||
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||
description: z.string().optional(),
|
||||
schedulingType: z.nativeEnum(SchedulingType).optional(),
|
||||
locations: z
|
||||
|
@ -83,17 +59,19 @@ const querySchema = z.object({
|
|||
.optional(),
|
||||
});
|
||||
|
||||
const CreateEventTypeDialog = () => {
|
||||
export default function CreateEventTypeDialog() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
data: { teamId, eventPage: pageSlug, ...defaultValues },
|
||||
data: { teamId, eventPage: pageSlug },
|
||||
} = useTypedQuery(querySchema);
|
||||
|
||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||
defaultValues: {
|
||||
length: 15,
|
||||
},
|
||||
resolver: zodResolver(createEventTypeInput),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { register } = form;
|
||||
|
@ -123,7 +101,7 @@ const CreateEventTypeDialog = () => {
|
|||
|
||||
return (
|
||||
<Dialog
|
||||
name="new-eventtype"
|
||||
name="new"
|
||||
clearQueryParamsOnClose={[
|
||||
"eventPage",
|
||||
"teamId",
|
||||
|
@ -249,81 +227,4 @@ const CreateEventTypeDialog = () => {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CreateEventTypeButton(props: CreateEventTypeBtnProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openModal = (option: EventTypeParent) => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "new-eventtype",
|
||||
eventPage: option.slug,
|
||||
teamId: option.teamId,
|
||||
};
|
||||
if (!option.teamId) {
|
||||
delete query.teamId;
|
||||
}
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query,
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasTeams || props.isIndividualTeam ? (
|
||||
<Button
|
||||
onClick={() => openModal(props.options[0])}
|
||||
data-testid="new-event-type"
|
||||
StartIcon={FiPlus}
|
||||
variant="fab"
|
||||
disabled={!props.canAddEvents}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
) : (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="fab" StartIcon={FiPlus}>
|
||||
{t("new")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={14} align="end">
|
||||
<DropdownMenuLabel>
|
||||
<div className="max-w-48">{t("new_event_subtitle")}</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{props.options.map((option) => (
|
||||
<DropdownMenuItem key={option.slug}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={(props: any) => (
|
||||
<Avatar
|
||||
alt={option.name || ""}
|
||||
imageSrc={option.image || `${WEBAPP_URL}/${option.slug}/avatar.png`} // if no image, use default avatar
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onClick={() => openModal(option)}>
|
||||
<span>{option.name ? option.name : option.slug}</span>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
{/* Dialog for duplicate event type */}
|
||||
{router.query.dialog === "duplicate-event-type" && <DuplicateDialog />}
|
||||
{router.query.dialog === "new-eventtype" && <CreateEventTypeDialog />}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
|
@ -44,6 +45,16 @@ const DuplicateDialog = () => {
|
|||
});
|
||||
const { register } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.dialog === "duplicate") {
|
||||
form.setValue("id", Number(router.query.id as string) || -1);
|
||||
form.setValue("title", (router.query.title as string) || "");
|
||||
form.setValue("slug", t("event_type_duplicate_copy_text", { slug: router.query.slug as string }));
|
||||
form.setValue("description", (router.query.description as string) || "");
|
||||
form.setValue("length", Number(router.query.length) || 30);
|
||||
}
|
||||
}, [router.query.dialog]);
|
||||
|
||||
const duplicateMutation = trpc.viewer.eventTypes.duplicate.useMutation({
|
||||
onSuccess: async ({ eventType }) => {
|
||||
await router.replace("/event-types/" + eventType.id);
|
||||
|
@ -69,7 +80,7 @@ const DuplicateDialog = () => {
|
|||
|
||||
return (
|
||||
<Dialog
|
||||
name="duplicate-event-type"
|
||||
name="duplicate"
|
||||
clearQueryParamsOnClose={["description", "title", "length", "slug", "name", "id", "pageSlug"]}>
|
||||
<DialogContent type="creation" className="overflow-y-auto" title="Duplicate Event Type">
|
||||
<Form
|
||||
|
|
|
@ -2,6 +2,6 @@ import dynamic from "next/dynamic";
|
|||
|
||||
export { default as CheckedTeamSelect } from "./CheckedTeamSelect";
|
||||
export { default as CustomInputItem } from "./CustomInputItem";
|
||||
export { default as CreateEventTypeButton } from "./CreateEventTypeButton";
|
||||
export { default as CreateEventTypeDialog } from "./CreateEventTypeDialog";
|
||||
export { default as EventTypeDescription } from "./EventTypeDescription";
|
||||
export const EventTypeDescriptionLazy = dynamic(() => import("./EventTypeDescription"));
|
||||
|
|
|
@ -152,6 +152,14 @@ export default async function getEventTypeById({
|
|||
include: {
|
||||
workflow: {
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
activeOn: {
|
||||
select: {
|
||||
eventType: {
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Workflow" ADD COLUMN "teamId" INTEGER,
|
||||
ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Workflow" ADD CONSTRAINT "Workflow_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -0,0 +1,6 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "VerifiedNumber" ADD COLUMN "teamId" INTEGER,
|
||||
ALTER COLUMN "userId" DROP NOT NULL;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VerifiedNumber" ADD CONSTRAINT "VerifiedNumber_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@ -203,23 +203,25 @@ model User {
|
|||
}
|
||||
|
||||
model Team {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
/// @zod.min(1)
|
||||
name String
|
||||
/// @zod.min(1)
|
||||
slug String? @unique
|
||||
slug String? @unique
|
||||
logo String?
|
||||
bio String?
|
||||
hideBranding Boolean @default(false)
|
||||
hideBookATeamMember Boolean @default(false)
|
||||
hideBranding Boolean @default(false)
|
||||
hideBookATeamMember Boolean @default(false)
|
||||
members Membership[]
|
||||
eventTypes EventType[]
|
||||
createdAt DateTime @default(now())
|
||||
workflows Workflow[]
|
||||
createdAt DateTime @default(now())
|
||||
/// @zod.custom(imports.teamMetadataSchema)
|
||||
metadata Json?
|
||||
theme String?
|
||||
brandColor String @default("#292929")
|
||||
darkBrandColor String @default("#fafafa")
|
||||
brandColor String @default("#292929")
|
||||
darkBrandColor String @default("#fafafa")
|
||||
verifiedNumbers VerifiedNumber[]
|
||||
}
|
||||
|
||||
enum MembershipRole {
|
||||
|
@ -598,8 +600,10 @@ model WorkflowStep {
|
|||
model Workflow {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId Int?
|
||||
activeOn WorkflowsOnEventTypes[]
|
||||
trigger WorkflowTriggerEvents
|
||||
time Int?
|
||||
|
@ -656,7 +660,9 @@ enum WorkflowMethods {
|
|||
|
||||
model VerifiedNumber {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
teamId Int?
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
phoneNumber String
|
||||
}
|
||||
|
|
|
@ -219,6 +219,7 @@ export const eventTypesRouter = router({
|
|||
startTime: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
avatar: true,
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true,
|
||||
|
@ -323,6 +324,7 @@ export const eventTypesRouter = router({
|
|||
profile: {
|
||||
slug: user.username,
|
||||
name: user.name,
|
||||
image: user.avatar || undefined,
|
||||
},
|
||||
eventTypes: _.orderBy(mergedEventTypes, ["position", "id"], ["desc", "asc"]),
|
||||
metadata: {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { PrismaPromise } from "@prisma/client";
|
||||
import type { PrismaPromise, Workflow } from "@prisma/client";
|
||||
import {
|
||||
WorkflowTemplates,
|
||||
WorkflowActions,
|
||||
|
@ -6,9 +6,11 @@ import {
|
|||
BookingStatus,
|
||||
WorkflowMethods,
|
||||
TimeUnit,
|
||||
MembershipRole,
|
||||
} from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { WorkflowType } from "@calcom/features/ee/workflows/components/WorkflowListPage";
|
||||
// import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
WORKFLOW_TEMPLATES,
|
||||
|
@ -30,11 +32,12 @@ import {
|
|||
verifyPhoneNumber,
|
||||
sendVerificationCode,
|
||||
} from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber";
|
||||
import { IS_SELF_HOSTED, SENDER_ID } from "@calcom/lib/constants";
|
||||
import { IS_SELF_HOSTED, SENDER_ID, CAL_URL } from "@calcom/lib/constants";
|
||||
import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
// import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import type PrismaType from "@calcom/prisma";
|
||||
import type { WorkflowStep } from "@calcom/prisma/client";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
@ -48,32 +51,210 @@ function getSender(
|
|||
return isSMSAction(step.action) ? step.sender || SENDER_ID : step.senderName || SENDER_NAME;
|
||||
}
|
||||
|
||||
export const workflowsRouter = router({
|
||||
list: authedProcedure.query(async ({ ctx }) => {
|
||||
const workflows = await ctx.prisma.workflow.findMany({
|
||||
async function isAuthorized(
|
||||
workflow: Pick<Workflow, "id" | "teamId" | "userId"> | null,
|
||||
prisma: typeof PrismaType,
|
||||
currentUserId: number,
|
||||
readOnly?: boolean
|
||||
) {
|
||||
if (!workflow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!readOnly) {
|
||||
const userWorkflow = await prisma.workflow.findFirst({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
id: workflow.id,
|
||||
OR: [
|
||||
{ userId: currentUserId },
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: currentUserId,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
activeOn: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
});
|
||||
if (userWorkflow) return true;
|
||||
}
|
||||
|
||||
const userWorkflow = await prisma.workflow.findFirst({
|
||||
where: {
|
||||
id: workflow.id,
|
||||
OR: [
|
||||
{ userId: currentUserId },
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: currentUserId,
|
||||
accepted: true,
|
||||
NOT: {
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return { workflows };
|
||||
}),
|
||||
if (userWorkflow) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export const workflowsRouter = router({
|
||||
list: authedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
teamId: z.number().optional(),
|
||||
userId: z.number().optional(),
|
||||
})
|
||||
.optional()
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (input && input.teamId) {
|
||||
const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: input.teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId: ctx.user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
activeOn: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
},
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
const workflowsWithReadOnly = workflows.map((workflow) => {
|
||||
const readOnly = !!workflow.team?.members?.find(
|
||||
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
|
||||
);
|
||||
return { ...workflow, readOnly };
|
||||
});
|
||||
|
||||
return { workflows: workflowsWithReadOnly };
|
||||
}
|
||||
|
||||
if (input && input.userId) {
|
||||
const workflows: WorkflowType[] = await ctx.prisma.workflow.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
include: {
|
||||
activeOn: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
return { workflows };
|
||||
}
|
||||
|
||||
const workflows = await ctx.prisma.workflow.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: ctx.user.id },
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: ctx.user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
activeOn: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const workflowsWithReadOnly: WorkflowType[] = workflows.map((workflow) => {
|
||||
const readOnly = !!workflow.team?.members?.find(
|
||||
(member) => member.userId === ctx.user.id && member.role === MembershipRole.MEMBER
|
||||
);
|
||||
|
||||
return { readOnly, ...workflow };
|
||||
});
|
||||
|
||||
return { workflows: workflowsWithReadOnly };
|
||||
}),
|
||||
get: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
@ -83,12 +264,20 @@ export const workflowsRouter = router({
|
|||
.query(async ({ ctx, input }) => {
|
||||
const workflow = await ctx.prisma.workflow.findFirst({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
id: input.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
time: true,
|
||||
timeUnit: true,
|
||||
activeOn: {
|
||||
|
@ -104,45 +293,71 @@ export const workflowsRouter = router({
|
|||
},
|
||||
},
|
||||
});
|
||||
if (!workflow) {
|
||||
|
||||
const isUserAuthorized = await isAuthorized(workflow, ctx.prisma, ctx.user.id);
|
||||
|
||||
if (!isUserAuthorized) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}),
|
||||
create: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
trigger: z.enum(WORKFLOW_TRIGGER_EVENTS),
|
||||
action: z.enum(WORKFLOW_ACTIONS),
|
||||
timeUnit: z.enum(TIME_UNIT).optional(),
|
||||
time: z.number().optional(),
|
||||
sendTo: z.string().optional(),
|
||||
teamId: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { name, trigger, action, timeUnit, time, sendTo } = input;
|
||||
const { teamId } = input;
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
if (teamId) {
|
||||
const team = await ctx.prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
userId: ctx.user.id,
|
||||
accepted: true,
|
||||
NOT: {
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const workflow = await ctx.prisma.workflow.create({
|
||||
const workflow: Workflow = await ctx.prisma.workflow.create({
|
||||
data: {
|
||||
name,
|
||||
trigger,
|
||||
name: "",
|
||||
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
|
||||
time: 24,
|
||||
timeUnit: TimeUnit.HOUR,
|
||||
userId,
|
||||
timeUnit: time ? timeUnit : undefined,
|
||||
time,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.prisma.workflowStep.create({
|
||||
data: {
|
||||
stepNumber: 1,
|
||||
action,
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
workflowId: workflow.id,
|
||||
sendTo,
|
||||
sender: SENDER_NAME,
|
||||
numberVerificationPending: false,
|
||||
},
|
||||
});
|
||||
return { workflow };
|
||||
|
@ -150,35 +365,6 @@ export const workflowsRouter = router({
|
|||
throw e;
|
||||
}
|
||||
}),
|
||||
createV2: authedProcedure.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.user.id;
|
||||
|
||||
try {
|
||||
const workflow = await ctx.prisma.workflow.create({
|
||||
data: {
|
||||
name: "",
|
||||
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
|
||||
time: 24,
|
||||
timeUnit: TimeUnit.HOUR,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.prisma.workflowStep.create({
|
||||
data: {
|
||||
stepNumber: 1,
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
template: WorkflowTemplates.REMINDER,
|
||||
workflowId: workflow.id,
|
||||
sender: SENDER_NAME,
|
||||
numberVerificationPending: false,
|
||||
},
|
||||
});
|
||||
return { workflow };
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
delete: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
@ -191,40 +377,42 @@ export const workflowsRouter = router({
|
|||
const workflowToDelete = await ctx.prisma.workflow.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (workflowToDelete) {
|
||||
const scheduledReminders = await ctx.prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
workflowStep: {
|
||||
workflowId: id,
|
||||
},
|
||||
scheduled: true,
|
||||
NOT: {
|
||||
referenceId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const isUserAuthorized = await isAuthorized(workflowToDelete, ctx.prisma, ctx.user.id, true);
|
||||
|
||||
//cancel workflow reminders of deleted workflow
|
||||
scheduledReminders.forEach((reminder) => {
|
||||
if (reminder.method === WorkflowMethods.EMAIL) {
|
||||
deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true);
|
||||
} else if (reminder.method === WorkflowMethods.SMS) {
|
||||
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
|
||||
}
|
||||
});
|
||||
|
||||
await ctx.prisma.workflow.deleteMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
id,
|
||||
},
|
||||
});
|
||||
if (!isUserAuthorized || !workflowToDelete) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const scheduledReminders = await ctx.prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
workflowStep: {
|
||||
workflowId: id,
|
||||
},
|
||||
scheduled: true,
|
||||
NOT: {
|
||||
referenceId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
//cancel workflow reminders of deleted workflow
|
||||
scheduledReminders.forEach((reminder) => {
|
||||
if (reminder.method === WorkflowMethods.EMAIL) {
|
||||
deleteScheduledEmailReminder(reminder.id, reminder.referenceId, true);
|
||||
} else if (reminder.method === WorkflowMethods.SMS) {
|
||||
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
|
||||
}
|
||||
});
|
||||
|
||||
await ctx.prisma.workflow.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
};
|
||||
|
@ -264,7 +452,9 @@ export const workflowsRouter = router({
|
|||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
user: {
|
||||
select: {
|
||||
teams: true,
|
||||
|
@ -274,12 +464,15 @@ export const workflowsRouter = router({
|
|||
},
|
||||
});
|
||||
|
||||
if (
|
||||
!userWorkflow ||
|
||||
userWorkflow.userId !== user.id ||
|
||||
steps.filter((step) => step.workflowId != id).length > 0
|
||||
)
|
||||
const isUserAuthorized = await isAuthorized(userWorkflow, ctx.prisma, ctx.user.id, true);
|
||||
|
||||
if (!isUserAuthorized || !userWorkflow) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
if (steps.find((step) => step.workflowId != id)) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const oldActiveOnEventTypes = await ctx.prisma.workflowsOnEventTypes.findMany({
|
||||
where: {
|
||||
|
@ -303,7 +496,7 @@ export const workflowsRouter = router({
|
|||
}
|
||||
});
|
||||
|
||||
//check if new event types belong to user
|
||||
//check if new event types belong to user or team
|
||||
for (const newEventTypeId of newActiveEventTypes) {
|
||||
const newEventType = await ctx.prisma.eventType.findFirst({
|
||||
where: {
|
||||
|
@ -318,13 +511,20 @@ export const workflowsRouter = router({
|
|||
},
|
||||
},
|
||||
});
|
||||
if (
|
||||
newEventType &&
|
||||
newEventType.userId !== user.id &&
|
||||
!newEventType?.team?.members.find((membership) => membership.userId === user.id) &&
|
||||
!newEventType?.users.find((eventTypeUser) => eventTypeUser.id === user.id)
|
||||
) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
if (newEventType) {
|
||||
if (userWorkflow.teamId && userWorkflow.teamId !== newEventType.teamId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
if (
|
||||
!userWorkflow.teamId &&
|
||||
userWorkflow.userId &&
|
||||
newEventType.userId !== userWorkflow.userId &&
|
||||
!newEventType?.users.find((eventTypeUser) => eventTypeUser.id === userWorkflow.userId)
|
||||
) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -484,7 +684,8 @@ export const workflowsRouter = router({
|
|||
step.id,
|
||||
step.template,
|
||||
step.sender || SENDER_ID,
|
||||
user.id
|
||||
user.id,
|
||||
userWorkflow.teamId
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -532,7 +733,8 @@ export const workflowsRouter = router({
|
|||
//step was edited
|
||||
} else if (JSON.stringify(oldStep) !== JSON.stringify(newStep)) {
|
||||
if (
|
||||
!userWorkflow.user.teams.length &&
|
||||
!userWorkflow.teamId &&
|
||||
!userWorkflow.user?.teams.length &&
|
||||
!isSMSAction(oldStep.action) &&
|
||||
isSMSAction(newStep.action)
|
||||
) {
|
||||
|
@ -674,7 +876,8 @@ export const workflowsRouter = router({
|
|||
newStep.id || 0,
|
||||
newStep.template,
|
||||
newStep.sender || SENDER_ID,
|
||||
user.id
|
||||
user.id,
|
||||
userWorkflow.teamId
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -684,7 +887,7 @@ export const workflowsRouter = router({
|
|||
//added steps
|
||||
const addedSteps = steps.map((s) => {
|
||||
if (s.id <= 0) {
|
||||
if (!userWorkflow.user.teams.length && isSMSAction(s.action)) {
|
||||
if (!userWorkflow.user?.teams.length && isSMSAction(s.action)) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
const { id: stepId, ...stepToAdd } = s;
|
||||
|
@ -801,7 +1004,8 @@ export const workflowsRouter = router({
|
|||
createdStep.id,
|
||||
step.template,
|
||||
step.sender || SENDER_ID,
|
||||
user.id
|
||||
user.id,
|
||||
userWorkflow.teamId
|
||||
);
|
||||
}
|
||||
});
|
||||
|
@ -833,6 +1037,13 @@ export const workflowsRouter = router({
|
|||
eventType: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
orderBy: {
|
||||
stepNumber: "asc",
|
||||
|
@ -1018,31 +1229,52 @@ action === WorkflowActions.EMAIL_ADDRESS*/
|
|||
.mutation(async ({ ctx, input }) => {
|
||||
const { eventTypeId, workflowId } = input;
|
||||
|
||||
// Check that workflow & event type belong to the user
|
||||
// Check that vent type belong to the user or team
|
||||
const userEventType = await ctx.prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
users: {
|
||||
some: {
|
||||
id: ctx.user.id,
|
||||
OR: [
|
||||
{ userId: ctx.user.id },
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: ctx.user.id,
|
||||
accepted: true,
|
||||
NOT: {
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!userEventType)
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "This event type does not belong to the user" });
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authorized to edit this event type" });
|
||||
|
||||
// Check that the workflow belongs to the user
|
||||
// Check that the workflow belongs to the user or team
|
||||
const eventTypeWorkflow = await ctx.prisma.workflow.findFirst({
|
||||
where: {
|
||||
id: workflowId,
|
||||
userId: ctx.user.id,
|
||||
OR: [
|
||||
{
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
{
|
||||
teamId: userEventType.teamId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventTypeWorkflow)
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "This event type does not belong to the user" });
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Not authorized to enable/disable this workflow",
|
||||
});
|
||||
|
||||
//check if event type is already active
|
||||
const isActive = await ctx.prisma.workflowsOnEventTypes.findFirst({
|
||||
|
@ -1083,24 +1315,31 @@ action === WorkflowActions.EMAIL_ADDRESS*/
|
|||
z.object({
|
||||
phoneNumber: z.string(),
|
||||
code: z.string(),
|
||||
teamId: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { phoneNumber, code } = input;
|
||||
const { phoneNumber, code, teamId } = input;
|
||||
const { user } = ctx;
|
||||
const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id);
|
||||
const verifyStatus = await verifyPhoneNumber(phoneNumber, code, user.id, teamId);
|
||||
return verifyStatus;
|
||||
}),
|
||||
getVerifiedNumbers: authedProcedure.query(async ({ ctx }) => {
|
||||
const { user } = ctx;
|
||||
const verifiedNumbers = await ctx.prisma.verifiedNumber.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
getVerifiedNumbers: authedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { user } = ctx;
|
||||
const verifiedNumbers = await ctx.prisma.verifiedNumber.findMany({
|
||||
where: {
|
||||
OR: [{ userId: user.id }, { teamId: input.teamId }],
|
||||
},
|
||||
});
|
||||
|
||||
return verifiedNumbers;
|
||||
}),
|
||||
return verifiedNumbers;
|
||||
}),
|
||||
getWorkflowActionOptions: authedProcedure.query(async ({ ctx }) => {
|
||||
const { user } = ctx;
|
||||
|
||||
|
@ -1114,4 +1353,113 @@ action === WorkflowActions.EMAIL_ADDRESS*/
|
|||
const t = await getTranslation(ctx.user.locale, "common");
|
||||
return getWorkflowActionOptions(t, IS_SELF_HOSTED || isCurrentUsernamePremium || isTeamsPlan);
|
||||
}),
|
||||
getByViewer: authedProcedure.query(async ({ ctx }) => {
|
||||
const { prisma } = ctx;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
name: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
bufferTime: true,
|
||||
workflows: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
|
||||
const userWorkflows = user.workflows;
|
||||
|
||||
type WorkflowGroup = {
|
||||
teamId?: number | null;
|
||||
profile: {
|
||||
slug: (typeof user)["username"];
|
||||
name: (typeof user)["name"];
|
||||
image?: string;
|
||||
};
|
||||
metadata?: {
|
||||
readOnly: boolean;
|
||||
};
|
||||
workflows: typeof userWorkflows;
|
||||
};
|
||||
|
||||
let workflowGroups: WorkflowGroup[] = [];
|
||||
|
||||
workflowGroups.push({
|
||||
teamId: null,
|
||||
profile: {
|
||||
slug: user.username,
|
||||
name: user.name,
|
||||
image: user.avatar || undefined,
|
||||
},
|
||||
workflows: userWorkflows,
|
||||
metadata: {
|
||||
readOnly: false,
|
||||
},
|
||||
});
|
||||
|
||||
workflowGroups = ([] as WorkflowGroup[]).concat(
|
||||
workflowGroups,
|
||||
user.teams.map((membership) => ({
|
||||
teamId: membership.team.id,
|
||||
profile: {
|
||||
name: membership.team.name,
|
||||
slug: "team/" + membership.team.slug,
|
||||
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
|
||||
},
|
||||
metadata: {
|
||||
readOnly: membership.role === MembershipRole.MEMBER,
|
||||
},
|
||||
workflows: membership.team.workflows,
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
workflowGroups: workflowGroups.filter((groupBy) => !!groupBy.workflows?.length),
|
||||
profiles: workflowGroups.map((group) => ({
|
||||
teamId: group.teamId,
|
||||
...group.profile,
|
||||
...group.metadata,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
import { useRouter } from "next/router";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui";
|
||||
import { FiPlus } from "@calcom/ui/components/icon";
|
||||
|
||||
export interface Option {
|
||||
teamId: number | null | undefined; // if undefined, then it's a profile
|
||||
label: string | null;
|
||||
image?: string | null;
|
||||
}
|
||||
|
||||
interface CreateBtnProps {
|
||||
options: Option[];
|
||||
createDialog?: () => JSX.Element;
|
||||
createFunction?: (teamId?: number) => void;
|
||||
subtitle?: string;
|
||||
buttonText?: string;
|
||||
isLoading?: boolean;
|
||||
disableMobileButton?: boolean;
|
||||
}
|
||||
|
||||
export function CreateButton(props: CreateBtnProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const CreateDialog = props.createDialog ? props.createDialog() : null;
|
||||
|
||||
const hasTeams = !!props.options.find((option) => option.teamId);
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openModal = (option: Option) => {
|
||||
const query = {
|
||||
...router.query,
|
||||
dialog: "new",
|
||||
eventPage: option.label,
|
||||
teamId: option.teamId,
|
||||
};
|
||||
if (!option.teamId) {
|
||||
delete query.teamId;
|
||||
}
|
||||
router.push(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query,
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hasTeams ? (
|
||||
<Button
|
||||
onClick={() =>
|
||||
!!CreateDialog
|
||||
? openModal(props.options[0])
|
||||
: props.createFunction
|
||||
? props.createFunction(props.options[0].teamId || undefined)
|
||||
: null
|
||||
}
|
||||
data-testid="new-event-type"
|
||||
StartIcon={FiPlus}
|
||||
loading={props.isLoading}
|
||||
variant={props.disableMobileButton ? "button" : "fab"}>
|
||||
{props.buttonText ? props.buttonText : t("new")}
|
||||
</Button>
|
||||
) : (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={props.disableMobileButton ? "button" : "fab"}
|
||||
StartIcon={FiPlus}
|
||||
loading={props.isLoading}>
|
||||
{props.buttonText ? props.buttonText : t("new")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent sideOffset={14} align="end">
|
||||
<DropdownMenuLabel>
|
||||
<div className="w-48 text-left text-xs">{props.subtitle}</div>
|
||||
</DropdownMenuLabel>
|
||||
{props.options.map((option) => (
|
||||
<DropdownMenuItem key={option.label}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={(props) => (
|
||||
<Avatar
|
||||
alt={option.label || ""}
|
||||
imageSrc={option.image || `${WEBAPP_URL}/${option.label}/avatar.png`} // if no image, use default avatar
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onClick={() =>
|
||||
!!CreateDialog
|
||||
? openModal(option)
|
||||
: props.createFunction
|
||||
? props.createFunction(option.teamId || undefined)
|
||||
: null
|
||||
}>
|
||||
{" "}
|
||||
{/*improve this code */}
|
||||
<span>{option.label}</span>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
)}
|
||||
{router.query.dialog === "new" && CreateDialog}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateButton } from "./CreateButton";
|
|
@ -44,9 +44,7 @@ export const AnimatedPopover = ({
|
|||
<Popover.Trigger asChild>
|
||||
<div
|
||||
ref={ref}
|
||||
className="mb-2 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border border-gray-300
|
||||
py-2 px-3 text-sm placeholder:text-gray-400
|
||||
hover:cursor-pointer hover:border-gray-400 focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
|
||||
className="mb-2 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border border-gray-300 px-3 py-2 text-sm placeholder:text-gray-400 hover:cursor-pointer hover:border-gray-400 focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
|
||||
<div className="max-w-36 flex items-center">
|
||||
<Tooltip content={text}>
|
||||
<div className="truncate">
|
||||
|
|
|
@ -128,3 +128,4 @@ export { default as MultiSelectCheckboxes } from "./components/form/checkbox/Mul
|
|||
export type { Option as MultiSelectCheckboxesOptionType } from "./components/form/checkbox/MultiSelectCheckboxes";
|
||||
export { default as ImageUploader } from "./components/image-uploader/ImageUploader";
|
||||
export type { ButtonColor } from "./components/button/Button";
|
||||
export { CreateButton } from "./components/createButton";
|
||||
|
|
Loading…
Reference in New Issue
Block a user