From 6ff13544ce4d59b864740d4ce4813173bbf0008e Mon Sep 17 00:00:00 2001 From: Yan <75355375+yancat160@users.noreply.github.com> Date: Fri, 8 May 2026 11:04:04 -0400 Subject: [PATCH 1/2] let users toggle DataGrid between table and card view DataGrid was already injecting displayMode='cards' on small viewports via useMobile() and leaving it undefined (default 'table') everywhere else. The user had no way to override either side: someone on a wide screen who preferred a denser card list could not get there, and someone on a tablet with a tall narrow window could not force the table view to keep their layout consistent. Add a small icon toggle in the DataGrid header row that flips between table and cards, and persist the choice in localStorage under umami.datagrid.displayMode. The user choice wins; if there is none, the existing useMobile-driven default applies. Every DataGrid consumer (sessions, websites, links, pixels, boards, team admin, etc.) gets the toggle automatically with no caller-side change. Verified in playwright on the sessions page: at 1400 viewport the default is table; clicking the toggle switches to cards and a reload keeps cards. At 800 viewport the default is cards; clicking the toggle switches to table even though useMobile would otherwise force cards. Round trip in both directions works and the choice survives navigation away and back. --- src/components/common/DataGrid.tsx | 52 +++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/components/common/DataGrid.tsx b/src/components/common/DataGrid.tsx index 993929fd2..4ac8e72af 100644 --- a/src/components/common/DataGrid.tsx +++ b/src/components/common/DataGrid.tsx @@ -1,5 +1,15 @@ import type { UseQueryResult } from '@tanstack/react-query'; -import { Column, Row, SearchField } from '@umami/react-zen'; +import { + Button, + Column, + Icon, + Row, + SearchField, + Text, + Tooltip, + TooltipTrigger, +} from '@umami/react-zen'; +import { LayoutGrid, Table2 } from 'lucide-react'; import { cloneElement, isValidElement, @@ -12,9 +22,13 @@ import { Empty } from '@/components/common/Empty'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Pager } from '@/components/common/Pager'; import { useMessages, useMobile, useNavigation } from '@/components/hooks'; +import { getItem, setItem } from '@/lib/storage'; import type { PageResult } from '@/lib/types'; const DEFAULT_SEARCH_DELAY = 600; +const DISPLAY_MODE_STORAGE_KEY = 'umami.datagrid.displayMode'; + +type DisplayMode = 'table' | 'cards'; export interface DataGridProps { query: UseQueryResult, any>; @@ -43,7 +57,19 @@ export function DataGrid({ const [search, setSearch] = useState(queryParams?.search || data?.search || ''); const showPager = allowPaging && data && data.count > data.pageSize; const { isMobile } = useMobile(); - const displayMode = isMobile ? 'cards' : undefined; + const [userDisplayMode, setUserDisplayMode] = useState( + () => getItem(DISPLAY_MODE_STORAGE_KEY) ?? null, + ); + + // Effective mode: explicit user choice wins, otherwise fall back to the + // mobile-driven default (cards on small viewports, table elsewhere). + const displayMode: DisplayMode | undefined = userDisplayMode ?? (isMobile ? 'cards' : undefined); + + const handleToggleDisplayMode = () => { + const next: DisplayMode = displayMode === 'cards' ? 'table' : 'cards'; + setItem(DISPLAY_MODE_STORAGE_KEY, next); + setUserDisplayMode(next); + }; const handleSearch = (value: string) => { if (value !== search) { @@ -61,10 +87,21 @@ export function DataGrid({ const child = data ? (typeof children === 'function' ? children(data) : children) : null; + const viewToggleButton = ( + + + + {displayMode === 'cards' ? 'Switch to table view' : 'Switch to card view'} + + + ); + return ( - {allowSearch && ( - + + {allowSearch ? ( + ) : ( + + )} + {renderActions?.()} + {viewToggleButton} - )} + Date: Fri, 8 May 2026 13:16:06 -0400 Subject: [PATCH 2/2] guard the localStorage read and drop the empty span placeholder Address Greptile review on PR #4262. P2 (line 62): the value read back from localStorage was typed as any and trusted blindly, so a value written by an extension or a manual edit could end up as the chart's displayMode. Read it once, keep only 'table' or 'cards', otherwise fall back to null and let the existing useMobile-driven default decide. Self-correcting on the next click is no longer necessary because the next render is already clean. P2 (line 114): the empty was a flex placeholder that existed only to make justify-content: space-between push the toggle button to the right when allowSearch was false. Drop the span, drop space-between, and put marginLeft: auto on the inner Row holding the actions and the toggle. The toggle now hugs the right edge whether search is rendered or not, with no extra DOM node. style={{}} is used because the react-zen Row marginLeft prop only accepts spacing tokens, not auto. Verified in Playwright at 1400 viewport: the toggle is now flush with the right edge of the action row, the toggle still flips between table and cards on click, and a deliberately invalid localStorage value ({"malicious":true}) is rejected on reload so the page falls back to the table default. --- src/components/common/DataGrid.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/common/DataGrid.tsx b/src/components/common/DataGrid.tsx index 4ac8e72af..7d790c050 100644 --- a/src/components/common/DataGrid.tsx +++ b/src/components/common/DataGrid.tsx @@ -57,9 +57,13 @@ export function DataGrid({ const [search, setSearch] = useState(queryParams?.search || data?.search || ''); const showPager = allowPaging && data && data.count > data.pageSize; const { isMobile } = useMobile(); - const [userDisplayMode, setUserDisplayMode] = useState( - () => getItem(DISPLAY_MODE_STORAGE_KEY) ?? null, - ); + const [userDisplayMode, setUserDisplayMode] = useState(() => { + // localStorage can hold anything (extensions, manual edits, schema drift), + // so accept only the two values we know how to render and otherwise fall + // back to the useMobile-driven default. + const stored = getItem(DISPLAY_MODE_STORAGE_KEY); + return stored === 'table' || stored === 'cards' ? stored : null; + }); // Effective mode: explicit user choice wins, otherwise fall back to the // mobile-driven default (cards on small viewports, table elsewhere). @@ -100,8 +104,8 @@ export function DataGrid({ return ( - - {allowSearch ? ( + + {allowSearch && ( - ) : ( - )} - + {renderActions?.()} {viewToggleButton}