diff --git a/src/api/query-hooks/useAllConfigsQuery.ts b/src/api/query-hooks/useAllConfigsQuery.ts index a186cf3ee..2d8c72d51 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,9 +9,15 @@ import { useShowDeletedConfigs } from "@flanksource-ui/store/preference.state"; export const useAllConfigsQuery = ({ enabled = true, staleTime = defaultStaleTime, + paramPrefix, ...rest -}) => { - const [searchParams] = useSearchParams({ +}: { + enabled?: boolean; + staleTime?: number; + paramPrefix?: string; + [key: string]: any; +} = {}) => { + const [searchParams] = usePrefixedSearchParams(paramPrefix, false, { sortBy: "type", sortOrder: "asc", groupBy: "type" @@ -25,7 +31,9 @@ export const useAllConfigsQuery = ({ 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..65e6586da 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,14 +37,22 @@ 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({ + const { timeRangeValue } = useTimeRangeParams( + configChangesDefaultDateFilter, + paramPrefix + ); + const [params] = usePrefixedSearchParams(paramPrefix, false, { sortBy: "created_at", sortDirection: "desc" }); @@ -52,11 +61,13 @@ export function useGetAllConfigsChangesQuery( 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,7 +101,7 @@ export function useGetConfigChangesByIDQuery( const { id } = useParams(); const showChangesFromDeletedConfigs = useShowDeletedConfigs(); const { timeRangeValue } = useTimeRangeParams(configChangesDefaultDateFilter); - const [params] = useSearchParams({ + const [params] = usePrefixedSearchParams(undefined, false, { downstream: "true", upstream: "false", sortBy: "created_at", 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..08faf0617 100644 --- a/src/components/Forms/FormikFilterForm.tsx +++ b/src/components/Forms/FormikFilterForm.tsx @@ -1,46 +1,113 @@ 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"; + +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[] }>(); + + 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 = {} + defaultFieldValues, + paramPrefix }: FormikChangesListenerProps) { + const stableDefaults = useStableRecord(defaultFieldValues); const { values, setFieldValue } = useFormikContext>(); - const [searchParams, setSearchParams] = useSearchParams({ - ...defaultFieldValues - }); + const [searchParams, setSearchParams] = usePrefixedSearchParams( + paramPrefix, + false, + stableDefaults + ); + 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 +124,7 @@ type FilterFormProps = { * configType when available in the changes view */ defaultFieldValues?: Record; + paramPrefix?: string; }; /** @@ -71,30 +139,35 @@ 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 stableDefaults = useStableRecord(defaultFieldValues); const initialValues = useMemo( () => - filterFields.reduce( + 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, filterFields, searchParams] + [stableDefaults, 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/hooks/usePrefixedSearchParams.ts b/src/hooks/usePrefixedSearchParams.ts index 6a46493b3..185791958 100644 --- a/src/hooks/usePrefixedSearchParams.ts +++ b/src/hooks/usePrefixedSearchParams.ts @@ -1,70 +1,195 @@ import { useCallback, useMemo } from "react"; -import { useSearchParams } from "react-router-dom"; +import { + NavigateOptions, + URLSearchParamsInit, + 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 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, + 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 + * + * 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') + * @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 -): [ - URLSearchParams, - (updater: (prev: URLSearchParams) => URLSearchParams) => void -] { - const [searchParams, setSearchParams] = useSearchParams(); + prefix?: string, + useGlobalParams: boolean = true, + defaults?: URLSearchParamsInit +): [URLSearchParams, SetPrefixedSearchParams] { + const defaultSearchParams = useMemo( + () => buildDefaultSearchParams(prefix, useGlobalParams, defaults), + [defaults, prefix, useGlobalParams] + ); - 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); - } - }); + const [searchParams, setSearchParams] = useSearchParams(defaultSearchParams); - return filtered; - }, [searchParams, prefix, useGlobalParams]); + const prefixedParams = useMemo(() => { + 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); + // 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]; } 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/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]; diff --git a/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx b/src/ui/Dates/TimeRangePicker/useTimeRangeParams.tsx index 61e730a08..b35077216 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 { URLSearchParamsInit } from "react-router-dom"; +import { usePrefixedSearchParams } from "@flanksource-ui/hooks/usePrefixedSearchParams"; import { parseDateMath } from "./parseDateMath"; import { MappedOptionsDisplay, @@ -18,35 +19,48 @@ 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, + defaults + ); 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"); + nextParams.delete("range"); - // 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..0b532bc44 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 [, 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) {