From 5ac1e4e7b87716d9e63ab4eaadd7ffaafd18ed7e Mon Sep 17 00:00:00 2001 From: Madeorsk Date: Sat, 27 Jul 2024 11:49:55 +0200 Subject: [PATCH] Normalize async content handling with AsyncManager. + Add AsyncManager which manages asynchronous table content for sorting and rendering. --- src/Smartable/AsyncManager.tsx | 333 +++++++++++++++++++++++++++++++++ src/Smartable/Cell.tsx | 9 + src/Smartable/Instance.tsx | 34 ++-- src/Smartable/Row.tsx | 59 ++++-- 4 files changed, 397 insertions(+), 38 deletions(-) create mode 100644 src/Smartable/AsyncManager.tsx diff --git a/src/Smartable/AsyncManager.tsx b/src/Smartable/AsyncManager.tsx new file mode 100644 index 0000000..d6b3311 --- /dev/null +++ b/src/Smartable/AsyncManager.tsx @@ -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 +{ + /** + * Current rows state, undefined if they are still loading. + */ + rows?: CurrentRowData[]; +} + +/** + * Smartable current row cells data state. + */ +export type CurrentRowData = Modify, { + cells: Record>; +}>; + + +/** + * Get current async table data manager. + */ +export function useAsyncManager(data: SmartableData): AsyncManager +{ + // Get the main instance of async manager. + const asyncManager = useRef>(); + + // Get the main async manager state. + const [currentDataState, setCurrentDataState] = useState>({ + rows: undefined, + }); + + if (!asyncManager.current) + { + // Initialize a new async manager if there is none. + asyncManager.current = new AsyncManager(); + } + + // 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 +{ + currentDataState: CurrentTableData; + setCurrentDataState: React.Dispatch>>; + + /** + * Main promised data. + * @protected + */ + protected promisedData: Promised>; + + /** + * Promised rows data. + * @protected + */ + protected promisedRows: Promised>[] = []; + + /** + * Promised rows cells data. + * @protected + */ + protected promisedRowsCells: Partial>>[] = []; + + + /** + * Rows data. + * @protected + */ + protected rowsData: RowData[] = []; + + /** + * Rows cells definitions. + * @protected + */ + protected cellsDefinitions: Partial>>[] = []; + + constructor() + { + // Initialize promised data object. + this.promisedData = new Promised(this.handleNewRowsDefinitions.bind(this)); + } + + /** + * Handle new smartable data. + * @param data Smartable data. + */ + handle(data: SmartableData): void + { + this.promisedData.refresh(data); + } + + /** + * Handle new rows definitions. + * @param rowsDefinitions Rows definitions. + * @protected + */ + protected handleNewRowsDefinitions(rowsDefinitions: Promisable>[]): 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): 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, + } + } + + // Update the current data state. + this.currentDataState = newData; + this.setCurrentDataState( + newData + ); + } +} + +/** + * Promised data class. + */ +export class Promised +{ + /** + * The main data promise. + */ + promise?: Promise; + + /** + * 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) + { + this.onData = onChanged; + if (data) + this.refresh(data); + } + + /** + * Refresh the promised data. + * @param data Promised data. + */ + refresh(data: Promisable): 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; + } +} diff --git a/src/Smartable/Cell.tsx b/src/Smartable/Cell.tsx index acf1fec..fbd00ed 100644 --- a/src/Smartable/Cell.tsx +++ b/src/Smartable/Cell.tsx @@ -2,6 +2,7 @@ import React, {useContext} from "react"; import {ColumnKey, useColumn} from "./Column"; import {Smartable} from "./Smartable"; import {useRow} from "./Row"; +import {SpinningLoader} from "@kernelui/core"; /** * Smartable cell definition. @@ -67,3 +68,11 @@ export function CellInstance({cell}: {cell: CellDefinition}) ) } + +/** + * Animated cell loader. + */ +export function CellLoader() +{ + return ; +} diff --git a/src/Smartable/Instance.tsx b/src/Smartable/Instance.tsx index 0a6dbb3..1562454 100644 --- a/src/Smartable/Instance.tsx +++ b/src/Smartable/Instance.tsx @@ -1,8 +1,8 @@ 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 {Async, Promisable} from "@kernelui/core"; -import {RowDefinition, RowInstance} from "./Row"; +import {RowInstance, RowLoader} from "./Row"; +import {useAsyncManager} from "./AsyncManager"; /** * Smartable instance component properties. @@ -55,23 +55,17 @@ export function TableBody() // Get data from table. const {data} = useTable(); + // Get current data state from the async table value. + const {currentDataState} = useAsyncManager(data); + return ( - >)[]> promise={data}> - {(rowsData) => ( - // Rendering defined rows. - <> - { // Rendering each row. - rowsData.map((rowData, index) => ( - // Rendering current row from its definition. - > key={index} promise={rowData}> - {(rowDefinition) => ( - - )} - - )) - } - - )} - + Array.isArray(currentDataState?.rows) ? ( + currentDataState.rows.map((rowData, index) => ( + // Rendering each row from its definition. + + )) + ) : ( + + ) ); } diff --git a/src/Smartable/Row.tsx b/src/Smartable/Row.tsx index 7310e1c..eb15871 100644 --- a/src/Smartable/Row.tsx +++ b/src/Smartable/Row.tsx @@ -1,8 +1,9 @@ -import React, {useContext, useMemo} from "react"; +import React, {useContext} from "react"; import {ColumnContext, ColumnKey} from "./Column"; -import {CellDefinition, CellInstance} from "./Cell"; +import {CellDefinition, CellInstance, CellLoader} from "./Cell"; import {Smartable, useTable} from "./Smartable"; -import {Async, Promisable} from "@kernelui/core"; +import { Promisable} from "@kernelui/core"; +import {CurrentRowData} from "./AsyncManager"; /** * Smartable row cells. @@ -35,10 +36,27 @@ export interface RowData */ export type RowDefinition = RowCells|RowData; +/** + * Normalize row definition to row data. + */ +export function normalizeRowDefinition(rowDefinition: RowDefinition): RowData +{ + if (!("cells" in rowDefinition) || !Object.values(rowDefinition?.cells).some((cellData: Promisable) => ( + 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, + }; + } + + // Return changed row definition, or just keep the default one if it matched RowData. + return rowDefinition; +} + /** * Table row context data. */ -export interface RowContextData extends RowData +export interface RowContextData extends CurrentRowData { } @@ -75,11 +93,11 @@ export function RowCells() return ( Object.entries(columns).map(([columnKey, column]) => ( - promise={row.cells?.[columnKey] ?? { data: undefined }}> - {(cellDefinition) => ( - - )} - + { + row.cells?.[columnKey] + ? + : + } )) ); @@ -88,21 +106,26 @@ export function RowCells() /** * Row instance component. */ -export function RowInstance({row}: { row: RowDefinition }) +export function RowInstance({row}: { row: CurrentRowData }) { - // Get row context value from given row definition. - const rowContextValue = useMemo(() => ( - // If a simple RowCells object is given, converting it to a RowData - !("cells" in row) ? { cells: row } : row - ), [row]); - + // Get table row element. const {rowElement} = useTable(); return ( - + { // Trying to render row-specific element, then table-specific element, then default element. - rowContextValue.element ?? rowElement ?? + row.element ?? rowElement ?? } ); } + +/** + * Animated row loader. + */ +export function RowLoader() +{ + return ( + + ) +}