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.
This commit is contained in:
Madeorsk 2024-07-27 11:49:55 +02:00
parent 792624fa73
commit 5ac1e4e7b8
Signed by: Madeorsk
SSH key fingerprint: SHA256:J9G0ofIOLKf7kyS2IfrMqtMaPdfsk1W02+oGueZzDDU
4 changed files with 397 additions and 38 deletions

View file

@ -0,0 +1,333 @@
import React, {startTransition, useEffect, useRef, useState} from "react";
import {ColumnKey} from "./Column";
import {normalizeRowDefinition, RowCells, RowData, RowDefinition} from "./Row";
import {CellDefinition} from "./Cell";
import {SmartableData} from "./Smartable";
import {Modify, Promisable} from "@kernelui/core";
/**
* Current Smartable data state.
*/
export interface CurrentTableData<CK extends ColumnKey>
{
/**
* Current rows state, undefined if they are still loading.
*/
rows?: CurrentRowData<CK>[];
}
/**
* Smartable current row cells data state.
*/
export type CurrentRowData<CK extends ColumnKey, T = any> = Modify<RowData<CK>, {
cells: Record<CK, CellDefinition<T>>;
}>;
/**
* Get current async table data manager.
*/
export function useAsyncManager<CK extends ColumnKey>(data: SmartableData<CK>): AsyncManager<CK>
{
// Get the main instance of async manager.
const asyncManager = useRef<AsyncManager<CK>>();
// Get the main async manager state.
const [currentDataState, setCurrentDataState] = useState<CurrentTableData<CK>>({
rows: undefined,
});
if (!asyncManager.current)
{
// Initialize a new async manager if there is none.
asyncManager.current = new AsyncManager<CK>();
}
// Update current data state and its dispatcher.
asyncManager.current.currentDataState = currentDataState;
asyncManager.current.setCurrentDataState = setCurrentDataState;
// When defined table data change, process async state again.
useEffect(() => {
// Process new data.
asyncManager.current.handle(data);
}, [asyncManager, data]);
// Return the main instance of async manager.
return asyncManager.current;
}
/**
* Smartable async data manager.
*/
class AsyncManager<CK extends ColumnKey>
{
currentDataState: CurrentTableData<CK>;
setCurrentDataState: React.Dispatch<React.SetStateAction<CurrentTableData<CK>>>;
/**
* Main promised data.
* @protected
*/
protected promisedData: Promised<SmartableData<CK>>;
/**
* Promised rows data.
* @protected
*/
protected promisedRows: Promised<RowDefinition<CK>>[] = [];
/**
* Promised rows cells data.
* @protected
*/
protected promisedRowsCells: Partial<Record<CK, Promised<CellDefinition>>>[] = [];
/**
* Rows data.
* @protected
*/
protected rowsData: RowData<CK>[] = [];
/**
* Rows cells definitions.
* @protected
*/
protected cellsDefinitions: Partial<Record<CK, CellDefinition<CK>>>[] = [];
constructor()
{
// Initialize promised data object.
this.promisedData = new Promised(this.handleNewRowsDefinitions.bind(this));
}
/**
* Handle new smartable data.
* @param data Smartable data.
*/
handle(data: SmartableData<CK>): void
{
this.promisedData.refresh(data);
}
/**
* Handle new rows definitions.
* @param rowsDefinitions Rows definitions.
* @protected
*/
protected handleNewRowsDefinitions(rowsDefinitions: Promisable<RowDefinition<CK>>[]): void
{
// Ignore undefined value.
if (rowsDefinitions == undefined) return;
// Initialize rows data and cells definitions.
this.rowsData = [];
this.cellsDefinitions = [];
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.rowsData.length > 0 && this.cellsDefinitions.some((rowCells) => (
Object.keys(rowCells).length > 0
))
)) // Nothing has changed.
return;
// Initialize new data.
const newData = {
rows: [
...(this.currentDataState?.rows ?? [])
],
};
for (const [rowId, newRow] of this.rowsData.entries())
{ // Update value of each new row.
newData.rows[rowId] = {
element: newRow.element,
cellElement: newRow.cellElement,
cells: newData.rows[rowId]?.cells,
};
}
for (const [rowId, rowCells] of this.cellsDefinitions.entries())
{ // Update cells of each changed row.
newData.rows[rowId] = {
...newData.rows[rowId],
cells: {...(newData.rows[rowId]?.cells ?? {}), ...rowCells} as Record<CK, CellDefinition>,
}
}
// Update the current data state.
this.currentDataState = newData;
this.setCurrentDataState(
newData
);
}
}
/**
* Promised data class.
*/
export class Promised<T>
{
/**
* The main data promise.
*/
promise?: Promise<T>;
/**
* Data retrieved from promise or given in parameter.
*/
data?: T;
/**
* Called when data is changed.
*/
onData?: (data: T) => void;
constructor(onChanged?: (data: T) => void, data?: Promisable<T>)
{
this.onData = onChanged;
if (data)
this.refresh(data);
}
/**
* Refresh the promised data.
* @param data Promised data.
*/
refresh(data: Promisable<T>): this
{
if (data instanceof Promise)
{ // We have a promise of data.
if (data != this.promise)
{ // The promise is different from the saved one.
// Save the new promise and set data to undefined.
this.promise = data;
this.data = undefined;
this.onData?.(undefined);
// Wait for promise to resolve to get actual data.
this.promise.then((data) => {
// Data is retrieved, saving it.
this.data = data;
this.onData?.(data);
});
}
}
else if (data != this.data)
{ // We already have data, and it is different from the current state.
this.data = data;
this.onData?.(data);
}
return this;
}
/**
* Return true if some data (or its promise) have been provided.
*/
isInitialized(): boolean
{
return !!this.data || !!this.promise;
}
/**
* Return true if we are waiting for a promise result.
*/
isLoading(): boolean
{
return this.data === undefined && !!this.promise;
}
}

