diff --git a/src/config.ts b/src/config.ts index 71e07a43..9f8dc465 100644 --- a/src/config.ts +++ b/src/config.ts @@ -36,6 +36,8 @@ export interface Config { /** to parse a HTML table and load the data */ from: HTMLElement; storage: Storage; + /** Pipeline process throttle timeout in milliseconds */ + processingThrottleMs: number; pipeline: Pipeline; /** to automatically calculate the columns width */ autoWidth: boolean; @@ -128,6 +130,7 @@ export class Config { tableRef: createRef(), width: '100%', height: 'auto', + processingThrottleMs: 100, autoWidth: true, style: {}, className: {}, diff --git a/src/hooks/useSelector.ts b/src/hooks/useSelector.ts index ed3d7b29..74ade663 100644 --- a/src/hooks/useSelector.ts +++ b/src/hooks/useSelector.ts @@ -15,7 +15,7 @@ export default function useSelector(selector: (state) => T) { }); return unsubscribe; - }, []); + }, [store, current]); return current; } diff --git a/src/pipeline/extractor/storage.ts b/src/pipeline/extractor/storage.ts index 7efbad1c..4c5c529a 100644 --- a/src/pipeline/extractor/storage.ts +++ b/src/pipeline/extractor/storage.ts @@ -10,7 +10,7 @@ interface StorageExtractorProps extends PipelineProcessorProps { } class StorageExtractor extends PipelineProcessor< - Promise, + StorageResponse, StorageExtractorProps > { get type(): ProcessorType { diff --git a/src/pipeline/pipeline.ts b/src/pipeline/pipeline.ts index aa6a6ded..050d2d4e 100644 --- a/src/pipeline/pipeline.ts +++ b/src/pipeline/pipeline.ts @@ -3,13 +3,13 @@ import { ID } from '../util/id'; import log from '../util/log'; import { EventEmitter } from '../util/eventEmitter'; -interface PipelineEvents { +interface PipelineEvents { /** * Generic updated event. Triggers the callback function when the pipeline * is updated, including when a new processor is registered, a processor's props * get updated, etc. */ - updated: (processor: PipelineProcessor) => void; + updated: (processor: PipelineProcessor) => void; /** * Triggers the callback function when a new * processor is registered successfully @@ -27,27 +27,29 @@ interface PipelineEvents { * afterProcess will not be called if there is an * error in the pipeline (i.e a step throw an Error) */ - afterProcess: (prev: T) => void; + afterProcess: (prev: R) => void; /** * Triggers the callback function when the pipeline * fails to process all steps or at least one step * throws an Error */ - error: (prev: T) => void; + error: (prev: T) => void; } -class Pipeline extends EventEmitter> { +class Pipeline extends EventEmitter> { // available steps for this pipeline - private readonly _steps: Map[]> = - new Map[]>(); + private readonly _steps: Map< + ProcessorType, + PipelineProcessor[] + > = new Map[]>(); // used to cache the results of processors using their id field - private cache: Map = new Map(); + private cache: Map = new Map(); // keeps the index of the last updated processor in the registered // processors list and will be used to invalidate the cache // -1 means all new processors should be processed private lastProcessorIndexUpdated = -1; - constructor(steps?: PipelineProcessor[]) { + constructor(steps?: PipelineProcessor[]) { super(); if (steps) { @@ -59,7 +61,7 @@ class Pipeline extends EventEmitter> { * Clears the `cache` array */ clearCache(): void { - this.cache = new Map(); + this.cache = new Map(); this.lastProcessorIndexUpdated = -1; } @@ -69,21 +71,47 @@ class Pipeline extends EventEmitter> { * @param processor * @param priority */ - register( - processor: PipelineProcessor, + register( + processor: PipelineProcessor, priority: number = null, - ): void { - if (!processor) return; + ): PipelineProcessor { + if (!processor) { + throw Error('Processor is not defined'); + } if (processor.type === null) { throw Error('Processor type is not defined'); } + if (this.findProcessorIndexByID(processor.id) > -1) { + throw Error(`Processor ID ${processor.id} is already defined`); + } + // binding the propsUpdated callback to the Pipeline processor.on('propsUpdated', this.processorPropsUpdated.bind(this)); this.addProcessorByPriority(processor, priority); this.afterRegistered(processor); + + return processor; + } + + /** + * Tries to register a new processor + * @param processor + * @param priority + */ + tryRegister( + processor: PipelineProcessor, + priority: number = null, + ): PipelineProcessor | undefined { + try { + return this.register(processor, priority); + } catch (_) { + // noop + } + + return undefined; } /** @@ -91,8 +119,9 @@ class Pipeline extends EventEmitter> { * * @param processor */ - unregister(processor: PipelineProcessor): void { + unregister(processor: PipelineProcessor): void { if (!processor) return; + if (this.findProcessorIndexByID(processor.id) === -1) return; const subSteps = this._steps.get(processor.type); @@ -111,7 +140,7 @@ class Pipeline extends EventEmitter> { * @param processor * @param priority */ - private addProcessorByPriority( + private addProcessorByPriority( processor: PipelineProcessor, priority: number, ): void { @@ -142,8 +171,8 @@ class Pipeline extends EventEmitter> { /** * Flattens the _steps Map and returns a list of steps with their correct priorities */ - get steps(): PipelineProcessor[] { - let steps: PipelineProcessor[] = []; + get steps(): PipelineProcessor[] { + let steps: PipelineProcessor[] = []; for (const type of this.getSortedProcessorTypes()) { const subSteps = this._steps.get(type); @@ -163,7 +192,7 @@ class Pipeline extends EventEmitter> { * * @param type */ - getStepsByType(type: ProcessorType): PipelineProcessor[] { + getStepsByType(type: ProcessorType): PipelineProcessor[] { return this.steps.filter((process) => process.type === type); } @@ -182,7 +211,7 @@ class Pipeline extends EventEmitter> { * * @param data */ - async process(data?: T): Promise { + async process(data?: R): Promise { const lastProcessorIndexUpdated = this.lastProcessorIndexUpdated; const steps = this.steps; @@ -197,11 +226,11 @@ class Pipeline extends EventEmitter> { // updated processor was before "processor". // This is to ensure that we always have correct and up to date // data from processors and also to skip them when necessary - prev = await processor.process(prev); + prev = (await processor.process(prev)) as R; this.cache.set(processor.id, prev); } else { // cached results already exist - prev = this.cache.get(processor.id); + prev = this.cache.get(processor.id) as R; } } } catch (e) { @@ -236,7 +265,9 @@ class Pipeline extends EventEmitter> { * This is used to invalid or skip a processor in * the process() method */ - private setLastProcessorIndex(processor: PipelineProcessor): void { + private setLastProcessorIndex( + processor: PipelineProcessor, + ): void { const processorIndex = this.findProcessorIndexByID(processor.id); if (this.lastProcessorIndexUpdated > processorIndex) { diff --git a/src/pipeline/processor.ts b/src/pipeline/processor.ts index b3f2d2f7..19291c93 100644 --- a/src/pipeline/processor.ts +++ b/src/pipeline/processor.ts @@ -2,6 +2,7 @@ // e.g. Extractor = 0 will be processed before Transformer = 1 import { generateUUID, ID } from '../util/id'; import { EventEmitter } from '../util/eventEmitter'; +import { deepEqual } from '../util/deepEqual'; export enum ProcessorType { Initiator, @@ -15,8 +16,8 @@ export enum ProcessorType { Limit, } -interface PipelineProcessorEvents { - propsUpdated: (processor: PipelineProcessor) => void; +interface PipelineProcessorEvents { + propsUpdated: (processor: PipelineProcessor) => void; beforeProcess: (...args) => void; afterProcess: (...args) => void; } @@ -27,9 +28,9 @@ export interface PipelineProcessorProps {} export abstract class PipelineProcessor< T, P extends Partial, -> extends EventEmitter> { +> extends EventEmitter { public readonly id: ID; - private readonly _props: P; + private _props: P; abstract get type(): ProcessorType; protected abstract _process(...args): T | Promise; @@ -62,8 +63,16 @@ export abstract class PipelineProcessor< } setProps(props: Partial

): this { - Object.assign(this._props, props); - this.emit('propsUpdated', this); + const updatedProps = { + ...this._props, + ...props, + }; + + if (!deepEqual(updatedProps, this._props)) { + this._props = updatedProps; + this.emit('propsUpdated', this); + } + return this; } diff --git a/src/util/deepEqual.ts b/src/util/deepEqual.ts new file mode 100644 index 00000000..70a9be25 --- /dev/null +++ b/src/util/deepEqual.ts @@ -0,0 +1,9 @@ +/** + * Returns true if both objects are equal + * @param a left object + * @param b right object + * @returns + */ +export function deepEqual(a: A, b: B) { + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/src/util/throttle.ts b/src/util/throttle.ts index 7a677609..aae8e113 100644 --- a/src/util/throttle.ts +++ b/src/util/throttle.ts @@ -1,21 +1,35 @@ +/** + * Throttle a given function + * @param fn Function to be called + * @param wait Throttle timeout in milliseconds + * @returns Throttled function + */ export const throttle = (fn: (...args) => void, wait = 100) => { - let inThrottle: boolean; - let lastFn: ReturnType; - let lastTime: number; + let timeoutId: ReturnType; + let lastTime = Date.now(); + + const execute = (...args) => { + lastTime = Date.now(); + fn(...args); + }; return (...args) => { - if (!inThrottle) { - fn(...args); - lastTime = Date.now(); - inThrottle = true; + const currentTime = Date.now(); + const elapsed = currentTime - lastTime; + + if (elapsed >= wait) { + // If enough time has passed since the last call, execute the function immediately + execute(args); } else { - clearTimeout(lastFn); - lastFn = setTimeout(() => { - if (Date.now() - lastTime >= wait) { - fn(...args); - lastTime = Date.now(); - } - }, Math.max(wait - (Date.now() - lastTime), 0)); + // If not enough time has passed, schedule the function call after the remaining delay + if (timeoutId) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + execute(args); + timeoutId = null; + }, wait - elapsed); } }; }; diff --git a/src/view/container.tsx b/src/view/container.tsx index 92b6dd58..40a5ef98 100644 --- a/src/view/container.tsx +++ b/src/view/container.tsx @@ -8,8 +8,9 @@ import log from '../util/log'; import { useEffect } from 'preact/hooks'; import * as actions from './actions'; import { useStore } from '../hooks/useStore'; -import useSelector from '../../src/hooks/useSelector'; -import { useConfig } from '../../src/hooks/useConfig'; +import useSelector from '../hooks/useSelector'; +import { useConfig } from '../hooks/useConfig'; +import { throttle } from '../util/throttle'; export function Container() { const config = useConfig(); @@ -19,6 +20,23 @@ export function Container() { const tableRef = useSelector((state) => state.tableRef); const tempRef = createRef(); + const processPipeline = throttle(async () => { + dispatch(actions.SetLoadingData()); + + try { + const data = await config.pipeline.process(); + dispatch(actions.SetData(data)); + + // TODO: do we need this setTimemout? + setTimeout(() => { + dispatch(actions.SetStatusToRendered()); + }, 0); + } catch (e) { + log.error(e); + dispatch(actions.SetDataErrored()); + } + }, config.processingThrottleMs); + useEffect(() => { // set the initial header object // we update the header width later when "data" @@ -41,23 +59,6 @@ export function Container() { } }, [data, config, tempRef]); - const processPipeline = async () => { - dispatch(actions.SetLoadingData()); - - try { - const data = await config.pipeline.process(); - dispatch(actions.SetData(data)); - - // TODO: do we need this setTimemout? - setTimeout(() => { - dispatch(actions.SetStatusToRendered()); - }, 0); - } catch (e) { - log.error(e); - dispatch(actions.SetDataErrored()); - } - }; - return (

{ - config.pipeline.unregister(processor.current); + config.pipeline.unregister(processor.current); config.pipeline.off('updated', onUpdate); }; }, []); diff --git a/src/view/plugin/search/search.tsx b/src/view/plugin/search/search.tsx index 89593af2..ab7a5984 100644 --- a/src/view/plugin/search/search.tsx +++ b/src/view/plugin/search/search.tsx @@ -67,9 +67,11 @@ export function Search() { }, [props]); useEffect(() => { - config.pipeline.register(processor); + if (!processor) return undefined; - return () => config.pipeline.unregister(processor); + config.pipeline.register(processor); + + return () => config.pipeline.unregister(processor); }, [config, processor]); const debouncedOnInput = useCallback( diff --git a/src/view/plugin/sort/actions.ts b/src/view/plugin/sort/actions.ts index 722382f9..e642c0b7 100644 --- a/src/view/plugin/sort/actions.ts +++ b/src/view/plugin/sort/actions.ts @@ -8,7 +8,7 @@ export const SortColumn = compare?: Comparator, ) => (state) => { - let columns = state.sort ? [...state.sort.columns] : []; + let columns = state.sort ? structuredClone(state.sort.columns) : []; const count = columns.length; const column = columns.find((x) => x.index === index); const exists = column !== undefined; @@ -86,7 +86,7 @@ export const SortColumn = export const SortToggle = (index: number, multi: boolean, compare?: Comparator) => (state) => { - const columns = state.sort ? [...state.sort.columns] : []; + const columns = state.sort ? structuredClone(state.sort.columns) : []; const column = columns.find((x) => x.index === index); if (!column) { diff --git a/src/view/plugin/sort/sort.tsx b/src/view/plugin/sort/sort.tsx index 5b19779c..3dd1e6d6 100644 --- a/src/view/plugin/sort/sort.tsx +++ b/src/view/plugin/sort/sort.tsx @@ -1,7 +1,7 @@ import { h, JSX } from 'preact'; import { classJoin, className } from '../../../util/className'; -import { ProcessorType } from '../../../pipeline/processor'; +import { PipelineProcessor, ProcessorType } from '../../../pipeline/processor'; import NativeSort from '../../../pipeline/sort/native'; import { Comparator, TCell, TColumnSort } from '../../../types'; import * as actions from './actions'; @@ -38,25 +38,52 @@ export function Sort( } & SortConfig, ) { const config = useConfig(); + const { dispatch } = useStore(); const _ = useTranslator(); const [direction, setDirection] = useState(0); - const [processor, setProcessor] = useState( - undefined, - ); - const state = useSelector((state) => state.sort); - const { dispatch } = useStore(); const sortConfig = config.sort as GenericSortConfig; + const state = useSelector((state) => state.sort); + const processorType = + typeof sortConfig?.server === 'object' + ? ProcessorType.ServerSort + : ProcessorType.Sort; - useEffect(() => { - const processor = getOrCreateSortProcessor(); - if (processor) setProcessor(processor); - }, []); + const getSortProcessor = () => { + const processors = config.pipeline.getStepsByType(processorType); + if (processors.length) { + return processors[0]; + } + return undefined; + }; + + const createSortProcessor = () => { + if (processorType === ProcessorType.ServerSort) { + return new ServerSort({ + columns: state ? state.columns : [], + ...sortConfig.server, + }); + } + + return new NativeSort({ + columns: state ? state.columns : [], + }); + }; + + const getOrCreateSortProcessor = (): PipelineProcessor => { + const existingSortProcessor = getSortProcessor(); + if (existingSortProcessor) { + return existingSortProcessor; + } + + return createSortProcessor(); + }; useEffect(() => { - config.pipeline.register(processor); + const processor = getOrCreateSortProcessor(); + config.pipeline.tryRegister(processor); return () => config.pipeline.unregister(processor); - }, [config, processor]); + }, [config]); /** * Sets the internal state of component @@ -74,6 +101,8 @@ export function Sort( }, [state]); useEffect(() => { + const processor = getSortProcessor(); + if (!processor) return; if (!state) return; @@ -82,36 +111,6 @@ export function Sort( }); }, [state]); - const getOrCreateSortProcessor = (): NativeSort | null => { - let processorType = ProcessorType.Sort; - - if (sortConfig && typeof sortConfig.server === 'object') { - processorType = ProcessorType.ServerSort; - } - - const processors = config.pipeline.getStepsByType(processorType); - - if (processors.length === 0) { - // my assumption is that we only have ONE sorting processor in the - // entire pipeline and that's why I'm displaying a warning here - let processor; - - if (processorType === ProcessorType.ServerSort) { - processor = new ServerSort({ - columns: state ? state.columns : [], - ...sortConfig.server, - }); - } else { - processor = new NativeSort({ - columns: state ? state.columns : [], - }); - } - return processor; - } - - return null; - }; - const changeDirection = (e: JSX.TargetedMouseEvent) => { e.preventDefault(); e.stopPropagation(); @@ -125,7 +124,7 @@ export function Sort( ); }; - const getSortClassName = (direction) => { + const getSortClassName = (direction: number) => { if (direction === 1) { return 'asc'; } else if (direction === -1) { diff --git a/tests/jest/grid.test.ts b/tests/jest/grid.test.ts index 440b0a10..bc3d542d 100644 --- a/tests/jest/grid.test.ts +++ b/tests/jest/grid.test.ts @@ -12,6 +12,7 @@ describe('Grid class', () => { it('should trigger the events in the correct order', async () => { const grid = new Grid({ + processingThrottleMs: 0, columns: ['a', 'b', 'c'], data: [[1, 2, 3]], }); diff --git a/tests/jest/util/throttle.test.ts b/tests/jest/util/throttle.test.ts new file mode 100644 index 00000000..4e6ed5ea --- /dev/null +++ b/tests/jest/util/throttle.test.ts @@ -0,0 +1,69 @@ +import { throttle } from '../../../src/util/throttle'; + +const sleep = (wait: number) => new Promise((r) => setTimeout(r, wait)); + +describe('throttle', () => { + it('should throttle calls', async () => { + const wait = 100; + const fn = jest.fn(); + const throttled = throttle(fn, wait); + + throttled('a'); + sleep(wait - 5); + throttled('b'); + sleep(wait - 10); + throttled('c'); + + await sleep(wait); + + expect(fn).toBeCalledTimes(1); + expect(fn).toBeCalledWith(['c']); + }); + + it('should execute the first call', async () => { + const wait = 100; + const fn = jest.fn(); + const throttled = throttle(fn, wait); + + throttled(); + + await sleep(wait); + + expect(fn).toBeCalledTimes(1); + }); + + it('should call at trailing edge of the timeout', async () => { + const wait = 100; + const fn = jest.fn(); + const throttled = throttle(fn, wait); + + throttled(); + + expect(fn).toBeCalledTimes(0); + + await sleep(wait); + + expect(fn).toBeCalledTimes(1); + }); + + it('should call after the timer', async () => { + const wait = 100; + const fn = jest.fn(); + const throttled = throttle(fn, wait); + + throttled(); + await sleep(wait); + + expect(fn).toBeCalledTimes(1); + + throttled(); + await sleep(wait); + + expect(fn).toBeCalledTimes(2); + + throttled(); + await sleep(wait); + + expect(fn).toBeCalledTimes(3); + }); +}); diff --git a/tests/jest/view/container.test.tsx b/tests/jest/view/container.test.tsx index fae66f47..43b788e5 100644 --- a/tests/jest/view/container.test.tsx +++ b/tests/jest/view/container.test.tsx @@ -20,6 +20,7 @@ describe('Container component', () => { beforeEach(() => { config = new Config().update({ + processingThrottleMs: 0, data: [ [1, 2, 3], ['a', 'b', 'c'], @@ -244,6 +245,7 @@ describe('Container component', () => { it('should render a container with array of objects without columns input', async () => { const config = Config.fromPartialConfig({ + processingThrottleMs: 0, data: [ [1, 2, 3], ['a', 'b', 'c'], @@ -263,6 +265,7 @@ describe('Container component', () => { it('should render a container with array of objects with string columns', async () => { const config = Config.fromPartialConfig({ + processingThrottleMs: 0, columns: ['Name', 'Phone Number'], data: [ { name: 'boo', phoneNumber: '123' }, @@ -285,6 +288,7 @@ describe('Container component', () => { it('should render a container with array of objects with object columns', async () => { const config = Config.fromPartialConfig({ + processingThrottleMs: 0, columns: [ { name: 'Name', @@ -341,6 +345,7 @@ describe('Container component', () => { it('should unregister the processors', async () => { const config = new Config().update({ + processingThrottleMs: 0, pagination: true, search: true, sort: true,