refactor: Only send dirty fields for event type update call (#13292)

* init

* typefix maybe

* fix type

* fix e2e

* Extract delete dialog to its own function to optimise rerender

* wip

* Delete isEqual

* adds eslint tsignore comment

* only update changed fields

* fix type err

* --WIP

* fix type err isLoading—>isPending

* isLoading—>isPending

* post conflict resoution fix

* shouldDirty minimumBookingNotice

* adds shouldDirty to setValues

* reset form to ensure updated default values

* shouldDirty changes and fixed deep check of dirty

* --WIP

* should dirty recurring

* shouldDirty team tab

* multiple-fixes

* adding console log to test something in e2e

* shouldDirty apps tab

* fixes managed event type children association

* fixed managed events update

* dirty managed fix

* fixes some onChange versus setValue and periodCountCalendarDays

* Seeing different behaviour explicitly unwrapping formState

* only mutate if at least 1 field dirty

* fix type error

* bunch of fixes

* --WIP

* update only dirtyFields for Managed Events existing children

* Fixes update old managed event type tests --WIP

* Fix unit tests

A bunch of unit test fixes which started failing
due to update fields only requiring actual updated
fields. some other small fixes also included

* type fix

* remove unnecessary comment

* Adds dirtyField to instant event

* fix: add shouldDirty

* fixes availability and team tabs, and disable save button unless dirty

* fix for hosts and bookerLayout

* add shouldDirty when resetting hosts after changing schedulingType

* add missing shouldDirty to useEventTypeDestinationCalendarEmail

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
Syed Ali Shahbaz
2024-02-15 22:44:20 +00:00
committed by GitHub
co-authored by Alex van Andel Udit Takkar CarinaWolli
parent 72c493ee21
commit ee286a600f
15 changed files with 382 additions and 196 deletions
@@ -59,7 +59,6 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
const selectedThemeIsDark =
user?.theme === "dark" ||
(!user?.theme && typeof document !== "undefined" && document.documentElement.classList.contains("dark"));
formMethods.getValues().bookingFields.forEach(({ name }) => {
bookingFields[name] = `${name} input`;
});
@@ -101,7 +100,8 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
};
}
return field;
})
}),
{ shouldDirty: true }
);
};
@@ -183,7 +183,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
checked={useEventTypeDestinationCalendarEmail}
onCheckedChange={(val) => {
setUseEventTypeDestinationCalendarEmail(val);
formMethods.setValue("useEventTypeDestinationCalendarEmail", val);
formMethods.setValue("useEventTypeDestinationCalendarEmail", val, { shouldDirty: true });
}}
/>
</div>
@@ -319,7 +319,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
description={t("private_link_description", { appName: APP_NAME })}
checked={hashedLinkVisible}
onCheckedChange={(e) => {
formMethods.setValue("hashedLink", e ? hashedUrl : undefined);
formMethods.setValue("hashedLink", e ? hashedUrl : undefined, { shouldDirty: true });
setHashedLinkVisible(e);
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
@@ -383,10 +383,12 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
// Enabling seats will disable guests and requiring confirmation until fully supported
if (e) {
toggleGuests(false);
formMethods.setValue("requiresConfirmation", false);
formMethods.setValue("requiresConfirmation", false, { shouldDirty: true });
setRequiresConfirmation(false);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("seatsPerTimeSlot", eventType.seatsPerTimeSlot ?? 2);
formMethods.setValue("metadata.multipleDuration", undefined, { shouldDirty: true });
formMethods.setValue("seatsPerTimeSlot", eventType.seatsPerTimeSlot ?? 2, {
shouldDirty: true,
});
} else {
formMethods.setValue("seatsPerTimeSlot", null);
toggleGuests(true);
@@ -506,7 +508,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
{showEventNameTip && (
<CustomEventTypeModal
close={closeEventNameTip}
setValue={(val: string) => formMethods.setValue("eventName", val)}
setValue={(val: string) => formMethods.setValue("eventName", val, { shouldDirty: true })}
defaultValue={formMethods.getValues("eventName")}
placeHolder={eventNamePlaceholder}
event={eventNameObject}
@@ -31,10 +31,14 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
const allAppsData = formMethods.watch("metadata")?.apps || {};
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
formMethods.setValue("metadata", {
...formMethods.getValues("metadata"),
apps: _allAppsData,
});
formMethods.setValue(
"metadata",
{
...formMethods.getValues("metadata"),
apps: _allAppsData,
},
{ shouldDirty: true }
);
};
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
@@ -230,7 +230,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
);
setValue("availability", value);
setValue("availability", value, { shouldDirty: true });
},
[data]
);
@@ -238,7 +238,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
useEffect(() => {
if (!availabilityValue?.value) return;
setValue("schedule", availabilityValue.value);
setValue("schedule", availabilityValue.value, { shouldDirty: true });
}, [availabilityValue, setValue]);
return (
@@ -260,7 +260,7 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
isSearchable={false}
onChange={(selected) => {
field.onChange(selected?.value || null);
if (selected?.value) setValue("availability", selected);
if (selected?.value) setValue("availability", selected, { shouldDirty: true });
}}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
value={availabilityValue}
@@ -298,7 +298,7 @@ const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSe
onCheckedChange={(checked) => {
onChange(!checked);
if (!checked) {
setValue("schedule", null);
setValue("schedule", null, { shouldDirty: true });
}
}}
title={t("choose_common_schedule_team_event")}
@@ -61,7 +61,8 @@ const MinimumBookingNoticeInput = React.forwardRef<
minimumBookingNoticeDisplayValues.type,
"minutes",
minimumBookingNoticeDisplayValues.value
)
),
{ shouldDirty: true }
);
}, [minimumBookingNoticeDisplayValues, setValue, passThroughProps.name]);
@@ -230,7 +231,9 @@ export const EventLimitsTab = () => {
<Select
isSearchable={false}
onChange={(val) => {
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null, {
shouldDirty: true,
});
}}
defaultValue={
slotIntervalOptions.find(
@@ -258,11 +261,15 @@ export const EventLimitsTab = () => {
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("bookingLimits", {
PER_DAY: 1,
});
formMethods.setValue(
"bookingLimits",
{
PER_DAY: 1,
},
{ shouldDirty: true }
);
} else {
formMethods.setValue("bookingLimits", {});
formMethods.setValue("bookingLimits", {}, { shouldDirty: true });
}
}}
switchContainerClassName={classNames(
@@ -279,7 +286,7 @@ export const EventLimitsTab = () => {
/>
<Controller
name="onlyShowFirstAvailableSlot"
render={({ field: { value } }) => {
render={({ field: { onChange, value } }) => {
const isChecked = value;
return (
<SettingsToggle
@@ -289,7 +296,7 @@ export const EventLimitsTab = () => {
description={t("limit_booking_only_first_slot_description")}
checked={isChecked}
onCheckedChange={(active) => {
formMethods.setValue("onlyShowFirstAvailableSlot", active ?? false);
onChange(active ?? false);
}}
switchContainerClassName={classNames(
"border-subtle mt-6 rounded-lg border py-6 px-4 sm:px-6",
@@ -301,7 +308,7 @@ export const EventLimitsTab = () => {
/>
<Controller
name="durationLimits"
render={({ field: { value } }) => {
render={({ field: { onChange, value } }) => {
const isChecked = Object.keys(value ?? {}).length > 0;
return (
<SettingsToggle
@@ -317,11 +324,11 @@ export const EventLimitsTab = () => {
checked={isChecked}
onCheckedChange={(active) => {
if (active) {
formMethods.setValue("durationLimits", {
onChange({
PER_DAY: 60,
});
} else {
formMethods.setValue("durationLimits", {});
onChange({});
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
@@ -338,7 +345,7 @@ export const EventLimitsTab = () => {
/>
<Controller
name="periodType"
render={({ field: { value } }) => {
render={({ field: { onChange, value } }) => {
const isChecked = value && value !== "UNLIMITED";
return (
@@ -353,11 +360,16 @@ export const EventLimitsTab = () => {
title={t("limit_future_bookings")}
description={t("limit_future_bookings_description")}
checked={isChecked}
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
onCheckedChange={(bool) =>
// formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED", { shouldDirty: true })
onChange(bool ? "ROLLING" : "UNLIMITED")
}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<RadioGroup.Root
value={watchPeriodType}
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
onValueChange={(val) =>
formMethods.setValue("periodType", val as PeriodType, { shouldDirty: true })
}>
{PERIOD_TYPES.map((period) => {
if (period.type === "UNLIMITED") return null;
return (
@@ -388,17 +400,21 @@ export const EventLimitsTab = () => {
options={optionsPeriod}
isSearchable={false}
onChange={(opt) =>
formMethods.setValue("periodCountCalendarDays", opt?.value === 1 ? "1" : "0")
formMethods.setValue(
"periodCountCalendarDays",
opt?.value === 1 ? true : false,
{ shouldDirty: true }
)
}
name="periodCoundCalendarDays"
value={optionsPeriod.find((opt) => {
opt.value ===
(formMethods.getValues("periodCountCalendarDays") === "1" ? 1 : 0);
(formMethods.getValues("periodCountCalendarDays") === true ? 1 : 0);
})}
defaultValue={optionsPeriod.find(
(opt) =>
opt.value ===
(formMethods.getValues("periodCountCalendarDays") === "1" ? 1 : 0)
(formMethods.getValues("periodCountCalendarDays") === true ? 1 : 0)
)}
/>
</div>
@@ -407,12 +423,12 @@ export const EventLimitsTab = () => {
<div className="me-2 ms-2 inline-flex space-x-2 rtl:space-x-reverse">
<Controller
name="periodDates"
render={() => (
render={({ field: { onChange } }) => (
<DateRangePicker
startDate={formMethods.getValues("periodDates").startDate}
endDate={formMethods.getValues("periodDates").endDate}
onDatesChange={({ startDate, endDate }) => {
formMethods.setValue("periodDates", {
onChange({
startDate,
endDate,
});
@@ -446,7 +462,7 @@ export const EventLimitsTab = () => {
onCheckedChange={(active) => {
setOffsetToggle(active);
if (!active) {
formMethods.setValue("offsetStart", 0);
formMethods.setValue("offsetStart", 0, { shouldDirty: true });
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
@@ -577,11 +593,16 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
);
if (!rest || !currentKeys.length) return;
//currentDurationLimits is always defined so can be casted
// @ts-expect-error FIXME Fix these typings
setValue(propertyName, {
...watchIntervalLimits,
[rest.value]: defaultLimit,
});
setValue(
propertyName,
// @ts-expect-error FIXME Fix these typings
{
...watchIntervalLimits,
[rest.value]: defaultLimit,
},
{ shouldDirty: true }
);
};
return (
@@ -611,7 +632,7 @@ const IntervalLimitsManager = <K extends "durationLimits" | "bookingLimits">({
)}
onLimitChange={(intervalLimitKey, val) =>
// @ts-expect-error FIXME Fix these typings
setValue(`${propertyName}.${intervalLimitKey}`, val)
setValue(`${propertyName}.${intervalLimitKey}`, val, { shouldDirty: true })
}
onDelete={(intervalLimitKey) => {
const current = currentIntervalLimits;
@@ -83,7 +83,7 @@ const DescriptionEditor = ({ isEditable }: { isEditable: boolean }) => {
return mounted ? (
<Editor
getText={() => md.render(formMethods.getValues("description") || "")}
setText={(value: string) => formMethods.setValue("description", turndown(value))}
setText={(value: string) => formMethods.setValue("description", turndown(value), { shouldDirty: true })}
excludedToolbarItems={["blockType"]}
placeholder={t("quick_video_meeting")}
editable={isEditable}
@@ -512,16 +512,16 @@ export const EventSetupTab = (
if (!newOptions.find((opt) => opt.value === defaultDuration?.value)) {
if (newOptions.length > 0) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
formMethods.setValue("length", newOptions[0].value, { shouldDirty: true });
} else {
setDefaultDuration(null);
}
}
if (newOptions.length === 1 && defaultDuration === null) {
setDefaultDuration(newOptions[0]);
formMethods.setValue("length", newOptions[0].value);
formMethods.setValue("length", newOptions[0].value, { shouldDirty: true });
}
formMethods.setValue("metadata.multipleDuration", values);
formMethods.setValue("metadata.multipleDuration", values, { shouldDirty: true });
}}
/>
</div>
@@ -542,7 +542,7 @@ export const EventSetupTab = (
setDefaultDuration(
selectedMultipleDuration.find((opt) => opt.value === option?.value) ?? null
);
if (option) formMethods.setValue("length", option.value);
if (option) formMethods.setValue("length", option.value, { shouldDirty: true });
}}
/>
</div>
@@ -571,12 +571,12 @@ export const EventSetupTab = (
setMultipleDuration(undefined);
setSelectedMultipleDuration([]);
setDefaultDuration(null);
formMethods.setValue("metadata.multipleDuration", undefined);
formMethods.setValue("length", eventType.length);
formMethods.setValue("metadata.multipleDuration", undefined, { shouldDirty: true });
formMethods.setValue("length", eventType.length, { shouldDirty: true });
} else {
setMultipleDuration([]);
formMethods.setValue("metadata.multipleDuration", []);
formMethods.setValue("length", 0);
formMethods.setValue("metadata.multipleDuration", [], { shouldDirty: true });
formMethods.setValue("length", 0, { shouldDirty: true });
}
}}
/>
+18 -13
View File
@@ -127,7 +127,7 @@ const AssignAllTeamMembers = ({
labelClassName="mt-0.5 font-normal"
checked={assignAllTeamMembers}
onCheckedChange={(active) => {
setValue("assignAllTeamMembers", active);
setValue("assignAllTeamMembers", active, { shouldDirty: true });
setAssignAllTeamMembers(active);
if (active) {
onActive();
@@ -250,7 +250,8 @@ const FixedHosts = ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
}))
})),
{ shouldDirty: true }
)
}
/>
@@ -269,7 +270,7 @@ const FixedHosts = ({
const rrHosts = getValues("hosts")
.filter((host) => !host.isFixed)
.sort((a, b) => (b.priority ?? 2) - (a.priority ?? 2));
setValue("hosts", rrHosts);
setValue("hosts", rrHosts, { shouldDirty: true });
}
setIsDisabled(checked);
}}
@@ -290,7 +291,8 @@ const FixedHosts = ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
}))
})),
{ shouldDirty: true }
)
}
/>
@@ -332,7 +334,7 @@ const AddMembersWithSwitch = ({
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={onActive}
onInactive={() => setValue("hosts", [])}
onInactive={() => setValue("hosts", [], { shouldDirty: true })}
/>
</div>
) : (
@@ -395,7 +397,8 @@ const RoundRobinHosts = ({
userId: parseInt(teamMember.value, 10),
priority: 2,
}))
.sort((a, b) => b.priority - a.priority)
.sort((a, b) => b.priority - a.priority),
{ shouldDirty: true }
)
}
/>
@@ -420,7 +423,7 @@ const ChildrenEventTypes = ({
<AssignAllTeamMembers
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
onActive={() => setValue("children", childrenEventTypeOptions)}
onActive={() => setValue("children", childrenEventTypeOptions, { shouldDirty: true })}
/>
{!assignAllTeamMembers ? (
<Controller<FormValues>
@@ -449,7 +452,7 @@ const Hosts = ({
const { t } = useLocale();
const {
control,
resetField,
setValue,
getValues,
formState: { submitCount },
} = useFormContext<FormValues>();
@@ -469,10 +472,12 @@ const Hosts = ({
initialValue.current = { hosts: getValues("hosts"), schedulingType, submitCount };
return;
}
resetField("hosts", {
defaultValue: initialValue.current.schedulingType === schedulingType ? initialValue.current.hosts : [],
});
}, [schedulingType, resetField, getValues, submitCount]);
setValue(
"hosts",
initialValue.current.schedulingType === schedulingType ? initialValue.current.hosts : [],
{ shouldDirty: true }
);
}, [schedulingType, setValue, getValues, submitCount]);
return (
<Controller<FormValues>
@@ -586,7 +591,7 @@ export const EventTeamTab = ({
className="w-full"
onChange={(val) => {
onChange(val?.value);
setValue("assignAllTeamMembers", false);
setValue("assignAllTeamMembers", false, { shouldDirty: true });
setAssignAllTeamMembers(false);
}}
/>
@@ -14,6 +14,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
import { SchedulingType } from "@calcom/prisma/enums";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import type { DialogProps } from "@calcom/ui";
import {
Button,
ButtonGroup,
@@ -133,6 +134,60 @@ function getNavigation({
];
}
function DeleteDialog({
isManagedEvent,
eventTypeId,
open,
onOpenChange,
}: { isManagedEvent: string; eventTypeId: number } & Pick<DialogProps, "open" | "onOpenChange">) {
const utils = trpc.useContext();
const { t } = useLocale();
const router = useRouter();
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: async () => {
await utils.viewer.eventTypes.invalidate();
showToast(t("event_type_deleted_successfully"), "success");
router.push("/event-types");
onOpenChange?.(false);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
onOpenChange?.(false);
} else if (err instanceof TRPCClientError) {
showToast(err.message, "error");
}
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<ConfirmationDialogContent
isPending={deleteMutation.isPending}
variety="danger"
title={t(`delete${isManagedEvent}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
onConfirm={(e) => {
e.preventDefault();
deleteMutation.mutate({ id: eventTypeId });
}}>
<p className="mt-5">
<Trans
i18nKey={`delete${isManagedEvent}_event_type_description`}
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>Members assigned to this event type will also have their event types deleted.</li>
<li>Anyone who they&apos;ve shared their link with will no longer be able to book using it.</li>
</ul>
</Trans>
</p>
</ConfirmationDialogContent>
</Dialog>
);
}
function EventTypeSingleLayout({
children,
eventType,
@@ -149,9 +204,7 @@ function EventTypeSingleLayout({
bookerUrl,
activeWebhooksNumber,
}: Props) {
const utils = trpc.useContext();
const { t } = useLocale();
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const hasPermsToDelete =
@@ -160,24 +213,6 @@ function EventTypeSingleLayout({
formMethods.getValues("schedulingType") === SchedulingType.MANAGED ||
isUserOrganizationAdmin;
const deleteMutation = trpc.viewer.eventTypes.delete.useMutation({
onSuccess: async () => {
await utils.viewer.eventTypes.invalidate();
showToast(t("event_type_deleted_successfully"), "success");
router.push("/event-types");
setDeleteDialogOpen(false);
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
setDeleteDialogOpen(false);
} else if (err instanceof TRPCClientError) {
showToast(err.message, "error");
}
},
});
const { isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager(
formMethods.getValues(),
t("locked_fields_admin_description"),
@@ -308,7 +343,7 @@ function EventTypeSingleLayout({
id="hiddenSwitch"
checked={!formMethods.watch("hidden")}
onCheckedChange={(e) => {
formMethods.setValue("hidden", !e);
formMethods.setValue("hidden", !e, { shouldDirty: true });
}}
/>
</div>
@@ -424,7 +459,7 @@ function EventTypeSingleLayout({
id="hiddenSwitch"
checked={!formMethods.watch("hidden")}
onCheckedChange={(e) => {
formMethods.setValue("hidden", !e);
formMethods.setValue("hidden", !e, { shouldDirty: true });
}}
/>
</div>
@@ -435,6 +470,7 @@ function EventTypeSingleLayout({
className="ml-4 lg:ml-0"
type="submit"
loading={isUpdateMutationLoading}
disabled={!formMethods.formState.isDirty}
data-testid="update-eventtype"
form="event-type-form">
{t("save")}
@@ -467,31 +503,13 @@ function EventTypeSingleLayout({
</div>
</div>
</Suspense>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isPending={deleteMutation.isPending}
variety="danger"
title={t(`delete${isManagedEvent}_event_type`)}
confirmBtnText={t(`confirm_delete_event_type`)}
loadingText={t(`confirm_delete_event_type`)}
onConfirm={(e) => {
e.preventDefault();
deleteMutation.mutate({ id: formMethods.getValues("id") });
}}>
<p className="mt-5">
<Trans
i18nKey={`delete${isManagedEvent}_event_type_description`}
components={{ li: <li />, ul: <ul className="ml-4 list-disc" /> }}>
<ul>
<li>Members assigned to this event type will also have their event types deleted.</li>
<li>
Anyone who they&apos;ve shared their link with will no longer be able to book using it.
</li>
</ul>
</Trans>
</p>
</ConfirmationDialogContent>
</Dialog>
<DeleteDialog
eventTypeId={eventType.id}
isManagedEvent={isManagedEvent}
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
/>
<EventTypeEmbedDialog />
</Shell>
);
@@ -81,10 +81,10 @@ export default function InstantEventController({
data-testid="instant-event-check"
onCheckedChange={(e) => {
if (!e) {
formMethods.setValue("isInstantEvent", false);
formMethods.setValue("isInstantEvent", false, { shouldDirty: true });
setInstantEventState(false);
} else {
formMethods.setValue("isInstantEvent", true);
formMethods.setValue("isInstantEvent", true, { shouldDirty: true });
setInstantEventState(true);
}
}}>
@@ -66,7 +66,7 @@ export default function RecurringEventController({
data-testid="recurring-event-check"
onCheckedChange={(e) => {
if (!e) {
formMethods.setValue("recurringEvent", null);
formMethods.setValue("recurringEvent", null, { shouldDirty: true });
setRecurringEventState(null);
} else {
const newVal = eventType.recurringEvent || {
@@ -74,7 +74,7 @@ export default function RecurringEventController({
count: 12,
freq: Frequency.WEEKLY,
};
formMethods.setValue("recurringEvent", newVal);
formMethods.setValue("recurringEvent", newVal, { shouldDirty: true });
setRecurringEventState(newVal);
}
}}>
@@ -95,7 +95,7 @@ export default function RecurringEventController({
...recurringEventState,
interval: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
formMethods.setValue("recurringEvent", newVal, { shouldDirty: true });
setRecurringEventState(newVal);
}}
/>
@@ -110,7 +110,7 @@ export default function RecurringEventController({
...recurringEventState,
freq: parseInt(event?.value || `${Frequency.WEEKLY}`),
};
formMethods.setValue("recurringEvent", newVal);
formMethods.setValue("recurringEvent", newVal, { shouldDirty: true });
setRecurringEventState(newVal);
}}
/>
@@ -129,7 +129,7 @@ export default function RecurringEventController({
...recurringEventState,
count: parseInt(event?.target.value),
};
formMethods.setValue("recurringEvent", newVal);
formMethods.setValue("recurringEvent", newVal, { shouldDirty: true });
setRecurringEventState(newVal);
}}
/>
@@ -37,7 +37,7 @@ export default function RequiresConfirmationController({
useEffect(() => {
if (!requiresConfirmation) {
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined, { shouldDirty: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [requiresConfirmation]);
@@ -82,7 +82,7 @@ export default function RequiresConfirmationController({
checked={requiresConfirmation}
LockedIcon={requiresConfirmationLockedProps.LockedIcon}
onCheckedChange={(val) => {
formMethods.setValue("requiresConfirmation", val);
formMethods.setValue("requiresConfirmation", val, { shouldDirty: true });
onRequiresConfirmation(val);
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
@@ -96,16 +96,19 @@ export default function RequiresConfirmationController({
}
onValueChange={(val) => {
if (val === "always") {
formMethods.setValue("requiresConfirmation", true);
formMethods.setValue("requiresConfirmation", true, { shouldDirty: true });
onRequiresConfirmation(true);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined);
formMethods.setValue("metadata.requiresConfirmationThreshold", undefined, {
shouldDirty: true,
});
setRequiresConfirmationSetup(undefined);
} else if (val === "notice") {
formMethods.setValue("requiresConfirmation", true);
formMethods.setValue("requiresConfirmation", true, { shouldDirty: true });
onRequiresConfirmation(true);
formMethods.setValue(
"metadata.requiresConfirmationThreshold",
requiresConfirmationSetup || defaultRequiresConfirmationSetup
requiresConfirmationSetup || defaultRequiresConfirmationSetup,
{ shouldDirty: true }
);
}
}}>
@@ -146,7 +149,8 @@ export default function RequiresConfirmationController({
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.time",
val
val,
{ shouldDirty: true }
);
}}
className="border-default !m-0 block w-16 rounded-r-none border-r-0 text-sm [appearance:textfield] focus:z-10 focus:border-r"
@@ -171,7 +175,8 @@ export default function RequiresConfirmationController({
});
formMethods.setValue(
"metadata.requiresConfirmationThreshold.unit",
opt?.value as UnitTypeLongPlural
opt?.value as UnitTypeLongPlural,
{ shouldDirty: true }
);
}}
defaultValue={defaultValue}
@@ -119,7 +119,7 @@ export type FormValues = {
schedule: number | null;
periodType: PeriodType;
periodDays: number;
periodCountCalendarDays: "1" | "0";
periodCountCalendarDays: boolean;
periodDates: { startDate: Date; endDate: Date };
seatsPerTimeSlot: number | null;
seatsShowAttendees: boolean | null;
@@ -192,14 +192,17 @@ const EventTypePage = (props: EventTypeSetupProps) => {
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async () => {
formMethods.setValue(
"children",
formMethods.getValues().children.map((child) => ({
...child,
created: true,
}))
);
formMethods.setValue("assignAllTeamMembers", formMethods.getValues("assignAllTeamMembers") || false);
const currentValues = formMethods.getValues();
currentValues.children = currentValues.children.map((child) => ({
...child,
created: true,
}));
currentValues.assignAllTeamMembers = currentValues.assignAllTeamMembers || false;
// Reset the form with these values as new default values to ensure the correct comparison for dirtyFields eval
formMethods.reset(currentValues);
showToast(t("event_type_updated_successfully", { eventTypeTitle: eventType.title }), "success");
},
async onSettled() {
@@ -253,7 +256,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
eventType.bookingFields.forEach(({ name }) => {
bookingFields[name] = name;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const defaultValues: any = useMemo(() => {
return {
@@ -290,7 +292,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
offsetStart: eventType.offsetStart,
bookingFields: eventType.bookingFields,
periodType: eventType.periodType,
periodCountCalendarDays: eventType.periodCountCalendarDays ? "1" : "0",
periodCountCalendarDays: eventType.periodCountCalendarDays ? true : false,
schedulingType: eventType.schedulingType,
requiresConfirmation: eventType.requiresConfirmation,
slotInterval: eventType.slotInterval,
@@ -316,7 +318,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
assignAllTeamMembers: eventType.assignAllTeamMembers,
};
}, [eventType, periodDates, metadata]);
const formMethods = useForm<FormValues>({
defaultValues,
resolver: zodResolver(
@@ -405,14 +406,9 @@ const EventTypePage = (props: EventTypeSetupProps) => {
.passthrough()
),
});
useEffect(() => {
if (!formMethods.formState.isDirty) {
//TODO: What's the best way to sync the form with backend
formMethods.setValue("bookingFields", defaultValues.bookingFields);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues]);
const {
formState: { isDirty: isFormDirty, dirtyFields },
} = formMethods;
const appsMetadata = formMethods.getValues("metadata")?.apps;
const availability = formMethods.watch("availability");
@@ -453,8 +449,100 @@ const EventTypePage = (props: EventTypeSetupProps) => {
),
webhooks: <EventWebhooksTab eventType={eventType} />,
} as const;
const isObject = <T,>(value: T): boolean => {
return value !== null && typeof value === "object" && !Array.isArray(value);
};
const isArray = <T,>(value: T): boolean => {
return Array.isArray(value);
};
const isFieldDirty = (fieldName: keyof FormValues) => {
// If the field itself is directly marked as dirty
if (dirtyFields[fieldName] === true) {
return true;
}
// Check if the field is an object or an array
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fieldValue: any = getNestedField(dirtyFields, fieldName);
if (isObject(fieldValue)) {
for (const key in fieldValue) {
if (fieldValue[key] === true) {
return true;
}
if (isObject(fieldValue[key]) || isArray(fieldValue[key])) {
const nestedFieldName = `${fieldName}.${key}` as keyof FormValues;
// Recursive call for nested objects or arrays
if (isFieldDirty(nestedFieldName)) {
return true;
}
}
}
}
if (isArray(fieldValue)) {
for (const element of fieldValue) {
// If element is an object, check each property of the object
if (isObject(element)) {
for (const key in element) {
if (element[key] === true) {
return true;
}
if (isObject(element[key]) || isArray(element[key])) {
const nestedFieldName = `${fieldName}.${key}` as keyof FormValues;
// Recursive call for nested objects or arrays within each element
if (isFieldDirty(nestedFieldName)) {
return true;
}
}
}
} else if (element === true) {
return true;
}
}
}
return false;
};
const getNestedField = (obj: typeof dirtyFields, path: string) => {
const keys = path.split(".");
let current = obj;
for (let i = 0; i < keys.length; i++) {
// @ts-expect-error /—— currentKey could be any deeply nested fields thanks to recursion
const currentKey = current[keys[i]];
if (currentKey === undefined) return undefined;
current = currentKey;
}
return current;
};
const getDirtyFields = (values: FormValues): Partial<FormValues> => {
if (!isFormDirty) {
return {};
}
const updatedFields: Partial<FormValues> = {};
Object.keys(dirtyFields).forEach((key) => {
const typedKey = key as keyof typeof dirtyFields;
updatedFields[typedKey] = undefined;
const isDirty = isFieldDirty(typedKey);
if (isDirty) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
updatedFields[typedKey] = values[typedKey];
}
});
return updatedFields;
};
const handleSubmit = async (values: FormValues) => {
const { children } = values;
const dirtyValues = getDirtyFields(values);
const dirtyFieldExists = Object.keys(dirtyValues).length !== 0;
const {
periodDates,
periodCountCalendarDays,
@@ -470,7 +558,6 @@ const EventTypePage = (props: EventTypeSetupProps) => {
locations,
metadata,
customInputs,
children,
assignAllTeamMembers,
// We don't need to send send these values to the backend
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -483,8 +570,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
multipleDurationEnabled,
length,
...input
} = values;
} = dirtyValues;
if (!Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) {
@@ -504,8 +590,12 @@ const EventTypePage = (props: EventTypeSetupProps) => {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
if (!length && !metadata?.multipleDuration?.includes(length)) {
throw new Error(t("event_setup_multiple_duration_default_error"));
// if length is unchanged, we skip this check
if (length !== undefined) {
if (!length && !metadata?.multipleDuration?.includes(length)) {
//This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
}
@@ -521,14 +611,14 @@ const EventTypePage = (props: EventTypeSetupProps) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, users, scheduleName, ...rest } = input;
updateMutation.mutate({
const payload = {
...rest,
length,
locations,
recurringEvent,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
periodStartDate: periodDates?.startDate,
periodEndDate: periodDates?.endDate,
periodCountCalendarDays,
id: eventType.id,
beforeEventBuffer,
afterEventBuffer,
@@ -542,7 +632,19 @@ const EventTypePage = (props: EventTypeSetupProps) => {
customInputs,
children,
assignAllTeamMembers,
});
};
// Filter out undefined values
const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => {
if (value !== undefined) {
// @ts-expect-error Element implicitly has any type
acc[key] = value;
}
return acc;
}, {});
if (dirtyFieldExists) {
updateMutation.mutate({ ...filteredPayload, id: eventType.id });
}
};
const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState<ChildrenEventType[]>([]);
@@ -595,6 +697,9 @@ const EventTypePage = (props: EventTypeSetupProps) => {
form={formMethods}
id="event-type-form"
handleSubmit={async (values) => {
const { children } = values;
const dirtyValues = getDirtyFields(values);
const dirtyFieldExists = Object.keys(dirtyValues).length !== 0;
const {
periodDates,
periodCountCalendarDays,
@@ -617,9 +722,9 @@ const EventTypePage = (props: EventTypeSetupProps) => {
multipleDurationEnabled,
length,
...input
} = values;
} = dirtyValues;
if (!Number(length)) throw new Error(t("event_setup_length_error"));
if (length && !Number(length)) throw new Error(t("event_setup_length_error"));
if (bookingLimits) {
const isValid = validateIntervalLimitOrder(bookingLimits);
@@ -638,8 +743,11 @@ const EventTypePage = (props: EventTypeSetupProps) => {
if (metadata?.multipleDuration.length < 1) {
throw new Error(t("event_setup_multiple_duration_error"));
} else {
if (!length && !metadata?.multipleDuration?.includes(length)) {
throw new Error(t("event_setup_multiple_duration_default_error"));
if (length !== undefined) {
if (!length && !metadata?.multipleDuration?.includes(length)) {
//This would work but it leaves the potential of this check being useless. Need to check against length and not eventType.length, but length can be undefined
throw new Error(t("event_setup_multiple_duration_default_error"));
}
}
}
}
@@ -651,14 +759,15 @@ const EventTypePage = (props: EventTypeSetupProps) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, users, scheduleName, ...rest } = input;
updateMutation.mutate({
const payload = {
...rest,
children,
length,
locations,
recurringEvent,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
periodStartDate: periodDates?.startDate,
periodEndDate: periodDates?.endDate,
periodCountCalendarDays,
id: eventType.id,
beforeEventBuffer,
afterEventBuffer,
@@ -670,7 +779,19 @@ const EventTypePage = (props: EventTypeSetupProps) => {
seatsShowAvailabilityCount,
metadata,
customInputs,
});
};
// Filter out undefined values
const filteredPayload = Object.entries(payload).reduce((acc, [key, value]) => {
if (value !== undefined) {
// @ts-expect-error Element implicitly has any type
acc[key] = value;
}
return acc;
}, {});
if (dirtyFieldExists) {
updateMutation.mutate({ ...filteredPayload, id: eventType.id });
}
}}>
<div ref={animationParentRef}>{tabMap[tabName]}</div>
</Form>
@@ -42,6 +42,7 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
expect(result.newUserIds).toEqual(undefined);
expect(result.oldUserIds).toEqual(undefined);
@@ -62,6 +63,7 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
expect(result.newUserIds).toEqual(undefined);
expect(result.oldUserIds).toEqual(undefined);
@@ -88,6 +90,7 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
expect(result.newUserIds).toEqual(undefined);
expect(result.oldUserIds).toEqual(undefined);
@@ -125,6 +128,7 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
@@ -173,13 +177,14 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {
bookingLimits: undefined,
},
});
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...evType,
hidden: false,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
hashedLink: { create: { link: expect.any(String) } },
},
where: {
@@ -207,6 +212,7 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
expect(result.newUserIds).toEqual([]);
expect(result.oldUserIds).toEqual([]);
@@ -232,6 +238,7 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
// Have been called
expect(result.newUserIds).toEqual([5]);
@@ -270,6 +277,7 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
@@ -318,14 +326,15 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {
length: 30,
},
});
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...evType,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
scheduleId: undefined,
hidden: false,
length: 30,
hashedLink: undefined,
},
where: {
userId_parentId: {
@@ -379,6 +388,7 @@ describe("handleChildrenEventTypes", () => {
connectedLink: null,
prisma: prismaMock,
profileId: null,
updatedValues: {},
});
expect(prismaMock.eventType.create).toHaveBeenCalledWith({
data: {
@@ -404,13 +414,8 @@ describe("handleChildrenEventTypes", () => {
});
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...evType,
bookingLimits: undefined,
durationLimits: undefined,
recurringEvent: undefined,
hidden: false,
hashedLink: undefined,
workflows: undefined,
scheduleId: undefined,
},
where: {
userId_parentId: {
@@ -36,6 +36,7 @@ interface handleChildrenEventTypesProps {
}[]
| undefined;
prisma: PrismaClient | DeepMockProxy<PrismaClient>;
updatedValues: Prisma.EventTypeUpdateInput;
}
const sendAllSlugReplacementEmails = async (
@@ -95,6 +96,7 @@ export default async function handleChildrenEventTypes({
children,
prisma,
profileId,
updatedValues,
}: handleChildrenEventTypesProps) {
// Check we are dealing with a managed event type
if (updatedEventType?.schedulingType !== SchedulingType.MANAGED)
@@ -114,8 +116,12 @@ export default async function handleChildrenEventTypes({
message: "Missing event type",
};
// Define what values are expected to be changed from a managed event type
const allManagedEventTypePropsZod = _EventTypeModel.pick(allManagedEventTypeProps);
// bookingFields is expected to be filled by the _EventTypeModel but is null at create event
const _ManagedEventTypeModel = _EventTypeModel.extend({
bookingFields: _EventTypeModel.shape.bookingFields.nullish(),
});
const allManagedEventTypePropsZod = _ManagedEventTypeModel.pick(allManagedEventTypeProps); //FIXME
const managedEventTypeValues = allManagedEventTypePropsZod
.omit(unlockedManagedEventTypeProps)
.parse(eventType);
@@ -130,14 +136,12 @@ export default async function handleChildrenEventTypes({
const unlockedEventTypeValues = allManagedEventTypePropsZod
.pick(unlockedManagedEventTypeProps)
.parse(eventType);
// Calculate if there are new/existent/deleted children users for which the event type needs to be created/updated/deleted
const previousUserIds = oldEventType.children?.flatMap((ch) => ch.userId ?? []);
const currentUserIds = children?.map((ch) => ch.owner.id);
const deletedUserIds = previousUserIds?.filter((id) => !currentUserIds?.includes(id));
const newUserIds = currentUserIds?.filter((id) => !previousUserIds?.includes(id));
const oldUserIds = currentUserIds?.filter((id) => previousUserIds?.includes(id));
// Calculate if there are new workflows for which assigned members will get too
const currentWorkflowIds = eventType.workflows?.map((wf) => wf.workflowId);
@@ -227,18 +231,9 @@ export default async function handleChildrenEventTypes({
},
},
data: {
...managedEventTypeValues,
profileId: profileId ?? null,
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,
metadata: (managedEventTypeValues.metadata as Prisma.InputJsonValue) ?? undefined,
bookingFields: (managedEventTypeValues.bookingFields as Prisma.InputJsonValue) ?? undefined,
durationLimits: (managedEventTypeValues.durationLimits as Prisma.InputJsonValue) ?? undefined,
...updatedValues,
hashedLink: hashedLinkQuery(userId),
hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false,
},
});
})
@@ -436,7 +436,7 @@ function FieldEditDialog({
if (!value) {
return;
}
fieldForm.setValue("type", value);
fieldForm.setValue("type", value, { shouldDirty: true });
}}
value={fieldTypesConfigMap[fieldForm.getValues("type")]}
options={fieldTypes.filter((f) => !f.systemOnly)}
@@ -451,7 +451,9 @@ function FieldEditDialog({
{...fieldForm.register("name")}
containerClassName="mt-6"
onChange={(e) => {
fieldForm.setValue("name", getFieldIdentifier(e.target.value || ""));
fieldForm.setValue("name", getFieldIdentifier(e.target.value || ""), {
shouldDirty: true,
});
}}
disabled={
fieldForm.getValues("editable") === "system" ||
@@ -341,6 +341,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
throw e;
}
const updatedValues = Object.entries(data).reduce((acc, [key, value]) => {
if (value !== undefined) {
// @ts-expect-error Element implicitly has any type
acc[key] = value;
}
return acc;
}, {});
// Handling updates to children event types (managed events types)
await updateChildrenEventTypes({
@@ -353,6 +360,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
children,
profileId: ctx.user.profile.id,
prisma: ctx.prisma,
updatedValues,
});
const res = ctx.res as NextApiResponse;