@v4fire/client
Version:
V4Fire client core library
1,146 lines (932 loc) • 26.4 kB
text/typescript
/* eslint-disable max-lines */
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
/**
* [[include:super/i-data/README.md]]
* @packageDocumentation
*/
import symbolGenerator from 'core/symbol';
import SyncPromise from 'core/promise/sync';
import { deprecate, deprecated } from 'core/functools/deprecation';
import RequestError from 'core/request/error';
import { providers } from 'core/data/const';
//#if runtime has core/data
import type Provider from 'core/data';
import type {
RequestQuery,
RequestBody,
RequestResponseObject,
ModelMethod,
ProviderOptions
} from 'core/data';
//#endif
import type Async from 'core/async';
import type { AsyncOptions } from 'core/async';
import iProgress from 'traits/i-progress/i-progress';
import iBlock, {
component,
wrapEventEmitter,
prop,
field,
system,
watch,
wait,
ReadonlyEventEmitterWrapper,
InitLoadCb,
InitLoadOptions,
ModsDecl,
UnsafeGetter
} from 'super/i-block/i-block';
import { providerMethods } from 'super/i-data/const';
import type {
UnsafeIData,
RequestParams,
DefaultRequest,
RequestFilter,
CreateRequestOptions,
RetryRequestFn,
ComponentConverter,
CheckDBEquality
} from 'super/i-data/interface';
export { RequestError };
//#if runtime has core/data
export {
Socket,
RequestQuery,
RequestBody,
RequestResponseObject,
Response,
ModelMethod,
ProviderOptions,
ExtraProvider,
ExtraProviders
} from 'core/data';
//#endif
export * from 'super/i-block/i-block';
export * from 'super/i-data/const';
export * from 'super/i-data/interface';
export const
$$ = symbolGenerator();
/**
* Superclass for all components that need to download data from data providers
*/
export default abstract class iData extends iBlock implements iProgress {
/**
* Type: raw provider data
*/
readonly DB!: object;
//#if runtime has iData
/**
* Data provider name
*/
readonly dataProvider?: string;
/**
* Initial parameters for the data provider instance
*/
readonly dataProviderOptions?: ProviderOptions;
/**
* External request parameters.
* Keys of the object represent names of data provider methods.
* Parameters that associated with provider methods will be automatically appended to
* invocation as parameters by default.
*
* This parameter is useful to provide some request parameters from a parent component.
*
* @example
* ```
* < b-select :dataProvider = 'Cities' | :request = {get: {text: searchValue}}
*
* // Also, you can provide additional parameters to request method
* < b-select :dataProvider = 'Cities' | :request = {get: [{text: searchValue}, {cacheStrategy: 'never'}]}
* ```
*/
readonly request?: RequestParams;
/**
* Remote data converter/s.
* This function (or a list of functions) transforms initial provider data before saving to `db`.
*/
readonly dbConverter?: CanArray<ComponentConverter>;
/**
* Converter/s from the raw `db` to the component fields
*/
readonly componentConverter?: CanArray<ComponentConverter>;
/**
* A function to filter all "default" requests, i.e., all requests that were produced implicitly,
* like an initial component request or requests that are triggered by changing of parameters from
* `request` and `requestParams`. If the filter returns negative value, the tied request will be aborted.
*
* Also, you can set this parameter to true, and it will filter only requests with a payload.
*/
readonly defaultRequestFilter?: RequestFilter;
/**
* @deprecated
* @see [[iData.defaultRequestFilter]]
*/
readonly requestFilter?: RequestFilter;
/**
* If true, all requests to the data provider are suspended till you don't manually force it.
* This prop is used when you want to organize the lazy loading of components.
* For instance, you can load only components in the viewport.
*/
readonly suspendRequestsProp: boolean = false;
/**
* Enables the suspending of all requests to the data provider till you don't manually force it.
* Also, the parameter can contain a promise resolve function.
* @see [[iData.suspendRequestsProp]]
*/
suspendRequests?: boolean | Function;
/**
* If true, then the component can reload data within the offline mode
*/
readonly offlineReload: boolean = false;
/**
* If true, then all new initial provider data will be compared with the old data.
* Also, the parameter can be passed as a function, that returns true if data are equal.
*/
readonly checkDBEquality: CheckDBEquality = true;
override get unsafe(): UnsafeGetter<UnsafeIData<this>> {
return Object.cast(this);
}
/**
* Initial component data.
* When a component takes data from own data provider it stores the value within this property.
*/
get db(): CanUndef<this['DB']> {
return this.field.get('dbStore');
}
/**
* Sets new component data
*
* @emits `dbCanChange(value: CanUndef<this['DB']>)`
* @emits `dbChange(value: CanUndef<this['DB']>)`
*/
set db(value: CanUndef<this['DB']>) {
this.emit('dbCanChange', value);
if (value === this.db) {
return;
}
const
{async: $a} = this;
$a.terminateWorker({
label: $$.db
});
this.field.set('dbStore', value);
if (this.initRemoteData() !== undefined) {
this.watch('dbStore', this.initRemoteData.bind(this), {
deep: true,
label: $$.db
});
}
this.emit('dbChange', value);
}
static override readonly mods: ModsDecl = {
...iProgress.mods
};
/**
* Event emitter of a component data provider
*/
<iData>({
atom: true,
after: 'async',
unique: true,
init: (o, d) => wrapEventEmitter(<Async>d.async, () => o.dp?.emitter, true)
})
protected readonly dataEmitter!: ReadonlyEventEmitterWrapper<this>;
/**
* @deprecated
* @see [[iData.dataEmitter]]
*/
get dataEvent(): ReadonlyEventEmitterWrapper<this> {
return this.dataEmitter;
}
/**
* Request parameters for the data provider.
* Keys of the object represent names of data provider methods.
* Parameters that associated with provider methods will be automatically appended to
* invocation as parameters by default.
*
* To create logic when the data provider automatically reload data, if some properties has been
* changed, you need to use 'sync.object'.
*
* @example
* ```ts
* class Foo extends iData {
* @system()
* i: number = 0;
*
* // {get: {step: 0}, upd: {i: 0}, del: {i: '0'}}
* @system((ctx) => ({
* ...ctx.sync.link('get', [
* ['step', 'i']
* ]),
*
* ...ctx.sync.link('upd', [
* ['i']
* ]),
*
* ...ctx.sync.link('del', [
* ['i', String]
* ]),
* })
*
* protected readonly requestParams!: RequestParams;
* }
* ```
*/
protected readonly requestParams: RequestParams = {get: {}};
/**
* Component data store
* @see [[iData.db]]
*/
// @ts-ignore (extend)
protected dbStore?: CanUndef<this['DB']>;
/**
* Instance of a component data provider
*/
protected dp?: Provider;
/**
* Unsuspend requests to the data provider
*/
unsuspendRequests(): void {
if (Object.isFunction(this.suspendRequests)) {
this.suspendRequests();
}
}
override initLoad(data?: unknown, opts: InitLoadOptions = {}): CanPromise<void> {
if (!this.isActivated) {
return;
}
const
{async: $a} = this;
const label = <AsyncOptions>{
label: $$.initLoad,
join: 'replace'
};
const
callSuper = () => super.initLoad(() => this.db, opts);
try {
if (opts.emitStartEvent !== false) {
this.emit('initLoadStart', opts);
}
opts = {
emitStartEvent: false,
...opts
};
$a
.clearAll({group: 'requestSync:get'});
if (this.isNotRegular && !this.isSSR) {
const res = super.initLoad(() => {
if (data !== undefined) {
this.db = this.convertDataToDB<this['DB']>(data);
}
return this.db;
}, opts);
if (Object.isPromise(res)) {
this.$initializer = res;
}
return res;
}
if (this.dataProvider != null && this.dp == null) {
this.syncDataProviderWatcher(false);
}
if (!opts.silent) {
this.componentStatus = 'loading';
}
if (data !== undefined) {
const db = this.convertDataToDB<this['DB']>(data);
void this.lfc.execCbAtTheRightTime(() => this.db = db, label);
} else if (this.dp?.baseURL != null) {
const
needRequest = Object.isArray(this.getDefaultRequestParams('get'));
if (needRequest) {
const res = $a
.nextTick(label)
.then(() => {
const
defParams = this.getDefaultRequestParams<this['DB']>('get');
if (defParams == null) {
return;
}
const
query = defParams[0],
opts = {
...defParams[1],
...label,
important: this.componentStatus === 'unloaded'
};
// Prefetch
void this.moduleLoader.load(...this.dependencies);
void this.state.initFromStorage();
return this.get(<RequestQuery>query, opts);
})
.then(
(data) => {
void this.lfc.execCbAtTheRightTime(() => {
this.saveDataToRootStore(data);
this.db = this.convertDataToDB<this['DB']>(data);
}, label);
return callSuper();
},
(err) => {
stderr(err);
return callSuper();
}
);
this.$initializer = res;
return res;
}
if (this.db !== undefined) {
void this.lfc.execCbAtTheRightTime(() => this.db = undefined, label);
}
}
return callSuper();
} catch (err) {
stderr(err);
return callSuper();
}
}
/**
* Link to `iBlock.initLoad`
*
* @see [[iBlock.initLoad]]
* @param [data]
* @param [opts]
*/
initBaseLoad(data?: unknown | InitLoadCb, opts?: InitLoadOptions): CanPromise<void> {
return super.initLoad(data, opts);
}
override reload(opts?: InitLoadOptions): Promise<void> {
if (!this.r.isOnline && !this.offlineReload) {
return Promise.resolve();
}
return super.reload(opts);
}
/**
* Drops the data provider cache
*/
dropDataCache(): void {
this.dp?.dropCache();
}
/**
* @deprecated
* @see [[iData.dropDataCache]]
*/
dropRequestCache(): void {
this.dropDataCache();
}
/**
* Returns the full URL of any request
*/
url(): CanUndef<string>;
/**
* Sets an extra URL part for any request (it is concatenated with the base part of URL).
* This method returns a new component object with additional context.
*
* @param [value]
*
* @example
* ```js
* this.url('list').get()
* ```
*/
url(value: string): this;
url(value?: string): CanUndef<string> | this {
if (value == null) {
return this.dp?.url();
}
if (this.dp != null) {
const ctx = Object.create(this);
ctx.dp = this.dp.url(value);
return this.patchProviderContext(ctx);
}
return this;
}
/**
* Returns the base part of URL of any request
*/
base(): CanUndef<string>;
/**
* Sets the base part of URL for any request.
* This method returns a new component object with additional context.
*
* @param [value]
*
* @example
* ```js
* this.base('list').get()
* ```
*/
base(value: string): this;
base(value?: string): CanUndef<string> | this {
if (value == null) {
return this.dp?.base();
}
if (this.dp != null) {
const ctx = Object.create(this);
ctx.dp = this.dp.base(value);
return this.patchProviderContext(ctx);
}
return this;
}
/**
* Requests the provider for data by a query.
* This method is similar for a GET request.
*
* @see [[Provider.get]]
* @param [query] - request query
* @param [opts] - additional request options
*/
get<D = unknown>(query?: RequestQuery, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
const
args = arguments.length > 0 ? [query, opts] : this.getDefaultRequestParams<D>('get');
if (Object.isArray(args)) {
return this.createRequest('get', ...Object.cast<[RequestQuery, CreateRequestOptions<D>]>(args));
}
return Promise.resolve(undefined);
}
/**
* Checks accessibility of the provider by a query.
* This method is similar for a HEAD request.
*
* @see [[Provider.peek]]
* @param [query] - request query
* @param [opts] - additional request options
*/
peek<D = unknown>(query?: RequestQuery, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
const
args = arguments.length > 0 ? [query, opts] : this.getDefaultRequestParams('peek');
if (Object.isArray(args)) {
return this.createRequest('peek', ...Object.cast<[RequestQuery, CreateRequestOptions<D>]>(args));
}
return Promise.resolve(undefined);
}
/**
* Sends custom data to the provider without any logically effect.
* This method is similar for a POST request.
*
* @see [[Provider.post]]
* @param [body] - request body
* @param [opts] - additional request options
*/
post<D = unknown>(body?: RequestBody, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
const
args = arguments.length > 0 ? [body, opts] : this.getDefaultRequestParams('post');
if (Object.isArray(args)) {
return this.createRequest('post', ...Object.cast<[RequestBody, CreateRequestOptions<D>]>(args));
}
return Promise.resolve(undefined);
}
/**
* Adds new data to the provider.
* This method is similar for a POST request.
*
* @see [[Provider.add]]
* @param [body] - request body
* @param [opts] - additional request options
*/
add<D = unknown>(body?: RequestBody, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
const
args = arguments.length > 0 ? [body, opts] : this.getDefaultRequestParams('add');
if (Object.isArray(args)) {
return this.createRequest('add', ...Object.cast<[RequestBody, CreateRequestOptions<D>]>(args));
}
return Promise.resolve(undefined);
}
/**
* Updates data of the provider by a query.
* This method is similar for PUT or PATCH requests.
*
* @see [[Provider.upd]]
* @param [body] - request body
* @param [opts] - additional request options
*/
upd<D = unknown>(body?: RequestBody, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
const
args = arguments.length > 0 ? [body, opts] : this.getDefaultRequestParams('upd');
if (Object.isArray(args)) {
return this.createRequest('upd', ...Object.cast<[RequestBody, CreateRequestOptions<D>]>(args));
}
return Promise.resolve(undefined);
}
/**
* Deletes data of the provider by a query.
* This method is similar for a DELETE request.
*
* @see [[Provider.del]]
* @param [body] - request body
* @param [opts] - additional request options
*/
del<D = unknown>(body?: RequestBody, opts?: CreateRequestOptions<D>): Promise<CanUndef<D>> {
const
args = arguments.length > 0 ? [body, opts] : this.getDefaultRequestParams('del');
if (Object.isArray(args)) {
return this.createRequest('del', ...Object.cast<[RequestBody, CreateRequestOptions<D>]>(args));
}
return Promise.resolve(undefined);
}
/**
* Saves data to the root data store.
* All components with specified global names or data providers by default store data from initial providers'
* requests with the root component.
*
* You can check each provider data by using `r.providerDataStore`.
*
* @param data
* @param [key] - key to save data
*/
protected saveDataToRootStore(data: unknown, key?: string): void {
key ??= this.globalName ?? this.dataProvider;
if (key == null) {
return;
}
this.r.providerDataStore.set(key, data);
}
/**
* Converts raw provider data to the component `db` format and returns it
* @param data
*/
protected convertDataToDB<O>(data: unknown): O;
protected convertDataToDB(data: unknown): this['DB'];
protected convertDataToDB<O>(data: unknown): O | this['DB'] {
let
val = data;
if (this.dbConverter != null) {
const
converters = Array.concat([], this.dbConverter);
if (converters.length > 0) {
val = Object.isArray(val) || Object.isDictionary(val) ? val.valueOf() : val;
for (let i = 0; i < converters.length; i++) {
val = converters[i].call(this, val, this);
}
}
}
const
{db, checkDBEquality} = this;
const canKeepOldData = Object.isFunction(checkDBEquality) ?
Object.isTruly(checkDBEquality.call(this, val, db)) :
checkDBEquality && Object.fastCompare(val, db);
if (canKeepOldData) {
return <O | this['DB']>db;
}
return <O | this['DB']>val;
}
/**
* Converts data from `db` to the component field format and returns it
* @param data
*/
protected convertDBToComponent<O = unknown>(data: unknown): O | this['DB'] {
let
val = data;
if (this.componentConverter) {
const
converters = Array.concat([], this.componentConverter);
if (converters.length > 0) {
val = Object.isArray(val) || Object.isDictionary(val) ? val.valueOf() : val;
for (let i = 0; i < converters.length; i++) {
val = converters[i].call(this, val, this);
}
}
}
return <O | this['DB']>val;
}
/**
* Initializes component data from the data provider.
* This method is used to map `db` to component properties.
* If the method is used, it must return some value that not equals to undefined.
*/
protected initRemoteData(): CanUndef<unknown> {
return undefined;
}
protected override initGlobalEvents(resetListener?: boolean): void {
super.initGlobalEvents(resetListener != null ? resetListener : Boolean(this.dataProvider));
}
/**
* Initializes data event listeners
*/
protected initDataListeners(): void {
const {
dataEmitter: $e
} = this;
const group = {group: 'dataProviderSync'};
$e.off(group);
$e.on('add', async (data) => {
if (this.getDefaultRequestParams('get')) {
this.onAddData(await (Object.isFunction(data) ? data() : data));
}
}, group);
$e.on('upd', async (data) => {
if (this.getDefaultRequestParams('get')) {
this.onUpdData(await (Object.isFunction(data) ? data() : data));
}
}, group);
$e.on('del', async (data) => {
if (this.getDefaultRequestParams('get')) {
this.onDelData(await (Object.isFunction(data) ? data() : data));
}
}, group);
$e.on('refresh', async (data) => {
await this.onRefreshData(await (Object.isFunction(data) ? data() : data));
}, group);
}
/**
* Synchronization of request fields
*
* @param [value]
* @param [oldValue]
*/
protected syncRequestParamsWatcher<T = unknown>(
value?: RequestParams<T>,
oldValue?: RequestParams<T>
): void {
if (!value) {
return;
}
const
{async: $a} = this;
for (let o = Object.keys(value), i = 0; i < o.length; i++) {
const
key = o[i],
val = value[key],
oldVal = oldValue?.[key];
if (val != null && oldVal != null && Object.fastCompare(val, oldVal)) {
continue;
}
const
m = key.split(':', 1)[0],
group = {group: `requestSync:${m}`};
$a
.clearAll(group);
if (m === 'get') {
this.componentStatus = 'loading';
$a.setImmediate(this.initLoad.bind(this), group);
} else {
$a.setImmediate(() => this[m](...this.getDefaultRequestParams(key) ?? []), group);
}
}
}
/**
* Synchronization of `dataProvider` properties
* @param [initLoad] - if false, there is no need to call `initLoad`
*/
protected syncDataProviderWatcher(initLoad: boolean = true): void {
const
provider = this.dataProvider;
if (this.dp) {
this.async
.clearAll({group: /requestSync/})
.clearAll({label: $$.initLoad});
this.dataEmitter.off();
this.dp = undefined;
}
if (provider != null) {
const
ProviderConstructor = <CanUndef<typeof Provider>>providers[provider];
if (ProviderConstructor == null) {
if (provider === 'Provider') {
return;
}
throw new ReferenceError(`The provider "${provider}" is not defined`);
}
const watchParams = {
deep: true,
group: 'requestSync'
};
this.watch('request', watchParams, this.syncRequestParamsWatcher.bind(this));
this.watch('requestParams', watchParams, this.syncRequestParamsWatcher.bind(this));
this.dp = new ProviderConstructor(this.dataProviderOptions);
this.initDataListeners();
if (initLoad) {
void this.initLoad();
}
}
}
/**
* Returns default request parameters for the specified data provider method
* @param method
*/
protected getDefaultRequestParams<T = unknown>(method: string): CanUndef<DefaultRequest<T>> {
const
{field} = this;
const
[customData, customOpts] = Array.concat([], field.get(`request.${method}`));
const
p = field.get(`requestParams.${method}`),
isGet = /^get(:|$)/.test(method);
let
res;
if (Object.isArray(p)) {
p[1] = p[1] ?? {};
res = p;
} else {
res = [p, {}];
}
if (Object.isPlainObject(res[0]) && Object.isPlainObject(customData)) {
res[0] = Object.mixin({
onlyNew: true,
filter: (el) => {
if (isGet) {
return el != null;
}
return el !== undefined;
}
}, undefined, res[0], customData);
} else {
res[0] = res[0] != null ? res[0] : customData;
}
res[1] = Object.mixin({deep: true}, undefined, res[1], customOpts);
const
requestFilter = this.requestFilter ?? this.defaultRequestFilter,
isEmpty = Object.size(res[0]) === 0;
const info = {
isEmpty,
method,
params: res[1]
};
let
needSkip = false;
if (this.requestFilter != null) {
deprecate({
name: 'requestFilter',
type: 'property',
alternative: {name: 'defaultRequestFilter'}
});
if (Object.isFunction(requestFilter)) {
needSkip = !Object.isTruly(requestFilter.call(this, res[0], info));
} else if (requestFilter === false) {
needSkip = isEmpty;
}
} else if (Object.isFunction(requestFilter)) {
needSkip = !Object.isTruly(requestFilter.call(this, res[0], info));
} else if (requestFilter === true) {
needSkip = isEmpty;
}
if (needSkip) {
return;
}
return res;
}
/**
* Returns a promise that will be resolved when the component can produce requests to the data provider
*/
protected waitPermissionToRequest(): Promise<boolean> {
if (this.suspendRequests === false) {
return SyncPromise.resolve(true);
}
return this.async.promise(() => new Promise((resolve) => {
this.suspendRequests = () => {
resolve(true);
this.suspendRequests = false;
};
}), {
label: $$.waitPermissionToRequest,
join: true
});
}
/**
* Patches the specified component context with the provider' CRUD methods
* @param ctx
*/
protected patchProviderContext(ctx: this): this {
for (let i = 0; i < providerMethods.length; i++) {
const
method = providerMethods[i];
Object.defineProperty(ctx, method, {
writable: true,
configurable: true,
value: this.instance[method]
});
}
return ctx;
}
/**
* Creates a new request to the data provider
*
* @param method - request method
* @param [body] - request body
* @param [opts] - additional options
*/
protected createRequest<D = unknown>(
method: ModelMethod | Provider[ModelMethod],
body?: RequestQuery | RequestBody,
opts: CreateRequestOptions<D> = {}
): Promise<CanUndef<D>> {
if (!this.dp) {
return Promise.resolve(undefined);
}
const
asyncFields = ['join', 'label', 'group'],
reqParams = Object.reject(opts, asyncFields),
asyncParams = Object.select(opts, asyncFields);
const req = this.waitPermissionToRequest()
.then(() => {
let
rawRequest;
if (Object.isFunction(method)) {
rawRequest = method(Object.cast(body), reqParams);
} else {
if (this.dp == null) {
throw new ReferenceError('The data provider to request is not defined');
}
rawRequest = this.dp[method](Object.cast(body), reqParams);
}
return this.async.request<RequestResponseObject<D>>(rawRequest, asyncParams);
});
if (this.mods.progress !== 'true') {
const
is = (v) => v !== false;
if (is(opts.showProgress)) {
void this.setMod('progress', true);
}
const then = () => {
if (is(opts.hideProgress)) {
void this.lfc.execCbAtTheRightTime(() => this.setMod('progress', false));
}
};
req.then(then, (err) => {
this.onRequestError(err, () => this.createRequest<D>(method, body, opts));
then();
});
}
return req.then((res) => res.data).then((data) => data ?? undefined);
}
/**
* Handler: `dataProvider.error`
*
* @param err
* @param retry - retry function
* @emits `requestError(err: Error |` [[RequestError]], retry:` [[RetryRequestFn]]`)`
*/
protected onRequestError(err: Error | RequestError, retry: RetryRequestFn): void {
this.emitError('requestError', err, retry);
}
/**
* Handler: `dataProvider.add`
* @param data
*/
protected onAddData(data: unknown): void {
if (data != null) {
this.db = this.convertDataToDB(data);
} else {
this.reload().catch(stderr);
}
}
/**
* Handler: `dataProvider.upd`
* @param data
*/
protected onUpdData(data: unknown): void {
if (data != null) {
this.db = this.convertDataToDB(data);
} else {
this.reload().catch(stderr);
}
}
/**
* Handler: `dataProvider.del`
* @param data
*/
protected onDelData(data: unknown): void {
if (data != null) {
this.db = this.convertDataToDB(data);
} else {
this.reload().catch(stderr);
}
}
/**
* Handler: `dataProvider.refresh`
* @param data
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars-experimental
protected onRefreshData(data: this['DB']): Promise<void> {
return this.reload();
}
//#endif
}