fix: scheduleId not unset properly bug (#15993)

* fix: scheduleId not unset properly bug

* Refactor almost complete, couple of tests needed

* More testid fixes

* Add migration to fixup schedule id bug

* Remove migration

* fix: Refactor useLockedFieldManager for correct useState use

---------

Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Alex van Andel
2024-08-13 16:44:16 +01:00
committed by GitHub
co-authored by zomars
parent d5f7065fdb
commit b09b32b121
7 changed files with 155 additions and 182 deletions
@@ -66,26 +66,17 @@ const format = (date: Date, hour12: boolean) =>
}).format(new Date(dayjs.utc(date).format("YYYY-MM-DDTHH:mm:ss")));
const EventTypeScheduleDetails = memo(
({
isManagedEventType,
selectedScheduleValue,
}: {
isManagedEventType: boolean;
selectedScheduleValue: AvailabilityOption | undefined;
}) => {
({ isManagedEventType, scheduleId }: { isManagedEventType: boolean; scheduleId: number | null }) => {
const { data: loggedInUser } = useMeQuery();
const timeFormat = loggedInUser?.timeFormat;
const { t, i18n } = useLocale();
const { watch } = useFormContext<FormValues>();
const scheduleId = watch("schedule");
const { isPending, data: schedule } = trpc.viewer.availability.schedule.get.useQuery(
{
scheduleId:
scheduleId || loggedInUser?.defaultScheduleId || selectedScheduleValue?.value || undefined,
scheduleId: scheduleId || loggedInUser?.defaultScheduleId || undefined,
isManagedEventType,
},
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId || !!selectedScheduleValue }
{ enabled: !!scheduleId || !!loggedInUser?.defaultScheduleId }
);
const weekStart = weekStartNum(loggedInUser?.weekStart);
@@ -159,117 +150,100 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
const formMethods = useFormContext<FormValues>();
const { shouldLockIndicator, shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } =
useLockedFieldsManager({ eventType, translate: t, formMethods });
const { watch, setValue, getValues } = formMethods;
const watchSchedule = watch("schedule");
const [options, setOptions] = useState<AvailabilityOption[]>([]);
const { watch, setValue } = formMethods;
const { data, isPending } = trpc.viewer.availability.list.useQuery(undefined);
useEffect(
function refactorMeWithoutEffect() {
if (!data) {
return;
}
const schedules = data.schedules;
const options = schedules.map((schedule) => ({
value: schedule.id,
label: schedule.name,
isDefault: schedule.isDefault,
isManaged: false,
}));
// We are showing a managed event for a team admin, so adding the option to let members choose their schedule
if (isManagedEventType) {
options.push({
value: 0,
label: t("members_default_schedule"),
isDefault: false,
isManaged: false,
});
}
// We are showing a managed event for a member and team owner selected their own schedule, so adding
// the managed schedule option
if (
isChildrenManagedEventType &&
watchSchedule &&
!schedules.find((schedule) => schedule.id === watchSchedule)
) {
options.push({
value: watchSchedule,
label: eventType.scheduleName ?? t("default_schedule_name"),
isDefault: false,
isManaged: false,
});
}
// We push the selected schedule from the event type if it's not part of the list response. This happens if the user is an admin but not the schedule owner.
else if (eventType.schedule && !schedules.find((schedule) => schedule.id === eventType.schedule)) {
options.push({
value: eventType.schedule,
label: eventType.scheduleName ?? t("default_schedule_name"),
isDefault: false,
isManaged: false,
});
}
setOptions(options);
const scheduleId = getValues("schedule");
const value = options.find((option) =>
scheduleId
? option.value === scheduleId
: isManagedEventType
? option.value === 0
: option.value === schedules.find((schedule) => schedule.isDefault)?.id
);
setValue("availability", value, { shouldDirty: true });
},
[data]
);
const availabilityValue = watch("availability");
const scheduleId = watch("schedule");
useEffect(() => {
if (!availabilityValue?.value) return;
setValue("schedule", availabilityValue.value, { shouldDirty: true });
}, [availabilityValue, setValue]);
// after data is loaded.
if (data && scheduleId !== 0 && !scheduleId) {
const newValue = isManagedEventType ? 0 : data.schedules.find((schedule) => schedule.isDefault)?.id;
if (!newValue && newValue !== 0) return;
setValue("schedule", newValue, {
shouldDirty: true,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scheduleId, data]);
if (isPending || !data) {
return <SelectSkeletonLoader />;
}
const schedules = data.schedules;
const options = schedules.map((schedule) => ({
value: schedule.id,
label: schedule.name,
isDefault: schedule.isDefault,
isManaged: false,
}));
// We are showing a managed event for a team admin, so adding the option to let members choose their schedule
if (isManagedEventType) {
options.push({
value: 0,
label: t("members_default_schedule"),
isDefault: false,
isManaged: false,
});
}
// We are showing a managed event for a member and team owner selected their own schedule, so adding
// the managed schedule option
if (isChildrenManagedEventType && scheduleId && !schedules.find((schedule) => schedule.id === scheduleId)) {
options.push({
value: scheduleId,
label: eventType.scheduleName ?? t("default_schedule_name"),
isDefault: false,
isManaged: false,
});
}
// We push the selected schedule from the event type if it's not part of the list response. This happens if the user is an admin but not the schedule owner.
else if (eventType.schedule && !schedules.find((schedule) => schedule.id === eventType.schedule)) {
options.push({
value: eventType.schedule,
label: eventType.scheduleName ?? t("default_schedule_name"),
isDefault: false,
isManaged: false,
});
}
return (
<div>
<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")}
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("availability")}
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("schedule")}
</label>
{isPending && <SelectSkeletonLoader />}
{!isPending && (
<Controller
name="schedule"
render={({ field }) => {
return (
<Select
placeholder={t("select")}
options={options}
isDisabled={shouldLockDisableProps("availability").disabled}
isSearchable={false}
onChange={(selected) => {
field.onChange(selected?.value || null);
if (selected?.value) setValue("availability", selected, { shouldDirty: true });
}}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
value={availabilityValue}
components={{ Option, SingleValue }}
isMulti={false}
/>
);
}}
/>
)}
<Controller
name="schedule"
render={({ field: { onChange, value } }) => {
const optionValue: AvailabilityOption | undefined = options.find(
(option) => option.value === value
);
return (
<Select
placeholder={t("select")}
options={options}
isDisabled={shouldLockDisableProps("schedule").disabled}
isSearchable={false}
onChange={(selected) => {
if (selected) onChange(selected.value);
}}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
value={optionValue}
components={{ Option, SingleValue }}
isMulti={false}
/>
);
}}
/>
</div>
{availabilityValue?.value !== 0 ? (
{scheduleId !== 0 ? (
<EventTypeScheduleDetails
selectedScheduleValue={availabilityValue}
scheduleId={scheduleId}
isManagedEventType={isManagedEventType || isChildrenManagedEventType}
/>
) : (
@@ -283,25 +257,27 @@ const EventTypeSchedule = ({ eventType }: { eventType: EventTypeSetup }) => {
const UseCommonScheduleSettingsToggle = ({ eventType }: { eventType: EventTypeSetup }) => {
const { t } = useLocale();
const { setValue } = useFormContext<FormValues>();
const { setValue, resetField, getFieldState, getValues } = useFormContext<FormValues>();
const [useHostSchedulesForTeamEvent, setUseHostSchedulesForTeamEvent] = useState(
Boolean(getFieldState("schedule").isDirty ? getValues("schedule") : eventType.schedule)
);
return (
<Controller
name="metadata.config.useHostSchedulesForTeamEvent"
render={({ field: { value, onChange } }) => (
<SettingsToggle
checked={!value}
onCheckedChange={(checked) => {
onChange(!checked);
if (!checked) {
setValue("schedule", null, { shouldDirty: true });
}
}}
title={t("choose_common_schedule_team_event")}
description={t("choose_common_schedule_team_event_description")}>
<EventTypeSchedule eventType={eventType} />
</SettingsToggle>
)}
/>
<SettingsToggle
checked={useHostSchedulesForTeamEvent}
onCheckedChange={(checked) => {
setUseHostSchedulesForTeamEvent(checked);
if (checked) {
if (Boolean(eventType.schedule)) resetField("schedule");
} else {
setValue("schedule", null, { shouldDirty: Boolean(eventType.schedule) });
}
}}
title={t("choose_common_schedule_team_event")}
description={t("choose_common_schedule_team_event_description")}>
<EventTypeSchedule eventType={eventType} />
</SettingsToggle>
);
};
@@ -248,21 +248,6 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
endDate: new Date(eventType.periodEndDate || Date.now()),
});
const metadata = eventType.metadata;
// fallback to !!eventType.schedule when 'useHostSchedulesForTeamEvent' is undefined
if (!!team && metadata !== null) {
metadata.config = {
...metadata.config,
useHostSchedulesForTeamEvent:
typeof eventType.metadata?.config?.useHostSchedulesForTeamEvent !== "undefined"
? eventType.metadata?.config?.useHostSchedulesForTeamEvent === true
: !!eventType.schedule,
};
} else {
// Make sure non-team events NEVER have this config key;
delete metadata?.config?.useHostSchedulesForTeamEvent;
}
const bookingFields: Prisma.JsonObject = {};
eventType.bookingFields.forEach(({ name }) => {
@@ -310,7 +295,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
requiresConfirmation: eventType.requiresConfirmation,
slotInterval: eventType.slotInterval,
minimumBookingNotice: eventType.minimumBookingNotice,
metadata,
metadata: eventType.metadata,
hosts: eventType.hosts,
successRedirectUrl: eventType.successRedirectUrl || "",
forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect,
@@ -345,7 +330,7 @@ const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workf
schedulerName: eventType.aiPhoneCallConfig?.schedulerName,
},
};
}, [eventType, periodDates, metadata]);
}, [eventType, periodDates]);
const formMethods = useForm<FormValues>({
defaultValues,
resolver: zodResolver(
+1 -3
View File
@@ -323,8 +323,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
)[0];
const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent;
const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule;
const schedule = eventType?.schedule ? eventType.schedule : userSchedule;
const isDefaultSchedule = userSchedule && userSchedule.id === schedule.id;
@@ -334,7 +333,6 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
chosenSchedule: schedule,
eventTypeSchedule: eventType?.schedule,
userSchedule: userSchedule,
useHostSchedulesForTeamEvent: eventType?.metadata?.config?.useHostSchedulesForTeamEvent,
})
);
@@ -15,7 +15,7 @@ import { Badge, Icon, Switch, Tooltip } from "@calcom/ui";
export const LockedSwitch = (
isManagedEventType: boolean,
[isLocked, setIsLocked]: [boolean, Dispatch<SetStateAction<boolean>>],
[fieldState, setFieldState]: [Record<string, boolean>, Dispatch<SetStateAction<Record<string, boolean>>>],
fieldName: string,
setUnlockedFields: (fieldName: string, val: boolean | undefined) => void,
options = { simple: false }
@@ -24,10 +24,13 @@ export const LockedSwitch = (
<Switch
data-testid={`locked-indicator-${fieldName}`}
onCheckedChange={(enabled) => {
setIsLocked(enabled);
setFieldState({
...fieldState,
[fieldName]: enabled,
});
setUnlockedFields(fieldName, !enabled || undefined);
}}
checked={isLocked}
checked={fieldState[fieldName]}
small={!options.simple}
/>
) : null;
@@ -36,12 +39,13 @@ export const LockedSwitch = (
export const LockedIndicator = (
isChildrenManagedEventType: boolean,
isManagedEventType: boolean,
[isLocked, setIsLocked]: [boolean, Dispatch<SetStateAction<boolean>>],
[fieldState, setFieldState]: [Record<string, boolean>, Dispatch<SetStateAction<Record<string, boolean>>>],
t: TFunction,
fieldName: string,
setUnlockedFields: (fieldName: string, val: boolean | undefined) => void,
options = { simple: false }
) => {
const isLocked = fieldState[fieldName];
const stateText = t(isLocked ? "locked" : "unlocked");
const tooltipText = t(
`${isLocked ? "locked" : "unlocked"}_fields_${isManagedEventType ? "admin" : "member"}_description`
@@ -67,7 +71,10 @@ export const LockedIndicator = (
<Switch
data-testid={`locked-indicator-${fieldName}`}
onCheckedChange={(enabled) => {
setIsLocked(enabled);
setFieldState({
...fieldState,
[fieldName]: enabled,
});
setUnlockedFields(fieldName, !enabled || undefined);
}}
checked={isLocked}
@@ -91,7 +98,7 @@ const useLockedFieldsManager = ({
formMethods: UseFormReturn<FormValues>;
}) => {
const { setValue, getValues } = formMethods;
const fieldStates: Record<string, [boolean, Dispatch<SetStateAction<boolean>>]> = {};
const [fieldStates, setFieldStates] = useState<Record<string, boolean>>({});
const unlockedFields =
(eventType.metadata?.managedEventConfig?.unlockedFields !== undefined &&
eventType.metadata?.managedEventConfig?.unlockedFields) ||
@@ -123,28 +130,31 @@ const useLockedFieldsManager = ({
const getLockedInitState = (fieldName: string): boolean => {
let locked = isManagedEventType || isChildrenManagedEventType;
const unlockedFieldList = getValues("metadata")?.managedEventConfig?.unlockedFields;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
const fieldIsUnlocked = unlockedFieldList && unlockedFieldList[fieldName] === true;
if (fieldName.includes(".")) {
locked = locked && get(unlockedFields, fieldName) === undefined;
} else {
type FieldName = string;
const unlockedFieldList = getValues("metadata")?.managedEventConfig?.unlockedFields as
| Record<FieldName, boolean>
| undefined;
const fieldIsUnlocked = !!unlockedFieldList?.[fieldName];
locked = locked && !fieldIsUnlocked;
}
return locked;
};
const useShouldLockIndicator = (fieldName: string, options?: { simple: true }) => {
if (!fieldStates[fieldName]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
if (typeof fieldStates[fieldName] === "undefined") {
setFieldStates({
...fieldStates,
[fieldName]: getLockedInitState(fieldName),
});
}
return LockedIndicator(
isChildrenManagedEventType,
isManagedEventType,
fieldStates[fieldName],
[fieldStates, setFieldStates],
translate,
fieldName,
setUnlockedFields,
@@ -153,12 +163,13 @@ const useLockedFieldsManager = ({
};
const useLockedLabel = (fieldName: string, options?: { simple: true }) => {
if (!fieldStates[fieldName]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
if (typeof fieldStates[fieldName] === "undefined") {
setFieldStates({
...fieldStates,
[fieldName]: getLockedInitState(fieldName),
});
}
const isLocked = fieldStates[fieldName][0];
const isLocked = fieldStates[fieldName];
return {
disabled:
!isManagedEventType &&
@@ -170,19 +181,22 @@ const useLockedFieldsManager = ({
};
const useLockedSwitch = (fieldName: string, options = { simple: false }) => {
if (!fieldStates[fieldName]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
fieldStates[fieldName] = useState(getLockedInitState(fieldName));
if (typeof fieldStates[fieldName] === "undefined") {
setFieldStates({
...fieldStates,
[fieldName]: getLockedInitState(fieldName),
});
}
return () =>
LockedSwitch(isManagedEventType, fieldStates[fieldName], fieldName, setUnlockedFields, options);
LockedSwitch(isManagedEventType, [fieldStates, setFieldStates], 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));
if (typeof fieldStates[fieldName] === "undefined") {
setFieldStates({
...fieldStates,
[fieldName]: getLockedInitState(fieldName),
});
}
return {
disabled:
@@ -190,7 +204,7 @@ const useLockedFieldsManager = ({
eventType.metadata?.managedEventConfig !== undefined &&
unlockedFields[fieldName as keyof Omit<Prisma.EventTypeSelect, "id">] === undefined,
LockedIcon: useShouldLockIndicator(fieldName, options),
isLocked: fieldStates[fieldName][0],
isLocked: fieldStates[fieldName],
};
};
@@ -75,7 +75,7 @@ export default function TeamList(props: Props) {
actionButton={{
href: `/settings/organizations/new`,
child: t("set_up_your_organization"),
"data-testId": "setup_your_org_action_button",
"data-testid": "setup_your_org_action_button",
}}
/>
<Card
+4 -4
View File
@@ -107,7 +107,7 @@ export interface BaseCardProps extends CVACardType {
href?: string;
child: ReactNode;
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
"data-testId"?: string;
"data-testid"?: string;
};
learnMore?: {
href: string;
@@ -177,7 +177,7 @@ export function Card({
target="_blank"
rel="noreferrer"
href={mediaLink}
data-testId={actionButton?.["data-testId"]}
data-testid={actionButton?.["data-testid"]}
className="group relative my-3 flex aspect-video items-center overflow-hidden rounded">
<div className="absolute inset-0 bg-black bg-opacity-50 transition group-hover:bg-opacity-40" />
<svg
@@ -210,7 +210,7 @@ export function Card({
href={actionButton?.href}
className="mt-10"
EndIcon="arrow-right"
data-testId={actionButton["data-testId"]}>
data-testid={actionButton["data-testid"]}>
{actionButton?.child}
</Button>
</div>
@@ -232,7 +232,7 @@ export function Card({
<button
className="text-default hover:text-emphasis p-0 text-xs font-normal"
color="minimal"
data-testId={actionButton?.["data-testId"]}
data-testid={actionButton?.["data-testid"]}
onClick={actionButton?.onClick}>
{actionButton?.child}
</button>
+1 -1
View File
@@ -113,7 +113,7 @@ export function DataTable<TData, TValue>({
tableCTA={tableCTA}
/>
<div ref={tableContainerRef} onScroll={onScroll} data-testid={rest["data-testid"] ?? "data-table"}>
<Table data-testId="">
<Table data-testid="">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>