Compare commits

..

21 commits

Author SHA1 Message Date
04d28f880f
Allow to set className of a Smartable.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-25 19:13:57 +02:00
6b96a85554
Add a way to disable sort and filter features with properties.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-25 18:51:26 +02:00
d13f56b24a
Add missing key of table rows.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-25 15:42:34 +02:00
79b616dd95
Fix row removal and add a demo table to test it.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-24 22:44:00 +02:00
649d608c89
Fix package externals for correct library build.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-23 17:02:09 +02:00
cefae670ab
Fix exported library classes.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-23 16:41:14 +02:00
2376d780cc
Add router dom to peer dependencies and fix library build configuration.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2024-09-23 10:57:13 +02:00
5e14ebf780
Set KernelUI Core as a peer dependency.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-09-23 00:17:15 +02:00
222242163d
Fix dev dependencies after changes in core package.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-09-23 00:08:07 +02:00
9ef04dbbe1
Version 1.0.0
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-29 22:50:15 +02:00
0bb88b177f
Add paginated table content.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-29 11:46:03 +02:00
267afa68dd
Add column enum filter.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-28 15:41:10 +02:00
71b10a3c89
Reorganize columns sorting and filter systems.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-28 14:36:36 +02:00
519facc608
Add columns filters system and default StringFilter dans NumberFilter.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-28 14:18:17 +02:00
f25ca0cc2e
Add clickable cells and allow customizations on cells.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-28 12:49:24 +02:00
a82da6541d
Show column sort order.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 18:56:10 +02:00
e71d0aa446
Add a random table as demo table.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 14:34:49 +02:00
c8601aaa30
Improve loading rows and cells animations.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 14:13:37 +02:00
2753b6eb9f
Fix rows loading indefinitely when no rows are returned.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 14:05:42 +02:00
2513e711b1
Add multi-columns rows sorting with customizable comparison function for each column.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-27 13:45:25 +02:00
5ac1e4e7b8
Normalize async content handling with AsyncManager.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
+ Add AsyncManager which manages asynchronous table content for sorting and rendering.
2024-07-27 11:49:55 +02:00
27 changed files with 1589 additions and 750 deletions

View file

@ -1,6 +0,0 @@
- [ ] Add shown columns.
- [ ] Test async content.
- [ ] Multi-columns sort.
- [ ] Pagination.
- [ ] Filters.
- [ ] Async filters.

View file

@ -1,14 +1,17 @@
import React from "react";
import {Application} from "@kernelui/core";
import {DemoTable} from "./DemoTable";
import {RemoveDemoTable} from "./RemoveDemoTable";
export function DemoApp()
{
return (
<Application>
<h1>Simple table</h1>
<h1>Random table</h1>
<DemoTable />
<RemoveDemoTable />
</Application>
)
}

View file

