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 <peeroke@gmail.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Leo Giovanetti 2023-04-12 23:10:23 -03:00 committed by GitHub
parent 7349fb9f6d
commit 5170fc2424
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 2798 additions and 655 deletions

View File

@ -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<EventTypeSetupProps,
);
};
const { shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const eventNamePlaceholder = getEventName({
...eventNameObject,
eventName: formMethods.watch("eventName"),
});
const successRedirectUrlLocked = shouldLockDisableProps("successRedirectUrl");
const seatsLocked = shouldLockDisableProps("seatsPerTimeSlotEnabled");
const closeEventNameTip = () => setShowEventNameTip(false);
const setEventName = (value: string) => formMethods.setValue("eventName", value);
@ -128,19 +137,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<TextField
label={t("event_name_in_calendar")}
type="text"
{...shouldLockDisableProps("eventName")}
placeholder={eventNamePlaceholder}
defaultValue={eventType.eventName || ""}
{...formMethods.register("eventName")}
addOnSuffix={
<Button
type="button"
StartIcon={Edit}
variant="icon"
color="minimal"
className="hover:stroke-3 hover:text-emphasis min-w-fit px-0 hover:bg-transparent"
onClick={() => 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)}>
<Edit className="h-4 w-4" />
</Button>
}
/>
</div>
@ -150,6 +159,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
description={t("booking_questions_description")}
addFieldLabel={t("add_a_booking_question")}
formProp="bookingFields"
{...shouldLockDisableProps("bookingFields")}
dataStore={{
options: {
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
@ -158,6 +168,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
/>
<hr className="border-subtle" />
<RequiresConfirmationController
eventType={eventType}
seatsEnabled={seatsEnabled}
metadata={eventType.metadata}
requiresConfirmation={requiresConfirmation}
@ -171,6 +182,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
render={({ field: { value, onChange } }) => (
<SettingsToggle
title={t("disable_notes")}
{...shouldLockDisableProps("hideCalendarNotes")}
description={t("disable_notes_description")}
checked={value}
onCheckedChange={(e) => onChange(e)}
@ -185,6 +197,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<>
<SettingsToggle
title={t("redirect_success_booking")}
{...successRedirectUrlLocked}
description={t("redirect_url_description")}
checked={redirectUrlVisible}
onCheckedChange={(e) => {
@ -197,6 +210,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
className="w-full"
label={t("redirect_success_booking")}
labelSrOnly
disabled={successRedirectUrlLocked.disabled}
placeholder={t("external_redirect_url")}
required={redirectUrlVisible}
type="text"
@ -219,6 +233,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<SettingsToggle
data-testid="hashedLinkCheck"
title={t("private_link")}
{...shouldLockDisableProps("hashedLinkCheck")}
description={t("private_link_description", { appName: APP_NAME })}
checked={hashedLinkVisible}
onCheckedChange={(e) => {
@ -240,6 +255,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
<Button
color="minimal"
size="sm"
type="button"
className="hover:stroke-3 hover:text-emphasis min-w-fit px-0 !py-0 hover:bg-transparent"
aria-label="copy link"
onClick={() => {
navigator.clipboard.writeText(placeholderHashedLink);
if (eventType.hashedLink) {
@ -247,10 +266,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
} else {
showToast(t("enabled_after_update_description"), "warning");
}
}}
className="hover:stroke-3 hover:text-emphasis hover:bg-transparent"
type="button">
<Copy />
}}>
<Copy className="h-4 w-4" />
</Button>
</Tooltip>
}
@ -267,6 +284,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<SettingsToggle
data-testid="offer-seats-toggle"
title={t("offer_seats")}
{...seatsLocked}
description={t("offer_seats_description")}
checked={value}
disabled={noShowFeeEnabled}
@ -295,8 +313,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
labelSrOnly
label={t("number_of_seats")}
type="number"
disabled={seatsLocked.disabled}
defaultValue={value || 2}
min={1}
className="w-24"
addOnSuffix={<>{t("seats")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
@ -305,6 +325,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<div className="mt-2">
<Checkbox
description={t("show_attendees")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
defaultChecked={!!eventType.seatsShowAttendees}
/>

View File

@ -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<EventTypeSetupProps, "eventType">["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 (
<>
<div>
<div className="before:border-0">
{!installedApps?.length && isManagedEventType && (
<Alert
severity="neutral"
className="mb-2"
title={t("locked_for_members")}
message={t("locked_apps_description")}
/>
)}
{!isLoading && !installedApps?.length ? (
<EmptyScreen
Icon={Grid}
headline={t("empty_installed_apps_headline")}
description={t("empty_installed_apps_description")}
buttonRaw={
<Button target="_blank" color="secondary" href="/apps">
{t("empty_installed_apps_button")}{" "}
</Button>
isChildrenManagedEventType && !isManagedEventType ? (
<Button StartIcon={Lock} color="secondary" disabled>
{t("locked_by_admin")}
</Button>
) : (
<Button target="_blank" color="secondary" href="/apps">
{t("empty_installed_apps_button")}{" "}
</Button>
)
}
/>
) : null}
@ -82,22 +103,24 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
))}
</div>
</div>
<div>
{!isLoading && notInstalledApps?.length ? (
<h2 className="text-emphasis mt-0 mb-2 text-lg font-semibold">Available Apps</h2>
) : null}
<div className="before:border-0">
{notInstalledApps?.map((app) => (
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
app={app}
eventType={eventType}
/>
))}
{!shouldLockDisableProps("apps").disabled && (
<div>
{!isLoading && notInstalledApps?.length ? (
<h2 className="text-emphasis mt-0 mb-2 text-lg font-semibold">{t("available_apps")}</h2>
) : null}
<div className="before:border-0">
{notInstalledApps?.map((app) => (
<EventTypeAppCard
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
key={app.slug}
app={app}
eventType={eventType}
/>
))}
</div>
</div>
</div>
)}
</>
);
};

View File

@ -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<AvailabilityOption>) => {
const { label, isDefault } = props.data;
const { label, isDefault, isManaged = false } = props.data;
const { t } = useLocale();
return (
<components.Option {...props}>
@ -33,12 +33,17 @@ const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
{t("default")}
</Badge>
)}
{isManaged && (
<Badge variant="gray" className="ml-2">
{t("managed")}
</Badge>
)}
</components.Option>
);
};
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
const { label, isDefault } = props.data;
const { label, isDefault, isManaged = false } = props.data;
const { t } = useLocale();
return (
<components.SingleValue {...props}>
@ -48,50 +53,39 @@ const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
{t("default")}
</Badge>
)}
{isManaged && (
<Badge variant="gray" className="ml-2">
{t("managed")}
</Badge>
)}
</components.SingleValue>
);
};
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 <SelectSkeletonLoader />;
}
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 (
<Select
placeholder={t("select")}
options={options}
isDisabled={props.isDisabled}
isSearchable={false}
onChange={props.onChange}
className={classNames("block w-full min-w-0 flex-1 rounded-sm text-sm", className)}
value={value}
defaultValue={value}
components={{ Option, SingleValue }}
isMulti={false}
/>
@ -103,17 +97,25 @@ const format = (date: Date, hour12: boolean) =>
new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss"))
);
const EventTypeScheduleDetails = () => {
const EventTypeScheduleDetails = ({
isManagedEventType,
selectedScheduleValue,
}: {
isManagedEventType: boolean;
selectedScheduleValue: AvailabilityOption | undefined;
}) => {
const { data: loggedInUser } = useMeQuery();
const timeFormat = loggedInUser?.timeFormat;
const { t, i18n } = useLocale();
const { watch } = useFormContext<FormValues>();
const scheduleId = watch("schedule");
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined },
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
{
scheduleId: scheduleId || loggedInUser?.defaultScheduleId || selectedScheduleValue?.value || undefined,
isManagedEventType,
},
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId || !!selectedScheduleValue }
);
const filterDays = (dayNum: number) =>
@ -160,7 +162,7 @@ const EventTypeScheduleDetails = () => {
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
</span>
{!!schedule?.id && (
{!!schedule?.id && !schedule.isManaged && (
<Button
href={`/availability/${schedule.id}`}
disabled={isLoading}
@ -176,11 +178,20 @@ const EventTypeScheduleDetails = () => {
);
};
const EventTypeSchedule = () => {
const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const { data: schedules, isLoading } = trpc.viewer.availability.list.useQuery();
const { shouldLockIndicator, shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } =
useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const { watch } = useFormContext<FormValues>();
const watchSchedule = watch("schedule");
if (!schedules?.schedules.length && !isLoading)
const { data, isLoading } = trpc.viewer.availability.list.useQuery();
if (!data?.schedules.length && !isLoading)
return (
<EmptyScreen
Icon={Clock}
@ -191,22 +202,65 @@ const EventTypeSchedule = () => {
/>
);
const schedules = data?.schedules || [];
const options = schedules.map((schedule) => ({
value: schedule.id,
label: schedule.name,
isDefault: schedule.isDefault,
isManaged: false,
}));
// We are showing a managed event for a team admin, so adding the option to let members choose their schedule
if (isManagedEventType) {
options.push({
value: 0,
label: t("members_default_schedule"),
isDefault: false,
isManaged: false,
});
}
// We are showing a managed event for a member and team owner selected their own schedule, so adding
// the managed schedule option
if (
isChildrenManagedEventType &&
watchSchedule &&
!schedules.find((schedule) => schedule.id === watchSchedule)
) {
options.push({
value: watchSchedule,
label: eventType.scheduleName ?? t("default_schedule_name"),
isDefault: false,
isManaged: false,
});
}
const value = options.find((option) =>
watchSchedule
? option.value === watchSchedule
: isManagedEventType
? option.value === 0
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
);
return (
<div className="space-y-4">
<div>
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
{t("availability")}
{shouldLockIndicator("availability")}
</label>
<Controller
name="schedule"
render={({ field }) => (
<>
<AvailabilitySelect
value={field.value}
value={value}
options={options}
onBlur={field.onBlur}
isDisabled={shouldLockDisableProps("schedule").disabled}
name={field.name}
schedules={schedules?.schedules || []}
isLoading={isLoading}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
@ -215,12 +269,21 @@ const EventTypeSchedule = () => {
)}
/>
</div>
<EventTypeScheduleDetails />
{value?.value !== 0 ? (
<EventTypeScheduleDetails
selectedScheduleValue={value}
isManagedEventType={isManagedEventType || isChildrenManagedEventType}
/>
) : (
isManagedEventType && (
<p className="!mt-2 ml-1 text-sm text-gray-600">{t("members_default_schedule_description")}</p>
)
)}
</div>
);
};
const UseCommonScheduleSettingsToggle = () => {
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const { resetField, setValue } = useFormContext<FormValues>();
return (
@ -239,13 +302,23 @@ const UseCommonScheduleSettingsToggle = () => {
}}
title={t("choose_common_schedule_team_event")}
description={t("choose_common_schedule_team_event_description")}>
<EventTypeSchedule />
<EventTypeSchedule eventType={eventType} />
</SettingsToggle>
)}
/>
);
};
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
return isTeamEvent ? <UseCommonScheduleSettingsToggle /> : <EventTypeSchedule />;
export const EventAvailabilityTab = ({
eventType,
isTeamEvent,
}: {
eventType: EventTypeSetup;
isTeamEvent: boolean;
}) => {
return isTeamEvent && eventType.schedulingType !== SchedulingType.MANAGED ? (
<UseCommonScheduleSettingsToggle eventType={eventType} />
) : (
<EventTypeSchedule eventType={eventType} />
);
};

View File

@ -7,6 +7,7 @@ import type { UseFormRegisterReturn } from "react-hook-form";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import type { SingleValue } from "react-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
@ -14,16 +15,7 @@ import findDurationType from "@calcom/lib/findDurationType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { PeriodType } from "@calcom/prisma/client";
import type { IntervalLimit } from "@calcom/types/Calendar";
import {
Button,
DateRangePicker,
Input,
InputField,
Label,
Select,
SettingsToggle,
TextField,
} from "@calcom/ui";
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
import { Plus, Trash } from "@calcom/ui/components/icon";
const MinimumBookingNoticeInput = React.forwardRef<
@ -78,6 +70,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
<div className="w-1/2 md:w-full">
<InputField
required
disabled={passThroughProps.disabled}
defaultValue={minimumBookingNoticeDisplayValues.value}
onChange={(e) =>
setMinimumBookingNoticeDisplayValues({
@ -95,6 +88,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
</div>
<Select
isSearchable={false}
isDisabled={passThroughProps.disabled}
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
defaultValue={durationTypeOptions.find(
(option) => option.value === minimumBookingNoticeDisplayValues.type
@ -146,12 +140,30 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
defaultValue: periodType?.type,
});
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
const periodTypeLocked = shouldLockDisableProps("periodType");
const optionsPeriod = [
{ value: 1, label: t("calendar_days") },
{ value: 0, label: t("business_days") },
];
return (
<div className="space-y-8">
<div className="space-y-4 lg:space-y-8">
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
<div className="w-full">
<Label htmlFor="beforeBufferTime">{t("before_event")} </Label>
<Label htmlFor="beforeBufferTime">
{t("before_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Controller
name="beforeBufferTime"
control={formMethods.control}
@ -170,6 +182,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@ -183,7 +196,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
</div>
<div className="w-full">
<Label htmlFor="afterBufferTime">{t("after_event")} </Label>
<Label htmlFor="afterBufferTime">
{t("after_event")}
{shouldLockIndicator("bookingLimits")}
</Label>
<Controller
name="afterBufferTime"
control={formMethods.control}
@ -202,6 +218,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
onChange={(val) => {
if (val) onChange(val.value);
}}
@ -217,11 +234,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</div>
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
<div className="w-full">
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")} </Label>
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
<Label htmlFor="minimumBookingNotice">
{t("minimum_booking_notice")}
{shouldLockIndicator("minimumBookingNotice")}
</Label>
<MinimumBookingNoticeInput
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
{...formMethods.register("minimumBookingNotice")}
/>
</div>
<div className="w-full">
<Label htmlFor="slotInterval">{t("slot_interval")} </Label>
<Label htmlFor="slotInterval">
{t("slot_interval")}
{shouldLockIndicator("slotInterval")}
</Label>
<Controller
name="slotInterval"
control={formMethods.control}
@ -239,6 +265,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("slotInterval").disabled}
onChange={(val) => {
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
}}
@ -261,6 +288,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
render={({ field: { value } }) => (
<SettingsToggle
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
@ -272,7 +300,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
formMethods.setValue("bookingLimits", {});
}
}}>
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
<IntervalLimitsManager
disabled={bookingLimitsLocked.disabled}
propertyName="bookingLimits"
defaultLimit={1}
step={1}
/>
</SettingsToggle>
)}
/>
@ -284,6 +317,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<SettingsToggle
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={Object.keys(value ?? {}).length > 0}
onCheckedChange={(active) => {
if (active) {
@ -297,6 +331,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
@ -311,13 +346,16 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<SettingsToggle
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={value && value !== "UNLIMITED"}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<RadioGroup.Root
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => 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 (
<div
@ -326,30 +364,42 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default flex h-4 w-4 cursor-pointer items-center rounded-full border border-black focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
</RadioGroup.Item>
{!periodTypeLocked.disabled && (
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default flex h-4 w-4 cursor-pointer items-center rounded-full border border-black focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
</RadioGroup.Item>
)}
{period.prefix ? <span>{period.prefix}&nbsp;</span> : null}
{period.type === "ROLLING" && (
<div className="flex h-9">
<Input
<div className="flex items-center">
<TextField
labelSrOnly
type="number"
className="border-default block w-16 rounded-md py-3 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
placeholder="30"
disabled={periodTypeLocked.disabled}
{...formMethods.register("periodDays", { valueAsNumber: true })}
defaultValue={eventType.periodDays || 30}
/>
<select
id=""
className="border-default bg-default text-default block h-9 w-full rounded-md py-2 pl-3 pr-10 text-sm focus:outline-none"
{...formMethods.register("periodCountCalendarDays")}
defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}>
<option value="1">{t("calendar_days")}</option>
<option value="0">{t("business_days")}</option>
</select>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
opt?.value.toString() as "0" | "1"
);
}}
defaultValue={
optionsPeriod.find(
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
) ?? optionsPeriod[0]
}
/>
</div>
)}
{period.type === "RANGE" && (
@ -362,6 +412,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
@ -400,6 +451,7 @@ type IntervalLimitItemProps = {
step: number;
value: number;
textFieldSuffix?: string;
disabled?: boolean;
selectOptions: { value: keyof IntervalLimit; label: string }[];
hasDeleteButton?: boolean;
onDelete: (intervalLimitsKey: IntervalLimitsKey) => void;
@ -414,6 +466,7 @@ const IntervalLimitItem = ({
textFieldSuffix,
selectOptions,
hasDeleteButton,
disabled,
onDelete,
onLimitChange,
onIntervalSelect,
@ -424,8 +477,9 @@ const IntervalLimitItem = ({
required
type="number"
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
className="mb-0"
className="mb-0 !h-auto"
placeholder={`${value}`}
disabled={disabled}
min={step}
step={step}
defaultValue={value}
@ -435,10 +489,11 @@ const IntervalLimitItem = ({
<Select
options={selectOptions}
isSearchable={false}
isDisabled={disabled}
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
onChange={onIntervalSelect}
/>
{hasDeleteButton && (
{hasDeleteButton && !disabled && (
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
)}
</div>
@ -450,6 +505,7 @@ type IntervalLimitsManagerProps<K extends "durationLimits" | "bookingLimits"> =
defaultLimit: number;
step: number;
textFieldSuffix?: string;
disabled?: boolean;
};
const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
@ -457,6 +513,7 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
defaultLimit,
step,
textFieldSuffix,
disabled,
}: IntervalLimitsManagerProps<K>) => {
const { watch, setValue, control } = useFormContext<FormValues>();
const watchIntervalLimits = watch(propertyName);
@ -506,6 +563,7 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
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 = <K extends "durationLimits" | "bookingLimits">({
/>
);
})}
{currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && (
{currentIntervalLimits && Object.keys(currentIntervalLimits).length <= 3 && !disabled && (
<Button color="minimal" StartIcon={Plus} onClick={addLimit}>
{t("add_limit")}
</Button>

View File

@ -11,7 +11,7 @@ export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "even
return (
<div className="">
<RecurringEventController paymentEnabled={requirePayment} recurringEvent={eventType.recurringEvent} />
<RecurringEventController paymentEnabled={requirePayment} eventType={eventType} />
</div>
);
};

View File

@ -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<EventTypeSetupProps, "eventType" | "locationOptions">) => {
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}
/>
) : (
<SkeletonContainer>
@ -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 (
<div className="w-full">
{validLocations.length === 0 && (
@ -195,6 +235,8 @@ export const EventSetupTab = (
<LocationSelect
placeholder={t("select")}
options={locationOptions}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={defaultValue}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
@ -282,7 +324,15 @@ export const EventSetupTab = (
</Trans>
</div>
)}
{validLocations.length > 0 && (
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
)}
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
<li>
<Button
data-testid="add-location"
@ -299,22 +349,33 @@ export const EventSetupTab = (
);
};
const lengthLockedProps = shouldLockDisableProps("length");
const descriptionLockedProps = shouldLockDisableProps("description");
return (
<div>
<div className="space-y-8">
<TextField
required
label={t("title")}
{...shouldLockDisableProps("title")}
defaultValue={eventType.title}
{...formMethods.register("title")}
/>
<div>
<Label>{t("description")}</Label>
<DescriptionEditor description={eventType?.description} />
<Label>
{t("description")}
{shouldLockIndicator("description")}
</Label>
<DescriptionEditor
description={eventType?.description}
editable={!descriptionLockedProps.disabled}
/>
</div>
<TextField
required
label={t("URL")}
{...shouldLockDisableProps("slug")}
defaultValue={eventType.slug}
addOnLeading={
<>
@ -367,12 +428,14 @@ export const EventSetupTab = (
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("default_duration")}
{shouldLockIndicator("length")}
</Skeleton>
<Select
value={defaultDuration}
isSearchable={false}
name="length"
className="text-sm"
isDisabled={lengthLockedProps.disabled}
noOptionsMessage={() => t("default_duration_no_options")}
options={selectedMultipleDuration}
onChange={(option) => {
@ -388,32 +451,36 @@ export const EventSetupTab = (
<TextField
required
type="number"
{...lengthLockedProps}
label={t("duration")}
defaultValue={eventType.length ?? 15}
{...formMethods.register("length")}
addOnSuffix={<>{t("minutes")}</>}
/>
)}
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("allow_booker_to_select_duration")}
checked={multipleDuration !== undefined}
onCheckedChange={() => {
if (multipleDuration !== undefined) {
setMultipleDuration(undefined);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
} else {
setMultipleDuration([]);
formMethods.setValue("metadata.multipleDuration", []);
formMethods.setValue("length", 0);
}
}}
/>
</div>
{!lengthLockedProps.disabled && (
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("allow_booker_to_select_duration")}
checked={multipleDuration !== undefined}
onCheckedChange={() => {
if (multipleDuration !== undefined) {
setMultipleDuration(undefined);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
} else {
setMultipleDuration([]);
formMethods.setValue("metadata.multipleDuration", []);
formMethods.setValue("length", 0);
}
}}
/>
</div>
)}
<div>
<Skeleton as={Label} loadingClassName="w-16">
{t("location")}
{shouldLockIndicator("locations")}
</Skeleton>
<Controller

View File

@ -1,4 +1,4 @@
import type { SchedulingType } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useEffect, useRef } from "react";
import type { ComponentProps } from "react";
@ -7,24 +7,46 @@ import type { Options } from "react-select";
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label, Select } from "@calcom/ui";
interface IMemberToValue {
interface IUserToValue {
id: number | null;
name: string | null;
username: string | null;
email: string;
}
const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
const mapUserToValue = ({ id, name, username, email }: IUserToValue) => ({
value: `${id || ""}`,
label: `${name || ""}`,
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
email,
});
export const mapMemberToChildrenOption = (
member: EventTypeSetupProps["teamMembers"][number],
slug: string
) => {
return {
slug,
hidden: false,
created: false,
owner: {
id: member.id,
name: member.name ?? "",
email: member.email,
username: member.username ?? "",
membership: member.membership,
eventTypeSlugs: member.eventTypes ?? [],
},
value: `${member.id ?? ""}`,
label: member.name ?? "",
};
};
const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof mapUserToValue>) => {
if (a.label < b.label) {
return -1;
@ -35,6 +57,40 @@ const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof
return 0;
};
const ChildrenEventTypesList = ({
options = [],
value,
onChange,
...rest
}: {
value: ReturnType<typeof mapMemberToChildrenOption>[];
onChange?: (options: ReturnType<typeof mapMemberToChildrenOption>[]) => void;
options?: Options<ReturnType<typeof mapMemberToChildrenOption>>;
} & Omit<Partial<ComponentProps<typeof ChildrenEventTypeSelect>>, "onChange" | "value">) => {
const { t } = useLocale();
return (
<div className="flex flex-col space-y-5">
<div>
<Label>{t("assign_to")}</Label>
<ChildrenEventTypeSelect
onChange={(options) => {
onChange &&
onChange(
options.map((option) => ({
...option,
}))
);
}}
value={value}
options={options.filter((opt) => !value.find((val) => val.owner.id.toString() === opt.value))}
controlShouldRenderValue={false}
{...rest}
/>
</div>
</div>
);
};
const CheckedHostField = ({
labelText,
placeholder,
@ -123,6 +179,21 @@ const RoundRobinHosts = ({
);
};
const ChildrenEventTypes = ({
childrenEventTypeOptions,
}: {
childrenEventTypeOptions: ReturnType<typeof mapMemberToChildrenOption>[];
}) => {
return (
<Controller<FormValues>
name="children"
render={({ field: { onChange, value } }) => (
<ChildrenEventTypesList value={value} options={childrenEventTypeOptions} onChange={onChange} />
)}
/>
);
};
const Hosts = ({
teamMembers,
}: {
@ -189,6 +260,7 @@ const Hosts = ({
/>*/}
</>
),
MANAGED: <></>,
};
return !!schedulingType ? schedulingTypeRender[schedulingType] : <></>;
}}
@ -196,7 +268,11 @@ const Hosts = ({
);
};
export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "teamMembers" | "team">) => {
export const EventTeamTab = ({
team,
teamMembers,
eventType,
}: Pick<EventTypeSetupProps, "teamMembers" | "team" | "eventType">) => {
const { t } = useLocale();
const schedulingTypeOptions: {
@ -216,9 +292,13 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
},
];
const teamMembersOptions = teamMembers.map(mapUserToValue);
const childrenEventTypeOptions = teamMembers.map((member) => {
return mapMemberToChildrenOption(member, eventType.slug);
});
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
return (
<div>
{team && (
{team && !isManagedEventType && (
<div className="space-y-5">
<div className="flex flex-col">
<Label>{t("scheduling_type")}</Label>
@ -239,6 +319,9 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
<Hosts teamMembers={teamMembersOptions} />
</div>
)}
{team && isManagedEventType && (
<ChildrenEventTypes childrenEventTypeOptions={childrenEventTypeOptions} />
)}
</div>
);
};

View File

@ -1,16 +1,17 @@
import type { Webhook } from "@prisma/client";
import { Webhook as TbWebhook } from "lucide-react";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useState } from "react";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { WebhookForm } from "@calcom/features/webhooks/components";
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Webhook } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
import { Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
import { Plus } from "@calcom/ui/components/icon";
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
import { Plus, Lock } from "@calcom/ui/components/icon";
export const EventTeamWebhooksTab = ({
eventType,
@ -91,6 +92,14 @@ export const EventTeamWebhooksTab = ({
</Button>
);
};
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const webhookLockedStatus = shouldLockDisableProps("webhooks");
return (
<div>
{team && webhooks && !isLoading && (
@ -98,15 +107,24 @@ export const EventTeamWebhooksTab = ({
<div>
<div>
<>
{isManagedEventType && (
<Alert
severity="neutral"
className="mb-2"
title={t("locked_for_members")}
message={t("locked_webhooks_description")}
/>
)}
{webhooks.length ? (
<>
<div className="mb-8 rounded-md border">
<div className="mb-2 rounded-md border">
{webhooks.map((webhook, index) => {
return (
<WebhookListItem
key={webhook.id}
webhook={webhook}
lastItem={webhooks.length === index + 1}
canEditWebhook={!webhookLockedStatus.disabled}
onEditWebhook={() => {
setEditModalOpen(true);
setWebhookToEdit(webhook);
@ -122,7 +140,15 @@ export const EventTeamWebhooksTab = ({
Icon={TbWebhook}
headline={t("create_your_first_webhook")}
description={t("create_your_first_team_webhook_description", { appName: APP_NAME })}
buttonRaw={<NewWebhookButton />}
buttonRaw={
isChildrenManagedEventType && !isManagedEventType ? (
<Button StartIcon={Lock} color="secondary" disabled>
{t("locked_by_admin")}
</Button>
) : (
<NewWebhookButton />
)
}
/>
)}
</>

View File

@ -1,10 +1,13 @@
import { SchedulingType } from "@prisma/client";
import { Webhook as TbWebhook } from "lucide-react";
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { useRouter } from "next/router";
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
import { useMemo, useState, Suspense } from "react";
import type { UseFormReturn } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
@ -80,12 +83,6 @@ function getNavigation(props: {
icon: LinkIcon,
info: `${duration} ${t("minute_timeUnit")}`, // TODO: Get this from props
},
{
name: "availability",
href: `/event-types/${eventType.id}?tabName=availability`,
icon: Calendar,
info: `default_schedule_name`, // TODO: Get this from props
},
{
name: "event_limit_tab_title",
href: `/event-types/${eventType.id}?tabName=limits`,
@ -137,7 +134,10 @@ function EventTypeSingleLayout({
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const hasPermsToDelete = currentUserMembership?.role !== "MEMBER" || !currentUserMembership;
const hasPermsToDelete =
currentUserMembership?.role !== "MEMBER" ||
!currentUserMembership ||
eventType.schedulingType === SchedulingType.MANAGED;
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: async () => {
@ -157,23 +157,55 @@ function EventTypeSingleLayout({
},
});
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
// Define tab navigation here
const EventTypeTabs = useMemo(() => {
const navigation = getNavigation({
let navigation = getNavigation({
t,
eventType,
enabledAppsNumber,
installedAppsNumber,
enabledWorkflowsNumber,
});
navigation.splice(1, 0, {
name: "availability",
href: `/event-types/${eventType.id}?tabName=availability`,
icon: Calendar,
info:
isManagedEventType || isChildrenManagedEventType
? eventType.schedule === null
? "Member's default schedule"
: isChildrenManagedEventType
? `${
eventType.scheduleName
? `${eventType.scheduleName} - ${t("managed")}`
: `default_schedule_name`
}`
: eventType.scheduleName ?? `default_schedule_name`
: `default_schedule_name`,
});
// If there is a team put this navigation item within the tabs
if (team) {
navigation.splice(2, 0, {
name: "assignment",
href: `/event-types/${eventType.id}?tabName=team`,
icon: Users,
info: eventType.schedulingType === "COLLECTIVE" ? "collective" : "round_robin",
info: `${t(eventType.schedulingType?.toLowerCase() ?? "")}${
isManagedEventType
? ` - ${t("count_members", { count: formMethods.watch("children").length || 0 })}`
: ""
}`,
});
}
if (isManagedEventType || isChildrenManagedEventType) {
// Removing apps and workflows for manageg event types by admins v1
navigation = navigation.slice(0, -2);
} else {
navigation.push({
name: "webhooks",
href: `/event-types/${eventType.id}?tabName=webhooks`,
@ -189,6 +221,7 @@ function EventTypeSingleLayout({
}`;
const embedLink = `${team ? `team/${team.slug}` : eventType.users[0].username}/${eventType.slug}`;
const isManagedEvent = eventType.schedulingType === SchedulingType.MANAGED ? "_managed" : "";
return (
<Shell
@ -197,63 +230,73 @@ function EventTypeSingleLayout({
heading={eventType.title}
CTA={
<div className="flex items-center justify-end">
<div className="sm:hover:bg-subtle hidden items-center rounded-md px-2 lg:flex">
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 hidden cursor-pointer self-center whitespace-nowrap pr-2 sm:inline">
{t("hide_from_profile")}
</Skeleton>
<Switch
id="hiddenSwitch"
checked={formMethods.watch("hidden")}
onCheckedChange={(e) => {
formMethods.setValue("hidden", e);
}}
/>
</div>
<VerticalDivider className="hidden lg:block" />
{!eventType.metadata.managedEventConfig && (
<>
<div className="sm:hover:bg-subtle hidden items-center rounded-md px-2 lg:flex">
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 hidden cursor-pointer self-center whitespace-nowrap pr-2 sm:inline">
{t("hide_from_profile")}
</Skeleton>
<Switch
id="hiddenSwitch"
checked={formMethods.watch("hidden")}
onCheckedChange={(e) => {
formMethods.setValue("hidden", e);
}}
/>
</div>
<VerticalDivider className="hidden lg:block" />
</>
)}
{/* TODO: Figure out why combined isnt working - works in storybook */}
<ButtonGroup combined containerProps={{ className: "border-default hidden lg:flex" }}>
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
<Tooltip content={t("preview")}>
<Button
color="secondary"
data-testid="preview-button"
target="_blank"
variant="icon"
href={permalink}
rel="noreferrer"
StartIcon={ExternalLink}
/>
</Tooltip>
{!isManagedEventType && (
<>
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
<Tooltip content={t("preview")}>
<Button
color="secondary"
data-testid="preview-button"
target="_blank"
variant="icon"
href={permalink}
rel="noreferrer"
StartIcon={ExternalLink}
/>
</Tooltip>
<Button
color="secondary"
variant="icon"
StartIcon={LinkIcon}
tooltip={t("copy_link")}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Link copied!", "success");
}}
/>
<EmbedButton
embedUrl={encodeURIComponent(embedLink)}
StartIcon={Code}
color="secondary"
variant="icon"
tooltip={t("embed")}
/>
<Button
color="destructive"
variant="icon"
StartIcon={Trash}
tooltip={t("delete")}
disabled={!hasPermsToDelete}
onClick={() => setDeleteDialogOpen(true)}
/>
<Button
color="secondary"
variant="icon"
StartIcon={LinkIcon}
tooltip={t("copy_link")}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Link copied!", "success");
}}
/>
<EmbedButton
embedUrl={encodeURIComponent(embedLink)}
StartIcon={Code}
color="secondary"
variant="icon"
tooltip={t("embed")}
/>
</>
)}
{!isChildrenManagedEventType && (
<Button
color="destructive"
variant="icon"
StartIcon={Trash}
tooltip={t("delete")}
disabled={!hasPermsToDelete}
onClick={() => setDeleteDialogOpen(true)}
/>
)}
</ButtonGroup>
<VerticalDivider className="hidden lg:block" />
@ -351,14 +394,25 @@ function EventTypeSingleLayout({
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_event_type")}
confirmBtnText={t("confirm_delete_event_type")}
loadingText={t("confirm_delete_event_type")}
title={t(`delete${isManagedEvent}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
onConfirm={(e) => {
e.preventDefault();
deleteMutation.mutate({ id: eventType.id });
}}>
{t("delete_event_type_description")}
<p className="mt-5">
<Trans
i18nKey={`delete${isManagedEvent}_event_type_description`}
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>Members assigned to this event type will also have their event types deleted.</li>
<li>
Anyone who they&apos;ve shared their link with will no longer be able to book using it.
</li>
</ul>
</Trans>
</p>
</ConfirmationDialogContent>
</Dialog>
<EmbedDialog />

View File

@ -1,23 +1,26 @@
import type { FormValues } from "pages/event-types/[type]";
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Frequency } from "@calcom/prisma/zod-utils";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Alert, Select, SettingsToggle } from "@calcom/ui";
import { Alert, Select, SettingsToggle, TextField } from "@calcom/ui";
type RecurringEventControllerProps = {
recurringEvent: RecurringEvent | null;
eventType: EventTypeSetup;
paymentEnabled: boolean;
};
export default function RecurringEventController({
recurringEvent,
eventType,
paymentEnabled,
}: RecurringEventControllerProps) {
const { t } = useLocale();
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(
eventType.recurringEvent
);
const formMethods = useFormContext<FormValues>();
/* Just yearly-0, monthly-1 and weekly-2 */
@ -28,6 +31,14 @@ export default function RecurringEventController({
value: value.toString(),
}));
const { shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const recurringLocked = shouldLockDisableProps("recurringEvent");
return (
<div className="block items-start sm:flex">
<div className={!paymentEnabled ? "w-full" : ""}>
@ -37,6 +48,7 @@ export default function RecurringEventController({
<>
<SettingsToggle
title={t("recurring_event")}
{...recurringLocked}
description={t("recurring_event_description")}
checked={recurringEventState !== null}
data-testid="recurring-event-check"
@ -45,7 +57,7 @@ export default function RecurringEventController({
formMethods.setValue("recurringEvent", null);
setRecurringEventState(null);
} else {
const newVal = recurringEvent || {
const newVal = eventType.recurringEvent || {
interval: 1,
count: 12,
freq: Frequency.WEEKLY,
@ -58,11 +70,12 @@ export default function RecurringEventController({
<div data-testid="recurring-event-collapsible" className="text-sm">
<div className="flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
<input
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
className="border-default bg-default text-default block h-[36px] w-16 rounded-md text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
className="mb-0"
defaultValue={recurringEventState.interval}
onChange={(event) => {
const newVal = {
@ -77,7 +90,8 @@ export default function RecurringEventController({
options={recurringEventFreqOptions}
value={recurringEventFreqOptions[recurringEventState.freq]}
isSearchable={false}
className="w-18 block h-[36px] min-w-0 rounded-md text-sm"
className="w-18 ml-2 block min-w-0 rounded-md text-sm"
isDisabled={recurringLocked.disabled}
onChange={(event) => {
const newVal = {
...recurringEventState,
@ -90,12 +104,13 @@ export default function RecurringEventController({
</div>
<div className="mt-4 flex items-center">
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
<input
<TextField
disabled={recurringLocked.disabled}
type="number"
min="1"
max="20"
className="border-default bg-default text-default block h-[36px] w-16 rounded-md text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
defaultValue={recurringEventState.count}
className="mb-0"
onChange={(event) => {
const newVal = {
...recurringEventState,
@ -105,7 +120,7 @@ export default function RecurringEventController({
setRecurringEventState(newVal);
}}
/>
<p className="text-emphasis ltr:mr-2 rtl:ml-2">
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
{t("events", {
count: recurringEventState.count,
})}

View File

@ -1,25 +1,29 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import type { UnitTypeLongPlural } from "dayjs";
import { Trans } from "next-i18next";
import type { FormValues } from "pages/event-types/[type]";
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type z from "zod";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Input, Label, SettingsToggle, RadioField } from "@calcom/ui";
import { Input, SettingsToggle, RadioField, Select } from "@calcom/ui";
type RequiresConfirmationControllerProps = {
metadata: z.infer<typeof EventTypeMetaDataSchema>;
requiresConfirmation: boolean;
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
seatsEnabled: boolean;
eventType: EventTypeSetup;
};
export default function RequiresConfirmationController({
metadata,
eventType,
requiresConfirmation,
onRequiresConfirmation,
seatsEnabled,
@ -37,6 +41,23 @@ export default function RequiresConfirmationController({
}
}, [requiresConfirmation]);
const { shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const requiresConfirmationLockedProps = shouldLockDisableProps("requiresConfirmation");
const options = [
{ label: t("minute_timeUnit"), value: "minutes" },
{ label: t("hour_timeUnit"), value: "hours" },
];
const defaultValue = options.find(
(opt) =>
opt.value === (metadata?.requiresConfirmationThreshold?.unit ?? defaultRequiresConfirmationSetup.unit)
);
return (
<div className="block items-start sm:flex">
<div className="w-full">
@ -46,10 +67,11 @@ export default function RequiresConfirmationController({
render={() => (
<SettingsToggle
title={t("requires_confirmation")}
disabled={seatsEnabled}
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
description={t("requires_confirmation_description")}
checked={requiresConfirmation}
LockedIcon={requiresConfirmationLockedProps.LockedIcon}
onCheckedChange={(val) => {
formMethods.setValue("requiresConfirmation", val);
onRequiresConfirmation(val);
@ -77,70 +99,82 @@ export default function RequiresConfirmationController({
);
}
}}>
<div className="flex flex-col flex-wrap justify-start gap-y-2 space-y-2">
<RadioField label={t("always_requires_confirmation")} id="always" value="always" />
<RadioField
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 flex">
<Input
type="number"
min={1}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue("metadata.requiresConfirmationThreshold.time", val);
}}
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
<select
onChange={(evt) => {
const val = evt.target.value as UnitTypeLongPlural;
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: val,
});
formMethods.setValue("metadata.requiresConfirmationThreshold.unit", val);
}}
className="border-default text-default bg-default ml-2 block h-9 rounded-md py-2 pl-3 pr-10 text-sm focus:outline-none"
defaultValue={
metadata?.requiresConfirmationThreshold?.unit ||
defaultRequiresConfirmationSetup.unit
}>
<option value="minutes">{t("minute_timeUnit")}</option>
<option value="hours">{t("hour_timeUnit")}</option>
</select>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
/>
<div className="flex items-center">
<RadioGroup.Item
<div className="flex flex-col flex-wrap justify-start gap-y-2">
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
<RadioField
label={t("always_requires_confirmation")}
disabled={requiresConfirmationLockedProps.disabled}
id="always"
value="always"
/>
)}
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
<RadioField
disabled={requiresConfirmationLockedProps.disabled}
className="items-center"
label={
<>
<Trans
i18nKey="when_booked_with_less_than_notice"
defaults="When booked with less than <time></time> notice"
components={{
time: (
<div className="mx-2 inline-flex">
<Input
type="number"
min={1}
disabled={requiresConfirmationLockedProps.disabled}
onChange={(evt) => {
const val = Number(evt.target?.value);
setRequiresConfirmationSetup({
unit:
requiresConfirmationSetup?.unit ??
defaultRequiresConfirmationSetup.unit,
time: val,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.time",
val
);
}}
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
/>
<label
className={classNames(
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
)}>
<Select
inputId="notice"
options={options}
isSearchable={false}
isDisabled={requiresConfirmationLockedProps.disabled}
className="ml-2"
onChange={(opt) => {
setRequiresConfirmationSetup({
time:
requiresConfirmationSetup?.time ??
defaultRequiresConfirmationSetup.time,
unit: opt?.value as UnitTypeLongPlural,
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural
);
}}
defaultValue={defaultValue}
/>
</label>
</div>
),
}}
/>
</>
}
id="notice"
value="notice"
className="min-w-4 bg-default flex h-4 w-4 cursor-pointer items-center rounded-full border border-black focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
<RadioGroup.Indicator className="relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full after:bg-black" />
</RadioGroup.Item>
<Label htmlFor="notice" className="!m-0 flex items-center" />
</div>
/>
)}
</div>
</RadioGroup.Root>
</SettingsToggle>

View File

@ -180,7 +180,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
<div className="flex flex-wrap items-center">
<h2 className=" text-default pr-2 text-sm font-semibold">{type.title}</h2>
</div>
<EventTypeDescription eventType={type} />
<EventTypeDescription eventType={type} isPublic={true} />
</Link>
</div>
</div>

View File

@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import type { PeriodType } from "@prisma/client";
import type { SchedulingType } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import { Trans } from "next-i18next";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@ -11,26 +12,28 @@ import { z } from "zod";
import { validateCustomEventName } from "@calcom/core/event";
import type { EventLocationType } from "@calcom/core/location";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { validateIntervalLimitOrder } from "@calcom/lib";
import { CAL_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";
import { useTelemetry, telemetryEventTypes } from "@calcom/lib/telemetry";
import type { Prisma } from "@calcom/prisma/client";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar";
import { Form, showToast } from "@calcom/ui";
import { ConfirmationDialogContent, Dialog, Form, showToast } from "@calcom/ui";
import { asStringOrThrow } from "@lib/asStringOrNull";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import { AvailabilityTab } from "@components/eventtype/AvailabilityTab";
// These can't really be moved into calcom/ui due to the fact they use infered getserverside props typings
import { EventAdvancedTab } from "@components/eventtype/EventAdvancedTab";
import { EventAppsTab } from "@components/eventtype/EventAppsTab";
import { EventAvailabilityTab } from "@components/eventtype/EventAvailabilityTab";
import { EventLimitsTab } from "@components/eventtype/EventLimitsTab";
import { EventRecurringTab } from "@components/eventtype/EventRecurringTab";
import { EventSetupTab } from "@components/eventtype/EventSetupTab";
@ -87,6 +90,7 @@ export type FormValues = {
successRedirectUrl: string;
durationLimits?: IntervalLimit;
bookingLimits?: IntervalLimit;
children: ChildrenEventType[];
hosts: { userId: number; isFixed: boolean }[];
bookingFields: z.infer<typeof eventTypeBookingFields>;
};
@ -111,10 +115,12 @@ const querySchema = z.object({
});
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
const EventTypePage = (props: EventTypeSetupProps) => {
const { t } = useLocale();
const utils = trpc.useContext();
const telemetry = useTelemetry();
const {
data: { tabName },
} = useTypedQuery(querySchema);
@ -126,6 +132,41 @@ const EventTypePage = (props: EventTypeSetupProps) => {
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async () => {
showToast(
t("event_type_updated_successfully", {
eventTypeTitle: eventType.title,
}),
"success"
);
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
message = `${err.data.code}: You are not able to update this event`;
}
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
message = `${err.data.code}: ${err.message}`;
}
if (message) {
showToast(message, "error");
} else {
showToast(err.message, "error");
}
},
});
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
startDate: new Date(eventType.periodStartDate || Date.now()),
endDate: new Date(eventType.periodEndDate || Date.now()),
@ -173,6 +214,18 @@ const EventTypePage = (props: EventTypeSetupProps) => {
minimumBookingNotice: eventType.minimumBookingNotice,
metadata,
hosts: eventType.hosts,
children: eventType.children.map((ch) => ({
...ch,
created: true,
owner: {
...ch.owner,
eventTypeSlugs:
eventType.team?.members
.find((mem) => mem.user.id === ch.owner.id)
?.user.eventTypes.map((evTy) => evTy.slug)
.filter((slug) => slug !== eventType.slug) ?? [],
},
})),
} as const;
const formMethods = useForm<FormValues>({
@ -200,48 +253,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
),
});
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async () => {
showToast(
t("event_type_updated_successfully", {
eventTypeTitle: eventType.title,
}),
"success"
);
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
message = `${err.data.code}: You are not able to update this event`;
}
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
message = `${err.data.code}: ${err.message}`;
}
if (message) {
showToast(message, "error");
} else {
showToast(err.message, "error");
}
console.log(formMethods.formState.isSubmitting);
// formMethods.setFormState((prevState) => ({
// ...prevState,
// isSubmitting: false,
// }));
},
});
useEffect(() => {
if (!formMethods.formState.isDirty) {
//TODO: What's the best way to sync the form with backend
@ -273,8 +284,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
destinationCalendar={destinationCalendar}
/>
),
availability: <AvailabilityTab isTeamEvent={!!team} />,
team: <EventTeamTab teamMembers={teamMembers} team={team} />,
availability: <EventAvailabilityTab eventType={eventType} isTeamEvent={!!team} />,
team: <EventTeamTab teamMembers={teamMembers} team={team} eventType={eventType} />,
limits: <EventLimitsTab eventType={eventType} />,
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
recurring: <EventRecurringTab eventType={eventType} />,
@ -288,89 +299,143 @@ const EventTypePage = (props: EventTypeSetupProps) => {
webhooks: <EventTeamWebhooksTab eventType={eventType} team={team} />,
} as const;
const handleSubmit = async (values: FormValues) => {
const {
periodDates,
periodCountCalendarDays,
beforeBufferTime,
afterBufferTime,
seatsPerTimeSlot,
seatsShowAttendees,
bookingLimits,
durationLimits,
recurringEvent,
locations,
metadata,
customInputs,
children,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
minimumBookingNoticeInDurationType,
...input
} = values;
if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
}
if (durationLimits) {
const isValid = validateIntervalLimitOrder(durationLimits);
if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
}
if (metadata?.multipleDuration !== undefined) {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
if (!input.length && !metadata?.multipleDuration?.includes(input.length)) {
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) {
throw new Error(t("seats_and_no_show_fee_error"));
}
updateMutation.mutate({
...input,
locations,
recurringEvent,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
id: eventType.id,
beforeEventBuffer: beforeBufferTime,
afterEventBuffer: afterBufferTime,
bookingLimits,
durationLimits,
seatsPerTimeSlot,
seatsShowAttendees,
metadata,
customInputs,
children,
});
};
const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState<ChildrenEventType[]>([]);
const slug = formMethods.watch("slug") ?? eventType.slug;
return (
<EventTypeSingleLayout
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={numberOfInstalledApps}
enabledWorkflowsNumber={eventType.workflows.length}
eventType={eventType}
team={team}
isUpdateMutationLoading={updateMutation.isLoading}
formMethods={formMethods}
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
currentUserMembership={currentUserMembership}>
<Form
form={formMethods}
id="event-type-form"
handleSubmit={async (values) => {
const {
periodDates,
periodCountCalendarDays,
beforeBufferTime,
afterBufferTime,
seatsPerTimeSlot,
seatsShowAttendees,
bookingLimits,
durationLimits,
recurringEvent,
locations,
metadata,
customInputs,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
seatsPerTimeSlotEnabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
minimumBookingNoticeInDurationType,
...input
} = values;
if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits);
if (!isValid) throw new Error(t("event_setup_booking_limits_error"));
}
if (durationLimits) {
const isValid = validateIntervalLimitOrder(durationLimits);
if (!isValid) throw new Error(t("event_setup_duration_limits_error"));
}
if (metadata?.multipleDuration !== undefined) {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
if (!input.length && !metadata?.multipleDuration?.includes(input.length)) {
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
if (metadata?.apps?.stripe?.paymentOption === "HOLD" && seatsPerTimeSlot) {
throw new Error(t("seats_and_no_show_fee_error"));
}
updateMutation.mutate({
...input,
locations,
recurringEvent,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
id: eventType.id,
beforeEventBuffer: beforeBufferTime,
afterEventBuffer: afterBufferTime,
bookingLimits,
durationLimits,
seatsPerTimeSlot,
seatsShowAttendees,
metadata,
customInputs,
});
<>
<EventTypeSingleLayout
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={numberOfInstalledApps}
enabledWorkflowsNumber={eventType.workflows.length}
eventType={eventType}
team={team}
isUpdateMutationLoading={updateMutation.isLoading}
formMethods={formMethods}
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
currentUserMembership={currentUserMembership}>
<Form
form={formMethods}
id="event-type-form"
handleSubmit={async (values: FormValues) => {
if (!values.children.length) return handleSubmit(values);
const existingSlugEventTypes = values.children.filter((ch) =>
ch.owner.eventTypeSlugs.includes(slug)
);
if (!existingSlugEventTypes.length) return handleSubmit(values);
setSlugExistsChildrenDialogOpen(existingSlugEventTypes);
}}>
<div ref={animationParentRef}>{tabMap[tabName]}</div>
</Form>
</EventTypeSingleLayout>
<Dialog
open={slugExistsChildrenDialogOpen.length > 0}
onOpenChange={() => {
setSlugExistsChildrenDialogOpen([]);
}}>
<div ref={animationParentRef} className="space-y-6">
{tabMap[tabName]}
</div>
</Form>
</EventTypeSingleLayout>
<ConfirmationDialogContent
isLoading={formMethods.formState.isSubmitting}
variety="warning"
title={t("managed_event_dialog_title", {
slug,
count: slugExistsChildrenDialogOpen.length,
})}
confirmBtnText={t("managed_event_dialog_confirm_button", {
count: slugExistsChildrenDialogOpen.length,
})}
cancelBtnText={t("go_back")}
onConfirm={(e: { preventDefault: () => void }) => {
e.preventDefault();
handleSubmit(formMethods.getValues());
telemetry.event(telemetryEventTypes.slugReplacementAction);
setSlugExistsChildrenDialogOpen([]);
}}>
<p className="mt-5">
<Trans
i18nKey="managed_event_dialog_information"
values={{
names: `${slugExistsChildrenDialogOpen
.map((ch) => ch.owner.name)
.slice(0, -1)
.join(", ")} ${
slugExistsChildrenDialogOpen.length > 1 ? t("and") : ""
} ${slugExistsChildrenDialogOpen.map((ch) => ch.owner.name).slice(-1)}`,
slug,
}}
count={slugExistsChildrenDialogOpen.length}
/>
</p>{" "}
<p className="mt-5">{t("managed_event_dialog_clarification")}</p>
</ConfirmationDialogContent>
</Dialog>
</>
);
};

View File

@ -1,4 +1,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { User } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import { Trans } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
import type { FC } from "react";
@ -116,7 +119,40 @@ const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
const Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGroup; readOnly: boolean }) => {
const { t } = useLocale();
return (
const content = () => (
<div>
<span
className="font-semibold text-gray-700 ltr:mr-1 rtl:ml-1"
data-testid={"event-type-title-" + type.id}>
{type.title}
</span>
{group.profile.slug ? (
<small
className="hidden font-normal leading-4 text-gray-600 sm:inline"
data-testid={"event-type-slug-" + type.id}>
{`/${
type.schedulingType !== SchedulingType.MANAGED ? group.profile.slug : t("username_placeholder")
}/${type.slug}`}
</small>
) : null}
{readOnly && (
<Badge variant="gray" className="ml-2">
{t("readonly")}
</Badge>
)}
</div>
);
return readOnly ? (
<div className="flex-1 overflow-hidden pr-4 text-sm">
{content()}
<EventTypeDescription
// @ts-expect-error FIXME: We have a type mismatch here @hariombalhara @sean-brydon
eventType={type}
shortenDescription
/>
</div>
) : (
<Link
href={`/event-types/${type.id}?tabName=setup`}
className="flex-1 overflow-hidden pr-4 text-sm"
@ -157,6 +193,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
const [parent] = useAutoAnimate<HTMLUListElement>();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
const [deleteDialogTypeSchedulingType, setDeleteDialogSchedulingType] = useState<SchedulingType | null>(
null
);
const utils = trpc.useContext();
const mutation = trpc.viewer.eventTypeOrder.useMutation({
onError: async (err) => {
@ -317,6 +356,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
{types.map((type, index) => {
const embedLink = `${group.profile.slug}/${type.slug}`;
const calLink = `${CAL_URL}/${embedLink}`;
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
const isChildrenManagedEventType =
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
return (
<li key={type.id}>
<div className="hover:bg-muted flex w-full items-center justify-between">
@ -339,55 +381,77 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<MemoizedItem type={type} group={group} readOnly={readOnly} />
<div className="mt-4 hidden sm:mt-0 sm:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
{type.team && (
{type.team && !isManagedEventType && (
<AvatarGroup
className="relative top-1 right-3"
size="sm"
truncateAfter={4}
items={type.users.map((organizer) => ({
items={type.users.map((organizer: { name: any; username: any }) => ({
alt: organizer.name || "",
image: `${WEBAPP_URL}/${organizer.username}/avatar.png`,
title: organizer.name || "",
}))}
/>
)}
{isManagedEventType && (
<AvatarGroup
className="relative top-1 right-3"
size="sm"
truncateAfter={4}
items={type.children
.flatMap((ch) => ch.users)
.map((user: User) => ({
alt: user.name || "",
image: `${WEBAPP_URL}/${user.username}/avatar.png`,
title: user.name || "",
}))}
/>
)}
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
{type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
<Tooltip content={t("show_eventtype_on_profile")}>
<div className="self-center rounded-md p-2">
<Switch
name="Hidden"
checked={!type.hidden}
onCheckedChange={() => {
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
}}
/>
</div>
</Tooltip>
{!isManagedEventType && (
<>
{type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
<Tooltip content={t("show_eventtype_on_profile")}>
<div className="self-center rounded-md p-2">
<Switch
name="Hidden"
checked={!type.hidden}
onCheckedChange={() => {
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
}}
/>
</div>
</Tooltip>
</>
)}
<ButtonGroup combined>
<Tooltip content={t("preview")}>
<Button
data-testid="preview-link-button"
color="secondary"
target="_blank"
variant="icon"
href={calLink}
StartIcon={ExternalLink}
/>
</Tooltip>
{!isManagedEventType && (
<>
<Tooltip content={t("preview")}>
<Button
data-testid="preview-link-button"
color="secondary"
target="_blank"
variant="icon"
href={calLink}
StartIcon={ExternalLink}
/>
</Tooltip>
<Tooltip content={t("copy_link")}>
<Button
color="secondary"
variant="icon"
StartIcon={LinkIcon}
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(calLink);
}}
/>
</Tooltip>
<Tooltip content={t("copy_link")}>
<Button
color="secondary"
variant="icon"
StartIcon={LinkIcon}
onClick={() => {
showToast(t("link_copied"), "success");
navigator.clipboard.writeText(calLink);
}}
/>
</Tooltip>
</>
)}
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
<Button
@ -399,50 +463,60 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
data-testid={"event-type-edit-" + type.id}
StartIcon={Edit2}
onClick={() => router.push("/event-types/" + type.id)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Copy}
onClick={() => openDuplicateModal(type, group)}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<EmbedButton
as={DropdownItem}
type="button"
StartIcon={Code}
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}>
{t("embed")}
</EmbedButton>
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* 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) && (
{!readOnly && (
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
}}
StartIcon={Trash}
className="w-full rounded-none">
{t("delete")}
type="button"
data-testid={"event-type-edit-" + type.id}
StartIcon={Edit2}
onClick={() => router.push("/event-types/" + type.id)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{!isManagedEventType && !isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
type="button"
data-testid={"event-type-duplicate-" + type.id}
StartIcon={Copy}
onClick={() => openDuplicateModal(type, group)}>
{t("duplicate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="outline-none">
<EmbedButton
as={DropdownItem}
type="button"
StartIcon={Code}
className="w-full rounded-none"
embedUrl={encodeURIComponent(embedLink)}>
{t("embed")}
</EmbedButton>
</DropdownMenuItem>
</>
)}
{/* 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) &&
isManagedEventType && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon={Trash}
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</Dropdown>
</ButtonGroup>
@ -521,6 +595,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon={Trash}
className="w-full rounded-none">
@ -539,14 +614,37 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
variety="danger"
title={t("delete_event_type")}
confirmBtnText={t("confirm_delete_event_type")}
loadingText={t("confirm_delete_event_type")}
title={t(
`delete_${deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"}_event_type`
)}
confirmBtnText={t(
`confirm_delete_${
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
}_event_type`
)}
loadingText={t(
`confirm_delete_${
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
}_event_type`
)}
onConfirm={(e) => {
e.preventDefault();
deleteEventTypeHandler(deleteDialogTypeId);
}}>
{t("delete_event_type_description")}
<p className="mt-5">
{t(
`delete_${
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
}_event_type_description`
)}
</p>
<p className="mt-5">
<Trans
i18nKey={`delete_${
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
}_event_type_warning`}
/>
</p>
</ConfirmationDialogContent>
</Dialog>
</div>
@ -638,6 +736,7 @@ const CTA = () => {
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
membershipRole: profile.membershipRole,
slug: profile.slug,
};
});
@ -646,7 +745,7 @@ const CTA = () => {
<CreateButton
subtitle={t("create_event_on").toUpperCase()}
options={profileOptions}
createDialog={CreateEventTypeDialog}
createDialog={() => <CreateEventTypeDialog profileOptions={profileOptions} />}
/>
);
};

View File

@ -268,6 +268,9 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
routingForms: user.routingForms,
self,
login: async () => login({ ...(await self()), password: user.username }, store.page),
logout: async () => {
await page.goto("/auth/logout");
},
getPaymentCredential: async () => getPaymentCredential(store.page),
// ths is for developemnt only aimed to inject debugging messages in the metadata field of the user
debug: async (message: string | Record<string, JSONValue>) => {
@ -333,9 +336,8 @@ export async function login(
await passwordLocator.fill(user.password ?? user.username!);
await signInLocator.click();
// 2 seconds of delay to give the session enough time for a clean load
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2000);
// Moving away from waiting 2 seconds, as it is not a reliable way to expect session to be started
await page.waitForLoadState("networkidle");
}
export async function getPaymentCredential(page: Page) {

View File

@ -0,0 +1,94 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.afterEach(({ users }) => users.deleteAll());
test.describe("Managed Event Types tests", () => {
test("Can create managed event type", async ({ page, users }) => {
// Creating the owner user of the team
const adminUser = await users.create();
// Creating the member user of the team
const memberUser = await users.create();
// First we work with owner user, logging in
await adminUser.login();
// Making sure page loads completely
await page.waitForLoadState("networkidle");
// Let's create a team
await page.goto("/teams");
await test.step("Managed event option exists for team admin", async () => {
// Proceed to create a team
await page.locator("text=Create Team").click();
await page.waitForURL("/settings/teams/new");
// Filling team creation form wizard
await page.locator('input[name="name"]').fill(`${adminUser.username}'s Team`);
await page.locator("text=Continue").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i);
await page.locator('[data-testid="new-member-button"]').click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(`${memberUser.username}@example.com`);
await page.locator('[data-testid="invite-new-member-button"]').click();
await page.waitForLoadState("networkidle");
await page.locator("text=Publish team").click();
await page.waitForLoadState("networkidle");
// Going to create an event type
await page.goto("/event-types");
await page.waitForLoadState("networkidle");
await page.click("[data-testid=new-event-type-dropdown]");
await page.click("[data-testid=option-team-1]");
// Expecting we can add a managed event type as team owner
await expect(page.locator('input[value="MANAGED"]')).toBeVisible();
// Actually creating a managed event type to test things further
await page.click('input[value="MANAGED"]');
await page.fill("[name=title]", "managed");
await page.click("[type=submit]");
});
await test.step("Managed event type has unlocked fields for admin", async () => {
await page.waitForSelector('[data-testid="update-eventtype"]');
await expect(page.locator('input[name="title"]')).toBeEditable();
await expect(page.locator('input[name="slug"]')).toBeEditable();
await expect(page.locator('input[name="length"]')).toBeEditable();
});
await test.step("Managed event type exists for added member", async () => {
// Now we need to accept the invitation as member and come back in as admin to
// assign the member in the managed event type
await adminUser.logout();
await memberUser.login();
await page.waitForSelector('[data-testid="event-types"]');
await page.goto("/teams");
await page.waitForLoadState("networkidle");
await page.locator('button[data-testid^="accept-invitation"]').click();
await page.waitForLoadState("networkidle");
await memberUser.logout();
// Coming back as team owner to assign member user to managed event
await adminUser.login();
await page.waitForLoadState("networkidle");
await page.locator('[data-testid="event-types"] a[title="managed"]').click();
await page.locator('[data-testid="vertical-tab-assignment"]').click();
await page.locator('[class$="control"]').filter({ hasText: "Select..." }).click();
await page.locator("#react-select-5-option-1").click();
await page.locator('[type="submit"]').click();
await page.waitForLoadState("networkidle");
await adminUser.logout();
// Coming back as member user to see if there is a managed event present after assignment
await memberUser.login();
await page.waitForLoadState("networkidle");
await expect(page.locator('[data-testid="event-types"] a[title="managed"]')).toBeVisible();
});
await test.step("Managed event type has locked fields for added member", async () => {
page.locator('[data-testid="event-types"] a[title="managed"]').click();
await page.waitForLoadState("networkidle");
await expect(page.locator('input[name="title"]')).not.toBeEditable();
await expect(page.locator('input[name="slug"]')).not.toBeEditable();
await expect(page.locator('input[name="length"]')).not.toBeEditable();
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@ -224,6 +224,7 @@
"done": "Done",
"all_done": "All done!",
"all_apps": "All",
"available_apps": "Available Apps",
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
"finish": "Finish",
"few_sentences_about_yourself": "A few sentences about yourself. This will appear on your personal url page.",
@ -586,6 +587,20 @@
"minutes": "Minutes",
"round_robin": "Round Robin",
"round_robin_description": "Cycle meetings between multiple team members.",
"managed_event": "Managed Event",
"username_placeholder": "username",
"managed_event_description": "Create & distribute event types in bulk to team members",
"managed": "Managed",
"managed_event_url_clarification": "\"username\" will be filled by the username of the members assigned",
"assign_to": "Assign to",
"add_members": "Add members...",
"count_members_one": "{{count}} member",
"count_members_other": "{{count}} members",
"no_assigned_members": "No assigned members",
"assigned_to": "Assigned to",
"start_assigning_members_above": "Start assigning members above",
"locked_fields_admin_description": "Members will not be able to edit this",
"locked_fields_member_description": "This option was locked by the team admin",
"url": "URL",
"hidden": "Hidden",
"readonly": "Readonly",
@ -737,9 +752,11 @@
"minimum_booking_notice": "Minimum Notice",
"slot_interval": "Time-slot intervals",
"slot_interval_default": "Use event length (default)",
"delete_event_type_description": "Are you sure you want to delete this event type? Anyone who you've shared this link with will no longer be able to book using it.",
"delete_event_type": "Delete Event Type",
"confirm_delete_event_type": "Yes, delete event type",
"delete_event_type": "Delete event type?",
"delete_managed_event_type": "Delete managed event type?",
"delete_event_type_description": "Anyone who you've shared this link with will no longer be able to book using it.",
"delete_managed_event_type_description": "<ul><li>Members assigned to this event type will also have their event types deleted.</li><li>Anyone who they've shared their link with will no longer be able to book using it.</li></ul>",
"confirm_delete_event_type": "Yes, delete",
"delete_account": "Delete account",
"confirm_delete_account": "Yes, delete account",
"delete_account_confirmation_message": "Are you sure you want to delete your {{appName}} account? Anyone who you've shared your account link with will no longer be able to book using it and any preferences you have saved will be lost.",
@ -1453,6 +1470,11 @@
"duration_limit_reached": "Duration Limit for this event type has been reached",
"admin_has_disabled": "An admin has disabled {{appName}}",
"disabled_app_affects_event_type": "An admin has disabled {{appName}} which affects your event type {{eventType}}",
"event_replaced_notice": "An admin has replaced one of your event types",
"email_subject_slug_replacement": "A team administrator has replaced your event /{{slug}}",
"email_body_slug_replacement_notice": "An administrator on the <strong>{{teamName}}</strong> team has replaced your event type <strong>/{{slug}}</strong> with a managed event type that they control.",
"email_body_slug_replacement_info": "Your link will continue to work but some settings for it may have changed. You can review it in event types.",
"email_body_slug_replacement_suggestion": "If you have any questions about the event type, please reach out to your administrator.<br /><br />Happy scheduling, <br />The Cal.com team",
"disable_payment_app": "The admin has disabled {{appName}} which affects your event type {{title}}. Attendees are still able to book this type of event but will not be prompted to pay. You may hide hide the event type to prevent this until your admin renables your payment method.",
"payment_disabled_still_able_to_book": "Attendees are still able to book this type of event but will not be prompted to pay. You may hide hide the event type to prevent this until your admin reenables your payment method.",
"app_disabled_with_event_type": "The admin has disabled {{appName}} which affects your event type {{title}}.",
@ -1667,11 +1689,31 @@
"verification_code": "Verification code",
"can_you_try_again": "Can you try again with a different time?",
"verify": "Verify",
"invalid_event_name_variables": "There is an invalid variable in your event name",
"select_all": "Select All",
"default_conferencing_bulk_title": "Bulk update existing event types",
"invalid_event_name_variables": "There is an invalid variable in your event name",
"members_default_schedule": "Member's default schedule",
"set_by_admin": "Set by team admin",
"members_default_location": "Member's default location",
"members_default_schedule_description": "We will use each members default availability schedule. They will be able to edit or change it.",
"requires_at_least_one_schedule": "You are required to have at least one schedule",
"default_conferencing_bulk_description": "Update the locations for the selected event types",
"locked_for_members": "Locked for members",
"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",
"app_not_connected": "You have not connected a {{appName}} account.",
"connect_now": "Connect now",
"managed_event_dialog_confirm_button_one": "Replace & notify {{count}} member",
"managed_event_dialog_confirm_button_other": "Replace & notify {{count}} members",
"managed_event_dialog_title_one": "The url /{{slug}} already exists for {{count}} member. Do you want to replace it?",
"managed_event_dialog_title_other": "The url /{{slug}} already exists for {{count}} members. Do you want to replace it?",
"managed_event_dialog_information_one": "<strong>{{names}}</strong> is already using the <strong>/{{slug}}</strong> url.",
"managed_event_dialog_information_other": "<strong>{{names}}</strong> are already using the <string>/{{slug}}</strong> url.",
"managed_event_dialog_clarification": "If you choose to replace it, we will notify them. Go back and remove them if you don't want to overwrite it.",
"review_event_type": "Review Event Type",
"looking_for_more_analytics": "Looking for more analytics?",
"looking_for_more_insights": "Looking for more Insights?",
"add_filter": "Add filter",
"select_user": "Select User",

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-user-check"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="8.5" cy="7" r="4"></circle><polyline points="17 11 19 13 23 9"></polyline></svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@ -0,0 +1,290 @@
import type { EventType } from "@prisma/client";
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 { prismaMock } from "../../../../tests/config/singleton";
const mockFindFirstEventType = (data?: Partial<CompleteEventType>) => {
const eventType = buildEventType(data as Partial<EventType>);
prismaMock.eventType.findFirst.mockResolvedValue(eventType as EventType);
return eventType;
};
jest.mock("@calcom/emails/email-manager", () => {
return {
sendSlugReplacementEmail: () => ({}),
};
});
jest.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: (key: string) => key,
};
});
describe("handleChildrenEventTypes", () => {
describe("Shortcircuits", () => {
it("Returns message 'No managed event type'", async () => {
mockFindFirstEventType();
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [], team: { name: "" } },
children: [],
updatedEventType: { schedulingType: null, slug: "something" },
currentUserId: 1,
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
});
expect(result.newUserIds).toEqual(undefined);
expect(result.oldUserIds).toEqual(undefined);
expect(result.deletedUserIds).toEqual(undefined);
expect(result.deletedExistentEventTypes).toEqual(undefined);
expect(result.message).toBe("No managed event type");
});
it("Returns message 'No managed event metadata'", async () => {
mockFindFirstEventType();
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [], team: { name: "" } },
children: [],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
});
expect(result.newUserIds).toEqual(undefined);
expect(result.oldUserIds).toEqual(undefined);
expect(result.deletedUserIds).toEqual(undefined);
expect(result.deletedExistentEventTypes).toEqual(undefined);
expect(result.message).toBe("No managed event metadata");
});
it("Returns message 'Missing event type'", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prismaMock.eventType.findFirst.mockImplementation(() => {
return new Promise((resolve) => {
resolve(null);
});
});
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [], team: { name: "" } },
children: [],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
});
expect(result.newUserIds).toEqual(undefined);
expect(result.oldUserIds).toEqual(undefined);
expect(result.deletedUserIds).toEqual(undefined);
expect(result.deletedExistentEventTypes).toEqual(undefined);
expect(result.message).toBe("Missing event type");
});
});
describe("Happy paths", () => {
it("Adds new users", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { schedulingType, id, teamId, timeZone, scheduleId, users, ...evType } = mockFindFirstEventType({
id: 123,
metadata: { managedEventConfig: {} },
locations: [],
});
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [], team: { name: "" } },
children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } }],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
});
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
...evType,
parentId: 1,
users: { connect: [{ id: 4 }] },
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
userId: 4,
},
});
expect(result.newUserIds).toEqual([4]);
expect(result.oldUserIds).toEqual([]);
expect(result.deletedUserIds).toEqual([]);
expect(result.deletedExistentEventTypes).toEqual(undefined);
});
it("Updates old users", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { schedulingType, id, teamId, timeZone, locations, parentId, userId, scheduleId, ...evType } =
mockFindFirstEventType({
metadata: { managedEventConfig: {} },
locations: [],
});
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: [] } }],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
hashedLink: "somestring",
connectedLink: null,
prisma: prismaMock,
});
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...evType,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
hashedLink: { create: { link: expect.any(String) } },
},
where: {
userId_parentId: {
userId: 4,
parentId: 1,
},
},
});
expect(result.newUserIds).toEqual([]);
expect(result.oldUserIds).toEqual([4]);
expect(result.deletedUserIds).toEqual([]);
expect(result.deletedExistentEventTypes).toEqual(undefined);
});
it("Deletes old users", async () => {
mockFindFirstEventType({ users: [], metadata: { managedEventConfig: {} }, locations: [] });
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
children: [],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
});
expect(result.newUserIds).toEqual([]);
expect(result.oldUserIds).toEqual([]);
expect(result.deletedUserIds).toEqual([4]);
expect(result.deletedExistentEventTypes).toEqual(undefined);
});
it("Adds new users and updates/delete old users", async () => {
mockFindFirstEventType({
metadata: { managedEventConfig: {} },
locations: [],
});
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 4 }, { userId: 1 }], 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,
});
// Have been called
expect(result.newUserIds).toEqual([5]);
expect(result.oldUserIds).toEqual([4]);
expect(result.deletedUserIds).toEqual([1]);
expect(result.deletedExistentEventTypes).toEqual(undefined);
});
});
describe("Slug conflicts", () => {
it("Deletes existent event types for new users added", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { schedulingType, id, teamId, timeZone, scheduleId, users, ...evType } = mockFindFirstEventType({
id: 123,
metadata: { managedEventConfig: {} },
locations: [],
});
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [], team: { name: "" } },
children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: ["something"] } }],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
});
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
...evType,
parentId: 1,
users: { connect: [{ id: 4 }] },
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
hashedLink: undefined,
userId: 4,
},
});
expect(result.newUserIds).toEqual([4]);
expect(result.oldUserIds).toEqual([]);
expect(result.deletedUserIds).toEqual([]);
expect(result.deletedExistentEventTypes).toEqual([123]);
});
it("Deletes existent event types for old users updated", async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { schedulingType, id, teamId, timeZone, users, locations, parentId, userId, ...evType } =
mockFindFirstEventType({
metadata: { managedEventConfig: {} },
locations: [],
});
prismaMock.eventType.deleteMany.mockResolvedValue([123] as unknown as Prisma.BatchPayload);
const result = await updateChildrenEventTypes({
eventTypeId: 1,
oldEventType: { children: [{ userId: 4 }], team: { name: "" } },
children: [{ hidden: false, owner: { id: 4, name: "", email: "", eventTypeSlugs: ["something"] } }],
updatedEventType: { schedulingType: "MANAGED", slug: "something" },
currentUserId: 1,
hashedLink: undefined,
connectedLink: null,
prisma: prismaMock,
});
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...evType,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
scheduleId: undefined,
},
where: {
userId_parentId: {
userId: 4,
parentId: 1,
},
},
});
expect(result.newUserIds).toEqual([]);
expect(result.oldUserIds).toEqual([4]);
expect(result.deletedUserIds).toEqual([]);
expect(result.deletedExistentEventTypes).toEqual([123]);
});
});
});

View File

@ -53,14 +53,17 @@ export const InstalledAppVariants = [
export const ALL_APPS = Object.values(ALL_APPS_MAP);
export function getLocationGroupedOptions(integrations: ReturnType<typeof getApps>, t: TFunction) {
const apps: Record<string, { label: string; value: string; disabled?: boolean; icon?: string }[]> = {};
const apps: Record<
string,
{ label: string; value: string; disabled?: boolean; icon?: string; slug?: string }[]
> = {};
integrations.forEach((app) => {
if (app.locationOption) {
// All apps that are labeled as a locationOption are video apps. Extract the secondary category if available
let category =
app.categories.length >= 2 ? app.categories.find((category) => category !== "video") : app.category;
if (!category) category = "video";
const option = { ...app.locationOption, icon: app.logo };
const option = { ...app.locationOption, icon: app.logo, slug: app.slug };
if (apps[category]) {
apps[category] = [...apps[category], option];
} else {

View File

@ -31,6 +31,7 @@ import OrganizerRequestReminderEmail from "./templates/organizer-request-reminde
import OrganizerRequestedToRescheduleEmail from "./templates/organizer-requested-to-reschedule-email";
import OrganizerRescheduledEmail from "./templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
import SlugReplacementEmail from "./templates/slug-replacement-email";
import type { TeamInvite } from "./templates/team-invite-email";
import TeamInviteEmail from "./templates/team-invite-email";
@ -287,6 +288,22 @@ export const sendDisabledAppEmail = async ({
await sendEmail(() => new DisabledAppEmail(email, appName, appType, t, title, eventTypeId));
};
export const sendSlugReplacementEmail = async ({
email,
name,
teamName,
t,
slug,
}: {
email: string;
name: string;
teamName: string | null;
t: TFunction;
slug: string;
}) => {
await sendEmail(() => new SlugReplacementEmail(email, name, teamName, slug, t));
};
export const sendNoShowFeeChargedEmail = async (attendee: Person, evt: CalendarEvent) => {
await sendEmail(() => new NoShowFeeChargedEmail(evt, attendee));
};

View File

@ -1,11 +1,11 @@
import { CSSProperties } from "react";
import type { CSSProperties } from "react";
import { BASE_URL, IS_PRODUCTION } from "@calcom/lib/constants";
import EmailCommonDivider from "./EmailCommonDivider";
import Row from "./Row";
export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle";
export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle" | "teamCircle";
export const getHeadImage = (headerType: BodyHeadType): string => {
switch (headerType) {
@ -21,6 +21,10 @@ export const getHeadImage = (headerType: BodyHeadType): string => {
return IS_PRODUCTION
? BASE_URL + "/emails/calendarCircle@2x.png"
: "https://app.cal.com/emails/calendarCircle@2x.png";
case "teamCircle":
return IS_PRODUCTION
? BASE_URL + "/emails/teamCircle@2x.png"
: "https://app.cal.com/emails/teamCircle@2x.png";
}
};

View File

@ -0,0 +1,80 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { CAL_URL } from "@calcom/lib/constants";
import { BaseEmailHtml, CallToAction } from "../components";
export const SlugReplacementEmail = (
props: {
slug: string;
name: string;
teamName: string;
t: TFunction;
} & Partial<React.ComponentProps<typeof BaseEmailHtml>>
) => {
const { slug, name, teamName, t } = props;
return (
<BaseEmailHtml
subject={t("email_subject_slug_replacement", { slug: slug })}
headerType="teamCircle"
title={t("event_replaced_notice")}>
<>
<Trans i18nKey="hi_user_name" name={name}>
<p style={{ fontWeight: 400, lineHeight: "24px", display: "inline-block" }}>Hi {name}</p>
<p style={{ display: "inline" }}>,</p>
</Trans>
<Trans i18nKey="email_body_slug_replacement_notice" slug={slug}>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
An administrator on the <strong>{teamName}</strong> team has replaced your event type{" "}
<strong>/{slug}</strong> with a managed event type that they control.
</p>
</Trans>
<Trans i18nKey="email_body_slug_replacement_info">
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
Your link will continue to work but somesettings for it may have changed. You can review it in
event types.
</p>
</Trans>
<table
role="presentation"
border={0}
style={{ verticalAlign: "top", marginTop: "25px" }}
width="100%">
<tbody>
<tr>
<td align="center">
<CallToAction
label={t("review_event_type")}
href={`${CAL_URL}/event-types`}
endIconName="white-arrow-right"
/>
</td>
</tr>
</tbody>
</table>
<p
style={{
borderTop: "solid 1px #E1E1E1",
fontSize: 1,
margin: "35px auto",
width: "100%",
}}
/>
<Trans i18nKey="email_body_slug_replacement_suggestion">
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
If you have any questions about the event type, please reach out to your administrator.
<br />
<br />
Happy scheduling, <br />
The Cal.com team
</p>
</Trans>
{/*<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{t("email_body_slug_replacement_suggestion")}</>
</p>*/}
</>
</BaseEmailHtml>
);
};

View File

@ -8,6 +8,7 @@ export { AttendeeWasRequestedToRescheduleEmail } from "./AttendeeWasRequestedToR
export { AttendeeRescheduledEmail } from "./AttendeeRescheduledEmail";
export { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
export { DisabledAppEmail } from "./DisabledAppEmail";
export { SlugReplacementEmail } from "./SlugReplacementEmail";
export { FeedbackEmail } from "./FeedbackEmail";
export { ForgotPasswordEmail } from "./ForgotPasswordEmail";
export { OrganizerCancelledEmail } from "./OrganizerCancelledEmail";

View File

@ -0,0 +1,42 @@
import type { TFunction } from "next-i18next";
import { renderEmail } from "..";
import BaseEmail from "./_base-email";
export default class SlugReplacementEmail extends BaseEmail {
email: string;
name: string;
teamName: string | null;
slug: string;
t: TFunction;
constructor(email: string, name: string, teamName: string | null, slug: string, t: TFunction) {
super();
this.email = email;
this.name = name;
this.teamName = teamName;
this.slug = slug;
this.t = t;
}
protected getNodeMailerPayload(): Record<string, unknown> {
return {
from: `Cal.com <${this.getMailerOptions().from}>`,
to: this.email,
subject: this.t("email_subject_slug_replacement", { slug: this.slug }),
html: renderEmail("SlugReplacementEmail", {
slug: this.slug,
name: this.name,
teamName: this.teamName || "",
t: this.t,
}),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `${this.t("email_body_slug_replacement_notice", { slug: this.slug })} ${this.t(
"email_body_slug_replacement_suggestion"
)}`;
}
}

View File

@ -0,0 +1,58 @@
import { SchedulingType } from "@prisma/client";
import { get } from "lodash";
import React from "react";
import type z from "zod";
import type { Prisma } from "@calcom/prisma/client";
import type { _EventTypeModel } from "@calcom/prisma/zod/eventtype";
import { Tooltip } from "@calcom/ui";
import { Lock } from "@calcom/ui/components/icon";
const Indicator = (label: string) => (
<Tooltip content={<>{label}</>}>
<div className="bg ml-1 -mt-0.5 inline-flex h-4 w-4 rounded-sm p-0.5">
<Lock className="text-subtle hover:text-muted h-3 w-3" />
</div>
</Tooltip>
);
const useLockedFieldsManager = (
eventType: Pick<z.infer<typeof _EventTypeModel>, "schedulingType" | "userId" | "metadata">,
adminLabel: string,
memberLabel: string
) => {
const unlockedFields =
(eventType.metadata?.managedEventConfig?.unlockedFields !== undefined &&
eventType.metadata?.managedEventConfig?.unlockedFields) ||
{};
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
const isChildrenManagedEventType =
eventType.metadata?.managedEventConfig !== undefined &&
eventType.schedulingType !== SchedulingType.MANAGED;
const shouldLockIndicator = (fieldName: string) => {
let locked = isManagedEventType || isChildrenManagedEventType;
// Supports "metadata.fieldName"
if (fieldName.includes(".")) {
locked = locked && get(unlockedFields, fieldName) === undefined;
} else {
locked = locked && unlockedFields[fieldName as keyof Omit<Prisma.EventTypeSelect, "id">] === undefined;
}
return locked && Indicator(isManagedEventType ? adminLabel : memberLabel);
};
const shouldLockDisableProps = (fieldName: string) => {
return {
disabled:
!isManagedEventType &&
eventType.metadata?.managedEventConfig !== undefined &&
unlockedFields[fieldName as keyof Omit<Prisma.EventTypeSelect, "id">] === undefined,
LockedIcon: shouldLockIndicator(fieldName),
};
};
return { shouldLockIndicator, shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType };
};
export default useLockedFieldsManager;

View File

@ -0,0 +1,294 @@
import type { PrismaClient, Prisma } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { sendSlugReplacementEmail } from "@calcom/emails/email-manager";
import { getTranslation } from "@calcom/lib/server/i18n";
import { _EventTypeModel } from "@calcom/prisma/zod";
import { allManagedEventTypeProps, unlockedManagedEventTypeProps } from "@calcom/prisma/zod-utils";
const generateHashedLink = (id: number) => {
const translator = short();
const seed = `${id}:${new Date().getTime()}`;
const uid = translator.fromUUID(uuidv5(seed, uuidv5.URL));
return uid;
};
interface handleChildrenEventTypesProps {
eventTypeId: number;
updatedEventType: {
schedulingType: SchedulingType | null;
slug: string;
};
currentUserId: number;
oldEventType: {
children?: { userId: number | null }[] | null | undefined;
team: { name: string } | null;
} | null;
hashedLink: string | undefined;
connectedLink: { id: number } | null;
children:
| {
hidden: boolean;
owner: {
id: number;
name: string;
email: string;
eventTypeSlugs: string[];
};
}[]
| undefined;
prisma: PrismaClient<
Prisma.PrismaClientOptions,
never,
Prisma.RejectOnNotFound | Prisma.RejectPerOperation | undefined
>;
}
const sendAllSlugReplacementEmails = async (
persons: { email: string; name: string }[],
slug: string,
teamName: string | null
) => {
const t = await getTranslation("en", "common");
persons.map(
async (person) =>
await sendSlugReplacementEmail({ email: person.email, name: person.name, teamName, slug, t })
);
};
const checkExistentEventTypes = async ({
updatedEventType,
children,
prisma,
userIds,
teamName,
}: Pick<handleChildrenEventTypesProps, "updatedEventType" | "children" | "prisma"> & {
userIds: number[];
teamName: string | null;
}) => {
const replaceEventType = children?.filter(
(ch) => ch.owner.eventTypeSlugs.includes(updatedEventType.slug) && userIds.includes(ch.owner.id)
);
// If so, delete their event type with the same slug to proceed to create a managed one
if (replaceEventType?.length) {
const deletedReplacedEventTypes = await prisma.eventType.deleteMany({
where: {
slug: updatedEventType.slug,
userId: {
in: replaceEventType.map((evTy) => evTy.owner.id),
},
},
});
// Sending notification after deleting
await sendAllSlugReplacementEmails(
replaceEventType.map((evTy) => evTy.owner),
updatedEventType.slug,
teamName
);
return deletedReplacedEventTypes;
}
};
export default async function handleChildrenEventTypes({
eventTypeId: parentId,
oldEventType,
updatedEventType,
hashedLink,
connectedLink,
children,
prisma,
}: handleChildrenEventTypesProps) {
// Check we are dealing with a managed event type
if (updatedEventType?.schedulingType !== SchedulingType.MANAGED)
return {
message: "No managed event type",
};
// Retrieving the updated event type
const eventType = await prisma.eventType.findFirst({
where: { id: parentId },
select: allManagedEventTypeProps,
});
// Shortcircuit when no data for old and updated event types
if (!oldEventType || !eventType)
return {
message: "Missing event type",
};
// Define what values are expected to be changed from a managed event type
const allManagedEventTypePropsZod = _EventTypeModel.pick(allManagedEventTypeProps);
const managedEventTypeValues = allManagedEventTypePropsZod
.omit(unlockedManagedEventTypeProps)
.parse(eventType);
// Check we are certainly dealing with a managed event type through its metadata
if (!managedEventTypeValues.metadata?.managedEventConfig)
return {
message: "No managed event metadata",
};
// Define the values for unlocked properties to use on creation, not updation
const unlockedEventTypeValues = allManagedEventTypePropsZod
.pick(unlockedManagedEventTypeProps)
.parse(eventType);
// Calculate if there are new/existent/deleted children users for which the event type needs to be created/updated/deleted
const previousUserIds = oldEventType.children?.flatMap((ch) => ch.userId ?? []);
const currentUserIds = children?.map((ch) => ch.owner.id);
const deletedUserIds = previousUserIds?.filter((id) => !currentUserIds?.includes(id));
const newUserIds = currentUserIds?.filter((id) => !previousUserIds?.includes(id));
const oldUserIds = currentUserIds?.filter((id) => previousUserIds?.includes(id));
// Define hashedLink query input
const hashedLinkQuery = (userId: number) => {
return hashedLink
? !connectedLink
? { create: { link: generateHashedLink(userId) } }
: undefined
: connectedLink
? { delete: true }
: undefined;
};
// Store result for existent event types deletion process
let deletedExistentEventTypes = undefined;
// New users added
if (newUserIds?.length) {
// Check if there are children with existent homonym event types to send notifications
deletedExistentEventTypes = await checkExistentEventTypes({
updatedEventType,
children,
prisma,
userIds: newUserIds,
teamName: oldEventType.team?.name ?? null,
});
// Create event types for new users added
await prisma.$transaction(
newUserIds.map((userId) => {
return prisma.eventType.create({
data: {
...managedEventTypeValues,
...unlockedEventTypeValues,
bookingLimits:
(managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined,
recurringEvent:
(managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined,
metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined,
bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined,
durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined,
userId,
users: {
connect: [{ id: userId }],
},
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 })),
},
},
webhooks: eventType.webhooks && {
createMany: {
data: eventType.webhooks?.map((wh) => ({ ...wh, eventTypeId: undefined })),
},
},*/
hashedLink: hashedLinkQuery(userId),
},
});
})
);
}
// Old users updated
if (oldUserIds?.length) {
// Check if there are children with existent homonym event types to send notifications
deletedExistentEventTypes = await checkExistentEventTypes({
updatedEventType,
children,
prisma,
userIds: oldUserIds,
teamName: oldEventType.team?.name || null,
});
// Update event types for old users
await prisma.$transaction(
oldUserIds.map((userId) => {
return prisma.eventType.update({
where: {
userId_parentId: {
userId,
parentId,
},
},
data: {
...managedEventTypeValues,
hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false,
bookingLimits:
(managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined,
recurringEvent:
(managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined,
metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined,
bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined,
durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined,
hashedLink: hashedLinkQuery(userId),
},
});
})
);
// 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)
);
const updatedOldWebhooks = await prisma.webhook.updateMany({
where: {
userId: {
in: oldUserIds,
},
},
data: {
...eventType.webhooks,
},
});
console.log(
"handleChildrenEventTypes:updatedOldWebhooks",
JSON.stringify({ updatedOldWebhooks }, null, 2)
);*/
}
// Old users deleted
if (deletedUserIds?.length) {
// Delete event types for deleted users
await prisma.eventType.deleteMany({
where: {
userId: {
in: deletedUserIds,
},
parentId,
},
});
}
return { newUserIds, oldUserIds, deletedUserIds, deletedExistentEventTypes };
}

View File

@ -141,6 +141,7 @@ const PendingMemberItem = (props: { member: TeamMember; index: number; teamId: n
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.eventTypes.invalidate();
showToast("Member removed", "success");
},
async onError(err) {

View File

@ -56,6 +56,7 @@ export default function MemberListItem(props: Props) {
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.eventTypes.invalidate();
showToast(t("success"), "success");
},
async onError(err) {

View File

@ -153,6 +153,7 @@ export default function TeamListItem(props: Props) {
<Button
type="button"
color="secondary"
data-testid={`accept-invitation-${team.id}`}
StartIcon={Check}
className="ms-2 me-2"
onClick={acceptInvite}>

View File

@ -106,6 +106,7 @@ const ProfileView = () => {
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.list.invalidate();
await utils.viewer.eventTypes.invalidate();
showToast(t("success"), "success");
},
async onError(err) {

View File

@ -3,12 +3,14 @@ import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, showToast, Switch, Tooltip } from "@calcom/ui";
import { ExternalLink, Zap } from "@calcom/ui/components/icon";
import { Button, EmptyScreen, showToast, Switch, Tooltip, Alert } from "@calcom/ui";
import { ExternalLink, Zap, Lock } from "@calcom/ui/components/icon";
import LicenseRequired from "../../common/components/v2/LicenseRequired";
import { getActionIcon } from "../lib/getActionIcon";
@ -151,15 +153,10 @@ const WorkflowListItem = (props: ItemProps) => {
);
};
type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
type Props = {
eventType: {
id: number;
title: string;
userId: number | null;
team: {
id?: number;
} | null;
};
eventType: EventTypeSetup;
workflows: WorkflowType[];
};
@ -207,33 +204,58 @@ function EventWorkflowsTab(props: Props) {
},
});
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
return (
<LicenseRequired>
{!isLoading ? (
data?.workflows && data?.workflows.length > 0 ? (
<div className="space-y-4">
{sortedWorkflows.map((workflow) => {
return <WorkflowListItem key={workflow.id} workflow={workflow} eventType={props.eventType} />;
})}
</div>
) : (
<div className="pt-4 before:border-0">
<EmptyScreen
Icon={Zap}
headline={t("workflows")}
description={t("no_workflows_description")}
buttonRaw={
<Button
target="_blank"
color="secondary"
onClick={() => createMutation.mutate({ teamId: eventType.team?.id })}
loading={createMutation.isLoading}>
{t("create_workflow")}
</Button>
}
<>
{isManagedEventType && (
<Alert
severity="neutral"
title={t("locked_for_members")}
message={t("locked_workflows_description")}
/>
</div>
)
)}
{data?.workflows && data?.workflows.length > 0 ? (
<div>
<div className="space-y-4">
{sortedWorkflows.map((workflow) => {
return (
<WorkflowListItem key={workflow.id} workflow={workflow} eventType={props.eventType} />
);
})}
</div>
</div>
) : (
<div className="pt-2 before:border-0">
<EmptyScreen
Icon={Zap}
headline={t("workflows")}
description={t("no_workflows_description")}
buttonRaw={
isChildrenManagedEventType && !isManagedEventType ? (
<Button StartIcon={Lock} color="secondary" disabled>
{t("locked_by_admin")}
</Button>
) : (
<Button
target="_blank"
color="secondary"
onClick={() => createMutation.mutate({ teamId: eventType.team?.id })}
loading={createMutation.isLoading}>
{t("create_workflow")}
</Button>
)
}
/>
</div>
)}
</>
) : (
<SkeletonLoader />
)}

View File

@ -1,5 +1,5 @@
import type { WorkflowActions } from "@prisma/client";
import { WorkflowTemplates } from "@prisma/client";
import { WorkflowTemplates, SchedulingType } from "@prisma/client";
import { useRouter } from "next/router";
import type { Dispatch, SetStateAction } from "react";
import { useMemo, useState } from "react";
@ -48,10 +48,15 @@ export default function WorkflowDetailsPage(props: Props) {
if (teamId && teamId !== group.teamId) return options;
return [
...options,
...group.eventTypes.map((eventType) => ({
value: String(eventType.id),
label: eventType.title,
})),
...group.eventTypes
.filter(
(evType) =>
!evType.metadata?.managedEventConfig && evType.schedulingType !== SchedulingType.MANAGED
)
.map((eventType) => ({
value: String(eventType.id),
label: eventType.title,
})),
];
}, [] as Option[]) || [],
[data]

View File

@ -0,0 +1,81 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { Props } from "react-select";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar, EmptyScreen, Label, Select } from "@calcom/ui";
import { FiUserPlus, FiX } from "@calcom/ui/components/icon";
export type CheckedUserSelectOption = {
avatar: string;
label: string;
value: string;
disabled?: boolean;
};
export const CheckedUserSelect = ({
options = [],
value = [],
...props
}: Omit<Props<CheckedUserSelectOption, true>, "value" | "onChange"> & {
value?: readonly CheckedUserSelectOption[];
onChange: (value: readonly CheckedUserSelectOption[]) => void;
}) => {
const { t } = useLocale();
const [animationRef] = useAutoAnimate<HTMLUListElement>();
return (
<>
<Select
styles={{
option: (styles, { isDisabled }) => ({
...styles,
backgroundColor: isDisabled ? "#F5F5F5" : "inherit",
}),
}}
name={props.name}
placeholder={props.placeholder || t("select")}
isSearchable={false}
options={options}
value={value}
isMulti
{...props}
/>
{value.length > 0 ? (
<div className="mt-6">
<Label>{t("assigned_to")}</Label>
<div className="flex overflow-hidden rounded-md border border-gray-200 bg-white">
<ul className="w-full" data-testid="managed-event-types" ref={animationRef}>
{value.map((option, index) => {
const calLink = `${CAL_URL}/${option.value}`;
return (
<li
key={option.value}
className={`flex py-2 px-3 ${index === value.length - 1 ? "" : "border-b"}`}>
<Avatar size="sm" imageSrc={option.avatar} alt={option.label} />
<p className="my-auto ml-3 text-sm text-gray-900">{option.label}</p>
<FiX
onClick={() => props.onChange(value.filter((item) => item.value !== option.value))}
className="my-auto ml-auto"
/>
</li>
);
})}
</ul>
</div>
</div>
) : (
<div className="mt-6">
<EmptyScreen
Icon={FiUserPlus}
headline={t("no_assigned_members")}
description={t("start_assigning_members_above")}
/>
</div>
)}
</>
);
};
export default CheckedUserSelect;

View File

@ -0,0 +1,140 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { MembershipRole } from "@prisma/client";
import type { Props } from "react-select";
import { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Avatar, Badge, Button, ButtonGroup, Select, Switch, Tooltip } from "@calcom/ui";
import { ExternalLink, X } from "@calcom/ui/components/icon";
export type ChildrenEventType = {
value: string;
label: string;
created: boolean;
owner: {
id: number;
email: string;
name: string;
username: string;
membership: MembershipRole;
eventTypeSlugs: string[];
};
slug: string;
hidden: boolean;
};
export const ChildrenEventTypeSelect = ({
options = [],
value = [],
...props
}: Omit<Props<ChildrenEventType, true>, "value" | "onChange"> & {
value?: ChildrenEventType[];
onChange: (value: readonly ChildrenEventType[]) => void;
}) => {
const { t } = useLocale();
const [animationRef] = useAutoAnimate<HTMLUListElement>();
return (
<>
<Select
styles={{
option: (styles, { isDisabled }) => ({
...styles,
backgroundColor: isDisabled ? "#F5F5F5" : "inherit",
}),
}}
name={props.name}
placeholder={t("select")}
isSearchable={false}
options={options}
value={value}
isMulti
{...props}
/>
{/* This class name conditional looks a bit odd but it allows a seemless transition when using autoanimate
- Slides down from the top instead of just teleporting in from nowhere*/}
<ul
className={classNames(
"border-subtle divide-subtle mt-3 divide-y rounded-md",
value.length >= 1 && "border"
)}
ref={animationRef}>
{value.map((children, index) => (
<li key={index}>
<div className="flex flex-row items-center gap-3 p-3">
<Avatar
size="mdLg"
imageSrc={`${CAL_URL}/${children.owner.username}/avatar.png`}
alt={children.owner.name || ""}
/>
<div className="flex w-full flex-row justify-between">
<div className="flex flex-col">
<span className="text text-sm font-semibold leading-none">
{children.owner.name}
{children.owner.membership === MembershipRole.OWNER ? (
<Badge className="ml-2" variant="gray">
{t("owner")}
</Badge>
) : (
<Badge className="ml-2" variant="gray">
{t("member")}
</Badge>
)}
</span>
<small className="text-subtle font-normal leading-normal">
{`/${children.owner.username}/${children.slug}`}
</small>
</div>
<div className="flex flex-row items-center gap-2">
{children.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
<Tooltip content={t("show_eventtype_on_profile")}>
<div className="self-center rounded-md p-2">
<Switch
name="Hidden"
checked={!children.hidden}
onCheckedChange={(checked) => {
const newData = value.map((item) =>
item.owner.id === children.owner.id ? { ...item, hidden: !checked } : item
);
props.onChange(newData);
}}
/>
</div>
</Tooltip>
<ButtonGroup combined>
{children.created && (
<Tooltip content={t("preview")}>
<Button
color="secondary"
target="_blank"
variant="icon"
href={`${CAL_URL}/${children.owner?.username}/${children.slug}`}
StartIcon={ExternalLink}
/>
</Tooltip>
)}
<Tooltip content={t("delete")}>
<Button
color="secondary"
target="_blank"
variant="icon"
onClick={() =>
props.onChange(value.filter((item) => item.owner.id !== children.owner.id))
}
StartIcon={X}
/>
</Tooltip>
</ButtonGroup>
</div>
</div>
</div>
</li>
))}
</ul>
</>
);
};
export default ChildrenEventTypeSelect;

View File

@ -1,16 +1,21 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { SchedulingType } from "@prisma/client";
import { MembershipRole } from "@prisma/client";
import { isValidPhoneNumber } from "libphonenumber-js";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
import { HttpError } from "@calcom/lib/http-error";
import { md } from "@calcom/lib/markdownIt";
import slugify from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import { unlockedManagedEventTypeProps } from "@calcom/prisma/zod-utils";
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
import { trpc } from "@calcom/trpc/react";
import {
@ -29,6 +34,7 @@ import {
// this describes the uniform data needed to create a new event type on Profile or Team
export interface EventTypeParent {
teamId: number | null | undefined; // if undefined, then it's a profile
membershipRole?: MembershipRole | null;
name?: string | null;
slug?: string | null;
image?: string | null;
@ -61,13 +67,23 @@ const querySchema = z.object({
.optional(),
});
export default function CreateEventTypeDialog() {
export default function CreateEventTypeDialog({
profileOptions,
}: {
profileOptions: {
teamId: number | null | undefined;
label: string | null;
image: string | undefined;
membershipRole: MembershipRole | null | undefined;
}[];
}) {
const { t } = useLocale();
const router = useRouter();
const {
data: { teamId, eventPage: pageSlug },
} = useTypedQuery(querySchema);
const teamProfile = profileOptions.find((profile) => profile.teamId === teamId);
const form = useForm<z.infer<typeof createEventTypeInput>>({
defaultValues: {
@ -76,8 +92,24 @@ export default function CreateEventTypeDialog() {
resolver: zodResolver(createEventTypeInput),
});
const schedulingTypeWatch = form.watch("schedulingType");
const isManagedEventType = schedulingTypeWatch === SchedulingType.MANAGED;
useEffect(() => {
if (isManagedEventType) {
form.setValue("metadata.managedEventConfig.unlockedFields", unlockedManagedEventTypeProps);
} else {
form.setValue("metadata", null);
}
}, [schedulingTypeWatch]);
const { register } = form;
const isAdmin =
teamId !== undefined &&
(teamProfile?.membershipRole === MembershipRole.OWNER ||
teamProfile?.membershipRole === MembershipRole.ADMIN);
const createMutation = trpc.viewer.eventTypes.create.useMutation({
onSuccess: async ({ eventType }) => {
await router.replace("/event-types/" + eventType.id);
@ -101,6 +133,8 @@ export default function CreateEventTypeDialog() {
},
});
const flags = useFlagMap();
return (
<Dialog
name="new"
@ -147,52 +181,67 @@ export default function CreateEventTypeDialog() {
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
<TextField
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
required
addOnLeading={<>/{pageSlug}/</>}
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
}}
/>
<div>
<TextField
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
required
addOnLeading={<>/{!isManagedEventType ? pageSlug : t("username_placeholder")}/</>}
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
}}
/>
{isManagedEventType && (
<p className="mt-2 text-sm text-gray-600">{t("managed_event_url_clarification")}</p>
)}
</div>
) : (
<TextField
label={t("url")}
required
addOnLeading={
<>
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
</>
}
{...register("slug")}
/>
<div>
<TextField
label={t("url")}
required
addOnLeading={
<>
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
{!isManagedEventType ? pageSlug : t("username_placeholder")}/
</>
}
{...register("slug")}
/>
{isManagedEventType && (
<p className="mt-2 text-sm text-gray-600">{t("managed_event_url_clarification")}</p>
)}
</div>
)}
{!teamId && (
<>
<Editor
getText={() => md.render(form.getValues("description") || "")}
setText={(value: string) => form.setValue("description", turndown(value))}
excludedToolbarItems={["blockType", "link"]}
placeholder={t("quick_video_meeting")}
/>
<Editor
getText={() => md.render(form.getValues("description") || "")}
setText={(value: string) => form.setValue("description", turndown(value))}
excludedToolbarItems={["blockType", "link"]}
placeholder={t("quick_video_meeting")}
/>
<div className="relative">
<TextField
type="number"
required
min="10"
placeholder="15"
label={t("length")}
className="pr-4"
{...register("length", { valueAsNumber: true })}
addOnSuffix={t("minutes")}
/>
</div>
<div className="relative">
<TextField
type="number"
required
min="10"
placeholder="15"
label={t("length")}
className="pr-4"
{...register("length", { valueAsNumber: true })}
addOnSuffix={t("minutes")}
/>
</div>
</>
)}
{teamId && (
<div className="mb-4">
<label htmlFor="schedulingType" className="text-default block text-sm font-bold">
{t("scheduling_type")}
{t("assignment")}
</label>
{form.formState.errors.schedulingType && (
<Alert
@ -201,21 +250,39 @@ export default function CreateEventTypeDialog() {
message={form.formState.errors.schedulingType.message}
/>
)}
<RadioArea.Group className="mt-1 flex space-x-4">
<RadioArea.Group
className={classNames(
"mt-1 flex gap-4",
isAdmin && flags["managed-event-types"] && "flex-col"
)}>
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.COLLECTIVE}
className="w-1/2 text-sm">
className={classNames("w-full text-sm", !isAdmin && "w-1/2")}
classNames={{ container: classNames(isAdmin && "w-full") }}>
<strong className="mb-1 block">{t("collective")}</strong>
<p>{t("collective_description")}</p>
</RadioArea.Item>
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.ROUND_ROBIN}
className="w-1/2 text-sm">
className={classNames("text-sm", !isAdmin && "w-1/2")}
classNames={{ container: classNames(isAdmin && "w-full") }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_description")}</p>
</RadioArea.Item>
<>
{isAdmin && flags["managed-event-types"] && (
<RadioArea.Item
{...register("schedulingType")}
value={SchedulingType.MANAGED}
className={classNames("text-sm", !isAdmin && "w-1/2")}
classNames={{ container: classNames(isAdmin && "w-full") }}>
<strong className="mb-1 block">{t("managed_event")}</strong>
<p>{t("managed_event_description")}</p>
</RadioArea.Item>
)}
</>
</RadioArea.Group>
</div>
)}

View File

@ -10,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { baseEventTypeSelect } from "@calcom/prisma";
import type { EventTypeModel } from "@calcom/prisma/zod";
import { Badge } from "@calcom/ui";
import { Clock, Users, RefreshCw, CreditCard, Clipboard, Plus, User } from "@calcom/ui/components/icon";
import { Clock, Users, RefreshCw, CreditCard, Clipboard, Plus, User, Lock } from "@calcom/ui/components/icon";
export type EventTypeDescriptionProps = {
eventType: Pick<
@ -23,12 +23,14 @@ export type EventTypeDescriptionProps = {
};
className?: string;
shortenDescription?: boolean;
isPublic?: boolean;
};
export const EventTypeDescription = ({
eventType,
className,
shortenDescription,
isPublic,
}: EventTypeDescriptionProps) => {
const { t } = useLocale();
@ -69,7 +71,7 @@ export const EventTypeDescription = ({
</Badge>
</li>
)}
{eventType.schedulingType && (
{eventType.schedulingType && eventType.schedulingType !== SchedulingType.MANAGED && (
<li>
<Badge variant="gray" startIcon={Users}>
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
@ -77,6 +79,11 @@ export const EventTypeDescription = ({
</Badge>
</li>
)}
{eventType.metadata?.managedEventConfig && !isPublic && (
<Badge variant="gray" startIcon={Lock}>
{t("managed")}
</Badge>
)}
{recurringEvent?.count && recurringEvent.count > 0 && (
<li className="hidden xl:block">
<Badge variant="gray" startIcon={RefreshCw}>

View File

@ -1,6 +1,6 @@
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge, List, ListItem, ListItemText, ListItemTitle, Switch } from "@calcom/ui";
import { Badge, List, ListItem, ListItemText, ListItemTitle, Switch, showToast } from "@calcom/ui";
export const FlagAdminList = () => {
const [data] = trpc.viewer.features.list.useSuspenseQuery();
@ -12,7 +12,7 @@ export const FlagAdminList = () => {
<ListItemTitle component="h3">
{flag.slug}
&nbsp;&nbsp;
<Badge variant="green">{flag.type}</Badge>
<Badge variant="green">{flag.type?.replace("_", " ")}</Badge>
</ListItemTitle>
<ListItemText component="p">{flag.description}</ListItemText>
</div>
@ -34,6 +34,7 @@ const FlagToggle = (props: { flag: Flag }) => {
const utils = trpc.useContext();
const mutation = trpc.viewer.features.toggle.useMutation({
onSuccess: () => {
showToast("Flags successfully updated", "success");
utils.viewer.features.list.invalidate();
utils.viewer.features.map.invalidate();
},

View File

@ -9,4 +9,5 @@ export type AppFlags = {
webhooks: boolean;
workflows: boolean;
"v2-booking-page": boolean;
"managed-event-types": boolean;
};

View File

@ -1,6 +1,8 @@
import { trpc } from "@calcom/trpc/react";
export function useFlags() {
const query = trpc.viewer.features.map.useQuery(undefined, { initialData: {} });
const query = trpc.viewer.features.map.useQuery(undefined, {
initialData: process.env.NEXT_PUBLIC_IS_E2E ? { "managed-event-types": true, teams: true } : {},
});
return query.data;
}

View File

@ -44,12 +44,16 @@ export const FormBuilder = function FormBuilder({
description,
addFieldLabel,
formProp,
disabled,
LockedIcon,
dataStore,
}: {
formProp: string;
title: string;
description: string;
addFieldLabel: string;
disabled: boolean;
LockedIcon: false | JSX.Element;
/**
* A readonly dataStore that is used to lookup the options for the fields. It works in conjunction with the field.getOptionAt property which acts as the key in options
*/
@ -271,9 +275,12 @@ export const FormBuilder = function FormBuilder({
return (
<div>
<div>
<div className="text-default text-sm font-semibold ltr:mr-1 rtl:ml-1">{title}</div>
<div className="text-default text-sm font-semibold ltr:mr-1 rtl:ml-1">
{title}
{LockedIcon}
</div>
<p className="text-subtle max-w-[280px] break-words py-1 text-sm sm:max-w-[500px]">{description}</p>
<ul className="border-default divide-subtle mt-2 divide-y-2 rounded-md border ">
<ul className="border-default divide-subtle mt-2 divide-y rounded-md border">
{fields.map((field, index) => {
const options = field.options
? field.options
@ -309,22 +316,27 @@ export const FormBuilder = function FormBuilder({
key={field.name}
data-testid={`field-${field.name}`}
className="hover:bg-muted group relative flex items-center justify-between p-4 ">
{index >= 1 && (
<button
type="button"
className="bg-default text-muted hover:text-emphasis disabled:hover:text-muted border-default hover:border-emphasis invisible absolute -left-[12px] -mt-4 mb-4 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:shadow disabled:hover:border-inherit disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
onClick={() => swap(index, index - 1)}>
<ArrowUp className="h-5 w-5" />
</button>
)}
{index < fields.length - 1 && (
<button
type="button"
className="bg-default text-muted hover:border-emphasis border-default hover:text-emphasis disabled:hover:text-muted invisible absolute -left-[12px] mt-8 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:shadow disabled:hover:border-inherit disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
onClick={() => swap(index, index + 1)}>
<ArrowDown className="h-5 w-5" />
</button>
{!disabled && (
<>
{index >= 1 && (
<button
type="button"
className="bg-default text-muted hover:text-emphasis disabled:hover:text-muted border-default hover:border-emphasis invisible absolute -left-[12px] -mt-4 mb-4 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:shadow disabled:hover:border-inherit disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
onClick={() => swap(index, index - 1)}>
<ArrowUp className="h-5 w-5" />
</button>
)}
{index < fields.length - 1 && (
<button
type="button"
className="bg-default text-muted hover:border-emphasis border-default hover:text-emphasis disabled:hover:text-muted invisible absolute -left-[12px] mt-8 -ml-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:shadow disabled:hover:border-inherit disabled:hover:shadow-none group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex"
onClick={() => swap(index, index + 1)}>
<ArrowDown className="h-5 w-5" />
</button>
)}
</>
)}
<div>
<div className="flex flex-col lg:flex-row lg:items-center">
<div className="text-default text-sm font-semibold ltr:mr-2 rtl:ml-2">
@ -349,9 +361,9 @@ export const FormBuilder = function FormBuilder({
{fieldType.label}
</p>
</div>
{field.editable !== "user-readonly" && (
{field.editable !== "user-readonly" && !disabled && (
<div className="flex items-center space-x-2">
{!isFieldEditableSystem && (
{!isFieldEditableSystem && !disabled && (
<Switch
data-testid="toggle-field"
disabled={isFieldEditableSystem}
@ -388,9 +400,16 @@ export const FormBuilder = function FormBuilder({
);
})}
</ul>
<Button color="minimal" data-testid="add-field" onClick={addField} className="mt-4" StartIcon={Plus}>
{addFieldLabel}
</Button>
{!disabled && (
<Button
color="minimal"
data-testid="add-field"
onClick={addField}
className="mt-4"
StartIcon={Plus}>
{addFieldLabel}
</Button>
)}
</div>
<Dialog
open={fieldDialog.isOpen}

View File

@ -30,6 +30,7 @@ type WebhookProps = {
export default function WebhookListItem(props: {
webhook: WebhookProps;
canEditWebhook?: boolean;
onEditWebhook: () => void;
lastItem: boolean;
}) {
@ -81,6 +82,7 @@ export default function WebhookListItem(props: {
<div className="ml-2 flex items-center space-x-4">
<Switch
defaultChecked={webhook.active}
disabled={!props.canEditWebhook}
onCheckedChange={() =>
toggleWebhook.mutate({
id: webhook.id,

View File

@ -1,4 +1,6 @@
import type { PrismaClient } from "@prisma/client";
import { MembershipRole } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { StripeData } from "@calcom/app-store/stripepayment/lib/server";
@ -114,7 +116,14 @@ export default async function getEventTypeById({
select: {
role: true,
user: {
select: userSelect,
select: {
...userSelect,
eventTypes: {
select: {
slug: true,
},
},
},
},
},
},
@ -127,6 +136,7 @@ export default async function getEventTypeById({
schedule: {
select: {
id: true,
name: true,
},
},
hosts: {
@ -137,6 +147,20 @@ export default async function getEventTypeById({
},
userId: true,
price: true,
children: {
select: {
owner: {
select: {
name: true,
username: true,
email: true,
id: true,
},
},
hidden: true,
slug: true,
},
},
destinationCalendar: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
@ -235,12 +259,31 @@ export default async function getEventTypeById({
const eventType = {
...restEventType,
schedule: rawEventType.schedule?.id || rawEventType.users[0]?.defaultScheduleId || null,
scheduleName: rawEventType.schedule?.name || null,
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
bookingLimits: parseBookingLimit(restEventType.bookingLimits),
durationLimits: parseDurationLimit(restEventType.durationLimits),
locations: locations as unknown as LocationObject[],
metadata: parsedMetaData,
customInputs: parsedCustomInputs,
users: rawEventType.users,
children: restEventType.children.flatMap((ch) =>
ch.owner !== null
? {
...ch,
owner: {
...ch.owner,
email: ch.owner.email,
name: ch.owner.name ?? "",
username: ch.owner.username ?? "",
membership:
restEventType.team?.members.find((tm) => tm.user.id === ch.owner?.id)?.role ||
MembershipRole.MEMBER,
},
created: true,
}
: []
),
};
// backwards compat
@ -267,6 +310,18 @@ export default async function getEventTypeById({
const t = await getTranslation(currentUser?.locale ?? "en", "common");
const integrations = await getEnabledApps(credentials);
const locationOptions = getLocationGroupedOptions(integrations, t);
if (eventType.schedulingType === SchedulingType.MANAGED) {
locationOptions.splice(0, 0, {
label: t("default"),
options: [
{
label: t("members_default_location"),
value: "",
icon: "/user-check.svg",
},
],
});
}
const eventTypeObject = Object.assign({}, eventType, {
periodStartDate: eventType.periodStartDate?.toString() ?? null,
@ -278,7 +333,7 @@ export default async function getEventTypeById({
? eventTypeObject.team.members.map((member) => {
const user = member.user;
user.avatar = `${CAL_URL}/${user.username}/avatar.png`;
return user;
return { ...user, eventTypes: user.eventTypes.map((evTy) => evTy.slug), membership: member.role };
})
: [];

View File

@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { Prisma, SchedulingType } from "@prisma/client";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@ -39,6 +39,9 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
eventTypes: {
where: {
hidden: false,
schedulingType: {
not: SchedulingType.MANAGED,
},
},
select: {
users: {

View File

@ -22,6 +22,7 @@ export const telemetryEventTypes = {
website: {
pageView: "website_page_view",
},
slugReplacementAction: "slug_replacement_action",
};
export function collectPageParameters(

View File

@ -98,7 +98,8 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
slotInterval: null,
metadata: null,
successRedirectUrl: null,
bookingFields: null,
bookingFields: [],
parentId: null,
...eventType,
};
};

View File

@ -0,0 +1,17 @@
/*
Warnings:
- A unique constraint covering the columns `[userId,parentId]` on the table `EventType` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterEnum
ALTER TYPE "SchedulingType" ADD VALUE 'managed';
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "parentId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "EventType_userId_parentId_key" ON "EventType"("userId", "parentId");
-- AddForeignKey
ALTER TABLE "EventType" ADD CONSTRAINT "EventType_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "EventType"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,9 @@
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'managed-event-types',
true,
'Enable creating & distributing event types in bulk to team members for this instance',
'OPERATIONAL'
) ON CONFLICT (slug) DO NOTHING;

View File

@ -21,6 +21,7 @@ generator zod {
enum SchedulingType {
ROUND_ROBIN @map("roundRobin")
COLLECTIVE @map("collective")
MANAGED @map("managed")
}
enum PeriodType {
@ -64,6 +65,9 @@ model EventType {
destinationCalendar DestinationCalendar?
eventName String?
customInputs EventTypeCustomInput[]
parentId Int?
parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade)
children EventType[] @relation("managed_eventtype")
/// @zod.custom(imports.eventTypeBookingFields)
bookingFields Json?
timeZone String?
@ -103,6 +107,7 @@ model EventType {
@@unique([userId, slug])
@@unique([teamId, slug])
@@unique([userId, parentId])
}
model Credential {

View File

@ -1,5 +1,7 @@
import type { Prisma } from "@prisma/client";
import { EventTypeCustomInputType } from "@prisma/client";
import type { UnitTypeLongPlural } from "dayjs";
import { pick } from "lodash";
import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
/* eslint-disable no-underscore-dangle */
@ -39,6 +41,11 @@ export const EventTypeMetaDataSchema = z
apps: z.object(appDataSchemas).partial().optional(),
additionalNotesRequired: z.boolean().optional(),
disableSuccessPage: z.boolean().optional(),
managedEventConfig: z
.object({
unlockedFields: z.custom<{ [k in keyof Omit<Prisma.EventTypeSelect, "id">]: true }>().optional(),
})
.optional(),
requiresConfirmationThreshold: z
.object({
time: z.number(),
@ -442,3 +449,51 @@ export const getAccessLinkResponseSchema = z.object({
});
export type GetAccessLinkResponseSchema = z.infer<typeof getAccessLinkResponseSchema>;
// All properties within event type that can and will be updated if needed
export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect, "id">]: true } = {
title: true,
description: true,
currency: true,
periodDays: true,
position: true,
price: true,
slug: true,
length: true,
locations: true,
hidden: true,
availability: true,
recurringEvent: true,
customInputs: true,
disableGuests: true,
requiresConfirmation: true,
eventName: true,
metadata: true,
children: true,
hideCalendarNotes: true,
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
successRedirectUrl: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
periodType: true,
hashedLink: true,
webhooks: true,
periodStartDate: true,
periodEndDate: true,
destinationCalendar: true,
periodCountCalendarDays: true,
bookingLimits: true,
slotInterval: true,
schedule: true,
workflows: true,
bookingFields: true,
durationLimits: true,
};
// All properties that are defined as unlocked based on all managed props
// Eventually this is going to be just a default and the user can change the config through the UI
export const unlockedManagedEventTypeProps = {
...pick(allManagedEventTypeProps, ["locations", "schedule", "destinationCalendar"]),
};

View File

@ -13,6 +13,7 @@ export const createEventTypeInput = z.object({
teamId: z.number().int().nullish(),
schedulingType: z.nativeEnum(SchedulingType).nullish(),
locations: imports.eventTypeLocations,
metadata: imports.EventTypeMetaDataSchema.optional(),
})
.partial({ hidden: true, locations: true })
.refine((data) => (data.teamId ? data.teamId && data.schedulingType : true), {

View File

@ -74,6 +74,7 @@ export const availabilityRouter = router({
.input(
z.object({
scheduleId: z.optional(z.number()),
isManagedEventType: z.optional(z.boolean()),
})
)
.query(async ({ ctx, input }) => {
@ -97,7 +98,7 @@ export const availabilityRouter = router({
},
},
});
if (!schedule || schedule.userId !== user.id) {
if (!schedule || (schedule.userId !== user.id && !input.isManagedEventType)) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
@ -112,6 +113,7 @@ export const availabilityRouter = router({
return {
id: schedule.id,
name: schedule.name,
isManaged: schedule.userId !== user.id,
workingHours: getWorkingHours(
{ timeZone: schedule.timeZone || undefined },
schedule.availability || []

View File

@ -9,6 +9,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
import { DailyLocationType } from "@calcom/app-store/locations";
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
import getApps, { getAppFromLocationValue, getAppFromSlug } from "@calcom/app-store/utils";
import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes";
import { validateIntervalLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import getEventTypeById from "@calcom/lib/getEventTypeById";
@ -20,7 +21,6 @@ import { eventTypeLocations as eventTypeLocationsSchema } from "@calcom/prisma/z
import {
customInputSchema,
EventTypeMetaDataSchema,
stringOrNumber,
userMetadata as userMetadataSchema,
} from "@calcom/prisma/zod-utils";
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
@ -88,7 +88,19 @@ const EventTypeUpdateInput = _EventTypeModel
integration: true,
externalId: true,
}),
users: z.array(stringOrNumber).optional(),
children: z
.array(
z.object({
owner: z.object({
id: z.number(),
name: z.string(),
email: z.string(),
eventTypeSlugs: z.array(z.string()),
}),
hidden: z.boolean(),
})
)
.optional(),
hosts: z
.array(
z.object({
@ -124,7 +136,7 @@ const eventOwnerProcedure = authedProcedure
.input(
z.object({
id: z.number(),
users: z.array(z.string()).optional().default([]),
users: z.array(z.number()).optional().default([]),
})
)
.use(async ({ ctx, input, next }) => {
@ -168,9 +180,9 @@ const eventOwnerProcedure = authedProcedure
const isAllowed = (function () {
if (event.team) {
const allTeamMembers = event.team.members.map((member) => member.userId);
return input.users.every((userId: string) => allTeamMembers.includes(Number.parseInt(userId)));
return input.users.every((userId: number) => allTeamMembers.includes(userId));
}
return input.users.every((userId: string) => Number.parseInt(userId) === ctx.user.id);
return input.users.every((userId: number) => userId === ctx.user.id);
})();
if (!isAllowed) {
@ -193,6 +205,7 @@ export const eventTypesRouter = router({
hashedLink: true,
locations: true,
destinationCalendar: true,
userId: true,
team: {
select: {
id: true,
@ -207,6 +220,11 @@ export const eventTypesRouter = router({
users: {
select: baseUserSelect,
},
children: {
include: {
users: true,
},
},
hosts: {
select: {
user: {
@ -286,8 +304,7 @@ export const eventTypesRouter = router({
...eventType,
safeDescription: markdownToSafeHTML(eventType.description),
users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users,
// @FIXME: cc @hariombalhara This is failing with production data
// metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : undefined,
});
const userEventTypes = user.eventTypes.map(mapEventType);
@ -311,6 +328,7 @@ export const eventTypesRouter = router({
type EventTypeGroup = {
teamId?: number | null;
membershipRole?: MembershipRole | null;
profile: {
slug: (typeof user)["username"];
name: (typeof user)["name"];
@ -329,9 +347,12 @@ export const eventTypesRouter = router({
hashMap[newItem.id] = { ...oldItem, ...newItem };
return hashMap;
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
const mergedEventTypes = Object.values(eventTypesHashMap).map((eventType) => eventType);
const mergedEventTypes = Object.values(eventTypesHashMap)
.map((eventType) => eventType)
.filter((evType) => evType.schedulingType !== SchedulingType.MANAGED);
eventTypeGroups.push({
teamId: null,
membershipRole: null,
profile: {
slug: user.username,
name: user.name,
@ -348,6 +369,7 @@ export const eventTypesRouter = router({
eventTypeGroups,
user.teams.map((membership) => ({
teamId: membership.team.id,
membershipRole: membership.role,
profile: {
name: membership.team.name,
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
@ -357,7 +379,14 @@ export const eventTypesRouter = router({
membershipCount: membership.team.members.length,
readOnly: membership.role === MembershipRole.MEMBER,
},
eventTypes: membership.team.eventTypes.map(mapEventType),
eventTypes: membership.team.eventTypes
.map(mapEventType)
.filter((evType) => evType.userId === null || evType.userId === ctx.user.id)
.filter((evType) =>
membership.role === MembershipRole.MEMBER
? evType.schedulingType !== SchedulingType.MANAGED
: true
),
}))
);
return {
@ -366,6 +395,7 @@ export const eventTypesRouter = router({
// so we can show a dropdown when the user has teams
profiles: eventTypeGroups.map((group) => ({
teamId: group.teamId,
membershipRole: group.membershipRole,
...group.profile,
...group.metadata,
})),
@ -419,9 +449,9 @@ export const eventTypesRouter = router({
});
}),
create: authedProcedure.input(createEventTypeInput).mutation(async ({ ctx, input }) => {
const { schedulingType, teamId, ...rest } = input;
const { schedulingType, teamId, metadata, ...rest } = input;
const userId = ctx.user.id;
const isManagedEventType = schedulingType === SchedulingType.MANAGED;
// Get Users default conferncing app
const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp;
@ -448,11 +478,9 @@ export const eventTypesRouter = router({
const data: Prisma.EventTypeCreateInput = {
...rest,
owner: teamId ? undefined : { connect: { id: userId } },
users: {
connect: {
id: userId,
},
},
metadata: (metadata as Prisma.InputJsonObject) ?? undefined,
// Only connecting the current user for non-managed event type
users: isManagedEventType ? undefined : { connect: { id: userId } },
locations,
};
@ -535,6 +563,7 @@ export const eventTypesRouter = router({
customInputs,
recurringEvent,
users,
children,
hosts,
id,
hashedLink,
@ -552,7 +581,7 @@ export const eventTypesRouter = router({
const data: Prisma.EventTypeUpdateInput = {
...rest,
bookingFields,
metadata: rest.metadata === null ? Prisma.DbNull : rest.metadata,
metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject),
};
data.locations = locations ?? undefined;
if (periodType) {
@ -697,10 +726,38 @@ export const eventTypesRouter = router({
});
}
}
const [oldEventType, eventType] = await ctx.prisma.$transaction([
ctx.prisma.eventType.findFirst({
where: { id },
select: {
children: {
select: {
userId: true,
},
},
team: {
select: {
name: true,
},
},
},
}),
ctx.prisma.eventType.update({
where: { id },
data,
}),
]);
const eventType = await ctx.prisma.eventType.update({
where: { id },
data,
// Handling updates to children event types (managed events types)
await updateChildrenEventTypes({
eventTypeId: id,
currentUserId: ctx.user.id,
oldEventType,
hashedLink,
connectedLink,
updatedEventType: eventType,
children,
prisma: ctx.prisma,
});
const res = ctx.res as NextApiResponse;
if (typeof res?.revalidate !== "undefined") {

View File

@ -271,6 +271,11 @@ export const viewerTeamsRouter = router({
},
});
// Deleted managed event types from this team from this member
await ctx.prisma.eventType.deleteMany({
where: { parent: { teamId: input.teamId }, userId: membership.userId },
});
// Sync Services
closeComDeleteTeamMembership(membership.user);
if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId);

View File

@ -25,6 +25,8 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
const displayedAvatars = props.items.filter((avatar) => avatar.image).slice(0, truncateAfter);
const numTruncatedAvatars = LENGTH - displayedAvatars.length;
if (!displayedAvatars.length) return <></>;
return (
<ul className={classNames("flex items-center", props.className)}>
{displayedAvatars.map((item, idx) => (

View File

@ -83,6 +83,7 @@ export function CreateButton(props: CreateBtnProps) {
<Button
variant={props.disableMobileButton ? "button" : "fab"}
StartIcon={Plus}
data-testid="new-event-type-dropdown"
loading={props.isLoading}>
{props.buttonText ? props.buttonText : t("new")}
</Button>
@ -91,10 +92,11 @@ export function CreateButton(props: CreateBtnProps) {
<DropdownMenuLabel>
<div className="w-48 text-left text-xs">{props.subtitle}</div>
</DropdownMenuLabel>
{props.options.map((option) => (
{props.options.map((option, idx) => (
<DropdownMenuItem key={option.label}>
<DropdownItem
type="button"
data-testid={`option${option.teamId ? "-team" : ""}-${idx}`}
StartIcon={(props) => (
<Avatar
alt={option.label || ""}

View File

@ -11,6 +11,8 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
import { classNames } from "@calcom/lib";
import ExampleTheme from "./ExampleTheme";
import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
import ToolbarPlugin from "./plugins/ToolbarPlugin";
@ -31,6 +33,7 @@ export type TextEditorProps = {
height?: string;
placeholder?: string;
disableLists?: boolean;
editable?: boolean;
};
const editorConfig = {
@ -55,17 +58,21 @@ const editorConfig = {
};
export const Editor = (props: TextEditorProps) => {
const editable = props.editable ?? true;
return (
<div className="editor">
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-container">
<div className="editor rounded-md">
<LexicalComposer initialConfig={{ ...editorConfig, editable }}>
<div className="editor-container rounded-md p-0">
<ToolbarPlugin
getText={props.getText}
setText={props.setText}
editable={editable}
excludedToolbarItems={props.excludedToolbarItems}
variables={props.variables}
/>
<div className="editor-inner" style={{ height: props.height }}>
<div
className={classNames("editor-inner", !editable && "bg-muted")}
style={{ height: props.height }}>
<RichTextPlugin
contentEditable={<ContentEditable style={{ height: props.height }} className="editor-input" />}
placeholder={<div className="text-muted -mt-11 p-3 text-sm">{props.placeholder || ""}</div>}

View File

@ -384,6 +384,8 @@ export default function ToolbarPlugin(props: TextEditorProps) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}
}, [editor, isLink]);
if (!props.editable) return <></>;
return (
<div className="toolbar flex" ref={toolbarRef}>
<>

View File

@ -23,7 +23,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{...props}
ref={ref}
className={classNames(
"hover:border-emphasis border-default bg-default placeholder:text-muted text-emphasis disabled:hover:border-default mb-2 block h-9 rounded-md border py-2 px-3 text-sm focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1 disabled:cursor-not-allowed",
"hover:border-emphasis border-default bg-default placeholder:text-muted text-emphasis disabled:hover:border-default min-h-9 disabled:bg-subtle mb-2 block rounded-md border py-2 px-3 text-sm focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1 disabled:cursor-not-allowed",
isFullWidth && "w-full",
props.className
)}
@ -41,6 +41,7 @@ export function InputLeading(props: JSX.IntrinsicElements["div"]) {
type InputFieldProps = {
label?: ReactNode;
LockedIcon?: React.ReactNode;
hint?: ReactNode;
hintErrors?: string[];
addOnLeading?: ReactNode;
@ -67,16 +68,16 @@ type AddonProps = {
const Addon = ({ isFilled, children, className, error }: AddonProps) => (
<div
className={classNames(
"addon-wrapper border-default h-9 border px-3",
"addon-wrapper border-default min-h-9 border px-3",
isFilled && "bg-subtle",
className
)}>
<div
className={classNames(
"flex h-full flex-col justify-center text-sm",
"min-h-9 flex flex-col justify-center text-sm",
error ? "text-error" : "text-default"
)}>
<span className="whitespace-nowrap py-2.5">{children}</span>
<span className="flex whitespace-nowrap">{children}</span>
</div>
</div>
);
@ -90,6 +91,8 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
label = t(name),
labelProps,
labelClassName,
disabled,
LockedIcon,
placeholder = isLocaleReady && i18n.exists(name + "_placeholder") ? t(name + "_placeholder") : "",
className,
addOnLeading,
@ -120,6 +123,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
{...labelProps}
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
{label}
{LockedIcon}
</Skeleton>
)}
{addOnLeading || addOnSuffix ? (
@ -141,6 +145,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
isFullWidth={inputIsFullWidth}
className={classNames(
className,
"disabled:bg-muted disabled:hover:border-subtle disabled:cursor-not-allowed",
addOnLeading && "ltr:rounded-l-none rtl:rounded-r-none",
addOnSuffix && "ltr:rounded-r-none rtl:rounded-l-none",
type === "search" && "pr-8",
@ -154,7 +159,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
},
value: inputValue,
})}
readOnly={readOnly}
disabled={readOnly || disabled}
ref={ref}
/>
{addOnSuffix && (
@ -182,11 +187,15 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
id={id}
type={type}
placeholder={placeholder}
className={className}
className={classNames(
className,
"disabled:bg-muted disabled:hover:border-subtle disabled:cursor-not-allowed"
)}
{...passThrough}
readOnly={readOnly}
ref={ref}
isFullWidth={inputIsFullWidth}
disabled={readOnly || disabled}
/>
)}
<HintsOrErrors hintErrors={hintErrors} fieldName={name} t={t} />

View File

@ -59,6 +59,7 @@ export const Select = <
? "p-1"
: "px-3 py-2"
: "py-2 px-3",
props.isDisabled && "bg-gray-100",
props.classNames?.control
),
singleValue: () => classNames("text-emphasis placeholder:text-muted", props.classNames?.singleValue),

View File

@ -10,6 +10,7 @@ type Props = {
description?: string;
checked: boolean;
disabled?: boolean;
LockedIcon?: React.ReactNode;
onCheckedChange?: (checked: boolean) => void;
"data-testid"?: string;
tooltip?: string;
@ -19,6 +20,7 @@ function SettingsToggle({
checked,
onCheckedChange,
description,
LockedIcon,
title,
children,
disabled,
@ -42,7 +44,10 @@ function SettingsToggle({
/>
<div>
<Label className="text-emphasis text-sm font-semibold leading-none">{title}</Label>
<Label className="text-emphasis text-sm font-semibold leading-none">
{title}
{LockedIcon}
</Label>
{description && <p className="text-default -mt-1.5 text-sm leading-normal">{description}</p>}
</div>
</div>

View File

@ -17,6 +17,7 @@ const Switch = (
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
label?: string;
fitToHeight?: boolean;
disabled?: boolean;
tooltip?: string;
classNames?: {
container?: string;

View File

@ -2,10 +2,10 @@ import React from "react";
import classNames from "@calcom/lib/classNames";
type RadioAreaProps = React.InputHTMLAttributes<HTMLInputElement>;
type RadioAreaProps = React.InputHTMLAttributes<HTMLInputElement> & { classNames?: { container?: string } };
const RadioArea = React.forwardRef<HTMLInputElement, RadioAreaProps>(
({ children, className, ...props }, ref) => {
({ children, className, classNames: innerClassNames, ...props }, ref) => {
return (
<label className={classNames("relative flex", className)}>
<input
@ -14,7 +14,11 @@ const RadioArea = React.forwardRef<HTMLInputElement, RadioAreaProps>(
type="radio"
{...props}
/>
<div className="text-default peer-checked:border-emphasis border-subtle rounded-md border p-4 pt-3 pl-10">
<div
className={classNames(
"text-default peer-checked:border-emphasis border-subtle rounded-md border p-4 pt-3 pl-10",
innerClassNames?.container
)}>
{children}
</div>
</label>

View File

@ -1,5 +1,6 @@
import { PrismaClient } from "@prisma/client";
import { mockDeep, mockReset, DeepMockProxy } from "jest-mock-extended";
import type { PrismaClient } from "@prisma/client";
import type { DeepMockProxy } from "jest-mock-extended";
import { mockDeep, mockReset } from "jest-mock-extended";
import * as CalendarManager from "@calcom/core/CalendarManager";
import prisma from "@calcom/prisma";