diff --git a/components/package.json b/components/package.json index 5bb8927..516980c 100644 --- a/components/package.json +++ b/components/package.json @@ -36,6 +36,7 @@ "@mui/x-date-pickers": "^7.23.1", "@perses-dev/core": "0.53.0", "@perses-dev/spec": "0.2.0-beta.0", + "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-table": "^8.20.5", "@uiw/react-codemirror": "^4.19.1", "date-fns": "^4.1.0", diff --git a/components/src/Table/Table.tsx b/components/src/Table/Table.tsx index 2a0d1ee..d2672f4 100644 --- a/components/src/Table/Table.tsx +++ b/components/src/Table/Table.tsx @@ -14,20 +14,23 @@ import { Stack, useTheme } from '@mui/material'; import { ColumnDef, + getCoreRowModel, + getExpandedRowModel, + getPaginationRowModel, + getSortedRowModel, OnChangeFn, Row, RowSelectionState, SortingState, Table as TanstackTable, - getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, useReactTable, + VisibilityState, } from '@tanstack/react-table'; -import { ReactElement, useCallback, useMemo } from 'react'; +import { ReactElement, useCallback, useMemo, useState } from 'react'; +import { useFuzzySearch } from './hooks/useFuzzySearch'; import { TableCheckbox } from './TableCheckbox'; import { VirtualizedTable } from './VirtualizedTable'; -import { DEFAULT_COLUMN_WIDTH, TableProps, persesColumnsToTanstackColumns } from './model/table-model'; +import { DEFAULT_COLUMN_WIDTH, persesColumnsToTanstackColumns, TableProps } from './model/table-model'; const DEFAULT_GET_ROW_ID = (data: unknown, index: number): string => { return `${index}`; @@ -64,10 +67,20 @@ export function Table({ pagination, onPaginationChange, rowSelectionVariant = 'standard', + getSubRows, + showSearch, + showColumnFilter, + hiddenColumns, ...otherProps }: TableProps): ReactElement { const theme = useTheme(); + const { globalFilter, setGlobalFilter, fuzzySearchOptions } = useFuzzySearch(showSearch); + + const [columnVisibility, setColumnVisibility] = useState( + hiddenColumns?.reduce((acc, columnId) => ({ ...acc, [columnId]: false }), {}) ?? {} + ); + const handleRowSelectionChange: OnChangeFn = (rowSelectionUpdater) => { const newRowSelection = typeof rowSelectionUpdater === 'function' ? rowSelectionUpdater(rowSelection) : rowSelectionUpdater; @@ -177,7 +190,7 @@ export function Table({ const table = useReactTable({ data, columns: tableColumns, - getRowId, + getRowId: getRowId, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: pagination ? getPaginationRowModel() : undefined, @@ -187,12 +200,18 @@ export function Table({ enableRowSelection: !!checkboxSelection, onRowSelectionChange: handleRowSelectionChange, onSortingChange: handleSortingChange, + onColumnVisibilityChange: setColumnVisibility, + getSubRows: getSubRows, + getExpandedRowModel: getSubRows ? getExpandedRowModel() : undefined, + ...fuzzySearchOptions, // For now, defaulting to sort by descending first. We can expose the ability // to customize it if/when we have use cases for it. sortDescFirst: true, state: { rowSelection, sorting, + globalFilter: showSearch ? globalFilter : undefined, + columnVisibility, ...(pagination ? { pagination } : {}), }, }); @@ -214,12 +233,17 @@ export function Table({ defaultColumnHeight={defaultColumnHeight} onRowClick={handleRowClick} rows={table.getRowModel().rows} - columns={table.getAllFlatColumns()} + columns={table.getVisibleFlatColumns()} headers={table.getHeaderGroups()} cellConfigs={cellConfigs} pagination={pagination} onPaginationChange={onPaginationChange} rowCount={table.getRowCount()} + showSearch={showSearch} + showColumnFilter={showColumnFilter} + globalFilter={globalFilter} + onGlobalFilterChange={setGlobalFilter} + allColumns={table.getAllColumns()} /> ); } diff --git a/components/src/Table/TableToolbar.tsx b/components/src/Table/TableToolbar.tsx new file mode 100644 index 0000000..1954c4d --- /dev/null +++ b/components/src/Table/TableToolbar.tsx @@ -0,0 +1,146 @@ +// 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 { Checkbox, IconButton, InputAdornment, ListItemText, Menu, MenuItem, Stack } from '@mui/material'; +import { Column } from '@tanstack/react-table'; +import { ReactElement, useState } from 'react'; +import Magnify from 'mdi-material-ui/Magnify'; +import Close from 'mdi-material-ui/Close'; +import ViewColumn from 'mdi-material-ui/ViewColumn'; +import { TextField } from '../controls'; + +export interface TableToolbarProps { + /** + * When `true`, a search input is rendered. + */ + showSearch?: boolean; + + /** + * Current value of the global filter / search query. + */ + globalFilter: string; + + /** + * Callback fired when the search query changes. + */ + onGlobalFilterChange: (value: string) => void; + + /** + * When `true`, a "Columns" button is rendered that opens a column visibility dropdown. + */ + showColumnFilter?: boolean; + + /** + * All columns from the table instance, used to build the visibility menu. + */ + columns: Array>; + /** + * The width of the toolbar, used to determine when to switch to a more compact layout. + */ + width: number | string; +} + +export function TableToolbar({ + showSearch, + globalFilter, + onGlobalFilterChange, + showColumnFilter, + columns, + width, +}: TableToolbarProps): ReactElement | null { + const [colMenuAnchor, setColMenuAnchor] = useState(null); + const colMenuOpen = Boolean(colMenuAnchor); + + if (!showSearch && !showColumnFilter) { + return null; + } + + return ( + theme.palette.background.default }} + > + {showSearch && ( + + + + ), + endAdornment: globalFilter !== '' && ( + + onGlobalFilterChange('')}> + + + + ), + }, + }} + sx={{ flexGrow: 1 }} + /> + )} + {showColumnFilter && ( + <> + setColMenuAnchor(e.currentTarget)} + aria-haspopup="listbox" + aria-expanded={colMenuOpen} + color="info" + > + + + setColMenuAnchor(null)} + slotProps={{ list: { dense: true } }} + > + {columns.map((column) => { + const header = column.columnDef.header; + const label = typeof header === 'string' ? header : column.id; + return ( + + + + + ); + })} + + + )} + + ); +} diff --git a/components/src/Table/VirtualizedTable.tsx b/components/src/Table/VirtualizedTable.tsx index 03d8009..5bcd5ba 100644 --- a/components/src/Table/VirtualizedTable.tsx +++ b/components/src/Table/VirtualizedTable.tsx @@ -11,10 +11,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Column, HeaderGroup, Row, flexRender } from '@tanstack/react-table'; +import { Column, flexRender, HeaderGroup, Row } from '@tanstack/react-table'; import { Box, TablePagination, TableRow as MuiTableRow } from '@mui/material'; -import { TableVirtuoso, TableComponents, TableVirtuosoHandle, TableVirtuosoProps } from 'react-virtuoso'; -import { useRef, useMemo, ReactElement } from 'react'; +import { TableComponents, TableVirtuoso, TableVirtuosoHandle, TableVirtuosoProps } from 'react-virtuoso'; +import { ReactElement, useMemo, useRef } from 'react'; +import { TableToolbar } from './TableToolbar'; import { TableRow } from './TableRow'; import { TableBody } from './TableBody'; import { InnerTable } from './InnerTable'; @@ -34,13 +35,19 @@ type TableCellPosition = { export type VirtualizedTableProps = Required< Pick, 'height' | 'width' | 'density' | 'defaultColumnWidth' | 'defaultColumnHeight'> > & - Pick, 'onRowMouseOver' | 'onRowMouseOut' | 'pagination' | 'onPaginationChange'> & { + Pick< + TableProps, + 'onRowMouseOver' | 'onRowMouseOut' | 'pagination' | 'onPaginationChange' | 'showSearch' | 'showColumnFilter' + > & { onRowClick: (e: React.MouseEvent, id: string) => void; rows: Array>; columns: Array>; headers: Array>; cellConfigs?: TableCellConfigs; rowCount: number; + globalFilter: string; + onGlobalFilterChange: (value: string) => void; + allColumns: Array>; }; // Separating out the virtualized table because we may want a paginated table @@ -62,6 +69,11 @@ export function VirtualizedTable({ pagination, onPaginationChange, rowCount, + showSearch, + showColumnFilter, + globalFilter, + onGlobalFilterChange, + allColumns, }: VirtualizedTableProps): ReactElement { const virtuosoRef = useRef(null); // Use a ref for these values because they are only needed for keyboard @@ -157,155 +169,165 @@ export function VirtualizedTable({ }; return ( - - { - return ( - <> - {headers.map((headerGroup) => { - return ( - - {headerGroup.headers.map((header, i, headers) => { - const column = header.column; - const position: TableCellPosition = { - row: 0, - column: i, - }; + <> + + + { + return ( + <> + {headers.map((headerGroup) => { + return ( + + {headerGroup.headers.map((header, i, headers) => { + const column = header.column; + const position: TableCellPosition = { + row: 0, + column: i, + }; - const isSorted = column.getIsSorted(); - const nextSorting = column.getNextSortingOrder(); + const isSorted = column.getIsSorted(); + const nextSorting = column.getNextSortingOrder(); - return ( - keyboardNav.onCellFocus(position)} - isFirstColumn={i === 0} - isLastColumn={i === headers.length - 1} - > - {flexRender(column.columnDef.header, header.getContext())} - - ); - })} - - ); - })} - - ); - }} - fixedFooterContent={ - pagination - ? (): ReactElement => ( - theme.palette.background.default }}> - - - ) - : undefined - } - itemContent={(index) => { - const row = rows[index]; - if (!row) { - return null; + return ( + keyboardNav.onCellFocus(position)} + isFirstColumn={i === 0} + isLastColumn={i === headers.length - 1} + > + {flexRender(column.columnDef.header, header.getContext())} + + ); + })} + + ); + })} + + ); + }} + fixedFooterContent={ + pagination + ? (): ReactElement => ( + theme.palette.background.default }}> + + + ) + : undefined } + itemContent={(index) => { + const row = rows[index]; + if (!row) { + return null; + } - return ( - <> - {row.getVisibleCells().map((cell, i, cells) => { - const position: TableCellPosition = { - row: index + 1, - column: i, - }; + return ( + <> + {row.getVisibleCells().map((cell, i, cells) => { + const position: TableCellPosition = { + row: index + 1, + column: i, + }; - const cellContext = cell.getContext(); - const cellConfig = cellConfigs?.[cellContext.cell.id]; + const cellContext = cell.getContext(); + const cellConfig = cellConfigs?.[cellContext.cell.id]; - const cellRenderFn = cell.column.columnDef.cell; - const cellContent = typeof cellRenderFn === 'function' ? cellRenderFn(cellContext) : null; + const cellRenderFn = cell.column.columnDef.cell; + const cellContent = typeof cellRenderFn === 'function' ? cellRenderFn(cellContext) : null; - /* - IMPORTANT: - If Variables exist in the link, they should have been translated by the plugin already. (Being developed at the moment) - Components have no access to any context (Which is intentional and correct) - We may want to add parameters to a link from neighboring cells in the future as well. - If this is the case, the value of the neighboring cells should be read from here and be replaced. (Bing discussed at the moment, not decided yet) - */ + /* + IMPORTANT: + If Variables exist in the link, they should have been translated by the plugin already. (Being developed at the moment) + Components have no access to any context (Which is intentional and correct) + We may want to add parameters to a link from neighboring cells in the future as well. + If this is the case, the value of the neighboring cells should be read from here and be replaced. (Bing discussed at the moment, not decided yet) + */ - const cellDescriptionDef = cell.column.columnDef.meta?.cellDescription; - let description: string | undefined = undefined; - if (typeof cellDescriptionDef === 'function') { - // If the cell description is a function, set the value using - // the function. - description = cellDescriptionDef(cellContext); - } else if (cellDescriptionDef && typeof cellContent === 'string') { - // If the cell description is `true` AND the cell content is - // a string (and thus viable as a `title` attribute), use the - // cell content. - description = cellContent; - } + const cellDescriptionDef = cell.column.columnDef.meta?.cellDescription; + let description: string | undefined = undefined; + if (typeof cellDescriptionDef === 'function') { + // If the cell description is a function, set the value using + // the function. + description = cellDescriptionDef(cellContext); + } else if (cellDescriptionDef && typeof cellContent === 'string') { + // If the cell description is `true` AND the cell content is + // a string (and thus viable as a `title` attribute), use the + // cell content. + description = cellContent; + } - /* this has been specifically added for the data link, - therefore, non string and numeric values should be excluded - */ - const adjacentCellsValuesMap = Object.entries(row.original as Record) - ?.filter(([_, value]) => ['string', 'number'].includes(typeof value)) - .reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: String(value), - }), - {} - ); + /* this has been specifically added for the data link, + therefore, non string and numeric values should be excluded + */ + const adjacentCellsValuesMap = Object.entries(row.original as Record) + ?.filter(([_, value]) => ['string', 'number'].includes(typeof value)) + .reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: String(value), + }), + {} + ); - return ( - keyboardNav.onCellFocus(position)} - isFirstColumn={i === 0} - isLastColumn={i === cells.length - 1} - description={description} - color={cellConfig?.textColor ?? undefined} - backgroundColor={cellConfig?.backgroundColor ?? undefined} - dataLink={cell.column.columnDef.meta?.dataLink} - adjacentCellsValuesMap={adjacentCellsValuesMap} - > - {cellConfig?.text || cellContent} - - ); - })} - - ); - }} - /> - + return ( + keyboardNav.onCellFocus(position)} + isFirstColumn={i === 0} + isLastColumn={i === cells.length - 1} + description={description} + color={cellConfig?.textColor ?? undefined} + backgroundColor={cellConfig?.backgroundColor ?? undefined} + dataLink={cell.column.columnDef.meta?.dataLink} + adjacentCellsValuesMap={adjacentCellsValuesMap} + > + {cellConfig?.text || cellContent} + + ); + })} + + ); + }} + /> + + ); } diff --git a/components/src/Table/hooks/useFuzzySearch.ts b/components/src/Table/hooks/useFuzzySearch.ts new file mode 100644 index 0000000..84ac16f --- /dev/null +++ b/components/src/Table/hooks/useFuzzySearch.ts @@ -0,0 +1,51 @@ +// 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 { FilterFn, getFilteredRowModel, OnChangeFn, TableOptions } from '@tanstack/react-table'; +import { rankItem } from '@tanstack/match-sorter-utils'; +import { useState } from 'react'; + +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(row.getValue(columnId), value); + addMeta({ itemRank }); + return itemRank.passed; +}; + +export interface UseFuzzySearchResult { + globalFilter: string; + setGlobalFilter: OnChangeFn; + fuzzySearchOptions: Pick< + TableOptions, + 'filterFns' | 'globalFilterFn' | 'getFilteredRowModel' | 'filterFromLeafRows' | 'onGlobalFilterChange' + >; +} + +/** + * Returns fuzzy search state and the corresponding `useReactTable` options. + * Filter options are disabled when `showSearch` is falsy. + */ +export function useFuzzySearch(showSearch: boolean | undefined): UseFuzzySearchResult { + const [globalFilter, setGlobalFilter] = useState(''); + + return { + globalFilter, + setGlobalFilter, + fuzzySearchOptions: { + filterFns: { fuzzy: fuzzyFilter }, + globalFilterFn: showSearch ? 'fuzzy' : undefined, + getFilteredRowModel: showSearch ? getFilteredRowModel() : undefined, + filterFromLeafRows: showSearch, + onGlobalFilterChange: setGlobalFilter, + }, + }; +} diff --git a/components/src/Table/index.ts b/components/src/Table/index.ts index 2c4c74f..3061c11 100644 --- a/components/src/Table/index.ts +++ b/components/src/Table/index.ts @@ -13,3 +13,4 @@ export * from './Table'; export * from './model/table-model'; +export type { SortingState as TableSortingState, PaginationState as TablePaginationState } from '@tanstack/react-table'; diff --git a/components/src/Table/model/table-model.ts b/components/src/Table/model/table-model.ts index 69516c6..84eb017 100644 --- a/components/src/Table/model/table-model.ts +++ b/components/src/Table/model/table-model.ts @@ -18,6 +18,7 @@ import { CellContext, ColumnDef, CoreOptions, + FilterFn, PaginationState, RowData, RowSelectionState, @@ -52,7 +53,7 @@ export interface TableProps { /** * Width of the table. */ - width: number; + width: number | string; /** * Array of data to render in the table. Each entry in the array will be @@ -178,6 +179,28 @@ export interface TableProps { * Item actions should be created */ hasItemActions?: boolean; + + /** + * Returns the sub rows for a given row, or `undefined` if there are none. + */ + getSubRows?: (originalRow: TableData, index: number) => undefined | TableData[]; + + /** + * When `true`, a search bar will be rendered above the table that allows + * the user to filter rows using a fuzzy global filter. + */ + showSearch?: boolean; + + /** + * When `true`, a "Columns" button will be rendered above the table that + * opens a dropdown allowing the user to toggle column visibility. + */ + showColumnFilter?: boolean; + + /** + * List of column ids that should be hidden when the table is initially rendered. + */ + hiddenColumns?: string[]; } function calculateTableCellHeight(lineHeight: CSSProperties['lineHeight'], paddingY: string): number { @@ -277,6 +300,12 @@ declare module '@tanstack/table-core' { } } +declare module '@tanstack/react-table' { + interface FilterFns { + fuzzy: FilterFn; + } +} + // Column link settings // The URL could be set to a static link or could be constructed dynamically // The URL may include reference to the variables or neighboring cells in the row @@ -291,7 +320,7 @@ export interface TableColumnConfig // TODO: revisit issue thread and see if there are any workarounds we can // use. // eslint-disable-next-line @typescript-eslint/no-explicit-any - extends Pick, 'accessorKey' | 'cell' | 'sortingFn'> { + extends Pick, 'accessorKey' | 'cell' | 'sortingFn' | 'id'> { /** * Text to display in the header for the column. */ diff --git a/components/src/controls/TextField.tsx b/components/src/controls/TextField.tsx index 14e43ad..b326406 100644 --- a/components/src/controls/TextField.tsx +++ b/components/src/controls/TextField.tsx @@ -12,16 +12,20 @@ // limitations under the License. import { TextFieldProps as MuiTextFieldProps, TextField as MuiTextField } from '@mui/material'; -import { ChangeEvent, ForwardedRef, forwardRef, useCallback, useMemo, useState } from 'react'; +import { ChangeEvent, ForwardedRef, forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; import debounce from 'lodash/debounce'; type TextFieldProps = Omit & { debounceMs?: number; onChange?: (value: string) => void }; export const TextField = forwardRef(function ( - { debounceMs = 250, value, onChange, ...props }: TextFieldProps, + { debounceMs = 250, value: initialValue, onChange, ...props }: TextFieldProps, ref: ForwardedRef ) { - const [currentValue, setCurrentValue] = useState(value); + const [currentValue, setCurrentValue] = useState(initialValue); + + useEffect(() => { + setCurrentValue(initialValue); + }, [initialValue]); function handleChange(event: ChangeEvent): void { setCurrentValue(event.target.value); diff --git a/package-lock.json b/package-lock.json index e72145f..b878a04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "@mui/x-date-pickers": "^7.23.1", "@perses-dev/core": "0.53.0", "@perses-dev/spec": "0.2.0-beta.0", + "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-table": "^8.20.5", "@uiw/react-codemirror": "^4.19.1", "date-fns": "^4.1.0", @@ -4720,6 +4721,22 @@ "node": ">=14.16" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", + "license": "MIT", + "dependencies": { + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "4.39.1", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.39.1.tgz", @@ -14252,6 +14269,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",