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..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.3", + "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/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} > = ({ 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); - 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); @@ -121,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 ( @@ -179,6 +215,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 +319,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..3d2401572 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 ( @@ -130,6 +134,9 @@ const FilterField: FC = ({ } FieldProps={{ type: "singleSelect", + ValueTypographyProps: { + variant: "body2", + }, options: { valueOptions: fieldOptions, }, @@ -142,7 +149,7 @@ const FilterField: FC = ({ }} /> - + onValueChange({ @@ -153,6 +160,9 @@ const FilterField: FC = ({ value={value.operator} FieldProps={{ type: "singleSelect", + ValueTypographyProps: { + variant: "body2", + }, options: { valueOptions: operationOptions, }, @@ -168,7 +178,7 @@ const FilterField: FC = ({ }} /> - + 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..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; @@ -133,7 +135,13 @@ interface AuditContextProviderProps { // Search state initialQuery?: string; + filters?: PublicAuditQuery; + onFiltersChange?: (filters: PublicAuditQuery) => void; + + sort?: Sort; + onSortChange?: (sort: Sort | undefined) => void; + filterOptions?: FilterOptions; } @@ -164,6 +172,11 @@ const AuditContextProvider = ({ // Search state initialQuery, filters, + onFiltersChange, + + sort: controlledSort, + onSortChange, + filterOptions, }: AuditContextProviderProps): JSX.Element => { const [offset, setOffset] = useState(0); @@ -178,8 +191,23 @@ const AuditContextProvider = ({ const consistencyRef = useRef({}); // Search state - const [sort, setSort] = useState(); - const queryState = useAuditQueryState(initialQuery, filters); + 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 ( ; } export const useAuditBody = ( @@ -505,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; diff --git a/packages/react-mui-audit-log-viewer/src/hooks/query.ts b/packages/react-mui-audit-log-viewer/src/hooks/query.ts index b2a2b7ad1..69337f047 100644 --- a/packages/react-mui-audit-log-viewer/src/hooks/query.ts +++ b/packages/react-mui-audit-log-viewer/src/hooks/query.ts @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState } from "react"; import isEmpty from "lodash/isEmpty"; -import { AuditQuery, PublicAuditQuery } from "../types/query"; +import { AuditQuery, AuditQueryRange, PublicAuditQuery } from "../types/query"; import { parseLexTokenError } from "../utils/query"; export const useAuditQueryState = ( initialQuery: string | undefined, - publicQueryObj: PublicAuditQuery | undefined + publicQueryObj: PublicAuditQuery | undefined, + + onFiltersChange: ((filters: PublicAuditQuery) => 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, };