-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Bug 8726-URL-based layout persistence #8729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: preview
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,8 @@ 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"; | ||
| import { getLayoutFromUrl, setLayoutInQuery } from "./layout-selection.logic"; | ||
|
|
||
| type Props = { | ||
| layouts: EIssueLayoutTypes[]; | ||
|
|
@@ -25,9 +27,27 @@ 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; | ||
|
|
||
| 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") { | ||
| const newQuery = setLayoutInQuery(window.location.search, layoutKey); | ||
| const newUrl = newQuery ? `${window.location.pathname}?${newQuery}` : window.location.pathname; | ||
|
|
||
| window.history.replaceState({}, "", newUrl); | ||
| } | ||
|
Comment on lines
+45
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check how other components in this codebase handle URL query parameter updates
rg -n "history\.replaceState|history\.pushState" apps/web/ --type ts --type tsx -C 2 | head -50
# Check if there's an established pattern using Next.js router
rg -n "router\.replace|router\.push.*query" apps/web/core/components/ --type ts --type tsx | head -30Repository: makeplane/plane Length of output: 114 🏁 Script executed: #!/bin/bash
# Search for history.replaceState and history.pushState without file type specification
rg "history\.(replaceState|pushState)" apps/web/ -C 2
# Also search the specific file to understand its context
cat apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.tsxRepository: makeplane/plane Length of output: 3428 🏁 Script executed: #!/bin/bash
# Search for useRouter usage in similar layout/filter components
rg "useRouter\|useSearchParams" apps/web/core/components/issues/ -C 1 | head -50
# Check if layout-selection is used in route components
rg "LayoutSelection" apps/web/ -C 1 | head -30
# Look for similar patterns of URL query updates in the codebase
rg "router\.(push|replace).*query|useSearchParams" apps/web/core/components/ -C 2 | head -50Repository: makeplane/plane Length of output: 6186 🏁 Script executed: #!/bin/bash
# Find the useQueryParams utility and its usage pattern
rg "useQueryParams" apps/web/core/components/ -B 2 -A 3 | head -80
# Check how LayoutSelection is integrated in filters.tsx
cat apps/web/core/components/issues/filters.tsx | head -100Repository: makeplane/plane Length of output: 8562 🏁 Script executed: #!/bin/bash
# Check mobile-layout-selection.tsx for the same pattern
cat apps/web/core/components/issues/issue-layouts/filters/header/mobile-layout-selection.tsx
# Check the logic file to understand the intent
cat apps/web/core/components/issues/issue-layouts/filters/header/layout-selection.logic.tsRepository: makeplane/plane Length of output: 2688 Use Next.js routing hooks instead of direct This component directly uses Either refactor this component to use Next.js routing hooks if it owns URL state, or move URL synchronization responsibility to the parent component (HeaderFilters) which should coordinate both state updates and route updates. Note that MobileLayoutSelection correctly handles this by only exposing the onChange callback without manipulating history. 🤖 Prompt for AI Agents |
||
| } | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; | ||
|
Comment on lines
+2
to
+5
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if the import path resolves correctly
fd -t f "layout-selection.logic.ts" --exec echo "Found: {}"
# Verify the test file location
fd -t f "layout-selection.logic.test.ts" --exec echo "Test at: {}"
# Check if there are other tests in codemods that import from apps/web
rg -l "from.*\.\./.*apps/web" packages/codemods/Repository: makeplane/plane Length of output: 275 🏁 Script executed: # Check tsconfig configurations for both locations
find . -name "tsconfig.json" -type f | head -20Repository: makeplane/plane Length of output: 843 🏁 Script executed: # Look at the packages/codemods structure and its tsconfig
cat packages/codemods/tsconfig.json 2>/dev/null || echo "tsconfig.json not found in packages/codemods"Repository: makeplane/plane Length of output: 352 🏁 Script executed: # Check if there are other imports from apps/web in the codebase tests
rg "from.*apps/web" --type ts --type tsx | grep -E "(test|spec)" | head -20Repository: makeplane/plane Length of output: 86 🏁 Script executed: # Check the root tsconfig and monorepo structure
cat tsconfig.json 2>/dev/null || cat tsconfig.base.json 2>/dev/null || echo "Base tsconfig not found"Repository: makeplane/plane Length of output: 82 🏁 Script executed: # Examine the actual import in the test file
cat -n packages/codemods/tests/layout-selection.logic.test.ts | head -10Repository: makeplane/plane Length of output: 533 🏁 Script executed: # Check monorepo configuration
cat pnpm-workspace.yaml 2>/dev/null || cat package.json | head -30Repository: makeplane/plane Length of output: 994 🏁 Script executed: # Check if there are exports configured in apps/web that would allow external imports
cat apps/web/package.json | grep -A 10 "exports"Repository: makeplane/plane Length of output: 41 🏁 Script executed: # Look for any documentation about module imports in the codebase
find . -maxdepth 2 -name "*.md" -type f | xargs grep -l "import\|path\|package" 2>/dev/null | head -10Repository: makeplane/plane Length of output: 147 🏁 Script executed: # Check if codemods package has any path or dependency configuration that might allow this import
cat packages/codemods/package.jsonRepository: makeplane/plane Length of output: 1046 🏁 Script executed: # Search for similar import patterns (without the test/spec filter)
rg "from.*\.\./.*apps/web" --type ts | head -10Repository: makeplane/plane Length of output: 216 Cross-package import violates monorepo structure. The test file imports from Move the test file alongside the logic file in 🤖 Prompt for AI Agents |
||
|
|
||
| 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"); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useEffect runs on every
selectedLayoutchange, not just on mount.The comment states "Read layout from URL once on mount," but including
selectedLayoutin the dependency array causes the effect to run on every layout change. This is unnecessary overhead since the URL is updated synchronously inhandleOnChange.To run truly only on mount, consider removing
selectedLayoutfrom dependencies. Alternatively, use a ref to track whether initialization has occurred.♻️ Option 1: Run only on mount (suppress lint warning)
useEffect(() => { if (typeof window === "undefined") return; const layout = getLayoutFromUrl(window.location.search, layouts); if (layout && (layout as EIssueLayoutTypes) !== selectedLayout) { onChange(layout as EIssueLayoutTypes); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Initialize from URL once on mount only - }, [layouts, onChange, selectedLayout]); + }, []);♻️ Option 2: Use a ref to track initialization
📝 Committable suggestion
🤖 Prompt for AI Agents