Files
calendar/packages/features/ee/workflows/components/EventWorkflowsTab.tsx
T
3c723d6f5b feat: Managed Events V2 (#12320)
* init

* Fix event limit interlinked switches

* wip

* WIP

* fix bugs

* lock hashed link until further notice

* lock private url for managed type to stop confusion

* fix recurring

* prettier fix

* early review fixes ...

* --WIP to send only changed form fields in updateMutation

* WIP with some type fixes

* Revert "WIP with some type fixes"

This reverts commit 00f10b772d6d08af17e7c9bd2d3601d93035d9b3.

* post merge conflict resolution fixes

* further type fix

* fixed test

* fixing e2e tests

* fix test --WIP

* attempt fix test & type --WIP

* fix duplicate locale en entry

* fix locked state persistence

* adds private URL locked/unlocked functionality

* fix tests

* Fix issue where locked labels showed up in non-managed events

* adds e2e test step

* update new test

* minor fixes

* fix test

* address changes request Part 1

fixed width on the lock badge
members default location as default selection
bugfix for offset toggle duplicate within itself

* fixes locked/unlocked label for child event types

* enable choice of destination calendar for children

* Fixes width for simple lock badge

* fix type

* fixes workflows list and apps for managed event type

* restricts creation of managed event types to EE only

* further fixes --WIP

* fix unit test for handleChildrenEventTypes

* fix test --WIP

* fix type err --WIP

* fix type err in test

* fix childevent payload

* fixes hashedLink bug

* fix test step title

* lock workflow create button when workflow is locked

* fix workflow detail

* Don't rely on parameter ordering, use object instead

* Removed console.log

* Missed one

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-03-12 00:10:08 +00:00

339 lines
12 KiB
TypeScript

import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { Trans } from "react-i18next";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { isTextMessageToAttendeeAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { WorkflowActions } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, showToast, Switch, Tooltip, Alert } from "@calcom/ui";
import { ExternalLink, Zap, Lock, Info } from "@calcom/ui/components/icon";
import LicenseRequired from "../../common/components/LicenseRequired";
import { getActionIcon } from "../lib/getActionIcon";
import SkeletonLoader from "./SkeletonLoaderEventWorkflowsTab";
import type { WorkflowType } from "./WorkflowListPage";
type ItemProps = {
workflow: WorkflowType;
eventType: {
id: number;
title: string;
requiresConfirmation: boolean;
};
isChildrenManagedEventType: boolean;
};
const WorkflowListItem = (props: ItemProps) => {
const { workflow, eventType } = props;
const { t } = useLocale();
const [activeEventTypeIds, setActiveEventTypeIds] = useState(
workflow.activeOn.map((active) => {
if (active.eventType) {
return active.eventType.id;
}
})
);
const isActive = activeEventTypeIds.includes(eventType.id);
const utils = trpc.useContext();
const activateEventTypeMutation = trpc.viewer.workflows.activateEventType.useMutation({
onSuccess: async () => {
let offOn = "";
if (activeEventTypeIds.includes(eventType.id)) {
const newActiveEventTypeIds = activeEventTypeIds.filter((id) => {
return id !== eventType.id;
});
setActiveEventTypeIds(newActiveEventTypeIds);
offOn = "off";
} else {
const newActiveEventTypeIds = activeEventTypeIds;
newActiveEventTypeIds.push(eventType.id);
setActiveEventTypeIds(newActiveEventTypeIds);
offOn = "on";
}
await utils.viewer.eventTypes.get.invalidate({ id: eventType.id });
showToast(
t("workflow_turned_on_successfully", {
workflowName: workflow.name,
offOn,
}),
"success"
);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
showToast(
t("unauthorized_workflow_error_message", {
errorCode: err.data.code,
}),
"error"
);
}
},
});
const sendTo: Set<string> = new Set();
workflow.steps.forEach((step) => {
switch (step.action) {
case WorkflowActions.EMAIL_HOST:
sendTo.add(t("organizer"));
break;
case WorkflowActions.EMAIL_ATTENDEE:
case WorkflowActions.SMS_ATTENDEE:
case WorkflowActions.WHATSAPP_ATTENDEE:
sendTo.add(t("attendee_name_variable"));
break;
case WorkflowActions.SMS_NUMBER:
case WorkflowActions.WHATSAPP_NUMBER:
case WorkflowActions.EMAIL_ADDRESS:
sendTo.add(step.sendTo || "");
break;
}
});
const needsRequiresConfirmationWarning =
!eventType.requiresConfirmation &&
workflow.steps.find((step) => {
return isTextMessageToAttendeeAction(step.action);
});
return (
<div className="border-subtle w-full overflow-hidden rounded-md border p-6 px-3 md:p-6">
<div className="flex items-center ">
<div className="bg-subtle mr-4 flex h-10 w-10 items-center justify-center rounded-full text-xs font-medium">
{getActionIcon(
workflow.steps,
isActive ? "h-6 w-6 stroke-[1.5px] text-default" : "h-6 w-6 stroke-[1.5px] text-muted"
)}
</div>
<div className=" grow">
<div
className={classNames(
"text-emphasis mb-1 w-full truncate text-base font-medium leading-4 md:max-w-max",
workflow.name && isActive ? "text-emphasis" : "text-subtle"
)}>
{workflow.name
? workflow.name
: `Untitled (${`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`
.charAt(0)
.toUpperCase()}${`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1)})`}
</div>
<>
<div
className={classNames(
" flex w-fit items-center whitespace-nowrap rounded-sm text-sm leading-4",
isActive ? "text-default" : "text-muted"
)}>
<span className="mr-1">{t("to")}:</span>
{Array.from(sendTo).map((sendToPerson, index) => {
return <span key={index}>{`${index ? ", " : ""}${sendToPerson}`}</span>;
})}
</div>
</>
</div>
{!workflow.readOnly && (
<div className="flex-none">
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
<Button type="button" color="minimal" className="mr-4">
<div className="hidden ltr:mr-2 rtl:ml-2 sm:block">{t("edit")}</div>
<ExternalLink className="text-default -mt-[2px] h-4 w-4 stroke-2" />
</Button>
</Link>
</div>
)}
<Tooltip
content={
t(
workflow.readOnly && props.isChildrenManagedEventType
? "locked_by_team_admin"
: isActive
? "turn_off"
: "turn_on"
) as string
}>
<div className="flex items-center ltr:mr-2 rtl:ml-2">
{workflow.readOnly && props.isChildrenManagedEventType && (
<Lock className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
)}
<Switch
checked={isActive}
disabled={workflow.readOnly}
onCheckedChange={() => {
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
}}
/>
</div>
</Tooltip>
</div>
{needsRequiresConfirmationWarning ? (
<div className="text-attention -mb-2 mt-3 flex">
<Info className="mr-1 mt-0.5 h-4 w-4" />
<p className="text-sm">{t("requires_confirmation_mandatory")}</p>
</div>
) : (
<></>
)}
</div>
);
};
type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
type Props = {
eventType: EventTypeSetup;
workflows: WorkflowType[];
};
function EventWorkflowsTab(props: Props) {
const { workflows, eventType } = props;
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
eventType,
translate: t,
formMethods,
});
const workflowsDisableProps = shouldLockDisableProps("workflows", { simple: true });
const lockedText = workflowsDisableProps.isLocked ? "locked" : "unlocked";
const { data, isPending } = trpc.viewer.workflows.list.useQuery({
teamId: eventType.team?.id,
userId: !isChildrenManagedEventType ? eventType.userId || undefined : undefined,
});
const router = useRouter();
const [sortedWorkflows, setSortedWorkflows] = useState<Array<WorkflowType>>([]);
useEffect(() => {
if (data?.workflows) {
const activeWorkflows = workflows.map((workflowOnEventType) => {
const dataWf = data.workflows.find((wf) => wf.id === workflowOnEventType.id);
return {
...workflowOnEventType,
readOnly: isChildrenManagedEventType && dataWf?.teamId ? true : dataWf?.readOnly ?? false,
} as WorkflowType;
});
const disabledWorkflows = data.workflows.filter(
(workflow) =>
(!workflow.teamId || eventType.teamId === workflow.teamId) &&
!workflows
.map((workflow) => {
return workflow.id;
})
.includes(workflow.id)
);
const allSortedWorkflows =
workflowsDisableProps.isLocked && !isManagedEventType
? activeWorkflows
: activeWorkflows.concat(disabledWorkflows);
setSortedWorkflows(allSortedWorkflows);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPending]);
const createMutation = trpc.viewer.workflows.create.useMutation({
onSuccess: async ({ workflow }) => {
await router.replace(`/workflows/${workflow.id}`);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: ${t("error_workflow_unauthorized_create")}`;
showToast(message, "error");
}
},
});
return (
<LicenseRequired>
{!isPending ? (
<>
{(isManagedEventType || isChildrenManagedEventType) && (
<Alert
severity={workflowsDisableProps.isLocked ? "neutral" : "green"}
className="mb-2"
title={
<Trans i18nKey={`${lockedText}_${isManagedEventType ? "for_members" : "by_team_admins"}`}>
{lockedText[0].toUpperCase()}
{lockedText.slice(1)} {isManagedEventType ? "for members" : "by team admins"}
</Trans>
}
actions={<div className="flex h-full items-center">{workflowsDisableProps.LockedIcon}</div>}
message={
<Trans
i18nKey={`workflows_${lockedText}_${
isManagedEventType ? "for_members" : "by_team_admins"
}_description`}>
{isManagedEventType ? "Members" : "You"}{" "}
{workflowsDisableProps.isLocked
? "will be able to see the active workflows but will not be able to edit any workflow settings"
: "will be able to see the active workflow and will be able to edit any workflow settings"}
</Trans>
}
/>
)}
{data?.workflows && sortedWorkflows.length > 0 ? (
<div>
<div className="space-y-4">
{sortedWorkflows.map((workflow) => {
return (
<WorkflowListItem
key={workflow.id}
workflow={workflow}
eventType={props.eventType}
isChildrenManagedEventType
/>
);
})}
</div>
</div>
) : (
<div className="pt-2 before:border-0">
<EmptyScreen
Icon={Zap}
headline={t("workflows")}
description={t("no_workflows_description")}
buttonRaw={
<Button
disabled={workflowsDisableProps.isLocked && !isManagedEventType}
target="_blank"
color="secondary"
onClick={() => createMutation.mutate({ teamId: eventType.team?.id })}
loading={createMutation.isPending}>
{t("create_workflow")}
</Button>
}
/>
</div>
)}
</>
) : (
<SkeletonLoader />
)}
</LicenseRequired>
);
}
export default EventWorkflowsTab;