From dd97c421e230cfe5e75fc53a3e56318fdb378358 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 12 Feb 2026 17:54:55 +0545 Subject: [PATCH 01/10] refactor(hooks): enhance usePrefixedSearchParams with optional prefix and NavigateOptions - Make prefix optional: when undefined, passes through to raw useSearchParams - Add NavigateOptions support on setter (e.g. { replace: true }) - Add equality checking to avoid unnecessary URL navigations - Read from window.location.search for freshest URL state - Extract helper functions for filtering and comparing params --- src/hooks/usePrefixedSearchParams.ts | 156 ++++++++++++++++++++------- 1 file changed, 115 insertions(+), 41 deletions(-) diff --git a/src/hooks/usePrefixedSearchParams.ts b/src/hooks/usePrefixedSearchParams.ts index 6a46493b3..2fcd008d7 100644 --- a/src/hooks/usePrefixedSearchParams.ts +++ b/src/hooks/usePrefixedSearchParams.ts @@ -1,70 +1,144 @@ import { useCallback, useMemo } from "react"; -import { useSearchParams } from "react-router-dom"; +import { NavigateOptions, useSearchParams } from "react-router-dom"; -// Global parameter keys that don't require prefixing const GLOBAL_PARAM_KEYS = ["sortBy", "sortOrder"] as const; +type SetPrefixedSearchParams = ( + updater: (prev: URLSearchParams) => URLSearchParams, + options?: NavigateOptions +) => void; + +function filterPrefixedParams( + params: URLSearchParams, + prefix: string | undefined, + useGlobalParams: boolean +) { + if (!prefix) { + return new URLSearchParams(params); + } + + const filtered = new URLSearchParams(); + const prefixWithSeparator = `${prefix}__`; + + Array.from(params.entries()).forEach(([key, value]) => { + if (useGlobalParams && GLOBAL_PARAM_KEYS.includes(key as any)) { + filtered.set(key, value); + return; + } + + if (key.startsWith(prefixWithSeparator)) { + filtered.set(key.substring(prefixWithSeparator.length), value); + } + }); + + return filtered; +} + +function toComparableParamsString(params: URLSearchParams) { + return Array.from(params.entries()) + .sort(([aKey, aValue], [bKey, bValue]) => { + if (aKey === bKey) { + return aValue.localeCompare(bValue); + } + return aKey.localeCompare(bKey); + }) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ) + .join("&"); +} + +function areSearchParamsEqual(a: URLSearchParams, b: URLSearchParams) { + return toComparableParamsString(a) === toComparableParamsString(b); +} + /** - * Hook that manages URL search params with a specific prefix. - * Provides filtered params (without prefix) and a setter that adds the prefix. + * usePrefixedSearchParams * - * @param prefix - The prefix to use for this component's params (e.g., 'viewvar', 'view_namespace_name') + * Allows optionally namespacing URL search params with a prefix. When a prefix + * is supplied, only params with that prefix are exposed to the caller and any + * updates are written back under the same prefix. + * + * @param prefix - The prefix to use for this component's params (e.g., 'viewvar', 'view_namespace_name'). When undefined, passes through to raw useSearchParams behavior. * @param useGlobalParams - Whether to include global parameters (e.g., sortBy, sortOrder) in the filtered params. Defaults to true. */ export function usePrefixedSearchParams( - prefix: string, + prefix?: string, useGlobalParams: boolean = true -): [ - URLSearchParams, - (updater: (prev: URLSearchParams) => URLSearchParams) => void -] { +): [URLSearchParams, SetPrefixedSearchParams] { const [searchParams, setSearchParams] = useSearchParams(); const prefixedParams = useMemo(() => { - const filtered = new URLSearchParams(); - const prefixWithSeparator = `${prefix}__`; - - Array.from(searchParams.entries()).forEach(([key, value]) => { - if (GLOBAL_PARAM_KEYS.includes(key as any) && useGlobalParams) { - filtered.set(key, value); - } else if (key.startsWith(prefixWithSeparator)) { - const cleanKey = key.substring(prefixWithSeparator.length); - filtered.set(cleanKey, value); - } - }); - - return filtered; - }, [searchParams, prefix, useGlobalParams]); + return filterPrefixedParams(searchParams, prefix, useGlobalParams); + }, [prefix, searchParams, useGlobalParams]); - // Setter that adds prefix to keys when updating URL - const setPrefixedParams = useCallback( - (updater: (prev: URLSearchParams) => URLSearchParams) => { + const setPrefixedSearchParams = useCallback( + ( + updater: (prev: URLSearchParams) => URLSearchParams, + options?: NavigateOptions + ) => { setSearchParams((currentParams) => { - const newParams = new URLSearchParams(currentParams); + const baseParams = + typeof window !== "undefined" + ? new URLSearchParams(window.location.search) + : new URLSearchParams(currentParams); + + if (!prefix) { + const updated = updater(new URLSearchParams(baseParams)); + + if (areSearchParamsEqual(updated, baseParams)) { + return currentParams; + } + + return updated; + } + const prefixWithSeparator = `${prefix}__`; + const nextParams = new URLSearchParams(baseParams); - // Remove all existing params with our prefix - Array.from(currentParams.entries()).forEach(([key]) => { + Array.from(baseParams.entries()).forEach(([key]) => { if (key.startsWith(prefixWithSeparator)) { - newParams.delete(key); + nextParams.delete(key); } }); - // Get the updated params from the updater - const updatedParams = updater(prefixedParams); + if (useGlobalParams) { + GLOBAL_PARAM_KEYS.forEach((key) => { + nextParams.delete(key); + }); + } + + // Compute filtered params from the latest URL state + const currentFiltered = filterPrefixedParams( + baseParams, + prefix, + useGlobalParams + ); + const updatedFiltered = updater(currentFiltered); - // Add new params with prefix - Array.from(updatedParams.entries()).forEach(([key, value]) => { - if (value && value.trim() !== "") { - newParams.set(`${prefixWithSeparator}${key}`, value); + Array.from(updatedFiltered.entries()).forEach(([key, value]) => { + if (!value || value.trim() === "") { + return; } + if (useGlobalParams && GLOBAL_PARAM_KEYS.includes(key as any)) { + nextParams.set(key, value); + return; + } + + const prefixedKey = `${prefixWithSeparator}${key}`; + nextParams.set(prefixedKey, value); }); - return newParams; - }); + if (areSearchParamsEqual(nextParams, baseParams)) { + return baseParams; + } + + return nextParams; + }, options); }, - [setSearchParams, prefixedParams, prefix] + [prefix, setSearchParams, useGlobalParams] ); - return [prefixedParams, setPrefixedParams]; + return [prefixedParams, setPrefixedSearchParams]; } From 275e795250a2ae0268117a3bd3709609f83a39fb Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 12 Feb 2026 17:55:50 +0545 Subject: [PATCH 02/10] refactor: thread paramPrefix through config components and adopt immutable URL param updates - Add optional paramPrefix prop to FormikFilterForm, ConfigChangeTable, ConfigsTable, FilterByCellValue, TagsFilterCell, ConfigListTagsCell, MRTConfigListTagsCell, ConfigGroupByDropdown, and ConfigChangesDateRangeFilter - Convert mrtConfigListColumns and configChangesColumn from constants to functions accepting paramPrefix - Replace direct useSearchParams with usePrefixedSearchParams across all touched components and hooks - Switch from mutable params.set()/setParams(params) to immutable callback pattern: setParams(prev => { const next = new URLSearchParams(prev); ... return next }) - Add paramPrefix support to useAllConfigsQuery, useGetAllConfigsChangesQuery, useConfigChangesArbitraryFilters, and useTimeRangeParams No callers pass a prefix yet, so runtime behavior is unchanged. --- src/api/query-hooks/useAllConfigsQuery.ts | 24 +++-- src/api/query-hooks/useConfigChangesHooks.ts | 45 ++++---- .../Configs/Changes/ConfigChangeTable.tsx | 31 +++++- .../ConfigChangesDateRangeFIlter.tsx | 7 +- .../ConfigList/Cells/ConfigListTagsCell.tsx | 59 ++++++----- .../Cells/MRTConfigListTagsCell.tsx | 17 ++- .../ConfigList/ConfigsRelationshipsTable.tsx | 2 +- .../Configs/ConfigList/ConfigsTable.tsx | 17 +-- .../ConfigList/MRTConfigListColumn.tsx | 5 +- .../ConfigGroupByDropdown.tsx | 35 +++--- src/components/Forms/FormikFilterForm.tsx | 100 +++++++++++++----- .../useConfigChangesArbitraryFilters.tsx | 6 +- src/ui/DataTable/FilterByCellValue.tsx | 59 ++++++----- .../TimeRangePicker/useTimeRangeParams.tsx | 88 ++++++++++----- src/ui/Tags/TagsFilterCell.tsx | 63 ++++++----- 15 files changed, 358 insertions(+), 200 deletions(-) diff --git a/src/api/query-hooks/useAllConfigsQuery.ts b/src/api/query-hooks/useAllConfigsQuery.ts index a186cf3ee..74e309ccc 100644 --- a/src/api/query-hooks/useAllConfigsQuery.ts +++ b/src/api/query-hooks/useAllConfigsQuery.ts @@ -1,7 +1,7 @@ import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import { useSearchParams } from "react-router-dom"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { defaultStaleTime, prepareConfigListQuery } from "."; import { getAllConfigsMatchingQuery } from "../services/configs"; import { useShowDeletedConfigs } from "@flanksource-ui/store/preference.state"; @@ -9,23 +9,27 @@ import { useShowDeletedConfigs } from "@flanksource-ui/store/preference.state"; export const useAllConfigsQuery = ({ enabled = true, staleTime = defaultStaleTime, + paramPrefix, ...rest -}) => { - const [searchParams] = useSearchParams({ - sortBy: "type", - sortOrder: "asc", - groupBy: "type" - }); +}: { + enabled?: boolean; + staleTime?: number; + paramPrefix?: string; + [key: string]: any; +} = {}) => { + const [searchParams] = usePrefixedSearchParams(paramPrefix, false); const showDeletedConfigs = useShowDeletedConfigs(); const search = searchParams.get("search") ?? undefined; - const sortBy = searchParams.get("sortBy"); - const sortOrder = searchParams.get("sortOrder"); + const sortBy = searchParams.get("sortBy") ?? "type"; + const sortOrder = searchParams.get("sortOrder") ?? "asc"; const configType = searchParams.get("configType") ?? undefined; const labels = searchParams.get("labels") ?? undefined; const status = searchParams.get("status") ?? undefined; const health = searchParams.get("health") ?? undefined; - const { pageIndex, pageSize } = useReactTablePaginationState(); + const { pageIndex, pageSize } = useReactTablePaginationState({ + paramPrefix + }); const query = useMemo( () => diff --git a/src/api/query-hooks/useConfigChangesHooks.ts b/src/api/query-hooks/useConfigChangesHooks.ts index c3b9b2fbd..a8fb82c7b 100644 --- a/src/api/query-hooks/useConfigChangesHooks.ts +++ b/src/api/query-hooks/useConfigChangesHooks.ts @@ -6,15 +6,16 @@ import useReactTableSortState from "@flanksource-ui/ui/DataTable/Hooks/useReactT import useTimeRangeParams from "@flanksource-ui/ui/Dates/TimeRangePicker/useTimeRangeParams"; import { UseQueryOptions, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -import { useParams, useSearchParams } from "react-router-dom"; +import { useParams } from "react-router-dom"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { CatalogChangesSearchResponse, GetConfigsRelatedChangesParams, getConfigsChanges } from "../services/configs"; -function useConfigChangesTagsFilter() { - const [params] = useSearchParams(); +function useConfigChangesTagsFilter(paramPrefix?: string) { + const [params] = usePrefixedSearchParams(paramPrefix, false); const tags = useMemo(() => { const allTags = params.get("tags"); @@ -36,27 +37,34 @@ function useConfigChangesTagsFilter() { } export function useGetAllConfigsChangesQuery( - queryOptions: UseQueryOptions = { + { + paramPrefix, + ...queryOptions + }: UseQueryOptions & { + paramPrefix?: string; + } = { enabled: true, keepPreviousData: true } ) { const showChangesFromDeletedConfigs = useShowDeletedConfigs(); - const { timeRangeValue } = useTimeRangeParams(configChangesDefaultDateFilter); - const [params] = useSearchParams({ - sortBy: "created_at", - sortDirection: "desc" - }); + const { timeRangeValue } = useTimeRangeParams( + configChangesDefaultDateFilter, + paramPrefix + ); + const [params] = usePrefixedSearchParams(paramPrefix, false); const changeType = params.get("changeType") ?? undefined; const severity = params.get("severity") ?? undefined; const configType = params.get("configType") ?? undefined; const from = timeRangeValue?.from ?? undefined; const to = timeRangeValue?.to ?? undefined; - const [sortBy] = useReactTableSortState(); + const [sortBy] = useReactTableSortState({ paramPrefix }); const configTypes = params.get("configTypes") ?? "all"; - const { pageSize, pageIndex } = useReactTablePaginationState(); - const tags = useConfigChangesTagsFilter(); - const arbitraryFilter = useConfigChangesArbitraryFilters(); + const { pageSize, pageIndex } = useReactTablePaginationState({ + paramPrefix + }); + const tags = useConfigChangesTagsFilter(paramPrefix); + const arbitraryFilter = useConfigChangesArbitraryFilters(paramPrefix); const props = { include_deleted_configs: showChangesFromDeletedConfigs, @@ -90,12 +98,7 @@ export function useGetConfigChangesByIDQuery( const { id } = useParams(); const showChangesFromDeletedConfigs = useShowDeletedConfigs(); const { timeRangeValue } = useTimeRangeParams(configChangesDefaultDateFilter); - const [params] = useSearchParams({ - downstream: "true", - upstream: "false", - sortBy: "created_at", - sortDirection: "desc" - }); + const [params] = usePrefixedSearchParams(undefined, false); const change_type = params.get("changeType") ?? undefined; const severity = params.get("severity") ?? undefined; const from = timeRangeValue?.from ?? undefined; @@ -103,8 +106,8 @@ export function useGetConfigChangesByIDQuery( const configTypes = params.get("configTypes") ?? "all"; const { pageIndex, pageSize } = useReactTablePaginationState(); const [sortBy] = useReactTableSortState(); - const upstream = params.get("upstream") === "true"; - const downstream = params.get("downstream") === "true"; + const upstream = (params.get("upstream") ?? "false") === "true"; + const downstream = (params.get("downstream") ?? "true") === "true"; const all = upstream && downstream; const arbitraryFilter = useConfigChangesArbitraryFilters(); diff --git a/src/components/Configs/Changes/ConfigChangeTable.tsx b/src/components/Configs/Changes/ConfigChangeTable.tsx index 8841207cf..95965869e 100644 --- a/src/components/Configs/Changes/ConfigChangeTable.tsx +++ b/src/components/Configs/Changes/ConfigChangeTable.tsx @@ -7,7 +7,7 @@ import { ChangeIcon } from "@flanksource-ui/ui/Icons/ChangeIcon"; import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; import { CellContext } from "@tanstack/react-table"; import { MRT_ColumnDef } from "mantine-react-table"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import ConfigLink from "../ConfigLink/ConfigLink"; import MRTConfigListTagsCell from "../ConfigList/Cells/MRTConfigListTagsCell"; import { ConfigDetailChangeModal } from "./ConfigDetailsChanges/ConfigDetailsChanges"; @@ -36,7 +36,9 @@ export function ConfigChangeDateCell({ ); } -const configChangesColumn: MRT_ColumnDef[] = [ +const configChangesColumn = ( + paramPrefix?: string +): MRT_ColumnDef[] => [ { header: "Last Seen", id: "created_at", @@ -76,6 +78,7 @@ const configChangesColumn: MRT_ColumnDef[] = [ filterValue={configId} paramKey="id" paramsToReset={paramsToReset.configChanges} + paramPrefix={paramPrefix} > [] = [ filterValue={changeType} paramKey="changeType" paramsToReset={paramsToReset.configChanges} + paramPrefix={paramPrefix} >
@@ -124,6 +128,7 @@ const configChangesColumn: MRT_ColumnDef[] = [ filterValue={summary} paramKey="summary" paramsToReset={paramsToReset.configChanges} + paramPrefix={paramPrefix} > {summary} @@ -133,7 +138,13 @@ const configChangesColumn: MRT_ColumnDef[] = [ { header: "Tags", accessorKey: "tags", - Cell: (props) => , + Cell: (props) => ( + + ), size: 100 }, { @@ -148,6 +159,7 @@ const configChangesColumn: MRT_ColumnDef[] = [ filterValue={userID} paramKey="created_by" paramsToReset={paramsToReset.configChanges} + paramPrefix={paramPrefix} > @@ -161,6 +173,7 @@ const configChangesColumn: MRT_ColumnDef[] = [ filterValue={externalCreatedBy} paramKey="external_created_by" paramsToReset={paramsToReset.configChanges} + paramPrefix={paramPrefix} > {externalCreatedBy} @@ -174,6 +187,7 @@ const configChangesColumn: MRT_ColumnDef[] = [ filterValue={source} paramKey="source" paramsToReset={paramsToReset.configChanges} + paramPrefix={paramPrefix} > {source} @@ -190,17 +204,23 @@ type ConfigChangeTableProps = { isLoading?: boolean; totalRecords: number; numberOfPages: number; + paramPrefix?: string; }; export function ConfigChangeTable({ data, isLoading, totalRecords, - numberOfPages + numberOfPages, + paramPrefix }: ConfigChangeTableProps) { const [selectedConfigChange, setSelectedConfigChange] = useState(); const [modalIsOpen, setModalIsOpen] = useState(false); + const columns = useMemo( + () => configChangesColumn(paramPrefix), + [paramPrefix] + ); const { data: configChange, isLoading: changeLoading } = useGetConfigChangesById( @@ -214,7 +234,7 @@ export function ConfigChangeTable({ return ( <> {configChange && ( ; id: string } + T extends { tags?: Record; id: string } >({ row, getValue, hideGroupByView = false, enableFilterByTag = false, - filterByTagParamKey = "tags" + filterByTagParamKey = "tags", + paramPrefix }: ConfigListTagsCellProps): JSX.Element | null { - const [params, setParams] = useSearchParams(); + const [params, setParams] = usePrefixedSearchParams(paramPrefix, false); const tagMap = getValue() || {}; const tagKeys = Object.keys(tagMap) @@ -47,31 +52,35 @@ export default function ConfigListTagsCell< e.preventDefault(); e.stopPropagation(); - // Get the current tags from the URL - const currentTags = params.get("tags"); - const currentTagsArray = ( - currentTags ? currentTags.split(",") : [] - ).filter((value) => { - const tagKey = value.split("____")[0]; - const tagAction = value.split(":")[1] === "1" ? "include" : "exclude"; + setParams((currentParams) => { + const nextParams = new URLSearchParams(currentParams); - if (tagKey === tag.key && tagAction !== action) { - return false; - } - return true; - }); + // Get the current tags from the URL + const currentTags = nextParams.get(filterByTagParamKey); + const currentTagsArray = ( + currentTags ? currentTags.split(",") : [] + ).filter((value) => { + const tagKey = value.split("____")[0]; + const tagAction = value.split(":")[1] === "1" ? "include" : "exclude"; + + if (tagKey === tag.key && tagAction !== action) { + return false; + } + return true; + }); - // Append the new value, but for same tags, don't allow including and excluding at the same time - const updatedValue = currentTagsArray - .concat(`${tag.key}____${tag.value}:${action === "include" ? 1 : -1}`) - .filter((value, index, self) => self.indexOf(value) === index) - .join(","); + // Append the new value, but for same tags, don't allow including and excluding at the same time + const updatedValue = currentTagsArray + .concat(`${tag.key}____${tag.value}:${action === "include" ? 1 : -1}`) + .filter((value, index, self) => self.indexOf(value) === index) + .join(","); - // Update the URL - params.set(filterByTagParamKey, updatedValue); - setParams(params); + // Update the URL + nextParams.set(filterByTagParamKey, updatedValue); + return nextParams; + }); }, - [enableFilterByTag, filterByTagParamKey, params, setParams] + [enableFilterByTag, filterByTagParamKey, setParams] ); const groupByProp = decodeURIComponent(params.get("groupByProp") ?? ""); diff --git a/src/components/Configs/ConfigList/Cells/MRTConfigListTagsCell.tsx b/src/components/Configs/ConfigList/Cells/MRTConfigListTagsCell.tsx index 7c9210905..decdeb243 100644 --- a/src/components/Configs/ConfigList/Cells/MRTConfigListTagsCell.tsx +++ b/src/components/Configs/ConfigList/Cells/MRTConfigListTagsCell.tsx @@ -1,6 +1,6 @@ +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { MRTCellProps } from "@flanksource-ui/ui/MRTDataTable/MRTCellProps"; import TagsFilterCell from "@flanksource-ui/ui/Tags/TagsFilterCell"; -import { useSearchParams } from "react-router-dom"; import { ConfigItem } from "../../../../api/types/configs"; type MRTConfigListTagsCellProps< @@ -12,6 +12,10 @@ type MRTConfigListTagsCellProps< hideGroupByView?: boolean; enableFilterByTag?: boolean; filterByTagParamKey?: string; + /** + * Optional prefix to namespace the search params. + */ + paramPrefix?: string; }; export default function MRTConfigListTagsCell< @@ -20,9 +24,10 @@ export default function MRTConfigListTagsCell< cell, hideGroupByView = false, enableFilterByTag = false, - filterByTagParamKey = "tags" + filterByTagParamKey = "tags", + paramPrefix }: MRTConfigListTagsCellProps): JSX.Element | null { - const [params] = useSearchParams(); + const [params] = usePrefixedSearchParams(paramPrefix, false); const tagMap = cell.getValue() || {}; const tagKeys = Object.keys(tagMap) @@ -71,6 +76,10 @@ export default function MRTConfigListTagsCell< } return ( - + ); } diff --git a/src/components/Configs/ConfigList/ConfigsRelationshipsTable.tsx b/src/components/Configs/ConfigList/ConfigsRelationshipsTable.tsx index 7b1c3a856..e2aa631eb 100644 --- a/src/components/Configs/ConfigList/ConfigsRelationshipsTable.tsx +++ b/src/components/Configs/ConfigList/ConfigsRelationshipsTable.tsx @@ -40,7 +40,7 @@ export default function ConfigsRelationshipsTable({ ); const relationshipsColumns = useMemo(() => { - return mrtConfigListColumns.map((column) => { + return mrtConfigListColumns().map((column) => { if (column.accessorKey === "name") { return { ...column, diff --git a/src/components/Configs/ConfigList/ConfigsTable.tsx b/src/components/Configs/ConfigList/ConfigsTable.tsx index b32b4dc59..a4f214bd7 100644 --- a/src/components/Configs/ConfigList/ConfigsTable.tsx +++ b/src/components/Configs/ConfigList/ConfigsTable.tsx @@ -2,7 +2,8 @@ import { ConfigItem } from "@flanksource-ui/api/types/configs"; import MRTDataTable from "@flanksource-ui/ui/MRTDataTable/MRTDataTable"; import { MRT_ColumnDef } from "mantine-react-table"; import { useMemo, useCallback } from "react"; -import { useSearchParams, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { mrtConfigListColumns } from "./MRTConfigListColumn"; export interface Props { @@ -13,6 +14,7 @@ export interface Props { expandAllRows?: boolean; totalRecords?: number; pageCount?: number; + paramPrefix?: string; } export default function ConfigsTable({ @@ -22,12 +24,10 @@ export default function ConfigsTable({ groupBy, expandAllRows = false, totalRecords, - pageCount + pageCount, + paramPrefix }: Props) { - const [queryParams] = useSearchParams({ - sortBy: "type", - sortOrder: "asc" - }); + const [queryParams] = usePrefixedSearchParams(paramPrefix, false); const navigate = useNavigate(); const groupByUserInput = queryParams.get("groupBy") ?? undefined; @@ -88,8 +88,8 @@ export default function ConfigsTable({ }; } ); - return [...virtualColumn, ...mrtConfigListColumns]; - }, [groupByColumns]); + return [...virtualColumn, ...mrtConfigListColumns(paramPrefix)]; + }, [groupByColumns, paramPrefix]); const handleRowClick = useCallback( (row?: ConfigItem) => { @@ -115,6 +115,7 @@ export default function ConfigsTable({ manualPageCount={pageCount} enableGrouping onRowClick={handleRowClick} + urlParamPrefix={paramPrefix} /> ); } diff --git a/src/components/Configs/ConfigList/MRTConfigListColumn.tsx b/src/components/Configs/ConfigList/MRTConfigListColumn.tsx index 2049131eb..54e9ee6dd 100644 --- a/src/components/Configs/ConfigList/MRTConfigListColumn.tsx +++ b/src/components/Configs/ConfigList/MRTConfigListColumn.tsx @@ -15,7 +15,9 @@ import { MRTConfigListDateCell } from "./Cells/ConfigListDateCell"; import MRTConfigListTagsCell from "./Cells/MRTConfigListTagsCell"; import { Link } from "react-router-dom"; -export const mrtConfigListColumns: MRT_ColumnDef[] = [ +export const mrtConfigListColumns = ( + paramPrefix?: string +): MRT_ColumnDef[] => [ { header: "Name", accessorKey: "name", @@ -152,6 +154,7 @@ export const mrtConfigListColumns: MRT_ColumnDef[] = [ {...props} enableFilterByTag filterByTagParamKey="labels" + paramPrefix={paramPrefix} /> ), maxSize: 300, diff --git a/src/components/Configs/ConfigsListFilters/ConfigGroupByDropdown.tsx b/src/components/Configs/ConfigsListFilters/ConfigGroupByDropdown.tsx index c2ee7f674..7249a3b4a 100644 --- a/src/components/Configs/ConfigsListFilters/ConfigGroupByDropdown.tsx +++ b/src/components/Configs/ConfigsListFilters/ConfigGroupByDropdown.tsx @@ -7,7 +7,7 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo } from "react"; import { BiLabel, BiStats } from "react-icons/bi"; import { MdDifference } from "react-icons/md"; -import { useSearchParams } from "react-router-dom"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { MultiValue } from "react-select"; type ConfigGroupByDropdownProps = { @@ -15,6 +15,7 @@ type ConfigGroupByDropdownProps = { searchParamKey?: string; value?: string; paramsToReset?: string[]; + paramPrefix?: string; }; const items: GroupByOptions[] = [ @@ -48,9 +49,10 @@ const items: GroupByOptions[] = [ export default function ConfigGroupByDropdown({ searchParamKey = "groupBy", onChange = () => {}, - paramsToReset = [] + paramsToReset = [], + paramPrefix }: ConfigGroupByDropdownProps) { - const [params, setParams] = useSearchParams(); + const [params, setParams] = usePrefixedSearchParams(paramPrefix, false); const configType = params.get("configType") ?? undefined; @@ -109,19 +111,24 @@ export default function ConfigGroupByDropdown({ const groupByChange = useCallback( (value: MultiValue | undefined) => { - if (!value || value.length === 0) { - params.delete(searchParamKey); - } else { - const values = value - .map((v) => (v.isTag ? `${v.value}__tag` : v.value)) - .join(","); - params.set(searchParamKey, values); - } - paramsToReset.forEach((param) => params.delete(param)); - setParams(params); + setParams((currentParams) => { + const nextParams = new URLSearchParams(currentParams); + if (!value || value.length === 0) { + nextParams.delete(searchParamKey); + } else { + const values = value + .map((v) => (v.isTag ? `${v.value}__tag` : v.value)) + .join(","); + nextParams.set(searchParamKey, values); + } + paramsToReset.forEach((param) => { + nextParams.delete(param); + }); + return nextParams; + }); onChange(value?.map((v) => v.value)); }, - [onChange, params, paramsToReset, searchParamKey, setParams] + [onChange, paramsToReset, searchParamKey, setParams] ); const value = useMemo( diff --git a/src/components/Forms/FormikFilterForm.tsx b/src/components/Forms/FormikFilterForm.tsx index dd3dd9e0f..03971122c 100644 --- a/src/components/Forms/FormikFilterForm.tsx +++ b/src/components/Forms/FormikFilterForm.tsx @@ -1,46 +1,86 @@ import { Form, Formik, useFormikContext } from "formik"; -import { useEffect, useMemo } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useEffect, useMemo, useRef } from "react"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; + +function useStableStringArray(values: string[]) { + const signature = values.join("\u001f"); + const cacheRef = useRef<{ signature: string; values: string[] }>(); + + if (!cacheRef.current || cacheRef.current.signature !== signature) { + cacheRef.current = { signature, values }; + } + + return cacheRef.current.values; +} type FormikChangesListenerProps = { children: React.ReactNode; filterFields: string[]; paramsToReset?: string[]; - defaultFieldValues?: Record; + paramPrefix?: string; }; function FormikChangesListener({ children, filterFields, paramsToReset = [], - defaultFieldValues = {} + paramPrefix }: FormikChangesListenerProps) { const { values, setFieldValue } = useFormikContext>(); - const [searchParams, setSearchParams] = useSearchParams({ - ...defaultFieldValues - }); + const [searchParams, setSearchParams] = usePrefixedSearchParams( + paramPrefix, + false + ); + const valuesRef = useRef(values); useEffect(() => { - filterFields.forEach((field) => { - const value = values[field]; - if (value && value.toLowerCase() !== "all") { - searchParams.set(field, value); - } else { - searchParams.delete(field); + valuesRef.current = values; + }, [values]); + + // Sync form values to URL params + useEffect(() => { + setSearchParams((currentParams) => { + let changed = false; + const nextParams = new URLSearchParams(currentParams); + + filterFields.forEach((field) => { + const value = values[field]; + const currentValue = nextParams.get(field); + if (value && value.toLowerCase() !== "all") { + if (currentValue !== value) { + nextParams.set(field, value); + changed = true; + } + } else if (currentValue !== null) { + nextParams.delete(field); + changed = true; + } + }); + + paramsToReset.forEach((param) => { + if (nextParams.has(param)) { + nextParams.delete(param); + changed = true; + } + }); + + if (!changed) { + return currentParams; } + + return nextParams; }); - paramsToReset.forEach((param) => searchParams.delete(param)); - setSearchParams(searchParams); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [values, setFieldValue]); + }, [filterFields, paramsToReset, setSearchParams, values]); - // reset form values, if the query params change + // Sync URL params to form values useEffect(() => { filterFields.forEach((field) => { - const value = searchParams.get(field); - setFieldValue(field, value); - }, []); + const value = searchParams.get(field) ?? undefined; + if (valuesRef.current[field] !== value) { + setFieldValue(field, value, false); + } + }); }, [filterFields, searchParams, setFieldValue]); // eslint-disable-next-line react/jsx-no-useless-fragment @@ -57,6 +97,7 @@ type FilterFormProps = { * configType when available in the changes view */ defaultFieldValues?: Record; + paramPrefix?: string; }; /** @@ -71,13 +112,16 @@ export default function FormikFilterForm({ children, paramsToReset, filterFields, - defaultFieldValues + defaultFieldValues, + paramPrefix }: FilterFormProps) { - const [searchParams] = useSearchParams(); + const [searchParams] = usePrefixedSearchParams(paramPrefix, false); + const stableFilterFields = useStableStringArray(filterFields); + const stableParamsToReset = useStableStringArray(paramsToReset); const initialValues = useMemo( () => - filterFields.reduce( + stableFilterFields.reduce( (acc, field) => { const alternativeValue = defaultFieldValues?.[field] ?? undefined; acc[field] = searchParams.get(field) ?? alternativeValue ?? undefined; @@ -85,16 +129,16 @@ export default function FormikFilterForm({ }, {} as Record ), - [defaultFieldValues, filterFields, searchParams] + [defaultFieldValues, searchParams, stableFilterFields] ); return ( {}}> {({ handleSubmit }) => (
{children}
diff --git a/src/hooks/useConfigChangesArbitraryFilters.tsx b/src/hooks/useConfigChangesArbitraryFilters.tsx index aa0eabd2e..9cd8cd9e8 100644 --- a/src/hooks/useConfigChangesArbitraryFilters.tsx +++ b/src/hooks/useConfigChangesArbitraryFilters.tsx @@ -1,8 +1,8 @@ import { useMemo } from "react"; -import { useSearchParams } from "react-router-dom"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; -export function useConfigChangesArbitraryFilters() { - const [params] = useSearchParams(); +export function useConfigChangesArbitraryFilters(paramPrefix?: string) { + const [params] = usePrefixedSearchParams(paramPrefix, false); const configId = params.get("id") ?? undefined; const changeSummary = params.get("summary") ?? undefined; diff --git a/src/ui/DataTable/FilterByCellValue.tsx b/src/ui/DataTable/FilterByCellValue.tsx index 94c42f92b..b860f52c4 100644 --- a/src/ui/DataTable/FilterByCellValue.tsx +++ b/src/ui/DataTable/FilterByCellValue.tsx @@ -3,7 +3,7 @@ import { PiMagnifyingGlassMinusThin, PiMagnifyingGlassPlusThin } from "react-icons/pi"; -import { useSearchParams } from "react-router-dom"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { IconButton } from "../Buttons/IconButton"; type FilterByCellProps = { @@ -11,44 +11,51 @@ type FilterByCellProps = { children: ReactNode; filterValue: string; paramsToReset?: string[]; + paramPrefix?: string; }; export function FilterByCellValue({ paramKey, children, filterValue, - paramsToReset = [] + paramsToReset = [], + paramPrefix }: FilterByCellProps) { - const [params, setParams] = useSearchParams(); + const [, setParams] = usePrefixedSearchParams(paramPrefix, false); const onClick = useCallback( (e: React.MouseEvent, action: "include" | "exclude") => { e.preventDefault(); e.stopPropagation(); - const currentValue = params.get(paramKey); - const arrayValue = currentValue?.split(",") || []; - // if include, we need to remove all exclude values and - // if exclude, we need to remove all include values - const newValues = arrayValue.filter( - (value) => - (action === "include" && parseInt(value.split(":")[1]) === 1) || - (action === "exclude" && parseInt(value.split(":")[1]) === -1) - ); - // append the new value - const updateValue = newValues - .concat( - `${filterValue.replaceAll(",", "||||").replaceAll(":", "____")}:${ - action === "include" ? 1 : -1 - }` - ) - // remove duplicates - .filter((value, index, self) => self.indexOf(value) === index) - .join(","); - params.set(paramKey, updateValue); - paramsToReset.forEach((param) => params.delete(param)); - setParams(params); + setParams((currentParams) => { + const nextParams = new URLSearchParams(currentParams); + const currentValue = nextParams.get(paramKey); + const arrayValue = currentValue?.split(",") || []; + // if include, we need to remove all exclude values and + // if exclude, we need to remove all include values + const newValues = arrayValue.filter( + (value) => + (action === "include" && parseInt(value.split(":")[1]) === 1) || + (action === "exclude" && parseInt(value.split(":")[1]) === -1) + ); + // append the new value + const updateValue = newValues + .concat( + `${filterValue.replaceAll(",", "||||").replaceAll(":", "____")}:${ + action === "include" ? 1 : -1 + }` + ) + // remove duplicates + .filter((value, index, self) => self.indexOf(value) === index) + .join(","); + nextParams.set(paramKey, updateValue); + paramsToReset.forEach((param) => { + nextParams.delete(param); + }); + return nextParams; + }); }, - [filterValue, paramKey, params, paramsToReset, setParams] + [filterValue, paramKey, paramsToReset, setParams] ); return ( diff --git a/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx b/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx index 61e730a08..096f827c0 100644 --- a/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx +++ b/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx @@ -1,6 +1,7 @@ import dayjs from "dayjs"; -import { useCallback, useMemo } from "react"; -import { URLSearchParamsInit, useSearchParams } from "react-router-dom"; +import { useCallback, useEffect, useMemo } from "react"; +import { URLSearchParamsInit } from "react-router-dom"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { parseDateMath } from "./parseDateMath"; import { MappedOptionsDisplay, @@ -18,35 +19,72 @@ import { * react-router-dom to manage the URL parameters. * */ -export default function useTimeRangeParams(defaults?: URLSearchParamsInit) { - const [params, setParams] = useSearchParams(defaults); +export default function useTimeRangeParams( + defaults?: URLSearchParamsInit, + paramPrefix?: string +) { + const [params, setParams] = usePrefixedSearchParams(paramPrefix, false); + + useEffect(() => { + if (!defaults) { + return; + } + + let changed = false; + const nextParams = new URLSearchParams(params); + + const defaultsParams = new URLSearchParams( + typeof defaults === "string" || defaults instanceof URLSearchParams + ? defaults + : Object.entries(defaults).flatMap(([key, value]) => + Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]] + ) + ); + defaultsParams.forEach((value, key) => { + if (!nextParams.has(key) && value != null) { + nextParams.set(key, String(value)); + changed = true; + } + }); + + if (!changed) { + return; + } + + setParams(() => nextParams); + }, [defaults, params, setParams]); const setTimeRangeParams = useCallback( (range: TimeRangeOption, paramsToReset: string[] = []) => { - params.set("rangeType", range.type); - params.set("display", range.display); + setParams((currentParams) => { + const nextParams = new URLSearchParams(currentParams); + nextParams.set("rangeType", range.type); + nextParams.set("display", range.display); - // remove the old time range parameters - params.delete("from"); - params.delete("to"); - params.delete("duration"); - params.delete("timeRange"); + // remove the old time range parameters + nextParams.delete("from"); + nextParams.delete("to"); + nextParams.delete("duration"); + nextParams.delete("timeRange"); - // set the new time range parameters - if (range.type === "absolute") { - params.set("from", range.from); - params.set("to", range.to); - } - if (range.type === "relative") { - params.set("range", range.range.toString()); - } - if (range.type === "mapped") { - params.set("timeRange", range.display); - } - paramsToReset.forEach((param) => params.delete(param)); - setParams(params); + // set the new time range parameters + if (range.type === "absolute") { + nextParams.set("from", range.from); + nextParams.set("to", range.to); + } + if (range.type === "relative") { + nextParams.set("range", range.range.toString()); + } + if (range.type === "mapped") { + nextParams.set("timeRange", range.display); + } + paramsToReset.forEach((param) => { + nextParams.delete(param); + }); + return nextParams; + }); }, - [params, setParams] + [setParams] ); const getTimeRangeFromUrl: () => TimeRangeOption | undefined = diff --git a/src/ui/Tags/TagsFilterCell.tsx b/src/ui/Tags/TagsFilterCell.tsx index 8fd846660..d6857e2f6 100644 --- a/src/ui/Tags/TagsFilterCell.tsx +++ b/src/ui/Tags/TagsFilterCell.tsx @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { useSearchParams } from "react-router-dom"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { fromBase64, toBase64 } from "../../utils/common"; import { Tag } from "./Tag"; @@ -11,14 +11,19 @@ type TagsFilterCellProps = { * issues with special characters like ':' and ','. */ useBase64Encoding?: boolean; + /** + * Optional prefix to namespace the search params. + */ + paramPrefix?: string; }; export default function TagsFilterCell({ tags, filterByTagParamKey = "labels", - useBase64Encoding = false + useBase64Encoding = false, + paramPrefix }: TagsFilterCellProps) { - const [params, setParams] = useSearchParams(); + const [params, setParams] = usePrefixedSearchParams(paramPrefix, false); const tagEntries = Object.entries(tags).filter(([key]) => key !== "toString"); @@ -34,34 +39,38 @@ export default function TagsFilterCell({ e.preventDefault(); e.stopPropagation(); - // Get the current tags from the URL - const currentTags = params.get(filterByTagParamKey); - const currentTagsArray = ( - currentTags ? currentTags.split(",") : [] - ).filter((value) => { - const rawTagKey = value.split("____")[0]; - const tagKey = useBase64Encoding ? fromBase64(rawTagKey) : rawTagKey; - const tagAction = value.split(":")[1] === "1" ? "include" : "exclude"; + setParams((currentParams) => { + const nextParams = new URLSearchParams(currentParams); - if (tagKey === tag.key && tagAction !== action) { - return false; - } - return true; - }); + // Get the current tags from the URL + const currentTags = nextParams.get(filterByTagParamKey); + const currentTagsArray = ( + currentTags ? currentTags.split(",") : [] + ).filter((value) => { + const rawTagKey = value.split("____")[0]; + const tagKey = useBase64Encoding ? fromBase64(rawTagKey) : rawTagKey; + const tagAction = value.split(":")[1] === "1" ? "include" : "exclude"; - // Append the new value, but for same tags, don't allow including and excluding at the same time - const keyPart = useBase64Encoding ? toBase64(tag.key) : tag.key; - const valuePart = useBase64Encoding ? toBase64(tag.value) : tag.value; - const updatedValue = currentTagsArray - .concat(`${keyPart}____${valuePart}:${action === "include" ? 1 : -1}`) - .filter((value, index, self) => self.indexOf(value) === index) - .join(","); + if (tagKey === tag.key && tagAction !== action) { + return false; + } + return true; + }); - // Update the URL - params.set(filterByTagParamKey, updatedValue); - setParams(params); + // Append the new value, but for same tags, don't allow including and excluding at the same time + const keyPart = useBase64Encoding ? toBase64(tag.key) : tag.key; + const valuePart = useBase64Encoding ? toBase64(tag.value) : tag.value; + const updatedValue = currentTagsArray + .concat(`${keyPart}____${valuePart}:${action === "include" ? 1 : -1}`) + .filter((value, index, self) => self.indexOf(value) === index) + .join(","); + + // Update the URL + nextParams.set(filterByTagParamKey, updatedValue); + return nextParams; + }); }, - [filterByTagParamKey, params, setParams, useBase64Encoding] + [filterByTagParamKey, setParams, useBase64Encoding] ); if (tagEntries.length === 0) { From a088b8aad3f90a8f8058a6a1eb7d7f3c41efe731 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 12 Feb 2026 18:48:29 +0545 Subject: [PATCH 03/10] fix(filters): support prefixed defaults in form-url sync --- src/components/Forms/FormikFilterForm.tsx | 11 ++-- src/hooks/usePrefixedSearchParams.ts | 63 +++++++++++++++++++++-- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/components/Forms/FormikFilterForm.tsx b/src/components/Forms/FormikFilterForm.tsx index 03971122c..695822d98 100644 --- a/src/components/Forms/FormikFilterForm.tsx +++ b/src/components/Forms/FormikFilterForm.tsx @@ -17,6 +17,7 @@ type FormikChangesListenerProps = { children: React.ReactNode; filterFields: string[]; paramsToReset?: string[]; + defaultFieldValues?: Record; paramPrefix?: string; }; @@ -24,13 +25,15 @@ function FormikChangesListener({ children, filterFields, paramsToReset = [], + defaultFieldValues = {}, paramPrefix }: FormikChangesListenerProps) { const { values, setFieldValue } = useFormikContext>(); const [searchParams, setSearchParams] = usePrefixedSearchParams( paramPrefix, - false + false, + defaultFieldValues ); const valuesRef = useRef(values); @@ -76,12 +79,13 @@ function FormikChangesListener({ // Sync URL params to form values useEffect(() => { filterFields.forEach((field) => { - const value = searchParams.get(field) ?? undefined; + const value = + searchParams.get(field) ?? defaultFieldValues[field] ?? undefined; if (valuesRef.current[field] !== value) { setFieldValue(field, value, false); } }); - }, [filterFields, searchParams, setFieldValue]); + }, [defaultFieldValues, filterFields, searchParams, setFieldValue]); // eslint-disable-next-line react/jsx-no-useless-fragment return <>{children}; @@ -138,6 +142,7 @@ export default function FormikFilterForm({
{children}
diff --git a/src/hooks/usePrefixedSearchParams.ts b/src/hooks/usePrefixedSearchParams.ts index 2fcd008d7..f2ef6c413 100644 --- a/src/hooks/usePrefixedSearchParams.ts +++ b/src/hooks/usePrefixedSearchParams.ts @@ -1,5 +1,9 @@ import { useCallback, useMemo } from "react"; -import { NavigateOptions, useSearchParams } from "react-router-dom"; +import { + NavigateOptions, + URLSearchParamsInit, + useSearchParams +} from "react-router-dom"; const GLOBAL_PARAM_KEYS = ["sortBy", "sortOrder"] as const; @@ -8,6 +12,52 @@ type SetPrefixedSearchParams = ( options?: NavigateOptions ) => void; +function toURLSearchParams(init?: URLSearchParamsInit) { + if (!init) { + return new URLSearchParams(); + } + + if (typeof init === "string" || init instanceof URLSearchParams) { + return new URLSearchParams(init); + } + + return new URLSearchParams( + Object.entries(init).flatMap(([key, value]) => + Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]] + ) + ); +} + +function buildDefaultSearchParams( + prefix: string | undefined, + useGlobalParams: boolean, + defaults?: URLSearchParamsInit +) { + if (!defaults) { + return undefined; + } + + const defaultsParams = toURLSearchParams(defaults); + + if (!prefix) { + return defaultsParams; + } + + const prefixedDefaults = new URLSearchParams(); + const prefixWithSeparator = `${prefix}__`; + + Array.from(defaultsParams.entries()).forEach(([key, value]) => { + if (useGlobalParams && GLOBAL_PARAM_KEYS.includes(key as any)) { + prefixedDefaults.append(key, value); + return; + } + + prefixedDefaults.append(`${prefixWithSeparator}${key}`, value); + }); + + return prefixedDefaults; +} + function filterPrefixedParams( params: URLSearchParams, prefix: string | undefined, @@ -62,12 +112,19 @@ function areSearchParamsEqual(a: URLSearchParams, b: URLSearchParams) { * * @param prefix - The prefix to use for this component's params (e.g., 'viewvar', 'view_namespace_name'). When undefined, passes through to raw useSearchParams behavior. * @param useGlobalParams - Whether to include global parameters (e.g., sortBy, sortOrder) in the filtered params. Defaults to true. + * @param defaults - Optional default values (unprefixed). These are exposed as fallback values when missing from the URL. */ export function usePrefixedSearchParams( prefix?: string, - useGlobalParams: boolean = true + useGlobalParams: boolean = true, + defaults?: URLSearchParamsInit ): [URLSearchParams, SetPrefixedSearchParams] { - const [searchParams, setSearchParams] = useSearchParams(); + const defaultSearchParams = useMemo( + () => buildDefaultSearchParams(prefix, useGlobalParams, defaults), + [defaults, prefix, useGlobalParams] + ); + + const [searchParams, setSearchParams] = useSearchParams(defaultSearchParams); const prefixedParams = useMemo(() => { return filterPrefixedParams(searchParams, prefix, useGlobalParams); From d6330901d1bb6d4e79efe41386bbe61228c66c0a Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 12 Feb 2026 19:02:04 +0545 Subject: [PATCH 04/10] fix(hooks): avoid clearing global params in prefixed updates --- src/hooks/usePrefixedSearchParams.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/hooks/usePrefixedSearchParams.ts b/src/hooks/usePrefixedSearchParams.ts index f2ef6c413..185791958 100644 --- a/src/hooks/usePrefixedSearchParams.ts +++ b/src/hooks/usePrefixedSearchParams.ts @@ -160,12 +160,6 @@ export function usePrefixedSearchParams( } }); - if (useGlobalParams) { - GLOBAL_PARAM_KEYS.forEach((key) => { - nextParams.delete(key); - }); - } - // Compute filtered params from the latest URL state const currentFiltered = filterPrefixedParams( baseParams, From f8da0de2072f670ad2a687e43f582ff58b563ff3 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 12 Feb 2026 19:06:10 +0545 Subject: [PATCH 05/10] fix(dates): clear stale range param when switching type --- src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx b/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx index 096f827c0..152455f3c 100644 --- a/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx +++ b/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx @@ -66,6 +66,7 @@ export default function useTimeRangeParams( nextParams.delete("to"); nextParams.delete("duration"); nextParams.delete("timeRange"); + nextParams.delete("range"); // set the new time range parameters if (range.type === "absolute") { From 20e3c84f84ddec9c81193e39b09978f69946849f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 12 Feb 2026 19:23:20 +0545 Subject: [PATCH 06/10] fix(time-range): avoid writing default params to URL --- .../TimeRangePicker/useTimeRangeParams.tsx | 37 +++---------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx b/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx index 152455f3c..b35077216 100644 --- a/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx +++ b/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx @@ -1,5 +1,5 @@ import dayjs from "dayjs"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { URLSearchParamsInit } from "react-router-dom"; import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { parseDateMath } from "./parseDateMath"; @@ -23,36 +23,11 @@ export default function useTimeRangeParams( defaults?: URLSearchParamsInit, paramPrefix?: string ) { - const [params, setParams] = usePrefixedSearchParams(paramPrefix, false); - - useEffect(() => { - if (!defaults) { - return; - } - - let changed = false; - const nextParams = new URLSearchParams(params); - - const defaultsParams = new URLSearchParams( - typeof defaults === "string" || defaults instanceof URLSearchParams - ? defaults - : Object.entries(defaults).flatMap(([key, value]) => - Array.isArray(value) ? value.map((v) => [key, v]) : [[key, value]] - ) - ); - defaultsParams.forEach((value, key) => { - if (!nextParams.has(key) && value != null) { - nextParams.set(key, String(value)); - changed = true; - } - }); - - if (!changed) { - return; - } - - setParams(() => nextParams); - }, [defaults, params, setParams]); + const [params, setParams] = usePrefixedSearchParams( + paramPrefix, + false, + defaults + ); const setTimeRangeParams = useCallback( (range: TimeRangeOption, paramsToReset: string[] = []) => { From 15ee0050a5e7b2186fd0ea86fd5f654cb5d28009 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 12 Feb 2026 19:24:34 +0545 Subject: [PATCH 07/10] refactor(search-params): restore default init usage in query hooks --- src/api/query-hooks/useAllConfigsQuery.ts | 10 +++++++--- src/api/query-hooks/useConfigChangesHooks.ts | 16 ++++++++++++---- src/ui/Tags/TagsFilterCell.tsx | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/api/query-hooks/useAllConfigsQuery.ts b/src/api/query-hooks/useAllConfigsQuery.ts index 74e309ccc..2d8c72d51 100644 --- a/src/api/query-hooks/useAllConfigsQuery.ts +++ b/src/api/query-hooks/useAllConfigsQuery.ts @@ -17,12 +17,16 @@ export const useAllConfigsQuery = ({ paramPrefix?: string; [key: string]: any; } = {}) => { - const [searchParams] = usePrefixedSearchParams(paramPrefix, false); + const [searchParams] = usePrefixedSearchParams(paramPrefix, false, { + sortBy: "type", + sortOrder: "asc", + groupBy: "type" + }); const showDeletedConfigs = useShowDeletedConfigs(); const search = searchParams.get("search") ?? undefined; - const sortBy = searchParams.get("sortBy") ?? "type"; - const sortOrder = searchParams.get("sortOrder") ?? "asc"; + const sortBy = searchParams.get("sortBy"); + const sortOrder = searchParams.get("sortOrder"); const configType = searchParams.get("configType") ?? undefined; const labels = searchParams.get("labels") ?? undefined; const status = searchParams.get("status") ?? undefined; diff --git a/src/api/query-hooks/useConfigChangesHooks.ts b/src/api/query-hooks/useConfigChangesHooks.ts index a8fb82c7b..65e6586da 100644 --- a/src/api/query-hooks/useConfigChangesHooks.ts +++ b/src/api/query-hooks/useConfigChangesHooks.ts @@ -52,7 +52,10 @@ export function useGetAllConfigsChangesQuery( configChangesDefaultDateFilter, paramPrefix ); - const [params] = usePrefixedSearchParams(paramPrefix, false); + const [params] = usePrefixedSearchParams(paramPrefix, false, { + sortBy: "created_at", + sortDirection: "desc" + }); const changeType = params.get("changeType") ?? undefined; const severity = params.get("severity") ?? undefined; const configType = params.get("configType") ?? undefined; @@ -98,7 +101,12 @@ export function useGetConfigChangesByIDQuery( const { id } = useParams(); const showChangesFromDeletedConfigs = useShowDeletedConfigs(); const { timeRangeValue } = useTimeRangeParams(configChangesDefaultDateFilter); - const [params] = usePrefixedSearchParams(undefined, false); + const [params] = usePrefixedSearchParams(undefined, false, { + downstream: "true", + upstream: "false", + sortBy: "created_at", + sortDirection: "desc" + }); const change_type = params.get("changeType") ?? undefined; const severity = params.get("severity") ?? undefined; const from = timeRangeValue?.from ?? undefined; @@ -106,8 +114,8 @@ export function useGetConfigChangesByIDQuery( const configTypes = params.get("configTypes") ?? "all"; const { pageIndex, pageSize } = useReactTablePaginationState(); const [sortBy] = useReactTableSortState(); - const upstream = (params.get("upstream") ?? "false") === "true"; - const downstream = (params.get("downstream") ?? "true") === "true"; + const upstream = params.get("upstream") === "true"; + const downstream = params.get("downstream") === "true"; const all = upstream && downstream; const arbitraryFilter = useConfigChangesArbitraryFilters(); diff --git a/src/ui/Tags/TagsFilterCell.tsx b/src/ui/Tags/TagsFilterCell.tsx index d6857e2f6..0b532bc44 100644 --- a/src/ui/Tags/TagsFilterCell.tsx +++ b/src/ui/Tags/TagsFilterCell.tsx @@ -23,7 +23,7 @@ export default function TagsFilterCell({ useBase64Encoding = false, paramPrefix }: TagsFilterCellProps) { - const [params, setParams] = usePrefixedSearchParams(paramPrefix, false); + const [, setParams] = usePrefixedSearchParams(paramPrefix, false); const tagEntries = Object.entries(tags).filter(([key]) => key !== "toString"); From 129a3d829e5cf8e2ae06d662d23d99ad668c2ca9 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 13 Feb 2026 02:15:34 +0545 Subject: [PATCH 08/10] fix: use stabe Record for default field values --- src/components/Forms/FormikFilterForm.tsx | 39 +++++++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/components/Forms/FormikFilterForm.tsx b/src/components/Forms/FormikFilterForm.tsx index 695822d98..45efa82e0 100644 --- a/src/components/Forms/FormikFilterForm.tsx +++ b/src/components/Forms/FormikFilterForm.tsx @@ -2,6 +2,29 @@ import { Form, Formik, useFormikContext } from "formik"; import { useEffect, useMemo, useRef } from "react"; import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; +const EMPTY_RECORD: Record = {}; + +function useStableRecord( + record: Record | undefined +): Record { + const signature = record + ? Object.entries(record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${k}\u001f${v}`) + .join("\u001e") + : ""; + const cacheRef = useRef<{ + signature: string; + record: Record; + }>(); + + if (!cacheRef.current || cacheRef.current.signature !== signature) { + cacheRef.current = { signature, record: record ?? EMPTY_RECORD }; + } + + return cacheRef.current.record; +} + function useStableStringArray(values: string[]) { const signature = values.join("\u001f"); const cacheRef = useRef<{ signature: string; values: string[] }>(); @@ -25,15 +48,16 @@ function FormikChangesListener({ children, filterFields, paramsToReset = [], - defaultFieldValues = {}, + defaultFieldValues, paramPrefix }: FormikChangesListenerProps) { + const stableDefaults = useStableRecord(defaultFieldValues); const { values, setFieldValue } = useFormikContext>(); const [searchParams, setSearchParams] = usePrefixedSearchParams( paramPrefix, false, - defaultFieldValues + stableDefaults ); const valuesRef = useRef(values); @@ -80,12 +104,12 @@ function FormikChangesListener({ useEffect(() => { filterFields.forEach((field) => { const value = - searchParams.get(field) ?? defaultFieldValues[field] ?? undefined; + searchParams.get(field) ?? stableDefaults[field] ?? undefined; if (valuesRef.current[field] !== value) { setFieldValue(field, value, false); } }); - }, [defaultFieldValues, filterFields, searchParams, setFieldValue]); + }, [stableDefaults, filterFields, searchParams, setFieldValue]); // eslint-disable-next-line react/jsx-no-useless-fragment return <>{children}; @@ -122,18 +146,19 @@ export default function FormikFilterForm({ const [searchParams] = usePrefixedSearchParams(paramPrefix, false); const stableFilterFields = useStableStringArray(filterFields); const stableParamsToReset = useStableStringArray(paramsToReset); + const stableDefaults = useStableRecord(defaultFieldValues); const initialValues = useMemo( () => stableFilterFields.reduce( (acc, field) => { - const alternativeValue = defaultFieldValues?.[field] ?? undefined; + const alternativeValue = stableDefaults[field] ?? undefined; acc[field] = searchParams.get(field) ?? alternativeValue ?? undefined; return acc; }, {} as Record ), - [defaultFieldValues, searchParams, stableFilterFields] + [stableDefaults, searchParams, stableFilterFields] ); return ( @@ -142,7 +167,7 @@ export default function FormikFilterForm({
{children}
From e44de867b0e24b5f64499c86ec77e23105ef7167 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 13 Feb 2026 02:50:49 +0545 Subject: [PATCH 09/10] refactor(hooks): migrate pagination and sort hooks to usePrefixedSearchParams Replace raw useSearchParams with usePrefixedSearchParams in useReactTablePaginationState and useReactTableSortState, eliminating duplicate prefix logic and manual key construction. Use functional updater pattern in setters to avoid stale-state race conditions. Provide sort defaults declaratively via usePrefixedSearchParams instead of imperatively writing them to the URL in a useEffect. --- .../Hooks/useReactTablePaginationState.tsx | 61 +++++------ .../Hooks/useReactTableSortState.tsx | 100 +++++++++--------- 2 files changed, 76 insertions(+), 85 deletions(-) diff --git a/src/ui/DataTable/Hooks/useReactTablePaginationState.tsx b/src/ui/DataTable/Hooks/useReactTablePaginationState.tsx index 34cbe7c4c..b39b22171 100644 --- a/src/ui/DataTable/Hooks/useReactTablePaginationState.tsx +++ b/src/ui/DataTable/Hooks/useReactTablePaginationState.tsx @@ -1,6 +1,7 @@ import { OnChangeFn, PaginationState } from "@tanstack/react-table"; import { useCallback } from "react"; -import { useSearchParams } from "react-router-dom"; + +import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; type PaginationStateOptions = { /** @@ -34,49 +35,43 @@ export default function useReactTablePaginationState( defaultPageSize = 50 } = options; - const pageIndexParamKey = paramPrefix - ? `${paramPrefix}__${pageIndexKey}` - : pageIndexKey; - const pageSizeParamKey = paramPrefix - ? `${paramPrefix}__${pageSizeKey}` - : pageSizeKey; - const defaultPageSizeValue = defaultPageSize.toString(); - const [params, setParams] = useSearchParams({ - [pageIndexParamKey]: "0", - [pageSizeParamKey]: defaultPageSizeValue + const [params, setParams] = usePrefixedSearchParams(paramPrefix, false, { + [pageIndexKey]: "0", + [pageSizeKey]: defaultPageSizeValue }); - const pageIndex = parseInt(params.get(pageIndexParamKey) ?? "0", 10); + const pageIndex = parseInt(params.get(pageIndexKey) ?? "0", 10); const pageSize = parseInt( - params.get(pageSizeParamKey) ?? defaultPageSizeValue, + params.get(pageSizeKey) ?? defaultPageSizeValue, 10 ); const setPageIndex: OnChangeFn = useCallback( (param) => { - const updated = - typeof param === "function" - ? param({ - pageIndex: pageIndex ?? 0, - pageSize: pageSize ?? defaultPageSize - }) - : param; - const newParams = new URLSearchParams(params); - newParams.set(pageIndexParamKey, updated.pageIndex.toString()); - newParams.set(pageSizeParamKey, updated.pageSize.toString()); - setParams(newParams); + setParams((current) => { + const currentPageIndex = parseInt(current.get(pageIndexKey) ?? "0", 10); + const currentPageSize = parseInt( + current.get(pageSizeKey) ?? defaultPageSizeValue, + 10 + ); + + const updated = + typeof param === "function" + ? param({ + pageIndex: currentPageIndex, + pageSize: currentPageSize + }) + : param; + + const next = new URLSearchParams(current); + next.set(pageIndexKey, updated.pageIndex.toString()); + next.set(pageSizeKey, updated.pageSize.toString()); + return next; + }); }, - [ - pageIndex, - pageSize, - params, - setParams, - pageIndexParamKey, - pageSizeParamKey, - defaultPageSize - ] + [defaultPageSizeValue, pageIndexKey, pageSizeKey, setParams] ); return { diff --git a/src/ui/DataTable/Hooks/useReactTableSortState.tsx b/src/ui/DataTable/Hooks/useReactTableSortState.tsx index fa5499829..c3b21b6a3 100644 --- a/src/ui/DataTable/Hooks/useReactTableSortState.tsx +++ b/src/ui/DataTable/Hooks/useReactTableSortState.tsx @@ -1,6 +1,7 @@ import { SortingState, Updater } from "@tanstack/react-table"; -import { useCallback, useEffect, useMemo } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useCallback, useMemo } from "react"; + +import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; type SortStateOptions = { /** @@ -41,16 +42,21 @@ export default function useReactTableSortState( defaultSorting } = options; - const sortByParamKey = paramPrefix - ? `${paramPrefix}__${sortByKey}` - : sortByKey; - const sortOrderParamKey = paramPrefix - ? `${paramPrefix}__${sortOrderKey}` - : sortOrderKey; - const [searchParams, setSearchParams] = useSearchParams(); + const defaultSort = defaultSorting?.[0]; + + const [searchParams, setSearchParams] = usePrefixedSearchParams( + paramPrefix, + false, + defaultSort + ? { + [sortByKey]: defaultSort.id, + [sortOrderKey]: defaultSort.desc ? "desc" : "asc" + } + : undefined + ); - const sortBy = searchParams.get(sortByParamKey) || undefined; - const sortOrder = searchParams.get(sortOrderParamKey) || undefined; + const sortBy = searchParams.get(sortByKey) || undefined; + const sortOrder = searchParams.get(sortOrderKey) || undefined; const tableSortByState = useMemo(() => { if (!sortBy || !sortOrder) { @@ -64,50 +70,40 @@ export default function useReactTableSortState( ] satisfies SortingState; }, [sortBy, sortOrder]); - useEffect(() => { - if ( - (!sortBy || !sortOrder) && - defaultSorting && - defaultSorting.length > 0 - ) { - const nextParams = new URLSearchParams(window.location.search); - const [firstSort] = defaultSorting; - nextParams.set(sortByParamKey, firstSort.id); - nextParams.set(sortOrderParamKey, firstSort.desc ? "desc" : "asc"); - setSearchParams(nextParams); - } - }, [ - defaultSorting, - setSearchParams, - sortBy, - sortOrder, - sortByParamKey, - sortOrderParamKey - ]); - const updateSortByFn = useCallback( (newSortBy: Updater) => { - const sort = - typeof newSortBy === "function" - ? newSortBy([...tableSortByState]) - : newSortBy; - const nextParams = new URLSearchParams(searchParams); - if (sort.length === 0) { - nextParams.delete(sortByParamKey); - nextParams.delete(sortOrderParamKey); - } else { - nextParams.set(sortByParamKey, sort[0].id); - nextParams.set(sortOrderParamKey, sort[0].desc ? "desc" : "asc"); - } - setSearchParams(nextParams); + setSearchParams((current) => { + const currentSortBy = current.get(sortByKey) || undefined; + const currentSortOrder = current.get(sortOrderKey) || undefined; + + const currentTableSortByState = + currentSortBy && currentSortOrder + ? ([ + { + id: currentSortBy, + desc: currentSortOrder === "desc" + } + ] satisfies SortingState) + : []; + + const sort = + typeof newSortBy === "function" + ? newSortBy(currentTableSortByState) + : newSortBy; + + const nextParams = new URLSearchParams(current); + if (sort.length === 0) { + nextParams.delete(sortByKey); + nextParams.delete(sortOrderKey); + } else { + nextParams.set(sortByKey, sort[0].id); + nextParams.set(sortOrderKey, sort[0].desc ? "desc" : "asc"); + } + + return nextParams; + }); }, - [ - searchParams, - setSearchParams, - sortByParamKey, - sortOrderParamKey, - tableSortByState - ] + [setSearchParams, sortByKey, sortOrderKey] ); return [tableSortByState, updateSortByFn]; From 20077340a9d7f51de75432364754ff955405d6a6 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Fri, 13 Feb 2026 02:53:43 +0545 Subject: [PATCH 10/10] fix(filters): keep defaults initial-only in form sync --- src/components/Forms/FormikFilterForm.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/Forms/FormikFilterForm.tsx b/src/components/Forms/FormikFilterForm.tsx index 45efa82e0..08faf0617 100644 --- a/src/components/Forms/FormikFilterForm.tsx +++ b/src/components/Forms/FormikFilterForm.tsx @@ -103,13 +103,12 @@ function FormikChangesListener({ // Sync URL params to form values useEffect(() => { filterFields.forEach((field) => { - const value = - searchParams.get(field) ?? stableDefaults[field] ?? undefined; + const value = searchParams.get(field) ?? undefined; if (valuesRef.current[field] !== value) { setFieldValue(field, value, false); } }); - }, [stableDefaults, filterFields, searchParams, setFieldValue]); + }, [filterFields, searchParams, setFieldValue]); // eslint-disable-next-line react/jsx-no-useless-fragment return <>{children};