diff --git a/frontend/src/components/layout/sidebar.tsx b/frontend/src/components/layout/sidebar.tsx index b02853be50..f35e381355 100644 --- a/frontend/src/components/layout/sidebar.tsx +++ b/frontend/src/components/layout/sidebar.tsx @@ -29,7 +29,6 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarProvider, - SidebarRail, useSidebar, } from 'components/redpanda-ui/components/sidebar'; import { ChevronsLeft, ChevronsRight, ChevronUp, LogOut, Settings } from 'lucide-react'; @@ -268,8 +267,6 @@ export function AppSidebar() { - - ); } diff --git a/frontend/src/components/misc/login-complete.tsx b/frontend/src/components/misc/login-complete.tsx index 9cc52179fb..a8babbb52c 100644 --- a/frontend/src/components/misc/login-complete.tsx +++ b/frontend/src/components/misc/login-complete.tsx @@ -19,7 +19,6 @@ import type { ApiError, UserData } from '../../state/rest-interfaces'; import { uiState } from '../../state/ui-state'; import { getBasePath } from '../../utils/env'; import fetchWithTimeout from '../../utils/fetch-with-timeout'; -import { queryToObj } from '../../utils/query-helper'; class LoginCompletePage extends Component<{ provider: string }> { componentDidMount() { @@ -30,14 +29,16 @@ class LoginCompletePage extends Component<{ provider: string }> { async completeLogin(provider: string, location: Location) { const query = location.search; - const queryObj = queryToObj(query); - if (queryObj.error || queryObj.error_description) { + const searchParams = new URLSearchParams(query); + const error = searchParams.get('error'); + const errorDescription = searchParams.get('error_description'); + if (error || errorDescription) { let errorString = ''; - if (queryObj.error) { - errorString += `Error: ${queryObj.error}\n`; + if (error) { + errorString += `Error: ${error}\n`; } - if (queryObj.error_description) { - errorString += `Description: ${queryObj.error_description}\n`; + if (errorDescription) { + errorString += `Description: ${errorDescription}\n`; } uiState.loginError = errorString.trim(); appGlobal.historyReplace(`${getBasePath()}/login`); diff --git a/frontend/src/components/misc/login.tsx b/frontend/src/components/misc/login.tsx index ecb0f88dac..f779328285 100644 --- a/frontend/src/components/misc/login.tsx +++ b/frontend/src/components/misc/login.tsx @@ -101,8 +101,8 @@ const authenticationApi = observable({ const AUTH_ELEMENTS: Partial> = { [AuthenticationMethod.NONE]: observer(() => { - const { search } = useLocation(); - const searchParams = new URLSearchParams(search); + const { searchStr } = useLocation(); + const searchParams = new URLSearchParams(searchStr); // Don't auto-redirect if there was an auth error - user needs to see the login page const hasError = searchParams.has('error_code') || authenticationApi.methodsErrorResponse !== null; @@ -218,8 +218,8 @@ const AUTH_ELEMENTS: Partial> = { }; const LoginPage = observer(() => { - const { search } = useLocation(); - const searchParams = new URLSearchParams(search); + const { searchStr } = useLocation(); + const searchParams = new URLSearchParams(searchStr); useEffect(() => { authenticationApi.refreshAuthenticationMethods(); diff --git a/frontend/src/components/pages/connect/overview.tsx b/frontend/src/components/pages/connect/overview.tsx index 5942d183e5..741160056c 100644 --- a/frontend/src/components/pages/connect/overview.tsx +++ b/frontend/src/components/pages/connect/overview.tsx @@ -11,7 +11,6 @@ import { create } from '@bufbuild/protobuf'; import { Badge, Box, DataTable, Stack, Tooltip } from '@redpanda-data/ui'; -import { useLocation } from '@tanstack/react-router'; import ErrorResult from 'components/misc/error-result'; import { Link, Text } from 'components/redpanda-ui/components/typography'; import { WaitingRedpanda } from 'components/redpanda-ui/components/waiting-redpanda'; @@ -78,21 +77,20 @@ const getDefaultView = (defaultView: string): { initialTab: ConnectView; redpand } }; -const WrapKafkaConnectOverview: FunctionComponent<{ matchedPath: string }> = (props) => { - const { search } = useLocation(); - const searchParams = new URLSearchParams(search); - const defaultTab = searchParams.get('defaultTab') || ''; - +const WrapKafkaConnectOverview: FunctionComponent<{ + matchedPath: string; + defaultTab?: ConnectView; +}> = (props) => { const { data: kafkaConnectors, isLoading: isLoadingKafkaConnectors } = useKafkaConnectConnectorsQuery(); const isKafkaConnectEnabled = kafkaConnectors?.isConfigured === true; return ( ); }; diff --git a/frontend/src/components/pages/consumers/group-details.tsx b/frontend/src/components/pages/consumers/group-details.tsx index 0f0048013d..7e1c56d896 100644 --- a/frontend/src/components/pages/consumers/group-details.tsx +++ b/frontend/src/components/pages/consumers/group-details.tsx @@ -44,7 +44,6 @@ import { api } from '../../../state/backend-api'; import type { GroupDescription, GroupMemberDescription } from '../../../state/rest-interfaces'; import { Features } from '../../../state/supported-features'; import { uiSettings } from '../../../state/ui'; -import { editQuery, queryToObj } from '../../../utils/query-helper'; import { Button, DefaultSkeleton, IconButton, numberToThousandsString } from '../../../utils/tsx-utils'; import PageContent from '../../misc/page-content'; import { ShortNum } from '../../misc/short-num'; @@ -52,6 +51,11 @@ import { Statistic } from '../../misc/statistic'; import { PageComponent, type PageInitHelper, type PageProps } from '../page'; import AclList from '../topics/Tab.Acl/acl-list'; +type GroupSearchParams = { + q?: string; + withLag?: boolean; +}; + const DEFAULT_MATCH_ALL_REGEX = /.*/s; const QUICK_SEARCH_REGEX_CACHE = new Map(); @@ -71,8 +75,14 @@ function getQuickSearchRegex(pattern: string): RegExp { return regExp; } +type GroupDetailsProps = { + groupId: string; + search: GroupSearchParams; + onSearchChange: (updates: Partial) => void; +}; + @observer -class GroupDetails extends PageComponent<{ groupId: string }> { +class GroupDetails extends PageComponent { @observable edittingOffsets: GroupOffset[] | null = null; @observable editedTopic: string | null = null; @observable editedPartition: number | null = null; @@ -80,12 +90,14 @@ class GroupDetails extends PageComponent<{ groupId: string }> { @observable deletingMode: GroupDeletingMode = 'group'; @observable deletingOffsets: GroupOffset[] | null = null; - @observable quickSearch = queryToObj(window.location.search).q; - @observable showWithLagOnly = Boolean(queryToObj(window.location.search).withLag); + @observable quickSearch = ''; + @observable showWithLagOnly = false; - constructor(p: Readonly>) { + constructor(p: Readonly>) { super(p); makeObservable(this); + this.quickSearch = p.search?.q ?? ''; + this.showWithLagOnly = p.search?.withLag ?? false; } initPage(p: PageInitHelper): void { @@ -118,21 +130,16 @@ class GroupDetails extends PageComponent<{ groupId: string }> { placeholderText="Filter by member" searchText={this.quickSearch} setSearchText={(filterText) => { - editQuery((query) => { - this.quickSearch = filterText; - const q = String(filterText); - query.q = q; - }); + this.quickSearch = filterText; + this.props.onSearchChange({ q: filterText }); }} width={300} /> { - editQuery((query) => { - this.showWithLagOnly = e.target.checked; - query.withLag = this.showWithLagOnly ? 'true' : null; - }); + this.showWithLagOnly = e.target.checked; + this.props.onSearchChange({ withLag: e.target.checked }); }} > Only show topics with lag diff --git a/frontend/src/components/redpanda-ui/components/motion-highlight.tsx b/frontend/src/components/redpanda-ui/components/motion-highlight.tsx index b172c7bad4..05c923487f 100644 --- a/frontend/src/components/redpanda-ui/components/motion-highlight.tsx +++ b/frontend/src/components/redpanda-ui/components/motion-highlight.tsx @@ -102,7 +102,7 @@ function MotionHighlight(props: MotionHighlightProps) { defaultValue, onValueChange, className, - transition = { type: 'spring', stiffness: 350, damping: 35 }, + transition = { type: 'tween', ease: 'easeOut', duration: 0.2 }, hover = false, enabled = true, controlledItems, diff --git a/frontend/src/components/redpanda-ui/components/sidebar.tsx b/frontend/src/components/redpanda-ui/components/sidebar.tsx index a8c18ae27b..5c27c3a3f4 100644 --- a/frontend/src/components/redpanda-ui/components/sidebar.tsx +++ b/frontend/src/components/redpanda-ui/components/sidebar.tsx @@ -251,7 +251,7 @@ function Sidebar({ {/* This is what handles the sidebar gap on desktop */}
span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', + 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 [&:not([data-highlight])]:hover:bg-sidebar-accent [&:not([data-highlight])]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', size === 'sm' && 'text-xs', size === 'md' && 'text-sm', diff --git a/frontend/src/components/redpanda-ui/components/tooltip.tsx b/frontend/src/components/redpanda-ui/components/tooltip.tsx index a5fdbe07f0..b193a79fe3 100644 --- a/frontend/src/components/redpanda-ui/components/tooltip.tsx +++ b/frontend/src/components/redpanda-ui/components/tooltip.tsx @@ -81,7 +81,7 @@ function TooltipContent({ className, side = 'top', sideOffset = 4, - transition = { type: 'spring', stiffness: 300, damping: 25 }, + transition = { type: 'tween', ease: 'easeOut', duration: 0.15 }, arrow = true, children, testId, @@ -104,7 +104,7 @@ function TooltipContent({ exit={{ opacity: 0, scale: 0, ...initialPosition }} transition={transition} className={cn( - 'relative bg-primary text-primary-foreground shadow-md w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-sm text-balance', + 'relative bg-base-800 dark:bg-base-700 text-white shadow-md w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-sm text-balance', className, )} > @@ -113,7 +113,7 @@ function TooltipContent({ {arrow && ( )} diff --git a/frontend/src/components/redpanda-ui/style/theme.css b/frontend/src/components/redpanda-ui/style/theme.css index 11d13eff32..4adc83a793 100644 --- a/frontend/src/components/redpanda-ui/style/theme.css +++ b/frontend/src/components/redpanda-ui/style/theme.css @@ -76,7 +76,7 @@ --color-sidebar-accent: oklch(1 0 0 / 8%); --color-sidebar-accent-foreground: var(--color-base-100); --color-sidebar-border: var(--color-base-600); - --color-sidebar-ring: var(--color-primary-500); + --color-sidebar-ring: var(--color-secondary-400); --color-selection: oklch(0.8462 0.027 262.33); --color-selection-foreground: var(--color-base-800); --color-sidebar-selection: oklch(0.5547 0.087 264.14); @@ -127,7 +127,7 @@ --color-sidebar-accent: oklch(0 0 0 / 24%); --color-sidebar-accent-foreground: var(--color-base-100); --color-sidebar-border: var(--color-base-500); - --color-sidebar-ring: var(--color-primary-500); + --color-sidebar-ring: var(--color-secondary-400); --color-selection: oklch(0.8462 0.027 262.33); --color-selection-foreground: var(--color-base-200); --color-sidebar-selection: oklch(0.5547 0.087 264.14); diff --git a/frontend/src/hooks/use-pagination-params.test.tsx b/frontend/src/hooks/use-pagination-params.test.tsx index 27327ccd3f..182c11ba4f 100644 --- a/frontend/src/hooks/use-pagination-params.test.tsx +++ b/frontend/src/hooks/use-pagination-params.test.tsx @@ -1,40 +1,47 @@ import { renderHook } from '@testing-library/react'; -import { connectQueryWrapper } from 'test-utils'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import type { PropsWithChildren } from 'react'; import usePaginationParams from './use-pagination-params'; +const createWrapper = + (searchParams?: Record) => + ({ children }: PropsWithChildren) => {children}; + describe('usePaginationParams', () => { test('returns default values when URL parameters are absent', () => { - const { wrapper } = connectQueryWrapper(); - // Assuming a total data length of 100 for testing const totalDataLength = 100; - const { result } = renderHook(() => usePaginationParams(totalDataLength, 10), { wrapper }); + const { result } = renderHook(() => usePaginationParams(totalDataLength, 10), { + wrapper: createWrapper(), + }); expect(result.current.pageSize).toBe(10); expect(result.current.pageIndex).toBe(0); }); test('parses pageSize and pageIndex from URL parameters', () => { - const { wrapper } = connectQueryWrapper(); const totalDataLength = 100; - const { result } = renderHook(() => usePaginationParams(totalDataLength, 10), { wrapper }); - // Without setting initial URL, defaults apply - expect(result.current.pageSize).toBe(10); - expect(result.current.pageIndex).toBe(0); + const { result } = renderHook(() => usePaginationParams(totalDataLength, 10), { + wrapper: createWrapper({ pageSize: '20', page: '2' }), + }); + expect(result.current.pageSize).toBe(20); + expect(result.current.pageIndex).toBe(2); }); test('uses defaultPageSize when pageSize is not in URL', () => { - const { wrapper } = connectQueryWrapper(); const totalDataLength = 150; - const { result } = renderHook(() => usePaginationParams(totalDataLength, 15), { wrapper }); + const { result } = renderHook(() => usePaginationParams(totalDataLength, 15), { + wrapper: createWrapper(), + }); expect(result.current.pageSize).toBe(15); expect(result.current.pageIndex).toBe(0); }); - test('returns default pageIndex when URL page param would exceed total pages', () => { - const { wrapper } = connectQueryWrapper(); + test('returns bounded pageIndex when URL page param would exceed total pages', () => { const totalDataLength = 50; // Only 5 pages available with pageSize 10 - const { result } = renderHook(() => usePaginationParams(totalDataLength, 10), { wrapper }); + const { result } = renderHook(() => usePaginationParams(totalDataLength, 10), { + wrapper: createWrapper({ page: '10' }), // Page 10 exceeds available pages + }); expect(result.current.pageSize).toBe(10); - expect(result.current.pageIndex).toBe(0); + expect(result.current.pageIndex).toBe(4); // Bounded to max valid page (5 pages = index 0-4) }); }); diff --git a/frontend/src/hooks/use-pagination-params.ts b/frontend/src/hooks/use-pagination-params.ts index 462eb042ea..8295c33b3f 100644 --- a/frontend/src/hooks/use-pagination-params.ts +++ b/frontend/src/hooks/use-pagination-params.ts @@ -1,13 +1,14 @@ -import { useLocation } from '@tanstack/react-router'; import { DEFAULT_TABLE_PAGE_SIZE } from 'components/constants'; +import { parseAsInteger, useQueryState } from 'nuqs'; import { useMemo } from 'react'; /** * Custom hook for parsing pagination parameters from the URL search query. * - * This hook extracts 'pageSize' and 'pageIndex' parameters from the URL search query. + * This hook extracts 'pageSize' and 'pageIndex' parameters from the URL search query + * using nuqs for type-safe URL state management. * If these parameters are not present in the URL, it falls back to default values. - * 'pageSize' defaults to the value passed as an argument, or 10 if not provided. + * 'pageSize' defaults to the value passed as an argument, or DEFAULT_TABLE_PAGE_SIZE if not provided. * 'pageIndex' defaults to 0 if not present in the URL. * * @param {number} totalDataLength - The total length of the data to paginate over. @@ -22,22 +23,18 @@ const usePaginationParams = ( totalDataLength: number, defaultPageSize: number = DEFAULT_TABLE_PAGE_SIZE ): { pageSize: number; pageIndex: number } => { - const location = useLocation(); - const search = location.searchStr ?? ''; + const [pageSize] = useQueryState('pageSize', parseAsInteger.withDefault(defaultPageSize)); + const [pageIndex] = useQueryState('page', parseAsInteger.withDefault(0)); return useMemo(() => { - const searchParams = new URLSearchParams(search); - const pageSize = searchParams.has('pageSize') ? Number(searchParams.get('pageSize')) : defaultPageSize; - const pageIndex = searchParams.has('page') ? Number(searchParams.get('page')) : 0; const totalPages = Math.ceil(totalDataLength / pageSize); - const boundedPageIndex = Math.max(0, Math.min(pageIndex, totalPages - 1)); return { pageSize, pageIndex: boundedPageIndex, }; - }, [search, defaultPageSize, totalDataLength]); + }, [pageSize, pageIndex, totalDataLength]); }; export default usePaginationParams; diff --git a/frontend/src/routes/connect-clusters/$clusterName/$connector.tsx b/frontend/src/routes/connect-clusters/$clusterName/$connector.tsx index 9ce5bda36d..81ce0d67da 100644 --- a/frontend/src/routes/connect-clusters/$clusterName/$connector.tsx +++ b/frontend/src/routes/connect-clusters/$clusterName/$connector.tsx @@ -10,13 +10,22 @@ */ import { createFileRoute, useParams } from '@tanstack/react-router'; +import { fallback, zodValidator } from '@tanstack/zod-adapter'; +import { DEFAULT_TABLE_PAGE_SIZE } from 'components/constants'; +import { z } from 'zod'; import KafkaConnectorDetails from '../../../components/pages/connect/connector-details'; +const searchSchema = z.object({ + pageSize: fallback(z.number().int().positive().optional(), DEFAULT_TABLE_PAGE_SIZE), + page: fallback(z.number().int().nonnegative().optional(), 0), +}); + export const Route = createFileRoute('/connect-clusters/$clusterName/$connector')({ staticData: { title: 'Connector Details', }, + validateSearch: zodValidator(searchSchema), component: ConnectorDetailsWrapper, }); diff --git a/frontend/src/routes/connect-clusters/index.tsx b/frontend/src/routes/connect-clusters/index.tsx index 8830e5d224..5e806590fe 100644 --- a/frontend/src/routes/connect-clusters/index.tsx +++ b/frontend/src/routes/connect-clusters/index.tsx @@ -9,19 +9,31 @@ * by the Apache License, Version 2.0 */ -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { fallback, zodValidator } from '@tanstack/zod-adapter'; import { LinkIcon } from 'components/icons'; +import { z } from 'zod'; import KafkaConnectOverview from '../../components/pages/connect/overview'; +const connectViewValues = ['kafka-connect', 'redpanda-connect', 'redpanda-connect-secret'] as const; + +const searchSchema = z.object({ + defaultTab: fallback(z.enum(connectViewValues).optional(), undefined), +}); + +export type ConnectClustersSearchParams = z.infer; + export const Route = createFileRoute('/connect-clusters/')({ staticData: { title: 'Connect', icon: LinkIcon, }, + validateSearch: zodValidator(searchSchema), component: ConnectOverviewWrapper, }); function ConnectOverviewWrapper() { - return ; + const search = useSearch({ from: '/connect-clusters/' }); + return ; } diff --git a/frontend/src/routes/groups/$groupId.tsx b/frontend/src/routes/groups/$groupId.tsx index c0c9c7aeaa..57ce460e55 100644 --- a/frontend/src/routes/groups/$groupId.tsx +++ b/frontend/src/routes/groups/$groupId.tsx @@ -9,18 +9,55 @@ * by the Apache License, Version 2.0 */ -import { createFileRoute, useParams } from '@tanstack/react-router'; +import { createFileRoute, useNavigate, useParams, useSearch } from '@tanstack/react-router'; +import { fallback, zodValidator } from '@tanstack/zod-adapter'; +import { useCallback } from 'react'; +import { z } from 'zod'; import GroupDetails from '../../components/pages/consumers/group-details'; +const searchSchema = z.object({ + q: fallback(z.string().optional(), undefined), + withLag: fallback(z.coerce.boolean().optional(), false), +}); + +export type GroupSearchParams = z.infer; + export const Route = createFileRoute('/groups/$groupId')({ staticData: { title: 'Consumer Group Details', }, + validateSearch: zodValidator(searchSchema), component: GroupDetailsWrapper, }); function GroupDetailsWrapper() { const { groupId } = useParams({ from: '/groups/$groupId' }); - return ; + const search = useSearch({ from: '/groups/$groupId' }); + const navigate = useNavigate({ from: '/groups/$groupId' }); + + const onSearchChange = useCallback( + (updates: Partial) => { + navigate({ + search: (prev) => ({ + ...prev, + ...updates, + // Remove undefined/empty values from URL + q: updates.q === '' ? undefined : (updates.q ?? prev.q), + withLag: updates.withLag === false ? undefined : (updates.withLag ?? prev.withLag), + }), + replace: true, + }); + }, + [navigate] + ); + + return ( + + ); } diff --git a/frontend/src/routes/rp-connect/$pipelineId/index.tsx b/frontend/src/routes/rp-connect/$pipelineId/index.tsx index ccc71ea59b..bd56596b8c 100644 --- a/frontend/src/routes/rp-connect/$pipelineId/index.tsx +++ b/frontend/src/routes/rp-connect/$pipelineId/index.tsx @@ -10,13 +10,22 @@ */ import { createFileRoute, useParams } from '@tanstack/react-router'; +import { fallback, zodValidator } from '@tanstack/zod-adapter'; +import { DEFAULT_TABLE_PAGE_SIZE } from 'components/constants'; +import { z } from 'zod'; import RpConnectPipelinesDetails from '../../../components/pages/rp-connect/pipelines-details'; +const searchSchema = z.object({ + pageSize: fallback(z.number().int().positive().optional(), DEFAULT_TABLE_PAGE_SIZE), + page: fallback(z.number().int().nonnegative().optional(), 0), +}); + export const Route = createFileRoute('/rp-connect/$pipelineId/')({ staticData: { title: 'Pipeline Details', }, + validateSearch: zodValidator(searchSchema), component: PipelineDetailsWrapper, }); diff --git a/frontend/src/routes/topics/$topicName/index.tsx b/frontend/src/routes/topics/$topicName/index.tsx index 2d47e6af81..9763eabd3c 100644 --- a/frontend/src/routes/topics/$topicName/index.tsx +++ b/frontend/src/routes/topics/$topicName/index.tsx @@ -10,13 +10,22 @@ */ import { createFileRoute, useParams } from '@tanstack/react-router'; +import { fallback, zodValidator } from '@tanstack/zod-adapter'; +import { DEFAULT_TABLE_PAGE_SIZE } from 'components/constants'; +import { z } from 'zod'; import TopicDetails from '../../../components/pages/topics/topic-details'; +const searchSchema = z.object({ + pageSize: fallback(z.number().int().positive().optional(), DEFAULT_TABLE_PAGE_SIZE), + page: fallback(z.number().int().nonnegative().optional(), 0), +}); + export const Route = createFileRoute('/topics/$topicName/')({ staticData: { title: 'Topic Details', }, + validateSearch: zodValidator(searchSchema), component: TopicDetailsWrapper, }); diff --git a/frontend/src/routes/topics/index.tsx b/frontend/src/routes/topics/index.tsx index b430945cf3..d0d4785157 100644 --- a/frontend/src/routes/topics/index.tsx +++ b/frontend/src/routes/topics/index.tsx @@ -10,14 +10,23 @@ */ import { createFileRoute } from '@tanstack/react-router'; +import { fallback, zodValidator } from '@tanstack/zod-adapter'; +import { DEFAULT_TABLE_PAGE_SIZE } from 'components/constants'; import { CollectionIcon } from 'components/icons'; +import { z } from 'zod'; import TopicList from '../../components/pages/topics/topic-list'; +const searchSchema = z.object({ + pageSize: fallback(z.number().int().positive().optional(), DEFAULT_TABLE_PAGE_SIZE), + page: fallback(z.number().int().nonnegative().optional(), 0), +}); + export const Route = createFileRoute('/topics/')({ staticData: { title: 'Topics', icon: CollectionIcon, }, + validateSearch: zodValidator(searchSchema), component: TopicList, }); diff --git a/frontend/src/routes/transforms/$transformName.tsx b/frontend/src/routes/transforms/$transformName.tsx index 93692c47f9..76c45b6519 100644 --- a/frontend/src/routes/transforms/$transformName.tsx +++ b/frontend/src/routes/transforms/$transformName.tsx @@ -10,13 +10,22 @@ */ import { createFileRoute, useParams } from '@tanstack/react-router'; +import { fallback, zodValidator } from '@tanstack/zod-adapter'; +import { DEFAULT_TABLE_PAGE_SIZE } from 'components/constants'; +import { z } from 'zod'; import TransformDetails from '../../components/pages/transforms/transform-details'; +const searchSchema = z.object({ + pageSize: fallback(z.number().int().positive().optional(), DEFAULT_TABLE_PAGE_SIZE), + page: fallback(z.number().int().nonnegative().optional(), 0), +}); + export const Route = createFileRoute('/transforms/$transformName')({ staticData: { title: 'Transform Details', }, + validateSearch: zodValidator(searchSchema), component: TransformDetailsWrapper, }); diff --git a/frontend/src/utils/query-helper.ts b/frontend/src/utils/query-helper.ts index 2a9d0047b9..888760a80e 100644 --- a/frontend/src/utils/query-helper.ts +++ b/frontend/src/utils/query-helper.ts @@ -11,6 +11,19 @@ import { appGlobal } from '../state/app-global'; +/** + * Converts a URL search string to an object. + * + * @deprecated Use TanStack Router's `useSearch()` hook with route-level `validateSearch` instead. + * This provides type-safe search params with Zod validation. + * + * @example + * // Instead of: + * const params = queryToObj(window.location.search); + * + * // Use: + * const search = useSearch({ from: '/your-route' }); + */ export const queryToObj = (str: string) => { const query = new URLSearchParams(str); const obj = {} as Record; @@ -20,6 +33,20 @@ export const queryToObj = (str: string) => { return obj; }; + +/** + * Converts an object to a URL query string. + * + * @deprecated Use TanStack Router's `navigate({ search: ... })` instead. + * This provides type-safe navigation with search params. + * + * @example + * // Instead of: + * const queryStr = objToQuery({ page: 1, filter: 'active' }); + * + * // Use: + * navigate({ search: { page: 1, filter: 'active' } }); + */ export const objToQuery = (obj: { [key: string]: unknown }) => { // '?' + queryString.stringify(obj, stringifyOptions) const query = new URLSearchParams(); @@ -33,8 +60,25 @@ export const objToQuery = (obj: { [key: string]: unknown }) => { return `?${query.toString()}`; }; -// edit the current search query, -// IFF you make any changes inside editFunction, it returns the stringified version of the search query +/** + * Edit the current search query. + * + * @deprecated Use TanStack Router's `navigate({ search: (prev) => ... })` instead. + * This provides type-safe search param updates with proper React integration. + * + * @example + * // Instead of: + * editQuery((query) => { + * query.filter = 'active'; + * }); + * + * // Use: + * const navigate = useNavigate({ from: '/your-route' }); + * navigate({ + * search: (prev) => ({ ...prev, filter: 'active' }), + * replace: true, + * }); + */ export function editQuery(editFunction: (queryObject: Record) => void) { try { const location = appGlobal.historyLocation();