From ece8abff4eb69fdd4e7be914e89ed555ff2022eb Mon Sep 17 00:00:00 2001 From: "colinjfagan@gmail.com" Date: Mon, 28 Jul 2025 15:36:37 -0700 Subject: [PATCH 1/2] PAN-22374: AuditLogViewer updates to support filter headers --- .../react-mui-audit-log-viewer/CHANGELOG.md | 6 ++ .../react-mui-audit-log-viewer/package.json | 2 +- .../src/AuditLogViewer.tsx | 5 ++ .../AuditLogViewerComponent/index.tsx | 68 ++++++++++++++++++- .../AuditLogViewerFiltersForm/FilterField.tsx | 6 +- .../AuditLogViewerFiltersForm/index.tsx | 13 +++- .../src/hooks/context.tsx | 6 +- .../src/hooks/query.ts | 51 +++++++++++++- .../src/hooks/schema.tsx | 56 +++++++++++++-- .../src/stories/AuditLogViewer.stories.tsx | 7 ++ 10 files changed, 205 insertions(+), 15 deletions(-) diff --git a/packages/react-mui-audit-log-viewer/CHANGELOG.md b/packages/react-mui-audit-log-viewer/CHANGELOG.md index 14dd5333f..f87ade826 100644 --- a/packages/react-mui-audit-log-viewer/CHANGELOG.md +++ b/packages/react-mui-audit-log-viewer/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.4] - 2025-07-28 + +### Fixed + +- Add support filter menu accessible from column headers, onFiltersChange callback + ## [2.0.3] - 2025-07-01 ### Fixed diff --git a/packages/react-mui-audit-log-viewer/package.json b/packages/react-mui-audit-log-viewer/package.json index fe4066ce0..34d2be249 100644 --- a/packages/react-mui-audit-log-viewer/package.json +++ b/packages/react-mui-audit-log-viewer/package.json @@ -1,6 +1,6 @@ { "name": "@pangeacyber/react-mui-audit-log-viewer", - "version": "2.0.3", + "version": "2.0.4-beta.2", "description": "An extension of material ui data-grid for Pangea audit log records", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/react-mui-audit-log-viewer/src/AuditLogViewer.tsx b/packages/react-mui-audit-log-viewer/src/AuditLogViewer.tsx index 175664429..a438b28ad 100644 --- a/packages/react-mui-audit-log-viewer/src/AuditLogViewer.tsx +++ b/packages/react-mui-audit-log-viewer/src/AuditLogViewer.tsx @@ -103,6 +103,9 @@ export interface AuditLogViewerProps { /** The public audit query to filter the audit log data */ filters?: PublicAuditQuery; + /** Callback handler for everytime the filters are updated */ + onFiltersChange?: (filters: PublicAuditQuery) => void; + /** Authentication configuration. It is used to fetch your project's custom Audit schema, so the AuditLogViewer component can dynamically update when you update your configuration in the Pangea Console */ config?: AuthConfig; @@ -129,6 +132,7 @@ const AuditLogViewerWithProvider = ({ schemaOptions, initialQuery, filters, + onFiltersChange, fpeOptions, filterOptions, ...props @@ -295,6 +299,7 @@ const AuditLogViewerWithProvider = ({ rowToLeafIndex={rowToLeafIndex} initialQuery={initialQuery} filters={filters} + onFiltersChange={onFiltersChange} filterOptions={filterOptions} > = ({ const defaultVisibility = useDefaultVisibility(schema); const defaultOrder = useDefaultOrder(schema); - const schemaColumns = useAuditColumns(schema, fields, fieldTypes); + const headerRefMap = useRef>({}); + const [activeFilterColumn, setActiveFilterColumn] = useState( + null + ); + + const handleGetFilterRef = (field: string) => (el: any) => { + headerRefMap.current[field] = el; + }; + + const handleOnFilterOpen = (field: string, event: any) => { + setActiveFilterColumn(field); + }; + + const schemaColumns = useAuditColumns( + schema, + fields, + fieldTypes, + handleOnFilterOpen, + handleGetFilterRef + ); const columns = useAuditColumnsWithErrors(schemaColumns, logs); const filterFields = useAuditFilterFields(schema, filterOptions); const conditionalOptions = useAuditConditionalOptions(schema, filterOptions); @@ -179,6 +198,17 @@ const AuditLogViewerComponent: FC = ({ ".MuiDataGrid-root .MuiDataGrid-row.Mui-selected .MuiDataGrid-cell": { borderBottom: "none", }, + + ".AuditLogViewer-FilterButton": { + opacity: 0, + }, + + ".MuiDataGrid-columnHeader:hover": { + ".AuditLogViewer-FilterButton": { + opacity: 0.6, + }, + }, + ...sx, }} > @@ -272,6 +302,38 @@ const AuditLogViewerComponent: FC = ({ }, }} /> + {activeFilterColumn && !!queryObj && ( + { + if (!open) setActiveFilterColumn(null); + }} + flatTop + > +
+ { + setQueryObj( + mapValues(values, (v: any) => + typeof v === "string" ? v.trim() : v + ) + ); + setActiveFilterColumn(null); + }} + /> +
+
+ )} ); }; diff --git a/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerFiltersForm/FilterField.tsx b/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerFiltersForm/FilterField.tsx index fa8647134..77331851c 100644 --- a/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerFiltersForm/FilterField.tsx +++ b/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerFiltersForm/FilterField.tsx @@ -10,6 +10,7 @@ import { import find from "lodash/find"; import { getFieldValueOptions } from "../../hooks/schema"; +import { Audit } from "../../types"; export interface AuditFieldFilter { id: string; @@ -22,6 +23,8 @@ interface Props { onValueChange: (value: AuditFieldFilter) => void; options: FilterOptions; + + knownLogs?: Audit.FlattenedAuditRecord[]; } const FilterField: FC = ({ @@ -29,6 +32,7 @@ const FilterField: FC = ({ onValueChange, options, + knownLogs, }) => { const { schema } = useAuditContext(); @@ -95,7 +99,7 @@ const FilterField: FC = ({ return option?.valueOptions; } - return getFieldValueOptions(field, undefined); + return getFieldValueOptions(field, undefined, knownLogs); }, [value.id, field, options]); return ( diff --git a/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerFiltersForm/index.tsx b/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerFiltersForm/index.tsx index 4eb154639..870e66eca 100644 --- a/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerFiltersForm/index.tsx +++ b/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerFiltersForm/index.tsx @@ -11,18 +11,26 @@ import FilterField, { AuditFieldFilter } from "./FilterField"; import JoinChip from "./JoinChip"; import { useAuditContext } from "../../hooks/context"; import { getAppliedFiltersQuery } from "./utils"; +import { Audit } from "../../types"; interface AuditQueryFilters { fields: AuditFieldFilter[]; } interface Props { + initialField?: string; + + knownLogs?: Audit.FlattenedAuditRecord[]; + filters: FiltersObj; options: FilterOptions; onFilterChange: (filter: FiltersObj) => void; } const AuditLogViewerFiltersForm: FC = ({ + initialField, + knownLogs, + filters, options, onFilterChange, @@ -39,7 +47,7 @@ const AuditLogViewerFiltersForm: FC = ({ const options_ = Object.keys(options); return [ { - id: !!options_?.length ? options_[0] : "", + id: initialField ?? (!!options_?.length ? options_[0] : ""), operator: "=", value: "", }, @@ -47,7 +55,7 @@ const AuditLogViewerFiltersForm: FC = ({ } return values.fields; - }, [values, options]); + }, [values, options, initialField]); const isValid = useMemo(() => { return !!fields.filter( @@ -132,6 +140,7 @@ const AuditLogViewerFiltersForm: FC = ({ value={f} onValueChange={(f) => handleUpdate(f, idx)} options={options} + knownLogs={knownLogs} /> {idx !== 0 && ( handleRemoveCondition(idx)} /> diff --git a/packages/react-mui-audit-log-viewer/src/hooks/context.tsx b/packages/react-mui-audit-log-viewer/src/hooks/context.tsx index a2571d4e5..89eeb02dd 100644 --- a/packages/react-mui-audit-log-viewer/src/hooks/context.tsx +++ b/packages/react-mui-audit-log-viewer/src/hooks/context.tsx @@ -133,7 +133,10 @@ interface AuditContextProviderProps { // Search state initialQuery?: string; + filters?: PublicAuditQuery; + onFiltersChange?: (filters: PublicAuditQuery) => void; + filterOptions?: FilterOptions; } @@ -164,6 +167,7 @@ const AuditContextProvider = ({ // Search state initialQuery, filters, + onFiltersChange, filterOptions, }: AuditContextProviderProps): JSX.Element => { const [offset, setOffset] = useState(0); @@ -179,7 +183,7 @@ const AuditContextProvider = ({ // Search state const [sort, setSort] = useState(); - const queryState = useAuditQueryState(initialQuery, filters); + const queryState = useAuditQueryState(initialQuery, filters, onFiltersChange); return ( void) | undefined = undefined ) => { const [query, setQuery] = useState(initialQuery ?? ""); const [queryObj, setQueryObj] = useState( @@ -23,6 +25,51 @@ export const useAuditQueryState = ( } }, [initialQuery]); + useEffect(() => { + if (!onFiltersChange || !queryObj) return; + + const { active, since, after, before, ...rest } = queryObj; + + let range: AuditQueryRange | undefined; + switch (active) { + case "since": + if (since) { + range = { type: "relative", since }; + } + break; + + case "after": + if (after) { + range = { type: "after", after }; + } + break; + + case "before": + if (before) { + range = { type: "before", before }; + } + break; + + case "between": + if (after && before) { + range = { type: "between", after, before }; + } + break; + + default: + break; + } + + const queryPart: PublicAuditQuery["query"] = query.trim() + ? { type: "string", value: query } + : { type: "object", ...rest }; + + onFiltersChange({ + range, + query: queryPart, + }); + }, [query, queryObj]); + useEffect(() => { if (!publicQueryObj?.range && !publicQueryObj?.query) { if (isEmpty(queryObj)) diff --git a/packages/react-mui-audit-log-viewer/src/hooks/schema.tsx b/packages/react-mui-audit-log-viewer/src/hooks/schema.tsx index 95733c94d..d328f3c58 100644 --- a/packages/react-mui-audit-log-viewer/src/hooks/schema.tsx +++ b/packages/react-mui-audit-log-viewer/src/hooks/schema.tsx @@ -27,6 +27,9 @@ import { import { AuditErrorsColumn } from "../components/AuditLogViewerComponent/errorColumn"; import DateTimeFilterCell from "../components/AuditLogViewerComponent/DateTimeFilterCell"; import { StringCell } from "../components/AuditLogViewerComponent/StringCell"; +import { Box, IconButton, Typography } from "@mui/material"; + +import FilterAltOutlinedIcon from "@mui/icons-material/FilterAltOutlined"; export const DEFAULT_AUDIT_SCHEMA: Audit.Schema = { client_signable: true, @@ -191,7 +194,10 @@ export const useAuditColumns = ( fields: Partial>> | undefined, fieldTypes: | Partial>> - | undefined + | undefined, + + onFilterOpen?: (field: string, event: React.MouseEvent) => void, + getFilterRef?: (field: string) => React.RefCallback ) => { const gridFields: PDG.GridSchemaFields = useMemo(() => { const schemaFields = cloneDeep(schema?.fields ?? []); @@ -226,7 +232,35 @@ export const useAuditColumns = ( }`, // @ts-ignore type: get(COLUMN_TYPE_MAP, field.type, "string"), + sortable: field.type !== "string-unindexed", // FIXME: What fields exactly should be sortable + + ...(!!onFilterOpen && + !!getFilterRef && + field.type !== "string-unindexed" && { + renderHeader: (params: any) => ( + + + {params.colDef.headerName ?? params.field} + + { + onFilterOpen(field.id, event); + event.stopPropagation(); // prevent sort toggle + }} + size="small" + sx={{}} + > + + + + ), + }), + width: 150, ...(field.type === "datetime" && { width: 194, @@ -377,10 +411,10 @@ const getFieldsOperationOptions = (fields: Audit.SchemaField[]) => { export const getFieldValueOptions = ( field: Audit.SchemaField, - options: FieldFilterOptions | undefined -) => { - const valueOptions: AutocompleteValueOption[] = []; + options: FieldFilterOptions | undefined, + knownLogs: Audit.FlattenedAuditRecord[] | undefined = undefined +) => { if (options?.valueOptions) { return options.valueOptions; } @@ -458,7 +492,19 @@ export const getFieldValueOptions = ( ] as AutocompleteValueOption[]; } - return valueOptions; + const seen = new Set(); + for (const log of knownLogs ?? []) { + // @ts-ignore + const val = log[field.id]; + if (typeof val === "string" && val.length <= 64) { + seen.add(val); + } + } + + return Array.from(seen).map((value) => ({ + value: value.includes(" ") ? `"${value}"` : value, + label: value.includes(" ") ? `"${value}"` : value, + })) as AutocompleteValueOption[]; }; const getFieldsConditionalOptions = ( diff --git a/packages/react-mui-audit-log-viewer/src/stories/AuditLogViewer.stories.tsx b/packages/react-mui-audit-log-viewer/src/stories/AuditLogViewer.stories.tsx index 7c5b2c6a6..d12853a56 100644 --- a/packages/react-mui-audit-log-viewer/src/stories/AuditLogViewer.stories.tsx +++ b/packages/react-mui-audit-log-viewer/src/stories/AuditLogViewer.stories.tsx @@ -1,5 +1,6 @@ import React from "react"; import { ComponentStory, ComponentMeta } from "@storybook/react"; + import { Alert, Stack, Typography } from "@mui/material"; import AuditLogViewer from "../AuditLogViewer"; @@ -53,9 +54,15 @@ export const StandardAuditLogViewerExample = Template.bind({}); StandardAuditLogViewerExample.args = { onSearch: TEST_SERVER.onSearch, onPageChange: TEST_SERVER.onPageChange, + + onFiltersChange: (filters) => { + console.log("ON CHANGE", filters); + }, + // Optional 'config' prop allows the component to retrieve a custom Audit schema ...getConfigProps(), // Disable automatic search on each keystroke searchOnChange: false, + searchOnFilterChange: false, }; From 214fb359f636c1074f9998cb890481c585160503 Mon Sep 17 00:00:00 2001 From: "colinjfagan@gmail.com" Date: Wed, 30 Jul 2025 12:04:12 -0700 Subject: [PATCH 2/2] PAN-22374 --- .../react-mui-audit-log-viewer/package.json | 2 +- .../AuditLogViewerComponent/index.tsx | 21 ++++++++- .../AuditLogViewerFiltersForm/FilterField.tsx | 10 ++++- .../src/hooks/context.tsx | 43 ++++++++++++++++--- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/react-mui-audit-log-viewer/package.json b/packages/react-mui-audit-log-viewer/package.json index 34d2be249..d6e9d24ca 100644 --- a/packages/react-mui-audit-log-viewer/package.json +++ b/packages/react-mui-audit-log-viewer/package.json @@ -1,6 +1,6 @@ { "name": "@pangeacyber/react-mui-audit-log-viewer", - "version": "2.0.4-beta.2", + "version": "2.0.4-beta.5", "description": "An extension of material ui data-grid for Pangea audit log records", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerComponent/index.tsx b/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerComponent/index.tsx index 1a885473e..32d991580 100644 --- a/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerComponent/index.tsx +++ b/packages/react-mui-audit-log-viewer/src/components/AuditLogViewerComponent/index.tsx @@ -101,7 +101,10 @@ const AuditLogViewerComponent: FC = ({ filterOptions, } = useAuditContext(); - const { body, bodyWithoutQuery } = useAuditBody(limit, maxResults); + const { body, bodyWithoutQuery, tableSettingsQuery } = useAuditBody( + limit, + maxResults + ); const pagination = usePagination(); const defaultVisibility = useDefaultVisibility(schema); const defaultOrder = useDefaultOrder(schema); @@ -140,6 +143,20 @@ const AuditLogViewerComponent: FC = ({ return onSearch(bodyRef.current); }; + useEffect(() => { + if ( + tableSettingsQuery && + !searchOnChange && + // Check mount ref, so multiple use effects are not triggering the handleSearch + hasMountedRef.current !== undefined + ) { + setTimeout(() => { + handleSearch(); + // Add slight delay since since filters may update the query string + }, 100); + } + }, [tableSettingsQuery, searchOnChange]); + useEffect(() => { const initialQuery_ = initialQuery ?? ""; if ( @@ -315,7 +332,7 @@ const AuditLogViewerComponent: FC = ({ }} flatTop > -
+
= ({ } FieldProps={{ type: "singleSelect", + ValueTypographyProps: { + variant: "body2", + }, options: { valueOptions: fieldOptions, }, @@ -146,7 +149,7 @@ const FilterField: FC = ({ }} /> - + onValueChange({ @@ -157,6 +160,9 @@ const FilterField: FC = ({ value={value.operator} FieldProps={{ type: "singleSelect", + ValueTypographyProps: { + variant: "body2", + }, options: { valueOptions: operationOptions, }, @@ -172,7 +178,7 @@ const FilterField: FC = ({ }} /> - + diff --git a/packages/react-mui-audit-log-viewer/src/hooks/context.tsx b/packages/react-mui-audit-log-viewer/src/hooks/context.tsx index 89eeb02dd..d843567ac 100644 --- a/packages/react-mui-audit-log-viewer/src/hooks/context.tsx +++ b/packages/react-mui-audit-log-viewer/src/hooks/context.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useRef, useMemo, + useCallback, } from "react"; import get from "lodash/get"; import isEmpty from "lodash/isEmpty"; @@ -64,7 +65,8 @@ interface AuditContextShape { // Query state sort: Sort | undefined; - setSort: Dispatch>; + setSort: (sort: Sort | undefined) => void; + query: string; setQuery: Dispatch>; queryObj: AuditQuery | null; @@ -137,6 +139,9 @@ interface AuditContextProviderProps { filters?: PublicAuditQuery; onFiltersChange?: (filters: PublicAuditQuery) => void; + sort?: Sort; + onSortChange?: (sort: Sort | undefined) => void; + filterOptions?: FilterOptions; } @@ -168,6 +173,10 @@ const AuditContextProvider = ({ initialQuery, filters, onFiltersChange, + + sort: controlledSort, + onSortChange, + filterOptions, }: AuditContextProviderProps): JSX.Element => { const [offset, setOffset] = useState(0); @@ -182,7 +191,22 @@ const AuditContextProvider = ({ const consistencyRef = useRef({}); // Search state - const [sort, setSort] = useState(); + const [sort, setSort_] = useState(); + + const setSort = useCallback( + (sort: Sort | undefined) => { + if (onSortChange) onSortChange(sort); + return setSort_(sort); + }, + [setSort_] + ); + + useEffect(() => { + if (controlledSort !== undefined) { + setSort_(controlledSort); + } + }, [controlledSort]); + const queryState = useAuditQueryState(initialQuery, filters, onFiltersChange); return ( @@ -490,6 +514,7 @@ export const useVerification = ( interface UseAuditQuery { body: Audit.SearchRequest | null; bodyWithoutQuery: Audit.SearchRequest | null; + tableSettingsQuery: Partial; } export const useAuditBody = ( @@ -509,26 +534,32 @@ export const useAuditBody = ( return { query: "", ...getTimeFilterKwargs(queryObj), - ...(sort ?? {}), + }; + }, [queryObj, maxResults]); + + const tableSettingsQuery = useMemo>(() => { + return { limit, + ...(sort ?? {}), max_results: maxResults, verbose: true, }; - }, [queryObj, sort, maxResults]); + }, [sort, limit, maxResults]); const body = useMemo(() => { return { + ...tableSettingsQuery, ...bodyWithoutQuery, query: query, }; - }, [query, bodyWithoutQuery]); + }, [query, bodyWithoutQuery, tableSettingsQuery]); useEffect(() => { const queryString = constructQueryString(queryObj); if (queryString) setQuery(queryString); }, [queryObj]); - return { body, bodyWithoutQuery }; + return { body, bodyWithoutQuery, tableSettingsQuery }; }; export default AuditContextProvider;