Compare commits

..

1 commit

Author SHA1 Message Date
76c56145ca
WIP
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-07-24 17:46:26 +02:00
27 changed files with 777 additions and 1616 deletions

6
TODO.md Normal file
View file

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

View file

@ -1,17 +1,14 @@
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>Random table</h1> <h1>Simple table</h1>
<DemoTable /> <DemoTable />
<RemoveDemoTable />
</Application> </Application>
) )
} }

View file

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

View file

@ -1,95 +0,0 @@
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,18 +1,2 @@
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.1.1", "version": "1.0.0-rc1",
"name": "@kernelui/smartable", "name": "@kernelui/smartable",
"description": "Kernel UI Smartable.", "description": "Kernel UI Smartable.",
"scripts": { "scripts": {
@ -16,23 +16,17 @@
"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"
}, },
"peerDependencies": { "packageManager": "yarn@4.2.2"
"@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 {normalizeRowDefinition, RowData, RowDefinition} from "./Row"; import {RowCells, 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,6 +53,9 @@ 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;
} }
@ -66,236 +69,208 @@ 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 rows data. * Promised row definitions.
* @protected
*/ */
protected promisedRows: Promised<RowDefinition<CK>>[] = []; protected promisedRowsDefinitions: Promised<RowDefinition<CK>>[];
/** /**
* Promised rows cells data. * Promised full rows.
* @protected
*/ */
protected promisedRowsCells: Partial<Record<CK, Promised<CellDefinition>>>[] = []; protected promisedRows: Modify<RowData<CK>, {
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.handleNewRowsDefinitions.bind(this)); this.promisedData = new Promised(this.handleNewData.bind(this));
this.promisedRows = [];
} }
/** /**
* Handle new smartable data. * Handle new Smartable data.
* @param data Smartable data. * @param data Smartable data to handle.
*/ */
handle(data: SmartableData<CK>): void handle(data: SmartableData<CK>)
{ {
this.rowsLoaded = false; // Refresh global promised data.
this.promisedData.refresh(data); this.promisedData.refresh(data);
}
/** console.log(this.dataStateUpdate);
* Handle new rows definitions. // Update state.
* @param rowsDefinitions Rows definitions. this.dataStateUpdate = {
* @protected rows: this.dataStateUpdate?.rows,
*/
protected handleNewRowsDefinitions(rowsDefinitions: Promisable<RowDefinition<CK>>[]): void
{
// Ignore undefined value.
if (rowsDefinitions == undefined) return;
// 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);
}
// Try to update the current data state.
this.tryToUpdate();
}
/**
* Handle a new row.
* @param rowId Row ID.
* @param row Row definition.
* @protected
*/
protected handleNewRow(rowId: number, row: RowDefinition<CK>): void
{
// 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);
}
// Try to update the current data state.
this.tryToUpdate();
}
/**
* 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, cellDefinition: CellDefinition): void
{
// Ignore undefined value.
if (cellDefinition == undefined) return;
// Save cell definition.
this.cellsDefinitions[rowId][columnKey] = cellDefinition;
// Try to update the current data state.
this.tryToUpdate();
}
/**
* 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
{
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 ?? [])
],
}; };
this.update();
}
// Rows have been reinitialized. /**
this.reinitRows = false; * Called when new Smartable data is loaded.
* @param newData New loaded data.
* @protected
*/
protected handleNewData(newData: (Promisable<RowDefinition<CK>>)[]): void
{
// Initialize a new array of updated promised rows.
const updatedPromiseRows: Promised<RowDefinition<CK>>[] = [];
for (const [rowId, newRow] of this.rowsData?.entries()) for (const [rowId, row] of newData.entries())
{ // Update value of each new row. { // For each promisable row, save the promised row in the updated array.
newData.rows[rowId] = { updatedPromiseRows[rowId] = (this.promisedRowsDefinitions?.[rowId] ?? new Promised(this.handleNewRow.bind(this, rowId))).refresh(row);
element: newRow.element, }
cellElement: newRow.cellElement,
cells: newData.rows[rowId]?.cells, // Save new promised rows.
this.promisedRowsDefinitions = updatedPromiseRows;
// Update state.
this.dataStateUpdate = {
rows: this.dataStateUpdate.rows ?? undefined,
};
this.update();
}
/**
* Called when a new row definition is loaded.
* @param rowId Row ID.
* @param newRow New row definition.
* @protected
*/
protected handleNewRow(rowId: number, newRow: 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>,
}; };
} }
for (const [rowId, rowCells] of this.cellsDefinitions?.entries()) // Build the new row cells object, with the promised cells.
{ // Update cells of each changed row. this.promisedRows[rowId] = Object.assign({}, newRow, {
newData.rows[rowId] = { cells: Object.fromEntries(
...newData.rows[rowId], // For each cell, create its promised object from the given promisable.
cells: {...(newData.rows[rowId]?.cells ?? {}), ...rowCells} as Record<CK, CellDefinition>, (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.
* @protected
*/
protected handleNewCell(rowId: number, columnKey: CK, cellData: 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();
}
/**
* Update the current async state.
*/
protected update(): void
{
// Set the new current state.
this.setCurrentDataState(this.currentDataState = this.buildNewState());
}
/**
* Build a new state from the current async state.
* @protected
*/
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>;
} }
} }
// Update the current data state. return newState;
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>)
@ -305,31 +280,24 @@ 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. {
// Save the new promise and set data to undefined.
this.promise = data;
this.data = undefined; this.data = undefined;
this.promise = data;
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 if (data != this.data) else
{ // We already have data, and it is different from the current state. {
this.data = data; this.data = data;
this.onData?.(data); this.onData?.(data);
} }
@ -337,17 +305,11 @@ 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, ...props}: React.PropsWithChildren<React.TdHTMLAttributes<HTMLTableCellElement>>) export function Cell({children}: React.PropsWithChildren<{}>)
{ {
// 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 {...props}>{children ?? String(data)}</td> <td>{children ?? String(data)}</td>
); );
} }
@ -67,11 +67,3 @@ 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

