From a55d916e17334a87868deb683fa509b12cc7d940 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 27 Aug 2025 11:02:51 +0545 Subject: [PATCH 01/10] feat: view variables --- src/api/services/views.ts | 14 +- .../components/View/GlobalFilters.tsx | 150 ++++++++++++++++++ .../audit-report/components/View/View.tsx | 123 +++++++++----- src/pages/audit-report/types/index.ts | 10 ++ src/pages/views/components/SingleView.tsx | 33 +++- 5 files changed, 286 insertions(+), 44 deletions(-) create mode 100644 src/pages/audit-report/components/View/GlobalFilters.tsx diff --git a/src/api/services/views.ts b/src/api/services/views.ts index bc5ad9d5a..52ab826e6 100644 --- a/src/api/services/views.ts +++ b/src/api/services/views.ts @@ -66,11 +66,23 @@ export const getAllViews = ( }; export const getViewDataById = async ( viewId: string, + filters?: Record, headers?: Record ): Promise => { + const body: { variables?: Record } = {}; + + if (filters && Object.keys(filters).length > 0) { + body.variables = filters; + } + const response = await fetch(`/api/view/${viewId}`, { + method: "POST", credentials: "include", - headers + headers: { + "Content-Type": "application/json", + ...headers + }, + body: JSON.stringify(body) }); if (!response.ok) { diff --git a/src/pages/audit-report/components/View/GlobalFilters.tsx b/src/pages/audit-report/components/View/GlobalFilters.tsx new file mode 100644 index 000000000..37ace6382 --- /dev/null +++ b/src/pages/audit-report/components/View/GlobalFilters.tsx @@ -0,0 +1,150 @@ +import { useMemo, useState, useEffect, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + ReactSelectDropdown, + StateOption +} from "../../../../components/ReactSelectDropdown"; +import { ViewFilter } from "../../types"; +import { formatDisplayLabel } from "./panels/utils"; + +interface GlobalFiltersProps { + filters?: ViewFilter[]; + viewId: string; + namespace?: string; + name?: string; + onFilterStateChange?: (filterState: Record) => void; +} + +interface GlobalFilterDropdownProps { + label: string; + paramsKey: string; + options: string[]; + value?: string; + onChange: (key: string, value?: string) => void; +} + +const GlobalFilterDropdown: React.FC = ({ + label, + paramsKey, + options, + value, + onChange +}) => { + const dropdownOptions = useMemo(() => { + return options.map( + (option) => + ({ + id: option, + value: option, + label: option, + description: option + }) satisfies StateOption + ); + }, [options]); + + return ( + { + onChange( + paramsKey, + selectedValue && selectedValue !== "all" ? selectedValue : undefined + ); + }} + placeholder="Select..." + dropDownClassNames="w-auto max-w-[400px]" + isMulti={false} + /> + ); +}; + +const GlobalFilters: React.FC = ({ + filters, + viewId, + namespace, + name, + onFilterStateChange +}) => { + const queryClient = useQueryClient(); + + // Initialize filter state with defaults + const [filterState, setFilterState] = useState>({}); + const [hasInitialized, setHasInitialized] = useState(false); + + // Update filter state when filters prop changes (e.g., when view data loads) + // But only initialize once, don't reset user selections + useEffect(() => { + if (filters && filters.length > 0 && !hasInitialized) { + const initial: Record = {}; + filters.forEach((filter) => { + // Use default value if provided, otherwise use first option + const defaultValue = + filter.default || + (filter.options.length > 0 ? filter.options[0] : ""); + if (defaultValue) { + initial[filter.key] = defaultValue; + } + }); + + setFilterState(initial); + setHasInitialized(true); + console.log("Setting initial filter state:", initial); + onFilterStateChange?.(initial); + } + }, [filters, hasInitialized, onFilterStateChange]); + + const handleFilterChange = useCallback( + (key: string, value?: string) => { + const newFilterState = { ...filterState }; + if (value) { + newFilterState[key] = value; + } else { + delete newFilterState[key]; + } + + setFilterState(newFilterState); + console.log("Filter changed, new state:", newFilterState); + onFilterStateChange?.(newFilterState); + + // Invalidate table queries to refresh table data + if (namespace && name) { + queryClient.invalidateQueries({ + queryKey: ["view-table", namespace, name] + }); + } + }, + [filterState, namespace, name, queryClient, onFilterStateChange] + ); + + const filterComponents = useMemo(() => { + if (!filters || filters.length === 0) return []; + + return filters.map((filter) => ( + + )); + }, [filters, filterState, handleFilterChange]); + + if (!filters || filters.length === 0) { + return null; + } + + return ( +
+
+ {filterComponents} +
+
+ ); +}; + +export default GlobalFilters; diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index e47de74cd..a5bf3bdb7 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -4,7 +4,13 @@ import { useSearchParams } from "react-router-dom"; import { Box } from "lucide-react"; import DynamicDataTable from "../DynamicDataTable"; import { formatDisplayLabel } from "./panels/utils"; -import { PanelResult, ViewColumnDef, ViewRow } from "../../types"; +import { + PanelResult, + ViewColumnDef, + ViewRow, + ViewFilter, + ViewResult +} from "../../types"; import { ViewColumnDropdown } from "../ViewColumnDropdown"; import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; import FormikFilterForm from "@flanksource-ui/components/Forms/FormikFilterForm"; @@ -16,6 +22,7 @@ import { GaugePanel, TextPanel } from "./panels"; +import GlobalFilters from "./GlobalFilters"; interface ViewProps { title?: string; @@ -24,6 +31,11 @@ interface ViewProps { name: string; columns?: ViewColumnDef[]; columnOptions?: Record; + filters?: ViewFilter[]; + viewId?: string; + onGlobalFilterStateChange?: (filterState: Record) => void; + viewResult?: ViewResult; + currentGlobalFilters?: Record; } const View: React.FC = ({ @@ -32,35 +44,54 @@ const View: React.FC = ({ name, columns, columnOptions, - panels + panels, + filters, + viewId, + onGlobalFilterStateChange, + viewResult, + currentGlobalFilters }) => { const { pageSize } = useReactTablePaginationState(); const [searchParams] = useSearchParams(); const hasDataTable = columns && columns.length > 0; - const filterFields = useMemo(() => { - const baseFields: string[] = []; + const columnFilterFields = useMemo( + () => + hasDataTable + ? columns + .filter((column) => column.filter?.type === "multiselect") + .map((column) => column.name) + : [], + [hasDataTable, columns] + ); - if (hasDataTable) { - const filterableFields = columns - .filter((column) => column.filter?.type === "multiselect") - .map((column) => column.name); + const filterFields = useMemo(() => { + // Only include column filters in Formik form, not global filters + return columnFilterFields; + }, [columnFilterFields]); - return [...baseFields, ...filterableFields]; - } + const defaultFilterValues = useMemo(() => { + // No defaults needed since global filters are handled separately + return {}; + }, []); - return baseFields; - }, [hasDataTable, columns]); + // Use only column filters for table data, not global filters + const tableSearchParams = searchParams; - // Fetch table data if we have the necessary parameters + // Fetch table data with only column filters (no global filters) const { data: tableResponse, isLoading, error: tableError } = useQuery({ - queryKey: ["view-table", namespace, name, searchParams.toString()], + queryKey: ["view-table", namespace, name, tableSearchParams.toString()], queryFn: () => - queryViewTable(namespace ?? "", name ?? "", columns ?? [], searchParams), + queryViewTable( + namespace ?? "", + name ?? "", + columns ?? [], + tableSearchParams + ), enabled: !!namespace && !!name && !!columns && columns.length > 0, staleTime: 5 * 60 * 1000 }); @@ -94,6 +125,16 @@ const View: React.FC = ({ )} + + + {filters && filters.length > 0 &&
} +
{panels && panels.length > 0 && (
@@ -102,6 +143,27 @@ const View: React.FC = ({ )}
+ + {hasDataTable && ( +
+
+ {filterableColumns.map(({ column, uniqueValues }) => ( + + ))} +
+
+ )} +
+ {tableError && (

@@ -112,30 +174,13 @@ const View: React.FC = ({ )} {hasDataTable && ( - <> -

- -
- {filterableColumns.map(({ column, uniqueValues }) => ( - - ))} -
-
-
- - - + )} ); diff --git a/src/pages/audit-report/types/index.ts b/src/pages/audit-report/types/index.ts index a2cde547a..07c07d274 100644 --- a/src/pages/audit-report/types/index.ts +++ b/src/pages/audit-report/types/index.ts @@ -213,6 +213,15 @@ export interface ViewColumnDef { export type ViewRow = any[]; +export interface ViewFilter { + key: string; + value: string; + type: string; + options: string[]; + default?: string; + label?: string; +} + export interface ViewResult { title?: string; icon?: string; @@ -224,6 +233,7 @@ export interface ViewResult { rows?: ViewRow[]; panels?: PanelResult[]; columnOptions?: Record; + filters?: ViewFilter[]; } export interface GaugeConfig { diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index d612ee6e6..8e77964af 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -14,23 +14,37 @@ interface SingleViewProps { const SingleView: React.FC = ({ id }) => { const [error, setError] = useState(); + const [currentGlobalFilters, setCurrentGlobalFilters] = useState< + Record + >({}); const queryClient = useQueryClient(); + // Debug logging + console.log( + "SingleView render - currentGlobalFilters:", + currentGlobalFilters + ); + // Fetch all the view metadata, panel results and the column definitions // NOTE: This doesn't fetch the table rows. + // Use currentGlobalFilters in the query key so it updates when filters change const { data: viewResult, isLoading, error: viewDataError } = useQuery({ - queryKey: ["view-result", id], + queryKey: ["view-result", id, currentGlobalFilters], queryFn: () => { - return getViewDataById(id); + console.log("useQuery running with filters:", currentGlobalFilters); + return getViewDataById(id, currentGlobalFilters); }, enabled: !!id, staleTime: 5 * 60 * 1000 }); + // Debug logging for viewResult + console.log("viewResult panels:", viewResult?.panels?.length); + useEffect(() => { if (viewDataError) { setError( @@ -55,6 +69,8 @@ const SingleView: React.FC = ({ id }) => { } if (!viewResult) { + // FIXME: No view result does not mean the view is not found. + // we need to display the error in here. return (
@@ -82,10 +98,14 @@ const SingleView: React.FC = ({ id }) => { const handleForceRefresh = async () => { if (namespace && name) { - const freshData = await getViewDataById(id, { + console.log("Refreshing with global filters:", currentGlobalFilters); + const freshData = await getViewDataById(id, currentGlobalFilters, { "cache-control": "max-age=1" }); - queryClient.setQueryData(["view-result", id], freshData); + queryClient.setQueryData( + ["view-result", id, currentGlobalFilters], + freshData + ); // Invalidate the table query that will be handled by the View component await queryClient.invalidateQueries({ queryKey: ["view-table", namespace, name] @@ -127,6 +147,11 @@ const SingleView: React.FC = ({ id }) => { columns={viewResult?.columns} columnOptions={viewResult?.columnOptions} panels={viewResult?.panels} + filters={viewResult?.filters} + viewId={id} + onGlobalFilterStateChange={setCurrentGlobalFilters} + viewResult={viewResult} + currentGlobalFilters={currentGlobalFilters} />
From 3606370ef725d603179f6b5a98a1b5800af5f302 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 28 Aug 2025 10:40:37 +0545 Subject: [PATCH 02/10] fix: send request fingerprint on table query --- src/api/services/views.ts | 6 +++++- src/pages/audit-report/components/View/View.tsx | 11 +++++++++-- src/pages/audit-report/types/index.ts | 1 + src/pages/views/components/SingleView.tsx | 10 ---------- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/api/services/views.ts b/src/api/services/views.ts index 52ab826e6..1060993e4 100644 --- a/src/api/services/views.ts +++ b/src/api/services/views.ts @@ -114,7 +114,8 @@ export const queryViewTable = async ( namespace: string, name: string, columns: ViewColumnDef[], - searchParams: URLSearchParams + searchParams: URLSearchParams, + requestFingerprint: string ) => { const cleanNamespace = namespace.replace(/-/g, "_"); const cleanName = name.replace(/-/g, "_"); @@ -155,6 +156,9 @@ export const queryViewTable = async ( } } + // Add requestFingerprint as a filter if provided + queryString += `&request_fingerprint=eq.${encodeURIComponent(requestFingerprint)}`; + const response = await resolvePostGrestRequestWithPagination( ConfigDB.get(`/${tableName}${queryString}`, { headers: { diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index a5bf3bdb7..fc3a2e1d2 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -84,13 +84,20 @@ const View: React.FC = ({ isLoading, error: tableError } = useQuery({ - queryKey: ["view-table", namespace, name, tableSearchParams.toString()], + queryKey: [ + "view-table", + namespace, + name, + tableSearchParams.toString(), + viewResult?.requestFingerprint + ], queryFn: () => queryViewTable( namespace ?? "", name ?? "", columns ?? [], - tableSearchParams + tableSearchParams, + viewResult?.requestFingerprint || "" ), enabled: !!namespace && !!name && !!columns && columns.length > 0, staleTime: 5 * 60 * 1000 diff --git a/src/pages/audit-report/types/index.ts b/src/pages/audit-report/types/index.ts index 07c07d274..64a04af24 100644 --- a/src/pages/audit-report/types/index.ts +++ b/src/pages/audit-report/types/index.ts @@ -234,6 +234,7 @@ export interface ViewResult { panels?: PanelResult[]; columnOptions?: Record; filters?: ViewFilter[]; + requestFingerprint: string; } export interface GaugeConfig { diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index 8e77964af..97f902ec9 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -19,12 +19,6 @@ const SingleView: React.FC = ({ id }) => { >({}); const queryClient = useQueryClient(); - // Debug logging - console.log( - "SingleView render - currentGlobalFilters:", - currentGlobalFilters - ); - // Fetch all the view metadata, panel results and the column definitions // NOTE: This doesn't fetch the table rows. // Use currentGlobalFilters in the query key so it updates when filters change @@ -42,9 +36,6 @@ const SingleView: React.FC = ({ id }) => { staleTime: 5 * 60 * 1000 }); - // Debug logging for viewResult - console.log("viewResult panels:", viewResult?.panels?.length); - useEffect(() => { if (viewDataError) { setError( @@ -98,7 +89,6 @@ const SingleView: React.FC = ({ id }) => { const handleForceRefresh = async () => { if (namespace && name) { - console.log("Refreshing with global filters:", currentGlobalFilters); const freshData = await getViewDataById(id, currentGlobalFilters, { "cache-control": "max-age=1" }); From 2b1790950ca0338c223cf0f7be32570896f81ca0 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 28 Aug 2025 10:45:57 +0545 Subject: [PATCH 03/10] chore: rename to variables --- src/pages/audit-report/components/View/View.tsx | 12 +++++++----- src/pages/audit-report/types/index.ts | 4 ++-- src/pages/views/components/SingleView.tsx | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index fc3a2e1d2..15c6b7817 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -8,7 +8,7 @@ import { PanelResult, ViewColumnDef, ViewRow, - ViewFilter, + ViewVariable, ViewResult } from "../../types"; import { ViewColumnDropdown } from "../ViewColumnDropdown"; @@ -31,7 +31,7 @@ interface ViewProps { name: string; columns?: ViewColumnDef[]; columnOptions?: Record; - filters?: ViewFilter[]; + variables?: ViewVariable[]; viewId?: string; onGlobalFilterStateChange?: (filterState: Record) => void; viewResult?: ViewResult; @@ -45,7 +45,7 @@ const View: React.FC = ({ columns, columnOptions, panels, - filters, + variables, viewId, onGlobalFilterStateChange, viewResult, @@ -133,14 +133,16 @@ const View: React.FC = ({ )} - {filters && filters.length > 0 &&
} + {variables && variables.length > 0 && ( +
+ )}
{panels && panels.length > 0 && ( diff --git a/src/pages/audit-report/types/index.ts b/src/pages/audit-report/types/index.ts index 64a04af24..f6a2e3b0f 100644 --- a/src/pages/audit-report/types/index.ts +++ b/src/pages/audit-report/types/index.ts @@ -213,7 +213,7 @@ export interface ViewColumnDef { export type ViewRow = any[]; -export interface ViewFilter { +export interface ViewVariable { key: string; value: string; type: string; @@ -233,7 +233,7 @@ export interface ViewResult { rows?: ViewRow[]; panels?: PanelResult[]; columnOptions?: Record; - filters?: ViewFilter[]; + variables?: ViewVariable[]; requestFingerprint: string; } diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index 97f902ec9..930b15c0e 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -137,7 +137,7 @@ const SingleView: React.FC = ({ id }) => { columns={viewResult?.columns} columnOptions={viewResult?.columnOptions} panels={viewResult?.panels} - filters={viewResult?.filters} + variables={viewResult?.variables} viewId={id} onGlobalFilterStateChange={setCurrentGlobalFilters} viewResult={viewResult} From f1a6577a49df943692546d58a8739c89fd8f2ada Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 28 Aug 2025 11:06:30 +0545 Subject: [PATCH 04/10] fix: view variables --- src/api/services/views.ts | 14 +- .../components/View/GlobalFilters.tsx | 127 +++++++----------- .../audit-report/components/View/View.tsx | 18 +-- src/pages/views/components/SingleView.tsx | 63 ++++++--- 4 files changed, 111 insertions(+), 111 deletions(-) diff --git a/src/api/services/views.ts b/src/api/services/views.ts index 1060993e4..7821443c2 100644 --- a/src/api/services/views.ts +++ b/src/api/services/views.ts @@ -64,16 +64,18 @@ export const getAllViews = ( }) ); }; + +/** + * Get the data for a view by its id. + */ export const getViewDataById = async ( viewId: string, - filters?: Record, + variables?: Record, headers?: Record ): Promise => { - const body: { variables?: Record } = {}; - - if (filters && Object.keys(filters).length > 0) { - body.variables = filters; - } + const body: { variables?: Record } = { + variables: variables + }; const response = await fetch(`/api/view/${viewId}`, { method: "POST", diff --git a/src/pages/audit-report/components/View/GlobalFilters.tsx b/src/pages/audit-report/components/View/GlobalFilters.tsx index 37ace6382..1f88f650d 100644 --- a/src/pages/audit-report/components/View/GlobalFilters.tsx +++ b/src/pages/audit-report/components/View/GlobalFilters.tsx @@ -1,18 +1,15 @@ -import { useMemo, useState, useEffect, useCallback } from "react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useMemo, useCallback } from "react"; import { - ReactSelectDropdown, - StateOption -} from "../../../../components/ReactSelectDropdown"; -import { ViewFilter } from "../../types"; + GroupByOptions, + MultiSelectDropdown +} from "../../../../ui/Dropdowns/MultiSelectDropdown"; +import { ViewVariable } from "../../types"; import { formatDisplayLabel } from "./panels/utils"; interface GlobalFiltersProps { - filters?: ViewFilter[]; - viewId: string; - namespace?: string; - name?: string; - onFilterStateChange?: (filterState: Record) => void; + variables?: ViewVariable[]; + current?: Record; + onChange?: (filterState: Record) => void; } interface GlobalFilterDropdownProps { @@ -31,110 +28,84 @@ const GlobalFilterDropdown: React.FC = ({ onChange }) => { const dropdownOptions = useMemo(() => { - return options.map( + const mappedOptions = options.map( (option) => ({ - id: option, value: option, - label: option, - description: option - }) satisfies StateOption + label: option + }) satisfies GroupByOptions ); + + return mappedOptions; }, [options]); + const selectedValue = useMemo(() => { + if (!value) { + return { value: "all", label: "All" }; + } + return ( + dropdownOptions.find((option) => option.value === value) || { + value: "all", + label: "All" + } + ); + }, [value, dropdownOptions]); + return ( - { + options={dropdownOptions} + value={selectedValue} + onChange={(selectedOption: unknown) => { + const option = selectedOption as GroupByOptions; onChange( paramsKey, - selectedValue && selectedValue !== "all" ? selectedValue : undefined + option && option.value !== "all" ? option.value : undefined ); }} - placeholder="Select..." - dropDownClassNames="w-auto max-w-[400px]" + className="w-auto max-w-[400px]" isMulti={false} + closeMenuOnSelect={true} + isClearable={false} /> ); }; const GlobalFilters: React.FC = ({ - filters, - viewId, - namespace, - name, - onFilterStateChange + variables, + current = {}, + onChange }) => { - const queryClient = useQueryClient(); - - // Initialize filter state with defaults - const [filterState, setFilterState] = useState>({}); - const [hasInitialized, setHasInitialized] = useState(false); - - // Update filter state when filters prop changes (e.g., when view data loads) - // But only initialize once, don't reset user selections - useEffect(() => { - if (filters && filters.length > 0 && !hasInitialized) { - const initial: Record = {}; - filters.forEach((filter) => { - // Use default value if provided, otherwise use first option - const defaultValue = - filter.default || - (filter.options.length > 0 ? filter.options[0] : ""); - if (defaultValue) { - initial[filter.key] = defaultValue; - } - }); - - setFilterState(initial); - setHasInitialized(true); - console.log("Setting initial filter state:", initial); - onFilterStateChange?.(initial); - } - }, [filters, hasInitialized, onFilterStateChange]); - const handleFilterChange = useCallback( (key: string, value?: string) => { - const newFilterState = { ...filterState }; + const newFilterState = { ...current }; if (value) { newFilterState[key] = value; } else { delete newFilterState[key]; } - setFilterState(newFilterState); - console.log("Filter changed, new state:", newFilterState); - onFilterStateChange?.(newFilterState); - - // Invalidate table queries to refresh table data - if (namespace && name) { - queryClient.invalidateQueries({ - queryKey: ["view-table", namespace, name] - }); - } + onChange?.(newFilterState); }, - [filterState, namespace, name, queryClient, onFilterStateChange] + [current, onChange] ); const filterComponents = useMemo(() => { - if (!filters || filters.length === 0) return []; + if (!variables || variables.length === 0) return []; - return filters.map((filter) => ( + return variables.map((variable) => ( )); - }, [filters, filterState, handleFilterChange]); + }, [variables, current, handleFilterChange]); - if (!filters || filters.length === 0) { + if (!variables || variables.length === 0) { return null; } diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index 15c6b7817..0f5420f2e 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -32,10 +32,9 @@ interface ViewProps { columns?: ViewColumnDef[]; columnOptions?: Record; variables?: ViewVariable[]; - viewId?: string; - onGlobalFilterStateChange?: (filterState: Record) => void; + onVariableStateChange?: (filterState: Record) => void; viewResult?: ViewResult; - currentGlobalFilters?: Record; + currentVariables?: Record; } const View: React.FC = ({ @@ -46,10 +45,9 @@ const View: React.FC = ({ columnOptions, panels, variables, - viewId, - onGlobalFilterStateChange, + onVariableStateChange, viewResult, - currentGlobalFilters + currentVariables }) => { const { pageSize } = useReactTablePaginationState(); const [searchParams] = useSearchParams(); @@ -133,11 +131,9 @@ const View: React.FC = ({ )} {variables && variables.length > 0 && ( diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index 930b15c0e..675e8e8bd 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getViewDataById } from "../../../api/services/views"; +import { ViewVariable } from "../../audit-report/types"; import View from "../../audit-report/components/View/View"; import { Head } from "../../../ui/Head"; import { Icon } from "../../../ui/Icons/Icon"; @@ -14,26 +15,27 @@ interface SingleViewProps { const SingleView: React.FC = ({ id }) => { const [error, setError] = useState(); - const [currentGlobalFilters, setCurrentGlobalFilters] = useState< + const [currentViewVariables, setCurrentViewVariables] = useState< Record >({}); + const [hasFiltersInitialized, setHasFiltersInitialized] = useState(false); const queryClient = useQueryClient(); // Fetch all the view metadata, panel results and the column definitions // NOTE: This doesn't fetch the table rows. - // Use currentGlobalFilters in the query key so it updates when filters change const { data: viewResult, isLoading, + isFetching, error: viewDataError } = useQuery({ - queryKey: ["view-result", id, currentGlobalFilters], + queryKey: ["view-result", id, currentViewVariables], queryFn: () => { - console.log("useQuery running with filters:", currentGlobalFilters); - return getViewDataById(id, currentGlobalFilters); + return getViewDataById(id, currentViewVariables); }, enabled: !!id, - staleTime: 5 * 60 * 1000 + staleTime: 5 * 60 * 1000, + placeholderData: (previousData: any) => previousData }); useEffect(() => { @@ -48,7 +50,36 @@ const SingleView: React.FC = ({ id }) => { setError(undefined); }, [viewDataError]); - if (isLoading) { + // Initialize filters when view data loads, but preserve user selections + useEffect(() => { + if (viewResult?.variables && viewResult.variables.length > 0) { + if (!hasFiltersInitialized) { + // First time - initialize with defaults + const initial: Record = {}; + viewResult.variables.forEach((filter: ViewVariable) => { + const defaultValue = + filter.default || + (filter.options.length > 0 ? filter.options[0] : ""); + if (defaultValue) { + initial[filter.key] = defaultValue; + } + }); + setCurrentViewVariables(initial); + setHasFiltersInitialized(true); + } + } + }, [viewResult?.variables, hasFiltersInitialized]); + + // Handle global filter changes with useCallback to stabilize reference + const handleGlobalFilterChange = useCallback( + (newFilters: Record) => { + setCurrentViewVariables(newFilters); + }, + [] + ); + + // Only show full loading screen for initial load, not for filter refetches + if (isLoading && !viewResult) { return (
@@ -60,8 +91,9 @@ const SingleView: React.FC = ({ id }) => { } if (!viewResult) { - // FIXME: No view result does not mean the view is not found. - // we need to display the error in here. + // TODO: Better error handling. + // viewResult = undefined does not mean the view is not found. + // There could be errors other than 404. return (
@@ -89,11 +121,11 @@ const SingleView: React.FC = ({ id }) => { const handleForceRefresh = async () => { if (namespace && name) { - const freshData = await getViewDataById(id, currentGlobalFilters, { + const freshData = await getViewDataById(id, currentViewVariables, { "cache-control": "max-age=1" }); queryClient.setQueryData( - ["view-result", id, currentGlobalFilters], + ["view-result", id, currentViewVariables], freshData ); // Invalidate the table query that will be handled by the View component @@ -119,7 +151,7 @@ const SingleView: React.FC = ({ id }) => { } onRefresh={handleForceRefresh} contentClass="p-0 h-full" - loading={isLoading} + loading={isFetching} extra={ viewResult?.lastRefreshedAt && (

@@ -138,10 +170,9 @@ const SingleView: React.FC = ({ id }) => { columnOptions={viewResult?.columnOptions} panels={viewResult?.panels} variables={viewResult?.variables} - viewId={id} - onGlobalFilterStateChange={setCurrentGlobalFilters} + onVariableStateChange={handleGlobalFilterChange} viewResult={viewResult} - currentGlobalFilters={currentGlobalFilters} + currentVariables={currentViewVariables} />

From a6af3e5a8063cda3b890979a253fef465fe1485f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 28 Aug 2025 18:30:38 +0545 Subject: [PATCH 05/10] feat(view-state): create a generic URL state manager partitioned by prefix --- src/hooks/usePrefixedSearchParams.ts | 63 +++++++++++++ .../components/View/GlobalFilters.tsx | 37 +++----- .../audit-report/components/View/View.tsx | 18 ++-- .../components/View/ViewTableFilterForm.tsx | 90 +++++++++++++++++++ src/pages/views/components/SingleView.tsx | 73 ++++++++++----- 5 files changed, 224 insertions(+), 57 deletions(-) create mode 100644 src/hooks/usePrefixedSearchParams.ts create mode 100644 src/pages/audit-report/components/View/ViewTableFilterForm.tsx diff --git a/src/hooks/usePrefixedSearchParams.ts b/src/hooks/usePrefixedSearchParams.ts new file mode 100644 index 000000000..f87d8bb0c --- /dev/null +++ b/src/hooks/usePrefixedSearchParams.ts @@ -0,0 +1,63 @@ +import { useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; + +/** + * Hook that manages URL search params with a specific prefix. + * Provides filtered params (without prefix) and a setter that adds the prefix. + * + * @param prefix - The prefix to use for this component's params (e.g., 'viewvar', 'view_namespace_name') + */ +export function usePrefixedSearchParams( + prefix: string +): [ + URLSearchParams, + (updater: (prev: URLSearchParams) => URLSearchParams) => void +] { + const [searchParams, setSearchParams] = useSearchParams(); + + const prefixedParams = useMemo(() => { + const filtered = new URLSearchParams(); + const prefixWithSeparator = `${prefix}__`; + + Array.from(searchParams.entries()).forEach(([key, value]) => { + if (key.startsWith(prefixWithSeparator)) { + const cleanKey = key.substring(prefixWithSeparator.length); + filtered.set(cleanKey, value); + } + }); + + return filtered; + }, [searchParams, prefix]); + + // Setter that adds prefix to keys when updating URL + const setPrefixedParams = useCallback( + (updater: (prev: URLSearchParams) => URLSearchParams) => { + setSearchParams((currentParams) => { + const newParams = new URLSearchParams(currentParams); + const prefixWithSeparator = `${prefix}__`; + + // Remove all existing params with our prefix + Array.from(currentParams.entries()).forEach(([key]) => { + if (key.startsWith(prefixWithSeparator)) { + newParams.delete(key); + } + }); + + // Get the updated params from the updater + const updatedParams = updater(prefixedParams); + + // Add new params with prefix + Array.from(updatedParams.entries()).forEach(([key, value]) => { + if (value && value.trim() !== "") { + newParams.set(`${prefixWithSeparator}${key}`, value); + } + }); + + return newParams; + }); + }, + [setSearchParams, prefixedParams, prefix] + ); + + return [prefixedParams, setPrefixedParams]; +} diff --git a/src/pages/audit-report/components/View/GlobalFilters.tsx b/src/pages/audit-report/components/View/GlobalFilters.tsx index 1f88f650d..8856d2cc5 100644 --- a/src/pages/audit-report/components/View/GlobalFilters.tsx +++ b/src/pages/audit-report/components/View/GlobalFilters.tsx @@ -6,13 +6,7 @@ import { import { ViewVariable } from "../../types"; import { formatDisplayLabel } from "./panels/utils"; -interface GlobalFiltersProps { - variables?: ViewVariable[]; - current?: Record; - onChange?: (filterState: Record) => void; -} - -interface GlobalFilterDropdownProps { +interface DropdownProps { label: string; paramsKey: string; options: string[]; @@ -20,7 +14,7 @@ interface GlobalFilterDropdownProps { onChange: (key: string, value?: string) => void; } -const GlobalFilterDropdown: React.FC = ({ +const Dropdown: React.FC = ({ label, paramsKey, options, @@ -39,29 +33,14 @@ const GlobalFilterDropdown: React.FC = ({ return mappedOptions; }, [options]); - const selectedValue = useMemo(() => { - if (!value) { - return { value: "all", label: "All" }; - } - return ( - dropdownOptions.find((option) => option.value === value) || { - value: "all", - label: "All" - } - ); - }, [value, dropdownOptions]); - return ( option.value === value)} onChange={(selectedOption: unknown) => { const option = selectedOption as GroupByOptions; - onChange( - paramsKey, - option && option.value !== "all" ? option.value : undefined - ); + onChange(paramsKey, option?.value); }} className="w-auto max-w-[400px]" isMulti={false} @@ -71,6 +50,12 @@ const GlobalFilterDropdown: React.FC = ({ ); }; +interface GlobalFiltersProps { + variables?: ViewVariable[]; + current?: Record; + onChange?: (filterState: Record) => void; +} + const GlobalFilters: React.FC = ({ variables, current = {}, @@ -94,7 +79,7 @@ const GlobalFilters: React.FC = ({ if (!variables || variables.length === 0) return []; return variables.map((variable) => ( - = ({ currentVariables }) => { const { pageSize } = useReactTablePaginationState(); - const [searchParams] = useSearchParams(); + + // Create unique prefix for this view's table + const tablePrefix = `view_${namespace}_${name}`; + const [tableSearchParams] = usePrefixedSearchParams(tablePrefix); const hasDataTable = columns && columns.length > 0; const columnFilterFields = useMemo( @@ -73,9 +76,6 @@ const View: React.FC = ({ return {}; }, []); - // Use only column filters for table data, not global filters - const tableSearchParams = searchParams; - // Fetch table data with only column filters (no global filters) const { data: tableResponse, @@ -148,10 +148,10 @@ const View: React.FC = ({ )}
- {hasDataTable && (
@@ -167,7 +167,7 @@ const View: React.FC = ({
)} - + {tableError && (
diff --git a/src/pages/audit-report/components/View/ViewTableFilterForm.tsx b/src/pages/audit-report/components/View/ViewTableFilterForm.tsx new file mode 100644 index 000000000..ab43ca9d6 --- /dev/null +++ b/src/pages/audit-report/components/View/ViewTableFilterForm.tsx @@ -0,0 +1,90 @@ +import { Form, Formik, useFormikContext } from "formik"; +import { useEffect, useMemo } from "react"; +import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; + +type ViewTableFilterFormProps = { + children: React.ReactNode; + filterFields: string[]; + defaultFieldValues?: Record; + tablePrefix: string; +}; + +function ViewTableFilterListener({ + children, + filterFields, + defaultFieldValues = {}, + tablePrefix +}: ViewTableFilterFormProps): React.ReactElement { + const { values, setFieldValue } = + useFormikContext>(); + const [tableParams, setTableParams] = usePrefixedSearchParams(tablePrefix); + + useEffect(() => { + setTableParams(() => { + const newParams = new URLSearchParams(); + + filterFields.forEach((field) => { + const value = values[field]; + if (value && value.toLowerCase() !== "all") { + newParams.set(field, value); + } + }); + + // Note: paramsToReset is handled by the prefixed hook + return newParams; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values, setFieldValue, setTableParams]); + + // Reset form values when table filter params change + useEffect(() => { + filterFields.forEach((field) => { + const value = tableParams.get(field) || defaultFieldValues[field]; + setFieldValue(field, value); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableParams.toString(), filterFields, setFieldValue]); + + return children as React.ReactElement; +} + +/** + * Table-specific filter form that ignores view_ prefixed URL parameters. + * This prevents view-level filters from interfering with table column filters. + */ +export default function ViewTableFilterForm({ + children, + filterFields, + defaultFieldValues = {}, + tablePrefix +}: ViewTableFilterFormProps) { + const [tableParams] = usePrefixedSearchParams(tablePrefix); + + const initialValues = useMemo(() => { + const values: Record = {}; + filterFields.forEach((field) => { + values[field] = tableParams.get(field) || defaultFieldValues[field] || ""; + }); + return values; + }, [tableParams, filterFields, defaultFieldValues]); + + return ( + { + // Form submission is handled by the listener + }} + enableReinitialize + > +
+ + {children} + +
+
+ ); +} diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index 675e8e8bd..1577c6d49 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getViewDataById } from "../../../api/services/views"; +import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; import { ViewVariable } from "../../audit-report/types"; import View from "../../audit-report/components/View/View"; import { Head } from "../../../ui/Head"; @@ -13,14 +14,20 @@ interface SingleViewProps { id: string; } +// This is the prefix for all the query params that are related to the view variables. +const VIEW_VAR_PREFIX = "viewvar"; + const SingleView: React.FC = ({ id }) => { const [error, setError] = useState(); - const [currentViewVariables, setCurrentViewVariables] = useState< - Record - >({}); const [hasFiltersInitialized, setHasFiltersInitialized] = useState(false); const queryClient = useQueryClient(); + // Use prefixed search params for view variables + const [viewVarParams, setViewVarParams] = + usePrefixedSearchParams(VIEW_VAR_PREFIX); + + const currentViewVariables = Object.fromEntries(viewVarParams.entries()); + // Fetch all the view metadata, panel results and the column definitions // NOTE: This doesn't fetch the table rows. const { @@ -50,33 +57,55 @@ const SingleView: React.FC = ({ id }) => { setError(undefined); }, [viewDataError]); + // Handle global filter changes with useCallback to stabilize reference + const handleGlobalFilterChange = useCallback( + (newFilters: Record) => { + console.log("handleGlobalFilterChange", newFilters); + setViewVarParams(() => { + const newParams = new URLSearchParams(); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + newParams.set(key, value); + } + }); + return newParams; + }); + }, + [setViewVarParams] + ); + // Initialize filters when view data loads, but preserve user selections useEffect(() => { if (viewResult?.variables && viewResult.variables.length > 0) { if (!hasFiltersInitialized) { - // First time - initialize with defaults - const initial: Record = {}; - viewResult.variables.forEach((filter: ViewVariable) => { - const defaultValue = - filter.default || - (filter.options.length > 0 ? filter.options[0] : ""); - if (defaultValue) { - initial[filter.key] = defaultValue; + // Check if URL already has any variable values + const hasExistingValues = Object.keys(currentViewVariables).length > 0; + + if (!hasExistingValues) { + // First time with no URL params - initialize with defaults + const initial: Record = {}; + viewResult.variables.forEach((filter: ViewVariable) => { + const defaultValue = + filter.default || + (filter.options.length > 0 ? filter.options[0] : ""); + if (defaultValue) { + initial[filter.key] = defaultValue; + } + }); + + if (Object.keys(initial).length > 0) { + handleGlobalFilterChange(initial); } - }); - setCurrentViewVariables(initial); + } setHasFiltersInitialized(true); } } - }, [viewResult?.variables, hasFiltersInitialized]); - - // Handle global filter changes with useCallback to stabilize reference - const handleGlobalFilterChange = useCallback( - (newFilters: Record) => { - setCurrentViewVariables(newFilters); - }, - [] - ); + }, [ + viewResult?.variables, + hasFiltersInitialized, + currentViewVariables, + handleGlobalFilterChange + ]); // Only show full loading screen for initial load, not for filter refetches if (isLoading && !viewResult) { From dbe6b1d7fc598fdc5185c699ed0c1b481761a2bb Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 28 Aug 2025 21:05:38 +0545 Subject: [PATCH 06/10] fix: FilterByCellValue in view tables --- .../components/DynamicDataTable.tsx | 20 +++++++++++++++---- .../audit-report/components/View/View.tsx | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/pages/audit-report/components/DynamicDataTable.tsx b/src/pages/audit-report/components/DynamicDataTable.tsx index 6b5ce0624..fe5d984ec 100644 --- a/src/pages/audit-report/components/DynamicDataTable.tsx +++ b/src/pages/audit-report/components/DynamicDataTable.tsx @@ -23,6 +23,7 @@ interface DynamicDataTableProps { pageCount: number; totalRowCount?: number; isLoading?: boolean; + tablePrefix?: string; } interface RowAttributes { @@ -36,7 +37,8 @@ const DynamicDataTable: React.FC = ({ rows, pageCount, totalRowCount, - isLoading + isLoading, + tablePrefix }) => { const columnDef: MRT_ColumnDef[] = columns .filter((col) => !col.hidden && col.type !== "row_attributes") @@ -47,7 +49,7 @@ const DynamicDataTable: React.FC = ({ maxSize: minWidthForColumnType(col.type), header: formatDisplayLabel(col.name), Cell: ({ cell, row }: { cell: any; row: any }) => - renderCellValue(cell.getValue(), col, row.original) + renderCellValue(cell.getValue(), col, row.original, tablePrefix) }; }); @@ -91,7 +93,12 @@ const DynamicDataTable: React.FC = ({ ); }; -const renderCellValue = (value: any, column: ViewColumnDef, row: any) => { +const renderCellValue = ( + value: any, + column: ViewColumnDef, + row: any, + tablePrefix?: string +) => { if (value == null) return "-"; let cellContent: any; @@ -238,9 +245,14 @@ const renderCellValue = (value: any, column: ViewColumnDef, row: any) => { // Wrap with FilterByCellValue if column has multiselect filter if (column.filter?.type === "multiselect") { + // Use prefixed parameter key if tablePrefix is provided + const paramKey = tablePrefix + ? `${tablePrefix}__${column.name}` + : column.name; + return ( diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index b87251ee8..548d6212f 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -185,6 +185,7 @@ const View: React.FC = ({ rows={rows || []} pageCount={totalEntries ? Math.ceil(totalEntries / pageSize) : 1} totalRowCount={totalEntries} + tablePrefix={tablePrefix} /> )} From 0154e42b6e56292459539978b5a30b7d0d6a2f6f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 28 Aug 2025 21:22:04 +0545 Subject: [PATCH 07/10] fix: review comments --- src/pages/audit-report/components/View/View.tsx | 7 +------ .../components/View/ViewTableFilterForm.tsx | 14 ++------------ 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index 548d6212f..438be9ca0 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -71,11 +71,6 @@ const View: React.FC = ({ return columnFilterFields; }, [columnFilterFields]); - const defaultFilterValues = useMemo(() => { - // No defaults needed since global filters are handled separately - return {}; - }, []); - // Fetch table data with only column filters (no global filters) const { data: tableResponse, @@ -150,7 +145,7 @@ const View: React.FC = ({ {hasDataTable && ( diff --git a/src/pages/audit-report/components/View/ViewTableFilterForm.tsx b/src/pages/audit-report/components/View/ViewTableFilterForm.tsx index ab43ca9d6..f10e10029 100644 --- a/src/pages/audit-report/components/View/ViewTableFilterForm.tsx +++ b/src/pages/audit-report/components/View/ViewTableFilterForm.tsx @@ -1,5 +1,5 @@ import { Form, Formik, useFormikContext } from "formik"; -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; type ViewTableFilterFormProps = { @@ -58,19 +58,9 @@ export default function ViewTableFilterForm({ defaultFieldValues = {}, tablePrefix }: ViewTableFilterFormProps) { - const [tableParams] = usePrefixedSearchParams(tablePrefix); - - const initialValues = useMemo(() => { - const values: Record = {}; - filterFields.forEach((field) => { - values[field] = tableParams.get(field) || defaultFieldValues[field] || ""; - }); - return values; - }, [tableParams, filterFields, defaultFieldValues]); - return ( { // Form submission is handled by the listener }} From e4562f268ca8d12571c79c40ac9b57751ec02353 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 28 Aug 2025 21:39:58 +0545 Subject: [PATCH 08/10] fix: state management for global filter --- .../components/View/GlobalFilters.tsx | 48 +++------- .../components/View/GlobalFiltersForm.tsx | 90 +++++++++++++++++++ .../audit-report/components/View/View.tsx | 20 +++-- src/pages/views/components/SingleView.tsx | 59 +----------- 4 files changed, 117 insertions(+), 100 deletions(-) create mode 100644 src/pages/audit-report/components/View/GlobalFiltersForm.tsx diff --git a/src/pages/audit-report/components/View/GlobalFilters.tsx b/src/pages/audit-report/components/View/GlobalFilters.tsx index 8856d2cc5..f52c99631 100644 --- a/src/pages/audit-report/components/View/GlobalFilters.tsx +++ b/src/pages/audit-report/components/View/GlobalFilters.tsx @@ -1,26 +1,22 @@ -import { useMemo, useCallback } from "react"; +import { useMemo } from "react"; import { GroupByOptions, MultiSelectDropdown } from "../../../../ui/Dropdowns/MultiSelectDropdown"; import { ViewVariable } from "../../types"; import { formatDisplayLabel } from "./panels/utils"; +import { useField } from "formik"; interface DropdownProps { label: string; paramsKey: string; options: string[]; - value?: string; - onChange: (key: string, value?: string) => void; } -const Dropdown: React.FC = ({ - label, - paramsKey, - options, - value, - onChange -}) => { +const Dropdown: React.FC = ({ label, paramsKey, options }) => { + const [field] = useField({ + name: paramsKey + }); const dropdownOptions = useMemo(() => { const mappedOptions = options.map( (option) => @@ -37,10 +33,12 @@ const Dropdown: React.FC = ({ option.value === value)} + value={dropdownOptions.find((option) => option.value === field.value)} onChange={(selectedOption: unknown) => { const option = selectedOption as GroupByOptions; - onChange(paramsKey, option?.value); + field.onChange({ + target: { name: paramsKey, value: option?.value } + }); }} className="w-auto max-w-[400px]" isMulti={false} @@ -52,29 +50,9 @@ const Dropdown: React.FC = ({ interface GlobalFiltersProps { variables?: ViewVariable[]; - current?: Record; - onChange?: (filterState: Record) => void; } -const GlobalFilters: React.FC = ({ - variables, - current = {}, - onChange -}) => { - const handleFilterChange = useCallback( - (key: string, value?: string) => { - const newFilterState = { ...current }; - if (value) { - newFilterState[key] = value; - } else { - delete newFilterState[key]; - } - - onChange?.(newFilterState); - }, - [current, onChange] - ); - +const GlobalFilters: React.FC = ({ variables }) => { const filterComponents = useMemo(() => { if (!variables || variables.length === 0) return []; @@ -84,11 +62,9 @@ const GlobalFilters: React.FC = ({ label={variable.label || formatDisplayLabel(variable.key)} paramsKey={variable.key} options={variable.options} - value={current[variable.key]} - onChange={handleFilterChange} /> )); - }, [variables, current, handleFilterChange]); + }, [variables]); if (!variables || variables.length === 0) { return null; diff --git a/src/pages/audit-report/components/View/GlobalFiltersForm.tsx b/src/pages/audit-report/components/View/GlobalFiltersForm.tsx new file mode 100644 index 000000000..3e330d18d --- /dev/null +++ b/src/pages/audit-report/components/View/GlobalFiltersForm.tsx @@ -0,0 +1,90 @@ +import { Form, Formik, useFormikContext } from "formik"; +import { useEffect } from "react"; +import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; +import { ViewVariable } from "../../types"; + +type GlobalFiltersFormProps = { + children: React.ReactNode; + variables: ViewVariable[]; + globalVarPrefix: string; + currentVariables?: Record; +}; + +function GlobalFiltersListener({ + children, + variables, + globalVarPrefix, + currentVariables = {} +}: GlobalFiltersFormProps): React.ReactElement { + const { values, setFieldValue } = + useFormikContext>(); + const [globalParams, setGlobalParams] = + usePrefixedSearchParams(globalVarPrefix); + + useEffect(() => { + setGlobalParams(() => { + const newParams = new URLSearchParams(); + + variables.forEach((variable) => { + const value = values[variable.key]; + if (value) { + newParams.set(variable.key, value); + } + }); + + return newParams; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values, setGlobalParams]); + + // Initialize form values when variables load or URL params change + useEffect(() => { + variables.forEach((variable) => { + const urlValue = globalParams.get(variable.key); + const currentValue = currentVariables[variable.key]; + const defaultValue = + variable.default || + (variable.options.length > 0 ? variable.options[0] : ""); + + const valueToUse = urlValue || currentValue || defaultValue; + if (valueToUse) { + setFieldValue(variable.key, valueToUse); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [globalParams.toString(), variables, currentVariables, setFieldValue]); + + return children as React.ReactElement; +} + +/** + * Global filters form that manages view-level filter parameters. + * This handles synchronization between Formik form state and URL parameters + * for global filters that affect the entire view (panels + table). + */ +export default function GlobalFiltersForm({ + children, + variables, + globalVarPrefix, + currentVariables +}: GlobalFiltersFormProps) { + return ( + { + // Form submission is handled by the listener + }} + enableReinitialize + > +
+ + {children} + +
+
+ ); +} diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index 438be9ca0..cb22a6e55 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -22,6 +22,7 @@ import { TextPanel } from "./panels"; import GlobalFilters from "./GlobalFilters"; +import GlobalFiltersForm from "./GlobalFiltersForm"; import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; interface ViewProps { @@ -32,7 +33,6 @@ interface ViewProps { columns?: ViewColumnDef[]; columnOptions?: Record; variables?: ViewVariable[]; - onVariableStateChange?: (filterState: Record) => void; viewResult?: ViewResult; currentVariables?: Record; } @@ -45,7 +45,6 @@ const View: React.FC = ({ columnOptions, panels, variables, - onVariableStateChange, viewResult, currentVariables }) => { @@ -54,6 +53,9 @@ const View: React.FC = ({ // Create unique prefix for this view's table const tablePrefix = `view_${namespace}_${name}`; const [tableSearchParams] = usePrefixedSearchParams(tablePrefix); + + // Create unique prefix for global filters + const globalVarPrefix = "viewvar"; const hasDataTable = columns && columns.length > 0; const columnFilterFields = useMemo( @@ -125,11 +127,15 @@ const View: React.FC = ({ )} - + {variables && variables.length > 0 && ( + + + + )} {variables && variables.length > 0 && (
diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index 1577c6d49..fdf20a8b5 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -1,8 +1,7 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getViewDataById } from "../../../api/services/views"; import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; -import { ViewVariable } from "../../audit-report/types"; import View from "../../audit-report/components/View/View"; import { Head } from "../../../ui/Head"; import { Icon } from "../../../ui/Icons/Icon"; @@ -19,13 +18,10 @@ const VIEW_VAR_PREFIX = "viewvar"; const SingleView: React.FC = ({ id }) => { const [error, setError] = useState(); - const [hasFiltersInitialized, setHasFiltersInitialized] = useState(false); const queryClient = useQueryClient(); // Use prefixed search params for view variables - const [viewVarParams, setViewVarParams] = - usePrefixedSearchParams(VIEW_VAR_PREFIX); - + const [viewVarParams] = usePrefixedSearchParams(VIEW_VAR_PREFIX); const currentViewVariables = Object.fromEntries(viewVarParams.entries()); // Fetch all the view metadata, panel results and the column definitions @@ -57,56 +53,6 @@ const SingleView: React.FC = ({ id }) => { setError(undefined); }, [viewDataError]); - // Handle global filter changes with useCallback to stabilize reference - const handleGlobalFilterChange = useCallback( - (newFilters: Record) => { - console.log("handleGlobalFilterChange", newFilters); - setViewVarParams(() => { - const newParams = new URLSearchParams(); - Object.entries(newFilters).forEach(([key, value]) => { - if (value) { - newParams.set(key, value); - } - }); - return newParams; - }); - }, - [setViewVarParams] - ); - - // Initialize filters when view data loads, but preserve user selections - useEffect(() => { - if (viewResult?.variables && viewResult.variables.length > 0) { - if (!hasFiltersInitialized) { - // Check if URL already has any variable values - const hasExistingValues = Object.keys(currentViewVariables).length > 0; - - if (!hasExistingValues) { - // First time with no URL params - initialize with defaults - const initial: Record = {}; - viewResult.variables.forEach((filter: ViewVariable) => { - const defaultValue = - filter.default || - (filter.options.length > 0 ? filter.options[0] : ""); - if (defaultValue) { - initial[filter.key] = defaultValue; - } - }); - - if (Object.keys(initial).length > 0) { - handleGlobalFilterChange(initial); - } - } - setHasFiltersInitialized(true); - } - } - }, [ - viewResult?.variables, - hasFiltersInitialized, - currentViewVariables, - handleGlobalFilterChange - ]); - // Only show full loading screen for initial load, not for filter refetches if (isLoading && !viewResult) { return ( @@ -199,7 +145,6 @@ const SingleView: React.FC = ({ id }) => { columnOptions={viewResult?.columnOptions} panels={viewResult?.panels} variables={viewResult?.variables} - onVariableStateChange={handleGlobalFilterChange} viewResult={viewResult} currentVariables={currentViewVariables} /> From d89776e93508a69a252cb7e1c64933011636d5d1 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 28 Aug 2025 21:57:00 +0545 Subject: [PATCH 09/10] fix(usePrefixedSearchParams): allow some global keys without requiring the prefix --- src/hooks/usePrefixedSearchParams.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/usePrefixedSearchParams.ts b/src/hooks/usePrefixedSearchParams.ts index f87d8bb0c..e48169d8d 100644 --- a/src/hooks/usePrefixedSearchParams.ts +++ b/src/hooks/usePrefixedSearchParams.ts @@ -20,7 +20,9 @@ export function usePrefixedSearchParams( const prefixWithSeparator = `${prefix}__`; Array.from(searchParams.entries()).forEach(([key, value]) => { - if (key.startsWith(prefixWithSeparator)) { + if (["sortBy", "sortOrder"].includes(key)) { + filtered.set(key, value); + } else if (key.startsWith(prefixWithSeparator)) { const cleanKey = key.substring(prefixWithSeparator.length); filtered.set(cleanKey, value); } From fa1a030c78953920b6fac33a169831067dfc7537 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Thu, 28 Aug 2025 22:14:45 +0545 Subject: [PATCH 10/10] fix: review comments --- src/hooks/usePrefixedSearchParams.ts | 5 ++++- src/pages/audit-report/components/View/GlobalFiltersForm.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/hooks/usePrefixedSearchParams.ts b/src/hooks/usePrefixedSearchParams.ts index e48169d8d..cdfcf4522 100644 --- a/src/hooks/usePrefixedSearchParams.ts +++ b/src/hooks/usePrefixedSearchParams.ts @@ -1,6 +1,9 @@ import { useCallback, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; +// Global parameter keys that don't require prefixing +const GLOBAL_PARAM_KEYS = ["sortBy", "sortOrder"] as const; + /** * Hook that manages URL search params with a specific prefix. * Provides filtered params (without prefix) and a setter that adds the prefix. @@ -20,7 +23,7 @@ export function usePrefixedSearchParams( const prefixWithSeparator = `${prefix}__`; Array.from(searchParams.entries()).forEach(([key, value]) => { - if (["sortBy", "sortOrder"].includes(key)) { + if (GLOBAL_PARAM_KEYS.includes(key as any)) { filtered.set(key, value); } else if (key.startsWith(prefixWithSeparator)) { const cleanKey = key.substring(prefixWithSeparator.length); diff --git a/src/pages/audit-report/components/View/GlobalFiltersForm.tsx b/src/pages/audit-report/components/View/GlobalFiltersForm.tsx index 3e330d18d..a260d416d 100644 --- a/src/pages/audit-report/components/View/GlobalFiltersForm.tsx +++ b/src/pages/audit-report/components/View/GlobalFiltersForm.tsx @@ -43,7 +43,7 @@ function GlobalFiltersListener({ const urlValue = globalParams.get(variable.key); const currentValue = currentVariables[variable.key]; const defaultValue = - variable.default || + variable.default ?? (variable.options.length > 0 ? variable.options[0] : ""); const valueToUse = urlValue || currentValue || defaultValue;