View file

@ -2,6 +2,7 @@ import React, {useContext} from "react";
import {ColumnKey, useColumn} from "./Column"; import {ColumnKey, useColumn} from "./Column";
import {Smartable} from "./Smartable"; import {Smartable} from "./Smartable";
import {useRow} from "./Row"; import {useRow} from "./Row";
import {SpinningLoader} from "@kernelui/core";
/** /**
* Smartable cell definition. * Smartable cell definition.
@ -67,3 +68,11 @@ export function CellInstance<T>({cell}: {cell: CellDefinition<T>})
</CellContext.Provider> </CellContext.Provider>
) )
} }
/**
* Animated cell loader.
*/
export function CellLoader()
{
return <td className={"loading"}><SpinningLoader /></td>;
}

View file

@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import {AutoColumnContextProvider, Column, ColumnContext, ColumnHeading, ColumnKey, Columns} from "./Column"; import {AutoColumnContextProvider, ColumnHeading, 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";
/** /**
* Smartable instance component properties. * Smartable instance component properties.
@ -55,23 +55,17 @@ export function TableBody<CK extends ColumnKey>()
// Get data from table. // Get data from table.
const {data} = useTable(); const {data} = useTable();
// Get current data state from the async table value.
const {currentDataState} = useAsyncManager(data);
return ( return (
<Async<(Promisable<RowDefinition<CK>>)[]> promise={data}> Array.isArray(currentDataState?.rows) ? (
{(rowsData) => ( currentDataState.rows.map((rowData, index) => (
// Rendering defined rows. // Rendering each row from its definition.
<> <RowInstance row={rowData} />
{ // Rendering each row. ))
rowsData.map((rowData, index) => ( ) : (
// Rendering current row from its definition. <RowLoader />
<Async<RowDefinition<CK>> key={index} promise={rowData}> )
{(rowDefinition) => (
<RowInstance row={rowDefinition} />
)}
</Async>
))
}
</>
)}
</Async>
); );
} }

View file

@ -1,8 +1,9 @@
import React, {useContext, useMemo} from "react"; import React, {useContext} from "react";
import {ColumnContext, ColumnKey} from "./Column"; import {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>
{ {
} }
@ -75,11 +93,11 @@ export function RowCells()
return ( return (
Object.entries(columns).map(([columnKey, column]) => ( Object.entries(columns).map(([columnKey, column]) => (
<ColumnContext.Provider key={columnKey} value={{ key: columnKey, column: column }}> <ColumnContext.Provider key={columnKey} value={{ key: columnKey, column: column }}>
<Async<CellDefinition> promise={row.cells?.[columnKey] ?? { data: undefined }}> {
{(cellDefinition) => ( row.cells?.[columnKey]
<CellInstance cell={cellDefinition} /> ? <CellInstance cell={row.cells?.[columnKey]} />
)} : <CellLoader />
</Async> }
</ColumnContext.Provider> </ColumnContext.Provider>
)) ))
); );
@ -88,21 +106,26 @@ 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()
{
return (
<tr className={"generic loader"}></tr>
)
}