@ -1,17 +0,0 @@
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,7 +1,6 @@
import React, {useCallback, useContext} from "react"; import React, {useCallback, useContext} from "react";
import {Smartable, useTable} from "./Smartable"; import {Smartable, useTable} from "./Smartable";
import {ColumnFilter} from "./Columns/ColumnFilter"; import {Instance} from "./Instance";
import {SortState} from "./Sort";
/** /**
* Basic column key type. * Basic column key type.
@ -27,7 +26,7 @@ export function createColumns<K extends ColumnKey>(...columns: [K, Column][]): C
/** /**
* Smartable column definition. * Smartable column definition.
*/ */
export interface Column<T = any> export interface Column
{ {
/** /**
* Column title element. * Column title element.
@ -40,16 +39,11 @@ export interface Column<T = any>
cellElement?: React.ReactElement; cellElement?: React.ReactElement;
/** /**
* Cells data comparison in the column. * Sorting function for data of 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.
*/ */
compare?: (a: T, b: T) => number; sort?: (a: unknown, b: unknown) => number;
/**
* Column filter definition.
*/
filter?: ColumnFilter;
} }
/** /**
@ -62,6 +56,31 @@ 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.
*/ */
@ -81,17 +100,6 @@ 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);
@ -104,6 +112,45 @@ 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.
*/ */
@ -112,12 +159,6 @@ 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,
@ -125,12 +166,26 @@ 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

@ -1,32 +0,0 @@
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

@ -1,45 +0,0 @@
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

@ -1,69 +0,0 @@
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

@ -1,71 +0,0 @@
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

