feat: #15416 Expand round robin availability beyond default schedule (#15618)

* add feature to change host schedule

* update for type-check:ci

* Update SingleForm.tsx for linterr

* update as per design review

* update for rerendering

* migration files

* On Delete set null for schedule

if schedule is deleted, no need to delete the host

* updated schema

* added testcases

* checking permissions before

* updated for comments

* update for initial value

* updated and cleanup

* set initial value

* update after merge

* updated nit style

* update for console warning and added comments

* nit/chore

* chore

* update for typechecks

* update for scheduleId param after merge

* update after merge or new changes in metadata.config

* update after weights functionality

* updated description text and column name as per review suggestion

* resolved merge conflicts

* fix typecheck due to changes in getScheduleSchema

* update after merge having refactor

* update refactored EventTypeWebWrapper after merge

* updated props

* updated trpc err code and added log

* update due to refactor in EventAvailabilityTab

* perf: Optimise TS type + select only the data that's needed for a specific function

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Vijay
2024-10-15 17:36:00 +01:00
committed by GitHub
co-authored by Amit Sharma Syed Ali Shahbaz Alex van Andel
parent c21f0caecf
commit a48d190af3
27 changed files with 600 additions and 67 deletions
+11 -8
View File
@@ -44,7 +44,7 @@
"event_request_cancelled": "Your scheduled event was canceled",
"organizer": "Organizer",
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
"you_can_view_booking_details_with_this_url":"You can view the booking details from this url {{url}} and add the event to your calendar",
"you_can_view_booking_details_with_this_url": "You can view the booking details from this url {{url}} and add the event to your calendar",
"no_options_available": "No options available",
"cancellation_reason": "Reason for cancellation (optional)",
"cancellation_reason_placeholder": "Why are you cancelling?",
@@ -601,7 +601,7 @@
"enter_address": "Enter address",
"in_person_attendee_address": "In Person (Attendee Address)",
"somewhere_else": "Somewhere Else",
"custom_attendee_location":"Custom attendee location",
"custom_attendee_location": "Custom attendee location",
"any_location": "Any location",
"yes": "Yes",
"no": "No",
@@ -1702,7 +1702,7 @@
"invalid_credential_action": "Reinstall app",
"reschedule_reason": "Reschedule reason",
"choose_common_schedule_team_event": "Choose a common schedule",
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule.",
"choose_common_schedule_team_event_description": "Enable this if you want to use a common schedule between hosts. When disabled, each host will be booked based on their default schedule or their choosen schedule.",
"reason": "Reason",
"sender_id": "Sender ID",
"sender_id_error_message": "Only letters, numbers and spaces allowed (max. 11 characters)",
@@ -2484,7 +2484,7 @@
"add_emails": "Add Emails",
"add_email_description": "Add an email address to replace your primary or to use as an alternative email on your event types.",
"confirm_email": "Confirm your email",
"confirming_your_booking_sms":"$t(hey_there) {{name}}, confirming your booking on {{date}}.",
"confirming_your_booking_sms": "$t(hey_there) {{name}}, confirming your booking on {{date}}.",
"scheduler_first_name": "The first name of the person booking",
"scheduler_last_name": "The last name of the person booking",
"scheduler_name": "Scheduler Name",
@@ -2592,19 +2592,19 @@
"event_expired": "This event is expired",
"skip_contact_creation": "Skip creating contacts if they do not exist in {{appName}} ",
"skip_writing_to_calendar_note": "If your ICS link is read-only (e.g., Proton Calendar), check the box above to avoid errors. You'll also need to manually update your calendar for changes.",
"attributes":"Attributes",
"attributes": "Attributes",
"new_attribute": "New attribute",
"add_attributes": "Add attributes",
"add_attributes_description": "Add attributes to your team members",
"new_option": "New option",
"update_profile":"Update member",
"update_profile": "Update member",
"attribute_updated_successfully": "Attribute updated successfully",
"attributes_edited_successfully": "Attributes edited successfully",
"attribute_meta_description": "Manage attributes for your team members",
"attributes_edit_description": "Edit attributes for your team members",
"back_to_attributes": "Back to attributes",
"delete_attribute": "Are you sure you want to delete this option?",
"delete_attribute_description":"This option is assigned to {{numberOfUsers}} members. Deleting it will remove it from their profile.",
"delete_attribute_description": "This option is assigned to {{numberOfUsers}} members. Deleting it will remove it from their profile.",
"disable_all_emails_to_attendees": "Disable standard emails to attendees related to this event type",
"disable_all_emails_description": "Disables standard email communication related to this event type, including booking confirmations, reminders, and cancellations.",
"disable_all_emails_to_hosts": "Disable standard emails to hosts related to this event type",
@@ -2627,7 +2627,7 @@
"stripe_connect_atom_loading_label": "Checking Stripe credentials",
"booking_question_response_variables": "Booking question response variables",
"managed_by_teamAdmins": "Managed by {{teamAdmins}}",
"number_of_options":"{{count}} options",
"number_of_options": "{{count}} options",
"reschedule_with_same_round_robin_host_title": "Reschedule with same Round-Robin host",
"reschedule_with_same_round_robin_host_description": "Rescheduled events will be assigned to the same host as initially scheduled",
"disable_input_if_prefilled": "Disable input if the URL identifier is prefilled",
@@ -2654,5 +2654,8 @@
"salesforce_book_directly_with_attendee_owner": "If attendee exists in Salesforce, book directly with the owner",
"salesforce_check_owner_of": "Entity to check owner of to book directly",
"account": "Account",
"choose_hosts_schedule": "Choose host schedules",
"hosts_schedule_description": "You can select a different schedule for each host. Each host's default schedule is chosen by default.",
"no_hosts_description": "No hosts assigned, add hosts in assignment tab.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -8,7 +8,9 @@ describe("Tests to Check if Event Types have empty Assignment", () => {
checkForEmptyAssignment({
assignedUsers: [],
assignAllTeamMembers: false,
hosts: [{ userId: 101, isFixed: false, priority: 2, weight: 100, weightAdjustment: 0 }],
hosts: [
{ userId: 101, isFixed: false, priority: 2, weight: 100, weightAdjustment: 0, scheduleId: null },
],
isManagedEventType: true,
})
).toBe(true);
@@ -61,7 +63,9 @@ describe("Tests to Check if Event Types have empty Assignment", () => {
checkForEmptyAssignment({
assignedUsers: [],
assignAllTeamMembers: false,
hosts: [{ userId: 101, isFixed: false, priority: 2, weight: 100, weightAdjustment: 0 }],
hosts: [
{ userId: 101, isFixed: false, priority: 2, weight: 100, weightAdjustment: 0, scheduleId: null },
],
isManagedEventType: false,
})
).toBe(false);
+137
View File
@@ -1896,4 +1896,141 @@ describe("getSchedule", () => {
);
});
});
describe("Round robin event", async () => {
test("correctly get slots for event with round robin hosts choosen schedule (non-default)", async () => {
vi.setSystemTime("2024-05-21T00:00:13Z");
const plus1DateString = "2024-05-22";
const plus2DateString = "2024-05-23";
// An event with no common schedule and hosts choosen schedule (non-default schedule)
await createBookingScenario({
eventTypes: [
{
id: 1,
slotInterval: 60,
length: 60,
hosts: [
{
userId: 101,
isFixed: false,
scheduleId: 2,
},
{
userId: 102,
isFixed: false,
scheduleId: 3,
},
],
schedulingType: "ROUND_ROBIN",
schedule: null,
},
],
users: [
{
...TestData.users.example,
email: "example@example.com",
id: 101,
schedules: [TestData.schedules.IstMorningShift, TestData.schedules.IstEveningShift],
defaultScheduleId: 1,
},
{
...TestData.users.example,
email: "example1@example.com",
id: 102,
schedules: [TestData.schedules.IstEveningShift, TestData.schedules.IstMorningShift],
defaultScheduleId: 4,
},
],
bookings: [],
});
const scheduleWithHostChoosenSch = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
teamMemberEmail: "test@test.com",
},
});
// expect only slots of IstEveningShift , as this is the choosen schedule for hosts
expect(scheduleWithHostChoosenSch).toHaveTimeSlots(
[`11:30:00.000Z`, `12:30:00.000Z`, `13:30:00.000Z`, `14:30:00.000Z`, `15:30:00.000Z`],
{
dateString: plus2DateString,
}
);
});
test("correctly get slots for event with commom schedule", async () => {
vi.setSystemTime("2024-05-21T00:00:13Z");
const plus1DateString = "2024-05-22";
const plus2DateString = "2024-05-23";
// An event with common schedule
await createBookingScenario({
eventTypes: [
{
id: 1,
slotInterval: 60,
length: 60,
hosts: [
{
userId: 101,
isFixed: false,
},
{
userId: 102,
isFixed: false,
},
],
schedule: TestData.schedules.IstEveningShift,
schedulingType: "ROUND_ROBIN",
},
],
users: [
{
...TestData.users.example,
email: "example@example.com",
id: 101,
schedules: [TestData.schedules.IstMorningShift],
defaultScheduleId: 1,
},
{
...TestData.users.example,
email: "example1@example.com",
id: 102,
schedules: [TestData.schedules.IstMidShift],
defaultScheduleId: 2,
},
],
bookings: [],
});
const scheduleWithEventCommonSch = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
teamMemberEmail: "test@test.com",
},
});
// expect only slots of IstEveningShift , as this is the common schedule of event
expect(scheduleWithEventCommonSch).toHaveTimeSlots(
[`11:30:00.000Z`, `12:30:00.000Z`, `13:30:00.000Z`, `14:30:00.000Z`, `15:30:00.000Z`],
{
dateString: plus2DateString,
}
);
});
});
});
@@ -78,6 +78,7 @@ type InputWorkflowReminder = {
type InputHost = {
userId: number;
isFixed?: boolean;
scheduleId?: number | null;
};
/**
* Data to be mocked
@@ -165,7 +166,7 @@ export type InputEventType = {
};
requiresConfirmation?: boolean;
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
schedule?: InputUser["schedules"][number];
schedule?: InputUser["schedules"][number] | null;
bookingLimits?: IntervalLimit;
durationLimits?: IntervalLimit;
owner?: number;
@@ -222,6 +223,13 @@ async function addHostsToDb(eventTypes: InputEventType[]) {
id: host.userId,
},
},
schedule: host.scheduleId
? {
connect: {
id: host.scheduleId,
},
}
: undefined,
};
await prismock.host.create({
@@ -482,6 +482,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
avatar: member.avatarUrl || "",
email: member.email,
isFixed: true,
defaultScheduleId: member.defaultScheduleId,
}))}
value={sendUpdatesTo.map((userId) => ({
isFixed: true,
@@ -489,6 +490,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
priority: 2,
weight: 100,
weightAdjustment: 0,
scheduleId: 1,
}))}
onChange={(value) => {
hookForm.setValue(
@@ -81,6 +81,7 @@ export async function getSerializableForm<TForm extends App_RoutingForms_Form>({
name: true,
email: true,
avatarUrl: true,
defaultScheduleId: true,
},
});
}
+1
View File
@@ -31,6 +31,7 @@ export type SerializableFormTeamMembers = {
name: string | null;
email: string;
avatarUrl: string | null;
defaultScheduleId: number | null;
};
export type SerializableForm<T extends App_RoutingForms_Form> = Omit<
T,
+20 -2
View File
@@ -76,6 +76,21 @@ const _getEventType = async (id: number) => {
user: {
select: {
email: true,
id: true,
},
},
schedule: {
select: {
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
timeZone: true,
id: true,
},
},
},
@@ -281,7 +296,9 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
)[0];
const schedule = eventType?.schedule ? eventType.schedule : userSchedule;
const hostSchedule = eventType?.hosts?.find((host) => host.user.id === user.id)?.schedule;
const schedule = eventType?.schedule ? eventType.schedule : hostSchedule ? hostSchedule : userSchedule;
const timeZone = schedule?.timeZone || eventType?.timeZone || user.timeZone;
@@ -356,7 +373,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
...busyTimesFromTeamLimits,
];
const isDefaultSchedule = userSchedule && userSchedule.id === schedule.id;
const isDefaultSchedule = userSchedule && userSchedule.id === schedule?.id;
log.debug(
"Using schedule:",
@@ -364,6 +381,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
chosenSchedule: schedule,
eventTypeSchedule: eventType?.schedule,
userSchedule: userSchedule,
hostSchedule: hostSchedule,
})
);
@@ -119,6 +119,20 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
...userSelect.select,
},
},
schedule: {
select: {
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
timeZone: true,
id: true,
},
},
},
},
availability: {
@@ -101,6 +101,7 @@ export const roundRobinReassignment = async ({
priority: 2,
weight: 100,
weightAdjustment: 0,
schedule: null,
}));
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
@@ -16,16 +16,18 @@ interface IUserToValue {
username: string | null;
avatar: string;
email: string;
defaultScheduleId: number | null;
}
export const mapUserToValue = (
{ id, name, username, avatar, email }: IUserToValue,
{ id, name, username, avatar, email, defaultScheduleId }: IUserToValue,
pendingString: string
) => ({
value: `${id || ""}`,
label: `${name || email || ""}${!username ? ` (${pendingString})` : ""}`,
avatar,
email,
defaultScheduleId,
});
const sortByLabel = (a: ReturnType<typeof mapUserToValue>, b: ReturnType<typeof mapUserToValue>) => {
@@ -73,6 +75,7 @@ const CheckedHostField = ({
priority: option.priority ?? 2,
weight: option.weight ?? 100,
weightAdjustment: option.weightAdjustment ?? 0,
scheduleId: option.defaultScheduleId,
}))
);
}}
@@ -17,6 +17,7 @@ export type CheckedSelectOption = {
weightAdjustment?: number;
isFixed?: boolean;
disabled?: boolean;
defaultScheduleId?: number | null;
};
export const CheckedTeamSelect = ({
@@ -25,6 +25,7 @@ export type Host = {
priority: number;
weight: number;
weightAdjustment: number;
scheduleId?: number | null;
};
export type CustomInputParsed = typeof customInputSchema._output;
@@ -46,6 +47,7 @@ const tabs = [
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
export type EventTypeAssignedUsers = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["children"];
export type EventTypeHosts = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"]["hosts"];
export type TeamMembers = RouterOutputs["viewer"]["eventTypes"]["get"]["teamMembers"];
export type EventTypeComponentProps = EventTypeSetupProps & {
allActiveWorkflows?: Workflow[];
@@ -132,19 +132,25 @@ const FixedHosts = ({
setAssignAllTeamMembers={setAssignAllTeamMembers}
automaticAddAllEnabled={!isRoundRobinEvent}
isFixed={true}
onActive={() =>
onActive={() => {
const currentHosts = getValues("hosts");
setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
weight: 100,
weightAdjustment: 0,
})),
teamMembers.map((teamMember) => {
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
return {
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
weight: 100,
weightAdjustment: 0,
// if host was already added, retain scheduleId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
};
}),
{ shouldDirty: true }
)
}
);
}}
/>
</div>
</>
@@ -175,19 +181,25 @@ const FixedHosts = ({
setAssignAllTeamMembers={setAssignAllTeamMembers}
automaticAddAllEnabled={!isRoundRobinEvent}
isFixed={true}
onActive={() =>
onActive={() => {
const currentHosts = getValues("hosts");
setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
weight: 100,
weightAdjustment: 0,
})),
teamMembers.map((teamMember) => {
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
return {
isFixed: true,
userId: parseInt(teamMember.value, 10),
priority: 2,
weight: 100,
weightAdjustment: 0,
// if host was already added, retain scheduleId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
};
}),
{ shouldDirty: true }
)
}
);
}}
/>
</div>
</SettingsToggle>
@@ -255,15 +267,21 @@ const RoundRobinHosts = ({
isFixed={false}
containerClassName={assignAllTeamMembers ? "-mt-4" : ""}
onActive={() => {
const currentHosts = getValues("hosts");
setValue(
"hosts",
teamMembers.map((teamMember) => ({
isFixed: false,
userId: parseInt(teamMember.value, 10),
priority: 2,
weight: 100,
weightAdjustment: 0,
})),
teamMembers.map((teamMember) => {
const host = currentHosts.find((host) => host.userId === parseInt(teamMember.value, 10));
return {
isFixed: false,
userId: parseInt(teamMember.value, 10),
priority: 2,
weight: 100,
weightAdjustment: 0,
// if host was already added, retain scheduleId
scheduleId: host?.scheduleId || teamMember.defaultScheduleId,
};
}),
{ shouldDirty: true }
);
setValue("isRRWeightsEnabled", false);
@@ -346,6 +364,21 @@ const Hosts = ({
);
}, [schedulingType, setValue, getValues, submitCount]);
// To ensure existing host do not loose its scheduleId property, whenever a new host of same type is added.
// This is because the host is created from list option in CheckedHostField component.
const updatedHosts = (changedHosts: Host[]) => {
const existingHosts = getValues("hosts");
return changedHosts.map((newValue) => {
const existingHost = existingHosts.find((host: Host) => host.userId === newValue.userId);
return existingHost
? {
...newValue,
scheduleId: existingHost.scheduleId,
}
: newValue;
});
};
return (
<Controller<FormValues>
name="hosts"
@@ -355,7 +388,9 @@ const Hosts = ({
<FixedHosts
teamMembers={teamMembers}
value={value}
onChange={onChange}
onChange={(changeValue) => {
onChange([...updatedHosts(changeValue)]);
}}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
/>
@@ -366,7 +401,7 @@ const Hosts = ({
teamMembers={teamMembers}
value={value}
onChange={(changeValue) => {
onChange([...value.filter((host: Host) => !host.isFixed), ...changeValue]);
onChange([...value.filter((host: Host) => !host.isFixed), ...updatedHosts(changeValue)]);
}}
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
@@ -376,7 +411,7 @@ const Hosts = ({
teamMembers={teamMembers}
value={value}
onChange={(changeValue) => {
const hosts = [...value.filter((host: Host) => host.isFixed), ...changeValue];
const hosts = [...value.filter((host: Host) => host.isFixed), ...updatedHosts(changeValue)];
onChange(hosts);
}}
assignAllTeamMembers={assignAllTeamMembers}
@@ -1,20 +1,31 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useState, memo, useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { OptionProps, SingleValueProps } from "react-select";
import { components } from "react-select";
import type { EventAvailabilityTabWebWrapperProps } from "@calcom/atoms/event-types/wrappers/EventAvailabilityTabWebWrapper";
import type {
EventAvailabilityTabWebWrapperProps,
GetAllSchedulesByUserIdQueryType,
} from "@calcom/atoms/event-types/wrappers/EventAvailabilityTabWebWrapper";
import dayjs from "@calcom/dayjs";
import { SelectSkeletonLoader } from "@calcom/features/availability/components/SkeletonLoader";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { AvailabilityOption, FormValues, EventTypeSetup } from "@calcom/features/eventtypes/lib/types";
import type { TeamMembers } from "@calcom/features/eventtypes/components/EventType";
import type {
AvailabilityOption,
FormValues,
EventTypeSetup,
Host,
} 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";
import { weekStartNum } from "@calcom/lib/weekstart";
import { SchedulingType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Badge, Button, Icon, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
import { Avatar, Badge, Button, Icon, Label, Select, SettingsToggle, SkeletonText } from "@calcom/ui";
import { Spinner } from "@calcom/ui/components/icon/Spinner";
type EventTypeScheduleDetailsProps = {
scheduleQueryData?: RouterOutputs["viewer"]["availability"]["schedule"]["get"];
@@ -23,11 +34,17 @@ type EventTypeScheduleDetailsProps = {
editAvailabilityRedirectUrl?: string;
};
type EventTypeTeamScheduleProps = {
hostSchedulesQuery: GetAllSchedulesByUserIdQueryType;
hosts?: Host[];
};
type EventTypeScheduleProps = {
availabilityQueryData?: RouterOutputs["viewer"]["availability"]["list"];
isAvailabilityPending?: boolean;
eventType: EventTypeSetup;
} & EventTypeScheduleDetailsProps;
} & EventTypeScheduleDetailsProps &
EventTypeTeamScheduleProps;
type EventAvailabilityTabProps = EventAvailabilityTabWebWrapperProps & EventTypeScheduleProps;
@@ -270,32 +287,172 @@ const EventTypeSchedule = ({
);
};
const TeamMemberSchedule = ({
host,
index,
teamMembers,
hostScheduleQuery,
}: {
host: Host;
index: number;
teamMembers: TeamMembers;
hostScheduleQuery: GetAllSchedulesByUserIdQueryType;
}) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { getValues } = formMethods;
const { data, isPending } = hostScheduleQuery({
userId: host.userId,
});
const schedules = data?.schedules;
const options = schedules?.map((schedule) => ({
value: schedule.id,
label: schedule.name,
isDefault: schedule.isDefault,
isManaged: false,
}));
//Set to defaultSchedule if Host Schedule is not previously selected
const scheduleId = getValues(`hosts.${index}.scheduleId`);
const value = options?.find((option) =>
scheduleId
? option.value === scheduleId
: option.value === schedules?.find((schedule) => schedule.isDefault)?.id
);
const member = teamMembers.find((mem) => mem.id === host.userId);
const avatar = member?.avatar;
const label = member?.name;
return (
<>
<div className="flex w-full items-center">
<Avatar size="sm" imageSrc={avatar} alt={label || ""} />
<p className="text-emphasis my-auto ms-3 text-sm">{label}</p>
</div>
<div className="flex w-full flex-col pt-2 ">
{isPending ? (
<Spinner className="mt-2 h-6 w-6" />
) : (
<Controller
name={`hosts.${index}.scheduleId`}
render={({ field }) => {
return (
<Select
placeholder={t("select")}
options={options}
isSearchable={false}
onChange={(selected) => {
field.onChange(selected?.value || null);
}}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
value={value as AvailabilityOption}
components={{ Option, SingleValue }}
isMulti={false}
isDisabled={isPending}
/>
);
}}
/>
)}
</div>
</>
);
};
const TeamAvailability = ({
hosts,
teamMembers,
hostSchedulesQuery,
}: EventTypeTeamScheduleProps & { teamMembers: TeamMembers }) => {
const { t } = useLocale();
const [animationRef] = useAutoAnimate<HTMLUListElement>();
return (
<>
<div className="border-subtle flex flex-col rounded-md">
<div className="border-subtle mt-5 rounded-t-md border p-6 pb-5">
<Label className="mb-1 text-sm font-semibold">{t("choose_hosts_schedule")}</Label>
<p className="text-subtle max-w-full break-words text-sm leading-tight">
{t("hosts_schedule_description")}
</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
{hosts && hosts.length > 0 ? (
<ul
className={classNames("mb-4 mt-3 rounded-md", hosts.length >= 1 && "border-subtle border")}
ref={animationRef}>
{hosts?.map((host, index) => (
<li
key={host.userId}
className={`flex flex-col px-3 py-2 ${
index === hosts.length - 1 ? "" : "border-subtle border-b"
}`}>
<TeamMemberSchedule
host={host}
index={index}
teamMembers={teamMembers}
hostScheduleQuery={hostSchedulesQuery}
/>
</li>
))}
</ul>
) : (
<p className="text-subtle max-w-full break-words text-sm leading-tight">
{t("no_hosts_description")}
</p>
)}
</div>
</div>
</>
);
};
const UseCommonScheduleSettingsToggle = ({
eventType,
...rest
}: Omit<EventAvailabilityTabProps, "isTeamEvent">) => {
const { t } = useLocale();
const { setValue, resetField, getFieldState, getValues } = useFormContext<FormValues>();
const { setValue, resetField, getFieldState, getValues, watch } = useFormContext<FormValues>();
const [useHostSchedulesForTeamEvent, setUseHostSchedulesForTeamEvent] = useState(
Boolean(getFieldState("schedule").isDirty ? getValues("schedule") : eventType.schedule)
!Boolean(getFieldState("schedule").isDirty ? getValues("schedule") : eventType.schedule)
);
const watchHosts = watch("hosts");
return (
<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} {...rest} />
</SettingsToggle>
<>
<SettingsToggle
checked={!useHostSchedulesForTeamEvent}
onCheckedChange={(checked) => {
setUseHostSchedulesForTeamEvent(!checked);
if (checked) {
if (Boolean(eventType.schedule)) resetField("schedule");
getValues("hosts").map((_, index) => {
setValue(`hosts.${index}.scheduleId`, null, { shouldDirty: true });
});
} 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} {...rest} />
</SettingsToggle>
{useHostSchedulesForTeamEvent && (
<div className="lg:ml-14">
<TeamAvailability
hosts={watchHosts}
teamMembers={rest.teamMembers}
hostSchedulesQuery={rest.hostSchedulesQuery}
/>
</div>
)}
</>
);
};
@@ -27,12 +27,14 @@ export type Host = {
priority: number;
weight: number;
weightAdjustment: number;
scheduleId?: number | null;
};
export type TeamMember = {
value: string;
label: string;
avatar: string;
email: string;
defaultScheduleId: number | null;
};
export type FormValues = {
@@ -535,6 +535,7 @@ export class EventTypeRepository {
priority: true,
weight: true,
weightAdjustment: true,
scheduleId: true,
},
},
userId: true,
@@ -1,6 +1,7 @@
import { useFormContext } from "react-hook-form";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import type { TeamMembers } from "@calcom/features/eventtypes/components/EventType";
import { EventAvailabilityTab } from "@calcom/features/eventtypes/components/tabs/availability/EventAvailabilityTab";
import type { EventTypeSetup, FormValues } from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -11,8 +12,12 @@ export type EventAvailabilityTabWebWrapperProps = {
eventType: EventTypeSetup;
isTeamEvent: boolean;
loggedInUser?: RouterOutputs["viewer"]["me"];
teamMembers: TeamMembers;
};
export type GetAllSchedulesByUserIdQueryType =
typeof trpc.viewer.availability.schedule.getAllSchedulesByUserId.useQuery;
const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperProps) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
@@ -36,6 +41,8 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
const { data: availabilityQueryData, isPending: isAvailabilityPending } =
trpc.viewer.availability.list.useQuery(undefined);
const hostSchedulesQuery = trpc.viewer.availability.schedule.getAllSchedulesByUserId.useQuery;
return (
<EventAvailabilityTab
{...props}
@@ -44,6 +51,7 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
isSchedulePending={isSchedulePending}
scheduleQueryData={scheduleQueryData}
editAvailabilityRedirectUrl={`/availability/${scheduleQueryData?.id}`}
hostSchedulesQuery={hostSchedulesQuery}
/>
);
};
@@ -242,7 +242,12 @@ const EventTypeWeb = ({
/>
),
availability: (
<EventAvailabilityTab eventType={eventType} isTeamEvent={!!team} loggedInUser={loggedInUser} />
<EventAvailabilityTab
eventType={eventType}
isTeamEvent={!!team}
loggedInUser={loggedInUser}
teamMembers={teamMembers}
/>
),
team: <EventTeamAssignmentTab teamMembers={teamMembers} team={team} eventType={eventType} />,
limits: <EventLimitsTab eventType={eventType} />,
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Host" ADD COLUMN "scheduleId" INTEGER;
-- CreateIndex
CREATE INDEX "Host_scheduleId_idx" ON "Host"("scheduleId");
-- AddForeignKey
ALTER TABLE "Host" ADD CONSTRAINT "Host_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "Schedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+4
View File
@@ -52,10 +52,13 @@ model Host {
weight Int?
// amount of fake bookings to be added when calculating the weight (for new hosts, OOO, etc.)
weightAdjustment Int?
schedule Schedule? @relation(fields: [scheduleId], references: [id])
scheduleId Int?
@@id([userId, eventTypeId])
@@index([userId])
@@index([eventTypeId])
@@index([scheduleId])
}
model EventType {
@@ -645,6 +648,7 @@ model Schedule {
name String
timeZone String?
availability Availability[]
Host Host[]
@@index([userId])
}
@@ -5,6 +5,7 @@ import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
import { ZScheduleDuplicateSchema } from "./duplicate.schema";
import { ZGetInputSchema } from "./get.schema";
import { ZGetAllByUserIdInputSchema } from "./getAllSchedulesByUserId.schema";
import { ZGetByEventSlugInputSchema } from "./getScheduleByEventTypeSlug.schema";
import { ZGetByUserIdInputSchema } from "./getScheduleByUserId.schema";
import { ZUpdateInputSchema } from "./update.schema";
@@ -16,6 +17,7 @@ type ScheduleRouterHandlerCache = {
update?: typeof import("./update.handler").updateHandler;
duplicate?: typeof import("./duplicate.handler").duplicateHandler;
getScheduleByUserId?: typeof import("./getScheduleByUserId.handler").getScheduleByUserIdHandler;
getAllSchedulesByUserId?: typeof import("./getAllSchedulesByUserId.handler").getAllSchedulesByUserIdHandler;
getScheduleByEventSlug?: typeof import("./getScheduleByEventTypeSlug.handler").getScheduleByEventSlugHandler;
bulkUpdateToDefaultAvailability?: typeof import("./bulkUpdateDefaultAvailability.handler").bulkUpdateToDefaultAvailabilityHandler;
};
@@ -122,6 +124,25 @@ export const scheduleRouter = router({
input,
});
}),
getAllSchedulesByUserId: authedProcedure.input(ZGetAllByUserIdInputSchema).query(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getAllSchedulesByUserId) {
UNSTABLE_HANDLER_CACHE.getAllSchedulesByUserId = await import("./getAllSchedulesByUserId.handler").then(
(mod) => mod.getAllSchedulesByUserIdHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.getAllSchedulesByUserId) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.getAllSchedulesByUserId({
ctx,
input,
});
}),
getScheduleByEventSlug: authedProcedure.input(ZGetByEventSlugInputSchema).query(async ({ input, ctx }) => {
if (!UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug) {
UNSTABLE_HANDLER_CACHE.getScheduleByEventSlug = await import(
@@ -0,0 +1,62 @@
import { hasReadPermissionsForUserId } from "@calcom/lib/hasEditPermissionForUser";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../../trpc";
import { getDefaultScheduleId } from "../util";
import type { TGetAllByUserIdInputSchema } from "./getAllSchedulesByUserId.schema";
const log = logger.getSubLogger({ prefix: ["getAllSchedulesByUserIdHandler"] });
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetAllByUserIdInputSchema;
};
export const getAllSchedulesByUserIdHandler = async ({ ctx, input }: GetOptions) => {
const { user } = ctx;
const isCurrentUserPartOfTeam = hasReadPermissionsForUserId({ memberId: input?.userId, userId: user.id });
const isCurrentUserOwner = input?.userId === user.id;
if (!isCurrentUserPartOfTeam && !isCurrentUserOwner) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
const schedules = await prisma.schedule.findMany({
where: {
userId: input.userId,
},
select: {
id: true,
userId: true,
name: true,
},
});
if (!schedules) {
log.error(`No Schedules found for userId:${input.userId}`);
throw new TRPCError({
code: "NOT_FOUND",
});
}
const defaultScheduleId = await getDefaultScheduleId(input.userId, prisma);
return {
schedules: schedules.map((schedule) => {
return {
...schedule,
isDefault: schedule.id === defaultScheduleId,
readOnly: schedule.userId !== user.id,
};
}),
};
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetAllByUserIdInputSchema = z.object({
userId: z.number(),
});
export type TGetAllByUserIdInputSchema = z.infer<typeof ZGetAllByUserIdInputSchema>;
@@ -57,6 +57,7 @@ export const EventTypeUpdateInput = _EventTypeModel
isFixed: z.boolean().optional(),
priority: z.number().min(0).max(4).optional().nullable(),
weight: z.number().min(0).optional().nullable(),
scheduleId: z.number().optional().nullable(),
})
)
.optional(),
@@ -191,6 +191,7 @@ type Host = {
isFixed?: boolean | undefined;
priority?: number | null | undefined;
weight?: number | null | undefined;
scheduleId?: number | null | undefined;
};
type User = {
@@ -230,6 +231,7 @@ export async function addWeightAdjustmentToNewHosts({
weightAdjustment: true,
priority: true,
weight: true,
scheduleId: true,
},
},
},
@@ -249,6 +251,7 @@ export async function addWeightAdjustmentToNewHosts({
id: user.id,
email: user.email,
},
scheduleId: hostData?.scheduleId ?? null,
};
});
@@ -263,6 +266,7 @@ export async function addWeightAdjustmentToNewHosts({
priority: host.priority,
weight: host.weight,
weightAdjustment: host.weightAdjustment,
scheduleId: host.scheduleId,
}));
}
@@ -303,6 +307,7 @@ export async function addWeightAdjustmentToNewHosts({
priority: host.priority,
weight: host.weight,
weightAdjustment: weightAdjustment > 0 ? Math.floor(weightAdjustment) : 0,
scheduleId: host.scheduleId,
};
})
);
@@ -228,6 +228,20 @@ export async function getEventType(
...availabilityUserSelect,
},
},
schedule: {
select: {
availability: {
select: {
date: true,
startTime: true,
endTime: true,
days: true,
},
},
timeZone: true,
id: true,
},
},
},
},
users: {
@@ -444,9 +458,17 @@ export async function getAvailableSlots({ input, ctx }: GetScheduleOptions): Pro
}
let currentSeats: CurrentSeats | undefined;
const eventHosts =
const eventHosts: {
isFixed: boolean;
email: string;
user: (typeof eventType.hosts)[number]["user"];
}[] =
eventType.hosts?.length && eventType.schedulingType
? eventType.hosts
? eventType.hosts.map((host) => ({
isFixed: host.isFixed,
email: host.user.email,
user: host.user,
}))
: eventType.users.map((user) => {
return {
isFixed: !eventType.schedulingType || eventType.schedulingType === SchedulingType.COLLECTIVE,