@ -1,71 +1,171 @@
import React from "react";
import {createSmartable} from "../src/Smartable/Smartable";
import React, {useMemo} from "react";
import {createSmartable, SmartableColumns, SmartableData} from "../src/Smartable/Smartable";
import {createColumn, createColumns} from "../src/Smartable/Column";
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.
*/
const names: string[] = [
"Formica rufa",
"Lasius niger",
"Camponotus pennsylvanicus",
"Solenopsis invicta",
"Atta cephalotes",
"Pogonomyrmex barbatus",
"Myrmica rubra",
"Dorymyrmex insanus",
"Pheidole megacephala",
"Crematogaster scutellaris",
"Tetramorium caespitum",
"Tapinoma sessile",
"Linepithema humile",
"Monomorium pharaonis",
"Odontomachus bauri",
"Paraponera clavata",
"Oecophylla smaragdina",
"Pseudomyrmex gracilis",
"Eciton burchellii",
"Anoplolepis gracilipes",
"Acromyrmex octospinosus",
"Acanthomyops claviger",
"Dorylus nigricans",
"Neivamyrmex nigrescens",
"Hypoponera punctatissima",
"Solenopsis geminata",
"Camponotus chromaiodes",
"Brachymyrmex depilis",
"Ectatomma ruidum",
"Proceratium silaceum",
"Cephalotes atratus",
"Neoponera villosa",
"Dinoponera gigantea",
"Prenolepis imparis",
"Lasius flavus",
"Formica fusca",
"Myrmecia gulosa",
"Solenopsis molesta",
"Camponotus herculeanus",
"Cataulacus granulatus",
"Daceton armigerum",
"Polyrhachis dives",
"Pheidole dentata",
"Tetramorium immigrans",
"Messor barbarus",
"Cardiocondyla obscurior",
"Nylanderia flavipes",
"Forelius pruinosus",
"Amblyopone pallipes"
];
// Create main table.
const Smartable = createSmartable({
columns: createColumns(
createColumn("123", {
title: "test",
createColumn("name", {
title: "Name",
filter: StringFilter,
}),
createColumn("456", {
title: "ttt",
createColumn("quantity", {
title: "Quantity",
filter: NumberFilter,
}),
createColumn("789", {
title: "another",
createColumn("unit-price", {
title: "Unit price",
}),
createColumn("test", {
title: "last one",
createColumn("total-price", {
title: "Total",
}),
),
});
/**
* Generate a random quantity.
*/
export function randomQuantity(): number
{
return Math.floor(Math.random() * 8) + 1;
}
/**
* Generate a random unit price.
*/
export function randomPrice(): number
{
return Math.floor(Math.random() * 25) + 5;
}
/**
* Get a random name from `names` array.
*/
export function randomName(): string
{
return names[Math.floor(Math.random() * names.length)];
}
/**
* Generate a random computation time between 0 and 10s.
*/
export function randomComputationTime(): number
{
return Math.random() * 1000 * 10;
}
export function DemoTable()
{
const modals = useModals();
const demoDataPromise = useMemo<SmartableData<SmartableColumns<typeof Smartable>>>(() => (
new Promise((resolve) => {
// Resolving promise in 2s.
window.setTimeout(() => {
resolve(Array.from({ length: 43 }).map(() => {
// Compute random quantity and unit price.
const name = randomName();
const quantity = randomQuantity();
const price = randomPrice();
// Fake long computation of total price for each row.
const totalPricePromise = new Promise<CellDefinition<number>>((resolve) => {
window.setTimeout(() => {
return resolve({
data: quantity * price,
});
}, randomComputationTime());
});
return {
cells: {
name: {
data: name,
element: <ClickableCell onClick={() => {
const uuid = modals.open(<Modal type={ModalType.INFO} title={name}>
A great description about these ants.
<Buttons>
<button onClick={() => modals.close(uuid)}>OK</button>
</Buttons>
</Modal>);
}} />,
},
quantity: {
data: quantity,
},
"unit-price": {
data: price,
},
"total-price": totalPricePromise,
},
} as RowDefinition<SmartableColumns<typeof Smartable>>;
}));
}, 2000);
})
), []);
return (
<Smartable.Table shownColumns={["123", "456", "test"]} data={[
{
"123": {
data: "test abc",
},
test: {
data: 123,
},
"789": {
data: "test etset",
},
"456": {
data: "test vccvcvc",
},
},
{
"123": {
data: "any data",
},
test: {
data: 5552,
},
"789": {
data: "foo bar",
},
"456": {
data: "baz",
},
},
{
"123": {
data: "test test",
},
test: {
data: 5552,
},
"789": {
data: "other test",
},
"456": {
data: "infinite testing",
},
},
]} />
<Smartable.Table data={demoDataPromise} paginate={{ pageSize: 6 }} />
);
}

95
demo/RemoveDemoTable.tsx Normal file
View file

@ -0,0 +1,95 @@
import React, {useState} from "react";
import {createSmartable} from "../src/Smartable/Smartable";
import {createColumn, createColumns} from "../src/Smartable/Column";
import {StringFilter} from "../src/Smartable/Filters/StringFilter";
import { Cell } from "../src/Smartable/Cell";
// Create main table.
const Smartable = createSmartable({
columns: createColumns(
createColumn("name", {
title: "Name",
filter: StringFilter,
}),
createColumn("actions", {
title: "Actions",
}),
),
});
export function RemoveDemoTable()
{
const [rows, setRows] = useState<string[]>([
"Formica rufa",
"Lasius niger",
"Camponotus pennsylvanicus",
"Solenopsis invicta",
"Atta cephalotes",
"Pogonomyrmex barbatus",
"Myrmica rubra",
"Dorymyrmex insanus",
"Pheidole megacephala",
"Crematogaster scutellaris",
"Tetramorium caespitum",
"Tapinoma sessile",
"Linepithema humile",
"Monomorium pharaonis",
"Odontomachus bauri",
"Paraponera clavata",
"Oecophylla smaragdina",
"Pseudomyrmex gracilis",
"Eciton burchellii",
"Anoplolepis gracilipes",
"Acromyrmex octospinosus",
"Acanthomyops claviger",
"Dorylus nigricans",
"Neivamyrmex nigrescens",
"Hypoponera punctatissima",
"Solenopsis geminata",
"Camponotus chromaiodes",
"Brachymyrmex depilis",
"Ectatomma ruidum",
"Proceratium silaceum",
"Cephalotes atratus",
"Neoponera villosa",
"Dinoponera gigantea",
"Prenolepis imparis",
"Lasius flavus",
"Formica fusca",
"Myrmecia gulosa",
"Solenopsis molesta",
"Camponotus herculeanus",
"Cataulacus granulatus",
"Daceton armigerum",
"Polyrhachis dives",
"Pheidole dentata",
"Tetramorium immigrans",
"Messor barbarus",
"Cardiocondyla obscurior",
"Nylanderia flavipes",
"Forelius pruinosus",
"Amblyopone pallipes",
]);
return (
<Smartable.Table data={rows.map((row, rowIndex) => ({
cells: {
name: {
data: row,
},
actions: {
data: null,
element: (
<Cell>
<button className={"remove"}
onClick={() => setRows(rows.toSpliced(rowIndex, 1))}>
Remove
</button>
</Cell>
)
},
},
}))} disableFilter={true} disableSort={true} />
);
}

View file

@ -1,2 +1,18 @@
import "./src/styles/smartable.less";
export * from "./src/Smartable/AsyncManager";
export * from "./src/Smartable/Cell";
export * from "./src/Smartable/Column";
export * from "./src/Smartable/Instance";
export * from "./src/Smartable/Row";
export * from "./src/Smartable/Smartable";
export * from "./src/Smartable/Sort";
export * from "./src/Smartable/Cells/ClickableCell";
export * from "./src/Smartable/Columns/ColumnFilter";
export * from "./src/Smartable/Columns/ColumnHeading";
export * from "./src/Smartable/Filters/EnumFilter";
export * from "./src/Smartable/Filters/NumberFilter";
export * from "./src/Smartable/Filters/StringFilter";

View file

@ -1,5 +1,5 @@
{
"version": "1.0.0-rc1",
"version": "1.1.1",
"name": "@kernelui/smartable",
"description": "Kernel UI Smartable.",
"scripts": {
@ -16,17 +16,23 @@
"publishConfig": {
"@kernelui:registry": "https://code.zeptotech.net/api/packages/UIKernel/npm/"
},
"dependencies": {
"@kernelui/core": "^1.1.0"
},
"devDependencies": {
"@kernelui/core": "^1.6.0",
"@phosphor-icons/react": "^2.1.7",
"@types/node": "^22.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"less": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-plugin-dts": "^3.9.1"
},
"packageManager": "yarn@4.2.2"
"peerDependencies": {
"@kernelui/core": "^1.1.2"
},
"packageManager": "yarn@4.5.0"
}

View file

@ -1,6 +1,6 @@
import React, {useEffect, useRef, useState} from "react";
import {ColumnKey} from "./Column";
import {RowCells, RowData, RowDefinition} from "./Row";
import {normalizeRowDefinition, RowData, RowDefinition} from "./Row";
import {CellDefinition} from "./Cell";
import {SmartableData} from "./Smartable";
import {Modify, Promisable} from "@kernelui/core";
@ -53,9 +53,6 @@ export function useAsyncManager<CK extends ColumnKey>(data: SmartableData<CK>):
asyncManager.current.handle(data);
}, [asyncManager, data]);
//TODO is this required? Update the current async state.
//asyncManager.current.update();
// Return the main instance of async manager.
return asyncManager.current;
}
@ -69,208 +66,236 @@ class AsyncManager<CK extends ColumnKey>
currentDataState: CurrentTableData<CK>;
setCurrentDataState: React.Dispatch<React.SetStateAction<CurrentTableData<CK>>>;
/**
* Data state update object.
*/
protected dataStateUpdate: {
rows: Record<number, Modify<RowData<CK>, {
cells: Partial<Record<CK, CellDefinition>>;
}>>;
};
/**
* Main promised data.
* @protected
*/
protected promisedData: Promised<SmartableData<CK>>;
/**
* Promised row definitions.
* Promised rows data.
* @protected
*/
protected promisedRowsDefinitions: Promised<RowDefinition<CK>>[];
protected promisedRows: Promised<RowDefinition<CK>>[] = [];
/**
* Promised full rows.
* Promised rows cells data.
* @protected
*/
protected promisedRows: Modify<RowData<CK>, {
cells: Record<CK, Promised<CellDefinition>>;
}>[];
protected promisedRowsCells: Partial<Record<CK, Promised<CellDefinition>>>[] = [];
/**
* Tells if rows are loaded or not.
* @protected
*/
protected rowsLoaded: boolean = false;
/**
* Tells if rows need to be reinitialized or not.
* @protected
*/
protected reinitRows: boolean = false;
/**
* Rows data.
* @protected
*/
protected rowsData: RowData<CK>[] = [];
/**
* Rows cells definitions.
* @protected
*/
protected cellsDefinitions: Partial<Record<CK, CellDefinition<CK>>>[] = [];
constructor()
{
// Initialize promised data object.
this.promisedData = new Promised(this.handleNewData.bind(this));
this.promisedRows = [];
this.promisedData = new Promised(this.handleNewRowsDefinitions.bind(this));
}
/**
* Handle new Smartable data.
* @param data Smartable data to handle.
* Handle new smartable data.
* @param data Smartable data.
*/
handle(data: SmartableData<CK>)
handle(data: SmartableData<CK>): void
{
// Refresh global promised data.
this.rowsLoaded = false;
this.promisedData.refresh(data);
console.log(this.dataStateUpdate);
// Update state.
this.dataStateUpdate = {
rows: this.dataStateUpdate?.rows,
};
this.update();
}
/**
* Called when new Smartable data is loaded.
* @param newData New loaded data.
* Handle new rows definitions.
* @param rowsDefinitions Rows definitions.
* @protected
*/
protected handleNewData(newData: (Promisable<RowDefinition<CK>>)[]): void
protected handleNewRowsDefinitions(rowsDefinitions: Promisable<RowDefinition<CK>>[]): void
{
// Initialize a new array of updated promised rows.
const updatedPromiseRows: Promised<RowDefinition<CK>>[] = [];
// Ignore undefined value.
if (rowsDefinitions == undefined) return;
for (const [rowId, row] of newData.entries())
{ // For each promisable row, save the promised row in the updated array.
updatedPromiseRows[rowId] = (this.promisedRowsDefinitions?.[rowId] ?? new Promised(this.handleNewRow.bind(this, rowId))).refresh(row);
// Rows have been reinitialized.
this.reinitRows = true;
// Initialize rows data and cells definitions.
this.rowsData = [];
this.cellsDefinitions = [];
this.rowsLoaded = true;
for (const [rowId, rowDefinition] of rowsDefinitions.entries())
{ // Get row data of each row.
if (!this.promisedRows[rowId])
this.promisedRows[rowId] = new Promised(this.handleNewRow.bind(this, rowId), rowDefinition);
else
this.promisedRows[rowId].refresh(rowDefinition);
}
// Save new promised rows.
this.promisedRowsDefinitions = updatedPromiseRows;
// Update state.
this.dataStateUpdate = {
rows: this.dataStateUpdate.rows ?? undefined,
};
this.update();
// Try to update the current data state.
this.tryToUpdate();
}
/**
* Called when a new row definition is loaded.
* Handle a new row.
* @param rowId Row ID.
* @param newRow New row definition.
* @param row Row definition.
* @protected
*/
protected handleNewRow(rowId: number, newRow: RowDefinition<CK>): void
protected handleNewRow(rowId: number, row: RowDefinition<CK>): void
{
if (!("cells" in newRow) || !Object.values(newRow?.cells).some((cellData: Promisable<CellDefinition>) => (
cellData instanceof Promise || ("data" in cellData)
))) { // If the row definition doesn't form a RowData object (= it is a RowCell object), converting it.
newRow = {
cells: newRow as RowCells<CK>,
};
// Ignore undefined value.
if (row == undefined) return;
// Normalize row data.
const rowData = normalizeRowDefinition(row);
// Save row data.
this.rowsData[rowId] = rowData;
// Initialize cells definition.
this.cellsDefinitions[rowId] = {};
for (const [columnKey, cellDefinition] of Object.entries(rowData.cells) as [CK, CellDefinition][])
{ // Get cell definition of each row cell.
if (!this.promisedRowsCells[rowId]) this.promisedRowsCells[rowId] = {};
if (!this.promisedRowsCells[rowId][columnKey])
this.promisedRowsCells[rowId][columnKey] = new Promised(this.handleNewCell.bind(this, rowId, columnKey), cellDefinition);
else
this.promisedRowsCells[rowId][columnKey].refresh(cellDefinition);
}
// Build the new row cells object, with the promised cells.
this.promisedRows[rowId] = Object.assign({}, newRow, {
cells: Object.fromEntries(
// For each cell, create its promised object from the given promisable.
(Object.entries(newRow.cells) as [CK, Promisable<CellDefinition>][]).map(([columnKey, cellData]) => (
// Return the same entry, with a promised instead of a promisable.
[columnKey, (this.promisedRows?.[rowId]?.cells?.[columnKey] ?? new Promised<CellDefinition>(this.handleNewCell.bind(this, rowId, columnKey))).refresh(cellData)]
))
) as Record<CK, Promised<CellDefinition>>,
});
// Update state.
this.dataStateUpdate = {
rows: {
[rowId]: {
cells: Object.fromEntries((Object.keys(newRow.cells) as CK[]).map((columnKey) => (
[columnKey, this.dataStateUpdate.rows[rowId].cells[columnKey]] ?? [columnKey, undefined]
))) as Record<CK, CellDefinition>,
cellElement: newRow.cellElement,
element: newRow.element,
},
},
};
this.update();
// Try to update the current data state.
this.tryToUpdate();
}
/**
* Called when a new row cell definition is loaded.
* Handle a new cell.
* @param rowId Cell row ID.
* @param columnKey Cell column key.
* @param cellDefinition Cell definition.
* @protected
*/
protected handleNewCell(rowId: number, columnKey: CK, cellData: CellDefinition): void
protected handleNewCell(rowId: number, columnKey: CK, cellDefinition: CellDefinition): void
{
// Update state.
if (!this.dataStateUpdate?.rows)
this.dataStateUpdate = { rows: {} };
if (!this.dataStateUpdate.rows?.[rowId])
this.dataStateUpdate.rows[rowId] = { cells: {}, };
this.dataStateUpdate.rows[rowId].cells[columnKey] = cellData;
this.update();
// Ignore undefined value.
if (cellDefinition == undefined) return;
// Save cell definition.
this.cellsDefinitions[rowId][columnKey] = cellDefinition;
// Try to update the current data state.
this.tryToUpdate();
}
/**
* Update the current async state.
* True if there is a pending update.
* @protected
*/
protected pendingUpdate: boolean = false;
/**
* Try to update the current data state, if there are no more changes in the next 25ms.
* The next update will be in max 25ms.
* @protected
*/
protected tryToUpdate(): void
{
if (!this.pendingUpdate)
// Try to update in the next 25ms.
setTimeout(() => {
this.pendingUpdate = false;
this.update();
}, 25);
}
/**
* Update the current data state with the loaded rows and cells data.
* @protected
*/
protected update(): void
{
// Set the new current state.
this.setCurrentDataState(this.currentDataState = this.buildNewState());
if (!(
// Checking that there is at least one changed value.
this.reinitRows ||
this.rowsData.length > 0 || this.cellsDefinitions.some((rowCells) => (
Object.keys(rowCells).length > 0
))
|| ((this.rowsLoaded && !this.currentDataState.rows) || (!this.rowsLoaded && this.currentDataState.rows))
)) // Nothing has changed.
return;
// Initialize new data.
const newData = {
rows: !this.rowsLoaded ? undefined : this.reinitRows ? [] : [
...(this.currentDataState?.rows ?? [])
],
};
// Rows have been reinitialized.
this.reinitRows = false;
for (const [rowId, newRow] of this.rowsData?.entries())
{ // Update value of each new row.
newData.rows[rowId] = {
element: newRow.element,
cellElement: newRow.cellElement,
cells: newData.rows[rowId]?.cells,
};
}
for (const [rowId, rowCells] of this.cellsDefinitions?.entries())
{ // Update cells of each changed row.
newData.rows[rowId] = {
...newData.rows[rowId],
cells: {...(newData.rows[rowId]?.cells ?? {}), ...rowCells} as Record<CK, CellDefinition>,
}
}
// Update the current data state.
this.currentDataState = newData;
this.setCurrentDataState(
newData
);
}
}
/**
* Build a new state from the current async state.
* @protected
* Promised data class.
*/
protected buildNewState(): CurrentTableData<CK>
{
if (this.promisedData.isInitialized())
{ // Waiting for initialization.
return this.currentDataState;
}
const newState = {
rows: this.currentDataState.rows,
};
if (this.dataStateUpdate.rows)
{ // Something changed in the rows.
// Copy the existing rows, if there are some.
newState.rows = [...this.currentDataState.rows];
for (const [rowId, rowData] of Object.entries(this.dataStateUpdate.rows))
{ // For each changed row, creating its new state.
// Get current row state.
const currentRow = this.currentDataState.rows?.[parseInt(rowId)];
if (currentRow)
{ // If there was an existing row, copy the current row state and only change the specified cells.
const newRow = {...currentRow};
if (rowData.cells)
{ // If some cells have been changed, updating them.
newRow.cells = Object.assign({}, currentRow.cells, rowData.cells);
}
if (rowData.cellElement)
{ // If cell element have been changed, updating it.
newRow.cellElement = rowData.cellElement;
}
if (rowData.element)
{ // If element have been changed, updating it.
newRow.element = rowData.element;
}
// Set the new row state.
newState.rows[parseInt(rowId)] = newRow;
}
else
// Create a new row state with the given cells' data.
newState.rows[parseInt(rowId)] = {...rowData} as CurrentRowData<CK>;
}
}
return newState;
}
}
export class Promised<T>
{
/**
* The main data promise.
*/
promise?: Promise<T>;
/**
* Data retrieved from promise or given in parameter.
*/
data?: T;
/**
* Called when data is changed.
*/
onData?: (data: T) => void;
constructor(onChanged?: (data: T) => void, data?: Promisable<T>)
@ -280,24 +305,31 @@ export class Promised<T>
this.refresh(data);
}
/**
* Refresh the promised data.
* @param data Promised data.
*/
refresh(data: Promisable<T>): this
{
if (data instanceof Promise)
{
{ // We have a promise of data.
if (data != this.promise)
{
this.data = undefined;
{ // The promise is different from the saved one.
// Save the new promise and set data to undefined.
this.promise = data;
this.data = undefined;
this.onData?.(undefined);
// Wait for promise to resolve to get actual data.
this.promise.then((data) => {
// Data is retrieved, saving it.
this.data = data;
this.onData?.(data);
});
}
}
else
{
else if (data != this.data)
{ // We already have data, and it is different from the current state.
this.data = data;
this.onData?.(data);
}
@ -305,11 +337,17 @@ export class Promised<T>
return this;
}
/**
* Return true if some data (or its promise) have been provided.
*/
isInitialized(): boolean
{
return !!this.data || !!this.promise;
}
/**
* Return true if we are waiting for a promise result.
*/
isLoading(): boolean
{
return this.data === undefined && !!this.promise;

View file

@ -22,14 +22,14 @@ export interface CellDefinition<T = any>
/**
* Default cell component.
*/
export function Cell({children}: React.PropsWithChildren<{}>)
export function Cell({children, ...props}: React.PropsWithChildren<React.TdHTMLAttributes<HTMLTableCellElement>>)
{
// Get cell data.
const {data} = useCell();
// Try to render cell data to string when no children given.
return (
<td>{children ?? String(data)}</td>
<td {...props}>{children ?? String(data)}</td>
);
}
@ -67,3 +67,11 @@ export function CellInstance<T>({cell}: {cell: CellDefinition<T>})
</CellContext.Provider>
)
}
/**
* Animated cell loader.
*/
export function CellLoader()
{
return <td className={"generic loader"}></td>;
}

View file

@ -0,0 +1,17 @@
import React from "react";
import {Cell} from "../Cell";
import {Modify} from "@kernelui/core";
/**
* Clickable cell component.
*/
export function ClickableCell({role, children, ...props}: React.PropsWithChildren<Modify<React.TdHTMLAttributes<HTMLTableCellElement>, {
role?: never;
}>>)
{
return (
<Cell role={"button"} {...props}>
{children}
</Cell>
)
}

View file

@ -1,6 +1,7 @@
import React, {useCallback, useContext} from "react";
import {Smartable, useTable} from "./Smartable";
import {Instance} from "./Instance";
import {ColumnFilter} from "./Columns/ColumnFilter";
import {SortState} from "./Sort";
/**
* Basic column key type.
@ -26,7 +27,7 @@ export function createColumns<K extends ColumnKey>(...columns: [K, Column][]): C
/**
* Smartable column definition.
*/
export interface Column
export interface Column<T = any>
{
/**
* Column title element.
@ -39,11 +40,16 @@ export interface Column
cellElement?: React.ReactElement;
/**
* Sorting function for data of the column.
* Cells data comparison in the column.
* @param a First data to compare.
* @param b Second data to compare.
*/
sort?: (a: unknown, b: unknown) => number;
compare?: (a: T, b: T) => number;
/**
* Column filter definition.
*/
filter?: ColumnFilter;
}
/**
@ -56,31 +62,6 @@ export function createColumn<K extends ColumnKey>(key: K, column: Column): [K, C
return [key, column];
}
/**
* Column sort type.
*/
export enum SortType
{
ASC = "asc",
DESC = "desc",
}
/**
* Column sort state.
*/
export interface SortState
{
/**
* Sort type (ascending or descending).
*/
type: SortType;
/**
* Sort order.
*/
order: number;
}
/**
* Table column context data.
*/
@ -100,6 +81,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);
@ -112,45 +104,6 @@ export function useColumn<CK extends ColumnKey>(smartable?: Smartable<CK>): Colu
return useContext(ColumnContext);
}
/**
* Default column heading component.
*/
export function ColumnHeading()
{
// Get current column data.
const {key, column, sortState} = useColumn();
// Get column sort state setter.
const {setColumnSortState} = useTable();
// Initialize handle click function.
const handleClick = useCallback((event: React.MouseEvent) => {
if (event.button == 0)
{ // Normal click (usually left click).
// Toggle sort type.
setColumnSortState(key, sortState?.type == SortType.ASC ? SortType.DESC : SortType.ASC);
}
else if (event.button == 2 || event.button == 1)
{ // Alt click (usually right or middle click).
// Reset sort type.
setColumnSortState(key, null);
}
}, [key, sortState, setColumnSortState]);
// Disable context menu function.
const disableContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault();
return false;
}, []);
return (
<th className={sortState?.type ?? undefined} data-sort-order={sortState?.order ?? undefined}
onMouseDown={handleClick} onContextMenu={disableContextMenu}>
{column.title}
</th>
);
}
/**
* Auto column context provider from table context.
*/
@ -159,6 +112,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,
@ -166,26 +125,12 @@ 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>
)
}
/**
* Global generic string comparator.
*/
const comparator = Intl.Collator();
/**
* Generic sorting function for data of a column.
* @param a First data to compare.
* @param b Second data to compare.
*/
export function genericColumnSort(a: any, b: any): number
{
if (typeof a == "number" && typeof b == "number")
return b - a;
else
return comparator.compare(String(a), String(b));
}

View file

@ -0,0 +1,32 @@
import React from "react";
import {useColumn} from "../Column";
/**
* 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;
}
/**
* 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];
}

View file

@ -0,0 +1,45 @@
import React, {useCallback} from "react";
import {useColumn} from "../Column";
import {useTable} from "../Smartable";
import {SortType} from "../Sort";
/**
* Default column heading component.
*/
export function ColumnHeading()
{
// Get current column data.
const {key, column, sortState} = useColumn();
// Get column sort state setter.
const {setColumnSortState} = useTable();
// Initialize handle click function.
const handleClick = useCallback((event: React.MouseEvent) => {
if (event.button == 0)
{ // Normal click (usually left click).
// Toggle sort type.
setColumnSortState(key, sortState?.type == SortType.ASC ? SortType.DESC : SortType.ASC);
}
else if (event.button == 2 || event.button == 1)
{ // Alt click (usually right or middle click).
// Reset sort type.
setColumnSortState(key, null);
}
}, [key, sortState, setColumnSortState]);
// Disable context menu function.
const disableContextMenu = useCallback((event: React.MouseEvent) => {
event.preventDefault();
return false;
}, []);
return (
<th className={sortState?.type ?? undefined} data-sort-order={sortState?.order ?? undefined}
onMouseDown={handleClick} onContextMenu={disableContextMenu}>
{column.title}
{sortState?.order && <span className={"order"}>{sortState.order}</span>}
</th>
);
}

View file

@ -0,0 +1,69 @@
import React, {useCallback} from "react";
import {ColumnFilter, useFilterState} from "../Columns/ColumnFilter";
import {Select} from "@kernelui/core";
/**
* String column filter.
*/
export function EnumFilter<OptionKey extends keyof any, Option>(options: Record<OptionKey, Option>): ColumnFilter<OptionKey[], EnumFilterState<OptionKey>>
{
return (
{
filter: (data: OptionKey|OptionKey[], filterState: EnumFilterState<OptionKey>) => {
// Normalized data array.
const dataArray = Array.isArray(data) ? data : [data];
// Nothing in filter value, allow everything.
if (!filterState?.value?.length) return true;
// Get current filter result.
return filterState?.value?.some(
// Keep the row if any element in data match the current filter value.
(optionKey) => dataArray.includes(optionKey)
);
},
element: <EnumFilterComponent options={options} />
}
);
}
/**
* Enumeration filter state.
*/
export interface EnumFilterState<OptionKey extends keyof any>
{
/**
* Filter value.
*/
value: OptionKey[];
}
/**
* Enum filter component.
*/
export function EnumFilterComponent<OptionKey extends keyof any, Option>({options}: {
/**
* Enum options.
*/
options: Record<OptionKey, Option>;
})
{
// Initialize enum filter state.
const [enumFilterState, setEnumFilterState] =
useFilterState<EnumFilterState<OptionKey>>({ value: [] });
// Handle filter input change.
const handleChange = useCallback((filter: OptionKey[]) => {
// Save the current filter value.
setEnumFilterState({
value: filter,
})
}, [setEnumFilterState]);
return (
<Select<OptionKey, Option> options={options} multiple={true} size={1}
className={!enumFilterState.value?.length ? "empty" : undefined}
value={enumFilterState.value} onChange={handleChange} />
)
}

View file

@ -0,0 +1,71 @@
import React, {useCallback} from "react";
import {ColumnFilter, useFilterState} from "../Columns/ColumnFilter";
/**
* Filter value regex.
*/
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,60 @@
import React, {useCallback} from "react";
import {ColumnFilter, useFilterState} from "../Columns/ColumnFilter";
import {normalizeString} from "@kernelui/core";
/**
* Filter value regex.
*/
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,9 +1,11 @@
import React from "react";
import {AutoColumnContextProvider, Column, ColumnContext, ColumnHeading, ColumnKey, Columns} from "./Column";
import React, {useMemo} from "react";
import {AutoColumnContextProvider, Column, ColumnKey, Columns} from "./Column";
import {SmartableProperties, useTable} from "./Smartable";
import {Async, Promisable} from "@kernelui/core";
import {RowDefinition, RowInstance} from "./Row";
import {RowInstance, RowLoader} from "./Row";
import {useAsyncManager} from "./AsyncManager";
import {ColumnHeading} from "./Columns/ColumnHeading";
import {sortRows} from "./Sort";
import {AutoPaginate, classes} from "@kernelui/core";
/**
* Smartable instance component properties.
@ -19,15 +21,60 @@ export interface InstanceProperties<CK extends ColumnKey> extends SmartablePrope
/**
* Main component for a Smartable table.
*/
export function Instance<CK extends ColumnKey>({columns}: InstanceProperties<CK>)
export function Instance<CK extends ColumnKey>(props: InstanceProperties<CK>)
{
if (props.paginate)
{ // If pagination is enabled.
return <PaginatedInstance {...props} />
}
else
{ // No pagination, simple body render.
return (
<Table {...props}>
<TableBody />
</Table>
);
}
}
/**
* Paginated Smartable instance component.
*/
export function PaginatedInstance<CK extends ColumnKey>(props: InstanceProperties<CK>)
{
// Get data from table.
const {data} = useTable<CK>();
// Get current data state from the async table value.
const {currentDataState} = useAsyncManager<CK>(data);
// Compute page count for the current rows.
const pageCount = Math.ceil((currentDataState?.rows?.length ?? 0) / props.paginate.pageSize);
return (
<AutoPaginate count={pageCount}>
{(page) => (
// Render table with table body of the current page.
<Table {...props}>
<TableBody pagination={{page: page, pageSize: props.paginate.pageSize}} />
</Table>
)}
</AutoPaginate>
);
}
/**
* Base component for a Smartable table.
*/
export function Table<CK extends ColumnKey>({className, columns, children}: React.PropsWithChildren<InstanceProperties<CK>>)
{
return (
<table>
<table className={classes("smartable", className)}>
<thead>
<ColumnsHeadings columns={columns} />
</thead>
<tbody>
<TableBody />
{children}
</tbody>
</table>
);
@ -38,8 +85,12 @@ export function Instance<CK extends ColumnKey>({columns}: InstanceProperties<CK>
*/
export function ColumnsHeadings<CK extends ColumnKey>({columns}: {columns: Columns<CK>})
{
// Get feature disable options.
const {disableSort, disableFilter} = useTable<CK>();
return (
<tr className={"headings"}>
<>
<tr className={classes("headings", disableSort === true ? "disable-sort" : undefined)}>
{ // Showing title of each column.
Object.keys(columns).map((key) => (
<AutoColumnContextProvider key={key} columnKey={key}>
@ -48,49 +99,72 @@ export function ColumnsHeadings<CK extends ColumnKey>({columns}: {columns: Colum
))
}
</tr>
);
{ // Add filters if filter feature is not disabled.
disableFilter !== true && (
<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>
)
))
}
export function TableBody<CK extends ColumnKey>()
{
// Get data from table.
const {data} = useTable();
// Get async data manager for the current table.
const asyncManager = useAsyncManager(data);
console.log(
asyncManager.currentDataState
);
return (
asyncManager.currentDataState?.rows?.map((rowData, index) => (
// Rendering each row.
<RowInstance key={index} row={rowData} />
))
);
/*return (
<Async<(Promisable<RowDefinition<CK>>)[]> promise={data}>
{(rowsData) => (
// Rendering defined rows.
<>
{ // Rendering each row.
rowsData.map((rowData, index) => (
// Rendering current row from its definition.
<Async<RowDefinition<CK>> key={index} promise={rowData}>
{(rowDefinition) => (
<RowInstance row={rowDefinition} />
)}
</Async>
))
</tr>
)
}
</>
)}
</Async>
);*/
);
}
export function TableRows<CK extends ColumnKey>()
/**
* Smartable table body.
*/
export function TableBody<CK extends ColumnKey>({pagination}: {
/**
* Current pagination state.
*/
pagination?: { page: number; pageSize: number; };
})
{
// Get data from table.
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.
let sortedRows = useMemo(() => (
// Sort rows with the current columns sort state.
sortRows<CK>(filteredRows, columns, columnsSortState)
), [filteredRows, columns, columnsSortState]);
if (pagination)
{ // If pagination is enabled, showing only content of the current page.
const startIndex = (pagination.page - 1) * pagination.pageSize;
sortedRows = sortedRows?.slice(startIndex, startIndex + pagination.pageSize);
}
return (
sortedRows ? (
sortedRows.map((rowData, index) => (
// Rendering each row from its definition.
<RowInstance key={index} row={rowData} />
))
) : (
<RowLoader />
)
);
}

View file

@ -1,8 +1,9 @@
import React, {useContext, useMemo} from "react";
import {ColumnContext, ColumnKey} from "./Column";
import {CellDefinition, CellInstance} from "./Cell";
import React, {useContext} from "react";
import {AutoColumnContextProvider, ColumnContext, ColumnKey} from "./Column";
import {CellDefinition, CellInstance, CellLoader} from "./Cell";
import {Smartable, useTable} from "./Smartable";
import {Async, Promisable} from "@kernelui/core";
import { Promisable} from "@kernelui/core";
import {CurrentRowData} from "./AsyncManager";
/**
* Smartable row cells.
@ -35,10 +36,27 @@ export interface RowData<CK extends ColumnKey, T = any>
*/
export type RowDefinition<CK extends ColumnKey> = RowCells<CK>|RowData<CK>;
/**
* Normalize row definition to row data.
*/
export function normalizeRowDefinition<CK extends ColumnKey>(rowDefinition: RowDefinition<CK>): RowData<CK>
{
if (!("cells" in rowDefinition) || !Object.values(rowDefinition?.cells).some((cellData: Promisable<CellDefinition>) => (
cellData instanceof Promise || ("data" in cellData)
))) { // If the row definition doesn't form a RowData object (= it is a RowCell object), converting it.
rowDefinition = {
cells: rowDefinition as RowCells<CK>,
};
}
// Return changed row definition, or just keep the default one if it matched RowData.
return rowDefinition;
}
/**
* Table row context data.
*/
export interface RowContextData<CK extends ColumnKey, T = any> extends RowData<CK, T>
export interface RowContextData<CK extends ColumnKey, T = any> extends CurrentRowData<CK, T>
{
}
@ -73,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 }}>
<Async<CellDefinition> promise={row.cells?.[columnKey] ?? { data: undefined }}>
{(cellDefinition) => (
<CellInstance cell={cellDefinition} />
)}
</Async>
</ColumnContext.Provider>
Object.keys(columns).map((columnKey) => (
<AutoColumnContextProvider key={columnKey} columnKey={columnKey}>
{ // Show current cell.
row.cells?.[columnKey]
? <CellInstance cell={row.cells?.[columnKey]} />
: <CellLoader />
}
</AutoColumnContextProvider>
))
);
}
@ -88,21 +106,29 @@ export function RowCells()
/**
* Row instance component.
*/
export function RowInstance<CK extends ColumnKey>({row}: { row: RowDefinition<CK> })
export function RowInstance<CK extends ColumnKey>({row}: { row: CurrentRowData<CK> })
{
// Get row context value from given row definition.
const rowContextValue = useMemo(() => (
// If a simple RowCells<CK> object is given, converting it to a RowData<CK>
!("cells" in row) ? { cells: row } : row
), [row]);
// Get table row element.
const {rowElement} = useTable();
return (
<RowContext.Provider value={rowContextValue}>
<RowContext.Provider value={row}>
{ // Trying to render row-specific element, then table-specific element, then default element.
rowContextValue.element ?? rowElement ?? <Row />
row.element ?? rowElement ?? <Row />
}
</RowContext.Provider>
);
}
/**
* Animated row loader.
*/
export function RowLoader()
{
// Get table columns to get their count.
const {columns} = useTable();
return (
<tr><td colSpan={Object.keys(columns).length} className={"generic loader"}></td></tr>
)
}

