import { AxiosResponse } from 'axios'; import { bound } from '@matchlighter/common_library/decorators/bound'; import { hook } from './mobx_internal'; type LoadState = 'unloaded' | 'loading' | 'loaded' | 'error'; export interface SliceResponse { slice_start: number; slice_end: number; total_count: number; items: D[]; } interface LoadOptions { force?: boolean; } function isResponse(v: any): v is AxiosResponse { return !!(v.config && v.status); } export class SlicedArray extends Array { constructor( private fetchSlice: (start: number, end: number) => Promise | AxiosResponse>>, protected options: { mapFetchedData?: (data: D) => T; onSliceLoaded?: (newData: { [abs_index: number]: D }, slice: any) => void; pageSize?: number } = {}, ) { super(); options.pageSize = options.pageSize || 20; options.mapFetchedData = options.mapFetchedData || ((d) => d as any); hook.run(SlicedArray, 'construct', [this]); } //#region State Tracking protected loadingRows = new Set(); get isWorking() { return this.loadingRows.size > 0 } get isFullyLoaded() { return this._getSliceState(0, this.length - 1) == 'loaded'; } isRowLoaded(index: number) { return this[index] !== undefined; } wasRowRequested(index: number) { return this.isRowLoaded(index) || this.loadingRows.has(index); } private _initialLoadStatus: LoadState = 'unloaded'; protected touchInitialStatus(v: LoadState) { if (this._initialLoadStatus == 'loaded') return; this._initialLoadStatus = v; } get initialLoadState(): LoadState { if (this.length > 0) return 'loaded'; return this._initialLoadStatus; } protected _getSliceState(a: number, b: number): LoadState { if (this.initialLoadState != 'loaded') return 'unloaded'; let state: LoadState = 'loaded'; for (let i = a; i < b; i++) { if (state == 'loaded' && !this.isRowLoaded(i)) state = 'loading' if (state == 'loading' && !this.loadingRows.has(i)) state = 'unloaded'; if (state == 'unloaded') break; } return state; } protected _isSliceLoaded(a: number, b: number) { return this._getSliceState(a, b) == 'loaded' } //#endregion //#region Public API offerSlice(slice: SliceResponse) { if (slice.total_count != null) { this.length = slice.total_count; } const newlyLoaded = {}; slice.items.forEach((data, si) => { const i = slice.slice_start + si; if (this[i] !== undefined) return; newlyLoaded[i] = this[i] = this.options.mapFetchedData(data); }); if (this.options.onSliceLoaded) { this.options.onSliceLoaded(newlyLoaded, [slice.slice_start, slice.slice_end]); } this.touchInitialStatus('loaded'); return newlyLoaded; } getSliceState(slice: string): LoadState getSliceState(slice: [number, number]): LoadState getSliceState(slice_start: number, slice_end: number): LoadState getSliceState(a: number | string | number[], b?: number) { if (typeof a == 'string') { [a, b] = a.split(':').map(parseInt); } else if (typeof a == 'object') { [a, b] = a; } return this._getSliceState(a, b); } isSliceLoaded(slice: string): boolean isSliceLoaded(slice: [number, number]): boolean isSliceLoaded(slice_start: number, slice_end: number): boolean isSliceLoaded(a: number | string | number[], b?: number) { if (typeof a == 'string') { [a, b] = a.split(':').map(parseInt); } else if (typeof a == 'object') { [a, b] = a; } return this._isSliceLoaded(a, b); } async ensureSlice(slice: string, opts?: LoadOptions): Promise async ensureSlice(slice: [number, number], opts?: LoadOptions): Promise async ensureSlice(slice_start: number, slice_end: number, opts?: LoadOptions): Promise async ensureSlice(a: number | string | number[], b?: number | LoadOptions, options?: LoadOptions) { if (typeof b != 'number') { options = b; b = null as number; } if (typeof a == 'string') { [a, b] = a.split(':').map(parseInt); } else if (typeof a == 'object') { [a, b] = a; } options = Object.assign({ force: false, } as LoadOptions, options); return await this._ensureSlice(a, b, options); } /** * @param page Zero-Indexed page number * @param page_size */ isPageLoaded(page: number, page_size: number = this.options.pageSize) { const start = page * page_size; return this.isSliceLoaded(start, start + page_size); } /** * @param page Zero-Indexed page number * @param page_size */ async ensurePage(page: number, page_size: number = this.options.pageSize) { const start = page * page_size; return await this.ensureSlice(start, start + page_size); } //#endregion //#region Internal Implementation protected async _ensureSlice(a: number, b: number, options: LoadOptions) { if (!options.force && this.isSliceLoaded(a, b)) return; for (let i = a; i < b; i++) { this.loadingRows.add(i); } try { this.touchInitialStatus('loading'); const response = await this.fetchSlice(a, b); const slice = isResponse(response) ? response.data : response; this.offerSlice(slice); } catch (e) { this.touchInitialStatus('error'); throw e; } finally { for (let i = a; i < b; i++) { this.loadingRows.delete(i); } } } //#endregion //#region Helper Methods for InfiniteLoader @bound ilRowLoaded({ index }) { return this.wasRowRequested(index); } @bound async ilLoadMoreRows({ startIndex, stopIndex }) { return await this.ensureSlice(startIndex, stopIndex + 1); } //#endregion } export class DummySlicedArray extends SlicedArray { isRowLoaded(index: number) { return true; } getInitializationState(): LoadState { return 'loaded' } protected _getSliceState(a: number, b: number): LoadState { return 'loaded'; } protected async _ensureSlice(a: number, b: number, options: LoadOptions) { } }