From cb2225259c26a6cd901582f3de3dff543c14cba1 Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Mon, 15 May 2023 10:56:26 -0300 Subject: [PATCH] Enabling workflows for managed events (#8446) * Enabling workflows for managed events * Disabling fields for workflow when readonly * Disabling event type workflows if readonly * Installing auth app * Reverting yarn.lock * Again reverting yarn * Showing user workflows in managed event type * Reusing existing code to show all workflows * Further extending workflow support * Added unit test * Fixing workflow assignment * Fixing locked workflows for children MET * Update packages/features/ee/workflows/components/EventWorkflowsTab.tsx Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> * Personal workflows fix, matching count numbers * Fixing menu items for managed event types * Fixing type checks * Fixing empty activeOn select * Fixing unit test * handling active workflows for MET * Fixing MET slug nit * Embed option in action restored for non-MET * Correcting mobile view when hidden * More adjustments * fix sms reminder field for children * remove console.log --------- Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Co-authored-by: CarinaWolli --- .../components/eventtype/EventSetupTab.tsx | 7 +- .../eventtype/EventTypeSingleLayout.tsx | 4 +- apps/web/pages/auth/setup/index.tsx | 10 +- apps/web/pages/event-types/index.tsx | 22 +- apps/web/public/static/locales/en/common.json | 3 +- .../test/lib/handleChildrenEventTypes.test.ts | 83 ++++++- .../features/calendars/CalendarSwitch.tsx | 2 +- .../lib/handleChildrenEventTypes.ts | 55 +++-- .../components/EventWorkflowsTab.tsx | 83 ++++--- .../components/TimeTimeUnitInput.tsx | 2 + .../components/WorkflowDetailsPage.tsx | 73 +++--- .../workflows/components/WorkflowListPage.tsx | 23 +- .../components/WorkflowStepContainer.tsx | 217 ++++++++++-------- .../features/ee/workflows/pages/workflow.tsx | 27 ++- .../components/ChildrenEventTypeSelect.tsx | 23 +- .../schedules/components/Schedule.tsx | 16 +- packages/lib/getEventTypeById.ts | 11 + .../migration.sql | 9 + packages/prisma/schema.prisma | 1 + .../viewer/eventTypes/getByViewer.handler.ts | 1 + .../viewer/eventTypes/update.handler.ts | 5 + .../workflows/activateEventType.handler.ts | 35 +-- .../routers/viewer/workflows/list.handler.ts | 18 ++ .../viewer/workflows/update.handler.ts | 74 ++++-- packages/ui/components/editor/Editor.tsx | 10 +- .../ui/components/form/checkbox/Checkbox.tsx | 2 +- .../form/checkbox/MultiSelectCheckboxes.tsx | 2 + packages/ui/components/form/inputs/Input.tsx | 8 +- packages/ui/components/form/select/Select.tsx | 2 +- 29 files changed, 559 insertions(+), 269 deletions(-) create mode 100644 packages/prisma/migrations/20230428142539_event_type_workflow_uniqueness/migration.sql diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index 222a8312d5..82ba7366da 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -390,7 +390,12 @@ export const EventSetupTab = ( addOnLeading={ <> {CAL_URL?.replace(/^(https?:|)\/\//, "")}/ - {team ? "team/" + team.slug : eventType.users[0].username}/ + {!isManagedEventType + ? team + ? "team/" + team.slug + : eventType.users[0].username + : t("username_placeholder")} + / } {...formMethods.register("slug", { diff --git a/apps/web/components/eventtype/EventTypeSingleLayout.tsx b/apps/web/components/eventtype/EventTypeSingleLayout.tsx index 66b0ff7667..72a1778f4f 100644 --- a/apps/web/components/eventtype/EventTypeSingleLayout.tsx +++ b/apps/web/components/eventtype/EventTypeSingleLayout.tsx @@ -170,7 +170,7 @@ function EventTypeSingleLayout({ // Define tab navigation here const EventTypeTabs = useMemo(() => { - let navigation = getNavigation({ + const navigation = getNavigation({ t, eventType, enabledAppsNumber, @@ -210,7 +210,7 @@ function EventTypeSingleLayout({ } if (isManagedEventType || isChildrenManagedEventType) { // Removing apps and workflows for manageg event types by admins v1 - navigation = navigation.slice(0, -2); + navigation.splice(-2, 1); } else { navigation.push({ name: "webhooks", diff --git a/apps/web/pages/auth/setup/index.tsx b/apps/web/pages/auth/setup/index.tsx index 80214751d2..f9c3074a36 100644 --- a/apps/web/pages/auth/setup/index.tsx +++ b/apps/web/pages/auth/setup/index.tsx @@ -157,10 +157,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }; } - let deploymentKey = await getDeploymentKey(prisma); + const deploymentKey = await prisma.deployment.findUnique({ + where: { id: 1 }, + select: { licenseKey: true }, + }); // Check existant CALCOM_LICENSE_KEY env var and acccount for it - if (!!process.env.CALCOM_LICENSE_KEY && !deploymentKey) { + if (!!process.env.CALCOM_LICENSE_KEY && !deploymentKey?.licenseKey) { await prisma.deployment.upsert({ where: { id: 1 }, update: { @@ -172,10 +175,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => agreedLicenseAt: new Date(), }, }); - deploymentKey = await getDeploymentKey(prisma); } - const isFreeLicense = deploymentKey === ""; + const isFreeLicense = (await getDeploymentKey(prisma)) === ""; return { props: { diff --git a/apps/web/pages/event-types/index.tsx b/apps/web/pages/event-types/index.tsx index d5ef9e2ca5..d292055181 100644 --- a/apps/web/pages/event-types/index.tsx +++ b/apps/web/pages/event-types/index.tsx @@ -490,18 +490,20 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL {t("duplicate")} - - - {t("embed")} - - )} + {!isManagedEventType && ( + + + {t("embed")} + + + )} {/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */} {(group.metadata?.readOnly === false || group.metadata.readOnly === null) && !isChildrenManagedEventType && ( diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e3bd64ba40..529d56c259 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1195,6 +1195,7 @@ "create_workflow": "Create a workflow", "do_this": "Do this", "turn_off": "Turn off", + "turn_on": "Turn on", "settings_updated_successfully": "Settings updated successfully", "error_updating_settings": "Error updating settings", "personal_cal_url": "My personal {{appName}} URL", @@ -1740,7 +1741,7 @@ "locked_apps_description": "Members will be able to see the active apps but will not be able to edit any app settings", "locked_webhooks_description": "Members will be able to see the active webhooks but will not be able to edit any webhook settings", "locked_workflows_description": "Members will be able to see the active workflows but will not be able to edit any workflow settings", - "locked_by_admin": "Locked by admin", + "locked_by_admin": "Locked by team admin", "app_not_connected": "You have not connected a {{appName}} account.", "connect_now": "Connect now", "managed_event_dialog_confirm_button_one": "Replace & notify {{count}} member", diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index e88e89cc9c..d02d168100 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -3,7 +3,7 @@ import type { Prisma } from "@prisma/client"; import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; import { buildEventType } from "@calcom/lib/test/builder"; -import type { CompleteEventType } from "@calcom/prisma/zod"; +import type { CompleteEventType, CompleteWorkflowsOnEventTypes } from "@calcom/prisma/zod"; import { prismaMock } from "../../../../tests/config/singleton"; @@ -291,4 +291,85 @@ describe("handleChildrenEventTypes", () => { expect(result.deletedExistentEventTypes).toEqual([123]); }); }); + + describe("Workflows", () => { + it("Links workflows to new and existing assigned members", async () => { + const { schedulingType, id, teamId, locations, timeZone, parentId, userId, ...evType } = + mockFindFirstEventType({ + metadata: { managedEventConfig: {} }, + locations: [], + workflows: [ + { + workflowId: 11, + } as CompleteWorkflowsOnEventTypes, + ], + }); + prismaMock.$transaction.mockResolvedValue([{ id: 2 }]); + await updateChildrenEventTypes({ + eventTypeId: 1, + oldEventType: { children: [{ userId: 4 }], team: { name: "" } }, + children: [ + { hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } }, + { hidden: false, owner: { id: 5, name: "", email: "", eventTypeSlugs: [] } }, + ], + updatedEventType: { schedulingType: "MANAGED", slug: "something" }, + currentUserId: 1, + hashedLink: undefined, + connectedLink: null, + prisma: prismaMock, + }); + expect(prismaMock.eventType.create).toHaveBeenCalledWith({ + data: { + ...evType, + bookingLimits: undefined, + durationLimits: undefined, + recurringEvent: undefined, + hashedLink: undefined, + locations: [], + parentId: 1, + userId: 5, + users: { + connect: [ + { + id: 5, + }, + ], + }, + workflows: { + create: [{ workflowId: 11 }], + }, + }, + }); + expect(prismaMock.eventType.update).toHaveBeenCalledWith({ + data: { + ...evType, + bookingLimits: undefined, + durationLimits: undefined, + recurringEvent: undefined, + hashedLink: undefined, + workflows: undefined, + scheduleId: undefined, + }, + where: { + userId_parentId: { + userId: 4, + parentId: 1, + }, + }, + }); + expect(prismaMock.workflowsOnEventTypes.upsert).toHaveBeenCalledWith({ + create: { + eventTypeId: 2, + workflowId: 11, + }, + update: {}, + where: { + workflowId_eventTypeId: { + eventTypeId: 2, + workflowId: 11, + }, + }, + }); + }); + }); }); diff --git a/packages/features/calendars/CalendarSwitch.tsx b/packages/features/calendars/CalendarSwitch.tsx index 8836aef5f3..bce2cd7b24 100644 --- a/packages/features/calendars/CalendarSwitch.tsx +++ b/packages/features/calendars/CalendarSwitch.tsx @@ -72,7 +72,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => { } ); return ( -
+
!previousUserIds?.includes(id)); const oldUserIds = currentUserIds?.filter((id) => previousUserIds?.includes(id)); + // Calculate if there are new workflows for which assigned members will get too + const currentWorkflowIds = eventType.workflows?.map((wf) => wf.workflowId); + // Define hashedLink query input const hashedLinkQuery = (userId: number) => { return hashedLink @@ -190,13 +194,11 @@ export default async function handleChildrenEventTypes({ }, parentId, hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false, - // Reserved for v2 - /* - workflows: eventType.workflows && { - createMany: { - data: eventType.workflows?.map((wf) => ({ ...wf, eventTypeId: undefined })), - }, + workflows: currentWorkflowIds && { + create: currentWorkflowIds.map((wfId) => ({ workflowId: wfId })), }, + // Reserved for future releases + /* webhooks: eventType.webhooks && { createMany: { data: eventType.webhooks?.map((wh) => ({ ...wh, eventTypeId: undefined })), @@ -221,7 +223,7 @@ export default async function handleChildrenEventTypes({ }); // Update event types for old users - await prisma.$transaction( + const oldEventTypes = await prisma.$transaction( oldUserIds.map((userId) => { return prisma.eventType.update({ where: { @@ -246,21 +248,30 @@ export default async function handleChildrenEventTypes({ }) ); - // Reserved for v2 - /*const updatedOldWorkflows = await prisma.workflow.updateMany({ - where: { - userId: { - in: oldUserIds, - }, - }, - data: { - ...eventType.workflows, - }, - }); - console.log( - "handleChildrenEventTypes:updatedOldWorkflows", - JSON.stringify({ updatedOldWorkflows }, null, 2) - ); + if (currentWorkflowIds?.length) { + await prisma.$transaction( + currentWorkflowIds.flatMap((wfId) => { + return oldEventTypes.map((oEvTy) => { + return prisma.workflowsOnEventTypes.upsert({ + create: { + eventTypeId: oEvTy.id, + workflowId: wfId, + }, + update: {}, + where: { + workflowId_eventTypeId: { + eventTypeId: oEvTy.id, + workflowId: wfId, + }, + }, + }); + }); + }) + ); + } + + // Reserved for future releases + /** const updatedOldWebhooks = await prisma.webhook.updateMany({ where: { userId: { diff --git a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx index 7c742a43cf..6a1614302a 100644 --- a/packages/features/ee/workflows/components/EventWorkflowsTab.tsx +++ b/packages/features/ee/workflows/components/EventWorkflowsTab.tsx @@ -23,6 +23,7 @@ type ItemProps = { id: number; title: string; }; + isChildrenManagedEventType: boolean; }; const WorkflowListItem = (props: ItemProps) => { @@ -131,18 +132,33 @@ const WorkflowListItem = (props: ItemProps) => { })}
-
- - - -
- -
+ {!workflow.readOnly && ( +
+ + + +
+ )} + +
+ {workflow.readOnly && props.isChildrenManagedEventType && ( + + )} { activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id }); }} @@ -163,9 +179,14 @@ type Props = { function EventWorkflowsTab(props: Props) { const { workflows, eventType } = props; const { t } = useLocale(); + const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager( + eventType, + t("locked_fields_admin_description"), + t("locked_fields_member_description") + ); const { data, isLoading } = trpc.viewer.workflows.list.useQuery({ teamId: eventType.team?.id, - userId: eventType.userId || undefined, + userId: !isChildrenManagedEventType ? eventType.userId || undefined : undefined, }); const router = useRouter(); const [sortedWorkflows, setSortedWorkflows] = useState>([]); @@ -173,7 +194,11 @@ function EventWorkflowsTab(props: Props) { useEffect(() => { if (data?.workflows) { const activeWorkflows = workflows.map((workflowOnEventType) => { - return workflowOnEventType; + const dataWf = data.workflows.find((wf) => wf.id === workflowOnEventType.id); + return { + ...workflowOnEventType, + readOnly: isChildrenManagedEventType && dataWf?.teamId ? true : dataWf?.readOnly ?? true, + } as WorkflowType; }); const disabledWorkflows = data.workflows.filter( (workflow) => @@ -204,12 +229,6 @@ function EventWorkflowsTab(props: Props) { }, }); - const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager( - eventType, - t("locked_fields_admin_description"), - t("locked_fields_member_description") - ); - return ( {!isLoading ? ( @@ -217,6 +236,7 @@ function EventWorkflowsTab(props: Props) { {isManagedEventType && ( @@ -226,7 +246,12 @@ function EventWorkflowsTab(props: Props) {
{sortedWorkflows.map((workflow) => { return ( - + ); })}
@@ -238,19 +263,13 @@ function EventWorkflowsTab(props: Props) { headline={t("workflows")} description={t("no_workflows_description")} buttonRaw={ - isChildrenManagedEventType && !isManagedEventType ? ( - - ) : ( - - ) + } />
diff --git a/packages/features/ee/workflows/components/TimeTimeUnitInput.tsx b/packages/features/ee/workflows/components/TimeTimeUnitInput.tsx index d5e8e35611..baa797e90a 100644 --- a/packages/features/ee/workflows/components/TimeTimeUnitInput.tsx +++ b/packages/features/ee/workflows/components/TimeTimeUnitInput.tsx @@ -17,6 +17,7 @@ import type { FormValues } from "../pages/workflow"; type Props = { form: UseFormReturn; + disabled: boolean; }; export const TimeTimeUnitInput = (props: Props) => { @@ -33,6 +34,7 @@ export const TimeTimeUnitInput = (props: Props) => { type="number" min="1" label="" + disabled={props.disabled} defaultValue={form.getValues("time") || 24} className="-mt-2 rounded-r-none text-sm focus:ring-0" {...form.register("time", { valueAsNumber: true })} diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index 6a8bb886eb..85da5b44ce 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -6,7 +6,7 @@ import { Controller } from "react-hook-form"; import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { WorkflowTemplates, SchedulingType } from "@calcom/prisma/enums"; +import { WorkflowTemplates } from "@calcom/prisma/enums"; import type { WorkflowActions } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui"; @@ -26,6 +26,7 @@ interface Props { setSelectedEventTypes: Dispatch>; teamId?: number; isMixedEventType: boolean; + readOnly: boolean; } export default function WorkflowDetailsPage(props: Props) { @@ -48,15 +49,10 @@ export default function WorkflowDetailsPage(props: Props) { if (teamId && teamId !== group.teamId) return options; return [ ...options, - ...group.eventTypes - .filter( - (evType) => - !evType.metadata?.managedEventConfig && evType.schedulingType !== SchedulingType.MANAGED - ) - .map((eventType) => ({ - value: String(eventType.id), - label: eventType.title, - })), + ...group.eventTypes.map((eventType) => ({ + value: String(eventType.id), + label: `${eventType.title} ${eventType.children.length ? `(+${eventType.children.length})` : ``}`, + })), ]; }, [] as Option[]) || [], [data] @@ -117,7 +113,12 @@ export default function WorkflowDetailsPage(props: Props) {
- +
- + {!props.readOnly && ( + + )}
@@ -154,7 +158,7 @@ export default function WorkflowDetailsPage(props: Props) {
{form.getValues("trigger") && (
- +
)} {form.getValues("steps") && ( @@ -168,23 +172,28 @@ export default function WorkflowDetailsPage(props: Props) { reload={reload} setReload={setReload} teamId={teamId} + readOnly={props.readOnly} /> ); })} )} -
- -
-
- -
+ {!props.readOnly && ( + <> +
+ +
+
+ +
+ + )}
{workflow.activeOn && workflow.activeOn.length > 0 ? ( ( -

{activeOn.eventType.title}

- ))}> + content={workflow.activeOn + .filter((wf) => (workflow.teamId ? wf.eventType.parentId === null : true)) + .map((activeOn, key) => ( +

+ {activeOn.eventType.title} + {activeOn.eventType._count.children > 0 + ? ` (+${activeOn.eventType._count.children})` + : ""} +

+ ))}>
) : ( diff --git a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx index 4b5e9dce11..7135b6891c 100644 --- a/packages/features/ee/workflows/components/WorkflowStepContainer.tsx +++ b/packages/features/ee/workflows/components/WorkflowStepContainer.tsx @@ -52,6 +52,7 @@ type WorkflowStepProps = { reload?: boolean; setReload?: Dispatch>; teamId?: number; + readOnly: boolean; }; export default function WorkflowStepContainer(props: WorkflowStepProps) { @@ -249,6 +250,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { { if (val) { const oldValue = form.getValues(`steps.${step.stepNumber - 1}.action`); @@ -468,8 +475,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { { const isAlreadyVerified = !!verifiedNumbers @@ -483,9 +491,9 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { />
) : ( - <> -
- { - setVerificationCode(e.target.value); - }} - required - /> - -
- {form.formState.errors.steps && - form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( -

- {form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""} -

- )} - + !props.readOnly && ( + <> +
+ { + setVerificationCode(e.target.value); + }} + required + /> + +
+ {form.formState.errors.steps && + form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo && ( +

+ {form.formState?.errors?.steps[step.stepNumber - 1]?.sendTo?.message || ""} +

+ )} + + ) )}
)} @@ -551,6 +562,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { @@ -566,6 +578,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { @@ -580,6 +593,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) { control={form.control} render={() => ( @@ -611,6 +626,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {