diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/out-of-office/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/out-of-office/page.tsx index 411b6e3032..a3599d4f54 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/out-of-office/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/my-account/out-of-office/page.tsx @@ -4,6 +4,7 @@ import { getTranslate } from "app/_utils"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import CreateNewOutOfOfficeEntryButton from "@calcom/features/settings/outOfOffice/CreateNewOutOfOfficeEntryButton"; import { OutOfOfficeEntriesList } from "@calcom/features/settings/outOfOffice/OutOfOfficeEntriesList"; +import { OutOfOfficeToggleGroup } from "@calcom/features/settings/outOfOffice/OutOfOfficeToggleGroup"; export const generateMetadata = async () => await _generateMetadata( @@ -18,7 +19,12 @@ const Page = async () => { }> + CTA={ +
+ + +
+ }>
); diff --git a/apps/web/playwright/out-of-office.e2e.ts b/apps/web/playwright/out-of-office.e2e.ts index 8d2c7d68f7..b65e5677d5 100644 --- a/apps/web/playwright/out-of-office.e2e.ts +++ b/apps/web/playwright/out-of-office.e2e.ts @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from "uuid"; import dayjs from "@calcom/dayjs"; import { randomString } from "@calcom/lib/random"; import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import { test } from "./lib/fixtures"; import { submitAndWaitForResponse, localize } from "./lib/testUtils"; @@ -417,7 +418,7 @@ test.describe("Out of office", () => { //As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1' await dateButton.click(); - await selectDateAndCreateOOO(page, "1", "3", "member-1"); + await selectDateAndCreateOOO(page, "1", "3", member1User?.id); await expect( page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0) ).toBeVisible(); @@ -430,7 +431,7 @@ test.describe("Out of office", () => { await addOOOButton.click(); await reasonListRespPromise; await dateButton.click(); - await selectDateAndCreateOOO(page, "4", "5", "owner"); + await selectDateAndCreateOOO(page, "4", "5", owner.id); await expect(page.locator(`data-testid=table-redirect-${owner.username ?? "n-a"}`).nth(0)).toBeVisible(); }); @@ -461,26 +462,111 @@ test.describe("Out of office", () => { const reasonListRespPromise = page.waitForResponse( (response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200 ); - await addOOOButton.click(); - await reasonListRespPromise; + await test.step("As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1'", async () => { + await addOOOButton.click(); + await reasonListRespPromise; + await dateButton.click(); + await selectDateAndCreateOOO(page, "1", "3", member1User?.id); + await expect( + page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0) + ).toBeVisible(); + }); - //As owner,OOO is created on Next month 1st - 3rd, forwarding to 'member-1' - await dateButton.click(); - await selectDateAndCreateOOO(page, "1", "3", "member-1"); - await expect( - page.locator(`data-testid=table-redirect-${member1User?.username ?? "n-a"}`).nth(0) - ).toBeVisible(); + await test.step("As member1, expect error while OOO is created on Next month 4th - 5th, forwarding to 'owner'", async () => { + await member1User?.apiLogin(); + await page.goto("/settings/my-account/out-of-office"); + await page.waitForLoadState("domcontentloaded"); + await entriesListRespPromise; + await addOOOButton.click(); + await reasonListRespPromise; + await dateButton.click(); + await selectDateAndCreateOOO(page, "2", "5", owner.id, 400); + await expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy(); + }); + }); - //As member1, expect error while OOO is created on Next month 2nd - 5th, forwarding to 'owner' - await member1User?.apiLogin(); - await page.goto("/settings/my-account/out-of-office"); - await page.waitForLoadState("domcontentloaded"); - await entriesListRespPromise; - await addOOOButton.click(); - await reasonListRespPromise; - await dateButton.click(); - await selectDateAndCreateOOO(page, "2", "5", "owner", 400); - await expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy(); + test.describe("Team OOO", () => { + test("Create, edit and delete", async ({ page, users }) => { + const t = await localize("en"); + const teamMatesObj = [{ name: "member-1" }, { name: "member-2" }, { name: "member-3" }]; + const teamAdmin = await users.create( + { name: `team-owner-${Date.now()}` }, + { + hasTeam: true, + isOrg: true, + teamRole: MembershipRole.ADMIN, + teammates: teamMatesObj, + } + ); + const member1User = users.get().find((user) => user.name === "member-1"); + const member2User = users.get().find((user) => user.name === "member-2"); + const member3User = users.get().find((user) => user.name === "member-3"); + await teamAdmin.apiLogin(); + + const entriesListRespPromise = page.waitForResponse( + (response) => response.url().includes("outOfOfficeEntriesList") && response.status() === 200 + ); + await page.goto("/settings/my-account/out-of-office?type=team"); + await page.waitForLoadState("domcontentloaded"); + await entriesListRespPromise; + + const addOOOButton = page.getByTestId("add_entry_ooo"); + const dateButton = page.locator('[data-testid="date-range"]'); + const reasonListRespPromise = page.waitForResponse( + (response) => response.url().includes("outOfOfficeReasonList?batch=1") && response.status() === 200 + ); + const legacyListMembersRespPromise = page.waitForResponse( + (response) => response.url().includes("legacyListMembers") && response.status() === 200 + ); + await addOOOButton.click(); + await reasonListRespPromise; + await legacyListMembersRespPromise; + await legacyListMembersRespPromise; + + await test.step("Admin can create OOO for team member and add redirect", async () => { + //OOO is created for 'member-1' on Next month 1st - 3rd, forwarding to 'member-2' + await page.getByTestId(`ooofor_username_select_${member1User?.id}`).click(); + await dateButton.click(); + + await selectDateAndCreateOOO(page, "1", "3", member2User?.id, 200, true); + await expect( + page.locator(`data-testid=table-redirect-${member2User?.username ?? "n-a"}`).nth(0) + ).toBeVisible(); + }); + + await test.step("Reverse redirect not allowed for team member", async () => { + //Try to create OOO for 'member-2' on Next month 1st - 3rd, forwarding to 'member-1' + await page.getByTestId("add_entry_ooo").click(); + await reasonListRespPromise; + await legacyListMembersRespPromise; + await legacyListMembersRespPromise; + + await page.getByTestId(`ooofor_username_select_${member2User?.id}`).click(); + await dateButton.click(); + await selectDateAndCreateOOO(page, "1", "3", member1User?.id, 400, true); + expect(page.locator(`text=${t("booking_redirect_infinite_not_allowed")}`)).toBeTruthy(); + await page.locator(`text=${t("cancel")}`).click(); + }); + + await test.step("Edit OOO and change redirect member", async () => { + //Change redirect member to 'member-3' for OOO created in step 1 + await page.getByTestId(`ooo-edit-${member2User?.username}`).click(); + await reasonListRespPromise; + await legacyListMembersRespPromise; + await legacyListMembersRespPromise; + + await page.getByTestId(`team_username_select_${member3User?.id}`).click(); + await saveAndWaitForResponse(page); + await expect( + page.locator(`data-testid=table-redirect-${member3User?.username ?? "n-a"}`).nth(0) + ).toBeVisible(); + }); + + await test.step("Delete OOO successfully", async () => { + await page.getByTestId(`ooo-delete-${member3User?.username}`).click(); + expect(page.locator(`text=${t("success_deleted_entry_out_of_office")}`)).toBeTruthy(); + }); + }); }); }); @@ -504,21 +590,28 @@ async function selectDateAndCreateOOO( page: Page, fromDate: string, toDate: string, - redirectToUser?: string, - expectedStatusCode = 200 + redirectToUserId?: number, + expectedStatusCode = 200, + forTeamMember = false, + month: "previous-month" | "next-month" = "next-month", + editMode = false ) { const t = await localize("en"); - await page.locator(`button[name="next-month"]`).click(); + await page.locator(`button[name="${month}"]`).click(); await page.locator(`button[name="day"]:text-is("${fromDate}")`).nth(0).click(); await page.locator(`button[name="day"]:text-is("${toDate}")`).nth(0).click(); - await page.locator(`text=${t("create_an_out_of_office")}`).click(); + editMode + ? await page.locator(`text=${t("edit_an_out_of_office")}`).click() + : forTeamMember + ? await page.locator(`text=${t("create_ooo_dialog_team_title")}`).click() + : await page.locator(`text=${t("create_an_out_of_office")}`).click(); await page.getByTestId("reason_select").click(); await page.getByTestId("select-option-4").click(); await page.getByTestId("notes_input").click(); await page.getByTestId("notes_input").fill("Demo notes"); - if (redirectToUser) { + if (redirectToUserId) { await page.getByTestId("profile-redirect-switch").click(); - await page.locator(`text=${redirectToUser}`).click(); + await page.getByTestId(`team_username_select_${redirectToUserId}`).click(); } await saveAndWaitForResponse(page, expectedStatusCode); } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b0d29f734f..531113e302 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2964,5 +2964,17 @@ "verify_email": "Verify email", "verify_email_change": "Verify email change", "end_date_not_selected": "You didn't set an end date for your OOO", + "my_ooo": "My OOO", + "team_ooo": "Team OOO", + "out_of_office_team_description": "Manage you're team's OOO.", + "create_ooo_dialog_team_title": "Create Out of Office", + "create_ooo_dialog_team_subtitle": "Create an OOO for a team member", + "edit_ooo_dialog_team_subtitle": "Edit an OOO for team member", + "ooo_team_empty_title": "Create OOO for your team", + "ooo_team_empty_description": "Communicate to bookers when your team members are not available to take bookings. They can still be booked upon their return or you can forward them to another team member.", + "forward_to_team_member_only": "OOO Forwarding is only allowed to a member of the same team.", + "ooo_team_redirect_infinite_not_allowed": "There is already a reverse forwarding between these users.", + "previous_ooo_empty_title": "No previous OOO found", + "previous": "Previous", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/eventtypes/components/EventTypeLayout.tsx b/packages/features/eventtypes/components/EventTypeLayout.tsx index c976e65c20..01b72d0250 100644 --- a/packages/features/eventtypes/components/EventTypeLayout.tsx +++ b/packages/features/eventtypes/components/EventTypeLayout.tsx @@ -278,7 +278,7 @@ function EventTypeSingleLayout({
{ const { t } = useLocale(); const utils = trpc.useUtils(); + const me = useMeQuery(); - const [searchText, setSearchText] = useState(""); - const debouncedSearch = useDebounce(searchText, 500); + const searchParams = useCompatSearchParams(); + const oooType = searchParams?.get("type") ?? OutOfOfficeTab.MINE; - const members = trpc.viewer.teams.legacyListMembers.useInfiniteQuery( - { limit: 10, searchText: debouncedSearch }, + const [searchMember, setSearchMember] = useState(""); + const debouncedSearchMember = useDebounce(searchMember, 500); + const oooForMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery( + { limit: 10, searchText: debouncedSearchMember }, { enabled: true, 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 { ref: observerRefMember } = useInViewObserver(() => { + if (oooForMembers.hasNextPage && !oooForMembers.isFetching) { + oooForMembers.fetchNextPage(); + } + }, document.querySelector('[role="dialog"]')); - const me = useMeQuery(); - - const memberListOptions: { + const [searchRedirectMember, setSearchRedirectMember] = useState(""); + const debouncedSearchRedirect = useDebounce(searchRedirectMember, 500); + const redirectMembers = trpc.viewer.teams.legacyListMembers.useInfiniteQuery( + { limit: 10, searchText: debouncedSearchRedirect }, + { + enabled: true, + getNextPageParam: (lastPage) => lastPage.nextCursor, + } + ); + const redirectToMemberListOptions: { value: number; label: string; avatarUrl: string | null; }[] = - members?.data?.pages + redirectMembers?.data?.pages .flatMap((page) => page.members) - ?.filter((member) => me?.data?.id !== member.id) + ?.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 { ref: observerRef } = useInViewObserver(() => { - if (members.hasNextPage && !members.isFetching) { - members.fetchNextPage(); + const { ref: observerRefRedirect } = useInViewObserver(() => { + if (redirectMembers.hasNextPage && !redirectMembers.isFetching) { + redirectMembers.fetchNextPage(); } }, document.querySelector('[role="dialog"]')); - const { data: outOfOfficeReasonList } = trpc.viewer.outOfOfficeReasonList.useQuery(); - + const { data: outOfOfficeReasonList, isPending: isReasonListPending } = + trpc.viewer.outOfOfficeReasonList.useQuery(); const reasonList = (outOfOfficeReasonList || []).map((reason) => ({ label: `${reason.emoji} ${reason.userId === null ? t(reason.reason) : reason.reason}`, value: reason.id, @@ -99,6 +142,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ register, watch, formState: { isSubmitting }, + getValues, } = useForm({ defaultValues: currentlyEditingOutOfOfficeEntry ? currentlyEditingOutOfOfficeEntry @@ -110,10 +154,12 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ offset: dayjs().utcOffset(), toTeamUserId: null, reasonId: 1, + forUserId: null, }, }); const watchedTeamUserId = watch("toTeamUserId"); + const watchForUserId = watch("forUserId"); const createOrEditOutOfOfficeEntry = trpc.viewer.outOfOfficeCreateOrUpdate.useMutation({ onSuccess: () => { @@ -156,9 +202,87 @@ export const CreateOrEditOutOfOfficeEntryModal = ({
+ + {/* In case of Team, Select Member for whom OOO is created */} + {oooType === "team" && ( + <> +
+ +
+ setSearchMember(e.target.value)} + value={searchMember} + disabled={!!currentlyEditingOutOfOfficeEntry} + /> +
+ {oooMemberListOptions.map((member) => ( + + ))} + {!currentlyEditingOutOfOfficeEntry && ( +
+ +
+ )} +
+
+
+ + )} +

{t("dates")}

@@ -247,45 +371,47 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ setSearchText(e.target.value)} - value={searchText} + onChange={(e) => setSearchRedirectMember(e.target.value)} + value={searchRedirectMember} />
- {memberListOptions.map((member) => ( - - ))} -
+ {redirectToMemberListOptions + .filter((member) => member.value !== getValues("forUserId")) + .map((member) => ( + + ))} +
@@ -309,7 +435,7 @@ export const CreateOrEditOutOfOfficeEntryModal = ({ form="create-or-edit-ooo-form" color="primary" type="submit" - disabled={isSubmitting} + disabled={isSubmitting || isReasonListPending} data-testid="create-or-edit-entry-ooo-redirect"> {currentlyEditingOutOfOfficeEntry ? t("save") : t("create")} diff --git a/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx b/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx index 5ffb15d826..0dd5016b41 100644 --- a/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx +++ b/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx @@ -1,88 +1,144 @@ "use client"; +import { keepPreviousData } from "@tanstack/react-query"; +import { getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table"; import { Trans } from "next-i18next"; -import { useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useFormState } from "react-hook-form"; import dayjs from "@calcom/dayjs"; +import { DataTable, DataTableToolbar } from "@calcom/features/data-table"; +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 { - Button, - EmptyScreen, - Icon, - showToast, - SkeletonText, - TableBody, - TableCell, - TableNew, - TableRow, - Tooltip, -} from "@calcom/ui"; +import { Avatar, Button, EmptyScreen, Icon, showToast, SkeletonText, Tooltip } from "@calcom/ui"; import CreateNewOutOfOfficeEntryButton from "./CreateNewOutOfOfficeEntryButton"; import { CreateOrEditOutOfOfficeEntryModal } from "./CreateOrEditOutOfOfficeModal"; import type { BookingRedirectForm } from "./CreateOrEditOutOfOfficeModal"; +import { OutOfOfficeTab } 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; +} export const OutOfOfficeEntriesList = () => { const { t } = useLocale(); - const utils = trpc.useUtils(); - const { data, isPending } = trpc.viewer.outOfOfficeEntriesList.useQuery(); - const deleteOutOfOfficeEntryMutation = trpc.viewer.outOfOfficeEntryDelete.useMutation({ - onSuccess: () => { - showToast(t("success_deleted_entry_out_of_office"), "success"); - utils.viewer.outOfOfficeEntriesList.invalidate(); - useFormState; - }, - onError: () => { - showToast(`An error occurred`, "error"); - }, - }); - + const tableContainerRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(""); + const [deletedEntry, setDeletedEntry] = useState(0); const [currentlyEditingOutOfOfficeEntry, setCurrentlyEditingOutOfOfficeEntry] = useState(null); const [openModal, setOpenModal] = useState(false); - const editOutOfOfficeEntry = (entry: BookingRedirectForm) => { setCurrentlyEditingOutOfOfficeEntry(entry); setOpenModal(true); }; - if (data === null || data?.length === 0 || (data === undefined && !isPending)) - return ( - } - customIcon={ -
-
-
-
-
+ const searchParams = useCompatSearchParams(); + const selectedTab = searchParams?.get("type") ?? OutOfOfficeTab.MINE; + + const { data, isPending, fetchNextPage, isFetching, refetch } = + trpc.viewer.outOfOfficeEntriesList.useInfiniteQuery( + { + limit: 10, + fetchTeamMembersEntries: selectedTab === OutOfOfficeTab.TEAM, + searchTerm: selectedTab === OutOfOfficeTab.TEAM ? searchTerm : undefined, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + placeholderData: keepPreviousData, + } + ); + + useEffect(() => { + refetch(); + if (selectedTab === OutOfOfficeTab.MINE) { + setSearchTerm(""); + } + }, [deletedEntry, selectedTab, refetch]); + + const totalDBRowCount = 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 totalFetched = flatData.length; + + const memoColumns = useMemo(() => { + const columns: ColumnDef[] = []; + if (selectedTab === OutOfOfficeTab.TEAM) { + columns.push({ + id: "member", + header: `Member`, + size: 300, + cell: ({ row }) => { + if (!row.original || !row.original.user || isPending || isFetching) { + return ; + } + 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 ( +
+ +
+
+ {memberName}
-
-
-
-
- -
- z +
+ {email}
-
- } - /> - ); - return ( -
- - - {data?.map((item) => ( - - + ); + }, + }); + } + columns.push({ + id: "outOfOffice", + header: `${t("out_of_office")} (${totalDBRowCount})`, + size: selectedTab === OutOfOfficeTab.TEAM ? 370 : 660, + cell: ({ row }) => { + const item = row.original; + return ( + <> + {row.original && !isPending && !isFetching ? ( +
{item?.reason?.emoji || "🏝️"} @@ -117,81 +173,186 @@ export const OutOfOfficeEntriesList = () => { )}
+
+ ) : ( + + )} + + ); + }, + }); + columns.push({ + id: "actions", + size: 90, + cell: ({ row }) => { + const item = row.original; + return ( + <> + {row.original && !isPending && !isFetching ? ( +
+ +
+ ) : ( + + )} + + ); + }, + }); + return columns; + }, [selectedTab, isPending, isFetching]); -
- -