@ -1,60 +0,0 @@
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,11 +1,9 @@
import React, {useMemo} from "react"; import React from "react";
import {AutoColumnContextProvider, Column, ColumnKey, Columns} from "./Column"; import {AutoColumnContextProvider, Column, ColumnContext, ColumnHeading, ColumnKey, Columns} from "./Column";
import {SmartableProperties, useTable} from "./Smartable"; import {SmartableProperties, useTable} from "./Smartable";
import {RowInstance, RowLoader} from "./Row"; import {Async, Promisable} from "@kernelui/core";
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.
@ -21,60 +19,15 @@ 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>(props: InstanceProperties<CK>) export function Instance<CK extends ColumnKey>({columns}: 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 className={classes("smartable", className)}> <table>
<thead> <thead>
<ColumnsHeadings columns={columns} /> <ColumnsHeadings columns={columns} />
</thead> </thead>
<tbody> <tbody>
{children} <TableBody />
</tbody> </tbody>
</table> </table>
); );
@ -85,86 +38,59 @@ export function Table<CK extends ColumnKey>({className, columns, children}: Reac
*/ */
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"}>
<tr className={classes("headings", disableSort === true ? "disable-sort" : undefined)}> { // Showing title of each column.
{ // Showing title of each column. Object.keys(columns).map((key) => (
Object.keys(columns).map((key) => ( <AutoColumnContextProvider key={key} columnKey={key}>
<AutoColumnContextProvider key={key} columnKey={key}> <ColumnHeading />
<ColumnHeading/> </AutoColumnContextProvider>
</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, columns, columnsSortState, columnsFilterStates} = useTable<CK>(); const {data} = useTable();
// Get current data state from the async table value. // Get async data manager for the current table.
const {currentDataState} = useAsyncManager<CK>(data); const asyncManager = useAsyncManager(data);
// Memorize filtered rows. console.log(
const filteredRows = useMemo(() => ( asyncManager.currentDataState
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 (
sortedRows ? ( asyncManager.currentDataState?.rows?.map((rowData, index) => (
sortedRows.map((rowData, index) => ( // Rendering each row.
// Rendering each row from its definition. <RowInstance key={index} row={rowData} />
<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,9 +1,8 @@
import React, {useContext} from "react"; import React, {useContext, useMemo} from "react";
import {AutoColumnContextProvider, ColumnContext, ColumnKey} from "./Column"; import {ColumnContext, ColumnKey} from "./Column";
import {CellDefinition, CellInstance, CellLoader} from "./Cell"; import {CellDefinition, CellInstance} from "./Cell";
import {Smartable, useTable} from "./Smartable"; import {Smartable, useTable} from "./Smartable";
import { Promisable} from "@kernelui/core"; import {Async, Promisable} from "@kernelui/core";
import {CurrentRowData} from "./AsyncManager";
/** /**
* Smartable row cells. * Smartable row cells.
@ -36,27 +35,10 @@ 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 CurrentRowData<CK, T> export interface RowContextData<CK extends ColumnKey, T = any> extends RowData<CK, T>
{ {
} }
@ -91,14 +73,14 @@ export function RowCells()
const {columns} = useTable(); const {columns} = useTable();
return ( return (
Object.keys(columns).map((columnKey) => ( Object.entries(columns).map(([columnKey, column]) => (
<AutoColumnContextProvider key={columnKey} columnKey={columnKey}> <ColumnContext.Provider key={columnKey} value={{ key: columnKey, column: column }}>
{ // Show current cell. <Async<CellDefinition> promise={row.cells?.[columnKey] ?? { data: undefined }}>
row.cells?.[columnKey] {(cellDefinition) => (
? <CellInstance cell={row.cells?.[columnKey]} /> <CellInstance cell={cellDefinition} />
: <CellLoader /> )}
} </Async>
</AutoColumnContextProvider> </ColumnContext.Provider>
)) ))
); );
} }
@ -106,29 +88,21 @@ export function RowCells()
/** /**
* Row instance component. * Row instance component.
*/ */
export function RowInstance<CK extends ColumnKey>({row}: { row: CurrentRowData<CK> }) export function RowInstance<CK extends ColumnKey>({row}: { row: RowDefinition<CK> })
{ {
// Get table row element. // 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]);
const {rowElement} = useTable(); const {rowElement} = useTable();
return ( return (
<RowContext.Provider value={row}> <RowContext.Provider value={rowContextValue}>
{ // 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.
row.element ?? rowElement ?? <Row /> rowContextValue.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,9 +1,8 @@
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} from "./Column"; import {ColumnKey, Columns, SortState, SortType} 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.
@ -15,11 +14,6 @@ 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.
*/ */
@ -39,26 +33,6 @@ 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;
} }
/** /**
@ -71,11 +45,6 @@ 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.
*/ */
@ -112,7 +81,10 @@ 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 Partial<Record<CK, SortState>>); const [sortState, setSortState] = useState({} as 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 => {
@ -146,33 +118,13 @@ 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,
}), [sortState, setSortState, filterStates, setColumnFilterState, filteredColumns, props]); }), [filteredColumns, sortState, setSortState, props]);
return ( return (
<TableContext.Provider value={contextValue}> <TableContext.Provider value={contextValue}>
@ -197,26 +149,14 @@ export interface TableContextData<CK extends ColumnKey> extends SmartablePropert
/** /**
* Current table columns sort state. * Current table columns sort state.
*/ */
columnsSortState: Partial<Record<CK, SortState>>; columnsSortState: Record<CK, SortState>;
/** /**
* Set given table column sort state. * Set current table columns 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;
} }
/** /**

View file

@ -1,79 +0,0 @@
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));
}
}

0
src/Utils.tsx Normal file
View file

View file

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

View file

@ -1,30 +0,0 @@
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,89 +1,64 @@
tr.headings tr.headings
{ {
&.disable-sort th
{ {
th { pointer-events: none; } position: relative;
}
&:not(.disable-sort) cursor: pointer;
{
th &::before, &::after
{ // 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
{ {
position: relative; right: calc(0.33em - 1px);
cursor: pointer; width: 2px;
height: 0;
border-radius: 2px;
}
&::after
{
right: calc(0.33em - 3px);
&::before, &::after width: 6px;
{ // Sorting order indicator. height: 6px;
transition: height 0.2s ease, background 0.2s ease, top 0.2s ease, bottom 0.2s ease; border-radius: 6px;
}
content: ""; &.asc, &.desc
position: absolute; {
top: 0; &::after, &::before
bottom: 0; {
background: var(--primary);
display: block;
margin: auto;
box-sizing: border-box;
background: var(--background-darkest);
} }
&::before &::before
{ {
right: calc(0.33em - 1px); height: 0.8em;
width: 2px;
height: 0;
border-radius: 2px;
} }
&::after }
{
right: calc(0.33em - 3px);
width: 6px; &.asc::after
height: 6px; {
border-radius: 6px; top: 0.5em;
} }
&.desc::after
&.asc, &.desc {
{ bottom: 0.5em;
&::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;
}
} }
} }
} }

View file

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

View file

@ -1,9 +1 @@
table.smartable @import "_headings";
{
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", "react-dom", "react-router-dom", "@phosphor-icons/react", "@kernelui/core"], external: ["react"],
}, },
}, },

667
yarn.lock

File diff suppressed because it is too large Load diff