diff --git a/sippy-ng/src/component_readiness/ComponentReadiness.js b/sippy-ng/src/component_readiness/ComponentReadiness.js index 433ec199e9..b848beb925 100644 --- a/sippy-ng/src/component_readiness/ComponentReadiness.js +++ b/sippy-ng/src/component_readiness/ComponentReadiness.js @@ -394,6 +394,9 @@ export default function ComponentReadiness(props) { 'regressedModalPage', 'regressedModalTestRow', 'regressedModalTestPage', + 'regressedModalFilters', + 'regressedModalTestFilters', + 'triageFilters', 'searchComponent', 'searchColumn', 'searchRow', diff --git a/sippy-ng/src/component_readiness/RegressedTestsModal.js b/sippy-ng/src/component_readiness/RegressedTestsModal.js index 77346d0071..dddefab87d 100644 --- a/sippy-ng/src/component_readiness/RegressedTestsModal.js +++ b/sippy-ng/src/component_readiness/RegressedTestsModal.js @@ -1,4 +1,3 @@ -import { Box, Button, Grid, Tab, Tabs, Typography } from '@mui/material' import { ArrayParam, NumberParam, @@ -6,6 +5,7 @@ import { useQueryParam, useQueryParams, } from 'use-query-params' +import { Box, Button, Grid, Tab, Tabs, Typography } from '@mui/material' import Dialog from '@mui/material/Dialog' import PropTypes from 'prop-types' import React, { Fragment } from 'react' @@ -69,13 +69,16 @@ export default function RegressedTestsModal({ regressedModalPage: NumberParam, regressedModalTestRow: NumberParam, regressedModalTestPage: NumberParam, + regressedModalFilters: StringParam, + regressedModalTestFilters: StringParam, + triageFilters: StringParam, }, { updateType: 'replaceIn' } ) const handleTabChange = (event, newValue) => { setActiveTab(newValue) - // The active pages and rows in the DataGrid are most likely no longer relevant when switching tabs + // Reset pagination and selection when switching tabs, but keep filters setQuery( { regressedModalRow: undefined, diff --git a/sippy-ng/src/component_readiness/RegressedTestsPanel.js b/sippy-ng/src/component_readiness/RegressedTestsPanel.js index 11bdc9fa7a..58883475a4 100644 --- a/sippy-ng/src/component_readiness/RegressedTestsPanel.js +++ b/sippy-ng/src/component_readiness/RegressedTestsPanel.js @@ -1,14 +1,16 @@ +import { applyFilterModel } from '../datagrid/filterUtils' import { CapabilitiesContext } from '../App' import { CompReadyVarsContext } from './CompReadyVars' -import { DataGrid, GridToolbar } from '@mui/x-data-grid' +import { DataGrid } from '@mui/x-data-grid' import { FileCopy } from '@mui/icons-material' import { formColumnName, generateTestDetailsReportLink } from './CompReadyUtils' import { NumberParam, StringParam, useQueryParam } from 'use-query-params' import { Popover, Snackbar, Tooltip } from '@mui/material' -import { relativeTime } from '../helpers' +import { relativeTime, SafeJSONParam } from '../helpers' import Alert from '@mui/material/Alert' import Button from '@mui/material/Button' import CompSeverityIcon from './CompSeverityIcon' +import GridToolbar from '../datagrid/GridToolbar' import IconButton from '@mui/material/IconButton' import PropTypes from 'prop-types' import React, { Fragment, useContext } from 'react' @@ -25,12 +27,57 @@ export default function RegressedTestsPanel(props) { NumberParam, { updateType: 'replaceIn' } ) + const [filterModel = { items: [] }, setFilterModel] = useQueryParam( + 'regressedModalFilters', + SafeJSONParam, + { updateType: 'replaceIn' } + ) const { expandEnvironment, views, view } = useContext(CompReadyVarsContext) const { filterVals, regressedTests, setTriageActionTaken } = props const [sortModel, setSortModel] = React.useState([ { field: 'component', sort: 'asc' }, ]) + const addFilters = (filter) => { + const currentFilters = filterModel.items.filter((item) => item.value !== '') + + filter.forEach((item) => { + if (item.value && item.value !== '') { + currentFilters.push(item) + } + }) + setFilterModel({ + items: currentFilters, + linkOperator: filterModel.linkOperator || 'and', + }) + } + + // Quick search functionality - searches test_name field + const requestSearch = (searchValue) => { + const currentFilters = { ...filterModel } + currentFilters.items = currentFilters.items.filter( + (f) => f.columnField !== 'test_name' + ) + if (searchValue && searchValue !== '') { + currentFilters.items.push({ + id: 99, + columnField: 'test_name', + operatorValue: 'contains', + value: searchValue, + }) + } + setFilterModel({ + items: currentFilters.items, + linkOperator: currentFilters.linkOperator || 'and', + }) + } + + // Apply client-side filtering using shared utility + const filteredTests = React.useMemo( + () => applyFilterModel(regressedTests, filterModel), + [regressedTests, filterModel] + ) + // Helpers for copying the test ID to clipboard const [copyPopoverEl, setCopyPopoverEl] = React.useState(null) const copyPopoverOpen = Boolean(copyPopoverEl) @@ -86,6 +133,7 @@ export default function RegressedTestsPanel(props) { field: 'triage', headerName: 'Triage', flex: 4, + filterable: false, valueGetter: (params) => { if (!params.row.regression?.opened) { // For a regression we haven't yet detected: @@ -110,30 +158,35 @@ export default function RegressedTestsPanel(props) { field: 'component', headerName: 'Component', flex: 20, + autocomplete: 'component', renderCell: (param) =>
{param.value}
, }, { field: 'capability', headerName: 'Capability', flex: 12, + autocomplete: 'capability', renderCell: (param) =>
{param.value}
, }, { field: 'test_name', headerName: 'Test Name', flex: 40, + autocomplete: 'test_name', renderCell: (param) =>
{param.value}
, }, { field: 'test_suite', headerName: 'Test Suite', flex: 15, + autocomplete: 'test_suite', renderCell: (param) =>
{param.value}
, }, { field: 'variants', headerName: 'Variants', flex: 30, + filterable: false, valueGetter: (params) => { return formColumnName({ variants: params.row.variants }) }, @@ -143,6 +196,7 @@ export default function RegressedTestsPanel(props) { field: 'regression', headerName: 'Regressed Since', flex: 12, + filterable: false, valueGetter: (params) => { if (!params.row.regression?.opened) { // For a regression we haven't yet detected: @@ -172,6 +226,7 @@ export default function RegressedTestsPanel(props) { field: 'last_failure', headerName: 'Last Failure', flex: 12, + filterable: false, valueGetter: (params) => { if (!params.row.last_failure) { return null @@ -192,6 +247,7 @@ export default function RegressedTestsPanel(props) { field: 'test_id', flex: 5, headerName: 'ID', + filterable: false, renderCell: (params) => { return ( (
row.test_id + @@ -285,7 +342,12 @@ export default function RegressedTestsPanel(props) { componentsProps={{ toolbar: { columns: columns, - showQuickFilter: true, + addFilters: addFilters, + filterModel: filterModel, + setFilterModel: setFilterModel, + clearSearch: () => requestSearch(''), + doSearch: requestSearch, + autocompleteData: regressedTests, }, }} /> diff --git a/sippy-ng/src/component_readiness/TriagedRegressionTestList.js b/sippy-ng/src/component_readiness/TriagedRegressionTestList.js index 545928a495..3a23bafcde 100644 --- a/sippy-ng/src/component_readiness/TriagedRegressionTestList.js +++ b/sippy-ng/src/component_readiness/TriagedRegressionTestList.js @@ -1,10 +1,12 @@ +import { applyFilterModel } from '../datagrid/filterUtils' import { CompReadyVarsContext } from './CompReadyVars' -import { DataGrid, GridToolbar } from '@mui/x-data-grid' +import { DataGrid } from '@mui/x-data-grid' import { generateTestDetailsReportLink } from './CompReadyUtils' import { NumberParam, useQueryParam } from 'use-query-params' -import { relativeTime } from '../helpers' +import { relativeTime, SafeJSONParam } from '../helpers' import { Tooltip, Typography } from '@mui/material' import CompSeverityIcon from './CompSeverityIcon' +import GridToolbar from '../datagrid/GridToolbar' import PropTypes from 'prop-types' import React, { Fragment, useContext } from 'react' @@ -21,11 +23,50 @@ export default function TriagedRegressionTestList(props) { NumberParam, { updateType: 'replaceIn' } ) + const [filterModel = { items: [] }, setFilterModel] = useQueryParam( + 'regressedModalTestFilters', + SafeJSONParam, + { updateType: 'replaceIn' } + ) const [sortModel, setSortModel] = React.useState([ { field: 'component', sort: 'asc' }, ]) + const addFilters = (filter) => { + const currentFilters = filterModel.items.filter((item) => item.value !== '') + + filter.forEach((item) => { + if (item.value && item.value !== '') { + currentFilters.push(item) + } + }) + setFilterModel({ + items: currentFilters, + linkOperator: filterModel.linkOperator || 'and', + }) + } + + // Quick search functionality - searches test_name field + const requestSearch = (searchValue) => { + const currentFilters = { ...filterModel } + currentFilters.items = currentFilters.items.filter( + (f) => f.columnField !== 'test_name' + ) + if (searchValue && searchValue !== '') { + currentFilters.items.push({ + id: 99, + columnField: 'test_name', + operatorValue: 'contains', + value: searchValue, + }) + } + setFilterModel({ + items: currentFilters.items, + linkOperator: currentFilters.linkOperator || 'and', + }) + } + const [triagedRegressions, setTriagedRegressions] = React.useState( props.regressions !== undefined ? props.regressions : [] ) @@ -33,6 +74,12 @@ export default function TriagedRegressionTestList(props) { props.regressions !== undefined && props.regressions.length > 0 ) + // Apply client-side filtering using shared utility + const filteredRegressions = React.useMemo( + () => applyFilterModel(triagedRegressions, filterModel), + [triagedRegressions, filterModel] + ) + const handleTriagedRegressionGroupSelectionChanged = (data) => { let displayView = false if (data) { @@ -58,6 +105,7 @@ export default function TriagedRegressionTestList(props) { field: 'test_name', headerName: 'Test Name', flex: 50, + autocomplete: 'test_name', valueGetter: (params) => { return params.row.test_name }, @@ -67,6 +115,7 @@ export default function TriagedRegressionTestList(props) { field: 'release', headerName: 'Release', flex: 7, + autocomplete: 'release', valueGetter: (params) => { return params.row.release }, @@ -76,6 +125,7 @@ export default function TriagedRegressionTestList(props) { field: 'variants', headerName: 'Variants', flex: 20, + filterable: false, renderCell: (params) => (
{params.value ? params.value.sort().join('\n') : ''} @@ -86,6 +136,7 @@ export default function TriagedRegressionTestList(props) { field: 'opened', headerName: 'Regressed Since', flex: 12, + filterable: false, valueGetter: (params) => { if (!params.row.opened) { // For a regression we haven't yet detected: @@ -104,6 +155,7 @@ export default function TriagedRegressionTestList(props) { field: 'last_failure', headerName: 'Last Failure', flex: 12, + filterable: false, valueGetter: (params) => { if (!params.row.last_failure.Valid) { return null @@ -125,6 +177,7 @@ export default function TriagedRegressionTestList(props) { { field: 'status', headerName: 'Status', + filterable: false, renderHeader: () => ( Status @@ -184,7 +237,7 @@ export default function TriagedRegressionTestList(props) { sortModel={sortModel} onSortModelChange={setSortModel} components={{ Toolbar: GridToolbar }} - rows={triagedRegressions} + rows={filteredRegressions} columns={columns} getRowHeight={() => 'auto'} getRowId={(row) => row.id} @@ -205,6 +258,12 @@ export default function TriagedRegressionTestList(props) { componentsProps={{ toolbar: { columns: columns, + addFilters: addFilters, + filterModel: filterModel, + setFilterModel: setFilterModel, + clearSearch: () => requestSearch(''), + doSearch: requestSearch, + autocompleteData: triagedRegressions, }, }} /> diff --git a/sippy-ng/src/component_readiness/TriagedRegressions.js b/sippy-ng/src/component_readiness/TriagedRegressions.js index 5fb3254bf9..f71fb658ff 100644 --- a/sippy-ng/src/component_readiness/TriagedRegressions.js +++ b/sippy-ng/src/component_readiness/TriagedRegressions.js @@ -1,12 +1,14 @@ +import { applyFilterModel } from '../datagrid/filterUtils' import { CheckCircle, Error as ErrorIcon } from '@mui/icons-material' -import { DataGrid, GridToolbar } from '@mui/x-data-grid' -import { formatDateToSeconds, relativeTime } from '../helpers' +import { DataGrid } from '@mui/x-data-grid' +import { formatDateToSeconds, relativeTime, SafeJSONParam } from '../helpers' import { hasFailedFixRegression, jiraUrlPrefix } from './CompReadyUtils' import { Link } from 'react-router-dom' import { NumberParam, StringParam, useQueryParam } from 'use-query-params' import { Tooltip, Typography } from '@mui/material' import { useTheme } from '@mui/material/styles' import CompSeverityIcon from './CompSeverityIcon' +import GridToolbar from '../datagrid/GridToolbar' import InfoIcon from '@mui/icons-material/Info' import PropTypes from 'prop-types' import React, { Fragment, useEffect } from 'react' @@ -37,6 +39,51 @@ export default function TriagedRegressions({ NumberParam, { updateType: 'replaceIn' } ) + const [filterModel = { items: [] }, setFilterModel] = useQueryParam( + 'triageFilters', + SafeJSONParam, + { updateType: 'replaceIn' } + ) + + const addFilters = (filter) => { + const currentFilters = filterModel.items.filter((item) => item.value !== '') + + filter.forEach((item) => { + if (item.value && item.value !== '') { + currentFilters.push(item) + } + }) + setFilterModel({ + items: currentFilters, + linkOperator: filterModel.linkOperator || 'and', + }) + } + + // Quick search functionality - searches description field + const requestSearch = (searchValue) => { + const currentFilters = { ...filterModel } + currentFilters.items = currentFilters.items.filter( + (f) => f.columnField !== 'description' + ) + if (searchValue && searchValue !== '') { + currentFilters.items.push({ + id: 99, + columnField: 'description', + operatorValue: 'contains', + value: searchValue, + }) + } + setFilterModel({ + items: currentFilters.items, + linkOperator: currentFilters.linkOperator || 'and', + }) + } + + // Apply client-side filtering using shared utility + const filteredTriageEntries = React.useMemo( + () => applyFilterModel(triageEntries, filterModel), + [triageEntries, filterModel] + ) useEffect(() => { if (activeRow) { @@ -84,6 +131,7 @@ export default function TriagedRegressions({ headerName: 'Resolved', flex: 4, align: 'center', + filterable: false, renderCell: (param) => { const triage = triageEntries.find((t) => t.id === param.row.id) const hasFailedFix = hasFailedFixRegression(triage, allRegressedTests) @@ -115,6 +163,7 @@ export default function TriagedRegressions({ }, headerName: 'Description', flex: 20, + autocomplete: 'description', renderCell: (param) =>
{param.value}
, }, { @@ -124,6 +173,7 @@ export default function TriagedRegressions({ }, headerName: 'Type', flex: 5, + autocomplete: 'type', renderCell: (param) =>
{param.value}
, }, { @@ -141,6 +191,7 @@ export default function TriagedRegressions({ }, headerName: 'Jira', flex: 5, + filterable: false, renderCell: (param) => (
{param.value.text}
@@ -154,6 +205,7 @@ export default function TriagedRegressions({ }, headerName: 'State', flex: 5, + autocomplete: 'bug_state', renderCell: (param) =>
{param.value}
, }, { @@ -167,6 +219,7 @@ export default function TriagedRegressions({ }, headerName: 'Version', flex: 5, + autocomplete: 'bug_version', renderCell: (param) =>
{param.value}
, }, { @@ -176,6 +229,7 @@ export default function TriagedRegressions({ }, headerName: 'Release Blocker', flex: 5, + autocomplete: 'release_blocker', renderCell: (param) =>
{param.value}
, }, { @@ -185,6 +239,7 @@ export default function TriagedRegressions({ }, headerName: 'Jira updated', flex: 5, + filterable: false, renderCell: (param) => (
@@ -196,6 +251,7 @@ export default function TriagedRegressions({ { field: 'created_at', hide: true, + filterable: false, valueGetter: (value) => { return value.row.created_at }, @@ -212,6 +268,7 @@ export default function TriagedRegressions({ { field: 'updated_at', hide: true, + filterable: false, valueGetter: (value) => { return value.row.updated_at }, @@ -232,6 +289,7 @@ export default function TriagedRegressions({ }, headerName: 'Details', flex: 2, + filterable: false, renderCell: (param) => { const triageUrl = '/component_readiness/triages/' + param.value return ( @@ -252,7 +310,7 @@ export default function TriagedRegressions({ selectionModel={activeRow} onSelectionModelChange={handleSetSelectionModel} components={{ Toolbar: GridToolbar }} - rows={triageEntries} + rows={filteredTriageEntries} columns={columns} getRowId={(row) => String(row.id)} pageSize={entriesPerPage} @@ -266,6 +324,12 @@ export default function TriagedRegressions({ componentsProps={{ toolbar: { columns: columns, + addFilters: addFilters, + filterModel: filterModel, + setFilterModel: setFilterModel, + clearSearch: () => requestSearch(''), + doSearch: requestSearch, + autocompleteData: triageEntries, }, }} /> diff --git a/sippy-ng/src/datagrid/GridToolbar.js b/sippy-ng/src/datagrid/GridToolbar.js index 0f3906eebe..602a3f640d 100644 --- a/sippy-ng/src/datagrid/GridToolbar.js +++ b/sippy-ng/src/datagrid/GridToolbar.js @@ -47,6 +47,7 @@ export default function GridToolbar(props) { columns={props.columns} filterModel={props.filterModel} setFilterModel={props.setFilterModel} + autocompleteData={props.autocompleteData} /> {props.bookmarks ? ( @@ -143,4 +144,5 @@ GridToolbar.propTypes = { value: PropTypes.string, downloadDataFunc: PropTypes.func, downloadFilePrefix: PropTypes.string, + autocompleteData: PropTypes.array, } diff --git a/sippy-ng/src/datagrid/GridToolbarAutocomplete.js b/sippy-ng/src/datagrid/GridToolbarAutocomplete.js index 04da195e58..32d26d38d9 100644 --- a/sippy-ng/src/datagrid/GridToolbarAutocomplete.js +++ b/sippy-ng/src/datagrid/GridToolbarAutocomplete.js @@ -43,7 +43,7 @@ export default function GridToolbarAutocomplete(props) { // Based on https://stackoverflow.com/a/61973338/1683486 return ( { setOpen(false) }} - onChange={(e, v) => v && props.onChange(v.name)} - defaultValue={{ name: props.value }} - isOptionEqualToValue={(option, value) => option.name === value.name} - getOptionLabel={(option) => option.name} + onChange={(e, v) => { + if (typeof v === 'string') { + props.onChange(v) + } else if (v && v.name) { + props.onChange(v.name) + } + }} + onInputChange={(e, value) => { + if (e && e.type === 'change') { + props.onChange(value) + } + }} + value={props.value || ''} + inputValue={props.value || ''} + isOptionEqualToValue={(option, value) => + option.name === (typeof value === 'string' ? value : value.name) + } + getOptionLabel={(option) => + typeof option === 'string' ? option : option.name + } options={options} loading={loading} renderInput={(params) => ( diff --git a/sippy-ng/src/datagrid/GridToolbarClientAutocomplete.js b/sippy-ng/src/datagrid/GridToolbarClientAutocomplete.js new file mode 100644 index 0000000000..a75c56ee35 --- /dev/null +++ b/sippy-ng/src/datagrid/GridToolbarClientAutocomplete.js @@ -0,0 +1,92 @@ +import Autocomplete from '@mui/lab/Autocomplete' +import PropTypes from 'prop-types' +import React from 'react' +import TextField from '@mui/material/TextField' + +/** + * Client-side autocomplete for filter fields + * Extracts unique values from the provided data instead of making API calls + */ +export default function GridToolbarClientAutocomplete(props) { + const [open, setOpen] = React.useState(false) + + // Extract unique values from the data for this field + const options = React.useMemo(() => { + if (!props.data || !Array.isArray(props.data)) { + return [] + } + + const uniqueValues = new Set() + props.data.forEach((row) => { + const value = row[props.field] + if (value !== null && value !== undefined && value !== '') { + // Handle arrays (like variants) + if (Array.isArray(value)) { + value.forEach((v) => uniqueValues.add(String(v))) + } else { + uniqueValues.add(String(value)) + } + } + }) + + // Convert to array and sort + return Array.from(uniqueValues) + .sort() + .map((v) => ({ name: v })) + }, [props.data, props.field]) + + return ( + { + setOpen(true) + }} + onClose={() => { + setOpen(false) + }} + onChange={(e, v) => { + if (typeof v === 'string') { + props.onChange(v) + } else if (v && v.name) { + props.onChange(v.name) + } + }} + onInputChange={(e, value) => { + if (e && e.type === 'change') { + props.onChange(value) + } + }} + value={props.value || ''} + inputValue={props.value || ''} + isOptionEqualToValue={(option, value) => + option.name === (typeof value === 'string' ? value : value.name) + } + getOptionLabel={(option) => + typeof option === 'string' ? option : option.name + } + options={options} + renderInput={(params) => ( + + )} + /> + ) +} + +GridToolbarClientAutocomplete.propTypes = { + id: PropTypes.string, + error: PropTypes.bool, + label: PropTypes.string, + field: PropTypes.string, + value: PropTypes.string, + onChange: PropTypes.func, + data: PropTypes.array.isRequired, +} diff --git a/sippy-ng/src/datagrid/GridToolbarFilterItem.js b/sippy-ng/src/datagrid/GridToolbarFilterItem.js index 285e9ee88c..02b20b9a2c 100644 --- a/sippy-ng/src/datagrid/GridToolbarFilterItem.js +++ b/sippy-ng/src/datagrid/GridToolbarFilterItem.js @@ -14,6 +14,7 @@ import { Close } from '@mui/icons-material' import { DateTimePicker, LocalizationProvider } from '@mui/x-date-pickers' import { makeStyles } from '@mui/styles' import GridToolbarAutocomplete from './GridToolbarAutocomplete' +import GridToolbarClientAutocomplete from './GridToolbarClientAutocomplete' import PropTypes from 'prop-types' import React, { Fragment } from 'react' @@ -120,24 +121,48 @@ export default function GridToolbarFilterItem(props) { ) default: if (autocomplete !== '') { - return ( - - props.setFilterModel({ - columnField: props.filterModel.columnField, - not: props.filterModel.not, - operatorValue: props.filterModel.operatorValue, - value: value, - }) - } - /> - ) + // Use client-side autocomplete if data is available, otherwise use server-side + if (props.autocompleteData && props.autocompleteData.length > 0) { + return ( + + props.setFilterModel({ + columnField: props.filterModel.columnField, + not: props.filterModel.not, + operatorValue: props.filterModel.operatorValue, + value: value, + }) + } + /> + ) + } else { + return ( + + props.setFilterModel({ + columnField: props.filterModel.columnField, + not: props.filterModel.not, + operatorValue: props.filterModel.operatorValue, + value: value, + }) + } + /> + ) + } } else { return ( @@ -281,4 +306,5 @@ GridToolbarFilterItem.propTypes = { disabled: PropTypes.bool, }).isRequired ), + autocompleteData: PropTypes.array, } diff --git a/sippy-ng/src/datagrid/GridToolbarFilterMenu.js b/sippy-ng/src/datagrid/GridToolbarFilterMenu.js index 141dec848d..ba7f2f4ebe 100644 --- a/sippy-ng/src/datagrid/GridToolbarFilterMenu.js +++ b/sippy-ng/src/datagrid/GridToolbarFilterMenu.js @@ -234,6 +234,7 @@ export default function GridToolbarFilterMenu(props) { destroy={() => removeFilter(index)} filterModel={models[index]} setFilterModel={(v) => updateModel(index, v)} + autocompleteData={props.autocompleteData} />
@@ -294,4 +295,5 @@ GridToolbarFilterMenu.propTypes = { type: PropTypes.string, }) ), + autocompleteData: PropTypes.array, } diff --git a/sippy-ng/src/datagrid/filterUtils.js b/sippy-ng/src/datagrid/filterUtils.js new file mode 100644 index 0000000000..e2eef63496 --- /dev/null +++ b/sippy-ng/src/datagrid/filterUtils.js @@ -0,0 +1,121 @@ +/** + * Client-side filtering utilities for DataGrid components. + * + * These utilities support Sippy's custom filter model which allows: + * - Multiple filters with AND/OR operators + * - NOT modifier for negating filters + * - Various filter operators (contains, equals, startsWith, endsWith, comparison operators) + */ + +/** + * Applies a filter model to an array of rows + * + * @param {Array} rows - The data rows to filter + * @param {Object} filterModel - The filter model with items and linkOperator + * @param {Array} filterModel.items - Array of filter items + * @param {string} filterModel.linkOperator - 'and' or 'or' to combine filters + * @returns {Array} Filtered rows + */ +export function applyFilterModel(rows, filterModel) { + if (!filterModel || !filterModel.items || filterModel.items.length === 0) { + return rows + } + + return rows.filter((row) => { + const results = filterModel.items.map((filter) => + evaluateFilter(row, filter) + ) + + // Apply AND/OR logic + const linkOperator = filterModel.linkOperator || 'and' + return linkOperator === 'and' + ? results.every((r) => r) + : results.some((r) => r) + }) +} + +/** + * Evaluates a single filter against a row + * + * @param {Object} row - The data row + * @param {Object} filter - The filter to apply + * @param {string} filter.columnField - The field name to filter on + * @param {string} filter.operatorValue - The comparison operator + * @param {any} filter.value - The value to compare against + * @param {boolean} filter.not - Whether to negate the result + * @returns {boolean} Whether the row matches the filter + */ +export function evaluateFilter(row, filter) { + const fieldValue = row[filter.columnField] + let match = false + + // Handle null/undefined values + if (fieldValue === null || fieldValue === undefined) { + return filter.not ? true : false + } + + const value = String(fieldValue).toLowerCase() + const filterValue = String(filter.value).toLowerCase() + + switch (filter.operatorValue) { + case 'contains': + match = value.includes(filterValue) + break + case 'equals': + match = value === filterValue + break + case 'startsWith': + match = value.startsWith(filterValue) + break + case 'endsWith': + match = value.endsWith(filterValue) + break + case 'isEmpty': + case 'is empty': + match = value === '' + break + case 'isNotEmpty': + case 'is not empty': + match = value !== '' + break + case '>': + match = parseFloat(fieldValue) > parseFloat(filter.value) + break + case '>=': + match = parseFloat(fieldValue) >= parseFloat(filter.value) + break + case '<': + match = parseFloat(fieldValue) < parseFloat(filter.value) + break + case '<=': + match = parseFloat(fieldValue) <= parseFloat(filter.value) + break + case '!=': + case 'not equals': + match = value !== filterValue + break + default: + // Default to contains for unknown operators + match = value.includes(filterValue) + } + + return filter.not ? !match : match +} + +/** + * React hook for managing filtered data with a filter model + * + * @param {Array} data - The data to filter + * @param {Object} filterModel - The filter model + * @returns {Array} Filtered data + * + * @example + * const filteredRows = useFilteredData(rows, filterModel) + */ +export function useFilteredData(data, filterModel) { + const React = require('react') + return React.useMemo( + () => applyFilterModel(data, filterModel), + [data, filterModel] + ) +} diff --git a/sippy-ng/src/datagrid/filterUtils.md b/sippy-ng/src/datagrid/filterUtils.md new file mode 100644 index 0000000000..a7f2577568 --- /dev/null +++ b/sippy-ng/src/datagrid/filterUtils.md @@ -0,0 +1,166 @@ +# Client-Side Filtering Utilities + +This module provides client-side filtering utilities for DataGrid components that work with Sippy's custom filter model. + +## Why This Exists + +MUI DataGrid's built-in client-side filtering only supports single filters. Sippy's filter system is more advanced, supporting: +- **Multiple filters** with AND/OR operators +- **NOT modifier** for negating filters +- **Various filter operators** (contains, equals, startsWith, endsWith, comparison operators) + +For components that work with pre-loaded data (like modals), we need to manually filter the data client-side. + +## Usage + +### Basic Example + +```javascript +import { applyFilterModel } from '../datagrid/filterUtils' +import React from 'react' + +function MyComponent({ data, filterModel }) { + // Filter the data client-side + const filteredData = React.useMemo( + () => applyFilterModel(data, filterModel), + [data, filterModel] + ) + + return +} +``` + +### Complete Example with Query Parameters + +```javascript +import { applyFilterModel } from '../datagrid/filterUtils' +import { DataGrid } from '@mui/x-data-grid' +import { SafeJSONParam } from '../helpers' +import { useQueryParam } from 'use-query-params' +import GridToolbar from '../datagrid/GridToolbar' +import React from 'react' + +export default function MyFilterableComponent({ data }) { + // Manage filter state in URL query parameters + const [filterModel = { items: [] }, setFilterModel] = useQueryParam( + 'filters', + SafeJSONParam + ) + + // Add filters from bookmarks or other sources + const addFilters = (filter) => { + const currentFilters = filterModel.items.filter((item) => item.value !== '') + filter.forEach((item) => { + if (item.value && item.value !== '') { + currentFilters.push(item) + } + }) + setFilterModel({ + items: currentFilters, + linkOperator: filterModel.linkOperator || 'and', + }) + } + + // Apply client-side filtering + const filteredData = React.useMemo( + () => applyFilterModel(data, filterModel), + [data, filterModel] + ) + + return ( + {}, + doSearch: () => {}, + }, + }} + /> + ) +} +``` + +## API + +### `applyFilterModel(rows, filterModel)` + +Applies a filter model to an array of rows. + +**Parameters:** +- `rows` (Array): The data rows to filter +- `filterModel` (Object): The filter model with `items` and `linkOperator` + - `items` (Array): Array of filter items + - `linkOperator` (string): 'and' or 'or' to combine filters + +**Returns:** Array of filtered rows + +### `evaluateFilter(row, filter)` + +Evaluates a single filter against a row. + +**Parameters:** +- `row` (Object): The data row +- `filter` (Object): The filter to apply + - `columnField` (string): The field name to filter on + - `operatorValue` (string): The comparison operator + - `value` (any): The value to compare against + - `not` (boolean): Whether to negate the result + +**Returns:** Boolean indicating whether the row matches the filter + +## Supported Filter Operators + +- `contains`: Case-insensitive substring match +- `equals`: Exact match (case-insensitive) +- `startsWith`: Starts with (case-insensitive) +- `endsWith`: Ends with (case-insensitive) +- `isEmpty` / `is empty`: Field is empty string +- `isNotEmpty` / `is not empty`: Field is not empty +- `>`: Greater than (numeric) +- `>=`: Greater than or equal (numeric) +- `<`: Less than (numeric) +- `<=`: Less than or equal (numeric) +- `!=` / `not equals`: Not equal to + +## Filter Model Structure + +```javascript +{ + items: [ + { + columnField: 'component', + operatorValue: 'contains', + value: 'storage', + not: false + }, + { + columnField: 'status', + operatorValue: 'equals', + value: 'regressed', + not: false + } + ], + linkOperator: 'and' // or 'or' +} +``` + +## Performance + +The `applyFilterModel` function uses Array.filter which is O(n). For large datasets, use it with `React.useMemo` to avoid unnecessary recomputations: + +```javascript +const filteredData = React.useMemo( + () => applyFilterModel(data, filterModel), + [data, filterModel] +) +``` + +This ensures filtering only happens when `data` or `filterModel` changes. +