View file

@ -1,8 +1,9 @@
import React, {useCallback, useContext, useMemo, useState} from "react";
import {Instance} from "./Instance";
import {ColumnKey, Columns, SortState, SortType} from "./Column";
import {ColumnKey, Columns} from "./Column";
import {RowDefinition} from "./Row";
import {Promisable} from "@kernelui/core";
import {SortState, SortType} from "./Sort";
/**
* Smartable data type.
@ -14,6 +15,11 @@ export type SmartableData<CK extends ColumnKey> = Promisable<(Promisable<RowDefi
*/
export interface SmartableProperties<CK extends ColumnKey>
{
/**
* Table custom class name.
*/
className?: string;
/**
* Table data.
*/
@ -33,6 +39,26 @@ export interface SmartableProperties<CK extends ColumnKey>
* Default column heading element.
*/
columnHeadingElement?: React.ReactElement;
/**
* Table rows pagination.
*/
paginate?: {
/**
* Number of rows per page.
*/
pageSize: number;
};
/**
* Disable sort feature.
*/
disableSort?: boolean;
/**
* Disable filter feature.
*/
disableFilter?: boolean;
}
/**
@ -45,6 +71,11 @@ export type Smartable<CK extends ColumnKey> = {
Table: React.FunctionComponent<SmartableProperties<CK>>;
};
/**
* Smartable columns infered type.
*/
export type SmartableColumns<CK> = CK extends Smartable<infer CK> ? CK : never;
/**
* Define a new Smartable.
*/
@ -81,10 +112,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 => {
@ -118,13 +146,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}>
@ -149,14 +197,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;
}
/**

79
src/Smartable/Sort.tsx Normal file
View file

@ -0,0 +1,79 @@
import {ColumnKey, Columns} from "./Column";
import {CurrentRowData} from "./AsyncManager";
/**
* Column sort type.
*/
export enum SortType
{
ASC = "asc",
DESC = "desc",
}
/**
* Column sort state.
*/
export interface SortState
{
/**
* Sort type (ascending or descending).
*/
type: SortType;
/**
* Sort order.
*/
order: number;
}
/**
* Sort the given rows from the columns sort state.
* @param rows Rows to sort.
* @param columns Columns definition.
* @param columnsSortState Columns current sort state.
*/
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;
// Get the sort column states following their priority order.
const orderedSortColumns = (Object.entries(columnsSortState) as [CK, SortState][]).sort((a, b) => (
a[1].order - b[1].order
));
// Sort the given rows.
return rows.toSorted((a, b) => {
for (const [columnKey, columnSort] of orderedSortColumns)
{
// Get the current comparison result for the given cells' data.
const comparison =
(columnSort.type == SortType.DESC ? 1 : -1) *
(columns[columnKey]?.compare ?? genericCompare)(a.cells[columnKey]?.data, b.cells[columnKey]?.data);
if (comparison != 0)
// If an order can be determined from the current comparison, returning it.
return comparison;
}
// The comparisons always returned 0.
return 0;
});
}
/**
* Generic comparison function for table data.
* @param a First data to compare.
* @param b Second data to compare.
*/
export function genericCompare(a: any, b: any): number
{
if (typeof a == "number" && typeof a == typeof b)
{ // Compare numbers.
return b - a;
}
else
{ // Compare data as strings.
return String(b).localeCompare(String(a));
}
}

