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 React from "react";
import {Application} from "@kernelui/core"; import {Application} from "@kernelui/core";
import {DemoTable} from "./DemoTable"; import {DemoTable} from "./DemoTable";
import {RemoveDemoTable} from "./RemoveDemoTable";
export function DemoApp() export function DemoApp()
{ {
return ( return (
<Application> <Application>
<h1>Simple table</h1> <h1>Random table</h1>
<DemoTable /> <DemoTable />
<RemoveDemoTable />
</Application> </Application>
) )
} }

View file

@ -1,71 +1,171 @@
import React from "react"; import React, {useMemo} from "react";
import {createSmartable} from "../src/Smartable/Smartable"; import {createSmartable, SmartableColumns, SmartableData} from "../src/Smartable/Smartable";
import {createColumn, createColumns} from "../src/Smartable/Column"; 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. // Create main table.
const Smartable = createSmartable({ const Smartable = createSmartable({
columns: createColumns( columns: createColumns(
createColumn("123", { createColumn("name", {
title: "test", title: "Name",
filter: StringFilter,
}), }),
createColumn("456", { createColumn("quantity", {
title: "ttt", title: "Quantity",
filter: NumberFilter,
}), }),
createColumn("789", { createColumn("unit-price", {
title: "another", title: "Unit price",
}), }),
createColumn("test", { createColumn("total-price", {
title: "last one", 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() 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 ( return (
<Smartable.Table shownColumns={["123", "456", "test"]} data={[ <Smartable.Table data={demoDataPromise} paginate={{ pageSize: 6 }} />
{
"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",
},
},
]} />
); );
} }

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"; 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", "name": "@kernelui/smartable",
"description": "Kernel UI Smartable.", "description": "Kernel UI Smartable.",
"scripts": { "scripts": {
@ -16,17 +16,23 @@
"publishConfig": { "publishConfig": {
"@kernelui:registry": "https://code.zeptotech.net/api/packages/UIKernel/npm/" "@kernelui:registry": "https://code.zeptotech.net/api/packages/UIKernel/npm/"
}, },
"dependencies": {
"@kernelui/core": "^1.1.0"
},
"devDependencies": { "devDependencies": {
"@kernelui/core": "^1.6.0",
"@phosphor-icons/react": "^2.1.7",
"@types/node": "^22.0.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"less": "^4.2.0", "less": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"typescript": "^5.4.5", "typescript": "^5.4.5",
"vite": "^5.2.11", "vite": "^5.2.11",
"vite-plugin-dts": "^3.9.1" "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 React, {useEffect, useRef, useState} from "react";
import {ColumnKey} from "./Column"; import {ColumnKey} from "./Column";
import {RowCells, RowData, RowDefinition} from "./Row"; import {normalizeRowDefinition, RowData, RowDefinition} from "./Row";
import {CellDefinition} from "./Cell"; import {CellDefinition} from "./Cell";
import {SmartableData} from "./Smartable"; import {SmartableData} from "./Smartable";
import {Modify, Promisable} from "@kernelui/core"; import {Modify, Promisable} from "@kernelui/core";
@ -53,9 +53,6 @@ export function useAsyncManager<CK extends ColumnKey>(data: SmartableData<CK>):
asyncManager.current.handle(data); asyncManager.current.handle(data);
}, [asyncManager, data]); }, [asyncManager, data]);
//TODO is this required? Update the current async state.
//asyncManager.current.update();
// Return the main instance of async manager. // Return the main instance of async manager.
return asyncManager.current; return asyncManager.current;
} }
@ -69,208 +66,236 @@ class AsyncManager<CK extends ColumnKey>
currentDataState: CurrentTableData<CK>; currentDataState: CurrentTableData<CK>;
setCurrentDataState: React.Dispatch<React.SetStateAction<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. * Main promised data.
* @protected
*/ */
protected promisedData: Promised<SmartableData<CK>>; 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>, { protected promisedRowsCells: Partial<Record<CK, Promised<CellDefinition>>>[] = [];
cells: 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() constructor()
{ {
// Initialize promised data object. // Initialize promised data object.
this.promisedData = new Promised(this.handleNewData.bind(this)); this.promisedData = new Promised(this.handleNewRowsDefinitions.bind(this));
this.promisedRows = [];
} }
/** /**
* Handle new Smartable data. * Handle new smartable data.
* @param data Smartable data to handle. * @param data Smartable data.
*/ */
handle(data: SmartableData<CK>) handle(data: SmartableData<CK>): void
{ {
// Refresh global promised data. this.rowsLoaded = false;
this.promisedData.refresh(data); 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. * Handle new rows definitions.
* @param newData New loaded data. * @param rowsDefinitions Rows definitions.
* @protected * @protected
*/ */
protected handleNewData(newData: (Promisable<RowDefinition<CK>>)[]): void protected handleNewRowsDefinitions(rowsDefinitions: Promisable<RowDefinition<CK>>[]): void
{ {
// Initialize a new array of updated promised rows. // Ignore undefined value.
const updatedPromiseRows: Promised<RowDefinition<CK>>[] = []; if (rowsDefinitions == undefined) return;
for (const [rowId, row] of newData.entries()) // Rows have been reinitialized.
{ // For each promisable row, save the promised row in the updated array. this.reinitRows = true;
updatedPromiseRows[rowId] = (this.promisedRowsDefinitions?.[rowId] ?? new Promised(this.handleNewRow.bind(this, rowId))).refresh(row);
// 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. // Try to update the current data state.
this.promisedRowsDefinitions = updatedPromiseRows; this.tryToUpdate();
// Update state.
this.dataStateUpdate = {
rows: this.dataStateUpdate.rows ?? undefined,
};
this.update();
} }
/** /**
* Called when a new row definition is loaded. * Handle a new row.
* @param rowId Row ID. * @param rowId Row ID.
* @param newRow New row definition. * @param row Row definition.
* @protected * @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>) => ( // Ignore undefined value.
cellData instanceof Promise || ("data" in cellData) if (row == undefined) return;
))) { // If the row definition doesn't form a RowData object (= it is a RowCell object), converting it.
newRow = { // Normalize row data.
cells: newRow as RowCells<CK>, 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. // Try to update the current data state.
this.promisedRows[rowId] = Object.assign({}, newRow, { this.tryToUpdate();
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();
} }
/** /**
* 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
*/ */
protected handleNewCell(rowId: number, columnKey: CK, cellData: CellDefinition): void protected handleNewCell(rowId: number, columnKey: CK, cellDefinition: CellDefinition): void
{ {
// Update state. // Ignore undefined value.
if (!this.dataStateUpdate?.rows) if (cellDefinition == undefined) return;
this.dataStateUpdate = { rows: {} };
if (!this.dataStateUpdate.rows?.[rowId]) // Save cell definition.
this.dataStateUpdate.rows[rowId] = { cells: {}, }; this.cellsDefinitions[rowId][columnKey] = cellDefinition;
this.dataStateUpdate.rows[rowId].cells[columnKey] = cellData;
this.update(); // 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 protected update(): void
{ {
// Set the new current state. if (!(
this.setCurrentDataState(this.currentDataState = this.buildNewState()); // 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.
* Build a new state from the current async state. const newData = {
* @protected rows: !this.rowsLoaded ? undefined : this.reinitRows ? [] : [
*/ ...(this.currentDataState?.rows ?? [])
protected buildNewState(): CurrentTableData<CK> ],
{
if (this.promisedData.isInitialized())
{ // Waiting for initialization.
return this.currentDataState;
}
const newState = {
rows: this.currentDataState.rows,
}; };
if (this.dataStateUpdate.rows) // Rows have been reinitialized.
{ // Something changed in the rows. this.reinitRows = false;
// 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) for (const [rowId, newRow] of this.rowsData?.entries())
{ // If there was an existing row, copy the current row state and only change the specified cells. { // Update value of each new row.
const newRow = {...currentRow}; newData.rows[rowId] = {
element: newRow.element,
cellElement: newRow.cellElement,
cells: newData.rows[rowId]?.cells,
};
}
if (rowData.cells) for (const [rowId, rowCells] of this.cellsDefinitions?.entries())
{ // If some cells have been changed, updating them. { // Update cells of each changed row.
newRow.cells = Object.assign({}, currentRow.cells, rowData.cells); newData.rows[rowId] = {
} ...newData.rows[rowId],
if (rowData.cellElement) cells: {...(newData.rows[rowId]?.cells ?? {}), ...rowCells} as Record<CK, CellDefinition>,
{ // 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; // Update the current data state.
this.currentDataState = newData;
this.setCurrentDataState(
newData
);
} }
} }
/**
* Promised data class.
*/
export class Promised<T> export class Promised<T>
{ {
/**
* The main data promise.
*/
promise?: Promise<T>; promise?: Promise<T>;
/**
* Data retrieved from promise or given in parameter.
*/
data?: T; data?: T;
/**
* Called when data is changed.
*/
onData?: (data: T) => void; onData?: (data: T) => void;
constructor(onChanged?: (data: T) => void, data?: Promisable<T>) constructor(onChanged?: (data: T) => void, data?: Promisable<T>)
@ -280,24 +305,31 @@ export class Promised<T>
this.refresh(data); this.refresh(data);
} }
/**
* Refresh the promised data.
* @param data Promised data.
*/
refresh(data: Promisable<T>): this refresh(data: Promisable<T>): this
{ {
if (data instanceof Promise) if (data instanceof Promise)
{ { // We have a promise of data.
if (data != this.promise) if (data != this.promise)
{ { // The promise is different from the saved one.
this.data = undefined; // Save the new promise and set data to undefined.
this.promise = data; this.promise = data;
this.data = undefined;
this.onData?.(undefined); this.onData?.(undefined);
// Wait for promise to resolve to get actual data.
this.promise.then((data) => { this.promise.then((data) => {
// Data is retrieved, saving it.
this.data = data; this.data = data;
this.onData?.(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.data = data;
this.onData?.(data); this.onData?.(data);
} }
@ -305,11 +337,17 @@ export class Promised<T>
return this; return this;
} }
/**
* Return true if some data (or its promise) have been provided.
*/
isInitialized(): boolean isInitialized(): boolean
{ {
return !!this.data || !!this.promise; return !!this.data || !!this.promise;
} }
/**
* Return true if we are waiting for a promise result.
*/
isLoading(): boolean isLoading(): boolean
{ {
return this.data === undefined && !!this.promise; return this.data === undefined && !!this.promise;

View file

@ -22,14 +22,14 @@ export interface CellDefinition<T = any>
/** /**
* Default cell component. * Default cell component.
*/ */
export function Cell({children}: React.PropsWithChildren<{}>) export function Cell({children, ...props}: React.PropsWithChildren<React.TdHTMLAttributes<HTMLTableCellElement>>)
{ {
// Get cell data. // Get cell data.
const {data} = useCell(); const {data} = useCell();
// Try to render cell data to string when no children given. // Try to render cell data to string when no children given.
return ( 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> </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 React, {useCallback, useContext} from "react";
import {Smartable, useTable} from "./Smartable"; import {Smartable, useTable} from "./Smartable";
import {Instance} from "./Instance"; import {ColumnFilter} from "./Columns/ColumnFilter";
import {SortState} from "./Sort";
/** /**
* Basic column key type. * Basic column key type.
@ -26,7 +27,7 @@ export function createColumns<K extends ColumnKey>(...columns: [K, Column][]): C
/** /**
* Smartable column definition. * Smartable column definition.
*/ */
export interface Column export interface Column<T = any>
{ {
/** /**
* Column title element. * Column title element.
@ -39,11 +40,16 @@ export interface Column
cellElement?: React.ReactElement; cellElement?: React.ReactElement;
/** /**
* Sorting function for data of the column. * Cells data comparison in the column.
* @param a First data to compare. * @param a First data to compare.
* @param b Second 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]; 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. * Table column context data.
*/ */
@ -100,6 +81,17 @@ export interface ColumnContextData<CK extends ColumnKey>
* Column sort state. * Column sort state.
*/ */
sortState?: SortState; 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); 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); 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. * Auto column context provider from table context.
*/ */
@ -159,6 +112,12 @@ export function AutoColumnContextProvider({columnKey, children}: React.PropsWith
// Get table data. // Get table data.
const table = useTable(); 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 ( return (
<ColumnContext.Provider value={{ <ColumnContext.Provider value={{
key: columnKey, key: columnKey,
@ -166,26 +125,12 @@ export function AutoColumnContextProvider({columnKey, children}: React.PropsWith
column: table.columns[columnKey], column: table.columns[columnKey],
// Get current column sort state from table data. // Get current column sort state from table data.
sortState: table.columnsSortState?.[columnKey], sortState: table.columnsSortState?.[columnKey],
// Get current column filter state from table data.
filterState: table.columnsFilterStates?.[columnKey],
// Current column filter state dispatcher.
setFilterState: setFilterState,
}}> }}>
{children} {children}
</ColumnContext.Provider> </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 React, {useMemo} from "react";
import {AutoColumnContextProvider, Column, ColumnContext, ColumnHeading, ColumnKey, Columns} from "./Column"; import {AutoColumnContextProvider, Column, ColumnKey, Columns} from "./Column";
import {SmartableProperties, useTable} from "./Smartable"; import {SmartableProperties, useTable} from "./Smartable";
import {Async, Promisable} from "@kernelui/core"; import {RowInstance, RowLoader} from "./Row";
import {RowDefinition, RowInstance} from "./Row";
import {useAsyncManager} from "./AsyncManager"; import {useAsyncManager} from "./AsyncManager";
import {ColumnHeading} from "./Columns/ColumnHeading";
import {sortRows} from "./Sort";
import {AutoPaginate, classes} from "@kernelui/core";
/** /**
* Smartable instance component properties. * Smartable instance component properties.
@ -19,15 +21,60 @@ export interface InstanceProperties<CK extends ColumnKey> extends SmartablePrope
/** /**
* Main component for a Smartable table. * 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 ( return (
<table> <table className={classes("smartable", className)}>
<thead> <thead>
<ColumnsHeadings columns={columns} /> <ColumnsHeadings columns={columns} />
</thead> </thead>
<tbody> <tbody>
<TableBody /> {children}
</tbody> </tbody>
</table> </table>
); );
@ -38,59 +85,86 @@ export function Instance<CK extends ColumnKey>({columns}: InstanceProperties<CK>
*/ */
export function ColumnsHeadings<CK extends ColumnKey>({columns}: {columns: Columns<CK>}) export function ColumnsHeadings<CK extends ColumnKey>({columns}: {columns: Columns<CK>})
{ {
// Get feature disable options.
const {disableSort, disableFilter} = useTable<CK>();
return ( return (
<tr className={"headings"}> <>
{ // Showing title of each column. <tr className={classes("headings", disableSort === true ? "disable-sort" : undefined)}>
Object.keys(columns).map((key) => ( { // Showing title of each column.
<AutoColumnContextProvider key={key} columnKey={key}> Object.keys(columns).map((key) => (
<ColumnHeading /> <AutoColumnContextProvider key={key} columnKey={key}>
</AutoColumnContextProvider> <ColumnHeading/>
)) </AutoColumnContextProvider>
))
}
</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>
)
))
}
</tr>
)
} }
</tr> </>
); );
} }
export function TableBody<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. // Get data from table.
const {data} = useTable(); const {data, columns, columnsSortState, columnsFilterStates} = useTable<CK>();
// Get async data manager for the current table. // Get current data state from the async table value.
const asyncManager = useAsyncManager(data); const {currentDataState} = useAsyncManager<CK>(data);
console.log( // Memorize filtered rows.
asyncManager.currentDataState 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 ( return (
asyncManager.currentDataState?.rows?.map((rowData, index) => ( sortedRows ? (
// Rendering each row. sortedRows.map((rowData, index) => (
<RowInstance key={index} row={rowData} /> // Rendering each row from its definition.
)) <RowInstance key={index} row={rowData} />
))
) : (
<RowLoader />
)
); );
/*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>
))
}
</>
)}
</Async>
);*/
}
export function TableRows<CK extends ColumnKey>()
{
} }

View file

@ -1,8 +1,9 @@
import React, {useContext, useMemo} from "react"; import React, {useContext} from "react";
import {ColumnContext, ColumnKey} from "./Column"; import {AutoColumnContextProvider, ColumnContext, ColumnKey} from "./Column";
import {CellDefinition, CellInstance} from "./Cell"; import {CellDefinition, CellInstance, CellLoader} from "./Cell";
import {Smartable, useTable} from "./Smartable"; import {Smartable, useTable} from "./Smartable";
import {Async, Promisable} from "@kernelui/core"; import { Promisable} from "@kernelui/core";
import {CurrentRowData} from "./AsyncManager";
/** /**
* Smartable row cells. * 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>; 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. * 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(); const {columns} = useTable();
return ( return (
Object.entries(columns).map(([columnKey, column]) => ( Object.keys(columns).map((columnKey) => (
<ColumnContext.Provider key={columnKey} value={{ key: columnKey, column: column }}> <AutoColumnContextProvider key={columnKey} columnKey={columnKey}>
<Async<CellDefinition> promise={row.cells?.[columnKey] ?? { data: undefined }}> { // Show current cell.
{(cellDefinition) => ( row.cells?.[columnKey]
<CellInstance cell={cellDefinition} /> ? <CellInstance cell={row.cells?.[columnKey]} />
)} : <CellLoader />
</Async> }
</ColumnContext.Provider> </AutoColumnContextProvider>
)) ))
); );
} }
@ -88,21 +106,29 @@ export function RowCells()
/** /**
* Row instance component. * 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. // Get table row element.
const rowContextValue = useMemo(() => (
// If a simple RowCells<CK> object is given, converting it to a RowData<CK>
!("cells" in row) ? { cells: row } : row
), [row]);
const {rowElement} = useTable(); const {rowElement} = useTable();
return ( return (
<RowContext.Provider value={rowContextValue}> <RowContext.Provider value={row}>
{ // Trying to render row-specific element, then table-specific element, then default element. { // Trying to render row-specific element, then table-specific element, then default element.
rowContextValue.element ?? rowElement ?? <Row /> row.element ?? rowElement ?? <Row />
} }
</RowContext.Provider> </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 React, {useCallback, useContext, useMemo, useState} from "react";
import {Instance} from "./Instance"; import {Instance} from "./Instance";
import {ColumnKey, Columns, SortState, SortType} from "./Column"; import {ColumnKey, Columns} from "./Column";
import {RowDefinition} from "./Row"; import {RowDefinition} from "./Row";
import {Promisable} from "@kernelui/core"; import {Promisable} from "@kernelui/core";
import {SortState, SortType} from "./Sort";
/** /**
* Smartable data type. * Smartable data type.
@ -14,6 +15,11 @@ export type SmartableData<CK extends ColumnKey> = Promisable<(Promisable<RowDefi
*/ */
export interface SmartableProperties<CK extends ColumnKey> export interface SmartableProperties<CK extends ColumnKey>
{ {
/**
* Table custom class name.
*/
className?: string;
/** /**
* Table data. * Table data.
*/ */
@ -33,6 +39,26 @@ export interface SmartableProperties<CK extends ColumnKey>
* Default column heading element. * Default column heading element.
*/ */
columnHeadingElement?: React.ReactElement; 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>>; Table: React.FunctionComponent<SmartableProperties<CK>>;
}; };
/**
* Smartable columns infered type.
*/
export type SmartableColumns<CK> = CK extends Smartable<infer CK> ? CK : never;
/** /**
* Define a new Smartable. * Define a new Smartable.
*/ */
@ -81,10 +112,7 @@ export function createSmartable<CK extends ColumnKey>({columns}: {
return { return {
Table: (props: SmartableProperties<CK>) => { Table: (props: SmartableProperties<CK>) => {
// Initialize sort state. // Initialize sort state.
const [sortState, setSortState] = useState({} as Record<CK, SortState>); const [sortState, setSortState] = useState({} as Partial<Record<CK, SortState>>);
// Filter columns from the given property.
const filteredColumns = props.shownColumns ? filterColumns(columns, props.shownColumns) : columns;
// Set sort state of a specific column. // Set sort state of a specific column.
const setColumnSortState = useCallback((key: CK, sortType: SortType|null): void => { const setColumnSortState = useCallback((key: CK, sortType: SortType|null): void => {
@ -118,13 +146,33 @@ export function createSmartable<CK extends ColumnKey>({columns}: {
setSortState(newSortState); setSortState(newSortState);
}, [sortState, setSortState]); }, [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. // Initialize table context value.
const contextValue = useMemo<TableContextData<CK>>(() => ({ const contextValue = useMemo<TableContextData<CK>>(() => ({
columns: filteredColumns, columns: filteredColumns,
columnsSortState: sortState, columnsSortState: sortState,
setColumnSortState: setColumnSortState, setColumnSortState: setColumnSortState,
columnsFilterStates: filterStates,
setColumnFilterState: setColumnFilterState,
...props, ...props,
}), [filteredColumns, sortState, setSortState, props]); }), [sortState, setSortState, filterStates, setColumnFilterState, filteredColumns, props]);
return ( return (
<TableContext.Provider value={contextValue}> <TableContext.Provider value={contextValue}>
@ -149,14 +197,26 @@ export interface TableContextData<CK extends ColumnKey> extends SmartablePropert
/** /**
* Current table columns sort state. * 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 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. * @param sortType The sort type to set for the given column. NULL to reset sort state.
*/ */
setColumnSortState: (key: CK, sortType: SortType|null) => void; 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,64 +1,89 @@
tr.headings tr.headings
{ {
th &.disable-sort
{ {
position: relative; th { pointer-events: none; }
}
cursor: pointer; &:not(.disable-sort)
{
&::before, &::after th
{ // Sorting order indicator.
transition: height 0.2s ease, background 0.2s ease, top 0.2s ease, bottom 0.2s ease;
content: "";
position: absolute;
top: 0;
bottom: 0;
display: block;
margin: auto;
box-sizing: border-box;
background: var(--background-darkest);
}
&::before
{ {
right: calc(0.33em - 1px); position: relative;
width: 2px; cursor: pointer;
height: 0;
border-radius: 2px;
}
&::after
{
right: calc(0.33em - 3px);
width: 6px; &::before, &::after
height: 6px; { // Sorting order indicator.
border-radius: 6px; transition: height 0.2s ease, background 0.2s ease, top 0.2s ease, bottom 0.2s ease;
}
&.asc, &.desc content: "";
{ position: absolute;
&::after, &::before top: 0;
{ bottom: 0;
background: var(--primary);
display: block;
margin: auto;
box-sizing: border-box;
background: var(--background-darkest);
} }
&::before &::before
{ {
height: 0.8em; right: calc(0.33em - 1px);
}
}
&.asc::after width: 2px;
{ height: 0;
top: 0.5em; border-radius: 2px;
} }
&.desc::after &::after
{ {
bottom: 0.5em; right: calc(0.33em - 3px);
width: 6px;
height: 6px;
border-radius: 6px;
}
&.asc, &.desc
{
&::after, &::before
{
background: var(--primary);
}
&::before
{
height: 0.8em;
}
}
&.asc::after
{
top: 0.5em;
}
&.desc::after
{
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 @@
@import "_headings"; 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", fileName: "index",
}, },
rollupOptions: { 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