Compare commits

...

15 Commits

Author SHA1 Message Date
Syed Ali Shahbaz a7749288ee
Merge branch 'main' into feat/v2-managed-events 2024-01-12 18:26:01 +04:00
alishaz-polymath 83dfa0248b early review fixes ... 2024-01-12 18:03:59 +04:00
alishaz-polymath 2b279356d9 prettier fix 2024-01-11 15:29:49 +04:00
alishaz-polymath 922053a4c4 fix recurring 2024-01-11 15:15:16 +04:00
alishaz-polymath d3e1d12c8c lock private url for managed type to stop confusion 2024-01-11 14:21:56 +04:00
alishaz-polymath afd0d2e0cd lock hashed link until further notice 2024-01-11 14:12:54 +04:00
alishaz-polymath bcac078cee fix bugs 2024-01-10 18:00:09 +04:00
Syed Ali Shahbaz 409658a43d
Merge branch 'main' into feat/v2-managed-events 2024-01-10 16:44:14 +04:00
alishaz-polymath 6ebf167d4b WIP 2024-01-10 16:38:59 +04:00
Syed Ali Shahbaz b2c6122ed7
Merge branch 'main' into feat/v2-managed-events 2024-01-02 12:43:29 +04:00
alishaz-polymath 48d18a3925 wip 2024-01-02 12:39:25 +04:00
alishaz-polymath 1821c7aa3a Fix event limit interlinked switches 2023-12-15 22:37:47 +04:00
Syed Ali Shahbaz 3b6f0d3a53
Merge branch 'main' into feat/v2-managed-events 2023-12-13 17:06:36 +04:00
Syed Ali Shahbaz bc65859444
Merge branch 'main' into feat/v2-managed-events 2023-12-07 17:27:53 +04:00
alishaz-polymath dc6fcaae76 init 2023-11-10 17:29:28 +04:00
23 changed files with 523 additions and 231 deletions

View File

@ -1,6 +1,6 @@
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 short from "short-uuid";
@ -16,6 +16,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";
@ -107,11 +108,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
);
};
const { shouldLockDisableProps } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
const { isChildrenManagedEventType, isManagedEventType, shouldLockDisableProps, shouldLockIndicator } =
useLockedFieldsManager(eventType, formMethods, t);
const eventNamePlaceholder = getEventName({
...eventNameObject,
eventName: formMethods.watch("eventName"),
@ -119,9 +117,15 @@ 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);
// For the field 'eventName'
// const EventNameLabel = useLockedLabel("eventName");
// const EventNameSwitch = useLockedSwitch("eventName")();
const setEventName = (value: string) => formMethods.setValue("eventName", value);
return (
<div className="flex flex-col space-y-4">
@ -165,6 +169,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<TextField
label={t("event_name_in_calendar")}
type="text"
isDisabled={shouldLockDisableProps("eventName").disabled}
{...shouldLockDisableProps("eventName")}
placeholder={eventNamePlaceholder}
defaultValue={eventType.eventName || ""}
@ -173,6 +178,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)}>
@ -182,9 +188,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")}
@ -199,7 +203,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
}}
/>
</div>
<RequiresConfirmationController
eventType={eventType}
seatsEnabled={seatsEnabled}
@ -207,7 +210,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
requiresConfirmation={requiresConfirmation}
onRequiresConfirmation={setRequiresConfirmation}
/>
<Controller
name="requiresBookerEmailVerification"
control={formMethods.control}
@ -218,14 +220,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("requires_booker_email_verification")}
{...shouldLockDisableProps("requiresBookerEmailVerification")}
{...requiresBookerEmailVerificationProps}
description={t("description_requires_booker_email_verification")}
checked={value}
onCheckedChange={(e) => onChange(e)}
/>
)}
/>
<Controller
name="hideCalendarNotes"
control={formMethods.control}
@ -236,14 +237,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_notes")}
{...shouldLockDisableProps("hideCalendarNotes")}
{...hideCalendarNotesLocked}
description={t("disable_notes_description")}
checked={value}
onCheckedChange={(e) => onChange(e)}
/>
)}
/>
<Controller
name="successRedirectUrl"
control={formMethods.control}
@ -289,7 +289,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
</>
)}
/>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
@ -308,7 +307,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
<Info className="ml-1.5 h-4 w-4 cursor-pointer" />
</a>
}
{...shouldLockDisableProps("hashedLinkCheck")}
{...(isManagedEventType || isChildrenManagedEventType ? { disabled: true } : {})}
{...(isChildrenManagedEventType ? { LockedIcon: shouldLockIndicator("hashedLink", false) } : {})}
description={t("private_link_description", { appName: APP_NAME })}
checked={hashedLinkVisible}
onCheckedChange={(e) => {
@ -350,7 +350,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
)}
</div>
</SettingsToggle>
<Controller
name="seatsPerTimeSlotEnabled"
control={formMethods.control}
@ -444,7 +443,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)}

