Files
calendar/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx
6b9115dfde perf: server-fetch data for all pages in /settings/my-account (#20712)
* wip

* fix type check

* refactor "/settings/my-account/profile"

* refactor "settings/my-account/general"

* refactor "/settings/my-accouunt/calendars"

* refactor "/settings/my-account/appearance"

* fix: type-check

---------

Co-authored-by: Amit Sharma <[email protected]>
Co-authored-by: Amit Sharma <[email protected]>
2025-07-24 22:36:40 +01:00

424 lines
16 KiB
TypeScript

"use client";
import { keepPreviousData } from "@tanstack/react-query";
import {
createColumnHelper,
getCoreRowModel,
getFilteredRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFormState } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import {
DataTableWrapper,
DataTableToolbar,
DataTableProvider,
ColumnFilterType,
useDataTable,
useFilterValue,
ZDateRangeFilterValue,
DataTableFilters,
DataTableSegment,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Icon } from "@calcom/ui/components/icon";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import CreateNewOutOfOfficeEntryButton from "./CreateNewOutOfOfficeEntryButton";
import { CreateOrEditOutOfOfficeEntryModal } from "./CreateOrEditOutOfOfficeModal";
import type { BookingRedirectForm } from "./CreateOrEditOutOfOfficeModal";
import { OutOfOfficeTab, OutOfOfficeToggleGroup } from "./OutOfOfficeToggleGroup";
interface OutOfOfficeEntry {
id: number;
uuid: string;
start: Date;
end: Date;
toUserId: number | null;
toUser: {
username: string;
name: string;
} | null;
reason: {
id: number;
emoji: string;
reason: string;
userId: number;
} | null;
notes: string | null;
user: { id: number; avatarUrl: string; username: string; email: string; name: string } | null;
canEditAndDelete: boolean;
}
export default function OutOfOfficeEntriesList() {
const { t } = useLocale();
return (
<SettingsHeader
title={t("out_of_office")}
description={t("out_of_office_description")}
CTA={
<div className="flex gap-2">
<OutOfOfficeToggleGroup />
<CreateNewOutOfOfficeEntryButton data-testid="add_entry_ooo" />
</div>
}>
<DataTableProvider useSegments={useSegments}>
<OutOfOfficeEntriesListContent />
</DataTableProvider>
</SettingsHeader>
);
}
function OutOfOfficeEntriesListContent() {
const { t } = useLocale();
const tableContainerRef = useRef<HTMLDivElement>(null);
const [deletedEntry, setDeletedEntry] = useState(0);
const [currentlyEditingOutOfOfficeEntry, setCurrentlyEditingOutOfOfficeEntry] =
useState<BookingRedirectForm | null>(null);
const [openModal, setOpenModal] = useState(false);
const editOutOfOfficeEntry = (entry: BookingRedirectForm) => {
setCurrentlyEditingOutOfOfficeEntry(entry);
setOpenModal(true);
};
const { searchTerm } = useDataTable();
const searchParams = useCompatSearchParams();
const selectedTab = searchParams?.get("type") ?? OutOfOfficeTab.MINE;
const endDateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
const { data, isPending, fetchNextPage, isFetching, refetch, hasNextPage } =
trpc.viewer.ooo.outOfOfficeEntriesList.useInfiniteQuery(
{
limit: 10,
fetchTeamMembersEntries: selectedTab === OutOfOfficeTab.TEAM,
searchTerm,
endDateFilterStartRange: endDateRange?.startDate ?? undefined,
endDateFilterEndRange: endDateRange?.endDate ?? undefined,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
placeholderData: keepPreviousData,
}
);
useEffect(() => {
refetch();
}, [deletedEntry, selectedTab, refetch]);
const totalRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
const flatData = useMemo(
() =>
isPending || isFetching ? new Array(5).fill(null) : data?.pages?.flatMap((page) => page.rows) ?? [],
[data, selectedTab, isPending, isFetching, searchTerm]
) as OutOfOfficeEntry[];
const memoColumns = useMemo(() => {
const columnHelper = createColumnHelper<OutOfOfficeEntry>();
return [
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
filterFn: () => true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: "past",
},
},
},
}),
...(selectedTab === OutOfOfficeTab.TEAM
? [
columnHelper.display({
id: "member",
header: `Member`,
size: 300,
cell: ({ row }) => {
if (!row.original || !row.original.user || isPending || isFetching) {
return <SkeletonText className="h-8 w-full" />;
}
const { avatarUrl, username, email, name } = row.original.user;
const memberName =
name ||
(() => {
const emailName = email.split("@")[0];
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
})();
return (
<div className="flex items-center gap-2">
<Avatar
size="sm"
alt={username || email}
imageSrc={getUserAvatarUrl({
avatarUrl,
})}
/>
<div className="">
<div
data-testid={`ooo-member-${username}-username`}
className="text-emphasis text-sm font-medium leading-none">
{memberName}
</div>
<div
data-testid={`ooo-member-${username}-email`}
className="text-subtle mt-1 text-sm leading-none">
{email}
</div>
</div>
</div>
);
},
}),
]
: []),
columnHelper.display({
id: "outOfOffice",
header: `${t("out_of_office")} (${totalRowCount})`,
size: selectedTab === OutOfOfficeTab.TEAM ? 370 : 660,
cell: ({ row }) => {
const item = row.original;
return (
<>
{row.original && !isPending && !isFetching ? (
<div
className="flex flex-row justify-between p-2"
data-testid={`table-redirect-${item.toUser?.username || "n-a"}`}>
<div className="flex flex-row items-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gray-50">
{item?.reason?.emoji || "🏝️"}
</div>
<div className="ml-2 flex flex-col">
<p className="px-2 font-bold">
{dayjs.utc(item.start).format("ll")} - {dayjs.utc(item.end).format("ll")}
</p>
<p className="px-2">
{item.toUser?.username ? (
<ServerTrans
t={t}
i18nKey="ooo_forwarding_to"
values={{
username: item.toUser?.username,
}}
components={[<span key="ooo-username" className="text-subtle font-bold" />]}
/>
) : (
<>{t("ooo_not_forwarding")}</>
)}
</p>
{item.notes && (
<p className="px-2">
<span className="text-subtle">{t("notes")}: </span>
<span data-testid={`ooo-entry-note-${item.toUser?.username || "n-a"}`}>
{item.notes}
</span>
</p>
)}
</div>
</div>
</div>
) : (
<SkeletonText className="h-8 w-full" />
)}
</>
);
},
}),
columnHelper.display({
id: "actions",
size: 90,
cell: ({ row }) => {
const item = row.original;
return (
<>
{row.original && !isPending && !isFetching ? (
<div className="flex flex-row items-center justify-end gap-x-2" data-testid="ooo-actions">
<Tooltip content={t("edit")}>
<Button
className="self-center rounded-lg border"
type="button"
color="secondary"
variant="icon"
data-testid={`ooo-edit-${item.toUser?.username || "n-a"}`}
StartIcon="pencil"
onClick={() => {
const startDateOffset = -1 * item.start.getTimezoneOffset();
const endDateOffset = -1 * item.end.getTimezoneOffset();
const outOfOfficeEntryData: BookingRedirectForm = {
uuid: item.uuid,
dateRange: {
startDate: dayjs(item.start).subtract(startDateOffset, "minute").toDate(),
endDate: dayjs(item.end).subtract(endDateOffset, "minute").startOf("d").toDate(),
},
startDateOffset,
endDateOffset,
toTeamUserId: item.toUserId,
reasonId: item.reason?.id ?? 1,
notes: item.notes ?? undefined,
forUserId: item.user?.id || null,
forUserName:
item.user?.name ||
(item.user?.email &&
(() => {
const emailName = item.user?.email.split("@")[0];
return emailName.charAt(0).toUpperCase() + emailName.slice(1);
})()),
forUserAvatar: item.user?.avatarUrl,
toUserName: item.toUser?.name || item.toUser?.username,
};
editOutOfOfficeEntry(outOfOfficeEntryData);
}}
disabled={isPending || isFetching || !item.canEditAndDelete}
/>
</Tooltip>
<Tooltip content={t("delete")}>
<Button
className="self-center rounded-lg border"
type="button"
color="destructive"
variant="icon"
disabled={
deleteOutOfOfficeEntryMutation.isPending ||
isPending ||
isFetching ||
!item.canEditAndDelete
}
StartIcon="trash-2"
data-testid={`ooo-delete-${item.toUser?.username || "n-a"}`}
onClick={() => {
deleteOutOfOfficeEntryMutation.mutate({
outOfOfficeUid: item.uuid,
userId: selectedTab === OutOfOfficeTab.TEAM ? item.user?.id : undefined,
});
}}
/>
</Tooltip>
</div>
) : (
<SkeletonText className="h-8 w-full" />
)}
</>
);
},
}),
];
}, [selectedTab, isPending, isFetching]);
const table = useReactTable({
data: flatData,
columns: memoColumns,
initialState: {
columnVisibility: {
dateRange: false,
},
},
enableRowSelection: false,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
const deleteOutOfOfficeEntryMutation = trpc.viewer.ooo.outOfOfficeEntryDelete.useMutation({
onSuccess: () => {
showToast(t("success_deleted_entry_out_of_office"), "success");
setDeletedEntry((previousValue) => previousValue + 1);
useFormState;
},
onError: () => {
showToast(`An error occurred`, "error");
},
});
return (
<>
<DataTableWrapper
testId="ooo-list-data-table"
rowClassName={selectedTab === OutOfOfficeTab.MINE ? "hidden" : ""}
table={table}
isPending={isPending}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
totalRowCount={totalRowCount}
tableContainerRef={tableContainerRef}
paginationMode="infinite"
ToolbarLeft={
<>
<DataTableToolbar.SearchBar />
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</>
}
EmptyView={
<EmptyScreen
className="mt-6"
headline={selectedTab === OutOfOfficeTab.TEAM ? t("ooo_team_empty_title") : t("ooo_empty_title")}
description={
selectedTab === OutOfOfficeTab.TEAM
? t("ooo_team_empty_description")
: t("ooo_empty_description")
}
buttonRaw={<CreateNewOutOfOfficeEntryButton size="sm" />}
customIcon={
<div className="mt-4 h-[102px]">
<div className="flex h-full flex-col items-center justify-center p-2 md:mt-0 md:p-0">
<div className="relative">
<div className="dark:bg-darkgray-50 absolute -left-3 -top-3 -z-20 h-[70px] w-[70px] -rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-40 dark:opacity-80">
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 absolute -top-3 left-3 -z-10 h-[70px] w-[70px] rotate-[24deg] rounded-3xl border-2 border-[#e5e7eb] p-8 opacity-60 dark:opacity-90">
<div className="w-12" />
</div>
<div className="dark:bg-darkgray-50 text-inverted relative z-0 flex h-[70px] w-[70px] items-center justify-center rounded-3xl border-2 border-[#e5e7eb] bg-white">
<Icon name="clock" size={28} className="text-black" />
<div className="dark:bg-darkgray-50 absolute right-4 top-5 h-[12px] w-[12px] rotate-[56deg] bg-white text-lg font-bold" />
<span className="absolute right-4 top-3 font-sans text-sm font-extrabold text-black">
z
</span>
</div>
</div>
</div>
</div>
}
/>
}
/>
{openModal && (
<CreateOrEditOutOfOfficeEntryModal
openModal={openModal}
closeModal={() => {
setOpenModal(false);
setCurrentlyEditingOutOfOfficeEntry(null);
}}
currentlyEditingOutOfOfficeEntry={currentlyEditingOutOfOfficeEntry}
/>
)}
</>
);
}