From 519facc6086cadf1576f81c85db4293c1933c492 Mon Sep 17 00:00:00 2001 From: Madeorsk Date: Sun, 28 Jul 2024 14:18:17 +0200 Subject: [PATCH] Add columns filters system and default StringFilter dans NumberFilter. --- demo/DemoTable.tsx | 4 ++ src/Smartable/Column.tsx | 38 ++++++++++++++ src/Smartable/Columns/ColumnFilter.tsx | 20 +++++++ src/Smartable/Filters/NumberFilter.tsx | 72 ++++++++++++++++++++++++++ src/Smartable/Filters/StringFilter.tsx | 61 ++++++++++++++++++++++ src/Smartable/Instance.tsx | 52 ++++++++++++++----- src/Smartable/Row.tsx | 10 ++-- src/Smartable/Smartable.tsx | 43 ++++++++++++--- src/styles/_filters.less | 17 ++++++ src/styles/smartable.less | 1 + 10 files changed, 292 insertions(+), 26 deletions(-) create mode 100644 src/Smartable/Columns/ColumnFilter.tsx create mode 100644 src/Smartable/Filters/NumberFilter.tsx create mode 100644 src/Smartable/Filters/StringFilter.tsx create mode 100644 src/styles/_filters.less diff --git a/demo/DemoTable.tsx b/demo/DemoTable.tsx index 76f7785..1b4fb93 100644 --- a/demo/DemoTable.tsx +++ b/demo/DemoTable.tsx @@ -5,6 +5,8 @@ import {RowDefinition} from "../src/Smartable/Row"; import {CellDefinition} from "../src/Smartable/Cell"; import {ClickableCell} from "../src/Smartable/Cells/ClickableCell"; import {Buttons, Modal, ModalType, useModals} from "@kernelui/core"; +import {StringFilter} from "../src/Smartable/Filters/StringFilter"; +import {NumberFilter} from "../src/Smartable/Filters/NumberFilter"; /** * Some ants names. @@ -66,9 +68,11 @@ const Smartable = createSmartable({ columns: createColumns( createColumn("name", { title: "Name", + filter: StringFilter, }), createColumn("quantity", { title: "Quantity", + filter: NumberFilter, }), createColumn("unit-price", { title: "Unit price", diff --git a/src/Smartable/Column.tsx b/src/Smartable/Column.tsx index c7ba038..20e7e6f 100644 --- a/src/Smartable/Column.tsx +++ b/src/Smartable/Column.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useContext} from "react"; import {Smartable, useTable} from "./Smartable"; +import {ColumnFilter} from "./Columns/ColumnFilter"; /** * Basic column key type. @@ -43,6 +44,11 @@ export interface Column * @param b Second data to compare. */ compare?: (a: T, b: T) => number; + + /** + * Column filter definition. + */ + filter?: ColumnFilter; } /** @@ -99,6 +105,17 @@ export interface ColumnContextData * Column sort state. */ sortState?: SortState; + + /** + * Column filter state. + */ + filterState: any; + + /** + * Set current column filter state. + * @param filterState New filter state. + */ + setFilterState: (filterState: T) => void; } export const ColumnContext = React.createContext>(undefined); @@ -111,6 +128,17 @@ export function useColumn(smartable?: Smartable): Colu return useContext(ColumnContext); } +/** + * Hook to get current column filter state. + */ +export function useFilterState(initialValue: T): [T, (newFilterState: T) => void] +{ + // Get current column data. + const column = useColumn(); + // Return filter state array from current column data. + return [column.filterState ?? initialValue, column.setFilterState]; +} + /** * Default column heading component. */ @@ -160,6 +188,12 @@ export function AutoColumnContextProvider({columnKey, children}: React.PropsWith // Get table data. const table = useTable(); + // Initialize filterState dispatcher for the current column. + const setFilterState = useCallback((filterState: any) => ( + // Set the filter state for the current column key. + table.setColumnFilterState(columnKey, filterState) + ), [columnKey, table.setColumnFilterState]); + return ( {children} diff --git a/src/Smartable/Columns/ColumnFilter.tsx b/src/Smartable/Columns/ColumnFilter.tsx new file mode 100644 index 0000000..bba0d4e --- /dev/null +++ b/src/Smartable/Columns/ColumnFilter.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +/** + * Column filter definition. + */ +export interface ColumnFilter +{ + /** + * Visual element for the filter. + */ + element: React.ReactElement; + + /** + * Data filter function. + * @param data Data to filter + * @param filterState Current filter state. + * @returns True when data should be kept, false otherwise. + */ + filter: (data: T, filterState: FilterState) => boolean; +} diff --git a/src/Smartable/Filters/NumberFilter.tsx b/src/Smartable/Filters/NumberFilter.tsx new file mode 100644 index 0000000..da6141d --- /dev/null +++ b/src/Smartable/Filters/NumberFilter.tsx @@ -0,0 +1,72 @@ +import React, {useCallback} from "react"; +import {ColumnFilter} from "../Columns/ColumnFilter"; +import {useFilterState} from "../Column"; + +/** + * Filter value regex. + */ +export const filterRegex = /^([=!><])?([0-9]+)$/; + +/** + * Number column filter. + */ +export const NumberFilter: ColumnFilter = { + filter: (data: number, filterState: NumberFilterState) => { + // Read filter value. + const filterValue = filterRegex.exec(filterState.value); + // No valid filter value, allow everything. + if (!filterValue?.length) return true; + // Get current filter modifier and number. + const filterModifier = filterValue[1]; + const filterNumber = Number.parseFloat(filterValue[2]); + // Return result based on modifier. + switch (filterModifier) + { + case ">": + return data > filterNumber; + case "<": + return data < filterNumber; + case "!": + return data != filterNumber; + case "=": + default: + return data == filterNumber; + } + }, + + element: +}; + +/** + * Number filter state. + */ +export interface NumberFilterState +{ + /** + * Filter value. + */ + value: string; +} + +/** + * Number filter component. + */ +export function NumberFilterComponent() +{ + // Initialize number filter state. + const [numberFilterState, setNumberFilterState] = + useFilterState({ value: "" }); + + // Handle filter input change. + const handleChange = useCallback((event: React.ChangeEvent) => { + // Save the current filter value. + setNumberFilterState({ + value: event.currentTarget.value, + }) + }, [setNumberFilterState]); + + return ( + + ); +} diff --git a/src/Smartable/Filters/StringFilter.tsx b/src/Smartable/Filters/StringFilter.tsx new file mode 100644 index 0000000..c9507ff --- /dev/null +++ b/src/Smartable/Filters/StringFilter.tsx @@ -0,0 +1,61 @@ +import React, {useCallback} from "react"; +import {ColumnFilter} from "../Columns/ColumnFilter"; +import {useFilterState} from "../Column"; +import {normalizeString} from "@kernelui/core"; + +/** + * Filter value regex. + */ +export const filterRegex = /^([=!])?([^=!].+)$/; + +/** + * String column filter. + */ +export const StringFilter: ColumnFilter = { + filter: (data: string, filterState: StringFilterState) => { + // Read filter value. + const filterValue = filterRegex.exec(filterState.value); + // No valid filter value, allow everything. + if (!filterValue?.length) return true; + // Get current filter result. + const filterResult = normalizeString(data).includes(normalizeString(filterValue[2])); + // Invert filter result based on filter modifier. + return filterValue[1] == "!" ? !filterResult : filterResult; + }, + + element: +}; + +/** + * String filter state. + */ +export interface StringFilterState +{ + /** + * Filter value. + */ + value: string; +} + +/** + * String filter component. + */ +export function StringFilterComponent() +{ + // Initialize string filter state. + const [stringFilterState, setStringFilterState] = + useFilterState({ value: "" }); + + // Handle filter input change. + const handleChange = useCallback((event: React.ChangeEvent) => { + // Save the current filter value. + setStringFilterState({ + value: event.currentTarget.value, + }) + }, [setStringFilterState]); + + return ( + + ); +} diff --git a/src/Smartable/Instance.tsx b/src/Smartable/Instance.tsx index 2d44810..d6f0c50 100644 --- a/src/Smartable/Instance.tsx +++ b/src/Smartable/Instance.tsx @@ -1,5 +1,5 @@ import React, {useMemo} from "react"; -import {AutoColumnContextProvider, ColumnHeading, ColumnKey, Columns, SortState, SortType} from "./Column"; +import {AutoColumnContextProvider, Column, ColumnHeading, ColumnKey, Columns, SortState, SortType} from "./Column"; import {SmartableProperties, useTable} from "./Smartable"; import {RowInstance, RowLoader} from "./Row"; import {CurrentRowData, useAsyncManager} from "./AsyncManager"; @@ -38,30 +38,54 @@ export function Instance({columns}: InstanceProperties export function ColumnsHeadings({columns}: {columns: Columns}) { return ( - - { // Showing title of each column. - Object.keys(columns).map((key) => ( - - - - )) - } - + <> + + { // Showing title of each column. + Object.keys(columns).map((key) => ( + + + + )) + } + + + { // Add columns filters, if there are some. + (Object.entries(columns) as [CK, Column][]).map(([columnKey, column]) => ( + column.filter && ( + + {column.filter.element} + + ) + )) + } + + ); } export function TableBody() { // Get data from table. - const {data, columns, columnsSortState} = useTable(); + const {data, columns, columnsSortState, columnsFilterStates} = useTable(); // Get current data state from the async table value. const {currentDataState} = useAsyncManager(data); + // Memorize filtered rows. + const filteredRows = useMemo(() => ( + currentDataState.rows?.filter((row) => ( + // Checking each row to keep only those which match the filters. + (Object.entries(columnsFilterStates) as [CK, any][]).every(([columnKey, filterState]) => ( + // For each filter, keep the row if data match the current filter. + columns[columnKey].filter.filter(row.cells[columnKey].data, filterState) + )) + )) + ), [currentDataState.rows, columnsFilterStates]); + // Memorize sorted rows. const sortedRows = useMemo(() => ( - sortRows(currentDataState.rows, columns, columnsSortState) - ), [currentDataState.rows, columns, columnsSortState]); + sortRows(filteredRows, columns, columnsSortState) + ), [filteredRows, columns, columnsSortState]); return ( sortedRows ? ( @@ -81,7 +105,7 @@ export function TableBody() * @param columns Columns definition. * @param columnsSortState Columns current sort state. */ -export function sortRows(rows: CurrentRowData[], columns: Columns, columnsSortState: Record): CurrentRowData[] +export function sortRows(rows: CurrentRowData[], columns: Columns, columnsSortState: Partial>): CurrentRowData[] { // Normalize value to undefined when rows are not loaded. if (!Array.isArray(rows)) return undefined; diff --git a/src/Smartable/Row.tsx b/src/Smartable/Row.tsx index ac66003..330b2b7 100644 --- a/src/Smartable/Row.tsx +++ b/src/Smartable/Row.tsx @@ -1,5 +1,5 @@ import React, {useContext} from "react"; -import {ColumnContext, ColumnKey} from "./Column"; +import {AutoColumnContextProvider, ColumnContext, ColumnKey} from "./Column"; import {CellDefinition, CellInstance, CellLoader} from "./Cell"; import {Smartable, useTable} from "./Smartable"; import { Promisable} from "@kernelui/core"; @@ -91,14 +91,14 @@ export function RowCells() const {columns} = useTable(); return ( - Object.entries(columns).map(([columnKey, column]) => ( - - { + Object.keys(columns).map((columnKey) => ( + + { // Show current cell. row.cells?.[columnKey] ? : } - + )) ); } diff --git a/src/Smartable/Smartable.tsx b/src/Smartable/Smartable.tsx index aa94b13..69e3f09 100644 --- a/src/Smartable/Smartable.tsx +++ b/src/Smartable/Smartable.tsx @@ -86,10 +86,7 @@ export function createSmartable({columns}: { return { Table: (props: SmartableProperties) => { // Initialize sort state. - const [sortState, setSortState] = useState({} as Record); - - // Filter columns from the given property. - const filteredColumns = props.shownColumns ? filterColumns(columns, props.shownColumns) : columns; + const [sortState, setSortState] = useState({} as Partial>); // Set sort state of a specific column. const setColumnSortState = useCallback((key: CK, sortType: SortType|null): void => { @@ -123,13 +120,33 @@ export function createSmartable({columns}: { setSortState(newSortState); }, [sortState, setSortState]); + // Initialize filter states. + const [filterStates, setFilterStates] = useState>>({}); + + // Set filter state of a specific column. + const setColumnFilterState = useCallback((key: CK, filterState: any) => { + setFilterStates( + { + // Copy the other filters states. + ...filterStates, + // Set the filter state for the given column. + [key]: filterState, + } + ); + }, [filterStates, setFilterStates]); + + // Filter columns from the given property. + const filteredColumns = props.shownColumns ? filterColumns(columns, props.shownColumns) : columns; + // Initialize table context value. const contextValue = useMemo>(() => ({ columns: filteredColumns, columnsSortState: sortState, setColumnSortState: setColumnSortState, + columnsFilterStates: filterStates, + setColumnFilterState: setColumnFilterState, ...props, - }), [filteredColumns, sortState, setSortState, props]); + }), [sortState, setSortState, filterStates, setColumnFilterState, filteredColumns, props]); return ( @@ -154,14 +171,26 @@ export interface TableContextData extends SmartablePropert /** * Current table columns sort state. */ - columnsSortState: Record; + columnsSortState: Partial>; /** - * Set current table columns sort state. + * Set given table column sort state. * @param key The column key for which to set the sort type. * @param sortType The sort type to set for the given column. NULL to reset sort state. */ setColumnSortState: (key: CK, sortType: SortType|null) => void; + + /** + * Current table columsn filter states. + */ + columnsFilterStates: Partial>; + + /** + * Set given table column filter state. + * @param key The column key for which to set the filter state. + * @param filterState The filter state to set for the given column. + */ + setColumnFilterState: (key: CK, filterState: any) => void; } /** diff --git a/src/styles/_filters.less b/src/styles/_filters.less new file mode 100644 index 0000000..7372b72 --- /dev/null +++ b/src/styles/_filters.less @@ -0,0 +1,17 @@ +tr.filters +{ // Filters row style. + td + { + padding: 0.25em 0.125em; + + &:first-child:not(:last-child) + { + padding-left: 0.25em; + } + + input + { + width: 100%; + } + } +} diff --git a/src/styles/smartable.less b/src/styles/smartable.less index bf1d56c..516ab48 100644 --- a/src/styles/smartable.less +++ b/src/styles/smartable.less @@ -1,6 +1,7 @@ table.smartable { @import "_cells"; + @import "_filters"; @import "_headings"; @import "_loaders"; }