View File

@ -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";
@ -73,9 +74,11 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
methods,
t
);
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) => {
@ -130,12 +133,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>
}
/>
)}
{!isLoading && !installedApps?.length ? (
@ -144,9 +163,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">
@ -177,7 +196,7 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
})}
</div>
</div>
{!shouldLockDisableProps("apps").disabled && (
{!appsDisableProps.disabled && (
<div className="bg-muted mt-6 rounded-md p-8">
{!isLoading && notInstalledApps?.length ? (
<>

View File

@ -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,11 +154,9 @@ 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 formMethods = useFormContext<FormValues>();
const { shouldLockIndicator, shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } =
useLockedFieldsManager(eventType, formMethods, t);
const { watch, setValue, getValues } = useFormContext<FormValues>();
const watchSchedule = watch("schedule");
const [options, setOptions] = useState<AvailabilityOption[]>([]);
@ -239,7 +231,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>
{isLoading && <SelectSkeletonLoader />}
{!isLoading && (
@ -250,6 +242,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);

View File

@ -1,12 +1,14 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as RadioGroup from "@radix-ui/react-radio-group";
import type { EventTypeSetupProps, 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, useWatch } 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";
@ -140,6 +142,14 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
defaultValue: periodType?.type,
});
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(eventType, formMethods, t);
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
const onlyFirstAvailableSlotLocked = shouldLockDisableProps("onlyShowFirstAvailableSlot");
const periodTypeLocked = shouldLockDisableProps("periodType");
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
const optionsPeriod = [
{ value: 1, label: t("calendar_days") },
{ value: 0, label: t("business_days") },
@ -162,7 +172,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<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="beforeBufferTime">{t("before_event")}</Label>
<Label htmlFor="beforeBufferTime">
{t("before_event")}
{shouldLockIndicator("beforeBufferTime")}
</Label>
<Controller
name="beforeBufferTime"
control={formMethods.control}
@ -184,6 +197,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
onChange={(val) => {
if (val) onChange(val.value);
}}
isDisabled={shouldLockDisableProps("beforeBufferTime").disabled}
defaultValue={
beforeBufferOptions.find((option) => option.value === value) || beforeBufferOptions[0]
}
@ -194,7 +208,10 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
/>
</div>
<div className="w-full">
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
<Label htmlFor="afterBufferTime">
{t("after_event")}
{shouldLockIndicator("afterBufferTime")}
</Label>
<Controller
name="afterBufferTime"
control={formMethods.control}
@ -216,6 +233,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
onChange={(val) => {
if (val) onChange(val.value);
}}
isDisabled={shouldLockDisableProps("afterBufferTime").disabled}
defaultValue={
afterBufferOptions.find((option) => option.value === value) || afterBufferOptions[0]
}
@ -228,11 +246,20 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
</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"
control={formMethods.control}
@ -250,6 +277,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
return (
<Select
isSearchable={false}
isDisabled={shouldLockDisableProps("slotInterval").disabled}
onChange={(val) => {
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
}}
@ -275,6 +303,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
toggleSwitchAtTheEnd={true}
labelClassName="text-sm"
title={t("limit_booking_frequency")}
{...bookingLimitsLocked}
description={t("limit_booking_frequency_description")}
checked={isChecked}
onCheckedChange={(active) => {
@ -292,7 +321,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
)}
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>
);
@ -310,6 +344,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
title={t("limit_booking_only_first_slot")}
description={t("limit_booking_only_first_slot_description")}
checked={isChecked}
{...onlyFirstAvailableSlotLocked}
onCheckedChange={(active) => {
formMethods.setValue("onlyShowFirstAvailableSlot", active ?? false);
}}
@ -337,6 +372,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
childrenClassName="lg:ml-0"
title={t("limit_total_booking_duration")}
description={t("limit_total_booking_duration_description")}
{...durationLimitsLocked}
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
@ -351,6 +387,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<IntervalLimitsManager
propertyName="durationLimits"
defaultLimit={60}
disabled={durationLimitsLocked.disabled}
step={15}
textFieldSuffix={t("minutes")}
/>
@ -376,6 +413,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
childrenClassName="lg:ml-0"
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
{...periodTypeLocked}
checked={isChecked}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
@ -383,7 +421,9 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
defaultValue={watchPeriodType}
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
{PERIOD_TYPES.map((period) => {
{PERIOD_TYPES.filter((opt) =>
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
).map((period) => {
if (period.type === "UNLIMITED") return null;
return (
<div
@ -392,12 +432,14 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
)}
key={period.type}>
<RadioGroup.Item
id={period.type}
value={period.type}
className="min-w-4 bg-default 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}&nbsp;</span> : null}
{period.type === "ROLLING" && (
@ -407,12 +449,14 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
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 })}
defaultValue={eventType.periodDays || 30}
/>
<Select
options={optionsPeriod}
isSearchable={false}
isDisabled={periodTypeLocked.disabled}
onChange={(opt) => {
formMethods.setValue(
"periodCountCalendarDays",
@ -437,6 +481,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
disabled={periodTypeLocked.disabled}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
startDate,
@ -468,6 +513,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
childrenClassName="lg:ml-0"
title={t("offset_toggle")}
description={t("offset_toggle_description")}
{...offsetStartLockedProps}
checked={offsetToggle}
onCheckedChange={(active) => {
setOffsetToggle(active);
@ -480,6 +526,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
required
type="number"
containerClassName="max-w-80"
{...offsetStartLockedProps}
label={t("offset_start")}
{...formMethods.register("offsetStart")}
addOnSuffix={<>{t("minutes")}</>}

View File

@ -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 { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
@ -147,11 +148,7 @@ export const EventSetupTab = (
);
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
);
useLockedFieldsManager(eventType, formMethods, t);
const Locations = () => {
const { t } = useLocale();
@ -206,6 +203,7 @@ export const EventSetupTab = (
required
onChange={onChange}
value={value}
{...(shouldLockDisableProps("locations").disabled ? { disabled: true } : {})}
className="my-0"
{...rest}
/>
@ -225,6 +223,7 @@ export const EventSetupTab = (
return (
<PhoneInput
required
isDisabled={shouldLockDisableProps("locations").disabled}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
value={value}
@ -295,16 +294,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 && (
@ -336,6 +337,7 @@ export const EventSetupTab = (
<CheckboxField
name={`locations[${index}].displayLocationPublicly`}
data-testid="display-location"
isDisabled={shouldLockDisableProps("locations").disabled}
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}
onChange={(e) => {
@ -424,7 +426,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"
@ -451,6 +454,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?:|)\/\//, "")
: `${CAL_URL?.replace(/^(https?:|)\/\//, "")}`;
@ -462,14 +467,14 @@ 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
description={eventType?.description}
@ -479,7 +484,7 @@ export const EventSetupTab = (
<TextField
required
label={t("URL")}
{...shouldLockDisableProps("slug")}
{...(isManagedEventType || isChildrenManagedEventType ? urlLockedProps : {})}
defaultValue={eventType.slug}
addOnLeading={
<>
@ -562,7 +567,7 @@ export const EventSetupTab = (
<TextField
required
type="number"
{...lengthLockedProps}
{...(isManagedEventType || isChildrenManagedEventType ? lengthLockedProps : {})}
label={t("duration")}
defaultValue={eventType.length ?? 15}
{...formMethods.register("length")}
@ -592,14 +597,14 @@ 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"
control={formMethods.control}

View File

@ -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 { useEffect, useRef } from "react";
import type { ComponentProps } 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 } from "@calcom/ui";

View File

@ -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";
@ -51,8 +52,6 @@ import {
Loader,
} from "@calcom/ui/components/icon";
import type { AvailabilityOption } from "@components/eventtype/EventAvailabilityTab";
type Props = {
children: React.ReactNode;
eventType: EventTypeSetupProps["eventType"];
@ -168,8 +167,8 @@ function EventTypeSingleLayout({
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
formMethods,
t
);
// Define tab navigation here

View File

@ -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,8 +99,8 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
const { shouldLockDisableProps, isChildrenManagedEventType, isManagedEventType } = useLockedFieldsManager(
eventType,
t("locked_fields_admin_description"),
t("locked_fields_member_description")
formMethods,
t
);
const webhookLockedStatus = shouldLockDisableProps("webhooks");
@ -161,7 +164,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 />

View File

@ -1,10 +1,11 @@
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 { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, Button, EmptyScreen, SettingsToggle } from "@calcom/ui";

View File

@ -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";
@ -32,11 +33,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, formMethods, t);
const recurringLocked = shouldLockDisableProps("recurringEvent");

View File

@ -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, formMethods, t);
const requiresConfirmationLockedProps = shouldLockDisableProps("requiresConfirmation");
const options = [

View File

@ -11,9 +11,9 @@ 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 { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { validateIntervalLimitOrder } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -22,23 +22,16 @@ 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 } 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 { asStringOrThrow } from "@lib/asStringOrNull";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import PageWrapper from "@components/PageWrapper";
import type { AvailabilityOption } from "@components/eventtype/EventAvailabilityTab";
import { EventTypeSingleLayout } from "@components/eventtype/EventTypeSingleLayout";
import { ssrInit } from "@server/lib/ssr";
@ -84,68 +77,6 @@ const EventWebhooksTab = dynamic(() =>
const ManagedEventTypeDialog = dynamic(() => import("@components/eventtype/ManagedEventDialog"));
export type FormValues = {
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: "1" | "0";
periodDates: { startDate: Date; endDate: Date };
seatsPerTimeSlot: number | null;
seatsShowAttendees: boolean | null;
seatsShowAvailabilityCount: boolean | null;
seatsPerTimeSlotEnabled: boolean;
minimumBookingNotice: number;
minimumBookingNoticeInDurationType: number;
beforeBufferTime: number;
afterBufferTime: 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: { userId: number; isFixed: boolean }[];
bookingFields: z.infer<typeof eventTypeBookingFields>;
availability?: AvailabilityOption;
bookerLayouts: BookerLayoutSettings;
multipleDurationEnabled: boolean;
};
export type CustomInputParsed = typeof customInputSchema._output;
const querySchema = z.object({

View File

@ -11,9 +11,10 @@ import {
selectFirstAvailableTimeSlotNextMonth,
} from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
test.afterAll(({ users }) => users.deleteAll());
test.setTimeout(120000);
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 +62,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 +74,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 +108,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 +118,42 @@ 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();
});
});
});

View File

@ -695,8 +695,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",
@ -1258,7 +1260,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",
@ -1868,10 +1870,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",
@ -2150,7 +2158,10 @@
"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}}",
"join_meeting": "Join Meeting",
"troubleshooting":"Troubleshooting",
"calendars_were_checking_for_conflicts":"Calendars were checking for conflicts",
"availabilty_schedules":"Availability schedules",
@ -2223,4 +2234,4 @@
"create_entry": "Create entry",
"time_range": "Time range",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
}

View File

@ -1,27 +1,87 @@
// 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 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>
);
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 = (
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 (
<Tooltip content={<>{tooltipText}</>}>
<div className="inline">
<Badge variant={isLocked ? "gray" : "green"} className="ml-2 transform gap-1.5 p-1">
{!options.simple && (
<>
{isLocked ? (
<Lock className="text-subtle h-3 w-3" />
) : (
<Unlock className="text-subtle h-3 w-3" />
)}
<span className="font-medium">{stateText}</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: Pick<z.infer<typeof _EventTypeModel>, "schedulingType" | "userId" | "metadata">,
adminLabel: string,
memberLabel: string
eventType: Pick<z.infer<typeof _EventTypeModel>, "schedulingType" | "userId" | "metadata" | "id">,
formMethods: UseFormReturn<FormValues>,
translate: TFunction
) => {
const fieldStates: Record<string, [boolean, Dispatch<SetStateAction<boolean>>]> = {};
const unlockedFields =
(eventType.metadata?.managedEventConfig?.unlockedFields !== undefined &&
eventType.metadata?.managedEventConfig?.unlockedFields) ||
@ -32,7 +92,22 @@ 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 = formMethods.getValues(path);
if (!metaUnlockedFields) return;
if (val === undefined) {
delete metaUnlockedFields[fieldName as keyof typeof metaUnlockedFields];
formMethods.setValue(path, { ...metaUnlockedFields });
} else {
formMethods.setValue(path, {
...metaUnlockedFields,
[fieldName]: val,
});
}
};
const getLockedInitState = (fieldName: string): boolean => {
let locked = isManagedEventType || isChildrenManagedEventType;
// Supports "metadata.fieldName"
if (fieldName.includes(".")) {
@ -40,20 +115,75 @@ const useLockedFieldsManager = (
} else {
locked = locked && unlockedFields[fieldName as keyof Omit<Prisma.EventTypeSelect, "id">] === undefined;
}
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(
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;

View File

@ -122,9 +122,7 @@ export default async function handleChildrenEventTypes({
// Define what values are expected to be changed from a managed event type
const allManagedEventTypePropsZod = _EventTypeModel.pick(allManagedEventTypeProps);
const managedEventTypeValues = allManagedEventTypePropsZod
.omit(unlockedManagedEventTypeProps)
.parse(eventType);
const managedEventTypeValues = allManagedEventTypePropsZod.parse(eventType);
// Check we are certainly dealing with a managed event type through its metadata
if (!managedEventTypeValues.metadata?.managedEventConfig)
@ -233,12 +231,13 @@ export default async function handleChildrenEventTypes({
},
data: {
...managedEventTypeValues,
locations: managedEventTypeValues.locations,
hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false,
bookingLimits:
(managedEventTypeValues.bookingLimits as unknown as Prisma.InputJsonObject) ?? undefined,
onlyShowFirstAvailableSlot: managedEventTypeValues.onlyShowFirstAvailableSlot ?? false,
recurringEvent:
(managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? undefined,
(managedEventTypeValues.recurringEvent as unknown as Prisma.InputJsonValue) ?? null,
metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined,
bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined,
durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined,

View File

@ -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")
formMethods,
t
);
const workflowsDisableProps = shouldLockDisableProps("workflows", { simple: true });
const lockedText = workflowsDisableProps.isLocked ? "locked" : "unlocked";
const { data, isLoading } = trpc.viewer.workflows.list.useQuery({
teamId: eventType.team?.id,
userId: !isChildrenManagedEventType ? eventType.userId || undefined : undefined,
@ -223,13 +232,17 @@ 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
? activeWorkflows
: activeWorkflows.concat(disabledWorkflows);
setSortedWorkflows(allSortedWorkflows);
}
}, [isLoading]);
@ -254,15 +267,31 @@ function EventWorkflowsTab(props: Props) {
<LicenseRequired>
{!isLoading ? (
<>
{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) => {

View File

@ -54,12 +54,18 @@ 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
)
.map((eventType) => ({
value: String(eventType.id),
label: `${eventType.title} ${
eventType.children && eventType.children.length ? `(+${eventType.children.length})` : ``
}`,
})),
];
}, [] as Option[]) || [],
[data]

View File

@ -0,0 +1,80 @@
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 { 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 FormValues = {
title: string;
eventTitle: string;
eventName: string;
slug: string;
length: number;
offsetStart: number;
description: string;
disableGuests: 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: "1" | "0";
periodDates: { startDate: Date; endDate: Date };
seatsPerTimeSlot: number | null;
seatsShowAttendees: boolean | null;
seatsShowAvailabilityCount: boolean | null;
seatsPerTimeSlotEnabled: boolean;
minimumBookingNotice: number;
minimumBookingNoticeInDurationType: number;
beforeBufferTime: number;
afterBufferTime: number;
slotInterval: number | null;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
destinationCalendar: {
integration: string;
externalId: string;
};
successRedirectUrl: string;
durationLimits?: IntervalLimit;
bookingLimits?: IntervalLimit;
children: ChildrenEventType[];
hosts: { userId: number; isFixed: boolean }[];
bookingFields: z.infer<typeof eventTypeBookingFields>;
availability?: AvailabilityOption;
bookerLayouts: BookerLayoutSettings;
multipleDurationEnabled: boolean;
isInstantEvent: boolean;
lockTimeZoneToggleOnBookingPage: boolean;
onlyShowFirstAvailableSlot: boolean;
};

View File

@ -601,6 +601,8 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
workflows: true,
bookingFields: true,
durationLimits: true,
lockTimeZoneToggleOnBookingPage: true,
requiresBookerEmailVerification: true,
};
// All properties that are defined as unlocked based on all managed props

View File

@ -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"
)}>

View File

@ -55,7 +55,8 @@ function SettingsToggle({
<div>
<div className="flex items-center">
<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>

View File

@ -19,6 +19,7 @@ const Switch = (
fitToHeight?: boolean;
disabled?: boolean;
tooltip?: string;
small?: boolean;
labelOnLeading?: boolean;
classNames?: {
container?: string;
@ -27,7 +28,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 (
@ -44,14 +45,18 @@ const Switch = (
className={cx(
isChecked ? "bg-brand-default dark:bg-brand-emphasis" : "bg-emphasis",
primitiveProps.disabled && "cursor-not-allowed",
"focus:ring-brand-default h-5 w-[34px] rounded-full shadow-none focus:border-neutral-300 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
)}