View file

11
src/styles/_cells.less Normal file
View file

@ -0,0 +1,11 @@
td[role="button"]
{
transition: background 0.2s ease;
&:hover
{
background: rgba(0, 0, 0, 0.1);
}
cursor: pointer;
}

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

@ -0,0 +1,30 @@
tr.filters
{ // Filters row style.
td
{
padding: 0.25em 0.125em;
&:first-child:not(:last-child)
{
padding-left: 0.25em;
}
input, .select
{
margin: 0;
width: 100%;
}
.select
{
ul.selected
{
margin: 0 0 0.25em 0;
}
&.empty ul.selected
{
margin: 0;
}
}
}
}

View file

@ -1,4 +1,11 @@
tr.headings
{
&.disable-sort
{
th { pointer-events: none; }
}
&:not(.disable-sort)
{
th
{
@ -60,5 +67,23 @@ tr.headings
{
bottom: 0.5em;
}
.order
{ // Sort order indicator.
position: relative;
top: 0;
bottom: 0;
display: block;
margin: auto 0;
padding: 0.3em 0.3em 0;
align-items: center;
justify-content: center;
color: var(--foreground-lightest);
font-size: 0.7em;
float: right;
}
}
}
}

4
src/styles/_loaders.less Normal file
View file

@ -0,0 +1,4 @@
tr > td.generic.loader:first-child:last-child
{
height: 3em;
}

View file

@ -1 +1,9 @@
table.smartable
{
margin: 0.5em auto;
@import "_cells";
@import "_filters";
@import "_headings";
@import "_loaders";
}

View file

@ -16,7 +16,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
fileName: "index",
},
rollupOptions: {
external: ["react"],
external: ["react", "react-dom", "react-router-dom", "@phosphor-icons/react", "@kernelui/core"],
},
},

667
yarn.lock

File diff suppressed because it is too large Load diff