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:
parent
7349fb9f6d
commit
5170fc2424
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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} />
|
||||
);
|
||||
};
|
|
@ -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} </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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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've shared their link with will no longer be able to book using it.
|
||||
</li>
|
||||
</ul>
|
||||
</Trans>
|
||||
</p>
|
||||
</ConfirmationDialogContent>
|
||||
</Dialog>
|
||||
<EmbedDialog />
|
||||
|
|
|
@ -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,
|
||||
})}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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",
|
||||
|
|
|
@ -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 |
|
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 {
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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";
|
||||
|
|
|
@ -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"
|
||||
)}`;
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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 };
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 />
|
||||
)}
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
|
||||
<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();
|
||||
},
|
||||
|
|
|
@ -9,4 +9,5 @@ export type AppFlags = {
|
|||
webhooks: boolean;
|
||||
workflows: boolean;
|
||||
"v2-booking-page": boolean;
|
||||
"managed-event-types": boolean;
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 };
|
||||
})
|
||||
: [];
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -22,6 +22,7 @@ export const telemetryEventTypes = {
|
|||
website: {
|
||||
pageView: "website_page_view",
|
||||
},
|
||||
slugReplacementAction: "slug_replacement_action",
|
||||
};
|
||||
|
||||
export function collectPageParameters(
|
||||
|
|
|
@ -98,7 +98,8 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
|||
slotInterval: null,
|
||||
metadata: null,
|
||||
successRedirectUrl: null,
|
||||
bookingFields: null,
|
||||
bookingFields: [],
|
||||
parentId: null,
|
||||
...eventType,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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"]),
|
||||
};
|
||||
|
|
|
@ -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), {
|
||||
|
|
|
@ -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 || []
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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 || ""}
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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}>
|
||||
<>
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -17,6 +17,7 @@ const Switch = (
|
|||
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
|
||||
label?: string;
|
||||
fitToHeight?: boolean;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
classNames?: {
|
||||
container?: string;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
|
Loading…
Reference in New Issue
Block a user