diff --git a/components/src/Table/ColumnFilter.tsx b/components/src/Table/ColumnFilter.tsx new file mode 100644 index 0000000..21e0a90 --- /dev/null +++ b/components/src/Table/ColumnFilter.tsx @@ -0,0 +1,172 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Box, ButtonBase, Typography, useTheme } from '@mui/material'; +import { ReactElement, useMemo, useRef, useState } from 'react'; +import { ColumnFilterDropdown } from './ColumnFilterDropDown'; +import { TableColumnConfig } from './model/table-model'; +import { FilterColumns } from './TableFilters'; + +interface Props extends FilterColumns { + id: string; + width?: number | 'auto'; + filters: Array; + borderRight: string; + column: TableColumnConfig; + columnUniqueValues: Record>; + openFilterColumn?: string; + setOpenFilterColumn: (columnId?: string) => void; +} + +export function ColumnFilter({ + id, + width, + filters, + column, + setColumnFilters, + columnFilters, + borderRight, + columnUniqueValues, + openFilterColumn, + setOpenFilterColumn, +}: Props): ReactElement { + const theme = useTheme(); + const dropdownId = id.concat('-dropdown'); + + const [filterAnchorEl, setFilterAnchorEl] = useState(undefined); + const [calculatedWidth, setCalculatedWidth] = useState('0px'); + + const handleFilterClick = (event: React.MouseEvent, columnId: string): void => { + event.preventDefault(); + event.stopPropagation(); + setFilterAnchorEl(event.target as HTMLButtonElement); + setOpenFilterColumn(columnId); + }; + + const handleFilterClose = (): void => { + setFilterAnchorEl(undefined); + setOpenFilterColumn(undefined); + }; + + const updateColumnFilter = (columnId: string, values: Array): void => { + const newFilters = columnFilters.filter((f) => f.id !== columnId); + if (values.length) { + newFilters.push({ id: columnId, value: values }); + } + setColumnFilters(newFilters); + }; + + const mainContainerRef = useRef(null); + const [mainContainerDimension, setMainContainerDimension] = useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + + const observeDimensionChanges = (htmlElements: ResizeObserverEntry[]): void => { + if (htmlElements?.length) { + const targetElement = htmlElements[0]?.target as HTMLElement; + const width = targetElement.offsetWidth; + const height = targetElement.offsetHeight; + setMainContainerDimension({ width, height }); + } + }; + + /** + * Width is taken from the optional column.width. Therefore, it could be possibly undefined + * To handle this, we need the actual width of the container to adjust the width of the dropdown. They need to be perfectly aligned + * Also, using an observer is necessary due to the effects of the toggle view mode which changes the table dimension + */ + const observer = useRef(new ResizeObserver(observeDimensionChanges)); + if (mainContainerRef.current) { + observer.current.observe(mainContainerRef.current); + } + + useMemo(() => { + if (width !== undefined) { + setCalculatedWidth(typeof width === 'number' ? `${width}px` : width); + } else if (mainContainerDimension) { + setCalculatedWidth(`${mainContainerDimension.width}px`); + } + }, [width, mainContainerDimension]); + + return ( + + + {filters.length ? `${filters.length} items` : 'All'} + + + handleFilterClick(e, column.accessorKey as string)} + sx={{ + border: '1px solid', + borderColor: 'divider', + backgroundColor: 'background.paper', + fontSize: '12px', + color: filters.length ? 'primary.main' : 'text.secondary', + px: 1, + py: 0.5, + borderRadius: 1, + minWidth: '20px', + height: '24px', + flexShrink: 0, + transition: (theme) => theme.transitions.create('all', { duration: 200 }), + '&:hover': { + backgroundColor: 'action.hover', + }, + }} + > + ▼ + + {filterAnchorEl && ( + updateColumnFilter(column.accessorKey as string, values)} + theme={theme} + handleFilterClose={handleFilterClose} + /> + )} + + ); +} diff --git a/components/src/Table/ColumnFilterDropDown.tsx b/components/src/Table/ColumnFilterDropDown.tsx new file mode 100644 index 0000000..8cf4fca --- /dev/null +++ b/components/src/Table/ColumnFilterDropDown.tsx @@ -0,0 +1,150 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ReactElement } from 'react'; +import { Box, Checkbox, Divider, FormControlLabel, Theme, Typography, Popover } from '@mui/material'; + +interface Props { + id: string; + allValues: Array; + selectedValues: Array; + onFilterChange: (values: Array) => void; + handleFilterClose: () => void; + theme: Theme; + width: string; + anchor: HTMLButtonElement; + open: boolean; +} + +export const ColumnFilterDropdown = ({ + id, + allValues, + selectedValues, + onFilterChange, + handleFilterClose, + theme, + width, + open, + anchor, +}: Props): ReactElement => { + const values = [...new Set(allValues)].filter((v) => v !== null).sort(); + + if (!values.length) { + return ( + + + No values found + + + ); + } + + return ( + + + + 0} + onChange={(e) => onFilterChange(e.target.checked ? values : [])} + indeterminate={selectedValues.length > 0 && selectedValues.length < values.length} + /> + } + label={Select All ({values.length})} + /> + + + {values.map((value, index) => ( + + { + if (e.target.checked) { + onFilterChange([...selectedValues, value]); + } else { + onFilterChange(selectedValues.filter((v) => v !== value)); + } + }} + /> + } + label={ + + {!value && value !== 0 ? '(empty)' : String(value)} + + } + /> + + ))} + + + ); +}; diff --git a/components/src/Table/Table.tsx b/components/src/Table/Table.tsx index 2a0d1ee..07205c7 100644 --- a/components/src/Table/Table.tsx +++ b/components/src/Table/Table.tsx @@ -14,6 +14,7 @@ import { Stack, useTheme } from '@mui/material'; import { ColumnDef, + ColumnFiltersState, OnChangeFn, Row, RowSelectionState, @@ -24,11 +25,14 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { ReactElement, useCallback, useMemo } from 'react'; +import { ReactElement, useCallback, useMemo, useState } from 'react'; + import { TableCheckbox } from './TableCheckbox'; import { VirtualizedTable } from './VirtualizedTable'; import { DEFAULT_COLUMN_WIDTH, TableProps, persesColumnsToTanstackColumns } from './model/table-model'; +import { TableFilter } from './TableFilters'; + const DEFAULT_GET_ROW_ID = (data: unknown, index: number): string => { return `${index}`; }; @@ -64,6 +68,8 @@ export function Table({ pagination, onPaginationChange, rowSelectionVariant = 'standard', + filteringEnabled = false, + width, ...otherProps }: TableProps): ReactElement { const theme = useTheme(); @@ -102,8 +108,8 @@ export function Table({ e.nativeEvent && (e.nativeEvent instanceof MouseEvent || e.nativeEvent instanceof KeyboardEvent) ? (e.nativeEvent as PointerEvent) : undefined; - const isModifed = !!nativePointerEvent?.metaKey || !!nativePointerEvent?.shiftKey; - handleRowSelectionEvent(table, row, isModifed); + const isModified = !!nativePointerEvent?.metaKey || !!nativePointerEvent?.shiftKey; + handleRowSelectionEvent(table, row, isModified); }, [handleRowSelectionEvent] ); @@ -174,8 +180,26 @@ export function Table({ return initTableColumns; }, [checkboxColumn, checkboxSelection, columns, hasItemActions, actionsColumn]); + const [columnFilters, setColumnFilters] = useState([]); + + const filteredData = useMemo(() => { + if (!filteringEnabled || !columnFilters.length) { + return [...data]; + } + + return data.filter((row) => + columnFilters.every(({ id, value }) => { + const rowValue = (row as Record)[id]; + const filterValues = value as Array; + // Use optional chaining and early return for clarity + if (!filterValues?.length) return true; + return filterValues.includes(rowValue as string | number); + }) + ); + }, [data, filteringEnabled, columnFilters]); + const table = useReactTable({ - data, + data: filteredData, columns: tableColumns, getRowId, getCoreRowModel: getCoreRowModel(), @@ -207,19 +231,31 @@ export function Table({ ); return ( - + <> + {filteringEnabled && ( + + )} + + ); } diff --git a/components/src/Table/TableFilters.tsx b/components/src/Table/TableFilters.tsx new file mode 100644 index 0000000..e265202 --- /dev/null +++ b/components/src/Table/TableFilters.tsx @@ -0,0 +1,90 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Box, useTheme } from '@mui/material'; +import { ReactElement, useMemo, useState } from 'react'; +import { ColumnFiltersState } from '@tanstack/react-table'; + +import { TableProps } from './model/table-model'; +import { ColumnFilter } from './ColumnFilter'; + +type FilterFields = 'columns' | 'width' | 'data'; + +export interface FilterColumns { + columnFilters: ColumnFiltersState; + setColumnFilters: (filters: ColumnFiltersState) => void; +} + +export function TableFilter({ + columns, + width, + data, + columnFilters, + setColumnFilters, +}: Pick, FilterFields> & FilterColumns): ReactElement { + const theme = useTheme(); + const [openFilterColumn, setOpenFilterColumn] = useState(undefined); + + const getSelectedFilterValues = (columnId: string): Array => { + const filter = columnFilters.find((f) => f.id === columnId); + return filter ? (filter.value as Array) : []; + }; + + const columnUniqueValues = useMemo(() => { + const uniqueValues: Record> = {}; + const keys: Set = new Set(); + data.forEach((entry) => { + Object.keys(entry as object).forEach((k) => keys.add(k)); + }); + + keys.forEach((key) => { + const values = data.map((row) => (row as Record)[key]).filter((i) => i !== undefined); + uniqueValues[key] = Array.from(new Set(values as Array)); + }); + + return uniqueValues; + }, [data]); + + return ( + + {columns.map((column, idx) => { + const filters = getSelectedFilterValues(column.accessorKey as string); + const borderRight = idx < columns.length - 1 ? `1px solid ${theme.palette.divider}` : 'none'; + const columnFilterId = `filter-${idx}`; + return ( + + ); + })} + + ); +} diff --git a/components/src/Table/model/table-model.ts b/components/src/Table/model/table-model.ts index e2aa3b5..74a4d26 100644 --- a/components/src/Table/model/table-model.ts +++ b/components/src/Table/model/table-model.ts @@ -178,6 +178,12 @@ export interface TableProps { * Item actions should be created */ hasItemActions?: boolean; + + /** + * When `true`, enables filtering functionality in the table. + * Default: `false` + */ + filteringEnabled?: boolean; } function calculateTableCellHeight(lineHeight: CSSProperties['lineHeight'], paddingY: string): number {