From c20f1e66c7145530275d1c7af651ff215aeb9997 Mon Sep 17 00:00:00 2001 From: Seth-Wadsworth Date: Mon, 2 Mar 2026 19:05:51 -0700 Subject: [PATCH 1/2] Apply URL-based layout persistence for Views (layout-selection) --- .../filters/header/layout-selection.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx index 6b8f857b1a6..82aae982965 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx @@ -14,6 +14,7 @@ import { cn } from "@plane/utils"; import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; +import { useEffect } from "react"; type Props = { layouts: EIssueLayoutTypes[]; @@ -25,9 +26,36 @@ export function LayoutSelection(props: Props) { const { layouts, onChange, selectedLayout } = props; const { isMobile } = usePlatformOS(); const { t } = useTranslation(); + + // Read layout from URL once on mount and apply if valid + useEffect(() => { + if (typeof window === "undefined") return; + try { + const params = new URLSearchParams(window.location.search); + const urlLayout = params.get("layout") as EIssueLayoutTypes | null; + if (urlLayout && urlLayout !== selectedLayout && layouts.includes(urlLayout)) { + onChange(urlLayout); + } + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.warn("Failed to read layout from URL", err); + } + }, [layouts, onChange, selectedLayout]); const handleOnChange = (layoutKey: EIssueLayoutTypes) => { if (selectedLayout !== layoutKey) { onChange(layoutKey); + if (typeof window !== "undefined") { + try { + const params = new URLSearchParams(window.location.search); + params.set("layout", layoutKey); + const newQuery = params.toString(); + const newUrl = newQuery ? `${window.location.pathname}?${newQuery}` : window.location.pathname; + window.history.replaceState({}, "", newUrl); + } catch (e: unknown) { + const err = e instanceof Error ? e : new Error(String(e)); + console.warn("Layout URL parsing failed (reported)", err); + } + } } }; From 63c71b331fb4696383e3c61924dc0fdff994551d Mon Sep 17 00:00:00 2001 From: Seth-Wadsworth Date: Fri, 6 Mar 2026 16:51:20 -0700 Subject: [PATCH 2/2] Extract layout-selection URL logic into pure functions + add tests --- .../filters/header/layout-selection.logic.ts | 31 ++++++++++++++++ .../filters/header/layout-selection.tsx | 30 ++++++---------- .../tests/layout-selection.logic.test.ts | 36 +++++++++++++++++++ 3 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.logic.ts create mode 100644 packages/codemods/tests/layout-selection.logic.test.ts diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.logic.ts b/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.logic.ts new file mode 100644 index 00000000000..72224c30aed --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.logic.ts @@ -0,0 +1,31 @@ +/** + * Extracts the layout value from a URL query string. + * Returns null if no valid layout is present. + */ +export function getLayoutFromUrl(search: string, validLayouts: string[]): string | null { + try { + const params = new URLSearchParams(search); + const layout = params.get("layout"); + + if (!layout) return null; + if (!validLayouts.includes(layout)) return null; + + return layout; + } catch { + return null; + } +} + +/** + * Returns a new query string with the layout updated. + * Does not touch window or history — pure logic only. + */ +export function setLayoutInQuery(search: string, layout: string): string { + try { + const params = new URLSearchParams(search); + params.set("layout", layout); + return params.toString(); + } catch { + return search; + } +} diff --git a/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx b/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx index 82aae982965..d66eec1a9b3 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsx @@ -15,6 +15,7 @@ import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; import { useEffect } from "react"; +import { getLayoutFromUrl, setLayoutInQuery } from "./layout-selection.logic"; type Props = { layouts: EIssueLayoutTypes[]; @@ -30,31 +31,22 @@ export function LayoutSelection(props: Props) { // Read layout from URL once on mount and apply if valid useEffect(() => { if (typeof window === "undefined") return; - try { - const params = new URLSearchParams(window.location.search); - const urlLayout = params.get("layout") as EIssueLayoutTypes | null; - if (urlLayout && urlLayout !== selectedLayout && layouts.includes(urlLayout)) { - onChange(urlLayout); - } - } catch (e: unknown) { - const err = e instanceof Error ? e : new Error(String(e)); - console.warn("Failed to read layout from URL", err); + + const layout = getLayoutFromUrl(window.location.search, layouts); + if (layout && (layout as EIssueLayoutTypes) !== selectedLayout) { + onChange(layout as EIssueLayoutTypes); } }, [layouts, onChange, selectedLayout]); + const handleOnChange = (layoutKey: EIssueLayoutTypes) => { if (selectedLayout !== layoutKey) { onChange(layoutKey); + if (typeof window !== "undefined") { - try { - const params = new URLSearchParams(window.location.search); - params.set("layout", layoutKey); - const newQuery = params.toString(); - const newUrl = newQuery ? `${window.location.pathname}?${newQuery}` : window.location.pathname; - window.history.replaceState({}, "", newUrl); - } catch (e: unknown) { - const err = e instanceof Error ? e : new Error(String(e)); - console.warn("Layout URL parsing failed (reported)", err); - } + const newQuery = setLayoutInQuery(window.location.search, layoutKey); + const newUrl = newQuery ? `${window.location.pathname}?${newQuery}` : window.location.pathname; + + window.history.replaceState({}, "", newUrl); } } }; diff --git a/packages/codemods/tests/layout-selection.logic.test.ts b/packages/codemods/tests/layout-selection.logic.test.ts new file mode 100644 index 00000000000..a2b86e0c05a --- /dev/null +++ b/packages/codemods/tests/layout-selection.logic.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { + getLayoutFromUrl, + setLayoutInQuery, +} from "../../../apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.logic"; + +describe("layout-selection.logic", () => { + describe("getLayoutFromUrl", () => { + it("returns the layout when valid", () => { + const result = getLayoutFromUrl("?layout=list", ["list", "kanban"]); + expect(result).toBe("list"); + }); + + it("returns null when layout is missing", () => { + const result = getLayoutFromUrl("?foo=bar", ["list"]); + expect(result).toBeNull(); + }); + + it("returns null when layout is invalid", () => { + const result = getLayoutFromUrl("?layout=unknown", ["list"]); + expect(result).toBeNull(); + }); + }); + + describe("setLayoutInQuery", () => { + it("updates the layout in the query string", () => { + const result = setLayoutInQuery("?foo=bar", "list"); + expect(result).toBe("foo=bar&layout=list"); + }); + + it("overwrites an existing layout", () => { + const result = setLayoutInQuery("?layout=kanban", "list"); + expect(result).toBe("layout=list"); + }); + }); +});