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 02:24:43 -05:00 committed by GitHub
parent c20835a4c8
commit 0ec71e52ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1219 additions and 476 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export { CreateButton } from "./CreateButton";

View File

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

View File

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