) {
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();