feat: Managed Events V2 (#12320)
* init * Fix event limit interlinked switches * wip * WIP * fix bugs * lock hashed link until further notice * lock private url for managed type to stop confusion * fix recurring * prettier fix * early review fixes ... * --WIP to send only changed form fields in updateMutation * WIP with some type fixes * Revert "WIP with some type fixes" This reverts commit 00f10b772d6d08af17e7c9bd2d3601d93035d9b3. * post merge conflict resolution fixes * further type fix * fixed test * fixing e2e tests * fix test --WIP * attempt fix test & type --WIP * fix duplicate locale en entry * fix locked state persistence * adds private URL locked/unlocked functionality * fix tests * Fix issue where locked labels showed up in non-managed events * adds e2e test step * update new test * minor fixes * fix test * address changes request Part 1 fixed width on the lock badge members default location as default selection bugfix for offset toggle duplicate within itself * fixes locked/unlocked label for child event types * enable choice of destination calendar for children * Fixes width for simple lock badge * fix type * fixes workflows list and apps for managed event type * restricts creation of managed event types to EE only * further fixes --WIP * fix unit test for handleChildrenEventTypes * fix test --WIP * fix type err --WIP * fix type err in test * fix childevent payload * fixes hashedLink bug * fix test step title * lock workflow create button when workflow is locked * fix workflow detail * Don't rely on parameter ordering, use object instead * Removed console.log * Missed one --------- Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
co-authored by
Joe Au-Yeung
Alex van Andel
parent
1482d9a9af
commit
3c723d6f5b
@@ -1,7 +1,7 @@
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import type { EditableSchema } from "@calcom/features/form-builder/schema";
|
||||
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
|
||||
@@ -55,7 +56,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
formMethods.getValues("useEventTypeDestinationCalendarEmail")
|
||||
);
|
||||
const [hashedUrl, setHashedUrl] = useState(eventType.hashedLink?.link);
|
||||
|
||||
const bookingFields: Prisma.JsonObject = {};
|
||||
|
||||
const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow);
|
||||
@@ -86,6 +86,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
|
||||
useEffect(() => {
|
||||
!hashedUrl && setHashedUrl(generateHashedLink(formMethods.getValues("users")[0]?.id ?? team?.id));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formMethods.getValues("users"), hashedUrl, team?.id]);
|
||||
|
||||
const toggleGuests = (enabled: boolean) => {
|
||||
@@ -108,11 +109,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
);
|
||||
};
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager(
|
||||
formMethods.getValues(),
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { isChildrenManagedEventType, isManagedEventType, shouldLockDisableProps } = useLockedFieldsManager({
|
||||
eventType,
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
const eventNamePlaceholder = getEventName({
|
||||
...eventNameObject,
|
||||
eventName: formMethods.watch("eventName"),
|
||||
@@ -120,10 +121,14 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
|
||||
const successRedirectUrlLocked = shouldLockDisableProps("successRedirectUrl");
|
||||
const seatsLocked = shouldLockDisableProps("seatsPerTimeSlotEnabled");
|
||||
const requiresBookerEmailVerificationProps = shouldLockDisableProps("requiresBookerEmailVerification");
|
||||
const hideCalendarNotesLocked = shouldLockDisableProps("hideCalendarNotes");
|
||||
const lockTimeZoneToggleOnBookingPageLocked = shouldLockDisableProps("lockTimeZoneToggleOnBookingPage");
|
||||
|
||||
const closeEventNameTip = () => setShowEventNameTip(false);
|
||||
const displayDestinationCalendarSelector =
|
||||
!!connectedCalendarsQuery.data?.connectedCalendars.length && !team;
|
||||
!!connectedCalendarsQuery.data?.connectedCalendars.length && (!team || isChildrenManagedEventType);
|
||||
|
||||
const verifiedSecondaryEmails = [
|
||||
{
|
||||
label: user?.email || "",
|
||||
@@ -181,6 +186,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
{...(shouldLockDisableProps("eventName").disabled ? { disabled: true } : {})}
|
||||
aria-label="edit custom name"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
onClick={() => setShowEventNameTip((old) => !old)}>
|
||||
@@ -233,9 +239,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BookerLayoutSelector fallbackToUserSettings isDark={selectedThemeIsDark} isOuterBorder={true} />
|
||||
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
@@ -250,7 +254,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RequiresConfirmationController
|
||||
eventType={eventType}
|
||||
seatsEnabled={seatsEnabled}
|
||||
@@ -258,7 +261,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
requiresConfirmation={requiresConfirmation}
|
||||
onRequiresConfirmation={setRequiresConfirmation}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="requiresBookerEmailVerification"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
@@ -268,14 +270,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("requires_booker_email_verification")}
|
||||
data-testid="requires-booker-email-verification"
|
||||
{...shouldLockDisableProps("requiresBookerEmailVerification")}
|
||||
{...requiresBookerEmailVerificationProps}
|
||||
description={t("description_requires_booker_email_verification")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="hideCalendarNotes"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
@@ -283,16 +284,15 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("disable_notes")}
|
||||
data-testid="disable-notes"
|
||||
{...shouldLockDisableProps("hideCalendarNotes")}
|
||||
title={t("disable_notes")}
|
||||
{...hideCalendarNotesLocked}
|
||||
description={t("disable_notes_description")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="successRedirectUrl"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
@@ -339,7 +339,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
@@ -359,52 +358,53 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
<Info className="ml-1.5 h-4 w-4 cursor-pointer" />
|
||||
</a>
|
||||
}
|
||||
{...shouldLockDisableProps("hashedLinkCheck")}
|
||||
{...shouldLockDisableProps("hashedLink")}
|
||||
description={t("private_link_description", { appName: APP_NAME })}
|
||||
checked={hashedLinkVisible}
|
||||
onCheckedChange={(e) => {
|
||||
formMethods.setValue("hashedLink", e ? hashedUrl : undefined, { shouldDirty: true });
|
||||
setHashedLinkVisible(e);
|
||||
}}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||
<TextField
|
||||
disabled
|
||||
name="hashedLink"
|
||||
label={t("private_link_label")}
|
||||
data-testid="generated-hash-url"
|
||||
labelSrOnly
|
||||
type="text"
|
||||
hint={t("private_link_hint")}
|
||||
defaultValue={placeholderHashedLink}
|
||||
addOnSuffix={
|
||||
<Tooltip
|
||||
content={
|
||||
formMethods.getValues("hashedLink") ? t("copy_to_clipboard") : t("enabled_after_update")
|
||||
}>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
aria-label="copy link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (formMethods.getValues("hashedLink")) {
|
||||
showToast(t("private_link_copied"), "success");
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isManagedEventType && (
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
{!IS_VISUAL_REGRESSION_TESTING && (
|
||||
<TextField
|
||||
disabled
|
||||
name="hashedLink"
|
||||
label={t("private_link_label")}
|
||||
data-testid="generated-hash-url"
|
||||
labelSrOnly
|
||||
type="text"
|
||||
hint={t("private_link_hint")}
|
||||
defaultValue={placeholderHashedLink}
|
||||
addOnSuffix={
|
||||
<Tooltip
|
||||
content={
|
||||
formMethods.getValues("hashedLink") ? t("copy_to_clipboard") : t("enabled_after_update")
|
||||
}>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="hover:stroke-3 hover:text-emphasis min-w-fit !py-0 px-0 hover:bg-transparent"
|
||||
aria-label="copy link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(placeholderHashedLink);
|
||||
if (formMethods.getValues("hashedLink")) {
|
||||
showToast(t("private_link_copied"), "success");
|
||||
} else {
|
||||
showToast(t("enabled_after_update_description"), "warning");
|
||||
}
|
||||
}}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsToggle>
|
||||
|
||||
<Controller
|
||||
name="seatsPerTimeSlotEnabled"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
@@ -504,7 +504,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("lock_timezone_toggle_on_booking_page")}
|
||||
{...shouldLockDisableProps("lockTimeZoneToggleOnBookingPage")}
|
||||
{...lockTimeZoneToggleOnBookingPageLocked}
|
||||
description={t("description_lock_timezone_toggle_on_booking_page")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
|
||||
@@ -8,6 +8,7 @@ import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCard
|
||||
import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
|
||||
import type { EventTypeAppsList } from "@calcom/app-store/utils";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, EmptyScreen, Alert } from "@calcom/ui";
|
||||
@@ -74,11 +75,13 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
|
||||
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
const appsDisableProps = shouldLockDisableProps("apps", { simple: true });
|
||||
const lockedText = appsDisableProps.isLocked ? "locked" : "unlocked";
|
||||
|
||||
const appsWithTeamCredentials = eventTypeApps?.items.filter((app) => app.teams.length) || [];
|
||||
const cardsForAppsWithTeams = appsWithTeamCredentials.map((app) => {
|
||||
@@ -97,7 +100,6 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -121,7 +123,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
}}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
disabled={shouldLockDisableProps("apps").disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -133,12 +135,28 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
<>
|
||||
<div>
|
||||
<div className="before:border-0">
|
||||
{isManagedEventType && (
|
||||
{(isManagedEventType || isChildrenManagedEventType) && (
|
||||
<Alert
|
||||
severity="neutral"
|
||||
severity={appsDisableProps.isLocked ? "neutral" : "green"}
|
||||
className="mb-2"
|
||||
title={t("locked_for_members")}
|
||||
message={t("locked_apps_description")}
|
||||
title={
|
||||
<Trans i18nKey={`${lockedText}_${isManagedEventType ? "for_members" : "by_team_admins"}`}>
|
||||
{lockedText[0].toUpperCase()}
|
||||
{lockedText.slice(1)} {isManagedEventType ? "for members" : "by team admins"}
|
||||
</Trans>
|
||||
}
|
||||
actions={<div className="flex h-full items-center">{appsDisableProps.LockedIcon}</div>}
|
||||
message={
|
||||
<Trans
|
||||
i18nKey={`apps_${lockedText}_${
|
||||
isManagedEventType ? "for_members" : "by_team_admins"
|
||||
}_description`}>
|
||||
{isManagedEventType ? "Members" : "You"}{" "}
|
||||
{appsDisableProps.isLocked
|
||||
? "will be able to see the active apps but will not be able to edit any app settings"
|
||||
: "will be able to see the active apps and will be able to edit any app settings"}
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!isPending && !installedApps?.length ? (
|
||||
@@ -147,9 +165,9 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
headline={t("empty_installed_apps_headline")}
|
||||
description={t("empty_installed_apps_description")}
|
||||
buttonRaw={
|
||||
isChildrenManagedEventType && !isManagedEventType ? (
|
||||
appsDisableProps.disabled ? (
|
||||
<Button StartIcon={Lock} color="secondary" disabled>
|
||||
{t("locked_by_admin")}
|
||||
{t("locked_by_team_admin")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button target="_blank" color="secondary" href="/apps">
|
||||
@@ -174,13 +192,12 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
|
||||
app={app}
|
||||
eventType={eventType}
|
||||
eventTypeFormMetadata={eventTypeFormMetadata}
|
||||
{...shouldLockDisableProps("apps")}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!shouldLockDisableProps("apps").disabled && (
|
||||
{!appsDisableProps.disabled && (
|
||||
<div className="bg-muted mt-6 rounded-md p-8">
|
||||
{!isPending && notInstalledApps?.length ? (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState, memo, useEffect } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { OptionProps, SingleValueProps } from "react-select";
|
||||
@@ -6,6 +6,7 @@ import { components } from "react-select";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { AvailabilityOption, FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
@@ -17,13 +18,6 @@ import { ExternalLink, Globe } from "@calcom/ui/components/icon";
|
||||
|
||||
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
|
||||
|
||||
export type AvailabilityOption = {
|
||||
label: string;
|
||||
value: number;
|
||||
isDefault: boolean;
|
||||
isManaged?: boolean;
|
||||
};
|
||||
|
||||
const Option = ({ ...props }: OptionProps<AvailabilityOption>) => {
|
||||
const { label, isDefault, isManaged = false } = props.data;
|
||||
const { t } = useLocale();
|
||||
@@ -160,12 +154,10 @@ EventTypeScheduleDetails.displayName = "EventTypeScheduleDetails";
|
||||
|
||||
const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const { shouldLockIndicator, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { watch, setValue, getValues } = useFormContext<FormValues>();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { shouldLockIndicator, shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } =
|
||||
useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
const { watch, setValue, getValues } = formMethods;
|
||||
const watchSchedule = watch("schedule");
|
||||
const [options, setOptions] = useState<AvailabilityOption[]>([]);
|
||||
|
||||
@@ -246,7 +238,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
<div className="border-subtle rounded-t-md border p-6">
|
||||
<label htmlFor="availability" className="text-default mb-2 block text-sm font-medium leading-none">
|
||||
{t("availability")}
|
||||
{shouldLockIndicator("availability")}
|
||||
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("availability")}
|
||||
</label>
|
||||
{isPending && <SelectSkeletonLoader />}
|
||||
{!isPending && (
|
||||
@@ -257,6 +249,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={options}
|
||||
isDisabled={shouldLockDisableProps("availability").disabled}
|
||||
isSearchable={false}
|
||||
onChange={(selected) => {
|
||||
field.onChange(selected?.value || null);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import type { FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import type { Key } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type { SingleValue } from "react-select";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
@@ -108,7 +110,7 @@ const MinimumBookingNoticeInput = React.forwardRef<
|
||||
);
|
||||
});
|
||||
|
||||
export const EventLimitsTab = () => {
|
||||
export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventType">) => {
|
||||
const { t, i18n } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
@@ -129,6 +131,18 @@ export const EventLimitsTab = () => {
|
||||
|
||||
const watchPeriodType = formMethods.watch("periodType");
|
||||
|
||||
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager({
|
||||
eventType,
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
|
||||
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
|
||||
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
|
||||
const onlyFirstAvailableSlotLocked = shouldLockDisableProps("onlyShowFirstAvailableSlot");
|
||||
const periodTypeLocked = shouldLockDisableProps("periodType");
|
||||
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
|
||||
|
||||
const optionsPeriod = [
|
||||
{ value: 0, label: t("business_days") },
|
||||
{ value: 1, label: t("calendar_days") },
|
||||
@@ -147,7 +161,10 @@ export const EventLimitsTab = () => {
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="beforeEventBuffer">{t("before_event")}</Label>
|
||||
<Label htmlFor="beforeBufferTime">
|
||||
{t("before_event")}
|
||||
{shouldLockIndicator("beforeBufferTime")}
|
||||
</Label>
|
||||
<Controller
|
||||
name="beforeEventBuffer"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
@@ -167,6 +184,7 @@ export const EventLimitsTab = () => {
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
isDisabled={shouldLockDisableProps("beforeBufferTime").disabled}
|
||||
defaultValue={
|
||||
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
|
||||
}
|
||||
@@ -177,7 +195,10 @@ export const EventLimitsTab = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="afterEventBuffer">{t("after_event")}</Label>
|
||||
<Label htmlFor="afterBufferTime">
|
||||
{t("after_event")}
|
||||
{shouldLockIndicator("afterBufferTime")}
|
||||
</Label>
|
||||
<Controller
|
||||
name="afterEventBuffer"
|
||||
render={({ field: { onChange, value } }) => {
|
||||
@@ -197,6 +218,7 @@ export const EventLimitsTab = () => {
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
isDisabled={shouldLockDisableProps("afterBufferTime").disabled}
|
||||
defaultValue={
|
||||
afterBufferOptions.find((option) => option.value === value) || afterBufferOptions[0]
|
||||
}
|
||||
@@ -209,11 +231,20 @@ export const EventLimitsTab = () => {
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")}</Label>
|
||||
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
||||
<Label htmlFor="minimumBookingNotice">
|
||||
{t("minimum_booking_notice")}
|
||||
{shouldLockIndicator("minimumBookingNotice")}
|
||||
</Label>
|
||||
<MinimumBookingNoticeInput
|
||||
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
|
||||
{...formMethods.register("minimumBookingNotice")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="slotInterval">{t("slot_interval")}</Label>
|
||||
<Label htmlFor="slotInterval">
|
||||
{t("slot_interval")}
|
||||
{shouldLockIndicator("slotInterval")}
|
||||
</Label>
|
||||
<Controller
|
||||
name="slotInterval"
|
||||
render={() => {
|
||||
@@ -230,6 +261,7 @@ export const EventLimitsTab = () => {
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("slotInterval").disabled}
|
||||
onChange={(val) => {
|
||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null, {
|
||||
shouldDirty: true,
|
||||
@@ -257,6 +289,7 @@ export const EventLimitsTab = () => {
|
||||
toggleSwitchAtTheEnd={true}
|
||||
labelClassName="text-sm"
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
@@ -278,7 +311,12 @@ export const EventLimitsTab = () => {
|
||||
)}
|
||||
childrenClassName="lg:ml-0">
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
@@ -295,6 +333,7 @@ export const EventLimitsTab = () => {
|
||||
title={t("limit_booking_only_first_slot")}
|
||||
description={t("limit_booking_only_first_slot_description")}
|
||||
checked={isChecked}
|
||||
{...onlyFirstAvailableSlotLocked}
|
||||
onCheckedChange={(active) => {
|
||||
onChange(active ?? false);
|
||||
}}
|
||||
@@ -321,6 +360,7 @@ export const EventLimitsTab = () => {
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
@@ -335,6 +375,7 @@ export const EventLimitsTab = () => {
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
@@ -359,6 +400,7 @@ export const EventLimitsTab = () => {
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(bool) => {
|
||||
if (bool && !formMethods.getValues("periodDays")) {
|
||||
@@ -372,7 +414,9 @@ export const EventLimitsTab = () => {
|
||||
onValueChange={(val) =>
|
||||
formMethods.setValue("periodType", val as PeriodType, { shouldDirty: true })
|
||||
}>
|
||||
{PERIOD_TYPES.map((period) => {
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
@@ -381,12 +425,14 @@ export const EventLimitsTab = () => {
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
@@ -396,18 +442,20 @@ export const EventLimitsTab = () => {
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
/>
|
||||
<Select
|
||||
options={optionsPeriod}
|
||||
isSearchable={false}
|
||||
onChange={(opt) =>
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
onChange={(opt) => {
|
||||
formMethods.setValue(
|
||||
"periodCountCalendarDays",
|
||||
opt?.value === 1 ? true : false,
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
name="periodCoundCalendarDays"
|
||||
value={optionsPeriod.find((opt) => {
|
||||
opt.value ===
|
||||
@@ -429,6 +477,7 @@ export const EventLimitsTab = () => {
|
||||
<DateRangePicker
|
||||
startDate={formMethods.getValues("periodDates").startDate}
|
||||
endDate={formMethods.getValues("periodDates").endDate}
|
||||
disabled={periodTypeLocked.disabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
onChange({
|
||||
startDate,
|
||||
@@ -460,6 +509,7 @@ export const EventLimitsTab = () => {
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("offset_toggle")}
|
||||
description={t("offset_toggle_description")}
|
||||
{...offsetStartLockedProps}
|
||||
checked={offsetToggle}
|
||||
onCheckedChange={(active) => {
|
||||
setOffsetToggle(active);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext, useFieldArray } from "react-hook-form";
|
||||
import type { MultiValue } from "react-select";
|
||||
@@ -11,6 +11,7 @@ import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
@@ -142,12 +143,7 @@ export const EventSetupTab = (
|
||||
);
|
||||
|
||||
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
|
||||
useLockedFieldsManager(
|
||||
formMethods.getValues(),
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
const Locations = () => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
@@ -183,7 +179,6 @@ export const EventSetupTab = (
|
||||
index: number;
|
||||
}) => {
|
||||
const { eventLocationType, index, ...remainingProps } = props;
|
||||
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
@@ -200,6 +195,7 @@ export const EventSetupTab = (
|
||||
required
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
{...(shouldLockDisableProps("locations").disabled ? { disabled: true } : {})}
|
||||
className="my-0"
|
||||
{...rest}
|
||||
/>
|
||||
@@ -218,6 +214,7 @@ export const EventSetupTab = (
|
||||
return (
|
||||
<PhoneInput
|
||||
required
|
||||
disabled={shouldLockDisableProps("locations").disabled}
|
||||
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
value={value}
|
||||
@@ -233,7 +230,10 @@ export const EventSetupTab = (
|
||||
};
|
||||
|
||||
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
||||
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(null);
|
||||
const defaultInitialLocation = defaultValue || null;
|
||||
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
|
||||
defaultInitialLocation
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -288,16 +288,18 @@ export const EventSetupTab = (
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
data-testid={`delete-locations.${index}.type`}
|
||||
className="min-h-9 block h-9 px-2"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
aria-label={t("remove")}>
|
||||
<div className="h-4 w-4">
|
||||
<X className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
{!(shouldLockDisableProps("locations").disabled && isChildrenManagedEventType) && (
|
||||
<button
|
||||
data-testid={`delete-locations.${index}.type`}
|
||||
className="min-h-9 block h-9 px-2"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
aria-label={t("remove")}>
|
||||
<div className="h-4 w-4">
|
||||
<X className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eventLocationType?.organizerInputType && (
|
||||
@@ -329,6 +331,7 @@ export const EventSetupTab = (
|
||||
<CheckboxField
|
||||
name={`locations[${index}].displayLocationPublicly`}
|
||||
data-testid="display-location"
|
||||
disabled={shouldLockDisableProps("locations").disabled}
|
||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) => {
|
||||
@@ -417,7 +420,8 @@ export const EventSetupTab = (
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
{validLocations.length > 0 && !shouldLockDisableProps("locations").disabled && (
|
||||
// && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin
|
||||
<li>
|
||||
<Button
|
||||
data-testid="add-location"
|
||||
@@ -444,7 +448,8 @@ export const EventSetupTab = (
|
||||
|
||||
const lengthLockedProps = shouldLockDisableProps("length");
|
||||
const descriptionLockedProps = shouldLockDisableProps("description");
|
||||
|
||||
const urlLockedProps = shouldLockDisableProps("slug");
|
||||
const titleLockedProps = shouldLockDisableProps("title");
|
||||
const urlPrefix = orgBranding
|
||||
? orgBranding?.fullDomain.replace(/^(https?:|)\/\//, "")
|
||||
: `${WEBSITE_URL?.replace(/^(https?:|)\/\//, "")}`;
|
||||
@@ -456,20 +461,22 @@ export const EventSetupTab = (
|
||||
<TextField
|
||||
required
|
||||
label={t("title")}
|
||||
{...shouldLockDisableProps("title")}
|
||||
{...(isManagedEventType || isChildrenManagedEventType ? titleLockedProps : {})}
|
||||
defaultValue={eventType.title}
|
||||
{...formMethods.register("title")}
|
||||
/>
|
||||
<div>
|
||||
<Label>
|
||||
<Label htmlFor="editor">
|
||||
{t("description")}
|
||||
{shouldLockIndicator("description")}
|
||||
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("description")}
|
||||
</Label>
|
||||
<DescriptionEditor isEditable={!descriptionLockedProps.disabled} />
|
||||
</div>
|
||||
<TextField
|
||||
required
|
||||
label={t("URL")}
|
||||
{...shouldLockDisableProps("slug")}
|
||||
{...(isManagedEventType || isChildrenManagedEventType ? urlLockedProps : {})}
|
||||
defaultValue={eventType.slug}
|
||||
addOnLeading={
|
||||
<>
|
||||
{urlPrefix}/
|
||||
@@ -498,6 +505,7 @@ export const EventSetupTab = (
|
||||
defaultValue={selectedMultipleDuration}
|
||||
name="metadata.multipleDuration"
|
||||
isSearchable={false}
|
||||
isDisabled={lengthLockedProps.disabled}
|
||||
className="h-auto !min-h-[36px] text-sm"
|
||||
options={multipleDurationOptions}
|
||||
value={selectedMultipleDuration}
|
||||
@@ -551,7 +559,8 @@ export const EventSetupTab = (
|
||||
<TextField
|
||||
required
|
||||
type="number"
|
||||
{...lengthLockedProps}
|
||||
data-testid="duration"
|
||||
{...(isManagedEventType || isChildrenManagedEventType ? lengthLockedProps : {})}
|
||||
label={t("duration")}
|
||||
defaultValue={formMethods.getValues("length") ?? 15}
|
||||
{...formMethods.register("length")}
|
||||
@@ -583,15 +592,20 @@ export const EventSetupTab = (
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-subtle rounded-lg border p-6">
|
||||
<div>
|
||||
<Skeleton as={Label} loadingClassName="w-16">
|
||||
<Skeleton as={Label} loadingClassName="w-16" htmlFor="locations">
|
||||
{t("location")}
|
||||
{/*improve shouldLockIndicator function to also accept eventType and then conditionally render
|
||||
based on Managed Event type or not.*/}
|
||||
{shouldLockIndicator("locations")}
|
||||
</Skeleton>
|
||||
|
||||
<Controller name="locations" render={() => <Locations />} />
|
||||
<Controller
|
||||
name="locations"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.locations || []}
|
||||
render={() => <Locations />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues, Host } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps, Host } from "pages/event-types/[type]";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ComponentProps, Dispatch, SetStateAction } from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
@@ -9,6 +9,7 @@ import type { Options } from "react-select";
|
||||
import type { CheckedSelectOption } from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import CheckedTeamSelect from "@calcom/features/eventtypes/components/CheckedTeamSelect";
|
||||
import ChildrenEventTypeSelect from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Label, Select, SettingsToggle } from "@calcom/ui";
|
||||
|
||||
@@ -2,12 +2,13 @@ import { Webhook as TbWebhook } from "lucide-react";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { Trans } from "next-i18next";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useMemo, useState, Suspense } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
|
||||
import type { FormValues, AvailabilityOption } from "@calcom/features/eventtypes/lib/types";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -52,8 +53,6 @@ import {
|
||||
Loader,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
import type { AvailabilityOption } from "@components/eventtype/EventAvailabilityTab";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
eventType: EventTypeSetupProps["eventType"];
|
||||
@@ -214,11 +213,11 @@ function EventTypeSingleLayout({
|
||||
formMethods.getValues("schedulingType") === SchedulingType.MANAGED ||
|
||||
isUserOrganizationAdmin;
|
||||
|
||||
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
|
||||
formMethods.getValues(),
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
|
||||
const length = formMethods.watch("length");
|
||||
const multipleDuration = formMethods.watch("metadata")?.multipleDuration;
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||
@@ -20,6 +22,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const { data: webhooks } = trpc.viewer.webhook.list.useQuery({ eventTypeId: eventType.id });
|
||||
|
||||
@@ -96,11 +99,11 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
||||
);
|
||||
};
|
||||
|
||||
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager(
|
||||
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
const webhookLockedStatus = shouldLockDisableProps("webhooks");
|
||||
|
||||
return (
|
||||
@@ -163,7 +166,7 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
|
||||
buttonRaw={
|
||||
isChildrenManagedEventType && !isManagedEventType ? (
|
||||
<Button StartIcon={Lock} color="secondary" disabled>
|
||||
{t("locked_by_admin")}
|
||||
{t("locked_by_team_admin")}
|
||||
</Button>
|
||||
) : (
|
||||
<NewWebhookButton />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Webhook } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { WebhookForm } from "@calcom/features/webhooks/components";
|
||||
import type { WebhookFormSubmitData } from "@calcom/features/webhooks/components/WebhookForm";
|
||||
import WebhookListItem from "@calcom/features/webhooks/components/WebhookListItem";
|
||||
@@ -33,11 +34,7 @@ export default function InstantEventController({
|
||||
const [instantEventState, setInstantEventState] = useState<boolean>(eventType?.isInstantEvent ?? false);
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
|
||||
const instantLocked = shouldLockDisableProps("isInstantEvent");
|
||||
|
||||
@@ -104,6 +101,8 @@ export default function InstantEventController({
|
||||
const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
|
||||
const { data: webhooks } = trpc.viewer.webhook.list.useQuery({
|
||||
eventTypeId: eventType.id,
|
||||
eventTriggers: [WebhookTriggerEvents.INSTANT_MEETING],
|
||||
@@ -179,11 +178,11 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
|
||||
);
|
||||
};
|
||||
|
||||
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager(
|
||||
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
formMethods,
|
||||
translate: t,
|
||||
});
|
||||
const webhookLockedStatus = shouldLockDisableProps("webhooks");
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
@@ -31,11 +32,7 @@ export default function RecurringEventController({
|
||||
value: value.toString(),
|
||||
}));
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
|
||||
const recurringLocked = shouldLockDisableProps("recurringEvent");
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import type { UnitTypeLongPlural } from "dayjs";
|
||||
import { Trans } from "next-i18next";
|
||||
import type { EventTypeSetup, FormValues } from "pages/event-types/[type]";
|
||||
import type { EventTypeSetup } from "pages/event-types/[type]";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -42,11 +43,7 @@ export default function RequiresConfirmationController({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [requiresConfirmation]);
|
||||
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
const { shouldLockDisableProps } = useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
const requiresConfirmationLockedProps = shouldLockDisableProps("requiresConfirmation");
|
||||
|
||||
const options = [
|
||||
|
||||
@@ -803,11 +803,10 @@ const CreateFirstEventTypeView = ({ slug }: { slug: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CTA = ({ data }: { data: GetByViewerResponse }) => {
|
||||
const CTA = ({ data, isOrganization }: { data: GetByViewerResponse; isOrganization: boolean }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const profileOptions = data.profiles
|
||||
.filter((profile) => !profile.readOnly)
|
||||
.map((profile) => {
|
||||
@@ -825,7 +824,9 @@ const CTA = ({ data }: { data: GetByViewerResponse }) => {
|
||||
data-testid="new-event-type"
|
||||
subtitle={t("create_event_on").toUpperCase()}
|
||||
options={profileOptions}
|
||||
createDialog={() => <CreateEventTypeDialog profileOptions={profileOptions} />}
|
||||
createDialog={() => (
|
||||
<CreateEventTypeDialog profileOptions={profileOptions} isOrganization={isOrganization} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -983,7 +984,7 @@ const EventTypesPage: React.FC & {
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
beforeCTAactions={<Actions />}
|
||||
CTA={<CTA data={data} />}>
|
||||
CTA={<CTA data={data} isOrganization={!!user?.organizationId} />}>
|
||||
<HeadSeo
|
||||
title="Event Types"
|
||||
description="Create events to share for people to book on your calendar."
|
||||
|
||||
@@ -12,8 +12,8 @@ import { z } from "zod";
|
||||
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
|
||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||
import { validateCustomEventName } from "@calcom/core/event";
|
||||
import type { EventLocationType } from "@calcom/core/location";
|
||||
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -22,21 +22,14 @@ import { HttpError } from "@calcom/lib/http-error";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { PeriodType, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type {
|
||||
BookerLayoutSettings,
|
||||
customInputSchema,
|
||||
EventTypeMetaDataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
import type { customInputSchema, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { Form, showToast } from "@calcom/ui";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers";
|
||||
|
||||
import type { AvailabilityOption } from "@components/eventtype/EventAvailabilityTab";
|
||||
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
|
||||
|
||||
import { type PageProps } from "~/event-types/views/event-types-single-view.getServerSideProps";
|
||||
@@ -84,74 +77,6 @@ const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/Manag
|
||||
|
||||
export type Host = { isFixed: boolean; userId: number; priority: number };
|
||||
|
||||
export type FormValues = {
|
||||
id: number;
|
||||
title: string;
|
||||
eventTitle: string;
|
||||
eventName: string;
|
||||
slug: string;
|
||||
isInstantEvent: boolean;
|
||||
length: number;
|
||||
offsetStart: number;
|
||||
description: string;
|
||||
disableGuests: boolean;
|
||||
lockTimeZoneToggleOnBookingPage: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
requiresBookerEmailVerification: boolean;
|
||||
recurringEvent: RecurringEvent | null;
|
||||
schedulingType: SchedulingType | null;
|
||||
hidden: boolean;
|
||||
hideCalendarNotes: boolean;
|
||||
hashedLink: string | undefined;
|
||||
locations: {
|
||||
type: EventLocationType["type"];
|
||||
address?: string;
|
||||
attendeeAddress?: string;
|
||||
link?: string;
|
||||
hostPhoneNumber?: string;
|
||||
displayLocationPublicly?: boolean;
|
||||
phone?: string;
|
||||
hostDefault?: string;
|
||||
credentialId?: number;
|
||||
teamName?: string;
|
||||
}[];
|
||||
customInputs: CustomInputParsed[];
|
||||
schedule: number | null;
|
||||
periodType: PeriodType;
|
||||
periodDays: number;
|
||||
periodCountCalendarDays: boolean;
|
||||
periodDates: { startDate: Date; endDate: Date };
|
||||
seatsPerTimeSlot: number | null;
|
||||
seatsShowAttendees: boolean | null;
|
||||
seatsShowAvailabilityCount: boolean | null;
|
||||
seatsPerTimeSlotEnabled: boolean;
|
||||
scheduleName: string;
|
||||
minimumBookingNotice: number;
|
||||
minimumBookingNoticeInDurationType: number;
|
||||
beforeEventBuffer: number;
|
||||
afterEventBuffer: number;
|
||||
slotInterval: number | null;
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
destinationCalendar: {
|
||||
integration: string;
|
||||
externalId: string;
|
||||
};
|
||||
successRedirectUrl: string;
|
||||
durationLimits?: IntervalLimit;
|
||||
bookingLimits?: IntervalLimit;
|
||||
onlyShowFirstAvailableSlot: boolean;
|
||||
children: ChildrenEventType[];
|
||||
hosts: Host[];
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||
availability?: AvailabilityOption;
|
||||
bookerLayouts: BookerLayoutSettings;
|
||||
multipleDurationEnabled: boolean;
|
||||
users: EventTypeSetup["users"];
|
||||
assignAllTeamMembers: boolean;
|
||||
useEventTypeDestinationCalendarEmail: boolean;
|
||||
secondaryEmailId?: number;
|
||||
};
|
||||
|
||||
export type CustomInputParsed = typeof customInputSchema._output;
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -438,7 +363,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
),
|
||||
availability: <EventAvailabilityTab eventType={eventType} isTeamEvent={!!team} />,
|
||||
team: <EventTeamTab teamMembers={teamMembers} team={team} eventType={eventType} />,
|
||||
limits: <EventLimitsTab />,
|
||||
limits: <EventLimitsTab eventType={eventType} />,
|
||||
advanced: <EventAdvancedTab eventType={eventType} team={team} />,
|
||||
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
|
||||
recurring: <EventRecurringTab eventType={eventType} />,
|
||||
|
||||
@@ -7,9 +7,9 @@ export type {
|
||||
CustomInputParsed,
|
||||
EventTypeSetup,
|
||||
EventTypeSetupProps,
|
||||
FormValues,
|
||||
Host,
|
||||
} from "~/event-types/views/event-types-single-view";
|
||||
export type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
|
||||
EventTypePageWrapper.PageWrapper = PageWrapper;
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
test.afterAll(({ users }) => users.deleteAll());
|
||||
|
||||
test.describe("Managed Event Types tests", () => {
|
||||
test.describe("Managed Event Types", () => {
|
||||
test("Can create managed event type", async ({ page, users }) => {
|
||||
// Creating the owner user of the team
|
||||
const adminUser = await users.create();
|
||||
@@ -61,7 +61,7 @@ test.describe("Managed Event Types tests", () => {
|
||||
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 adminUser.logout();
|
||||
await adminUser.apiLogin();
|
||||
});
|
||||
|
||||
await test.step("Managed event type exists for added member", async () => {
|
||||
@@ -73,7 +73,7 @@ test.describe("Managed Event Types tests", () => {
|
||||
await page.locator('button[data-testid^="accept-invitation"]').click();
|
||||
await page.getByText("Member").waitFor();
|
||||
|
||||
await memberUser.logout();
|
||||
await page.goto("/auth/logout");
|
||||
|
||||
// Coming back as team owner to assign member user to managed event
|
||||
await adminUser.apiLogin();
|
||||
@@ -107,7 +107,6 @@ test.describe("Managed Event Types tests", () => {
|
||||
|
||||
await test.step("Managed event type has locked fields for added member", async () => {
|
||||
await adminUser.logout();
|
||||
|
||||
// Coming back as member user to see if there is a managed event present after assignment
|
||||
await memberUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
@@ -118,6 +117,90 @@ test.describe("Managed Event Types tests", () => {
|
||||
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();
|
||||
|
||||
await page.goto("/auth/logout");
|
||||
});
|
||||
|
||||
await test.step("Managed event type provides discrete field lock/unlock state for admin", async () => {
|
||||
await adminUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
|
||||
await page.getByTestId("event-types").locator('a[title="managed"]').click();
|
||||
await page.waitForURL("event-types/**");
|
||||
|
||||
// Locked by default
|
||||
const titleLockIndicator = page.getByTestId("locked-indicator-title");
|
||||
await expect(titleLockIndicator).toBeVisible();
|
||||
await expect(titleLockIndicator.locator("[data-state='checked']")).toHaveCount(1);
|
||||
|
||||
// Proceed to unlock and check that it got unlocked
|
||||
titleLockIndicator.click();
|
||||
await expect(titleLockIndicator.locator("[data-state='checked']")).toHaveCount(0);
|
||||
await expect(titleLockIndicator.locator("[data-state='unchecked']")).toHaveCount(1);
|
||||
|
||||
// Save changes
|
||||
await page.locator('[type="submit"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.goto("/auth/logout");
|
||||
});
|
||||
|
||||
await test.step("Managed event type shows discretionally unlocked field to member", async () => {
|
||||
await memberUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
|
||||
await page.getByTestId("event-types").locator('a[title="managed"]').click();
|
||||
await page.waitForURL("event-types/**");
|
||||
|
||||
await expect(page.locator('input[name="title"]')).toBeEditable();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.goto("/auth/logout");
|
||||
});
|
||||
|
||||
await test.step("Managed event type should only update the unlocked fields modified by Admin", async () => {
|
||||
await memberUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
|
||||
await page.getByTestId("event-types").locator('a[title="managed"]').click();
|
||||
await page.waitForURL("event-types/**");
|
||||
|
||||
await expect(page.locator('input[name="title"]')).toBeEditable();
|
||||
await page.locator('input[name="title"]').fill(`Managed Event Title`);
|
||||
// Save changes
|
||||
await page.locator('[type="submit"]').click();
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.goto("/auth/logout");
|
||||
|
||||
await adminUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await page.getByTestId("event-types").locator('a[title="managed"]').click();
|
||||
await page.waitForURL("event-types/**");
|
||||
|
||||
await page.locator('input[name="length"]').fill(`45`);
|
||||
// Save changes
|
||||
await page.locator('[type="submit"]').click();
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.goto("/auth/logout");
|
||||
|
||||
await memberUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
|
||||
await page.getByTestId("event-types").locator('a[title="Managed Event Title"]').click();
|
||||
await page.waitForURL("event-types/**");
|
||||
|
||||
//match length
|
||||
expect(await page.locator("[data-testid=duration]").getAttribute("value")).toBe("45");
|
||||
//ensure description didn't update
|
||||
expect(await page.locator(`input[name="title"]`).getAttribute("value")).toBe(`Managed Event Title`);
|
||||
await page.locator('input[name="title"]').fill(`managed`);
|
||||
// Save changes
|
||||
await page.locator('[type="submit"]').click();
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ test.beforeEach(async ({ page, users, bookingPage }) => {
|
||||
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType(teamEventTitle);
|
||||
await page.getByTestId("location-select").click();
|
||||
await page.locator(`text="Cal Video (Global)"`).click();
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
});
|
||||
|
||||
@@ -19,7 +21,6 @@ test.describe("Check advanced options in a managed team event type", () => {
|
||||
await bookingPage.checkRequiresBookerEmailVerification();
|
||||
await bookingPage.checkHideNotes();
|
||||
await bookingPage.checkRedirectOnBooking();
|
||||
await bookingPage.checkEnablePrivateUrl();
|
||||
await bookingPage.checkLockTimezone();
|
||||
await bookingPage.updateEventType();
|
||||
await bookingPage.goToEventTypesPage();
|
||||
@@ -32,7 +33,6 @@ test.describe("Check advanced options in a managed team event type", () => {
|
||||
await bookingPage.checkRequiresBookerEmailVerification();
|
||||
await bookingPage.checkHideNotes();
|
||||
await bookingPage.checkRedirectOnBooking();
|
||||
await bookingPage.checkEnablePrivateUrl();
|
||||
await bookingPage.toggleOfferSeats();
|
||||
await bookingPage.checkLockTimezone();
|
||||
await bookingPage.updateEventType();
|
||||
|
||||
@@ -706,8 +706,10 @@
|
||||
"assigned_to": "Assigned to",
|
||||
"you_must_be_logged_in_to":"You must be logged in to {{url}}",
|
||||
"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",
|
||||
"locked_fields_admin_description": "Members can not edit",
|
||||
"unlocked_fields_admin_description": "Members can edit",
|
||||
"locked_fields_member_description": "Locked by the team admin",
|
||||
"unlocked_fields_member_description": "Unlocked by team admin",
|
||||
"url": "URL",
|
||||
"hidden": "Hidden",
|
||||
"readonly": "Readonly",
|
||||
@@ -1274,7 +1276,7 @@
|
||||
"recordings_are_part_of_the_teams_plan": "Recordings are part of the teams plan",
|
||||
"team_feature_teams": "This is a Team feature. Upgrade to Team to see your team's availability.",
|
||||
"team_feature_workflows": "This is a Team feature. Upgrade to Team to automate your event notifications and reminders with Workflows.",
|
||||
"show_eventtype_on_profile": "Show on profile",
|
||||
"show_eventtype_on_profile": "Show on Profile",
|
||||
"embed": "Embed",
|
||||
"new_username": "New username",
|
||||
"current_username": "Current username",
|
||||
@@ -1890,10 +1892,16 @@
|
||||
"requires_at_least_one_schedule": "You are required to have at least one schedule",
|
||||
"default_conferencing_bulk_description": "Update the locations for the selected event types",
|
||||
"locked_for_members": "Locked for members",
|
||||
"locked_apps_description": "Members will be able to see the active apps but will not be able to edit any app settings",
|
||||
"locked_webhooks_description": "Members will be able to see the active webhooks but will not be able to edit any webhook settings",
|
||||
"locked_workflows_description": "Members will be able to see the active workflows but will not be able to edit any workflow settings",
|
||||
"locked_by_admin": "Locked by team admin",
|
||||
"unlocked_for_members": "Unlocked for members",
|
||||
"apps_locked_for_members_description": "Members will be able to see the active apps but will not be able to edit any app settings",
|
||||
"apps_unlocked_for_members_description": "Members will be able to see the active apps and will be able to edit any app settings",
|
||||
"apps_locked_by_team_admins_description": "You will be able to see the active apps but will not be able to edit any app settings",
|
||||
"apps_unlocked_by_team_admins_description": "You will be able to see the active apps and will be able to edit any app settings",
|
||||
"workflows_locked_for_members_description": "Members can not add their personal workflows to this event type. Members will be able to see the active team workflows but will not be able to edit any workflow settings.",
|
||||
"workflows_unlocked_for_members_description": "Members will be able to add their personal workflows to this event type. Members will be able to see the active team workflows but will not be able to edit any workflow settings.",
|
||||
"workflows_locked_by_team_admins_description": "You will be able to see the active team workflows but will not be able to edit any workflow settings or add your personal workflows to this event type.",
|
||||
"workflows_unlocked_by_team_admins_description": "You will be able to enable/disable personal workflows on this event type. You will be able to see the active team workflows but will not be able to edit any team workflow settings.",
|
||||
"locked_by_team_admin": "Locked by team admin",
|
||||
"app_not_connected": "You have not connected a {{appName}} account.",
|
||||
"connect_now": "Connect now",
|
||||
"managed_event_dialog_confirm_button_one": "Replace & notify {{count}} member",
|
||||
@@ -2192,7 +2200,9 @@
|
||||
"overlay_my_calendar":"Overlay my calendar",
|
||||
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
|
||||
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
|
||||
"join_event_location":"Join {{eventLocationType}}",
|
||||
"locked": "Locked",
|
||||
"unlocked": "Unlocked",
|
||||
"join_event_location": "Join {{eventLocationType}}",
|
||||
"troubleshooting":"Troubleshooting",
|
||||
"calendars_were_checking_for_conflicts":"Calendars we’re checking for conflicts",
|
||||
"availabilty_schedules":"Availability schedules",
|
||||
@@ -2299,4 +2309,4 @@
|
||||
"confirm_email_description": "We sent an email to <strong>{{email}}</strong>. Click the link in the email to verify this address.",
|
||||
"send_event_details_to": "Send event details to",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import type { CompleteEventType, CompleteWorkflowsOnEventTypes } from "@calcom/p
|
||||
|
||||
const mockFindFirstEventType = (data?: Partial<CompleteEventType>) => {
|
||||
const eventType = buildEventType(data as Partial<EventType>);
|
||||
// const { scheduleId, destinationCalendar, ...restEventType } = eventType;
|
||||
prismaMock.eventType.findFirst.mockResolvedValue(eventType as EventType);
|
||||
|
||||
return eventType;
|
||||
};
|
||||
|
||||
@@ -52,7 +54,10 @@ describe("handleChildrenEventTypes", () => {
|
||||
});
|
||||
|
||||
it("Returns message 'No managed event metadata'", async () => {
|
||||
mockFindFirstEventType();
|
||||
mockFindFirstEventType({
|
||||
metadata: {},
|
||||
locations: [],
|
||||
});
|
||||
const result = await updateChildrenEventTypes({
|
||||
eventTypeId: 1,
|
||||
oldEventType: { children: [], team: { name: "" } },
|
||||
@@ -136,6 +141,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
...evType,
|
||||
parentId: 1,
|
||||
users: { connect: [{ id: 4 }] },
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
bookingLimits: undefined,
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
@@ -183,10 +190,14 @@ describe("handleChildrenEventTypes", () => {
|
||||
bookingLimits: undefined,
|
||||
},
|
||||
});
|
||||
const { profileId, ...rest } = evType;
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
data: {
|
||||
hidden: false,
|
||||
bookingLimits: undefined,
|
||||
...rest,
|
||||
locations: [],
|
||||
scheduleId: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
hashedLink: { create: { link: expect.any(String) } },
|
||||
},
|
||||
where: {
|
||||
@@ -291,7 +302,10 @@ describe("handleChildrenEventTypes", () => {
|
||||
durationLimits: undefined,
|
||||
recurringEvent: undefined,
|
||||
hashedLink: undefined,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
userId: 4,
|
||||
workflows: undefined,
|
||||
},
|
||||
});
|
||||
expect(result.newUserIds).toEqual([4]);
|
||||
@@ -334,11 +348,13 @@ describe("handleChildrenEventTypes", () => {
|
||||
length: 30,
|
||||
},
|
||||
});
|
||||
const { profileId, ...rest } = evType;
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
data: {
|
||||
hidden: false,
|
||||
length: 30,
|
||||
hashedLink: undefined,
|
||||
...rest,
|
||||
locations: [],
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
},
|
||||
where: {
|
||||
userId_parentId: {
|
||||
@@ -403,6 +419,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
recurringEvent: undefined,
|
||||
hashedLink: undefined,
|
||||
locations: [],
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
parentId: 1,
|
||||
userId: 5,
|
||||
users: {
|
||||
@@ -417,9 +435,14 @@ describe("handleChildrenEventTypes", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const { profileId, ...rest } = evType;
|
||||
if ("workflows" in rest) delete rest.workflows;
|
||||
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
|
||||
data: {
|
||||
hidden: false,
|
||||
...rest,
|
||||
locations: [],
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
hashedLink: undefined,
|
||||
},
|
||||
where: {
|
||||
|
||||
@@ -1,27 +1,102 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { get } from "lodash";
|
||||
import React from "react";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type z from "zod";
|
||||
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { _EventTypeModel } from "@calcom/prisma/zod/eventtype";
|
||||
import { Tooltip } from "@calcom/ui";
|
||||
import { Lock } from "@calcom/ui/components/icon";
|
||||
import { Tooltip, Badge, Switch } from "@calcom/ui";
|
||||
import { Lock, Unlock } from "@calcom/ui/components/icon";
|
||||
|
||||
export const LockedIndicator = (label: string) => (
|
||||
<Tooltip content={<>{label}</>}>
|
||||
<div className="bg -mt-0.5 ml-1 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
|
||||
export const LockedSwitch = (
|
||||
isManagedEventType: boolean,
|
||||
[isLocked, setIsLocked]: [boolean, Dispatch<SetStateAction<boolean>>],
|
||||
fieldName: string,
|
||||
setUnlockedFields: (fieldName: string, val: boolean | undefined) => void,
|
||||
options = { simple: false }
|
||||
) => {
|
||||
return isManagedEventType ? (
|
||||
<Switch
|
||||
data-testid={`locked-indicator-${fieldName}`}
|
||||
onCheckedChange={(enabled) => {
|
||||
setIsLocked(enabled);
|
||||
setUnlockedFields(fieldName, !enabled || undefined);
|
||||
}}
|
||||
checked={isLocked}
|
||||
small={!options.simple}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const LockedIndicator = (
|
||||
isChildrenManagedEventType: boolean,
|
||||
isManagedEventType: boolean,
|
||||
[isLocked, setIsLocked]: [boolean, Dispatch<SetStateAction<boolean>>],
|
||||
t: TFunction,
|
||||
fieldName: string,
|
||||
setUnlockedFields: (fieldName: string, val: boolean | undefined) => void,
|
||||
options = { simple: false }
|
||||
) => {
|
||||
const stateText = t(isLocked ? "locked" : "unlocked");
|
||||
const tooltipText = t(
|
||||
`${isLocked ? "locked" : "unlocked"}_fields_${isManagedEventType ? "admin" : "member"}_description`
|
||||
);
|
||||
|
||||
return (
|
||||
(isManagedEventType || isChildrenManagedEventType) && (
|
||||
<Tooltip content={<>{tooltipText}</>}>
|
||||
<div className="inline">
|
||||
<Badge
|
||||
variant={isLocked ? "gray" : "green"}
|
||||
className={classNames(
|
||||
"ml-2 transform justify-between gap-1.5 p-1",
|
||||
isManagedEventType && !options.simple && "w-28"
|
||||
)}>
|
||||
{!options.simple && (
|
||||
<span className="inline-flex">
|
||||
{isLocked ? (
|
||||
<Lock className="text-subtle h-3 w-3" />
|
||||
) : (
|
||||
<Unlock className="text-subtle h-3 w-3" />
|
||||
)}
|
||||
<span className="ml-1 font-medium">{stateText}</span>
|
||||
</span>
|
||||
)}
|
||||
{isManagedEventType && (
|
||||
<Switch
|
||||
data-testid={`locked-indicator-${fieldName}`}
|
||||
onCheckedChange={(enabled) => {
|
||||
setIsLocked(enabled);
|
||||
setUnlockedFields(fieldName, !enabled || undefined);
|
||||
}}
|
||||
checked={isLocked}
|
||||
small={!options.simple}
|
||||
/>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const useLockedFieldsManager = ({
|
||||
eventType,
|
||||
translate,
|
||||
formMethods,
|
||||
}: {
|
||||
eventType: Pick<z.infer<typeof _EventTypeModel>, "schedulingType" | "userId" | "metadata" | "id">;
|
||||
translate: TFunction;
|
||||
formMethods: UseFormReturn<FormValues>;
|
||||
}) => {
|
||||
const { setValue, getValues } = formMethods;
|
||||
const fieldStates: Record<string, [boolean, Dispatch<SetStateAction<boolean>>]> = {};
|
||||
const unlockedFields =
|
||||
(eventType.metadata?.managedEventConfig?.unlockedFields !== undefined &&
|
||||
eventType.metadata?.managedEventConfig?.unlockedFields) ||
|
||||
@@ -32,28 +107,106 @@ const useLockedFieldsManager = (
|
||||
eventType.metadata?.managedEventConfig !== undefined &&
|
||||
eventType.schedulingType !== SchedulingType.MANAGED;
|
||||
|
||||
const shouldLockIndicator = (fieldName: string) => {
|
||||
const setUnlockedFields = (fieldName: string, val: boolean | undefined) => {
|
||||
const path = "metadata.managedEventConfig.unlockedFields";
|
||||
const metaUnlockedFields = getValues(path);
|
||||
if (!metaUnlockedFields) return;
|
||||
if (val === undefined) {
|
||||
delete metaUnlockedFields[fieldName as keyof typeof metaUnlockedFields];
|
||||
setValue(path, { ...metaUnlockedFields }, { shouldDirty: true });
|
||||
} else {
|
||||
setValue(
|
||||
path,
|
||||
{
|
||||
...metaUnlockedFields,
|
||||
[fieldName]: val,
|
||||
},
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getLockedInitState = (fieldName: string): boolean => {
|
||||
let locked = isManagedEventType || isChildrenManagedEventType;
|
||||
// Supports "metadata.fieldName"
|
||||
const unlockedFieldList = getValues("metadata")?.managedEventConfig?.unlockedFields;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
const fieldIsUnlocked = unlockedFieldList && unlockedFieldList[fieldName] === true;
|
||||
if (fieldName.includes(".")) {
|
||||
locked = locked && get(unlockedFields, fieldName) === undefined;
|
||||
} else {
|
||||
locked = locked && unlockedFields[fieldName as keyof Omit<Prisma.EventTypeSelect, "id">] === undefined;
|
||||
locked = locked && !fieldIsUnlocked;
|
||||
}
|
||||
return locked && LockedIndicator(isManagedEventType ? adminLabel : memberLabel);
|
||||
return locked;
|
||||
};
|
||||
|
||||
const shouldLockDisableProps = (fieldName: string) => {
|
||||
const useShouldLockIndicator = (fieldName: string, options?: { simple: true }) => {
|
||||
if (!fieldStates[fieldName]) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
|
||||
}
|
||||
|
||||
return LockedIndicator(
|
||||
isChildrenManagedEventType,
|
||||
isManagedEventType,
|
||||
fieldStates[fieldName],
|
||||
translate,
|
||||
fieldName,
|
||||
setUnlockedFields,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
const useLockedLabel = (fieldName: string, options?: { simple: true }) => {
|
||||
if (!fieldStates[fieldName]) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
|
||||
}
|
||||
const isLocked = fieldStates[fieldName][0];
|
||||
|
||||
return {
|
||||
disabled:
|
||||
!isManagedEventType &&
|
||||
eventType.metadata?.managedEventConfig !== undefined &&
|
||||
unlockedFields[fieldName as keyof Omit<Prisma.EventTypeSelect, "id">] === undefined,
|
||||
LockedIcon: shouldLockIndicator(fieldName),
|
||||
LockedIcon: useShouldLockIndicator(fieldName, options),
|
||||
isLocked,
|
||||
};
|
||||
};
|
||||
|
||||
return { shouldLockIndicator, shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType };
|
||||
const useLockedSwitch = (fieldName: string, options = { simple: false }) => {
|
||||
if (!fieldStates[fieldName]) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
|
||||
}
|
||||
|
||||
return () =>
|
||||
LockedSwitch(isManagedEventType, fieldStates[fieldName], fieldName, setUnlockedFields, options);
|
||||
};
|
||||
|
||||
const useShouldLockDisableProps = (fieldName: string, options?: { simple: true }) => {
|
||||
if (!fieldStates[fieldName]) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
|
||||
}
|
||||
return {
|
||||
disabled:
|
||||
!isManagedEventType &&
|
||||
eventType.metadata?.managedEventConfig !== undefined &&
|
||||
unlockedFields[fieldName as keyof Omit<Prisma.EventTypeSelect, "id">] === undefined,
|
||||
LockedIcon: useShouldLockIndicator(fieldName, options),
|
||||
isLocked: fieldStates[fieldName][0],
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
shouldLockIndicator: useShouldLockIndicator,
|
||||
shouldLockDisableProps: useShouldLockDisableProps,
|
||||
useLockedLabel,
|
||||
useLockedSwitch,
|
||||
isManagedEventType,
|
||||
isChildrenManagedEventType,
|
||||
};
|
||||
};
|
||||
|
||||
export default useLockedFieldsManager;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type { DeepMockProxy } from "vitest-mock-extended";
|
||||
|
||||
import { sendSlugReplacementEmail } from "@calcom/emails/email-manager";
|
||||
@@ -121,7 +122,7 @@ export default async function handleChildrenEventTypes({
|
||||
bookingFields: _EventTypeModel.shape.bookingFields.nullish(),
|
||||
});
|
||||
|
||||
const allManagedEventTypePropsZod = _ManagedEventTypeModel.pick(allManagedEventTypeProps); //FIXME
|
||||
const allManagedEventTypePropsZod = _ManagedEventTypeModel.pick(allManagedEventTypeProps);
|
||||
const managedEventTypeValues = allManagedEventTypePropsZod
|
||||
.omit(unlockedManagedEventTypeProps)
|
||||
.parse(eventType);
|
||||
@@ -169,7 +170,6 @@ export default async function handleChildrenEventTypes({
|
||||
userIds: newUserIds,
|
||||
teamName: oldEventType.team?.name ?? null,
|
||||
});
|
||||
|
||||
// Create event types for new users added
|
||||
await prisma.$transaction(
|
||||
newUserIds.map((userId) => {
|
||||
@@ -220,6 +220,28 @@ export default async function handleChildrenEventTypes({
|
||||
teamName: oldEventType.team?.name || null,
|
||||
});
|
||||
|
||||
const { unlockedFields } = managedEventTypeValues.metadata?.managedEventConfig;
|
||||
const unlockedFieldProps = !unlockedFields
|
||||
? {}
|
||||
: Object.keys(unlockedFields).reduce((acc, key) => {
|
||||
const filteredKey =
|
||||
key === "afterBufferTime"
|
||||
? "afterEventBuffer"
|
||||
: key === "beforeBufferTime"
|
||||
? "beforeEventBuffer"
|
||||
: key;
|
||||
// @ts-expect-error Element implicitly has any type
|
||||
acc[filteredKey] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Add to payload all eventType values that belong to locked fields, changed or unchanged
|
||||
// Ignore from payload any eventType values that belong to unlocked fields
|
||||
const updatePayload = allManagedEventTypePropsZod.omit(unlockedFieldProps).parse(eventType);
|
||||
const updatePayloadFiltered = Object.entries(updatePayload)
|
||||
.filter(([key, _]) => key !== "children")
|
||||
.reduce((newObj, [key, value]) => ({ ...newObj, [key]: value }), {});
|
||||
console.log({ unlockedFieldProps });
|
||||
// Update event types for old users
|
||||
const oldEventTypes = await prisma.$transaction(
|
||||
oldUserIds.map((userId) => {
|
||||
@@ -231,9 +253,8 @@ export default async function handleChildrenEventTypes({
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...updatedValues,
|
||||
hashedLink: hashedLinkQuery(userId),
|
||||
hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false,
|
||||
...updatePayloadFiltered,
|
||||
hashedLink: "hashedLink" in unlockedFieldProps ? undefined : hashedLinkQuery(userId),
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { isTextMessageToAttendeeAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions";
|
||||
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -157,7 +160,7 @@ const WorkflowListItem = (props: ItemProps) => {
|
||||
content={
|
||||
t(
|
||||
workflow.readOnly && props.isChildrenManagedEventType
|
||||
? "locked_by_admin"
|
||||
? "locked_by_team_admin"
|
||||
: isActive
|
||||
? "turn_off"
|
||||
: "turn_on"
|
||||
@@ -200,11 +203,17 @@ type Props = {
|
||||
function EventWorkflowsTab(props: Props) {
|
||||
const { workflows, eventType } = props;
|
||||
const { t } = useLocale();
|
||||
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
translate: t,
|
||||
formMethods,
|
||||
});
|
||||
|
||||
const workflowsDisableProps = shouldLockDisableProps("workflows", { simple: true });
|
||||
|
||||
const lockedText = workflowsDisableProps.isLocked ? "locked" : "unlocked";
|
||||
|
||||
const { data, isPending } = trpc.viewer.workflows.list.useQuery({
|
||||
teamId: eventType.team?.id,
|
||||
userId: !isChildrenManagedEventType ? eventType.userId || undefined : undefined,
|
||||
@@ -223,14 +232,20 @@ function EventWorkflowsTab(props: Props) {
|
||||
});
|
||||
const disabledWorkflows = data.workflows.filter(
|
||||
(workflow) =>
|
||||
(!workflow.teamId || eventType.teamId === workflow.teamId) &&
|
||||
!workflows
|
||||
.map((workflow) => {
|
||||
return workflow.id;
|
||||
})
|
||||
.includes(workflow.id)
|
||||
);
|
||||
setSortedWorkflows(activeWorkflows.concat(disabledWorkflows));
|
||||
const allSortedWorkflows =
|
||||
workflowsDisableProps.isLocked && !isManagedEventType
|
||||
? activeWorkflows
|
||||
: activeWorkflows.concat(disabledWorkflows);
|
||||
setSortedWorkflows(allSortedWorkflows);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isPending]);
|
||||
|
||||
const createMutation = trpc.viewer.workflows.create.useMutation({
|
||||
@@ -254,15 +269,31 @@ function EventWorkflowsTab(props: Props) {
|
||||
<LicenseRequired>
|
||||
{!isPending ? (
|
||||
<>
|
||||
{isManagedEventType && (
|
||||
{(isManagedEventType || isChildrenManagedEventType) && (
|
||||
<Alert
|
||||
severity="neutral"
|
||||
severity={workflowsDisableProps.isLocked ? "neutral" : "green"}
|
||||
className="mb-2"
|
||||
title={t("locked_for_members")}
|
||||
message={t("locked_workflows_description")}
|
||||
title={
|
||||
<Trans i18nKey={`${lockedText}_${isManagedEventType ? "for_members" : "by_team_admins"}`}>
|
||||
{lockedText[0].toUpperCase()}
|
||||
{lockedText.slice(1)} {isManagedEventType ? "for members" : "by team admins"}
|
||||
</Trans>
|
||||
}
|
||||
actions={<div className="flex h-full items-center">{workflowsDisableProps.LockedIcon}</div>}
|
||||
message={
|
||||
<Trans
|
||||
i18nKey={`workflows_${lockedText}_${
|
||||
isManagedEventType ? "for_members" : "by_team_admins"
|
||||
}_description`}>
|
||||
{isManagedEventType ? "Members" : "You"}{" "}
|
||||
{workflowsDisableProps.isLocked
|
||||
? "will be able to see the active workflows but will not be able to edit any workflow settings"
|
||||
: "will be able to see the active workflow and will be able to edit any workflow settings"}
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{data?.workflows && data?.workflows.length > 0 ? (
|
||||
{data?.workflows && sortedWorkflows.length > 0 ? (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{sortedWorkflows.map((workflow) => {
|
||||
@@ -285,6 +316,7 @@ function EventWorkflowsTab(props: Props) {
|
||||
description={t("no_workflows_description")}
|
||||
buttonRaw={
|
||||
<Button
|
||||
disabled={workflowsDisableProps.isLocked && !isManagedEventType}
|
||||
target="_blank"
|
||||
color="secondary"
|
||||
onClick={() => createMutation.mutate({ teamId: eventType.team?.id })}
|
||||
|
||||
@@ -54,12 +54,19 @@ export default function WorkflowDetailsPage(props: Props) {
|
||||
if (teamId && teamId !== group.teamId) return options;
|
||||
return [
|
||||
...options,
|
||||
...group.eventTypes.map((eventType) => ({
|
||||
value: String(eventType.id),
|
||||
label: `${eventType.title} ${
|
||||
eventType.children && eventType.children.length ? `(+${eventType.children.length})` : ``
|
||||
}`,
|
||||
})),
|
||||
...group.eventTypes
|
||||
.filter(
|
||||
(evType) =>
|
||||
!evType.metadata?.managedEventConfig ||
|
||||
!!evType.metadata?.managedEventConfig.unlockedFields?.workflows ||
|
||||
!!teamId
|
||||
)
|
||||
.map((eventType) => ({
|
||||
value: String(eventType.id),
|
||||
label: `${eventType.title} ${
|
||||
eventType.children && eventType.children.length ? `(+${eventType.children.length})` : ``
|
||||
}`,
|
||||
})),
|
||||
];
|
||||
}, [] as Option[]) || [],
|
||||
[data]
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
@@ -69,6 +70,7 @@ const querySchema = z.object({
|
||||
|
||||
export default function CreateEventTypeDialog({
|
||||
profileOptions,
|
||||
isOrganization,
|
||||
}: {
|
||||
profileOptions: {
|
||||
teamId: number | null | undefined;
|
||||
@@ -76,6 +78,7 @@ export default function CreateEventTypeDialog({
|
||||
image: string | undefined;
|
||||
membershipRole: MembershipRole | null | undefined;
|
||||
}[];
|
||||
isOrganization: boolean;
|
||||
}) {
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
@@ -86,8 +89,10 @@ export default function CreateEventTypeDialog({
|
||||
const {
|
||||
data: { teamId, eventPage: pageSlug },
|
||||
} = useTypedQuery(querySchema);
|
||||
const teamProfile = profileOptions.find((profile) => profile.teamId === teamId);
|
||||
|
||||
const teamProfile = profileOptions.find((profile) => profile.teamId === teamId);
|
||||
const isSelfHosted = !HOSTED_CAL_FEATURES;
|
||||
const isEE = !!(isSelfHosted || isOrganization);
|
||||
const form = useForm<z.infer<typeof createEventTypeInput>>({
|
||||
defaultValues: {
|
||||
length: 15,
|
||||
@@ -283,7 +288,7 @@ export default function CreateEventTypeDialog({
|
||||
<p>{t("round_robin_description")}</p>
|
||||
</RadioArea.Item>
|
||||
<>
|
||||
{isAdmin && (
|
||||
{isAdmin && isEE && (
|
||||
<RadioArea.Item
|
||||
{...register("schedulingType")}
|
||||
value={SchedulingType.MANAGED}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import type { EventLocationType } from "@calcom/core/location";
|
||||
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
|
||||
import type { PeriodType, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { BookerLayoutSettings, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { customInputSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { IntervalLimit, RecurringEvent } from "@calcom/types/Calendar";
|
||||
|
||||
export type CustomInputParsed = typeof customInputSchema._output;
|
||||
|
||||
export type AvailabilityOption = {
|
||||
label: string;
|
||||
value: number;
|
||||
isDefault: boolean;
|
||||
isManaged?: boolean;
|
||||
};
|
||||
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
|
||||
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
|
||||
export type Host = { isFixed: boolean; userId: number; priority: number };
|
||||
|
||||
export type FormValues = {
|
||||
id: number;
|
||||
title: string;
|
||||
eventTitle: string;
|
||||
eventName: string;
|
||||
slug: string;
|
||||
isInstantEvent: boolean;
|
||||
length: number;
|
||||
offsetStart: number;
|
||||
description: string;
|
||||
disableGuests: boolean;
|
||||
lockTimeZoneToggleOnBookingPage: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
requiresBookerEmailVerification: boolean;
|
||||
recurringEvent: RecurringEvent | null;
|
||||
schedulingType: SchedulingType | null;
|
||||
hidden: boolean;
|
||||
hideCalendarNotes: boolean;
|
||||
hashedLink: string | undefined;
|
||||
locations: {
|
||||
type: EventLocationType["type"];
|
||||
address?: string;
|
||||
attendeeAddress?: string;
|
||||
link?: string;
|
||||
hostPhoneNumber?: string;
|
||||
displayLocationPublicly?: boolean;
|
||||
phone?: string;
|
||||
hostDefault?: string;
|
||||
credentialId?: number;
|
||||
teamName?: string;
|
||||
}[];
|
||||
customInputs: CustomInputParsed[];
|
||||
schedule: number | null;
|
||||
periodType: PeriodType;
|
||||
periodDays: number;
|
||||
periodCountCalendarDays: boolean;
|
||||
periodDates: { startDate: Date; endDate: Date };
|
||||
seatsPerTimeSlot: number | null;
|
||||
seatsShowAttendees: boolean | null;
|
||||
seatsShowAvailabilityCount: boolean | null;
|
||||
seatsPerTimeSlotEnabled: boolean;
|
||||
scheduleName: string;
|
||||
minimumBookingNotice: number;
|
||||
minimumBookingNoticeInDurationType: number;
|
||||
beforeEventBuffer: number;
|
||||
afterEventBuffer: number;
|
||||
slotInterval: number | null;
|
||||
metadata: z.infer<typeof EventTypeMetaDataSchema>;
|
||||
destinationCalendar: {
|
||||
integration: string;
|
||||
externalId: string;
|
||||
};
|
||||
successRedirectUrl: string;
|
||||
durationLimits?: IntervalLimit;
|
||||
bookingLimits?: IntervalLimit;
|
||||
onlyShowFirstAvailableSlot: boolean;
|
||||
children: ChildrenEventType[];
|
||||
hosts: Host[];
|
||||
bookingFields: z.infer<typeof eventTypeBookingFields>;
|
||||
availability?: AvailabilityOption;
|
||||
bookerLayouts: BookerLayoutSettings;
|
||||
multipleDurationEnabled: boolean;
|
||||
users: EventTypeSetup["users"];
|
||||
assignAllTeamMembers: boolean;
|
||||
useEventTypeDestinationCalendarEmail: boolean;
|
||||
secondaryEmailId?: number;
|
||||
};
|
||||
@@ -628,6 +628,8 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
|
||||
workflows: true,
|
||||
bookingFields: true,
|
||||
durationLimits: true,
|
||||
lockTimeZoneToggleOnBookingPage: true,
|
||||
requiresBookerEmailVerification: true,
|
||||
assignAllTeamMembers: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface AlertProps {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
// @TODO: Success and info shouldn't exist as per design?
|
||||
severity: "success" | "warning" | "error" | "info" | "neutral";
|
||||
severity: "success" | "warning" | "error" | "info" | "neutral" | "green";
|
||||
CustomIcon?: IconType;
|
||||
customIconColor?: string;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export const Alert = forwardRef<HTMLDivElement, AlertProps>((props, ref) => {
|
||||
severity === "error" && "bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-200",
|
||||
severity === "warning" && "text-attention bg-attention dark:bg-orange-900 dark:text-orange-200",
|
||||
severity === "info" && "bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-200",
|
||||
severity === "green" && "bg-success text-success",
|
||||
severity === "success" && "bg-inverted text-inverted",
|
||||
severity === "neutral" && "bg-subtle text-default"
|
||||
)}>
|
||||
|
||||
@@ -57,7 +57,8 @@ function SettingsToggle({
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2" data-testid={`${rest["data-testid"]}-title`}>
|
||||
<Label
|
||||
className={classNames("mt-0.5 text-base font-semibold leading-none", labelClassName)}>
|
||||
className={classNames("mt-0.5 text-base font-semibold leading-none", labelClassName)}
|
||||
htmlFor="">
|
||||
{title}
|
||||
{LockedIcon}
|
||||
</Label>
|
||||
|
||||
@@ -20,6 +20,7 @@ const Switch = (
|
||||
fitToHeight?: boolean;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
small?: boolean;
|
||||
labelOnLeading?: boolean;
|
||||
classNames?: {
|
||||
container?: string;
|
||||
@@ -28,7 +29,7 @@ const Switch = (
|
||||
LockedIcon?: React.ReactNode;
|
||||
}
|
||||
) => {
|
||||
const { label, fitToHeight, classNames, labelOnLeading, LockedIcon, ...primitiveProps } = props;
|
||||
const { label, fitToHeight, classNames, small, labelOnLeading, LockedIcon, ...primitiveProps } = props;
|
||||
const id = useId();
|
||||
const isChecked = props.checked || props.defaultChecked;
|
||||
return (
|
||||
@@ -45,14 +46,18 @@ const Switch = (
|
||||
className={cx(
|
||||
isChecked ? "bg-brand-default dark:bg-brand-emphasis" : "bg-emphasis",
|
||||
primitiveProps.disabled && "cursor-not-allowed",
|
||||
"focus:ring-brand-default focus:border-subtle h-5 w-[34px] min-w-[34px] rounded-full shadow-none focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1",
|
||||
small ? "h-4 w-[27px]" : "h-5 w-[34px]",
|
||||
"focus:ring-brand-default rounded-full shadow-none focus:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1",
|
||||
props.className
|
||||
)}
|
||||
{...primitiveProps}>
|
||||
<PrimitiveSwitch.Thumb
|
||||
id={id}
|
||||
className={cx(
|
||||
"block h-[14px] w-[14px] rounded-full transition will-change-transform ltr:translate-x-[4px] rtl:-translate-x-[4px] ltr:[&[data-state='checked']]:translate-x-[17px] rtl:[&[data-state='checked']]:-translate-x-[17px]",
|
||||
small
|
||||
? "h-[10px] w-[10px] ltr:translate-x-[2px] rtl:-translate-x-[2px] ltr:[&[data-state='checked']]:translate-x-[13px] rtl:[&[data-state='checked']]:-translate-x-[13px]"
|
||||
: "h-[14px] w-[14px] ltr:translate-x-[4px] rtl:-translate-x-[4px] ltr:[&[data-state='checked']]:translate-x-[17px] rtl:[&[data-state='checked']]:-translate-x-[17px]",
|
||||
"block rounded-full transition will-change-transform",
|
||||
isChecked ? "bg-brand-accent shadow-inner" : "bg-default",
|
||||
classNames?.thumb
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user