From 5170fc242435eb058fef21c2f74f20176dc8598a Mon Sep 17 00:00:00 2001 From: Leo Giovanetti Date: Wed, 12 Apr 2023 23:10:23 -0300 Subject: [PATCH] Managed event-types (#6876) * WIP * Locked fields manager * Leftovers * Bad merge fix * Type import fix * Moving away from classes * Progress refactoring locked logic * Covering apps, webhooks and workflows * Supporting webhooks and workflows (TBT) * Restoring yarn.lock * Progress * Refactoring code, adding default values * Fixing CRUD for children * Connect app link and case-sensitive lib renaming * Translation missing * Locked indicators, empty screens, locations * Member card and hidden status + missing i18n * Missing existent children shown * Showing preview for already created children * Email notification almost in place * Making progress over notif email * Fixing nodemailer by mixed FE/BE mixup * Delete dialog * Adding tests * New test * Reverting unneeded change * Removed console.log * Tweaking email * Reverting not applicable webhook changes * Reverting dev email api * Fixing last changes due to tests * Changing user-evType relationship * Availability and slug replacement tweaks * Fixing event type delete * Sometimes slug is not there... * Removing old webhooks references Changed slug hint * Fixing types * Fixing hiding event types actions * Changing delete dialog text * Removing unneeded code * Applying feedback * Update yarn.lock * Making sure locked fields values are static * Applying feedback * Feedback + relying on children list, not users * Removing console.log * PR Feedback * Telemetry for slug replacement action * More unit tests * Relying on schedule and editor tweaks * Fixing conteiner classname * PR Feedback * PR Feedback * Updating unit tests * Moving stuff to ee, added feature flag * type fix * Including e2e * Reverting unneeded changes in EmptyScreen * Fixing some UI issues after merging tokens * Fixing missing disabled locked fields * Theme fixes + e2e potential fix * Fixing e2e * Fixing login relying on network * Tweaking e2e * Removing unneeded code --------- Co-authored-by: Peer Richelsen Co-authored-by: zomars --- .../components/eventtype/EventAdvancedTab.tsx | 41 ++- .../web/components/eventtype/EventAppsTab.tsx | 63 ++-- ...bilityTab.tsx => EventAvailabilityTab.tsx} | 163 ++++++--- .../components/eventtype/EventLimitsTab.tsx | 132 ++++++-- .../eventtype/EventRecurringTab.tsx | 2 +- .../components/eventtype/EventSetupTab.tsx | 107 ++++-- .../web/components/eventtype/EventTeamTab.tsx | 93 ++++- .../eventtype/EventTeamWebhooksTab.tsx | 36 +- .../eventtype/EventTypeSingleLayout.tsx | 186 ++++++---- .../eventtype/RecurringEventController.tsx | 39 ++- .../RequiresConfirmationController.tsx | 164 +++++---- apps/web/pages/[user].tsx | 2 +- apps/web/pages/event-types/[type]/index.tsx | 319 +++++++++++------- apps/web/pages/event-types/index.tsx | 259 +++++++++----- apps/web/playwright/fixtures/users.ts | 8 +- .../web/playwright/managed-event-types.e2e.ts | 94 ++++++ apps/web/public/emails/white-arrow-right.png | Bin 0 -> 1032 bytes apps/web/public/emails/white-arrow-right.svg | 1 + apps/web/public/static/locales/en/common.json | 50 ++- apps/web/public/user-check.svg | 1 + .../test/lib/handleChildrenEventTypes.test.ts | 290 ++++++++++++++++ packages/app-store/utils.ts | 7 +- packages/emails/email-manager.ts | 17 + .../components/EmailSchedulingBodyHeader.tsx | 8 +- .../src/templates/SlugReplacementEmail.tsx | 80 +++++ packages/emails/src/templates/index.ts | 1 + .../templates/slug-replacement-email.ts | 42 +++ .../hooks/useLockedFieldsManager.tsx | 58 ++++ .../lib/handleChildrenEventTypes.ts | 294 ++++++++++++++++ .../ee/teams/components/AddNewTeamMembers.tsx | 1 + .../ee/teams/components/MemberListItem.tsx | 1 + .../ee/teams/components/TeamListItem.tsx | 1 + .../ee/teams/pages/team-profile-view.tsx | 1 + .../components/EventWorkflowsTab.tsx | 88 +++-- .../components/WorkflowDetailsPage.tsx | 15 +- .../components/CheckedUserSelect.tsx | 81 +++++ .../components/ChildrenEventTypeSelect.tsx | 140 ++++++++ .../components/CreateEventTypeDialog.tsx | 153 ++++++--- .../components/EventTypeDescription.tsx | 11 +- .../flags/components/FlagAdminList.tsx | 5 +- packages/features/flags/config.ts | 1 + packages/features/flags/hooks/index.ts | 4 +- .../features/form-builder/FormBuilder.tsx | 63 ++-- .../webhooks/components/WebhookListItem.tsx | 2 + packages/lib/getEventTypeById.ts | 59 +++- packages/lib/server/queries/teams/index.ts | 5 +- packages/lib/telemetry.ts | 1 + packages/lib/test/builder.ts | 3 +- .../migration.sql | 17 + .../migration.sql | 9 + packages/prisma/schema.prisma | 5 + packages/prisma/zod-utils.ts | 55 +++ packages/prisma/zod/custom/eventtype.ts | 1 + .../server/routers/viewer/availability.tsx | 4 +- .../trpc/server/routers/viewer/eventTypes.ts | 97 ++++-- packages/trpc/server/routers/viewer/teams.tsx | 5 + packages/ui/components/avatar/AvatarGroup.tsx | 2 + .../components/createButton/CreateButton.tsx | 4 +- packages/ui/components/editor/Editor.tsx | 15 +- .../editor/plugins/ToolbarPlugin.tsx | 2 + packages/ui/components/form/inputs/Input.tsx | 21 +- packages/ui/components/form/select/Select.tsx | 1 + .../components/form/switch/SettingsToggle.tsx | 7 +- packages/ui/components/form/switch/Switch.tsx | 1 + .../ui/form/radio-area/RadioAreaGroup.tsx | 10 +- tests/config/singleton.ts | 5 +- 66 files changed, 2798 insertions(+), 655 deletions(-) rename apps/web/components/eventtype/{AvailabilityTab.tsx => EventAvailabilityTab.tsx} (64%) create mode 100644 apps/web/playwright/managed-event-types.e2e.ts create mode 100644 apps/web/public/emails/white-arrow-right.png create mode 100644 apps/web/public/emails/white-arrow-right.svg create mode 100644 apps/web/public/user-check.svg create mode 100644 apps/web/test/lib/handleChildrenEventTypes.test.ts create mode 100644 packages/emails/src/templates/SlugReplacementEmail.tsx create mode 100644 packages/emails/templates/slug-replacement-email.ts create mode 100644 packages/features/ee/managed-event-types/hooks/useLockedFieldsManager.tsx create mode 100644 packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts create mode 100644 packages/features/eventtypes/components/CheckedUserSelect.tsx create mode 100644 packages/features/eventtypes/components/ChildrenEventTypeSelect.tsx create mode 100644 packages/prisma/migrations/20230216134219_managed_events/migration.sql create mode 100644 packages/prisma/migrations/20230404202721_add_feature_flag_managed_event_types/migration.sql diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index 1e74f0bdf3..0de520f5d5 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -10,6 +10,7 @@ import type { EventNameObjectType } from "@calcom/core/event"; import { getEventName } from "@calcom/core/event"; import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect"; import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector"; +import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { FormBuilder } from "@calcom/features/form-builder/FormBuilder"; import { classNames } from "@calcom/lib"; import { APP_NAME, CAL_URL } from "@calcom/lib/constants"; @@ -80,11 +81,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick setShowEventNameTip(false); const setEventName = (value: string) => formMethods.setValue("eventName", value); @@ -128,19 +137,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick setShowEventNameTip((old) => !old)} + size="sm" aria-label="edit custom name" - /> + className="hover:stroke-3 hover:text-emphasis min-w-fit px-0 !py-0 hover:bg-transparent" + onClick={() => setShowEventNameTip((old) => !old)}> + + } /> @@ -150,6 +159,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick
( onChange(e)} @@ -185,6 +197,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick { @@ -197,6 +210,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick { @@ -240,6 +255,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick } @@ -267,6 +284,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick{t("seats")}} onChange={(e) => { onChange(Math.abs(Number(e.target.value))); @@ -305,6 +325,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick formMethods.setValue("seatsShowAttendees", e.target.checked)} defaultChecked={!!eventType.seatsShowAttendees} /> diff --git a/apps/web/components/eventtype/EventAppsTab.tsx b/apps/web/components/eventtype/EventAppsTab.tsx index 2c7064c3c9..50dabb4abf 100644 --- a/apps/web/components/eventtype/EventAppsTab.tsx +++ b/apps/web/components/eventtype/EventAppsTab.tsx @@ -5,10 +5,11 @@ import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppConte import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface"; import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types"; import type { EventTypeAppsList } from "@calcom/app-store/utils"; +import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { Button, EmptyScreen } from "@calcom/ui"; -import { Grid } from "@calcom/ui/components/icon"; +import { Button, EmptyScreen, Alert } from "@calcom/ui"; +import { Grid, Lock } from "@calcom/ui/components/icon"; export type EventType = Pick["eventType"] & EventTypeAppCardComponentProps["eventType"]; @@ -55,19 +56,39 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => { }; }; + const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager( + eventType, + t("locked_fields_admin_description"), + t("locked_fields_member_description") + ); + return ( <>
+ {!installedApps?.length && isManagedEventType && ( + + )} {!isLoading && !installedApps?.length ? ( - {t("empty_installed_apps_button")}{" "} - + isChildrenManagedEventType && !isManagedEventType ? ( + + ) : ( + + ) } /> ) : null} @@ -82,22 +103,24 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => { ))}
-
- {!isLoading && notInstalledApps?.length ? ( -

Available Apps

- ) : null} -
- {notInstalledApps?.map((app) => ( - - ))} + {!shouldLockDisableProps("apps").disabled && ( +
+ {!isLoading && notInstalledApps?.length ? ( +

{t("available_apps")}

+ ) : null} +
+ {notInstalledApps?.map((app) => ( + + ))} +
-
+ )} ); }; diff --git a/apps/web/components/eventtype/AvailabilityTab.tsx b/apps/web/components/eventtype/EventAvailabilityTab.tsx similarity index 64% rename from apps/web/components/eventtype/AvailabilityTab.tsx rename to apps/web/components/eventtype/EventAvailabilityTab.tsx index 1dbab062b1..68240b3b82 100644 --- a/apps/web/components/eventtype/AvailabilityTab.tsx +++ b/apps/web/components/eventtype/EventAvailabilityTab.tsx @@ -1,29 +1,29 @@ -import type { FormValues } from "pages/event-types/[type]"; +import { SchedulingType } from "@prisma/client"; +import type { FormValues, EventTypeSetup } from "pages/event-types/[type]"; import { Controller, useFormContext } from "react-hook-form"; import type { OptionProps, SingleValueProps } from "react-select"; import { components } from "react-select"; import dayjs from "@calcom/dayjs"; +import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { NewScheduleButton } from "@calcom/features/schedules"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { weekdayNames } from "@calcom/lib/weekday"; -import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { Badge, Button, Select, SettingsToggle, SkeletonText, EmptyScreen } from "@calcom/ui"; import { ExternalLink, Globe, Clock } from "@calcom/ui/components/icon"; -import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader"; - type AvailabilityOption = { label: string; value: number; isDefault: boolean; + isManaged?: boolean; }; const Option = ({ ...props }: OptionProps) => { - const { label, isDefault } = props.data; + const { label, isDefault, isManaged = false } = props.data; const { t } = useLocale(); return ( @@ -33,12 +33,17 @@ const Option = ({ ...props }: OptionProps) => { {t("default")} )} + {isManaged && ( + + {t("managed")} + + )} ); }; const SingleValue = ({ ...props }: SingleValueProps) => { - const { label, isDefault } = props.data; + const { label, isDefault, isManaged = false } = props.data; const { t } = useLocale(); return ( @@ -48,50 +53,39 @@ const SingleValue = ({ ...props }: SingleValueProps) => { {t("default")} )} + {isManaged && ( + + {t("managed")} + + )} ); }; const AvailabilitySelect = ({ className = "", - isLoading, - schedules, + options, + value, ...props }: { className?: string; name: string; - value: number; - isLoading: boolean; - schedules: RouterOutputs["viewer"]["availability"]["list"]["schedules"] | []; + value: AvailabilityOption | undefined; + options: AvailabilityOption[]; + isDisabled?: boolean; onBlur: () => void; onChange: (value: AvailabilityOption | null) => void; }) => { const { t } = useLocale(); - - if (isLoading) { - return ; - } - - const options = schedules.map((schedule) => ({ - value: schedule.id, - label: schedule.name, - isDefault: schedule.isDefault, - })); - - const value = options.find((option) => - props.value - ? option.value === props.value - : option.value === schedules.find((schedule) => schedule.isDefault)?.id - ); - return ( option.value === minimumBookingNoticeDisplayValues.type @@ -146,12 +140,30 @@ export const EventLimitsTab = ({ eventType }: Pick
- + { if (val) onChange(val.value); }} @@ -183,7 +196,10 @@ export const EventLimitsTab = ({ eventType }: Pick
- + { if (val) onChange(val.value); }} @@ -217,11 +234,20 @@ export const EventLimitsTab = ({ eventType }: Pick
- - + +
- + { formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null); }} @@ -261,6 +288,7 @@ export const EventLimitsTab = ({ eventType }: Pick ( 0} onCheckedChange={(active) => { @@ -272,7 +300,12 @@ export const EventLimitsTab = ({ eventType }: Pick - + )} /> @@ -284,6 +317,7 @@ export const EventLimitsTab = ({ eventType }: Pick 0} onCheckedChange={(active) => { if (active) { @@ -297,6 +331,7 @@ export const EventLimitsTab = ({ eventType }: Pick @@ -311,13 +346,16 @@ export const EventLimitsTab = ({ eventType }: Pick formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}> formMethods.setValue("periodType", val as PeriodType)}> - {PERIOD_TYPES.map((period) => { + {PERIOD_TYPES.filter((opt) => + periodTypeLocked.disabled ? watchPeriodType === opt.type : true + ).map((period) => { if (period.type === "UNLIMITED") return null; return (
- - - + {!periodTypeLocked.disabled && ( + + + + )} {period.prefix ? {period.prefix}  : null} {period.type === "ROLLING" && ( -
- + - + option.value === limitKey)} onChange={onIntervalSelect} /> - {hasDeleteButton && ( + {hasDeleteButton && !disabled && (
@@ -450,6 +505,7 @@ type IntervalLimitsManagerProps = defaultLimit: number; step: number; textFieldSuffix?: string; + disabled?: boolean; }; const IntervalLimitsManager = ({ @@ -457,6 +513,7 @@ const IntervalLimitsManager = ({ defaultLimit, step, textFieldSuffix, + disabled, }: IntervalLimitsManagerProps) => { const { watch, setValue, control } = useFormContext(); const watchIntervalLimits = watch(propertyName); @@ -506,6 +563,7 @@ const IntervalLimitsManager = ({ limitKey={limitKey} step={step} value={value} + disabled={disabled} textFieldSuffix={textFieldSuffix} hasDeleteButton={Object.keys(currentIntervalLimits).length > 1} selectOptions={INTERVAL_LIMIT_OPTIONS.filter( @@ -536,7 +594,7 @@ const IntervalLimitsManager = ({ /> ); })} - {currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && ( + {currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && !disabled && ( diff --git a/apps/web/components/eventtype/EventRecurringTab.tsx b/apps/web/components/eventtype/EventRecurringTab.tsx index 2b41ca4679..6f72b9e6d3 100644 --- a/apps/web/components/eventtype/EventRecurringTab.tsx +++ b/apps/web/components/eventtype/EventRecurringTab.tsx @@ -11,7 +11,7 @@ export const EventRecurringTab = ({ eventType }: Pick - +
); }; diff --git a/apps/web/components/eventtype/EventSetupTab.tsx b/apps/web/components/eventtype/EventSetupTab.tsx index b7c7996dd3..57aa396573 100644 --- a/apps/web/components/eventtype/EventSetupTab.tsx +++ b/apps/web/components/eventtype/EventSetupTab.tsx @@ -11,6 +11,7 @@ import { z } from "zod"; import type { EventLocationType } from "@calcom/app-store/locations"; import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations"; +import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; import { CAL_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; @@ -45,8 +46,33 @@ const getLocationFromType = ( } }; +const getLocationInfo = (props: Pick) => { + const locationAvailable = + props.eventType.locations && + props.eventType.locations.length > 0 && + props.locationOptions.some((op) => + op.options.find((opt) => opt.value === props.eventType.locations[0].type) + ); + const locationDetails = props.eventType.locations && + props.eventType.locations.length > 0 && + !locationAvailable && { + slug: props.eventType.locations[0].type + .replace("integrations:", "") + .replace(":", "-") + .replace("_video", ""), + name: props.eventType.locations[0].type + .replace("integrations:", "") + .replace(":", " ") + .replace("_video", "") + .split(" ") + .map((word) => word[0].toUpperCase() + word.slice(1)) + .join(" "), + }; + return { locationAvailable, locationDetails }; +}; interface DescriptionEditorProps { description?: string | null; + editable?: boolean; } const DescriptionEditor = (props: DescriptionEditorProps) => { @@ -64,6 +90,7 @@ const DescriptionEditor = (props: DescriptionEditorProps) => { setText={(value: string) => formMethods.setValue("description", turndown(value))} excludedToolbarItems={["blockType"]} placeholder={t("quick_video_meeting")} + editable={props.editable} /> ) : ( @@ -174,6 +201,13 @@ export const EventSetupTab = ( resolver: zodResolver(locationFormSchema), }); + const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } = + useLockedFieldsManager( + eventType, + t("locked_fields_admin_description"), + t("locked_fields_member_description") + ); + const Locations = () => { const { t } = useLocale(); @@ -188,6 +222,12 @@ export const EventSetupTab = ( return true; }); + const defaultValue = isManagedEventType + ? locationOptions.find((op) => op.label === t("default"))?.options[0] + : undefined; + + const { locationDetails, locationAvailable } = getLocationInfo(props); + return (
{validLocations.length === 0 && ( @@ -195,6 +235,8 @@ export const EventSetupTab = (
)} - {validLocations.length > 0 && ( + {isChildrenManagedEventType && !locationAvailable && locationDetails && ( +

+ {t("app_not_connected", { appName: locationDetails.name })}{" "} + + {t("connect_now")} + +

+ )} + {validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (