Add columns filters system and default StringFilter dans NumberFilter.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
Madeorsk 2024-07-28 14:18:17 +02:00
parent f25ca0cc2e
commit 519facc608
Signed by: Madeorsk
SSH key fingerprint: SHA256:J9G0ofIOLKf7kyS2IfrMqtMaPdfsk1W02+oGueZzDDU
10 changed files with 292 additions and 26 deletions

View file

@ -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",

View file

@ -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<T = any>
* @param b Second data to compare.
*/
compare?: (a: T, b: T) => number;
/**
* Column filter definition.
*/
filter?: ColumnFilter;
}
/**
@ -99,6 +105,17 @@ export interface ColumnContextData<CK extends ColumnKey>
* Column sort state.
*/
sortState?: SortState;
/**
* Column filter state.
*/
filterState: any;
/**
* Set current column filter state.
* @param filterState New filter state.
*/
setFilterState: <T = any>(filterState: T) => void;
}
export const ColumnContext = React.createContext<ColumnContextData<ColumnKey>>(undefined);
@ -111,6 +128,17 @@ export function useColumn<CK extends ColumnKey>(smartable?: Smartable<CK>): Colu
return useContext(ColumnContext);
}
/**
* Hook to get current column filter state.
*/
export function useFilterState<T = any>(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 (
<ColumnContext.Provider value={{
key: columnKey,
@ -167,6 +201,10 @@ export function AutoColumnContextProvider({columnKey, children}: React.PropsWith
column: table.columns[columnKey],
// Get current column sort state from table data.
sortState: table.columnsSortState?.[columnKey],
// Get current column filter state from table data.
filterState: table.columnsFilterStates?.[columnKey],
// Current column filter state dispatcher.
setFilterState: setFilterState,
}}>
{children}
</ColumnContext.Provider>

View file

@ -0,0 +1,20 @@
import React from "react";
/**
* Column filter definition.
*/
export interface ColumnFilter<T = any, FilterState = any>
{
/**
* 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;
}

View file

@ -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<number, NumberFilterState> = {
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: <NumberFilterComponent />
};
/**
* Number filter state.
*/
export interface NumberFilterState
{
/**
* Filter value.
*/
value: string;
}
/**
* Number filter component.
*/
export function NumberFilterComponent()
{
// Initialize number filter state.
const [numberFilterState, setNumberFilterState] =
useFilterState<NumberFilterState>({ value: "" });
// Handle filter input change.
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
// Save the current filter value.
setNumberFilterState({
value: event.currentTarget.value,
})
}, [setNumberFilterState]);
return (
<input type={"text"} pattern={filterRegex.source} size={1}
value={numberFilterState.value} onChange={handleChange} />
);
}

View file

@ -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<string, StringFilterState> = {
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: <StringFilterComponent />
};
/**
* String filter state.
*/
export interface StringFilterState
{
/**
* Filter value.
*/
value: string;
}
/**
* String filter component.
*/
export function StringFilterComponent()
{
// Initialize string filter state.
const [stringFilterState, setStringFilterState] =
useFilterState<StringFilterState>({ value: "" });
// Handle filter input change.
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
// Save the current filter value.
setStringFilterState({
value: event.currentTarget.value,
})
}, [setStringFilterState]);
return (
<input type={"text"} pattern={filterRegex.source} size={1}
value={stringFilterState.value} onChange={handleChange} />
);
}

View file

@ -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<CK extends ColumnKey>({columns}: InstanceProperties<CK>
export function ColumnsHeadings<CK extends ColumnKey>({columns}: {columns: Columns<CK>})
{
return (
<tr className={"headings"}>
{ // Showing title of each column.
Object.keys(columns).map((key) => (
<AutoColumnContextProvider key={key} columnKey={key}>
<ColumnHeading />
</AutoColumnContextProvider>
))
}
</tr>
<>
<tr className={"headings"}>
{ // Showing title of each column.
Object.keys(columns).map((key) => (
<AutoColumnContextProvider key={key} columnKey={key}>
<ColumnHeading/>
</AutoColumnContextProvider>
))
}
</tr>
<tr className={"filters"}>
{ // Add columns filters, if there are some.
(Object.entries(columns) as [CK, Column][]).map(([columnKey, column]) => (
column.filter && (
<AutoColumnContextProvider key={columnKey as string} columnKey={columnKey}>
<td>{column.filter.element}</td>
</AutoColumnContextProvider>
)
))
}
</tr>
</>
);
}
export function TableBody<CK extends ColumnKey>()
{
// Get data from table.
const {data, columns, columnsSortState} = useTable<CK>();
const {data, columns, columnsSortState, columnsFilterStates} = useTable<CK>();
// Get current data state from the async table value.
const {currentDataState} = useAsyncManager<CK>(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<CK>(currentDataState.rows, columns, columnsSortState)
), [currentDataState.rows, columns, columnsSortState]);
sortRows<CK>(filteredRows, columns, columnsSortState)
), [filteredRows, columns, columnsSortState]);
return (
sortedRows ? (
@ -81,7 +105,7 @@ export function TableBody<CK extends ColumnKey>()
* @param columns Columns definition.
* @param columnsSortState Columns current sort state.
*/
export function sortRows<CK extends ColumnKey>(rows: CurrentRowData<CK>[], columns: Columns<CK>, columnsSortState: Record<CK, SortState>): CurrentRowData<CK>[]
export function sortRows<CK extends ColumnKey>(rows: CurrentRowData<CK>[], columns: Columns<CK>, columnsSortState: Partial<Record<CK, SortState>>): CurrentRowData<CK>[]
{
// Normalize value to undefined when rows are not loaded.
if (!Array.isArray(rows)) return undefined;

View file

@ -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]) => (
<ColumnContext.Provider key={columnKey} value={{ key: columnKey, column: column }}>
{
Object.keys(columns).map((columnKey) => (
<AutoColumnContextProvider key={columnKey} columnKey={columnKey}>
{ // Show current cell.
row.cells?.[columnKey]
? <CellInstance cell={row.cells?.[columnKey]} />
: <CellLoader />
}
</ColumnContext.Provider>
</AutoColumnContextProvider>
))
);
}

View file

@ -86,10 +86,7 @@ export function createSmartable<CK extends ColumnKey>({columns}: {
return {
Table: (props: SmartableProperties<CK>) => {
// Initialize sort state.
const [sortState, setSortState] = useState({} as Record<CK, SortState>);
// Filter columns from the given property.
const filteredColumns = props.shownColumns ? filterColumns(columns, props.shownColumns) : columns;
const [sortState, setSortState] = useState({} as Partial<Record<CK, SortState>>);
// Set sort state of a specific column.
const setColumnSortState = useCallback((key: CK, sortType: SortType|null): void => {
@ -123,13 +120,33 @@ export function createSmartable<CK extends ColumnKey>({columns}: {
setSortState(newSortState);
}, [sortState, setSortState]);
// Initialize filter states.
const [filterStates, setFilterStates] = useState<Partial<Record<CK, any>>>({});
// 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<TableContextData<CK>>(() => ({
columns: filteredColumns,
columnsSortState: sortState,
setColumnSortState: setColumnSortState,
columnsFilterStates: filterStates,
setColumnFilterState: setColumnFilterState,
...props,
}), [filteredColumns, sortState, setSortState, props]);
}), [sortState, setSortState, filterStates, setColumnFilterState, filteredColumns, props]);
return (
<TableContext.Provider value={contextValue}>
@ -154,14 +171,26 @@ export interface TableContextData<CK extends ColumnKey> extends SmartablePropert
/**
* Current table columns sort state.
*/
columnsSortState: Record<CK, SortState>;
columnsSortState: Partial<Record<CK, SortState>>;
/**
* 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<Record<CK, any>>;
/**
* 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;
}
/**

17
src/styles/_filters.less Normal file
View file

@ -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%;
}
}
}

View file

@ -1,6 +1,7 @@
table.smartable
{
@import "_cells";
@import "_filters";
@import "_headings";
@import "_loaders";
}