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 { getEventName } from "@calcom/core/event";
|
||||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
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 { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
|
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({
|
const eventNamePlaceholder = getEventName({
|
||||||
...eventNameObject,
|
...eventNameObject,
|
||||||
eventName: formMethods.watch("eventName"),
|
eventName: formMethods.watch("eventName"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const successRedirectUrlLocked = shouldLockDisableProps("successRedirectUrl");
|
||||||
|
const seatsLocked = shouldLockDisableProps("seatsPerTimeSlotEnabled");
|
||||||
|
|
||||||
const closeEventNameTip = () => setShowEventNameTip(false);
|
const closeEventNameTip = () => setShowEventNameTip(false);
|
||||||
|
|
||||||
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
const setEventName = (value: string) => formMethods.setValue("eventName", value);
|
||||||
|
@ -128,19 +137,19 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
<TextField
|
<TextField
|
||||||
label={t("event_name_in_calendar")}
|
label={t("event_name_in_calendar")}
|
||||||
type="text"
|
type="text"
|
||||||
|
{...shouldLockDisableProps("eventName")}
|
||||||
placeholder={eventNamePlaceholder}
|
placeholder={eventNamePlaceholder}
|
||||||
defaultValue={eventType.eventName || ""}
|
defaultValue={eventType.eventName || ""}
|
||||||
{...formMethods.register("eventName")}
|
{...formMethods.register("eventName")}
|
||||||
addOnSuffix={
|
addOnSuffix={
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
StartIcon={Edit}
|
|
||||||
variant="icon"
|
|
||||||
color="minimal"
|
color="minimal"
|
||||||
className="hover:stroke-3 hover:text-emphasis min-w-fit px-0 hover:bg-transparent"
|
size="sm"
|
||||||
onClick={() => setShowEventNameTip((old) => !old)}
|
|
||||||
aria-label="edit custom name"
|
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>
|
</div>
|
||||||
|
@ -150,6 +159,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
description={t("booking_questions_description")}
|
description={t("booking_questions_description")}
|
||||||
addFieldLabel={t("add_a_booking_question")}
|
addFieldLabel={t("add_a_booking_question")}
|
||||||
formProp="bookingFields"
|
formProp="bookingFields"
|
||||||
|
{...shouldLockDisableProps("bookingFields")}
|
||||||
dataStore={{
|
dataStore={{
|
||||||
options: {
|
options: {
|
||||||
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
||||||
|
@ -158,6 +168,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
/>
|
/>
|
||||||
<hr className="border-subtle" />
|
<hr className="border-subtle" />
|
||||||
<RequiresConfirmationController
|
<RequiresConfirmationController
|
||||||
|
eventType={eventType}
|
||||||
seatsEnabled={seatsEnabled}
|
seatsEnabled={seatsEnabled}
|
||||||
metadata={eventType.metadata}
|
metadata={eventType.metadata}
|
||||||
requiresConfirmation={requiresConfirmation}
|
requiresConfirmation={requiresConfirmation}
|
||||||
|
@ -171,6 +182,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
title={t("disable_notes")}
|
title={t("disable_notes")}
|
||||||
|
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||||
description={t("disable_notes_description")}
|
description={t("disable_notes_description")}
|
||||||
checked={value}
|
checked={value}
|
||||||
onCheckedChange={(e) => onChange(e)}
|
onCheckedChange={(e) => onChange(e)}
|
||||||
|
@ -185,6 +197,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
title={t("redirect_success_booking")}
|
title={t("redirect_success_booking")}
|
||||||
|
{...successRedirectUrlLocked}
|
||||||
description={t("redirect_url_description")}
|
description={t("redirect_url_description")}
|
||||||
checked={redirectUrlVisible}
|
checked={redirectUrlVisible}
|
||||||
onCheckedChange={(e) => {
|
onCheckedChange={(e) => {
|
||||||
|
@ -197,6 +210,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
className="w-full"
|
className="w-full"
|
||||||
label={t("redirect_success_booking")}
|
label={t("redirect_success_booking")}
|
||||||
labelSrOnly
|
labelSrOnly
|
||||||
|
disabled={successRedirectUrlLocked.disabled}
|
||||||
placeholder={t("external_redirect_url")}
|
placeholder={t("external_redirect_url")}
|
||||||
required={redirectUrlVisible}
|
required={redirectUrlVisible}
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -219,6 +233,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
data-testid="hashedLinkCheck"
|
data-testid="hashedLinkCheck"
|
||||||
title={t("private_link")}
|
title={t("private_link")}
|
||||||
|
{...shouldLockDisableProps("hashedLinkCheck")}
|
||||||
description={t("private_link_description", { appName: APP_NAME })}
|
description={t("private_link_description", { appName: APP_NAME })}
|
||||||
checked={hashedLinkVisible}
|
checked={hashedLinkVisible}
|
||||||
onCheckedChange={(e) => {
|
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")}>
|
<Tooltip content={eventType.hashedLink ? t("copy_to_clipboard") : t("enabled_after_update")}>
|
||||||
<Button
|
<Button
|
||||||
color="minimal"
|
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={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(placeholderHashedLink);
|
navigator.clipboard.writeText(placeholderHashedLink);
|
||||||
if (eventType.hashedLink) {
|
if (eventType.hashedLink) {
|
||||||
|
@ -247,10 +266,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
} else {
|
} else {
|
||||||
showToast(t("enabled_after_update_description"), "warning");
|
showToast(t("enabled_after_update_description"), "warning");
|
||||||
}
|
}
|
||||||
}}
|
}}>
|
||||||
className="hover:stroke-3 hover:text-emphasis hover:bg-transparent"
|
<Copy className="h-4 w-4" />
|
||||||
type="button">
|
|
||||||
<Copy />
|
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
@ -267,6 +284,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
data-testid="offer-seats-toggle"
|
data-testid="offer-seats-toggle"
|
||||||
title={t("offer_seats")}
|
title={t("offer_seats")}
|
||||||
|
{...seatsLocked}
|
||||||
description={t("offer_seats_description")}
|
description={t("offer_seats_description")}
|
||||||
checked={value}
|
checked={value}
|
||||||
disabled={noShowFeeEnabled}
|
disabled={noShowFeeEnabled}
|
||||||
|
@ -295,8 +313,10 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
labelSrOnly
|
labelSrOnly
|
||||||
label={t("number_of_seats")}
|
label={t("number_of_seats")}
|
||||||
type="number"
|
type="number"
|
||||||
|
disabled={seatsLocked.disabled}
|
||||||
defaultValue={value || 2}
|
defaultValue={value || 2}
|
||||||
min={1}
|
min={1}
|
||||||
|
className="w-24"
|
||||||
addOnSuffix={<>{t("seats")}</>}
|
addOnSuffix={<>{t("seats")}</>}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onChange(Math.abs(Number(e.target.value)));
|
onChange(Math.abs(Number(e.target.value)));
|
||||||
|
@ -305,6 +325,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
description={t("show_attendees")}
|
description={t("show_attendees")}
|
||||||
|
disabled={seatsLocked.disabled}
|
||||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||||
defaultChecked={!!eventType.seatsShowAttendees}
|
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 { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
|
||||||
import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
||||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, EmptyScreen } from "@calcom/ui";
|
import { Button, EmptyScreen, Alert } from "@calcom/ui";
|
||||||
import { Grid } from "@calcom/ui/components/icon";
|
import { Grid, Lock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
|
||||||
EventTypeAppCardComponentProps["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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className="before:border-0">
|
<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 ? (
|
{!isLoading && !installedApps?.length ? (
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
Icon={Grid}
|
Icon={Grid}
|
||||||
headline={t("empty_installed_apps_headline")}
|
headline={t("empty_installed_apps_headline")}
|
||||||
description={t("empty_installed_apps_description")}
|
description={t("empty_installed_apps_description")}
|
||||||
buttonRaw={
|
buttonRaw={
|
||||||
<Button target="_blank" color="secondary" href="/apps">
|
isChildrenManagedEventType && !isManagedEventType ? (
|
||||||
{t("empty_installed_apps_button")}{" "}
|
<Button StartIcon={Lock} color="secondary" disabled>
|
||||||
</Button>
|
{t("locked_by_admin")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button target="_blank" color="secondary" href="/apps">
|
||||||
|
{t("empty_installed_apps_button")}{" "}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
@ -82,22 +103,24 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{!shouldLockDisableProps("apps").disabled && (
|
||||||
{!isLoading && notInstalledApps?.length ? (
|
<div>
|
||||||
<h2 className="text-emphasis mt-0 mb-2 text-lg font-semibold">Available Apps</h2>
|
{!isLoading && notInstalledApps?.length ? (
|
||||||
) : null}
|
<h2 className="text-emphasis mt-0 mb-2 text-lg font-semibold">{t("available_apps")}</h2>
|
||||||
<div className="before:border-0">
|
) : null}
|
||||||
{notInstalledApps?.map((app) => (
|
<div className="before:border-0">
|
||||||
<EventTypeAppCard
|
{notInstalledApps?.map((app) => (
|
||||||
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
<EventTypeAppCard
|
||||||
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
getAppData={getAppDataGetter(app.slug as EventTypeAppsList)}
|
||||||
key={app.slug}
|
setAppData={getAppDataSetter(app.slug as EventTypeAppsList)}
|
||||||
app={app}
|
key={app.slug}
|
||||||
eventType={eventType}
|
app={app}
|
||||||
/>
|
eventType={eventType}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</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 { Controller, useFormContext } from "react-hook-form";
|
||||||
import type { OptionProps, SingleValueProps } from "react-select";
|
import type { OptionProps, SingleValueProps } from "react-select";
|
||||||
import { components } from "react-select";
|
import { components } from "react-select";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
|
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
import { NewScheduleButton } from "@calcom/features/schedules";
|
import { NewScheduleButton } from "@calcom/features/schedules";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { weekdayNames } from "@calcom/lib/weekday";
|
import { weekdayNames } from "@calcom/lib/weekday";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||||
import { Badge, Button, Select, SettingsToggle, SkeletonText, EmptyScreen } from "@calcom/ui";
|
import { Badge, Button, Select, SettingsToggle, SkeletonText, EmptyScreen } from "@calcom/ui";
|
||||||
import { ExternalLink, Globe, Clock } from "@calcom/ui/components/icon";
|
import { ExternalLink, Globe, Clock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
|
||||||
|
|
||||||
type AvailabilityOption = {
|
type AvailabilityOption = {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
|
isManaged?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||||
const { label, isDefault } = props.data;
|
const { label, isDefault, isManaged = false } = props.data;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<components.Option {...props}>
|
<components.Option {...props}>
|
||||||
|
@ -33,12 +33,17 @@ const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||||
{t("default")}
|
{t("default")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isManaged && (
|
||||||
|
<Badge variant="gray" className="ml-2">
|
||||||
|
{t("managed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</components.Option>
|
</components.Option>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||||
const { label, isDefault } = props.data;
|
const { label, isDefault, isManaged = false } = props.data;
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
return (
|
return (
|
||||||
<components.SingleValue {...props}>
|
<components.SingleValue {...props}>
|
||||||
|
@ -48,50 +53,39 @@ const SingleValue = ({ ...props }: SingleValueProps<AvailabilityOption>) => {
|
||||||
{t("default")}
|
{t("default")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isManaged && (
|
||||||
|
<Badge variant="gray" className="ml-2">
|
||||||
|
{t("managed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</components.SingleValue>
|
</components.SingleValue>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AvailabilitySelect = ({
|
const AvailabilitySelect = ({
|
||||||
className = "",
|
className = "",
|
||||||
isLoading,
|
options,
|
||||||
schedules,
|
value,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
name: string;
|
name: string;
|
||||||
value: number;
|
value: AvailabilityOption | undefined;
|
||||||
isLoading: boolean;
|
options: AvailabilityOption[];
|
||||||
schedules: RouterOutputs["viewer"]["availability"]["list"]["schedules"] | [];
|
isDisabled?: boolean;
|
||||||
onBlur: () => void;
|
onBlur: () => void;
|
||||||
onChange: (value: AvailabilityOption | null) => void;
|
onChange: (value: AvailabilityOption | null) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLocale();
|
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 (
|
return (
|
||||||
<Select
|
<Select
|
||||||
placeholder={t("select")}
|
placeholder={t("select")}
|
||||||
options={options}
|
options={options}
|
||||||
|
isDisabled={props.isDisabled}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
onChange={props.onChange}
|
onChange={props.onChange}
|
||||||
className={classNames("block w-full min-w-0 flex-1 rounded-sm text-sm", className)}
|
className={classNames("block w-full min-w-0 flex-1 rounded-sm text-sm", className)}
|
||||||
value={value}
|
defaultValue={value}
|
||||||
components={{ Option, SingleValue }}
|
components={{ Option, SingleValue }}
|
||||||
isMulti={false}
|
isMulti={false}
|
||||||
/>
|
/>
|
||||||
|
@ -103,17 +97,25 @@ const format = (date: Date, hour12: boolean) =>
|
||||||
new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss"))
|
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 { data: loggedInUser } = useMeQuery();
|
||||||
const timeFormat = loggedInUser?.timeFormat;
|
const timeFormat = loggedInUser?.timeFormat;
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const { watch } = useFormContext<FormValues>();
|
const { watch } = useFormContext<FormValues>();
|
||||||
|
|
||||||
const scheduleId = watch("schedule");
|
const scheduleId = watch("schedule");
|
||||||
|
|
||||||
const { isLoading, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
|
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) =>
|
const filterDays = (dayNum: number) =>
|
||||||
|
@ -160,7 +162,7 @@ const EventTypeScheduleDetails = () => {
|
||||||
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
<Globe className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||||
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
{schedule?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||||
</span>
|
</span>
|
||||||
{!!schedule?.id && (
|
{!!schedule?.id && !schedule.isManaged && (
|
||||||
<Button
|
<Button
|
||||||
href={`/availability/${schedule.id}`}
|
href={`/availability/${schedule.id}`}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
@ -176,11 +178,20 @@ const EventTypeScheduleDetails = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EventTypeSchedule = () => {
|
const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||||
const { t } = useLocale();
|
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 (
|
return (
|
||||||
<EmptyScreen
|
<EmptyScreen
|
||||||
Icon={Clock}
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
||||||
{t("availability")}
|
{t("availability")}
|
||||||
|
{shouldLockIndicator("availability")}
|
||||||
</label>
|
</label>
|
||||||
<Controller
|
<Controller
|
||||||
name="schedule"
|
name="schedule"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<>
|
<>
|
||||||
<AvailabilitySelect
|
<AvailabilitySelect
|
||||||
value={field.value}
|
value={value}
|
||||||
|
options={options}
|
||||||
onBlur={field.onBlur}
|
onBlur={field.onBlur}
|
||||||
|
isDisabled={shouldLockDisableProps("schedule").disabled}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
schedules={schedules?.schedules || []}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onChange={(selected) => {
|
onChange={(selected) => {
|
||||||
field.onChange(selected?.value || null);
|
field.onChange(selected?.value || null);
|
||||||
}}
|
}}
|
||||||
|
@ -215,12 +269,21 @@ const EventTypeSchedule = () => {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UseCommonScheduleSettingsToggle = () => {
|
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { resetField, setValue } = useFormContext<FormValues>();
|
const { resetField, setValue } = useFormContext<FormValues>();
|
||||||
return (
|
return (
|
||||||
|
@ -239,13 +302,23 @@ const UseCommonScheduleSettingsToggle = () => {
|
||||||
}}
|
}}
|
||||||
title={t("choose_common_schedule_team_event")}
|
title={t("choose_common_schedule_team_event")}
|
||||||
description={t("choose_common_schedule_team_event_description")}>
|
description={t("choose_common_schedule_team_event_description")}>
|
||||||
<EventTypeSchedule />
|
<EventTypeSchedule eventType={eventType} />
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvailabilityTab = ({ isTeamEvent }: { isTeamEvent: boolean }) => {
|
export const EventAvailabilityTab = ({
|
||||||
return isTeamEvent ? <UseCommonScheduleSettingsToggle /> : <EventTypeSchedule />;
|
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 { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||||
import type { SingleValue } from "react-select";
|
import type { SingleValue } from "react-select";
|
||||||
|
|
||||||
|
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||||
import convertToNewDurationType 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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { PeriodType } from "@calcom/prisma/client";
|
import type { PeriodType } from "@calcom/prisma/client";
|
||||||
import type { IntervalLimit } from "@calcom/types/Calendar";
|
import type { IntervalLimit } from "@calcom/types/Calendar";
|
||||||
import {
|
import { Button, DateRangePicker, InputField, Label, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||||
Button,
|
|
||||||
DateRangePicker,
|
|
||||||
Input,
|
|
||||||
InputField,
|
|
||||||
Label,
|
|
||||||
Select,
|
|
||||||
SettingsToggle,
|
|
||||||
TextField,
|
|
||||||
} from "@calcom/ui";
|
|
||||||
import { Plus, Trash } from "@calcom/ui/components/icon";
|
import { Plus, Trash } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
const MinimumBookingNoticeInput = React.forwardRef<
|
const MinimumBookingNoticeInput = React.forwardRef<
|
||||||
|
@ -78,6 +70,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
||||||
<div className="w-1/2 md:w-full">
|
<div className="w-1/2 md:w-full">
|
||||||
<InputField
|
<InputField
|
||||||
required
|
required
|
||||||
|
disabled={passThroughProps.disabled}
|
||||||
defaultValue={minimumBookingNoticeDisplayValues.value}
|
defaultValue={minimumBookingNoticeDisplayValues.value}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setMinimumBookingNoticeDisplayValues({
|
setMinimumBookingNoticeDisplayValues({
|
||||||
|
@ -95,6 +88,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
|
isDisabled={passThroughProps.disabled}
|
||||||
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
className="mb-0 ml-2 h-[38px] w-full capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||||
defaultValue={durationTypeOptions.find(
|
defaultValue={durationTypeOptions.find(
|
||||||
(option) => option.value === minimumBookingNoticeDisplayValues.type
|
(option) => option.value === minimumBookingNoticeDisplayValues.type
|
||||||
|
@ -146,12 +140,30 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
defaultValue: periodType?.type,
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-4 lg: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="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="beforeBufferTime">{t("before_event")} </Label>
|
<Label htmlFor="beforeBufferTime">
|
||||||
|
{t("before_event")}
|
||||||
|
{shouldLockIndicator("bookingLimits")}
|
||||||
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="beforeBufferTime"
|
name="beforeBufferTime"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
|
@ -170,6 +182,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
|
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
if (val) onChange(val.value);
|
if (val) onChange(val.value);
|
||||||
}}
|
}}
|
||||||
|
@ -183,7 +196,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="afterBufferTime">{t("after_event")} </Label>
|
<Label htmlFor="afterBufferTime">
|
||||||
|
{t("after_event")}
|
||||||
|
{shouldLockIndicator("bookingLimits")}
|
||||||
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="afterBufferTime"
|
name="afterBufferTime"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
|
@ -202,6 +218,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
|
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
if (val) onChange(val.value);
|
if (val) onChange(val.value);
|
||||||
}}
|
}}
|
||||||
|
@ -217,11 +234,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
<div className="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")} </Label>
|
<Label htmlFor="minimumBookingNotice">
|
||||||
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
{t("minimum_booking_notice")}
|
||||||
|
{shouldLockIndicator("minimumBookingNotice")}
|
||||||
|
</Label>
|
||||||
|
<MinimumBookingNoticeInput
|
||||||
|
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
|
||||||
|
{...formMethods.register("minimumBookingNotice")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Label htmlFor="slotInterval">{t("slot_interval")} </Label>
|
<Label htmlFor="slotInterval">
|
||||||
|
{t("slot_interval")}
|
||||||
|
{shouldLockIndicator("slotInterval")}
|
||||||
|
</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="slotInterval"
|
name="slotInterval"
|
||||||
control={formMethods.control}
|
control={formMethods.control}
|
||||||
|
@ -239,6 +265,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
|
isDisabled={shouldLockDisableProps("slotInterval").disabled}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
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 } }) => (
|
render={({ field: { value } }) => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
title={t("limit_booking_frequency")}
|
title={t("limit_booking_frequency")}
|
||||||
|
{...bookingLimitsLocked}
|
||||||
description={t("limit_booking_frequency_description")}
|
description={t("limit_booking_frequency_description")}
|
||||||
checked={Object.keys(value ?? {}).length > 0}
|
checked={Object.keys(value ?? {}).length > 0}
|
||||||
onCheckedChange={(active) => {
|
onCheckedChange={(active) => {
|
||||||
|
@ -272,7 +300,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
formMethods.setValue("bookingLimits", {});
|
formMethods.setValue("bookingLimits", {});
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
|
<IntervalLimitsManager
|
||||||
|
disabled={bookingLimitsLocked.disabled}
|
||||||
|
propertyName="bookingLimits"
|
||||||
|
defaultLimit={1}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -284,6 +317,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
title={t("limit_total_booking_duration")}
|
title={t("limit_total_booking_duration")}
|
||||||
description={t("limit_total_booking_duration_description")}
|
description={t("limit_total_booking_duration_description")}
|
||||||
|
{...durationLimitsLocked}
|
||||||
checked={Object.keys(value ?? {}).length > 0}
|
checked={Object.keys(value ?? {}).length > 0}
|
||||||
onCheckedChange={(active) => {
|
onCheckedChange={(active) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
|
@ -297,6 +331,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
<IntervalLimitsManager
|
<IntervalLimitsManager
|
||||||
propertyName="durationLimits"
|
propertyName="durationLimits"
|
||||||
defaultLimit={60}
|
defaultLimit={60}
|
||||||
|
disabled={durationLimitsLocked.disabled}
|
||||||
step={15}
|
step={15}
|
||||||
textFieldSuffix={t("minutes")}
|
textFieldSuffix={t("minutes")}
|
||||||
/>
|
/>
|
||||||
|
@ -311,13 +346,16 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
title={t("limit_future_bookings")}
|
title={t("limit_future_bookings")}
|
||||||
description={t("limit_future_bookings_description")}
|
description={t("limit_future_bookings_description")}
|
||||||
|
{...periodTypeLocked}
|
||||||
checked={value && value !== "UNLIMITED"}
|
checked={value && value !== "UNLIMITED"}
|
||||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||||
<RadioGroup.Root
|
<RadioGroup.Root
|
||||||
defaultValue={watchPeriodType}
|
defaultValue={watchPeriodType}
|
||||||
value={watchPeriodType}
|
value={watchPeriodType}
|
||||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
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;
|
if (period.type === "UNLIMITED") return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -326,30 +364,42 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||||
)}
|
)}
|
||||||
key={period.type}>
|
key={period.type}>
|
||||||
<RadioGroup.Item
|
{!periodTypeLocked.disabled && (
|
||||||
id={period.type}
|
<RadioGroup.Item
|
||||||
value={period.type}
|
id={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">
|
value={period.type}
|
||||||
<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" />
|
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.Item>
|
<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.prefix ? <span>{period.prefix} </span> : null}
|
||||||
{period.type === "ROLLING" && (
|
{period.type === "ROLLING" && (
|
||||||
<div className="flex h-9">
|
<div className="flex items-center">
|
||||||
<Input
|
<TextField
|
||||||
|
labelSrOnly
|
||||||
type="number"
|
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"
|
placeholder="30"
|
||||||
|
disabled={periodTypeLocked.disabled}
|
||||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||||
defaultValue={eventType.periodDays || 30}
|
defaultValue={eventType.periodDays || 30}
|
||||||
/>
|
/>
|
||||||
<select
|
<Select
|
||||||
id=""
|
options={optionsPeriod}
|
||||||
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"
|
isSearchable={false}
|
||||||
{...formMethods.register("periodCountCalendarDays")}
|
isDisabled={periodTypeLocked.disabled}
|
||||||
defaultValue={eventType.periodCountCalendarDays ? "1" : "0"}>
|
onChange={(opt) => {
|
||||||
<option value="1">{t("calendar_days")}</option>
|
formMethods.setValue(
|
||||||
<option value="0">{t("business_days")}</option>
|
"periodCountCalendarDays",
|
||||||
</select>
|
opt?.value.toString() as "0" | "1"
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
defaultValue={
|
||||||
|
optionsPeriod.find(
|
||||||
|
(opt) => opt.value === (eventType.periodCountCalendarDays ? 1 : 0)
|
||||||
|
) ?? optionsPeriod[0]
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{period.type === "RANGE" && (
|
{period.type === "RANGE" && (
|
||||||
|
@ -362,6 +412,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
startDate={formMethods.getValues("periodDates").startDate}
|
startDate={formMethods.getValues("periodDates").startDate}
|
||||||
endDate={formMethods.getValues("periodDates").endDate}
|
endDate={formMethods.getValues("periodDates").endDate}
|
||||||
|
disabled={periodTypeLocked.disabled}
|
||||||
onDatesChange={({ startDate, endDate }) => {
|
onDatesChange={({ startDate, endDate }) => {
|
||||||
formMethods.setValue("periodDates", {
|
formMethods.setValue("periodDates", {
|
||||||
startDate,
|
startDate,
|
||||||
|
@ -400,6 +451,7 @@ type IntervalLimitItemProps = {
|
||||||
step: number;
|
step: number;
|
||||||
value: number;
|
value: number;
|
||||||
textFieldSuffix?: string;
|
textFieldSuffix?: string;
|
||||||
|
disabled?: boolean;
|
||||||
selectOptions: { value: keyof IntervalLimit; label: string }[];
|
selectOptions: { value: keyof IntervalLimit; label: string }[];
|
||||||
hasDeleteButton?: boolean;
|
hasDeleteButton?: boolean;
|
||||||
onDelete: (intervalLimitsKey: IntervalLimitsKey) => void;
|
onDelete: (intervalLimitsKey: IntervalLimitsKey) => void;
|
||||||
|
@ -414,6 +466,7 @@ const IntervalLimitItem = ({
|
||||||
textFieldSuffix,
|
textFieldSuffix,
|
||||||
selectOptions,
|
selectOptions,
|
||||||
hasDeleteButton,
|
hasDeleteButton,
|
||||||
|
disabled,
|
||||||
onDelete,
|
onDelete,
|
||||||
onLimitChange,
|
onLimitChange,
|
||||||
onIntervalSelect,
|
onIntervalSelect,
|
||||||
|
@ -424,8 +477,9 @@ const IntervalLimitItem = ({
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
containerClassName={textFieldSuffix ? "w-44 -mb-1" : "w-16 mb-0"}
|
||||||
className="mb-0"
|
className="mb-0 !h-auto"
|
||||||
placeholder={`${value}`}
|
placeholder={`${value}`}
|
||||||
|
disabled={disabled}
|
||||||
min={step}
|
min={step}
|
||||||
step={step}
|
step={step}
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
|
@ -435,10 +489,11 @@ const IntervalLimitItem = ({
|
||||||
<Select
|
<Select
|
||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
|
isDisabled={disabled}
|
||||||
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
defaultValue={INTERVAL_LIMIT_OPTIONS.find((option) => option.value === limitKey)}
|
||||||
onChange={onIntervalSelect}
|
onChange={onIntervalSelect}
|
||||||
/>
|
/>
|
||||||
{hasDeleteButton && (
|
{hasDeleteButton && !disabled && (
|
||||||
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
|
<Button variant="icon" StartIcon={Trash} color="destructive" onClick={() => onDelete(limitKey)} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -450,6 +505,7 @@ type IntervalLimitsManagerProps<K extends "durationLimits" | "bookingLimits"> =
|
||||||
defaultLimit: number;
|
defaultLimit: number;
|
||||||
step: number;
|
step: number;
|
||||||
textFieldSuffix?: string;
|
textFieldSuffix?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
||||||
|
@ -457,6 +513,7 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
||||||
defaultLimit,
|
defaultLimit,
|
||||||
step,
|
step,
|
||||||
textFieldSuffix,
|
textFieldSuffix,
|
||||||
|
disabled,
|
||||||
}: IntervalLimitsManagerProps<K>) => {
|
}: IntervalLimitsManagerProps<K>) => {
|
||||||
const { watch, setValue, control } = useFormContext<FormValues>();
|
const { watch, setValue, control } = useFormContext<FormValues>();
|
||||||
const watchIntervalLimits = watch(propertyName);
|
const watchIntervalLimits = watch(propertyName);
|
||||||
|
@ -506,6 +563,7 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
|
||||||
limitKey={limitKey}
|
limitKey={limitKey}
|
||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
textFieldSuffix={textFieldSuffix}
|
textFieldSuffix={textFieldSuffix}
|
||||||
hasDeleteButton={Object.keys(currentIntervalLimits).length > 1}
|
hasDeleteButton={Object.keys(currentIntervalLimits).length > 1}
|
||||||
selectOptions={INTERVAL_LIMIT_OPTIONS.filter(
|
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}>
|
<Button color="minimal" StartIcon={Plus} onClick={addLimit}>
|
||||||
{t("add_limit")}
|
{t("add_limit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -11,7 +11,7 @@ export const EventRecurringTab = ({ eventType }: Pick<EventTypeSetupProps, "even
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="">
|
<div className="">
|
||||||
<RecurringEventController paymentEnabled={requirePayment} recurringEvent={eventType.recurringEvent} />
|
<RecurringEventController paymentEnabled={requirePayment} eventType={eventType} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { z } from "zod";
|
||||||
|
|
||||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||||
import { getEventLocationType, MeetLocationType, LocationType } 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 { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
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 {
|
interface DescriptionEditorProps {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
editable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DescriptionEditor = (props: DescriptionEditorProps) => {
|
const DescriptionEditor = (props: DescriptionEditorProps) => {
|
||||||
|
@ -64,6 +90,7 @@ const DescriptionEditor = (props: DescriptionEditorProps) => {
|
||||||
setText={(value: string) => formMethods.setValue("description", turndown(value))}
|
setText={(value: string) => formMethods.setValue("description", turndown(value))}
|
||||||
excludedToolbarItems={["blockType"]}
|
excludedToolbarItems={["blockType"]}
|
||||||
placeholder={t("quick_video_meeting")}
|
placeholder={t("quick_video_meeting")}
|
||||||
|
editable={props.editable}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<SkeletonContainer>
|
<SkeletonContainer>
|
||||||
|
@ -174,6 +201,13 @@ export const EventSetupTab = (
|
||||||
resolver: zodResolver(locationFormSchema),
|
resolver: zodResolver(locationFormSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
|
||||||
|
useLockedFieldsManager(
|
||||||
|
eventType,
|
||||||
|
t("locked_fields_admin_description"),
|
||||||
|
t("locked_fields_member_description")
|
||||||
|
);
|
||||||
|
|
||||||
const Locations = () => {
|
const Locations = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
@ -188,6 +222,12 @@ export const EventSetupTab = (
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const defaultValue = isManagedEventType
|
||||||
|
? locationOptions.find((op) => op.label === t("default"))?.options[0]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { locationDetails, locationAvailable } = getLocationInfo(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{validLocations.length === 0 && (
|
{validLocations.length === 0 && (
|
||||||
|
@ -195,6 +235,8 @@ export const EventSetupTab = (
|
||||||
<LocationSelect
|
<LocationSelect
|
||||||
placeholder={t("select")}
|
placeholder={t("select")}
|
||||||
options={locationOptions}
|
options={locationOptions}
|
||||||
|
isDisabled={shouldLockDisableProps("locations").disabled}
|
||||||
|
defaultValue={defaultValue}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||||
menuPlacement="auto"
|
menuPlacement="auto"
|
||||||
|
@ -282,7 +324,15 @@ export const EventSetupTab = (
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</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>
|
<li>
|
||||||
<Button
|
<Button
|
||||||
data-testid="add-location"
|
data-testid="add-location"
|
||||||
|
@ -299,22 +349,33 @@ export const EventSetupTab = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const lengthLockedProps = shouldLockDisableProps("length");
|
||||||
|
const descriptionLockedProps = shouldLockDisableProps("description");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
label={t("title")}
|
label={t("title")}
|
||||||
|
{...shouldLockDisableProps("title")}
|
||||||
defaultValue={eventType.title}
|
defaultValue={eventType.title}
|
||||||
{...formMethods.register("title")}
|
{...formMethods.register("title")}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<Label>{t("description")}</Label>
|
<Label>
|
||||||
<DescriptionEditor description={eventType?.description} />
|
{t("description")}
|
||||||
|
{shouldLockIndicator("description")}
|
||||||
|
</Label>
|
||||||
|
<DescriptionEditor
|
||||||
|
description={eventType?.description}
|
||||||
|
editable={!descriptionLockedProps.disabled}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
label={t("URL")}
|
label={t("URL")}
|
||||||
|
{...shouldLockDisableProps("slug")}
|
||||||
defaultValue={eventType.slug}
|
defaultValue={eventType.slug}
|
||||||
addOnLeading={
|
addOnLeading={
|
||||||
<>
|
<>
|
||||||
|
@ -367,12 +428,14 @@ export const EventSetupTab = (
|
||||||
<div>
|
<div>
|
||||||
<Skeleton as={Label} loadingClassName="w-16">
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
{t("default_duration")}
|
{t("default_duration")}
|
||||||
|
{shouldLockIndicator("length")}
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
<Select
|
<Select
|
||||||
value={defaultDuration}
|
value={defaultDuration}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
name="length"
|
name="length"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
|
isDisabled={lengthLockedProps.disabled}
|
||||||
noOptionsMessage={() => t("default_duration_no_options")}
|
noOptionsMessage={() => t("default_duration_no_options")}
|
||||||
options={selectedMultipleDuration}
|
options={selectedMultipleDuration}
|
||||||
onChange={(option) => {
|
onChange={(option) => {
|
||||||
|
@ -388,32 +451,36 @@ export const EventSetupTab = (
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
type="number"
|
type="number"
|
||||||
|
{...lengthLockedProps}
|
||||||
label={t("duration")}
|
label={t("duration")}
|
||||||
defaultValue={eventType.length ?? 15}
|
defaultValue={eventType.length ?? 15}
|
||||||
{...formMethods.register("length")}
|
{...formMethods.register("length")}
|
||||||
addOnSuffix={<>{t("minutes")}</>}
|
addOnSuffix={<>{t("minutes")}</>}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
{!lengthLockedProps.disabled && (
|
||||||
<SettingsToggle
|
<div className="!mt-4 [&_label]:my-1 [&_label]:font-normal">
|
||||||
title={t("allow_booker_to_select_duration")}
|
<SettingsToggle
|
||||||
checked={multipleDuration !== undefined}
|
title={t("allow_booker_to_select_duration")}
|
||||||
onCheckedChange={() => {
|
checked={multipleDuration !== undefined}
|
||||||
if (multipleDuration !== undefined) {
|
onCheckedChange={() => {
|
||||||
setMultipleDuration(undefined);
|
if (multipleDuration !== undefined) {
|
||||||
formMethods.setValue("metadata.multipleDuration", undefined);
|
setMultipleDuration(undefined);
|
||||||
formMethods.setValue("length", eventType.length);
|
formMethods.setValue("metadata.multipleDuration", undefined);
|
||||||
} else {
|
formMethods.setValue("length", eventType.length);
|
||||||
setMultipleDuration([]);
|
} else {
|
||||||
formMethods.setValue("metadata.multipleDuration", []);
|
setMultipleDuration([]);
|
||||||
formMethods.setValue("length", 0);
|
formMethods.setValue("metadata.multipleDuration", []);
|
||||||
}
|
formMethods.setValue("length", 0);
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Skeleton as={Label} loadingClassName="w-16">
|
<Skeleton as={Label} loadingClassName="w-16">
|
||||||
{t("location")}
|
{t("location")}
|
||||||
|
{shouldLockIndicator("locations")}
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
|
|
||||||
<Controller
|
<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 type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { ComponentProps } 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 type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||||
import CheckedTeamSelect 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 { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Label, Select } from "@calcom/ui";
|
import { Label, Select } from "@calcom/ui";
|
||||||
|
|
||||||
interface IMemberToValue {
|
interface IUserToValue {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapUserToValue = ({ id, name, username, email }: IMemberToValue) => ({
|
const mapUserToValue = ({ id, name, username, email }: IUserToValue) => ({
|
||||||
value: `${id || ""}`,
|
value: `${id || ""}`,
|
||||||
label: `${name || ""}`,
|
label: `${name || ""}`,
|
||||||
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
avatar: `${WEBAPP_URL}/${username}/avatar.png`,
|
||||||
email,
|
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>) => {
|
const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof mapUserToValue>) => {
|
||||||
if (a.label < b.label) {
|
if (a.label < b.label) {
|
||||||
return -1;
|
return -1;
|
||||||
|
@ -35,6 +57,40 @@ const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof
|
||||||
return 0;
|
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 = ({
|
const CheckedHostField = ({
|
||||||
labelText,
|
labelText,
|
||||||
placeholder,
|
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 = ({
|
const Hosts = ({
|
||||||
teamMembers,
|
teamMembers,
|
||||||
}: {
|
}: {
|
||||||
|
@ -189,6 +260,7 @@ const Hosts = ({
|
||||||
/>*/}
|
/>*/}
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
MANAGED: <></>,
|
||||||
};
|
};
|
||||||
return !!schedulingType ? schedulingTypeRender[schedulingType] : <></>;
|
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 { t } = useLocale();
|
||||||
|
|
||||||
const schedulingTypeOptions: {
|
const schedulingTypeOptions: {
|
||||||
|
@ -216,9 +292,13 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
const teamMembersOptions = teamMembers.map(mapUserToValue);
|
||||||
|
const childrenEventTypeOptions = teamMembers.map((member) => {
|
||||||
|
return mapMemberToChildrenOption(member, eventType.slug);
|
||||||
|
});
|
||||||
|
const isManagedEventType = eventType.schedulingType === SchedulingType.MANAGED;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{team && (
|
{team && !isManagedEventType && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<Label>{t("scheduling_type")}</Label>
|
<Label>{t("scheduling_type")}</Label>
|
||||||
|
@ -239,6 +319,9 @@ export const EventTeamTab = ({ team, teamMembers }: Pick<EventTypeSetupProps, "t
|
||||||
<Hosts teamMembers={teamMembersOptions} />
|
<Hosts teamMembers={teamMembersOptions} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{team && isManagedEventType && (
|
||||||
|
<ChildrenEventTypes childrenEventTypeOptions={childrenEventTypeOptions} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
|
import type { Webhook } from "@prisma/client";
|
||||||
import { Webhook as TbWebhook } from "lucide-react";
|
import { Webhook as TbWebhook } from "lucide-react";
|
||||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
import { WebhookForm } from "@calcom/features/webhooks/components";
|
import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||||
import { APP_NAME } from "@calcom/lib/constants";
|
import { APP_NAME } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { Webhook } from "@calcom/prisma/client";
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
import { Alert, Button, Dialog, DialogContent, EmptyScreen, showToast } from "@calcom/ui";
|
||||||
import { Plus } from "@calcom/ui/components/icon";
|
import { Plus, Lock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
export const EventTeamWebhooksTab = ({
|
export const EventTeamWebhooksTab = ({
|
||||||
eventType,
|
eventType,
|
||||||
|
@ -91,6 +92,14 @@ export const EventTeamWebhooksTab = ({
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager(
|
||||||
|
eventType,
|
||||||
|
t("locked_fields_admin_description"),
|
||||||
|
t("locked_fields_member_description")
|
||||||
|
);
|
||||||
|
const webhookLockedStatus = shouldLockDisableProps("webhooks");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{team && webhooks && !isLoading && (
|
{team && webhooks && !isLoading && (
|
||||||
|
@ -98,15 +107,24 @@ export const EventTeamWebhooksTab = ({
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<>
|
<>
|
||||||
|
{isManagedEventType && (
|
||||||
|
<Alert
|
||||||
|
severity="neutral"
|
||||||
|
className="mb-2"
|
||||||
|
title={t("locked_for_members")}
|
||||||
|
message={t("locked_webhooks_description")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{webhooks.length ? (
|
{webhooks.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-8 rounded-md border">
|
<div className="mb-2 rounded-md border">
|
||||||
{webhooks.map((webhook, index) => {
|
{webhooks.map((webhook, index) => {
|
||||||
return (
|
return (
|
||||||
<WebhookListItem
|
<WebhookListItem
|
||||||
key={webhook.id}
|
key={webhook.id}
|
||||||
webhook={webhook}
|
webhook={webhook}
|
||||||
lastItem={webhooks.length === index + 1}
|
lastItem={webhooks.length === index + 1}
|
||||||
|
canEditWebhook={!webhookLockedStatus.disabled}
|
||||||
onEditWebhook={() => {
|
onEditWebhook={() => {
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
setWebhookToEdit(webhook);
|
setWebhookToEdit(webhook);
|
||||||
|
@ -122,7 +140,15 @@ export const EventTeamWebhooksTab = ({
|
||||||
Icon={TbWebhook}
|
Icon={TbWebhook}
|
||||||
headline={t("create_your_first_webhook")}
|
headline={t("create_your_first_webhook")}
|
||||||
description={t("create_your_first_team_webhook_description", { appName: APP_NAME })}
|
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 { Webhook as TbWebhook } from "lucide-react";
|
||||||
import type { TFunction } from "next-i18next";
|
import type { TFunction } from "next-i18next";
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||||
import { useMemo, useState, Suspense } from "react";
|
import { useMemo, useState, Suspense } from "react";
|
||||||
import type { UseFormReturn } from "react-hook-form";
|
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 Shell from "@calcom/features/shell/Shell";
|
||||||
import { classNames } from "@calcom/lib";
|
import { classNames } from "@calcom/lib";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
|
@ -80,12 +83,6 @@ function getNavigation(props: {
|
||||||
icon: LinkIcon,
|
icon: LinkIcon,
|
||||||
info: `${duration} ${t("minute_timeUnit")}`, // TODO: Get this from props
|
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",
|
name: "event_limit_tab_title",
|
||||||
href: `/event-types/${eventType.id}?tabName=limits`,
|
href: `/event-types/${eventType.id}?tabName=limits`,
|
||||||
|
@ -137,7 +134,10 @@ function EventTypeSingleLayout({
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
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({
|
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
|
||||||
onSuccess: async () => {
|
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
|
// Define tab navigation here
|
||||||
const EventTypeTabs = useMemo(() => {
|
const EventTypeTabs = useMemo(() => {
|
||||||
const navigation = getNavigation({
|
let navigation = getNavigation({
|
||||||
t,
|
t,
|
||||||
eventType,
|
eventType,
|
||||||
enabledAppsNumber,
|
enabledAppsNumber,
|
||||||
installedAppsNumber,
|
installedAppsNumber,
|
||||||
enabledWorkflowsNumber,
|
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 there is a team put this navigation item within the tabs
|
||||||
if (team) {
|
if (team) {
|
||||||
navigation.splice(2, 0, {
|
navigation.splice(2, 0, {
|
||||||
name: "assignment",
|
name: "assignment",
|
||||||
href: `/event-types/${eventType.id}?tabName=team`,
|
href: `/event-types/${eventType.id}?tabName=team`,
|
||||||
icon: Users,
|
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({
|
navigation.push({
|
||||||
name: "webhooks",
|
name: "webhooks",
|
||||||
href: `/event-types/${eventType.id}?tabName=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 embedLink = `${team ? `team/${team.slug}` : eventType.users[0].username}/${eventType.slug}`;
|
||||||
|
const isManagedEvent = eventType.schedulingType === SchedulingType.MANAGED ? "_managed" : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Shell
|
<Shell
|
||||||
|
@ -197,63 +230,73 @@ function EventTypeSingleLayout({
|
||||||
heading={eventType.title}
|
heading={eventType.title}
|
||||||
CTA={
|
CTA={
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<div className="sm:hover:bg-subtle hidden items-center rounded-md px-2 lg:flex">
|
{!eventType.metadata.managedEventConfig && (
|
||||||
<Skeleton
|
<>
|
||||||
as={Label}
|
<div className="sm:hover:bg-subtle hidden items-center rounded-md px-2 lg:flex">
|
||||||
htmlFor="hiddenSwitch"
|
<Skeleton
|
||||||
className="mt-2 hidden cursor-pointer self-center whitespace-nowrap pr-2 sm:inline">
|
as={Label}
|
||||||
{t("hide_from_profile")}
|
htmlFor="hiddenSwitch"
|
||||||
</Skeleton>
|
className="mt-2 hidden cursor-pointer self-center whitespace-nowrap pr-2 sm:inline">
|
||||||
<Switch
|
{t("hide_from_profile")}
|
||||||
id="hiddenSwitch"
|
</Skeleton>
|
||||||
checked={formMethods.watch("hidden")}
|
<Switch
|
||||||
onCheckedChange={(e) => {
|
id="hiddenSwitch"
|
||||||
formMethods.setValue("hidden", e);
|
checked={formMethods.watch("hidden")}
|
||||||
}}
|
onCheckedChange={(e) => {
|
||||||
/>
|
formMethods.setValue("hidden", e);
|
||||||
</div>
|
}}
|
||||||
<VerticalDivider className="hidden lg:block" />
|
/>
|
||||||
|
</div>
|
||||||
|
<VerticalDivider className="hidden lg:block" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* TODO: Figure out why combined isnt working - works in storybook */}
|
{/* TODO: Figure out why combined isnt working - works in storybook */}
|
||||||
<ButtonGroup combined containerProps={{ className: "border-default hidden lg:flex" }}>
|
<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 */}
|
{!isManagedEventType && (
|
||||||
<Tooltip content={t("preview")}>
|
<>
|
||||||
<Button
|
{/* We have to warp this in tooltip as it has a href which disabels the tooltip on buttons */}
|
||||||
color="secondary"
|
<Tooltip content={t("preview")}>
|
||||||
data-testid="preview-button"
|
<Button
|
||||||
target="_blank"
|
color="secondary"
|
||||||
variant="icon"
|
data-testid="preview-button"
|
||||||
href={permalink}
|
target="_blank"
|
||||||
rel="noreferrer"
|
variant="icon"
|
||||||
StartIcon={ExternalLink}
|
href={permalink}
|
||||||
/>
|
rel="noreferrer"
|
||||||
</Tooltip>
|
StartIcon={ExternalLink}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
StartIcon={LinkIcon}
|
StartIcon={LinkIcon}
|
||||||
tooltip={t("copy_link")}
|
tooltip={t("copy_link")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(permalink);
|
navigator.clipboard.writeText(permalink);
|
||||||
showToast("Link copied!", "success");
|
showToast("Link copied!", "success");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<EmbedButton
|
<EmbedButton
|
||||||
embedUrl={encodeURIComponent(embedLink)}
|
embedUrl={encodeURIComponent(embedLink)}
|
||||||
StartIcon={Code}
|
StartIcon={Code}
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
tooltip={t("embed")}
|
tooltip={t("embed")}
|
||||||
/>
|
/>
|
||||||
<Button
|
</>
|
||||||
color="destructive"
|
)}
|
||||||
variant="icon"
|
{!isChildrenManagedEventType && (
|
||||||
StartIcon={Trash}
|
<Button
|
||||||
tooltip={t("delete")}
|
color="destructive"
|
||||||
disabled={!hasPermsToDelete}
|
variant="icon"
|
||||||
onClick={() => setDeleteDialogOpen(true)}
|
StartIcon={Trash}
|
||||||
/>
|
tooltip={t("delete")}
|
||||||
|
disabled={!hasPermsToDelete}
|
||||||
|
onClick={() => setDeleteDialogOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
<VerticalDivider className="hidden lg:block" />
|
<VerticalDivider className="hidden lg:block" />
|
||||||
|
@ -351,14 +394,25 @@ function EventTypeSingleLayout({
|
||||||
<ConfirmationDialogContent
|
<ConfirmationDialogContent
|
||||||
isLoading={deleteMutation.isLoading}
|
isLoading={deleteMutation.isLoading}
|
||||||
variety="danger"
|
variety="danger"
|
||||||
title={t("delete_event_type")}
|
title={t(`delete${isManagedEvent}_event_type`)}
|
||||||
confirmBtnText={t("confirm_delete_event_type")}
|
confirmBtnText={t(`confirm_delete_event_type`)}
|
||||||
loadingText={t("confirm_delete_event_type")}
|
loadingText={t(`confirm_delete_event_type`)}
|
||||||
onConfirm={(e) => {
|
onConfirm={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
deleteMutation.mutate({ id: eventType.id });
|
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>
|
</ConfirmationDialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<EmbedDialog />
|
<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 { useState } from "react";
|
||||||
import { useFormContext } from "react-hook-form";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||||
import { Alert, Select, SettingsToggle } from "@calcom/ui";
|
import { Alert, Select, SettingsToggle, TextField } from "@calcom/ui";
|
||||||
|
|
||||||
type RecurringEventControllerProps = {
|
type RecurringEventControllerProps = {
|
||||||
recurringEvent: RecurringEvent | null;
|
eventType: EventTypeSetup;
|
||||||
paymentEnabled: boolean;
|
paymentEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RecurringEventController({
|
export default function RecurringEventController({
|
||||||
recurringEvent,
|
eventType,
|
||||||
paymentEnabled,
|
paymentEnabled,
|
||||||
}: RecurringEventControllerProps) {
|
}: RecurringEventControllerProps) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(recurringEvent);
|
const [recurringEventState, setRecurringEventState] = useState<RecurringEvent | null>(
|
||||||
|
eventType.recurringEvent
|
||||||
|
);
|
||||||
const formMethods = useFormContext<FormValues>();
|
const formMethods = useFormContext<FormValues>();
|
||||||
|
|
||||||
/* Just yearly-0, monthly-1 and weekly-2 */
|
/* Just yearly-0, monthly-1 and weekly-2 */
|
||||||
|
@ -28,6 +31,14 @@ export default function RecurringEventController({
|
||||||
value: value.toString(),
|
value: value.toString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { shouldLockDisableProps } = useLockedFieldsManager(
|
||||||
|
eventType,
|
||||||
|
t("locked_fields_admin_description"),
|
||||||
|
t("locked_fields_member_description")
|
||||||
|
);
|
||||||
|
|
||||||
|
const recurringLocked = shouldLockDisableProps("recurringEvent");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="block items-start sm:flex">
|
<div className="block items-start sm:flex">
|
||||||
<div className={!paymentEnabled ? "w-full" : ""}>
|
<div className={!paymentEnabled ? "w-full" : ""}>
|
||||||
|
@ -37,6 +48,7 @@ export default function RecurringEventController({
|
||||||
<>
|
<>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
title={t("recurring_event")}
|
title={t("recurring_event")}
|
||||||
|
{...recurringLocked}
|
||||||
description={t("recurring_event_description")}
|
description={t("recurring_event_description")}
|
||||||
checked={recurringEventState !== null}
|
checked={recurringEventState !== null}
|
||||||
data-testid="recurring-event-check"
|
data-testid="recurring-event-check"
|
||||||
|
@ -45,7 +57,7 @@ export default function RecurringEventController({
|
||||||
formMethods.setValue("recurringEvent", null);
|
formMethods.setValue("recurringEvent", null);
|
||||||
setRecurringEventState(null);
|
setRecurringEventState(null);
|
||||||
} else {
|
} else {
|
||||||
const newVal = recurringEvent || {
|
const newVal = eventType.recurringEvent || {
|
||||||
interval: 1,
|
interval: 1,
|
||||||
count: 12,
|
count: 12,
|
||||||
freq: Frequency.WEEKLY,
|
freq: Frequency.WEEKLY,
|
||||||
|
@ -58,11 +70,12 @@ export default function RecurringEventController({
|
||||||
<div data-testid="recurring-event-collapsible" className="text-sm">
|
<div data-testid="recurring-event-collapsible" className="text-sm">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("repeats_every")}</p>
|
||||||
<input
|
<TextField
|
||||||
|
disabled={recurringLocked.disabled}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="20"
|
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}
|
defaultValue={recurringEventState.interval}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const newVal = {
|
const newVal = {
|
||||||
|
@ -77,7 +90,8 @@ export default function RecurringEventController({
|
||||||
options={recurringEventFreqOptions}
|
options={recurringEventFreqOptions}
|
||||||
value={recurringEventFreqOptions[recurringEventState.freq]}
|
value={recurringEventFreqOptions[recurringEventState.freq]}
|
||||||
isSearchable={false}
|
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) => {
|
onChange={(event) => {
|
||||||
const newVal = {
|
const newVal = {
|
||||||
...recurringEventState,
|
...recurringEventState,
|
||||||
|
@ -90,12 +104,13 @@ export default function RecurringEventController({
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-center">
|
<div className="mt-4 flex items-center">
|
||||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
<p className="text-emphasis ltr:mr-2 rtl:ml-2">{t("for_a_maximum_of")}</p>
|
||||||
<input
|
<TextField
|
||||||
|
disabled={recurringLocked.disabled}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="20"
|
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}
|
defaultValue={recurringEventState.count}
|
||||||
|
className="mb-0"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const newVal = {
|
const newVal = {
|
||||||
...recurringEventState,
|
...recurringEventState,
|
||||||
|
@ -105,7 +120,7 @@ export default function RecurringEventController({
|
||||||
setRecurringEventState(newVal);
|
setRecurringEventState(newVal);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-emphasis ltr:mr-2 rtl:ml-2">
|
<p className="text-emphasis ltr:ml-2 rtl:mr-2">
|
||||||
{t("events", {
|
{t("events", {
|
||||||
count: recurringEventState.count,
|
count: recurringEventState.count,
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -1,25 +1,29 @@
|
||||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||||
import type { UnitTypeLongPlural } from "dayjs";
|
import type { UnitTypeLongPlural } from "dayjs";
|
||||||
import { Trans } from "next-i18next";
|
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 type { Dispatch, SetStateAction } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Controller, useFormContext } from "react-hook-form";
|
import { Controller, useFormContext } from "react-hook-form";
|
||||||
import type z from "zod";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
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 = {
|
type RequiresConfirmationControllerProps = {
|
||||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||||
requiresConfirmation: boolean;
|
requiresConfirmation: boolean;
|
||||||
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
|
onRequiresConfirmation: Dispatch<SetStateAction<boolean>>;
|
||||||
seatsEnabled: boolean;
|
seatsEnabled: boolean;
|
||||||
|
eventType: EventTypeSetup;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RequiresConfirmationController({
|
export default function RequiresConfirmationController({
|
||||||
metadata,
|
metadata,
|
||||||
|
eventType,
|
||||||
requiresConfirmation,
|
requiresConfirmation,
|
||||||
onRequiresConfirmation,
|
onRequiresConfirmation,
|
||||||
seatsEnabled,
|
seatsEnabled,
|
||||||
|
@ -37,6 +41,23 @@ export default function RequiresConfirmationController({
|
||||||
}
|
}
|
||||||
}, [requiresConfirmation]);
|
}, [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 (
|
return (
|
||||||
<div className="block items-start sm:flex">
|
<div className="block items-start sm:flex">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
@ -46,10 +67,11 @@ export default function RequiresConfirmationController({
|
||||||
render={() => (
|
render={() => (
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
title={t("requires_confirmation")}
|
title={t("requires_confirmation")}
|
||||||
disabled={seatsEnabled}
|
disabled={seatsEnabled || requiresConfirmationLockedProps.disabled}
|
||||||
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
tooltip={seatsEnabled ? t("seat_options_doesnt_support_confirmation") : undefined}
|
||||||
description={t("requires_confirmation_description")}
|
description={t("requires_confirmation_description")}
|
||||||
checked={requiresConfirmation}
|
checked={requiresConfirmation}
|
||||||
|
LockedIcon={requiresConfirmationLockedProps.LockedIcon}
|
||||||
onCheckedChange={(val) => {
|
onCheckedChange={(val) => {
|
||||||
formMethods.setValue("requiresConfirmation", val);
|
formMethods.setValue("requiresConfirmation", val);
|
||||||
onRequiresConfirmation(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">
|
<div className="flex flex-col flex-wrap justify-start gap-y-2">
|
||||||
<RadioField label={t("always_requires_confirmation")} id="always" value="always" />
|
{(requiresConfirmationSetup === undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||||
<RadioField
|
<RadioField
|
||||||
label={
|
label={t("always_requires_confirmation")}
|
||||||
<>
|
disabled={requiresConfirmationLockedProps.disabled}
|
||||||
<Trans
|
id="always"
|
||||||
i18nKey="when_booked_with_less_than_notice"
|
value="always"
|
||||||
defaults="When booked with less than <time></time> notice"
|
/>
|
||||||
components={{
|
)}
|
||||||
time: (
|
{(requiresConfirmationSetup !== undefined || !requiresConfirmationLockedProps.disabled) && (
|
||||||
<div className="mx-2 flex">
|
<RadioField
|
||||||
<Input
|
disabled={requiresConfirmationLockedProps.disabled}
|
||||||
type="number"
|
className="items-center"
|
||||||
min={1}
|
label={
|
||||||
onChange={(evt) => {
|
<>
|
||||||
const val = Number(evt.target?.value);
|
<Trans
|
||||||
setRequiresConfirmationSetup({
|
i18nKey="when_booked_with_less_than_notice"
|
||||||
unit:
|
defaults="When booked with less than <time></time> notice"
|
||||||
requiresConfirmationSetup?.unit ??
|
components={{
|
||||||
defaultRequiresConfirmationSetup.unit,
|
time: (
|
||||||
time: val,
|
<div className="mx-2 inline-flex">
|
||||||
});
|
<Input
|
||||||
formMethods.setValue("metadata.requiresConfirmationThreshold.time", val);
|
type="number"
|
||||||
}}
|
min={1}
|
||||||
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
|
disabled={requiresConfirmationLockedProps.disabled}
|
||||||
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
onChange={(evt) => {
|
||||||
/>
|
const val = Number(evt.target?.value);
|
||||||
<select
|
setRequiresConfirmationSetup({
|
||||||
onChange={(evt) => {
|
unit:
|
||||||
const val = evt.target.value as UnitTypeLongPlural;
|
requiresConfirmationSetup?.unit ??
|
||||||
setRequiresConfirmationSetup({
|
defaultRequiresConfirmationSetup.unit,
|
||||||
time:
|
time: val,
|
||||||
requiresConfirmationSetup?.time ??
|
});
|
||||||
defaultRequiresConfirmationSetup.time,
|
formMethods.setValue(
|
||||||
unit: val,
|
"metadata.requiresConfirmationThreshold.time",
|
||||||
});
|
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"
|
className="border-default !m-0 block w-16 rounded-md text-sm [appearance:textfield]"
|
||||||
defaultValue={
|
defaultValue={metadata?.requiresConfirmationThreshold?.time || 30}
|
||||||
metadata?.requiresConfirmationThreshold?.unit ||
|
/>
|
||||||
defaultRequiresConfirmationSetup.unit
|
<label
|
||||||
}>
|
className={classNames(
|
||||||
<option value="minutes">{t("minute_timeUnit")}</option>
|
requiresConfirmationLockedProps.disabled && "cursor-not-allowed"
|
||||||
<option value="hours">{t("hour_timeUnit")}</option>
|
)}>
|
||||||
</select>
|
<Select
|
||||||
</div>
|
inputId="notice"
|
||||||
),
|
options={options}
|
||||||
}}
|
isSearchable={false}
|
||||||
/>
|
isDisabled={requiresConfirmationLockedProps.disabled}
|
||||||
</>
|
className="ml-2"
|
||||||
}
|
onChange={(opt) => {
|
||||||
id="notice"
|
setRequiresConfirmationSetup({
|
||||||
value="notice"
|
time:
|
||||||
/>
|
requiresConfirmationSetup?.time ??
|
||||||
<div className="flex items-center">
|
defaultRequiresConfirmationSetup.time,
|
||||||
<RadioGroup.Item
|
unit: opt?.value as UnitTypeLongPlural,
|
||||||
|
});
|
||||||
|
formMethods.setValue(
|
||||||
|
"metadata.requiresConfirmationThreshold.unit",
|
||||||
|
opt?.value as UnitTypeLongPlural
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
id="notice"
|
id="notice"
|
||||||
value="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>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</SettingsToggle>
|
</SettingsToggle>
|
||||||
|
|
|
@ -180,7 +180,7 @@ export default function User(props: inferSSRProps<typeof getServerSideProps> & E
|
||||||
<div className="flex flex-wrap items-center">
|
<div className="flex flex-wrap items-center">
|
||||||
<h2 className=" text-default pr-2 text-sm font-semibold">{type.title}</h2>
|
<h2 className=" text-default pr-2 text-sm font-semibold">{type.title}</h2>
|
||||||
</div>
|
</div>
|
||||||
<EventTypeDescription eventType={type} />
|
<EventTypeDescription eventType={type} isPublic={true} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import type { PeriodType } from "@prisma/client";
|
import type { PeriodType } from "@prisma/client";
|
||||||
import type { SchedulingType } from "@prisma/client";
|
import type { SchedulingType } from "@prisma/client";
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import { Trans } from "next-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
@ -11,26 +12,28 @@ import { z } from "zod";
|
||||||
import { validateCustomEventName } from "@calcom/core/event";
|
import { validateCustomEventName } from "@calcom/core/event";
|
||||||
import type { EventLocationType } from "@calcom/core/location";
|
import type { EventLocationType } from "@calcom/core/location";
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
|
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||||
import { validateIntervalLimitOrder } from "@calcom/lib";
|
import { validateIntervalLimitOrder } from "@calcom/lib";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import { useTelemetry, telemetryEventTypes } from "@calcom/lib/telemetry";
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||||
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar";
|
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 { asStringOrThrow } from "@lib/asStringOrNull";
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
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
|
// 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 { EventAdvancedTab } from "@components/eventtype/EventAdvancedTab";
|
||||||
import { EventAppsTab } from "@components/eventtype/EventAppsTab";
|
import { EventAppsTab } from "@components/eventtype/EventAppsTab";
|
||||||
|
import { EventAvailabilityTab } from "@components/eventtype/EventAvailabilityTab";
|
||||||
import { EventLimitsTab } from "@components/eventtype/EventLimitsTab";
|
import { EventLimitsTab } from "@components/eventtype/EventLimitsTab";
|
||||||
import { EventRecurringTab } from "@components/eventtype/EventRecurringTab";
|
import { EventRecurringTab } from "@components/eventtype/EventRecurringTab";
|
||||||
import { EventSetupTab } from "@components/eventtype/EventSetupTab";
|
import { EventSetupTab } from "@components/eventtype/EventSetupTab";
|
||||||
|
@ -87,6 +90,7 @@ export type FormValues = {
|
||||||
successRedirectUrl: string;
|
successRedirectUrl: string;
|
||||||
durationLimits?: IntervalLimit;
|
durationLimits?: IntervalLimit;
|
||||||
bookingLimits?: IntervalLimit;
|
bookingLimits?: IntervalLimit;
|
||||||
|
children: ChildrenEventType[];
|
||||||
hosts: { userId: number; isFixed: boolean }[];
|
hosts: { userId: number; isFixed: boolean }[];
|
||||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||||
};
|
};
|
||||||
|
@ -111,10 +115,12 @@ const querySchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
|
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
|
||||||
|
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
|
||||||
|
|
||||||
const EventTypePage = (props: EventTypeSetupProps) => {
|
const EventTypePage = (props: EventTypeSetupProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
const {
|
const {
|
||||||
data: { tabName },
|
data: { tabName },
|
||||||
} = useTypedQuery(querySchema);
|
} = useTypedQuery(querySchema);
|
||||||
|
@ -126,6 +132,41 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||||
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
|
const { eventType, locationOptions, team, teamMembers, currentUserMembership, destinationCalendar } = props;
|
||||||
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
|
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 }>({
|
const [periodDates] = useState<{ startDate: Date; endDate: Date }>({
|
||||||
startDate: new Date(eventType.periodStartDate || Date.now()),
|
startDate: new Date(eventType.periodStartDate || Date.now()),
|
||||||
endDate: new Date(eventType.periodEndDate || Date.now()),
|
endDate: new Date(eventType.periodEndDate || Date.now()),
|
||||||
|
@ -173,6 +214,18 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||||
minimumBookingNotice: eventType.minimumBookingNotice,
|
minimumBookingNotice: eventType.minimumBookingNotice,
|
||||||
metadata,
|
metadata,
|
||||||
hosts: eventType.hosts,
|
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;
|
} as const;
|
||||||
|
|
||||||
const formMethods = useForm<FormValues>({
|
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(() => {
|
useEffect(() => {
|
||||||
if (!formMethods.formState.isDirty) {
|
if (!formMethods.formState.isDirty) {
|
||||||
//TODO: What's the best way to sync the form with backend
|
//TODO: What's the best way to sync the form with backend
|
||||||
|
@ -273,8 +284,8 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||||
destinationCalendar={destinationCalendar}
|
destinationCalendar={destinationCalendar}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
availability: <AvailabilityTab isTeamEvent={!!team} />,
|
availability: <EventAvailabilityTab eventType={eventType} isTeamEvent={!!team} />,
|
||||||
team: <EventTeamTab teamMembers={teamMembers} team={team} />,
|
team: <EventTeamTab teamMembers={teamMembers} team={team} eventType={eventType} />,
|
||||||
limits: <EventLimitsTab eventType={eventType} />,
|
limits: <EventLimitsTab eventType={eventType} />,
|
||||||
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
|
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
|
||||||
recurring: <EventRecurringTab eventType={eventType} />,
|
recurring: <EventRecurringTab eventType={eventType} />,
|
||||||
|
@ -288,89 +299,143 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||||
webhooks: <EventTeamWebhooksTab eventType={eventType} team={team} />,
|
webhooks: <EventTeamWebhooksTab eventType={eventType} team={team} />,
|
||||||
} as const;
|
} 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 (
|
return (
|
||||||
<EventTypeSingleLayout
|
<>
|
||||||
enabledAppsNumber={numberOfActiveApps}
|
<EventTypeSingleLayout
|
||||||
installedAppsNumber={numberOfInstalledApps}
|
enabledAppsNumber={numberOfActiveApps}
|
||||||
enabledWorkflowsNumber={eventType.workflows.length}
|
installedAppsNumber={numberOfInstalledApps}
|
||||||
eventType={eventType}
|
enabledWorkflowsNumber={eventType.workflows.length}
|
||||||
team={team}
|
eventType={eventType}
|
||||||
isUpdateMutationLoading={updateMutation.isLoading}
|
team={team}
|
||||||
formMethods={formMethods}
|
isUpdateMutationLoading={updateMutation.isLoading}
|
||||||
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
formMethods={formMethods}
|
||||||
currentUserMembership={currentUserMembership}>
|
disableBorder={tabName === "apps" || tabName === "workflows" || tabName === "webhooks"}
|
||||||
<Form
|
currentUserMembership={currentUserMembership}>
|
||||||
form={formMethods}
|
<Form
|
||||||
id="event-type-form"
|
form={formMethods}
|
||||||
handleSubmit={async (values) => {
|
id="event-type-form"
|
||||||
const {
|
handleSubmit={async (values: FormValues) => {
|
||||||
periodDates,
|
if (!values.children.length) return handleSubmit(values);
|
||||||
periodCountCalendarDays,
|
const existingSlugEventTypes = values.children.filter((ch) =>
|
||||||
beforeBufferTime,
|
ch.owner.eventTypeSlugs.includes(slug)
|
||||||
afterBufferTime,
|
);
|
||||||
seatsPerTimeSlot,
|
if (!existingSlugEventTypes.length) return handleSubmit(values);
|
||||||
seatsShowAttendees,
|
setSlugExistsChildrenDialogOpen(existingSlugEventTypes);
|
||||||
bookingLimits,
|
}}>
|
||||||
durationLimits,
|
<div ref={animationParentRef}>{tabMap[tabName]}</div>
|
||||||
recurringEvent,
|
</Form>
|
||||||
locations,
|
</EventTypeSingleLayout>
|
||||||
metadata,
|
<Dialog
|
||||||
customInputs,
|
open={slugExistsChildrenDialogOpen.length > 0}
|
||||||
// We don't need to send send these values to the backend
|
onOpenChange={() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
setSlugExistsChildrenDialogOpen([]);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}}>
|
}}>
|
||||||
<div ref={animationParentRef} className="space-y-6">
|
<ConfirmationDialogContent
|
||||||
{tabMap[tabName]}
|
isLoading={formMethods.formState.isSubmitting}
|
||||||
</div>
|
variety="warning"
|
||||||
</Form>
|
title={t("managed_event_dialog_title", {
|
||||||
</EventTypeSingleLayout>
|
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 { 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 Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import type { FC } from "react";
|
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 Item = ({ type, group, readOnly }: { type: EventType; group: EventTypeGroup; readOnly: boolean }) => {
|
||||||
const { t } = useLocale();
|
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
|
<Link
|
||||||
href={`/event-types/${type.id}?tabName=setup`}
|
href={`/event-types/${type.id}?tabName=setup`}
|
||||||
className="flex-1 overflow-hidden pr-4 text-sm"
|
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 [parent] = useAutoAnimate<HTMLUListElement>();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
|
const [deleteDialogTypeId, setDeleteDialogTypeId] = useState(0);
|
||||||
|
const [deleteDialogTypeSchedulingType, setDeleteDialogSchedulingType] = useState<SchedulingType | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const mutation = trpc.viewer.eventTypeOrder.useMutation({
|
const mutation = trpc.viewer.eventTypeOrder.useMutation({
|
||||||
onError: async (err) => {
|
onError: async (err) => {
|
||||||
|
@ -317,6 +356,9 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||||
{types.map((type, index) => {
|
{types.map((type, index) => {
|
||||||
const embedLink = `${group.profile.slug}/${type.slug}`;
|
const embedLink = `${group.profile.slug}/${type.slug}`;
|
||||||
const calLink = `${CAL_URL}/${embedLink}`;
|
const calLink = `${CAL_URL}/${embedLink}`;
|
||||||
|
const isManagedEventType = type.schedulingType === SchedulingType.MANAGED;
|
||||||
|
const isChildrenManagedEventType =
|
||||||
|
type.metadata?.managedEventConfig !== undefined && type.schedulingType !== SchedulingType.MANAGED;
|
||||||
return (
|
return (
|
||||||
<li key={type.id}>
|
<li key={type.id}>
|
||||||
<div className="hover:bg-muted flex w-full items-center justify-between">
|
<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} />
|
<MemoizedItem type={type} group={group} readOnly={readOnly} />
|
||||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||||
{type.team && (
|
{type.team && !isManagedEventType && (
|
||||||
<AvatarGroup
|
<AvatarGroup
|
||||||
className="relative top-1 right-3"
|
className="relative top-1 right-3"
|
||||||
size="sm"
|
size="sm"
|
||||||
truncateAfter={4}
|
truncateAfter={4}
|
||||||
items={type.users.map((organizer) => ({
|
items={type.users.map((organizer: { name: any; username: any }) => ({
|
||||||
alt: organizer.name || "",
|
alt: organizer.name || "",
|
||||||
image: `${WEBAPP_URL}/${organizer.username}/avatar.png`,
|
image: `${WEBAPP_URL}/${organizer.username}/avatar.png`,
|
||||||
title: organizer.name || "",
|
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">
|
<div className="flex items-center justify-between space-x-2 rtl:space-x-reverse">
|
||||||
{type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
|
{!isManagedEventType && (
|
||||||
<Tooltip content={t("show_eventtype_on_profile")}>
|
<>
|
||||||
<div className="self-center rounded-md p-2">
|
{type.hidden && <Badge variant="gray">{t("hidden")}</Badge>}
|
||||||
<Switch
|
<Tooltip content={t("show_eventtype_on_profile")}>
|
||||||
name="Hidden"
|
<div className="self-center rounded-md p-2">
|
||||||
checked={!type.hidden}
|
<Switch
|
||||||
onCheckedChange={() => {
|
name="Hidden"
|
||||||
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
|
checked={!type.hidden}
|
||||||
}}
|
onCheckedChange={() => {
|
||||||
/>
|
setHiddenMutation.mutate({ id: type.id, hidden: !type.hidden });
|
||||||
</div>
|
}}
|
||||||
</Tooltip>
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<ButtonGroup combined>
|
<ButtonGroup combined>
|
||||||
<Tooltip content={t("preview")}>
|
{!isManagedEventType && (
|
||||||
<Button
|
<>
|
||||||
data-testid="preview-link-button"
|
<Tooltip content={t("preview")}>
|
||||||
color="secondary"
|
<Button
|
||||||
target="_blank"
|
data-testid="preview-link-button"
|
||||||
variant="icon"
|
color="secondary"
|
||||||
href={calLink}
|
target="_blank"
|
||||||
StartIcon={ExternalLink}
|
variant="icon"
|
||||||
/>
|
href={calLink}
|
||||||
</Tooltip>
|
StartIcon={ExternalLink}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip content={t("copy_link")}>
|
<Tooltip content={t("copy_link")}>
|
||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="icon"
|
variant="icon"
|
||||||
StartIcon={LinkIcon}
|
StartIcon={LinkIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showToast(t("link_copied"), "success");
|
showToast(t("link_copied"), "success");
|
||||||
navigator.clipboard.writeText(calLink);
|
navigator.clipboard.writeText(calLink);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Dropdown modal={false}>
|
<Dropdown modal={false}>
|
||||||
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
<DropdownMenuTrigger asChild data-testid={"event-type-options-" + type.id}>
|
||||||
<Button
|
<Button
|
||||||
|
@ -399,50 +463,60 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||||
/>
|
/>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem>
|
{!readOnly && (
|
||||||
<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) && (
|
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
color="destructive"
|
type="button"
|
||||||
onClick={() => {
|
data-testid={"event-type-edit-" + type.id}
|
||||||
setDeleteDialogOpen(true);
|
StartIcon={Edit2}
|
||||||
setDeleteDialogTypeId(type.id);
|
onClick={() => router.push("/event-types/" + type.id)}>
|
||||||
}}
|
{t("edit")}
|
||||||
StartIcon={Trash}
|
|
||||||
className="w-full rounded-none">
|
|
||||||
{t("delete")}
|
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
@ -521,6 +595,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
setDeleteDialogTypeId(type.id);
|
setDeleteDialogTypeId(type.id);
|
||||||
|
setDeleteDialogSchedulingType(type.schedulingType);
|
||||||
}}
|
}}
|
||||||
StartIcon={Trash}
|
StartIcon={Trash}
|
||||||
className="w-full rounded-none">
|
className="w-full rounded-none">
|
||||||
|
@ -539,14 +614,37 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<ConfirmationDialogContent
|
<ConfirmationDialogContent
|
||||||
variety="danger"
|
variety="danger"
|
||||||
title={t("delete_event_type")}
|
title={t(
|
||||||
confirmBtnText={t("confirm_delete_event_type")}
|
`delete_${deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"}_event_type`
|
||||||
loadingText={t("confirm_delete_event_type")}
|
)}
|
||||||
|
confirmBtnText={t(
|
||||||
|
`confirm_delete_${
|
||||||
|
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
|
||||||
|
}_event_type`
|
||||||
|
)}
|
||||||
|
loadingText={t(
|
||||||
|
`confirm_delete_${
|
||||||
|
deleteDialogTypeSchedulingType === SchedulingType.MANAGED && "managed"
|
||||||
|
}_event_type`
|
||||||
|
)}
|
||||||
onConfirm={(e) => {
|
onConfirm={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
deleteEventTypeHandler(deleteDialogTypeId);
|
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>
|
</ConfirmationDialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
@ -638,6 +736,7 @@ const CTA = () => {
|
||||||
teamId: profile.teamId,
|
teamId: profile.teamId,
|
||||||
label: profile.name || profile.slug,
|
label: profile.name || profile.slug,
|
||||||
image: profile.image,
|
image: profile.image,
|
||||||
|
membershipRole: profile.membershipRole,
|
||||||
slug: profile.slug,
|
slug: profile.slug,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -646,7 +745,7 @@ const CTA = () => {
|
||||||
<CreateButton
|
<CreateButton
|
||||||
subtitle={t("create_event_on").toUpperCase()}
|
subtitle={t("create_event_on").toUpperCase()}
|
||||||
options={profileOptions}
|
options={profileOptions}
|
||||||
createDialog={CreateEventTypeDialog}
|
createDialog={() => <CreateEventTypeDialog profileOptions={profileOptions} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -268,6 +268,9 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||||
routingForms: user.routingForms,
|
routingForms: user.routingForms,
|
||||||
self,
|
self,
|
||||||
login: async () => login({ ...(await self()), password: user.username }, store.page),
|
login: async () => login({ ...(await self()), password: user.username }, store.page),
|
||||||
|
logout: async () => {
|
||||||
|
await page.goto("/auth/logout");
|
||||||
|
},
|
||||||
getPaymentCredential: async () => getPaymentCredential(store.page),
|
getPaymentCredential: async () => getPaymentCredential(store.page),
|
||||||
// ths is for developemnt only aimed to inject debugging messages in the metadata field of the user
|
// ths is for developemnt only aimed to inject debugging messages in the metadata field of the user
|
||||||
debug: async (message: string | Record<string, JSONValue>) => {
|
debug: async (message: string | Record<string, JSONValue>) => {
|
||||||
|
@ -333,9 +336,8 @@ export async function login(
|
||||||
await passwordLocator.fill(user.password ?? user.username!);
|
await passwordLocator.fill(user.password ?? user.username!);
|
||||||
await signInLocator.click();
|
await signInLocator.click();
|
||||||
|
|
||||||
// 2 seconds of delay to give the session enough time for a clean load
|
// Moving away from waiting 2 seconds, as it is not a reliable way to expect session to be started
|
||||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
await page.waitForLoadState("networkidle");
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPaymentCredential(page: Page) {
|
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",
|
"done": "Done",
|
||||||
"all_done": "All done!",
|
"all_done": "All done!",
|
||||||
"all_apps": "All",
|
"all_apps": "All",
|
||||||
|
"available_apps": "Available Apps",
|
||||||
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
"check_email_reset_password": "Check your email. We sent you a link to reset your password.",
|
||||||
"finish": "Finish",
|
"finish": "Finish",
|
||||||
"few_sentences_about_yourself": "A few sentences about yourself. This will appear on your personal url page.",
|
"few_sentences_about_yourself": "A few sentences about yourself. This will appear on your personal url page.",
|
||||||
|
@ -586,6 +587,20 @@
|
||||||
"minutes": "Minutes",
|
"minutes": "Minutes",
|
||||||
"round_robin": "Round Robin",
|
"round_robin": "Round Robin",
|
||||||
"round_robin_description": "Cycle meetings between multiple team members.",
|
"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",
|
"url": "URL",
|
||||||
"hidden": "Hidden",
|
"hidden": "Hidden",
|
||||||
"readonly": "Readonly",
|
"readonly": "Readonly",
|
||||||
|
@ -737,9 +752,11 @@
|
||||||
"minimum_booking_notice": "Minimum Notice",
|
"minimum_booking_notice": "Minimum Notice",
|
||||||
"slot_interval": "Time-slot intervals",
|
"slot_interval": "Time-slot intervals",
|
||||||
"slot_interval_default": "Use event length (default)",
|
"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?",
|
||||||
"delete_event_type": "Delete Event Type",
|
"delete_managed_event_type": "Delete managed event type?",
|
||||||
"confirm_delete_event_type": "Yes, delete 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",
|
"delete_account": "Delete account",
|
||||||
"confirm_delete_account": "Yes, 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.",
|
"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",
|
"duration_limit_reached": "Duration Limit for this event type has been reached",
|
||||||
"admin_has_disabled": "An admin has disabled {{appName}}",
|
"admin_has_disabled": "An admin has disabled {{appName}}",
|
||||||
"disabled_app_affects_event_type": "An admin has disabled {{appName}} which affects your event type {{eventType}}",
|
"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.",
|
"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.",
|
"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}}.",
|
"app_disabled_with_event_type": "The admin has disabled {{appName}} which affects your event type {{title}}.",
|
||||||
|
@ -1667,11 +1689,31 @@
|
||||||
"verification_code": "Verification code",
|
"verification_code": "Verification code",
|
||||||
"can_you_try_again": "Can you try again with a different time?",
|
"can_you_try_again": "Can you try again with a different time?",
|
||||||
"verify": "Verify",
|
"verify": "Verify",
|
||||||
"invalid_event_name_variables": "There is an invalid variable in your event name",
|
|
||||||
"select_all": "Select All",
|
"select_all": "Select All",
|
||||||
"default_conferencing_bulk_title": "Bulk update existing event types",
|
"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",
|
"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",
|
"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?",
|
"looking_for_more_insights": "Looking for more Insights?",
|
||||||
"add_filter": "Add filter",
|
"add_filter": "Add filter",
|
||||||
"select_user": "Select User",
|
"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 const ALL_APPS = Object.values(ALL_APPS_MAP);
|
||||||
|
|
||||||
export function getLocationGroupedOptions(integrations: ReturnType<typeof getApps>, t: TFunction) {
|
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) => {
|
integrations.forEach((app) => {
|
||||||
if (app.locationOption) {
|
if (app.locationOption) {
|
||||||
// All apps that are labeled as a locationOption are video apps. Extract the secondary category if available
|
// All apps that are labeled as a locationOption are video apps. Extract the secondary category if available
|
||||||
let category =
|
let category =
|
||||||
app.categories.length >= 2 ? app.categories.find((category) => category !== "video") : app.category;
|
app.categories.length >= 2 ? app.categories.find((category) => category !== "video") : app.category;
|
||||||
if (!category) category = "video";
|
if (!category) category = "video";
|
||||||
const option = { ...app.locationOption, icon: app.logo };
|
const option = { ...app.locationOption, icon: app.logo, slug: app.slug };
|
||||||
if (apps[category]) {
|
if (apps[category]) {
|
||||||
apps[category] = [...apps[category], option];
|
apps[category] = [...apps[category], option];
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -31,6 +31,7 @@ import OrganizerRequestReminderEmail from "./templates/organizer-request-reminde
|
||||||
import OrganizerRequestedToRescheduleEmail from "./templates/organizer-requested-to-reschedule-email";
|
import OrganizerRequestedToRescheduleEmail from "./templates/organizer-requested-to-reschedule-email";
|
||||||
import OrganizerRescheduledEmail from "./templates/organizer-rescheduled-email";
|
import OrganizerRescheduledEmail from "./templates/organizer-rescheduled-email";
|
||||||
import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
|
import OrganizerScheduledEmail from "./templates/organizer-scheduled-email";
|
||||||
|
import SlugReplacementEmail from "./templates/slug-replacement-email";
|
||||||
import type { TeamInvite } from "./templates/team-invite-email";
|
import type { TeamInvite } from "./templates/team-invite-email";
|
||||||
import TeamInviteEmail 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));
|
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) => {
|
export const sendNoShowFeeChargedEmail = async (attendee: Person, evt: CalendarEvent) => {
|
||||||
await sendEmail(() => new NoShowFeeChargedEmail(evt, attendee));
|
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 { BASE_URL, IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
|
|
||||||
import EmailCommonDivider from "./EmailCommonDivider";
|
import EmailCommonDivider from "./EmailCommonDivider";
|
||||||
import Row from "./Row";
|
import Row from "./Row";
|
||||||
|
|
||||||
export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle";
|
export type BodyHeadType = "checkCircle" | "xCircle" | "calendarCircle" | "teamCircle";
|
||||||
|
|
||||||
export const getHeadImage = (headerType: BodyHeadType): string => {
|
export const getHeadImage = (headerType: BodyHeadType): string => {
|
||||||
switch (headerType) {
|
switch (headerType) {
|
||||||
|
@ -21,6 +21,10 @@ export const getHeadImage = (headerType: BodyHeadType): string => {
|
||||||
return IS_PRODUCTION
|
return IS_PRODUCTION
|
||||||
? BASE_URL + "/emails/calendarCircle@2x.png"
|
? BASE_URL + "/emails/calendarCircle@2x.png"
|
||||||
: "https://app.cal.com/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 { AttendeeRescheduledEmail } from "./AttendeeRescheduledEmail";
|
||||||
export { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
|
export { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";
|
||||||
export { DisabledAppEmail } from "./DisabledAppEmail";
|
export { DisabledAppEmail } from "./DisabledAppEmail";
|
||||||
|
export { SlugReplacementEmail } from "./SlugReplacementEmail";
|
||||||
export { FeedbackEmail } from "./FeedbackEmail";
|
export { FeedbackEmail } from "./FeedbackEmail";
|
||||||
export { ForgotPasswordEmail } from "./ForgotPasswordEmail";
|
export { ForgotPasswordEmail } from "./ForgotPasswordEmail";
|
||||||
export { OrganizerCancelledEmail } from "./OrganizerCancelledEmail";
|
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({
|
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await utils.viewer.teams.get.invalidate();
|
await utils.viewer.teams.get.invalidate();
|
||||||
|
await utils.viewer.eventTypes.invalidate();
|
||||||
showToast("Member removed", "success");
|
showToast("Member removed", "success");
|
||||||
},
|
},
|
||||||
async onError(err) {
|
async onError(err) {
|
||||||
|
|
|
@ -56,6 +56,7 @@ export default function MemberListItem(props: Props) {
|
||||||
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
|
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await utils.viewer.teams.get.invalidate();
|
await utils.viewer.teams.get.invalidate();
|
||||||
|
await utils.viewer.eventTypes.invalidate();
|
||||||
showToast(t("success"), "success");
|
showToast(t("success"), "success");
|
||||||
},
|
},
|
||||||
async onError(err) {
|
async onError(err) {
|
||||||
|
|
|
@ -153,6 +153,7 @@ export default function TeamListItem(props: Props) {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
data-testid={`accept-invitation-${team.id}`}
|
||||||
StartIcon={Check}
|
StartIcon={Check}
|
||||||
className="ms-2 me-2"
|
className="ms-2 me-2"
|
||||||
onClick={acceptInvite}>
|
onClick={acceptInvite}>
|
||||||
|
|
|
@ -106,6 +106,7 @@ const ProfileView = () => {
|
||||||
async onSuccess() {
|
async onSuccess() {
|
||||||
await utils.viewer.teams.get.invalidate();
|
await utils.viewer.teams.get.invalidate();
|
||||||
await utils.viewer.teams.list.invalidate();
|
await utils.viewer.teams.list.invalidate();
|
||||||
|
await utils.viewer.eventTypes.invalidate();
|
||||||
showToast(t("success"), "success");
|
showToast(t("success"), "success");
|
||||||
},
|
},
|
||||||
async onError(err) {
|
async onError(err) {
|
||||||
|
|
|
@ -3,12 +3,14 @@ import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||||
import classNames from "@calcom/lib/classNames";
|
import classNames from "@calcom/lib/classNames";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button, EmptyScreen, showToast, Switch, Tooltip } from "@calcom/ui";
|
import { Button, EmptyScreen, showToast, Switch, Tooltip, Alert } from "@calcom/ui";
|
||||||
import { ExternalLink, Zap } from "@calcom/ui/components/icon";
|
import { ExternalLink, Zap, Lock } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import LicenseRequired from "../../common/components/v2/LicenseRequired";
|
import LicenseRequired from "../../common/components/v2/LicenseRequired";
|
||||||
import { getActionIcon } from "../lib/getActionIcon";
|
import { getActionIcon } from "../lib/getActionIcon";
|
||||||
|
@ -151,15 +153,10 @@ const WorkflowListItem = (props: ItemProps) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
eventType: {
|
eventType: EventTypeSetup;
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
userId: number | null;
|
|
||||||
team: {
|
|
||||||
id?: number;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
workflows: WorkflowType[];
|
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 (
|
return (
|
||||||
<LicenseRequired>
|
<LicenseRequired>
|
||||||
{!isLoading ? (
|
{!isLoading ? (
|
||||||
data?.workflows && data?.workflows.length > 0 ? (
|
<>
|
||||||
<div className="space-y-4">
|
{isManagedEventType && (
|
||||||
{sortedWorkflows.map((workflow) => {
|
<Alert
|
||||||
return <WorkflowListItem key={workflow.id} workflow={workflow} eventType={props.eventType} />;
|
severity="neutral"
|
||||||
})}
|
title={t("locked_for_members")}
|
||||||
</div>
|
message={t("locked_workflows_description")}
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</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 />
|
<SkeletonLoader />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { WorkflowActions } from "@prisma/client";
|
import type { WorkflowActions } from "@prisma/client";
|
||||||
import { WorkflowTemplates } from "@prisma/client";
|
import { WorkflowTemplates, SchedulingType } from "@prisma/client";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
@ -48,10 +48,15 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||||
if (teamId && teamId !== group.teamId) return options;
|
if (teamId && teamId !== group.teamId) return options;
|
||||||
return [
|
return [
|
||||||
...options,
|
...options,
|
||||||
...group.eventTypes.map((eventType) => ({
|
...group.eventTypes
|
||||||
value: String(eventType.id),
|
.filter(
|
||||||
label: eventType.title,
|
(evType) =>
|
||||||
})),
|
!evType.metadata?.managedEventConfig && evType.schedulingType !== SchedulingType.MANAGED
|
||||||
|
)
|
||||||
|
.map((eventType) => ({
|
||||||
|
value: String(eventType.id),
|
||||||
|
label: eventType.title,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
}, [] as Option[]) || [],
|
}, [] as Option[]) || [],
|
||||||
[data]
|
[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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { SchedulingType } from "@prisma/client";
|
import { SchedulingType } from "@prisma/client";
|
||||||
|
import { MembershipRole } from "@prisma/client";
|
||||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { md } from "@calcom/lib/markdownIt";
|
import { md } from "@calcom/lib/markdownIt";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import turndown from "@calcom/lib/turndownService";
|
import turndown from "@calcom/lib/turndownService";
|
||||||
|
import { unlockedManagedEventTypeProps } from "@calcom/prisma/zod-utils";
|
||||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import {
|
import {
|
||||||
|
@ -29,6 +34,7 @@ import {
|
||||||
// this describes the uniform data needed to create a new event type on Profile or Team
|
// this describes the uniform data needed to create a new event type on Profile or Team
|
||||||
export interface EventTypeParent {
|
export interface EventTypeParent {
|
||||||
teamId: number | null | undefined; // if undefined, then it's a profile
|
teamId: number | null | undefined; // if undefined, then it's a profile
|
||||||
|
membershipRole?: MembershipRole | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
|
@ -61,13 +67,23 @@ const querySchema = z.object({
|
||||||
.optional(),
|
.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 { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { teamId, eventPage: pageSlug },
|
data: { teamId, eventPage: pageSlug },
|
||||||
} = useTypedQuery(querySchema);
|
} = useTypedQuery(querySchema);
|
||||||
|
const teamProfile = profileOptions.find((profile) => profile.teamId === teamId);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
@ -76,8 +92,24 @@ export default function CreateEventTypeDialog() {
|
||||||
resolver: zodResolver(createEventTypeInput),
|
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 { register } = form;
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
teamId !== undefined &&
|
||||||
|
(teamProfile?.membershipRole === MembershipRole.OWNER ||
|
||||||
|
teamProfile?.membershipRole === MembershipRole.ADMIN);
|
||||||
|
|
||||||
const createMutation = trpc.viewer.eventTypes.create.useMutation({
|
const createMutation = trpc.viewer.eventTypes.create.useMutation({
|
||||||
onSuccess: async ({ eventType }) => {
|
onSuccess: async ({ eventType }) => {
|
||||||
await router.replace("/event-types/" + eventType.id);
|
await router.replace("/event-types/" + eventType.id);
|
||||||
|
@ -101,6 +133,8 @@ export default function CreateEventTypeDialog() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flags = useFlagMap();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
name="new"
|
name="new"
|
||||||
|
@ -147,52 +181,67 @@ export default function CreateEventTypeDialog() {
|
||||||
|
|
||||||
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
|
{process.env.NEXT_PUBLIC_WEBSITE_URL !== undefined &&
|
||||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
|
process.env.NEXT_PUBLIC_WEBSITE_URL?.length >= 21 ? (
|
||||||
<TextField
|
<div>
|
||||||
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
|
<TextField
|
||||||
required
|
label={`${t("url")}: ${process.env.NEXT_PUBLIC_WEBSITE_URL}`}
|
||||||
addOnLeading={<>/{pageSlug}/</>}
|
required
|
||||||
{...register("slug")}
|
addOnLeading={<>/{!isManagedEventType ? pageSlug : t("username_placeholder")}/</>}
|
||||||
onChange={(e) => {
|
{...register("slug")}
|
||||||
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
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
|
<div>
|
||||||
label={t("url")}
|
<TextField
|
||||||
required
|
label={t("url")}
|
||||||
addOnLeading={
|
required
|
||||||
<>
|
addOnLeading={
|
||||||
{process.env.NEXT_PUBLIC_WEBSITE_URL}/{pageSlug}/
|
<>
|
||||||
</>
|
{process.env.NEXT_PUBLIC_WEBSITE_URL}/
|
||||||
}
|
{!isManagedEventType ? pageSlug : t("username_placeholder")}/
|
||||||
{...register("slug")}
|
</>
|
||||||
/>
|
}
|
||||||
|
{...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
|
<div className="relative">
|
||||||
getText={() => md.render(form.getValues("description") || "")}
|
<TextField
|
||||||
setText={(value: string) => form.setValue("description", turndown(value))}
|
type="number"
|
||||||
excludedToolbarItems={["blockType", "link"]}
|
required
|
||||||
placeholder={t("quick_video_meeting")}
|
min="10"
|
||||||
/>
|
placeholder="15"
|
||||||
|
label={t("length")}
|
||||||
<div className="relative">
|
className="pr-4"
|
||||||
<TextField
|
{...register("length", { valueAsNumber: true })}
|
||||||
type="number"
|
addOnSuffix={t("minutes")}
|
||||||
required
|
/>
|
||||||
min="10"
|
</div>
|
||||||
placeholder="15"
|
</>
|
||||||
label={t("length")}
|
)}
|
||||||
className="pr-4"
|
|
||||||
{...register("length", { valueAsNumber: true })}
|
|
||||||
addOnSuffix={t("minutes")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{teamId && (
|
{teamId && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="schedulingType" className="text-default block text-sm font-bold">
|
<label htmlFor="schedulingType" className="text-default block text-sm font-bold">
|
||||||
{t("scheduling_type")}
|
{t("assignment")}
|
||||||
</label>
|
</label>
|
||||||
{form.formState.errors.schedulingType && (
|
{form.formState.errors.schedulingType && (
|
||||||
<Alert
|
<Alert
|
||||||
|
@ -201,21 +250,39 @@ export default function CreateEventTypeDialog() {
|
||||||
message={form.formState.errors.schedulingType.message}
|
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
|
<RadioArea.Item
|
||||||
{...register("schedulingType")}
|
{...register("schedulingType")}
|
||||||
value={SchedulingType.COLLECTIVE}
|
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>
|
<strong className="mb-1 block">{t("collective")}</strong>
|
||||||
<p>{t("collective_description")}</p>
|
<p>{t("collective_description")}</p>
|
||||||
</RadioArea.Item>
|
</RadioArea.Item>
|
||||||
<RadioArea.Item
|
<RadioArea.Item
|
||||||
{...register("schedulingType")}
|
{...register("schedulingType")}
|
||||||
value={SchedulingType.ROUND_ROBIN}
|
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>
|
<strong className="mb-1 block">{t("round_robin")}</strong>
|
||||||
<p>{t("round_robin_description")}</p>
|
<p>{t("round_robin_description")}</p>
|
||||||
</RadioArea.Item>
|
</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>
|
</RadioArea.Group>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { baseEventTypeSelect } from "@calcom/prisma";
|
import type { baseEventTypeSelect } from "@calcom/prisma";
|
||||||
import type { EventTypeModel } from "@calcom/prisma/zod";
|
import type { EventTypeModel } from "@calcom/prisma/zod";
|
||||||
import { Badge } from "@calcom/ui";
|
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 = {
|
export type EventTypeDescriptionProps = {
|
||||||
eventType: Pick<
|
eventType: Pick<
|
||||||
|
@ -23,12 +23,14 @@ export type EventTypeDescriptionProps = {
|
||||||
};
|
};
|
||||||
className?: string;
|
className?: string;
|
||||||
shortenDescription?: boolean;
|
shortenDescription?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EventTypeDescription = ({
|
export const EventTypeDescription = ({
|
||||||
eventType,
|
eventType,
|
||||||
className,
|
className,
|
||||||
shortenDescription,
|
shortenDescription,
|
||||||
|
isPublic,
|
||||||
}: EventTypeDescriptionProps) => {
|
}: EventTypeDescriptionProps) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
@ -69,7 +71,7 @@ export const EventTypeDescription = ({
|
||||||
</Badge>
|
</Badge>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
{eventType.schedulingType && (
|
{eventType.schedulingType && eventType.schedulingType !== SchedulingType.MANAGED && (
|
||||||
<li>
|
<li>
|
||||||
<Badge variant="gray" startIcon={Users}>
|
<Badge variant="gray" startIcon={Users}>
|
||||||
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
|
{eventType.schedulingType === SchedulingType.ROUND_ROBIN && t("round_robin")}
|
||||||
|
@ -77,6 +79,11 @@ export const EventTypeDescription = ({
|
||||||
</Badge>
|
</Badge>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{eventType.metadata?.managedEventConfig && !isPublic && (
|
||||||
|
<Badge variant="gray" startIcon={Lock}>
|
||||||
|
{t("managed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{recurringEvent?.count && recurringEvent.count > 0 && (
|
{recurringEvent?.count && recurringEvent.count > 0 && (
|
||||||
<li className="hidden xl:block">
|
<li className="hidden xl:block">
|
||||||
<Badge variant="gray" startIcon={RefreshCw}>
|
<Badge variant="gray" startIcon={RefreshCw}>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import type { RouterOutputs } 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 = () => {
|
export const FlagAdminList = () => {
|
||||||
const [data] = trpc.viewer.features.list.useSuspenseQuery();
|
const [data] = trpc.viewer.features.list.useSuspenseQuery();
|
||||||
|
@ -12,7 +12,7 @@ export const FlagAdminList = () => {
|
||||||
<ListItemTitle component="h3">
|
<ListItemTitle component="h3">
|
||||||
{flag.slug}
|
{flag.slug}
|
||||||
|
|
||||||
<Badge variant="green">{flag.type}</Badge>
|
<Badge variant="green">{flag.type?.replace("_", " ")}</Badge>
|
||||||
</ListItemTitle>
|
</ListItemTitle>
|
||||||
<ListItemText component="p">{flag.description}</ListItemText>
|
<ListItemText component="p">{flag.description}</ListItemText>
|
||||||
</div>
|
</div>
|
||||||
|
@ -34,6 +34,7 @@ const FlagToggle = (props: { flag: Flag }) => {
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
const mutation = trpc.viewer.features.toggle.useMutation({
|
const mutation = trpc.viewer.features.toggle.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
showToast("Flags successfully updated", "success");
|
||||||
utils.viewer.features.list.invalidate();
|
utils.viewer.features.list.invalidate();
|
||||||
utils.viewer.features.map.invalidate();
|
utils.viewer.features.map.invalidate();
|
||||||
},
|
},
|
||||||
|
|
|
@ -9,4 +9,5 @@ export type AppFlags = {
|
||||||
webhooks: boolean;
|
webhooks: boolean;
|
||||||
workflows: boolean;
|
workflows: boolean;
|
||||||
"v2-booking-page": boolean;
|
"v2-booking-page": boolean;
|
||||||
|
"managed-event-types": boolean;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
export function useFlags() {
|
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;
|
return query.data;
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,12 +44,16 @@ export const FormBuilder = function FormBuilder({
|
||||||
description,
|
description,
|
||||||
addFieldLabel,
|
addFieldLabel,
|
||||||
formProp,
|
formProp,
|
||||||
|
disabled,
|
||||||
|
LockedIcon,
|
||||||
dataStore,
|
dataStore,
|
||||||
}: {
|
}: {
|
||||||
formProp: string;
|
formProp: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
addFieldLabel: 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
|
* 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<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>
|
<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) => {
|
{fields.map((field, index) => {
|
||||||
const options = field.options
|
const options = field.options
|
||||||
? field.options
|
? field.options
|
||||||
|
@ -309,22 +316,27 @@ export const FormBuilder = function FormBuilder({
|
||||||
key={field.name}
|
key={field.name}
|
||||||
data-testid={`field-${field.name}`}
|
data-testid={`field-${field.name}`}
|
||||||
className="hover:bg-muted group relative flex items-center justify-between p-4 ">
|
className="hover:bg-muted group relative flex items-center justify-between p-4 ">
|
||||||
{index >= 1 && (
|
{!disabled && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
{index >= 1 && (
|
||||||
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"
|
<button
|
||||||
onClick={() => swap(index, index - 1)}>
|
type="button"
|
||||||
<ArrowUp className="h-5 w-5" />
|
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"
|
||||||
</button>
|
onClick={() => swap(index, index - 1)}>
|
||||||
)}
|
<ArrowUp className="h-5 w-5" />
|
||||||
{index < fields.length - 1 && (
|
</button>
|
||||||
<button
|
)}
|
||||||
type="button"
|
{index < fields.length - 1 && (
|
||||||
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"
|
<button
|
||||||
onClick={() => swap(index, index + 1)}>
|
type="button"
|
||||||
<ArrowDown className="h-5 w-5" />
|
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"
|
||||||
</button>
|
onClick={() => swap(index, index + 1)}>
|
||||||
|
<ArrowDown className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center">
|
<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">
|
<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}
|
{fieldType.label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{field.editable !== "user-readonly" && (
|
{field.editable !== "user-readonly" && !disabled && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{!isFieldEditableSystem && (
|
{!isFieldEditableSystem && !disabled && (
|
||||||
<Switch
|
<Switch
|
||||||
data-testid="toggle-field"
|
data-testid="toggle-field"
|
||||||
disabled={isFieldEditableSystem}
|
disabled={isFieldEditableSystem}
|
||||||
|
@ -388,9 +400,16 @@ export const FormBuilder = function FormBuilder({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<Button color="minimal" data-testid="add-field" onClick={addField} className="mt-4" StartIcon={Plus}>
|
{!disabled && (
|
||||||
{addFieldLabel}
|
<Button
|
||||||
</Button>
|
color="minimal"
|
||||||
|
data-testid="add-field"
|
||||||
|
onClick={addField}
|
||||||
|
className="mt-4"
|
||||||
|
StartIcon={Plus}>
|
||||||
|
{addFieldLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={fieldDialog.isOpen}
|
open={fieldDialog.isOpen}
|
||||||
|
|
|
@ -30,6 +30,7 @@ type WebhookProps = {
|
||||||
|
|
||||||
export default function WebhookListItem(props: {
|
export default function WebhookListItem(props: {
|
||||||
webhook: WebhookProps;
|
webhook: WebhookProps;
|
||||||
|
canEditWebhook?: boolean;
|
||||||
onEditWebhook: () => void;
|
onEditWebhook: () => void;
|
||||||
lastItem: boolean;
|
lastItem: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
@ -81,6 +82,7 @@ export default function WebhookListItem(props: {
|
||||||
<div className="ml-2 flex items-center space-x-4">
|
<div className="ml-2 flex items-center space-x-4">
|
||||||
<Switch
|
<Switch
|
||||||
defaultChecked={webhook.active}
|
defaultChecked={webhook.active}
|
||||||
|
disabled={!props.canEditWebhook}
|
||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
toggleWebhook.mutate({
|
toggleWebhook.mutate({
|
||||||
id: webhook.id,
|
id: webhook.id,
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { MembershipRole } from "@prisma/client";
|
||||||
|
import { SchedulingType } from "@prisma/client";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
import type { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
import type { StripeData } from "@calcom/app-store/stripepayment/lib/server";
|
||||||
|
@ -114,7 +116,14 @@ export default async function getEventTypeById({
|
||||||
select: {
|
select: {
|
||||||
role: true,
|
role: true,
|
||||||
user: {
|
user: {
|
||||||
select: userSelect,
|
select: {
|
||||||
|
...userSelect,
|
||||||
|
eventTypes: {
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -127,6 +136,7 @@ export default async function getEventTypeById({
|
||||||
schedule: {
|
schedule: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
hosts: {
|
hosts: {
|
||||||
|
@ -137,6 +147,20 @@ export default async function getEventTypeById({
|
||||||
},
|
},
|
||||||
userId: true,
|
userId: true,
|
||||||
price: true,
|
price: true,
|
||||||
|
children: {
|
||||||
|
select: {
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
username: true,
|
||||||
|
email: true,
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hidden: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
destinationCalendar: true,
|
destinationCalendar: true,
|
||||||
seatsPerTimeSlot: true,
|
seatsPerTimeSlot: true,
|
||||||
seatsShowAttendees: true,
|
seatsShowAttendees: true,
|
||||||
|
@ -235,12 +259,31 @@ export default async function getEventTypeById({
|
||||||
const eventType = {
|
const eventType = {
|
||||||
...restEventType,
|
...restEventType,
|
||||||
schedule: rawEventType.schedule?.id || rawEventType.users[0]?.defaultScheduleId || null,
|
schedule: rawEventType.schedule?.id || rawEventType.users[0]?.defaultScheduleId || null,
|
||||||
|
scheduleName: rawEventType.schedule?.name || null,
|
||||||
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
|
recurringEvent: parseRecurringEvent(restEventType.recurringEvent),
|
||||||
bookingLimits: parseBookingLimit(restEventType.bookingLimits),
|
bookingLimits: parseBookingLimit(restEventType.bookingLimits),
|
||||||
durationLimits: parseDurationLimit(restEventType.durationLimits),
|
durationLimits: parseDurationLimit(restEventType.durationLimits),
|
||||||
locations: locations as unknown as LocationObject[],
|
locations: locations as unknown as LocationObject[],
|
||||||
metadata: parsedMetaData,
|
metadata: parsedMetaData,
|
||||||
customInputs: parsedCustomInputs,
|
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
|
// backwards compat
|
||||||
|
@ -267,6 +310,18 @@ export default async function getEventTypeById({
|
||||||
const t = await getTranslation(currentUser?.locale ?? "en", "common");
|
const t = await getTranslation(currentUser?.locale ?? "en", "common");
|
||||||
const integrations = await getEnabledApps(credentials);
|
const integrations = await getEnabledApps(credentials);
|
||||||
const locationOptions = getLocationGroupedOptions(integrations, t);
|
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, {
|
const eventTypeObject = Object.assign({}, eventType, {
|
||||||
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
periodStartDate: eventType.periodStartDate?.toString() ?? null,
|
||||||
|
@ -278,7 +333,7 @@ export default async function getEventTypeById({
|
||||||
? eventTypeObject.team.members.map((member) => {
|
? eventTypeObject.team.members.map((member) => {
|
||||||
const user = member.user;
|
const user = member.user;
|
||||||
user.avatar = `${CAL_URL}/${user.username}/avatar.png`;
|
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 prisma, { baseEventTypeSelect } from "@calcom/prisma";
|
||||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||||
|
@ -39,6 +39,9 @@ export async function getTeamWithMembers(id?: number, slug?: string, userId?: nu
|
||||||
eventTypes: {
|
eventTypes: {
|
||||||
where: {
|
where: {
|
||||||
hidden: false,
|
hidden: false,
|
||||||
|
schedulingType: {
|
||||||
|
not: SchedulingType.MANAGED,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
users: {
|
users: {
|
||||||
|
|
|
@ -22,6 +22,7 @@ export const telemetryEventTypes = {
|
||||||
website: {
|
website: {
|
||||||
pageView: "website_page_view",
|
pageView: "website_page_view",
|
||||||
},
|
},
|
||||||
|
slugReplacementAction: "slug_replacement_action",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function collectPageParameters(
|
export function collectPageParameters(
|
||||||
|
|
|
@ -98,7 +98,8 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
||||||
slotInterval: null,
|
slotInterval: null,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
successRedirectUrl: null,
|
successRedirectUrl: null,
|
||||||
bookingFields: null,
|
bookingFields: [],
|
||||||
|
parentId: null,
|
||||||
...eventType,
|
...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 {
|
enum SchedulingType {
|
||||||
ROUND_ROBIN @map("roundRobin")
|
ROUND_ROBIN @map("roundRobin")
|
||||||
COLLECTIVE @map("collective")
|
COLLECTIVE @map("collective")
|
||||||
|
MANAGED @map("managed")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PeriodType {
|
enum PeriodType {
|
||||||
|
@ -64,6 +65,9 @@ model EventType {
|
||||||
destinationCalendar DestinationCalendar?
|
destinationCalendar DestinationCalendar?
|
||||||
eventName String?
|
eventName String?
|
||||||
customInputs EventTypeCustomInput[]
|
customInputs EventTypeCustomInput[]
|
||||||
|
parentId Int?
|
||||||
|
parent EventType? @relation("managed_eventtype", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
|
children EventType[] @relation("managed_eventtype")
|
||||||
/// @zod.custom(imports.eventTypeBookingFields)
|
/// @zod.custom(imports.eventTypeBookingFields)
|
||||||
bookingFields Json?
|
bookingFields Json?
|
||||||
timeZone String?
|
timeZone String?
|
||||||
|
@ -103,6 +107,7 @@ model EventType {
|
||||||
|
|
||||||
@@unique([userId, slug])
|
@@unique([userId, slug])
|
||||||
@@unique([teamId, slug])
|
@@unique([teamId, slug])
|
||||||
|
@@unique([userId, parentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Credential {
|
model Credential {
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
import { EventTypeCustomInputType } from "@prisma/client";
|
import { EventTypeCustomInputType } from "@prisma/client";
|
||||||
import type { UnitTypeLongPlural } from "dayjs";
|
import type { UnitTypeLongPlural } from "dayjs";
|
||||||
|
import { pick } from "lodash";
|
||||||
import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
|
import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
|
||||||
|
|
||||||
/* eslint-disable no-underscore-dangle */
|
/* eslint-disable no-underscore-dangle */
|
||||||
|
@ -39,6 +41,11 @@ export const EventTypeMetaDataSchema = z
|
||||||
apps: z.object(appDataSchemas).partial().optional(),
|
apps: z.object(appDataSchemas).partial().optional(),
|
||||||
additionalNotesRequired: z.boolean().optional(),
|
additionalNotesRequired: z.boolean().optional(),
|
||||||
disableSuccessPage: 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
|
requiresConfirmationThreshold: z
|
||||||
.object({
|
.object({
|
||||||
time: z.number(),
|
time: z.number(),
|
||||||
|
@ -442,3 +449,51 @@ export const getAccessLinkResponseSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GetAccessLinkResponseSchema = z.infer<typeof getAccessLinkResponseSchema>;
|
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(),
|
teamId: z.number().int().nullish(),
|
||||||
schedulingType: z.nativeEnum(SchedulingType).nullish(),
|
schedulingType: z.nativeEnum(SchedulingType).nullish(),
|
||||||
locations: imports.eventTypeLocations,
|
locations: imports.eventTypeLocations,
|
||||||
|
metadata: imports.EventTypeMetaDataSchema.optional(),
|
||||||
})
|
})
|
||||||
.partial({ hidden: true, locations: true })
|
.partial({ hidden: true, locations: true })
|
||||||
.refine((data) => (data.teamId ? data.teamId && data.schedulingType : true), {
|
.refine((data) => (data.teamId ? data.teamId && data.schedulingType : true), {
|
||||||
|
|
|
@ -74,6 +74,7 @@ export const availabilityRouter = router({
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
scheduleId: z.optional(z.number()),
|
scheduleId: z.optional(z.number()),
|
||||||
|
isManagedEventType: z.optional(z.boolean()),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.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({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
});
|
});
|
||||||
|
@ -112,6 +113,7 @@ export const availabilityRouter = router({
|
||||||
return {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
name: schedule.name,
|
name: schedule.name,
|
||||||
|
isManaged: schedule.userId !== user.id,
|
||||||
workingHours: getWorkingHours(
|
workingHours: getWorkingHours(
|
||||||
{ timeZone: schedule.timeZone || undefined },
|
{ timeZone: schedule.timeZone || undefined },
|
||||||
schedule.availability || []
|
schedule.availability || []
|
||||||
|
|
|
@ -9,6 +9,7 @@ import type { LocationObject } from "@calcom/app-store/locations";
|
||||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||||
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
|
import { stripeDataSchema } from "@calcom/app-store/stripepayment/lib/server";
|
||||||
import getApps, { getAppFromLocationValue, getAppFromSlug } from "@calcom/app-store/utils";
|
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 { validateIntervalLimitOrder } from "@calcom/lib";
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { CAL_URL } from "@calcom/lib/constants";
|
||||||
import getEventTypeById from "@calcom/lib/getEventTypeById";
|
import getEventTypeById from "@calcom/lib/getEventTypeById";
|
||||||
|
@ -20,7 +21,6 @@ import { eventTypeLocations as eventTypeLocationsSchema } from "@calcom/prisma/z
|
||||||
import {
|
import {
|
||||||
customInputSchema,
|
customInputSchema,
|
||||||
EventTypeMetaDataSchema,
|
EventTypeMetaDataSchema,
|
||||||
stringOrNumber,
|
|
||||||
userMetadata as userMetadataSchema,
|
userMetadata as userMetadataSchema,
|
||||||
} from "@calcom/prisma/zod-utils";
|
} from "@calcom/prisma/zod-utils";
|
||||||
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
import { createEventTypeInput } from "@calcom/prisma/zod/custom/eventtype";
|
||||||
|
@ -88,7 +88,19 @@ const EventTypeUpdateInput = _EventTypeModel
|
||||||
integration: true,
|
integration: true,
|
||||||
externalId: 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
|
hosts: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
@ -124,7 +136,7 @@ const eventOwnerProcedure = authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
users: z.array(z.string()).optional().default([]),
|
users: z.array(z.number()).optional().default([]),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.use(async ({ ctx, input, next }) => {
|
.use(async ({ ctx, input, next }) => {
|
||||||
|
@ -168,9 +180,9 @@ const eventOwnerProcedure = authedProcedure
|
||||||
const isAllowed = (function () {
|
const isAllowed = (function () {
|
||||||
if (event.team) {
|
if (event.team) {
|
||||||
const allTeamMembers = event.team.members.map((member) => member.userId);
|
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) {
|
if (!isAllowed) {
|
||||||
|
@ -193,6 +205,7 @@ export const eventTypesRouter = router({
|
||||||
hashedLink: true,
|
hashedLink: true,
|
||||||
locations: true,
|
locations: true,
|
||||||
destinationCalendar: true,
|
destinationCalendar: true,
|
||||||
|
userId: true,
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
@ -207,6 +220,11 @@ export const eventTypesRouter = router({
|
||||||
users: {
|
users: {
|
||||||
select: baseUserSelect,
|
select: baseUserSelect,
|
||||||
},
|
},
|
||||||
|
children: {
|
||||||
|
include: {
|
||||||
|
users: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
hosts: {
|
hosts: {
|
||||||
select: {
|
select: {
|
||||||
user: {
|
user: {
|
||||||
|
@ -286,8 +304,7 @@ export const eventTypesRouter = router({
|
||||||
...eventType,
|
...eventType,
|
||||||
safeDescription: markdownToSafeHTML(eventType.description),
|
safeDescription: markdownToSafeHTML(eventType.description),
|
||||||
users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users,
|
users: !!eventType.hosts?.length ? eventType.hosts.map((host) => host.user) : eventType.users,
|
||||||
// @FIXME: cc @hariombalhara This is failing with production data
|
metadata: eventType.metadata ? EventTypeMetaDataSchema.parse(eventType.metadata) : undefined,
|
||||||
// metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const userEventTypes = user.eventTypes.map(mapEventType);
|
const userEventTypes = user.eventTypes.map(mapEventType);
|
||||||
|
@ -311,6 +328,7 @@ export const eventTypesRouter = router({
|
||||||
|
|
||||||
type EventTypeGroup = {
|
type EventTypeGroup = {
|
||||||
teamId?: number | null;
|
teamId?: number | null;
|
||||||
|
membershipRole?: MembershipRole | null;
|
||||||
profile: {
|
profile: {
|
||||||
slug: (typeof user)["username"];
|
slug: (typeof user)["username"];
|
||||||
name: (typeof user)["name"];
|
name: (typeof user)["name"];
|
||||||
|
@ -329,9 +347,12 @@ export const eventTypesRouter = router({
|
||||||
hashMap[newItem.id] = { ...oldItem, ...newItem };
|
hashMap[newItem.id] = { ...oldItem, ...newItem };
|
||||||
return hashMap;
|
return hashMap;
|
||||||
}, {} as Record<number, EventTypeGroup["eventTypes"][number]>);
|
}, {} 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({
|
eventTypeGroups.push({
|
||||||
teamId: null,
|
teamId: null,
|
||||||
|
membershipRole: null,
|
||||||
profile: {
|
profile: {
|
||||||
slug: user.username,
|
slug: user.username,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
@ -348,6 +369,7 @@ export const eventTypesRouter = router({
|
||||||
eventTypeGroups,
|
eventTypeGroups,
|
||||||
user.teams.map((membership) => ({
|
user.teams.map((membership) => ({
|
||||||
teamId: membership.team.id,
|
teamId: membership.team.id,
|
||||||
|
membershipRole: membership.role,
|
||||||
profile: {
|
profile: {
|
||||||
name: membership.team.name,
|
name: membership.team.name,
|
||||||
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
|
image: `${CAL_URL}/team/${membership.team.slug}/avatar.png`,
|
||||||
|
@ -357,7 +379,14 @@ export const eventTypesRouter = router({
|
||||||
membershipCount: membership.team.members.length,
|
membershipCount: membership.team.members.length,
|
||||||
readOnly: membership.role === MembershipRole.MEMBER,
|
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 {
|
return {
|
||||||
|
@ -366,6 +395,7 @@ export const eventTypesRouter = router({
|
||||||
// so we can show a dropdown when the user has teams
|
// so we can show a dropdown when the user has teams
|
||||||
profiles: eventTypeGroups.map((group) => ({
|
profiles: eventTypeGroups.map((group) => ({
|
||||||
teamId: group.teamId,
|
teamId: group.teamId,
|
||||||
|
membershipRole: group.membershipRole,
|
||||||
...group.profile,
|
...group.profile,
|
||||||
...group.metadata,
|
...group.metadata,
|
||||||
})),
|
})),
|
||||||
|
@ -419,9 +449,9 @@ export const eventTypesRouter = router({
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
create: authedProcedure.input(createEventTypeInput).mutation(async ({ ctx, input }) => {
|
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 userId = ctx.user.id;
|
||||||
|
const isManagedEventType = schedulingType === SchedulingType.MANAGED;
|
||||||
// Get Users default conferncing app
|
// Get Users default conferncing app
|
||||||
|
|
||||||
const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp;
|
const defaultConferencingData = userMetadataSchema.parse(ctx.user.metadata)?.defaultConferencingApp;
|
||||||
|
@ -448,11 +478,9 @@ export const eventTypesRouter = router({
|
||||||
const data: Prisma.EventTypeCreateInput = {
|
const data: Prisma.EventTypeCreateInput = {
|
||||||
...rest,
|
...rest,
|
||||||
owner: teamId ? undefined : { connect: { id: userId } },
|
owner: teamId ? undefined : { connect: { id: userId } },
|
||||||
users: {
|
metadata: (metadata as Prisma.InputJsonObject) ?? undefined,
|
||||||
connect: {
|
// Only connecting the current user for non-managed event type
|
||||||
id: userId,
|
users: isManagedEventType ? undefined : { connect: { id: userId } },
|
||||||
},
|
|
||||||
},
|
|
||||||
locations,
|
locations,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -535,6 +563,7 @@ export const eventTypesRouter = router({
|
||||||
customInputs,
|
customInputs,
|
||||||
recurringEvent,
|
recurringEvent,
|
||||||
users,
|
users,
|
||||||
|
children,
|
||||||
hosts,
|
hosts,
|
||||||
id,
|
id,
|
||||||
hashedLink,
|
hashedLink,
|
||||||
|
@ -552,7 +581,7 @@ export const eventTypesRouter = router({
|
||||||
const data: Prisma.EventTypeUpdateInput = {
|
const data: Prisma.EventTypeUpdateInput = {
|
||||||
...rest,
|
...rest,
|
||||||
bookingFields,
|
bookingFields,
|
||||||
metadata: rest.metadata === null ? Prisma.DbNull : rest.metadata,
|
metadata: rest.metadata === null ? Prisma.DbNull : (rest.metadata as Prisma.InputJsonObject),
|
||||||
};
|
};
|
||||||
data.locations = locations ?? undefined;
|
data.locations = locations ?? undefined;
|
||||||
if (periodType) {
|
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({
|
// Handling updates to children event types (managed events types)
|
||||||
where: { id },
|
await updateChildrenEventTypes({
|
||||||
data,
|
eventTypeId: id,
|
||||||
|
currentUserId: ctx.user.id,
|
||||||
|
oldEventType,
|
||||||
|
hashedLink,
|
||||||
|
connectedLink,
|
||||||
|
updatedEventType: eventType,
|
||||||
|
children,
|
||||||
|
prisma: ctx.prisma,
|
||||||
});
|
});
|
||||||
const res = ctx.res as NextApiResponse;
|
const res = ctx.res as NextApiResponse;
|
||||||
if (typeof res?.revalidate !== "undefined") {
|
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
|
// Sync Services
|
||||||
closeComDeleteTeamMembership(membership.user);
|
closeComDeleteTeamMembership(membership.user);
|
||||||
if (IS_TEAM_BILLING_ENABLED) await updateQuantitySubscriptionFromStripe(input.teamId);
|
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 displayedAvatars = props.items.filter((avatar) => avatar.image).slice(0, truncateAfter);
|
||||||
const numTruncatedAvatars = LENGTH - displayedAvatars.length;
|
const numTruncatedAvatars = LENGTH - displayedAvatars.length;
|
||||||
|
|
||||||
|
if (!displayedAvatars.length) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={classNames("flex items-center", props.className)}>
|
<ul className={classNames("flex items-center", props.className)}>
|
||||||
{displayedAvatars.map((item, idx) => (
|
{displayedAvatars.map((item, idx) => (
|
||||||
|
|
|
@ -83,6 +83,7 @@ export function CreateButton(props: CreateBtnProps) {
|
||||||
<Button
|
<Button
|
||||||
variant={props.disableMobileButton ? "button" : "fab"}
|
variant={props.disableMobileButton ? "button" : "fab"}
|
||||||
StartIcon={Plus}
|
StartIcon={Plus}
|
||||||
|
data-testid="new-event-type-dropdown"
|
||||||
loading={props.isLoading}>
|
loading={props.isLoading}>
|
||||||
{props.buttonText ? props.buttonText : t("new")}
|
{props.buttonText ? props.buttonText : t("new")}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -91,10 +92,11 @@ export function CreateButton(props: CreateBtnProps) {
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
<div className="w-48 text-left text-xs">{props.subtitle}</div>
|
<div className="w-48 text-left text-xs">{props.subtitle}</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{props.options.map((option) => (
|
{props.options.map((option, idx) => (
|
||||||
<DropdownMenuItem key={option.label}>
|
<DropdownMenuItem key={option.label}>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
type="button"
|
type="button"
|
||||||
|
data-testid={`option${option.teamId ? "-team" : ""}-${idx}`}
|
||||||
StartIcon={(props) => (
|
StartIcon={(props) => (
|
||||||
<Avatar
|
<Avatar
|
||||||
alt={option.label || ""}
|
alt={option.label || ""}
|
||||||
|
|
|
@ -11,6 +11,8 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
||||||
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
|
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
|
||||||
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
|
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
|
||||||
|
|
||||||
|
import { classNames } from "@calcom/lib";
|
||||||
|
|
||||||
import ExampleTheme from "./ExampleTheme";
|
import ExampleTheme from "./ExampleTheme";
|
||||||
import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
|
import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
|
||||||
import ToolbarPlugin from "./plugins/ToolbarPlugin";
|
import ToolbarPlugin from "./plugins/ToolbarPlugin";
|
||||||
|
@ -31,6 +33,7 @@ export type TextEditorProps = {
|
||||||
height?: string;
|
height?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disableLists?: boolean;
|
disableLists?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const editorConfig = {
|
const editorConfig = {
|
||||||
|
@ -55,17 +58,21 @@ const editorConfig = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Editor = (props: TextEditorProps) => {
|
export const Editor = (props: TextEditorProps) => {
|
||||||
|
const editable = props.editable ?? true;
|
||||||
return (
|
return (
|
||||||
<div className="editor">
|
<div className="editor rounded-md">
|
||||||
<LexicalComposer initialConfig={editorConfig}>
|
<LexicalComposer initialConfig={{ ...editorConfig, editable }}>
|
||||||
<div className="editor-container">
|
<div className="editor-container rounded-md p-0">
|
||||||
<ToolbarPlugin
|
<ToolbarPlugin
|
||||||
getText={props.getText}
|
getText={props.getText}
|
||||||
setText={props.setText}
|
setText={props.setText}
|
||||||
|
editable={editable}
|
||||||
excludedToolbarItems={props.excludedToolbarItems}
|
excludedToolbarItems={props.excludedToolbarItems}
|
||||||
variables={props.variables}
|
variables={props.variables}
|
||||||
/>
|
/>
|
||||||
<div className="editor-inner" style={{ height: props.height }}>
|
<div
|
||||||
|
className={classNames("editor-inner", !editable && "bg-muted")}
|
||||||
|
style={{ height: props.height }}>
|
||||||
<RichTextPlugin
|
<RichTextPlugin
|
||||||
contentEditable={<ContentEditable style={{ height: props.height }} className="editor-input" />}
|
contentEditable={<ContentEditable style={{ height: props.height }} className="editor-input" />}
|
||||||
placeholder={<div className="text-muted -mt-11 p-3 text-sm">{props.placeholder || ""}</div>}
|
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.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||||
}
|
}
|
||||||
}, [editor, isLink]);
|
}, [editor, isLink]);
|
||||||
|
|
||||||
|
if (!props.editable) return <></>;
|
||||||
return (
|
return (
|
||||||
<div className="toolbar flex" ref={toolbarRef}>
|
<div className="toolbar flex" ref={toolbarRef}>
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -23,7 +23,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={classNames(
|
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",
|
isFullWidth && "w-full",
|
||||||
props.className
|
props.className
|
||||||
)}
|
)}
|
||||||
|
@ -41,6 +41,7 @@ export function InputLeading(props: JSX.IntrinsicElements["div"]) {
|
||||||
|
|
||||||
type InputFieldProps = {
|
type InputFieldProps = {
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
|
LockedIcon?: React.ReactNode;
|
||||||
hint?: ReactNode;
|
hint?: ReactNode;
|
||||||
hintErrors?: string[];
|
hintErrors?: string[];
|
||||||
addOnLeading?: ReactNode;
|
addOnLeading?: ReactNode;
|
||||||
|
@ -67,16 +68,16 @@ type AddonProps = {
|
||||||
const Addon = ({ isFilled, children, className, error }: AddonProps) => (
|
const Addon = ({ isFilled, children, className, error }: AddonProps) => (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"addon-wrapper border-default h-9 border px-3",
|
"addon-wrapper border-default min-h-9 border px-3",
|
||||||
isFilled && "bg-subtle",
|
isFilled && "bg-subtle",
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
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"
|
error ? "text-error" : "text-default"
|
||||||
)}>
|
)}>
|
||||||
<span className="whitespace-nowrap py-2.5">{children}</span>
|
<span className="flex whitespace-nowrap">{children}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -90,6 +91,8 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
|
||||||
label = t(name),
|
label = t(name),
|
||||||
labelProps,
|
labelProps,
|
||||||
labelClassName,
|
labelClassName,
|
||||||
|
disabled,
|
||||||
|
LockedIcon,
|
||||||
placeholder = isLocaleReady && i18n.exists(name + "_placeholder") ? t(name + "_placeholder") : "",
|
placeholder = isLocaleReady && i18n.exists(name + "_placeholder") ? t(name + "_placeholder") : "",
|
||||||
className,
|
className,
|
||||||
addOnLeading,
|
addOnLeading,
|
||||||
|
@ -120,6 +123,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
|
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
|
||||||
{label}
|
{label}
|
||||||
|
{LockedIcon}
|
||||||
</Skeleton>
|
</Skeleton>
|
||||||
)}
|
)}
|
||||||
{addOnLeading || addOnSuffix ? (
|
{addOnLeading || addOnSuffix ? (
|
||||||
|
@ -141,6 +145,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
|
||||||
isFullWidth={inputIsFullWidth}
|
isFullWidth={inputIsFullWidth}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
|
"disabled:bg-muted disabled:hover:border-subtle disabled:cursor-not-allowed",
|
||||||
addOnLeading && "ltr:rounded-l-none rtl:rounded-r-none",
|
addOnLeading && "ltr:rounded-l-none rtl:rounded-r-none",
|
||||||
addOnSuffix && "ltr:rounded-r-none rtl:rounded-l-none",
|
addOnSuffix && "ltr:rounded-r-none rtl:rounded-l-none",
|
||||||
type === "search" && "pr-8",
|
type === "search" && "pr-8",
|
||||||
|
@ -154,7 +159,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
|
||||||
},
|
},
|
||||||
value: inputValue,
|
value: inputValue,
|
||||||
})}
|
})}
|
||||||
readOnly={readOnly}
|
disabled={readOnly || disabled}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
/>
|
/>
|
||||||
{addOnSuffix && (
|
{addOnSuffix && (
|
||||||
|
@ -182,11 +187,15 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
|
||||||
id={id}
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={className}
|
className={classNames(
|
||||||
|
className,
|
||||||
|
"disabled:bg-muted disabled:hover:border-subtle disabled:cursor-not-allowed"
|
||||||
|
)}
|
||||||
{...passThrough}
|
{...passThrough}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
isFullWidth={inputIsFullWidth}
|
isFullWidth={inputIsFullWidth}
|
||||||
|
disabled={readOnly || disabled}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<HintsOrErrors hintErrors={hintErrors} fieldName={name} t={t} />
|
<HintsOrErrors hintErrors={hintErrors} fieldName={name} t={t} />
|
||||||
|
|
|
@ -59,6 +59,7 @@ export const Select = <
|
||||||
? "p-1"
|
? "p-1"
|
||||||
: "px-3 py-2"
|
: "px-3 py-2"
|
||||||
: "py-2 px-3",
|
: "py-2 px-3",
|
||||||
|
props.isDisabled && "bg-gray-100",
|
||||||
props.classNames?.control
|
props.classNames?.control
|
||||||
),
|
),
|
||||||
singleValue: () => classNames("text-emphasis placeholder:text-muted", props.classNames?.singleValue),
|
singleValue: () => classNames("text-emphasis placeholder:text-muted", props.classNames?.singleValue),
|
||||||
|
|
|
@ -10,6 +10,7 @@ type Props = {
|
||||||
description?: string;
|
description?: string;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
LockedIcon?: React.ReactNode;
|
||||||
onCheckedChange?: (checked: boolean) => void;
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
"data-testid"?: string;
|
"data-testid"?: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
|
@ -19,6 +20,7 @@ function SettingsToggle({
|
||||||
checked,
|
checked,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
description,
|
description,
|
||||||
|
LockedIcon,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
disabled,
|
disabled,
|
||||||
|
@ -42,7 +44,10 @@ function SettingsToggle({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<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>}
|
{description && <p className="text-default -mt-1.5 text-sm leading-normal">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,6 +17,7 @@ const Switch = (
|
||||||
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
|
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
|
||||||
label?: string;
|
label?: string;
|
||||||
fitToHeight?: boolean;
|
fitToHeight?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
classNames?: {
|
classNames?: {
|
||||||
container?: string;
|
container?: string;
|
||||||
|
|
|
@ -2,10 +2,10 @@ import React from "react";
|
||||||
|
|
||||||
import classNames from "@calcom/lib/classNames";
|
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>(
|
const RadioArea = React.forwardRef<HTMLInputElement, RadioAreaProps>(
|
||||||
({ children, className, ...props }, ref) => {
|
({ children, className, classNames: innerClassNames, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<label className={classNames("relative flex", className)}>
|
<label className={classNames("relative flex", className)}>
|
||||||
<input
|
<input
|
||||||
|
@ -14,7 +14,11 @@ const RadioArea = React.forwardRef<HTMLInputElement, RadioAreaProps>(
|
||||||
type="radio"
|
type="radio"
|
||||||
{...props}
|
{...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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import { mockDeep, mockReset, DeepMockProxy } from "jest-mock-extended";
|
import type { DeepMockProxy } from "jest-mock-extended";
|
||||||
|
import { mockDeep, mockReset } from "jest-mock-extended";
|
||||||
|
|
||||||
import * as CalendarManager from "@calcom/core/CalendarManager";
|
import * as CalendarManager from "@calcom/core/CalendarManager";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
Loading…
Reference in New Issue
Block a user