import deepEqual from 'deep-eql'; import { action, computed, observable, runInAction, transaction, untracked } from "mobx"; import { ConcurrentTransactor } from "../async/concurrent_transactor"; import { EventEmitter } from "../EventEmitter"; export type LoadState = 'unloaded' | 'loading' | 'loaded' | 'error'; export interface DataPage { page: number; page_size: number; page_count: number; total_count: number; unfiltered_count?: number; sort?: string; items: T[]; } export interface DataFetcherOptions { /** Debounce time or `false`. Default 250ms */ debounce?: number | false; /** * Maximum amount of time to spend debouncing before issuing a request. * Requires `incremental: true` */ max_debounce?: number | false; /** * If set, this fetcher will not be allowed to trigger more than once within the specified number of milliseconds. */ throttle?: false | number; /** * Initial value of the fetcher. * If not `null`, the fetcher will begin in `loaded` state and not trigger a fetch until parameters change or explicitly requested. */ initial?: D; /** * Parameters to assume upon initialization. */ initialParams?: any; /** * When true, even activated fetchers won't call `fetch` until something accesses `.data` */ lazy?: boolean; /** * Determines when the DataFetcher "goes active". Until it is active, it will not call the given `fetch` function. * - true: Activate the DataFetcher immediately * - false: Will not activate by itself; you must call .activate() * - 'queried': Will activate the first time something attempts to read `.data` * - 'requested': (Default) Will activate when explicitly requested, or when a parameter is changed with `.updateParam[s]()` */ activate?: boolean | 'queried' | 'requested'; // onParamsChanged?: (newParams: any) => void; /** * If a prior fetch returns after another one is triggered (but not resolved), should its result be kept (true) or discarded (false)? * The default is based on `max_debounce`. */ incremental?: boolean; } type ROAnyObject = { readonly [k: string]: any } interface TransactionResult { value?: T; req_params?: any; error?: any; } /** * Helper for making data/async requests * * Allows for clean declaration of what data a Component uses and has built-in error handling. * Integrates MobX to allow components to re-render when data has loaded/errored/etc. * * It can keep track of parameters (eg page number, sort order) and will refetch (with a debounce) whenever a parameter is changed. */ export class DataFetcher { constructor(fetch: (params: any) => PromiseLike, opts?: DataFetcherOptions) { opts = { debounce: 250, activate: 'requested', incremental: !!opts.max_debounce, ...opts } this.options = opts; this.fetchFunc = fetch; this.transactor = new ConcurrentTransactor>({ active: false, debounce: opts.debounce, max_debounce: opts.max_debounce, throttle: opts.throttle, filter_stale_results: !opts.incremental, transact: async () => { runInAction(() => { this.fetchStatus = 'loading'; this.updateCurrentStatus('loading'); }); const req_params = { ...this._desiredParams } try { return { value: await this.fetchFunc(req_params), req_params }; } catch (ex) { return { error: ex }; } }, handleResult: action((result) => { if ('value' in result) { if (this.transactor.isCaughtUp) { this.fetchStatus = 'loaded'; } this._data = result.value; this.currentStatus = 'loaded'; this._currentParams = result.req_params ?? {}; this.currentError = null; this.emitter.emit('fetched', []); } else { if (this.transactor.isCaughtUp) { this.fetchStatus = 'error' this._desiredParams = result.req_params ?? {}; } this.updateCurrentStatus('error'); this.currentError = result.error; } }), }); if (opts.initialParams) this._desiredParams = { ...opts.initialParams }; if (opts.initial) { this.manualSet(opts.initial); this._currentParams = this._desiredParams; } if (opts.activate === true) { this.activate(); } } duplicate(override_options: DataFetcherOptions = {}): this { return new (this.constructor as typeof DataFetcher)(this.fetchFunc, { ...this.options, ...override_options, }) as any; } private emitter = new EventEmitter<{ params_changed: [params: any, changedKeys: string[]], fetched: [], }>(); on = this.emitter.on; off = this.emitter.off; private transactor: ConcurrentTransactor; /** * Wait until the Transactor is caught up to any already-triggered triggers. * Has the same effect as `catchupToAll()` if `incremental: false`. */ catchupToCurrent() { return this.transactor.catchupToCurrent(); } /** Wait until the Transactor has no pending triggers */ catchupToAll() { return this.transactor.catchupToAll(); } private readonly options: Readonly; fetchFunc: (params: any) => PromiseLike private activated: boolean = false; private queried: boolean = false; @observable.shallow private accessor _desiredParams = {}; @observable.ref private accessor _currentParams; @observable.ref accessor _data: D; @observable.ref accessor currentError: Error; @observable accessor currentStatus: LoadState = 'unloaded'; @observable accessor fetchStatus: LoadState = 'unloaded'; get desiredParams(): ROAnyObject { return this._desiredParams } get currentParams(): ROAnyObject { return this._currentParams } get data() { this.triggerLazyFetch(); return this._data; } @computed get outdated() { return !deepEqual(this._desiredParams, this._currentParams); } @action activate() { if (this.activated) return; this.transactor.resume(); this.activated = true; if ((!this._data && !this.options.lazy) || this.queried) { this.transactor.performNow(); } } @action updateParams(p) { if (deepEqual(p, this._desiredParams)) return; const keySet = new Set([...Object.keys(this._desiredParams), ...Object.keys(p)]) const changedKeys = Array.from(keySet).filter(k => this._desiredParams[k] !== p[k]) this._desiredParams = { ...p }; this.emitter.emit('params_changed', [{ ...this._desiredParams }, changedKeys]) this.transactor.performSoon(); } @action updateParam(k, v) { if (this._desiredParams[k] == v) return; this._desiredParams[k] = v; this.emitter.emit('params_changed', [{ ...this._desiredParams }, [k]]) this.transactor.performSoon(); } private triggerLazyFetch() { untracked(() => { if (!this.activated && this.options.activate == 'queried') { this.activated = true; } this.queried = true; if (!this.activated) return; if (this.fetchStatus != 'unloaded') return; transaction(() => { this.transactor.performNow(); }) }) } /** *@deprecated Only use was with activate: "requested", but explicit `activate()` seems to be the better approach - * in fact, all observed uses of `requestRefetch()` are in `componentDidMount` */ requestRefetch() { if (this.options.activate == 'requested') { this.activate(); } } /** * Mark the DataFetcher as dirty and request a refetch. */ forceRefetch(skip_debounce = false) { this.transactor.performSoon(skip_debounce); } @action manualSet(value: D) { this._data = value; this.fetchStatus = 'loaded'; this.currentStatus = 'loaded'; this.currentError = null; this.transactor.markCaughtUp(); } private updateCurrentStatus(s: LoadState) { // Once loaded once, the main status latches if (this.currentStatus == 'loaded') return this.currentStatus = s; } } export interface LocallySortedDataFetcherOptions extends DataFetcherOptions> { sorts?: Record number>; default_sort?: string; default_page_size?: number; } /** * DataFetcher subclass that expects to receive the complete dataset and applies pagination and sorting locally. * Mainly intended for more legacy applications */ export class LocallySortedDataFetcher extends DataFetcher> { constructor(protected _fetch: (params: any) => PromiseLike>, opts?: LocallySortedDataFetcherOptions) { super((params) => this.sorting_fetch(params), opts); this.sorts = opts.sorts || {}; this.default_sort = opts.default_sort; this.default_page_size = opts.default_page_size || 15; if (opts.initialParams) { let { page, sort, page_size, ...pass } = opts.initialParams; this.last_params = pass; } } protected sorts: LocallySortedDataFetcherOptions['sorts'] = {}; protected default_sort: string; protected default_page_size: number; private last_params: any; private last_fetched: DataPage; private last_sort: string; protected async sorting_fetch(params: any) { let { page, sort, page_size, ...pass } = params; if (!this.last_fetched || !deepEqual(pass, this.last_params)) { this.last_params = pass; this.last_sort = null; this.last_fetched = await this._fetch(pass); } return this.getPagedData(); } manualSet(value: DataPage): void { this.last_fetched = value; super.manualSet(this.getPagedData()); } protected getPagedData() { let { sort, page, page_size } = this.desiredParams; page_size ||= this.default_page_size; page ||= 1; if (this.last_sort != sort) { const seen_sorts = new Set(); const sorts = [...(sort as string || "").split(","), this.default_sort].filter(s => !!s).map(s => s.split(" ")).filter(s => { if (seen_sorts.has(s[0])) return false; seen_sorts.add(s[0]); return true; }).reverse(); for (const [key, dir] of sorts) { const mapper = this.sorts[key] || ((v) => v[key]); this.last_fetched.items = this.last_fetched.items.sort((a, b) => { const a_val = mapper(a); const b_val = mapper(b); if (a_val == b_val) { return 0; } if (a_val > b_val) { return dir == "DESC" ? -1 : 1; } return dir == "DESC" ? 1 : -1; }) } } const new_page: DataPage = { ...this.last_fetched, items: this.last_fetched.items.slice((page - 1) * page_size, page * page_size), page, page_count: Math.ceil(this.last_fetched.items.length / page_size), page_size, sort: sort || this.default_sort || null, }; return new_page; } forceRefetch(): void { this.last_fetched = null; super.forceRefetch(); } }