diff --git a/src/api/services/views.ts b/src/api/services/views.ts index bc5ad9d5a..7821443c2 100644 --- a/src/api/services/views.ts +++ b/src/api/services/views.ts @@ -64,13 +64,27 @@ export const getAllViews = ( }) ); }; + +/** + * Get the data for a view by its id. + */ export const getViewDataById = async ( viewId: string, + variables?: Record, headers?: Record ): Promise => { + const body: { variables?: Record } = { + variables: variables + }; + 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) { @@ -102,7 +116,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, "_"); @@ -143,6 +158,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/hooks/usePrefixedSearchParams.ts b/src/hooks/usePrefixedSearchParams.ts new file mode 100644 index 000000000..cdfcf4522 --- /dev/null +++ b/src/hooks/usePrefixedSearchParams.ts @@ -0,0 +1,68 @@ +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. + * + * @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 (GLOBAL_PARAM_KEYS.includes(key as any)) { + filtered.set(key, value); + } else 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/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/GlobalFilters.tsx b/src/pages/audit-report/components/View/GlobalFilters.tsx new file mode 100644 index 000000000..f52c99631 --- /dev/null +++ b/src/pages/audit-report/components/View/GlobalFilters.tsx @@ -0,0 +1,82 @@ +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[]; +} + +const Dropdown: React.FC = ({ label, paramsKey, options }) => { + const [field] = useField({ + name: paramsKey + }); + const dropdownOptions = useMemo(() => { + const mappedOptions = options.map( + (option) => + ({ + value: option, + label: option + }) satisfies GroupByOptions + ); + + return mappedOptions; + }, [options]); + + return ( + option.value === field.value)} + onChange={(selectedOption: unknown) => { + const option = selectedOption as GroupByOptions; + field.onChange({ + target: { name: paramsKey, value: option?.value } + }); + }} + className="w-auto max-w-[400px]" + isMulti={false} + closeMenuOnSelect={true} + isClearable={false} + /> + ); +}; + +interface GlobalFiltersProps { + variables?: ViewVariable[]; +} + +const GlobalFilters: React.FC = ({ variables }) => { + const filterComponents = useMemo(() => { + if (!variables || variables.length === 0) return []; + + return variables.map((variable) => ( + + )); + }, [variables]); + + if (!variables || variables.length === 0) { + return null; + } + + return ( +
+
+ {filterComponents} +
+
+ ); +}; + +export default GlobalFilters; 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..a260d416d --- /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 e47de74cd..cb22a6e55 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -1,13 +1,18 @@ import React, { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -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, + ViewVariable, + ViewResult +} from "../../types"; import { ViewColumnDropdown } from "../ViewColumnDropdown"; import useReactTablePaginationState from "@flanksource-ui/ui/DataTable/Hooks/useReactTablePaginationState"; -import FormikFilterForm from "@flanksource-ui/components/Forms/FormikFilterForm"; +import ViewTableFilterForm from "./ViewTableFilterForm"; import { queryViewTable } from "../../../../api/services/views"; import { NumberPanel, @@ -16,6 +21,9 @@ import { GaugePanel, TextPanel } from "./panels"; +import GlobalFilters from "./GlobalFilters"; +import GlobalFiltersForm from "./GlobalFiltersForm"; +import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; interface ViewProps { title?: string; @@ -24,6 +32,9 @@ interface ViewProps { name: string; columns?: ViewColumnDef[]; columnOptions?: Record; + variables?: ViewVariable[]; + viewResult?: ViewResult; + currentVariables?: Record; } const View: React.FC = ({ @@ -32,35 +43,57 @@ const View: React.FC = ({ name, columns, columnOptions, - panels + panels, + variables, + viewResult, + currentVariables }) => { const { pageSize } = useReactTablePaginationState(); - const [searchParams] = useSearchParams(); - const hasDataTable = columns && columns.length > 0; - const filterFields = useMemo(() => { - const baseFields: string[] = []; + // Create unique prefix for this view's table + const tablePrefix = `view_${namespace}_${name}`; + const [tableSearchParams] = usePrefixedSearchParams(tablePrefix); - if (hasDataTable) { - const filterableFields = columns - .filter((column) => column.filter?.type === "multiselect") - .map((column) => column.name); + // Create unique prefix for global filters + const globalVarPrefix = "viewvar"; + const hasDataTable = columns && columns.length > 0; - return [...baseFields, ...filterableFields]; - } + const columnFilterFields = useMemo( + () => + hasDataTable + ? columns + .filter((column) => column.filter?.type === "multiselect") + .map((column) => column.name) + : [], + [hasDataTable, columns] + ); - return baseFields; - }, [hasDataTable, columns]); + const filterFields = useMemo(() => { + // Only include column filters in Formik form, not global filters + return columnFilterFields; + }, [columnFilterFields]); - // 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(), + viewResult?.requestFingerprint + ], queryFn: () => - queryViewTable(namespace ?? "", name ?? "", columns ?? [], searchParams), + queryViewTable( + namespace ?? "", + name ?? "", + columns ?? [], + tableSearchParams, + viewResult?.requestFingerprint || "" + ), enabled: !!namespace && !!name && !!columns && columns.length > 0, staleTime: 5 * 60 * 1000 }); @@ -94,6 +127,20 @@ const View: React.FC = ({ )} + {variables && variables.length > 0 && ( + + + + )} + + {variables && variables.length > 0 && ( +
+ )} +
{panels && panels.length > 0 && (
@@ -102,6 +149,27 @@ const View: React.FC = ({ )}
+ + {hasDataTable && ( +
+
+ {filterableColumns.map(({ column, uniqueValues }) => ( + + ))} +
+
+ )} +
+ {tableError && (

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

- -
- {filterableColumns.map(({ column, uniqueValues }) => ( - - ))} -
-
-
- - - + )} ); 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..f10e10029 --- /dev/null +++ b/src/pages/audit-report/components/View/ViewTableFilterForm.tsx @@ -0,0 +1,80 @@ +import { Form, Formik, useFormikContext } from "formik"; +import { useEffect } 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) { + return ( + { + // Form submission is handled by the listener + }} + enableReinitialize + > +
+ + {children} + +
+
+ ); +} diff --git a/src/pages/audit-report/types/index.ts b/src/pages/audit-report/types/index.ts index a2cde547a..f6a2e3b0f 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 ViewVariable { + key: string; + value: string; + type: string; + options: string[]; + default?: string; + label?: string; +} + export interface ViewResult { title?: string; icon?: string; @@ -224,6 +233,8 @@ export interface ViewResult { rows?: ViewRow[]; panels?: PanelResult[]; columnOptions?: Record; + variables?: ViewVariable[]; + requestFingerprint: string; } export interface GaugeConfig { diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index d612ee6e6..fdf20a8b5 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 { useQuery, useQueryClient } from "@tanstack/react-query"; import { getViewDataById } from "../../../api/services/views"; +import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; import View from "../../audit-report/components/View/View"; import { Head } from "../../../ui/Head"; import { Icon } from "../../../ui/Icons/Icon"; @@ -12,23 +13,32 @@ 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 queryClient = useQueryClient(); + // Use prefixed search params for view variables + const [viewVarParams] = 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 { data: viewResult, isLoading, + isFetching, error: viewDataError } = useQuery({ - queryKey: ["view-result", id], + queryKey: ["view-result", id, currentViewVariables], queryFn: () => { - return getViewDataById(id); + return getViewDataById(id, currentViewVariables); }, enabled: !!id, - staleTime: 5 * 60 * 1000 + staleTime: 5 * 60 * 1000, + placeholderData: (previousData: any) => previousData }); useEffect(() => { @@ -43,7 +53,8 @@ const SingleView: React.FC = ({ id }) => { setError(undefined); }, [viewDataError]); - if (isLoading) { + // Only show full loading screen for initial load, not for filter refetches + if (isLoading && !viewResult) { return (
@@ -55,6 +66,9 @@ const SingleView: React.FC = ({ id }) => { } if (!viewResult) { + // TODO: Better error handling. + // viewResult = undefined does not mean the view is not found. + // There could be errors other than 404. return (
@@ -82,10 +96,13 @@ const SingleView: React.FC = ({ id }) => { const handleForceRefresh = async () => { if (namespace && name) { - const freshData = await getViewDataById(id, { + const freshData = await getViewDataById(id, currentViewVariables, { "cache-control": "max-age=1" }); - queryClient.setQueryData(["view-result", id], freshData); + queryClient.setQueryData( + ["view-result", id, currentViewVariables], + freshData + ); // Invalidate the table query that will be handled by the View component await queryClient.invalidateQueries({ queryKey: ["view-table", namespace, name] @@ -109,7 +126,7 @@ const SingleView: React.FC = ({ id }) => { } onRefresh={handleForceRefresh} contentClass="p-0 h-full" - loading={isLoading} + loading={isFetching} extra={ viewResult?.lastRefreshedAt && (

@@ -127,6 +144,9 @@ const SingleView: React.FC = ({ id }) => { columns={viewResult?.columns} columnOptions={viewResult?.columnOptions} panels={viewResult?.panels} + variables={viewResult?.variables} + viewResult={viewResult} + currentVariables={currentViewVariables} />