Files
calendar/apps/web/modules/settings/outOfOffice/CreateOrEditOutOfOfficeModal.tsx
T
Eunjae LeeGitHubClaude Opus 4.5Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
81e4241f85 refactor: apply biome formatting to apps/web (#27692)
* refactor: apply biome formatting to apps/web (batch 1)

Formats apps/web non-modules directories:
- apps/web/app
- apps/web/lib
- apps/web/pages
- apps/web/styles
- apps/web/server
- apps/web/test
- Root-level .ts and .mjs files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules (batch 2)

Formats smaller apps/web/modules directories:
- onboarding, data-table, users, apps, auth
- integration-attribute-sync, shell, availability
- feature-flags, team, webhooks, getting-started
- feature-opt-in, troubleshooter, schedules, notifications
- signup-view.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules (batch 3)

Formats medium apps/web/modules directories:
- bookings
- event-types
- insights
- embed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules/ee (batch 4)

Formats apps/web/modules/ee directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web (batch 5)

Formats remaining apps/web directories:
- apps/web/modules/settings
- apps/web/modules/booking-audit
- apps/web/playwright

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/components (batch 6)

Formats remaining apps/web/components files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to new apps/web files from main

Formats newly added/modified files from main merge:
- WorkflowStepContainer.tsx (resolved merge conflicts)
- Newly moved files (blocklist, form-builder, schedules, etc.)
- Other files modified in main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to new apps/web files from main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-10 23:25:24 +05:30

456 lines
17 KiB
TypeScript

import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { DateRangePicker, TextArea, Input, Checkbox } from "@calcom/ui/components/form";
import { Label } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { Switch } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { useHasTeamPlan } from "@calcom/web/modules/billing/hooks/useHasPaidPlan";
import { UpgradeTeamsBadgeWebWrapper as UpgradeTeamsBadge } from "~/billing/components/UpgradeTeamsBadgeWebWrapper";
import { OutOfOfficeTab } from "~/settings/outOfOffice/OutOfOfficeToggleGroup";
export type { BookingRedirectForm } from "~/settings/outOfOffice/types";
import type { BookingRedirectForm } from "~/settings/outOfOffice/types";
type Option = { value: number; label: string };
export const CreateOrEditOutOfOfficeEntryModal = ({
openModal,
closeModal,
currentlyEditingOutOfOfficeEntry,
}: {
openModal: boolean;
closeModal: () => void;
currentlyEditingOutOfOfficeEntry: BookingRedirectForm | null;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const me = useMeQuery();
const searchParams = useCompatSearchParams();
const oooType = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
const [searchMember, setSearchMember] = useState("");
const debouncedSearchMember = useDebounce(searchMember, 500);
const oooForMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
{ limit: 10, searchText: debouncedSearchMember, adminOrOwnedTeamsOnly: true },
{
enabled: oooType === OutOfOfficeTab.TEAM,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const oooMemberListOptions: {
value: number;
label: string;
avatarUrl: string | null;
}[] = currentlyEditingOutOfOfficeEntry
? [
{
value: currentlyEditingOutOfOfficeEntry.forUserId || -1,
label: currentlyEditingOutOfOfficeEntry.forUserName || "",
avatarUrl: currentlyEditingOutOfOfficeEntry.forUserAvatar || "",
},
]
: oooForMembers?.data?.pages
.flatMap((page) => page.members)
?.filter((member) => me?.data?.id !== member.id)
.map((member) => ({
value: member.id,
label: member.name || member.username || "",
avatarUrl: member.avatarUrl,
})) || [];
const [searchRedirectMember, setSearchRedirectMember] = useState("");
const debouncedSearchRedirect = useDebounce(searchRedirectMember, 500);
const redirectMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery(
{
limit: 10,
searchText: debouncedSearchRedirect,
adminOrOwnedTeamsOnly: oooType === OutOfOfficeTab.TEAM,
},
{
enabled: true,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const redirectToMemberListOptions: {
value: number;
label: string;
avatarUrl: string | null;
}[] =
redirectMembers?.data?.pages
.flatMap((page) => page.members)
?.filter((member) =>
oooType === OutOfOfficeTab.MINE ? me?.data?.id !== member.id : oooType === OutOfOfficeTab.TEAM
)
.map((member) => ({
value: member.id,
label: member.name || member.username || "",
avatarUrl: member.avatarUrl,
})) || [];
const { data: outOfOfficeReasonList, isPending: isReasonListPending } =
trpc.viewer.ooo.outOfOfficeReasonList.useQuery();
const reasonList = (outOfOfficeReasonList || []).map((reason) => ({
label: `${reason.emoji} ${reason.userId === null ? t(reason.reason) : reason.reason}`,
value: reason.id,
}));
const [profileRedirect, setProfileRedirect] = useState(!!currentlyEditingOutOfOfficeEntry?.toTeamUserId);
const { hasTeamPlan } = useHasTeamPlan();
const {
handleSubmit,
setValue,
control,
register,
watch,
formState: { isSubmitting },
getValues,
} = useForm<BookingRedirectForm>({
defaultValues: currentlyEditingOutOfOfficeEntry
? currentlyEditingOutOfOfficeEntry
: {
dateRange: {
startDate: dayjs().startOf("d").toDate(),
endDate: dayjs().startOf("d").add(2, "d").toDate(),
},
startDateOffset: dayjs().utcOffset(),
endDateOffset: dayjs().utcOffset(),
toTeamUserId: null,
reasonId: 1,
forUserId: null,
showNotePublicly: false,
},
});
const watchedTeamUserId = watch("toTeamUserId");
const watchForUserId = watch("forUserId");
const watchedDateRange = watch("dateRange");
const watchedNotes = watch("notes");
const hasValidNotes = Boolean(watchedNotes?.trim());
// Fetch user's holiday settings to show warning if OOO dates overlap with holidays
const { data: holidaySettings } = trpc.viewer.holidays.getUserSettings.useQuery({});
// Check if selected dates overlap with any enabled holidays
const overlappingHolidays = useMemo(() => {
if (!holidaySettings?.countryCode || !watchedDateRange?.startDate || !watchedDateRange?.endDate) {
return [];
}
// Filter holidays that are enabled and fall within the date range
const startStr = dayjs(watchedDateRange.startDate).format("YYYY-MM-DD");
const endStr = dayjs(watchedDateRange.endDate).format("YYYY-MM-DD");
return (holidaySettings.holidays || [])
.filter((h) => h.enabled && h.date >= startStr && h.date <= endStr)
.map((h) => ({ date: h.date, holiday: { id: h.id, name: h.name } }));
}, [holidaySettings, watchedDateRange]);
const createOrEditOutOfOfficeEntry = trpc.viewer.ooo.outOfOfficeCreateOrUpdate.useMutation({
onSuccess: () => {
showToast(
currentlyEditingOutOfOfficeEntry
? t("success_edited_entry_out_of_office")
: t("success_entry_created"),
"success"
);
utils.viewer.ooo.outOfOfficeEntriesList.invalidate();
closeModal();
},
onError: (error) => {
showToast(t(error.message), "error");
},
});
return (
<Dialog
open={openModal}
onOpenChange={(open) => {
if (!open) {
closeModal();
}
}}>
<DialogContent
enableOverflow
onOpenAutoFocus={(event) => {
event.preventDefault();
}}>
<form
id="create-or-edit-ooo-form"
onSubmit={handleSubmit((data) => {
if (!data.dateRange.endDate) {
showToast(t("end_date_not_selected"), "error");
} else {
createOrEditOutOfOfficeEntry.mutate({
...data,
startDateOffset: -1 * data.dateRange.startDate.getTimezoneOffset(),
endDateOffset: -1 * data.dateRange.endDate.getTimezoneOffset(),
});
}
})}>
<div className="h-full px-1">
<DialogHeader
title={
currentlyEditingOutOfOfficeEntry
? t("edit_an_out_of_office")
: oooType === "team"
? t("create_ooo_dialog_team_title")
: t("create_an_out_of_office")
}
subtitle={
oooType === "team"
? currentlyEditingOutOfOfficeEntry
? t("edit_ooo_dialog_team_subtitle")
: t("create_ooo_dialog_team_subtitle")
: undefined
}
/>
{/* In case of Team, Select Member for whom OOO is created */}
{oooType === OutOfOfficeTab.TEAM && (
<div className="mb-4">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<Controller
control={control}
name="forUserId"
render={({ field: { onChange, value } }) => (
<Select
className="mt-2"
data-testid="ooofor_username_select"
isSearchable={true}
isDisabled={!!currentlyEditingOutOfOfficeEntry}
menuPlacement="bottom"
value={oooMemberListOptions.find((member) => member.value === value)}
placeholder={t("search")}
options={oooMemberListOptions}
onInputChange={(newValue) => setSearchMember(newValue)}
onChange={(selectedOption) => {
if (selectedOption?.value) {
onChange(selectedOption.value);
}
}}
onMenuScrollToBottom={() => {
if (oooForMembers.hasNextPage && !oooForMembers.isFetchingNextPage) {
oooForMembers.fetchNextPage();
}
}}
isLoading={oooForMembers.isFetchingNextPage}
/>
)}
/>
</div>
)}
<div>
<p className="text-emphasis mb-1 block text-sm font-medium capitalize">{t("dates")}</p>
<div>
<Controller
name="dateRange"
control={control}
render={({ field: { onChange, value } }) => (
<DateRangePicker
minDate={null}
dates={{ startDate: value.startDate, endDate: value.endDate }}
onDatesChange={(values) => {
onChange(values);
}}
strictlyBottom={true}
allowPastDates={true}
/>
)}
/>
</div>
{/* Holiday overlap warning */}
{overlappingHolidays.length > 0 && (
<Alert
className="mt-2"
severity="info"
title={t("holiday_overlap_info")}
message={
overlappingHolidays.length === 1
? t("holiday_overlap_message_single", {
holiday: overlappingHolidays[0].holiday.name,
date: dayjs(overlappingHolidays[0].date).format("D MMM"),
})
: t("holiday_overlap_message_multiple", {
count: overlappingHolidays.length,
holidays: overlappingHolidays
.slice(0, 3)
.map((h) => h.holiday.name)
.join(", "),
})
}
/>
)}
</div>
{/* Reason Select */}
<div className="mt-4 w-full">
<div className="">
<p className="text-emphasis block text-sm font-medium">{t("reason")}</p>
<Controller
control={control}
name="reasonId"
render={({ field: { onChange, value } }) => (
<Select<Option>
className="mb-0 mt-1 text-white"
name="reason"
data-testid="reason_select"
menuPlacement="bottom"
value={reasonList.find((reason) => reason.value === value)}
placeholder={t("ooo_select_reason")}
options={reasonList}
onChange={(selectedOption) => {
if (selectedOption?.value) {
onChange(selectedOption.value);
}
}}
/>
)}
/>
</div>
</div>
{/* Notes input */}
<div className="mt-4">
<p className="text-emphasis text-sm font-medium">{t("notes")}</p>
<TextArea
data-testid="notes_input"
className="border-subtle mt-2 h-10 w-full rounded-lg border px-2"
placeholder={t("additional_notes")}
{...register("notes")}
onChange={(e) => {
const newNotes = e?.target.value;
setValue("notes", newNotes);
if (!newNotes?.trim()) {
setValue("showNotePublicly", false);
}
}}
/>
<Controller
control={control}
name="showNotePublicly"
render={({ field: { value, onChange } }) => (
<div className="mt-2 flex items-center">
<Checkbox
id="show-note-publicly"
data-testid="show-note-publicly-checkbox"
checked={value ?? false}
onCheckedChange={onChange}
disabled={!hasValidNotes}
/>
<label
htmlFor="show-note-publicly"
className={classNames(
"ml-2 text-sm",
hasValidNotes ? "text-emphasis cursor-pointer" : "text-muted cursor-not-allowed"
)}>
{t("show_note_publicly_description")}
</label>
</div>
)}
/>
</div>
<div className="bg-cal-muted my-4 rounded-xl p-5">
<div className="flex flex-row">
<Switch
disabled={!hasTeamPlan}
data-testid="profile-redirect-switch"
checked={profileRedirect}
id="profile-redirect-switch"
onCheckedChange={(state) => {
setProfileRedirect(state);
if (!state) {
setValue("toTeamUserId", null);
}
}}
label={hasTeamPlan ? t("redirect_team_enabled") : t("redirect_team_disabled")}
/>
{!hasTeamPlan && (
<div className="mx-2" data-testid="upgrade-team-badge">
<UpgradeTeamsBadge />
</div>
)}
</div>
{profileRedirect && (
<div className="mb-2">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<Controller
control={control}
name="toTeamUserId"
render={({ field: { onChange, value } }) => (
<Select
className="mt-2"
data-testid="team_username_select"
isSearchable={true}
value={redirectToMemberListOptions
.filter((member) => member.value !== getValues("forUserId"))
.find((member) => member.value === value)}
placeholder={t("search")}
options={redirectToMemberListOptions.filter(
(member) => member.value !== getValues("forUserId")
)}
onInputChange={(newValue) => setSearchRedirectMember(newValue)}
onChange={(selectedOption) => {
if (selectedOption?.value) {
onChange(selectedOption.value);
}
}}
onMenuScrollToBottom={() => {
if (redirectMembers.hasNextPage && !redirectMembers.isFetchingNextPage) {
redirectMembers.fetchNextPage();
}
}}
isLoading={redirectMembers.isFetchingNextPage}
/>
)}
/>
</div>
)}
</div>
</div>
<DialogFooter showDivider noSticky>
<div className="flex">
<Button
color="minimal"
type="button"
onClick={() => {
closeModal();
}}
className="mr-1">
{t("cancel")}
</Button>
<Button
form="create-or-edit-ooo-form"
color="primary"
type="submit"
disabled={isSubmitting || isReasonListPending}
data-testid="create-or-edit-entry-ooo-redirect">
{currentlyEditingOutOfOfficeEntry ? t("save") : t("create")}
</Button>
</div>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};