Коммит
a4900d3782
|
@ -344,3 +344,5 @@ MigrationBackup/
|
|||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
*.orig
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
# @bingads-webui/overreact
|
||||
|
||||
**@bingads-webui/overreact** is a new component.
|
||||
|
||||
## References
|
||||
[API reference and tutorials](http://bingadsinternal.azurewebsites.net/campaignui/client-data/jsdoc/overreact)
|
||||
|
||||
## Installing
|
||||
|
||||
When using yarn, run the following command:
|
||||
|
||||
```shell
|
||||
yarn add --save @bingads-webui/overreact
|
||||
```
|
||||
|
||||
## Using
|
||||
|
||||
After installing, import the package into your code and start using it
|
||||
|
||||
```javascript
|
||||
import { Overreact } from '@bingads-webui/overreact';
|
||||
|
||||
/// ...
|
||||
```
|
|
@ -0,0 +1,13 @@
|
|||
export * from './src/data-fetcher';
|
||||
export * from './src/environment';
|
||||
export * from './src/spec';
|
||||
export * from './src/store';
|
||||
export * from './src/schema';
|
||||
export * from './src/hooks';
|
||||
export {
|
||||
FetchPolicy,
|
||||
middlewareTypes,
|
||||
createFetchPolicyMiddleware,
|
||||
createErrorMiddleware,
|
||||
createInstrumentationMiddleware,
|
||||
} from './src/middleware';
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@microsoft/overreact-core",
|
||||
"description": "TO BE ADDED",
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bluebird": "3.5.0",
|
||||
"json-stable-stringify": "*",
|
||||
"prop-types": ">=15.6.0",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"regenerator-runtime": "^0.13.2",
|
||||
"underscore": "^1.10.2",
|
||||
"uuid": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"fetch-mock": "^9.10.1",
|
||||
"jquery": "2.2.4",
|
||||
"json-stable-stringify": "*",
|
||||
"query-string": "*"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
import _ from 'underscore';
|
||||
import React, { useRef, memo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { EnvironmentContext } from '../environment/context';
|
||||
|
||||
const MAX_REQUEST_BATCH_SIZE = 10;
|
||||
const FETCH_INTERVAL = 50;
|
||||
const INIT_MIDDLEWARE_STATES = { isResponseFromStore: false };
|
||||
|
||||
function executeRequests(environment, requests) {
|
||||
const pendingRequests = [
|
||||
...requests,
|
||||
];
|
||||
while (pendingRequests.length > 0) {
|
||||
const req = pendingRequests.shift();
|
||||
|
||||
const {
|
||||
requestContract,
|
||||
spec,
|
||||
variables,
|
||||
data,
|
||||
id,
|
||||
} = req;
|
||||
|
||||
req.middlewareStates = {
|
||||
...INIT_MIDDLEWARE_STATES,
|
||||
};
|
||||
|
||||
const { responseContract } = spec;
|
||||
const {
|
||||
verb, uriFactoryFn, headerFactoryFn, payloadFactoryFn,
|
||||
} = requestContract;
|
||||
|
||||
const uri = uriFactoryFn({ requestContract, variables, data });
|
||||
const header = headerFactoryFn && headerFactoryFn({ requestContract, variables, data });
|
||||
const payload = payloadFactoryFn && payloadFactoryFn({ requestContract, variables, data });
|
||||
|
||||
const requestor = environment.getRequestor(id, spec, variables, req.middlewareStates);
|
||||
|
||||
requestor(uri, verb, header, payload).execute({
|
||||
onComplete: (response) => {
|
||||
responseContract.onGetResponse(environment, response, req);
|
||||
},
|
||||
onError: (error) => {
|
||||
responseContract.onGetError(environment, req, error);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function batchRequests(environment, batchSize) {
|
||||
// console.log(`executing requests for batch size ${batchSize}`);
|
||||
|
||||
// we'll try to batch requests
|
||||
const requests = [];
|
||||
while (requests.length < batchSize) {
|
||||
const request = environment.removeRequest();
|
||||
requests.push(request);
|
||||
}
|
||||
|
||||
executeRequests(environment, requests);
|
||||
}
|
||||
|
||||
export function useEnvironmentInitialization(environment) {
|
||||
// const [currentEnvironment, setCurrEnv] = useState(environment);
|
||||
const timer = useRef(null);
|
||||
|
||||
// TODO: we need better way to subscribe here
|
||||
// current way will result in memory leak or other bugs when environment change
|
||||
environment.subscribe(() => {
|
||||
if (!timer.current) {
|
||||
clearTimeout(timer.current);
|
||||
}
|
||||
|
||||
if (environment.requestCount() >= MAX_REQUEST_BATCH_SIZE) {
|
||||
// immediately execute requests to reduce queue size
|
||||
batchRequests(environment, MAX_REQUEST_BATCH_SIZE);
|
||||
} else {
|
||||
// a few requests outstanding, wait a bit to accumulate more.
|
||||
timer.current = setTimeout(() => {
|
||||
batchRequests(environment, environment.requestCount());
|
||||
}, FETCH_INTERVAL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// default top-level data fetcher
|
||||
export const DataFetcher = memo((props) => {
|
||||
const {
|
||||
environment,
|
||||
environments,
|
||||
children,
|
||||
} = props;
|
||||
useEnvironmentInitialization(environment);
|
||||
_.each(environments, env => useEnvironmentInitialization(env));
|
||||
|
||||
return (
|
||||
<EnvironmentContext.Provider value={[environment, environments]}>
|
||||
{children}
|
||||
</EnvironmentContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
DataFetcher.propTypes = {
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
environment: PropTypes.object.isRequired,
|
||||
environments: PropTypes.arrayOf(PropTypes.object),
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
DataFetcher.defaultProps = {
|
||||
environments: null,
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
import React from 'react';
|
||||
|
||||
export const EnvironmentContext = React.createContext();
|
|
@ -0,0 +1,107 @@
|
|||
import 'regenerator-runtime/runtime';
|
||||
|
||||
import { requestWithMiddleware, WrappedRequestor } from '../middleware';
|
||||
|
||||
export class Environment {
|
||||
constructor(networkRequestor, schema, store, middlewares, tag) {
|
||||
this.networkRequestor = networkRequestor;
|
||||
|
||||
this.schema = schema;
|
||||
|
||||
// an FIFO queue
|
||||
this.requestQueue = [];
|
||||
this.dataFetcherSubscriber = [];
|
||||
|
||||
// initialize the store
|
||||
this.store = store;
|
||||
|
||||
this.middlewares = middlewares || {};
|
||||
|
||||
this.tag = tag;
|
||||
|
||||
this.notifyObservers = this.notifyObservers.bind(this);
|
||||
this.pushRequest = this.pushRequest.bind(this);
|
||||
this.removeRequest = this.removeRequest.bind(this);
|
||||
this.requestCount = this.requestCount.bind(this);
|
||||
|
||||
this.subscribe = this.subscribe.bind(this);
|
||||
this.unsubscribe = this.unsubscribe.bind(this);
|
||||
|
||||
this.getSchema = this.getSchema.bind(this);
|
||||
this.getRequestor = this.getRequestor.bind(this);
|
||||
|
||||
this.dataRefIdPool = {};
|
||||
}
|
||||
|
||||
getRequestor(id, spec, variables, middlewareStates) {
|
||||
return (uri, verb, header, payload) => ({
|
||||
execute: (sink) => {
|
||||
const wrappedRequestor = new WrappedRequestor({
|
||||
requestor: this.networkRequestor,
|
||||
uri,
|
||||
verb,
|
||||
header,
|
||||
payload,
|
||||
spec,
|
||||
variables,
|
||||
store: this.store,
|
||||
dataRefId: id,
|
||||
middlewareStates,
|
||||
});
|
||||
const res = requestWithMiddleware(wrappedRequestor, this.middlewares);
|
||||
|
||||
res
|
||||
.then((value) => { sink.onComplete(value); })
|
||||
.catch((err) => { sink.onError(err); });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return this.schema;
|
||||
}
|
||||
|
||||
notifyObservers() {
|
||||
for (let i = 0; i < this.dataFetcherSubscriber.length; i += 1) {
|
||||
this.dataFetcherSubscriber[i].notify(this.requestQueue);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The request will have the following shape:
|
||||
|
||||
{
|
||||
id,
|
||||
requestContract,
|
||||
spec,
|
||||
variables,
|
||||
data,
|
||||
dataCb,
|
||||
}
|
||||
*/
|
||||
pushRequest(request) {
|
||||
this.requestQueue.push(request);
|
||||
|
||||
this.notifyObservers();
|
||||
}
|
||||
|
||||
removeRequest() {
|
||||
return this.requestQueue.shift();
|
||||
}
|
||||
|
||||
requestCount() {
|
||||
return this.requestQueue.length;
|
||||
}
|
||||
|
||||
subscribe(notifyCb) {
|
||||
this.dataFetcherSubscriber.push({
|
||||
notify: notifyCb,
|
||||
});
|
||||
|
||||
return this.dataFetcherSubscriber.length - 1;
|
||||
}
|
||||
|
||||
unsubscribe(id) {
|
||||
return this.dataFetcherSubscriber.splice(id, 1).length;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from './environment';
|
||||
export * from './context';
|
||||
export * from './use-environment';
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import _ from 'underscore';
|
||||
import { useContext } from 'react';
|
||||
import { EnvironmentContext } from './context';
|
||||
|
||||
export function useEnvironment(environmentLookupFn) {
|
||||
const [environment, environments] = useContext(EnvironmentContext);
|
||||
if (_.isFunction(environmentLookupFn)) {
|
||||
const specificEnv = _.find(environments, env => env && env.tag && environmentLookupFn(env.tag));
|
||||
if (!_.isEmpty(specificEnv)) {
|
||||
return specificEnv;
|
||||
}
|
||||
}
|
||||
return environment || _.first(environments);
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
import _ from 'underscore';
|
||||
import { responseTypes } from '../spec';
|
||||
import { FetchPolicy } from '../middleware';
|
||||
import { OVERREACT_ID_FIELD_NAME } from '../store/consts';
|
||||
|
||||
export function getDataFromRecords(records, responseContract) {
|
||||
const values = _.map(records, record => _.omit(record.getData(), OVERREACT_ID_FIELD_NAME));
|
||||
return responseContract.responseType === responseTypes.COLL ? values : values[0];
|
||||
}
|
||||
|
||||
function getDefaultLookupCacheFn(varKeySelector, dataKeySelector) {
|
||||
return (items, variables) =>
|
||||
_.find(items, item => dataKeySelector(item) === varKeySelector(variables));
|
||||
}
|
||||
|
||||
export function getLookupCacheFn(lookupCacheFn, spec, fetchPolicy) {
|
||||
const { requestContract, responseContract } = spec;
|
||||
if (fetchPolicy === FetchPolicy.StoreOrNetwork) {
|
||||
if (lookupCacheFn) {
|
||||
return lookupCacheFn;
|
||||
}
|
||||
|
||||
if (_.isFunction(requestContract.keySelector)
|
||||
&& _.isFunction(responseContract.keySelector)
|
||||
&& responseContract.responseType === responseTypes.ENTITY) {
|
||||
return getDefaultLookupCacheFn(requestContract.keySelector, responseContract.keySelector);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export * from './use-fetch';
|
||||
export * from './use-mutation';
|
||||
export { useRefetch } from './use-refetch';
|
||||
export { usePagination } from './use-pagination';
|
||||
export * from './use-data-ref-id';
|
||||
export { usePromisify } from './use-promisify';
|
|
@ -0,0 +1,23 @@
|
|||
import _ from 'underscore';
|
||||
import { getRecordGroup } from '../store';
|
||||
|
||||
export function getCacheIds({
|
||||
store,
|
||||
requestContract,
|
||||
variables,
|
||||
lookupFn,
|
||||
}) {
|
||||
const recordGroup = getRecordGroup(store, requestContract);
|
||||
const { records } = recordGroup;
|
||||
const cacheRawItems = _.map(records, record => record.data);
|
||||
const cacheHit = lookupFn(cacheRawItems, variables);
|
||||
const overreactIds = _.chain(_.flatten([cacheHit], true))
|
||||
.map((data) => {
|
||||
const record = _.find(records, r => r.data === data);
|
||||
return record ? record.id : undefined;
|
||||
})
|
||||
.compact()
|
||||
.value();
|
||||
|
||||
return overreactIds;
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { isFunction } from 'underscore';
|
||||
|
||||
export function getMergedConfig(config, origin) {
|
||||
// create new instance of origin, origin is also required to be an object
|
||||
const newInstance = { ...origin };
|
||||
|
||||
if (!config) {
|
||||
return newInstance;
|
||||
}
|
||||
|
||||
if (isFunction(config)) {
|
||||
return config(newInstance);
|
||||
}
|
||||
|
||||
return {
|
||||
...origin,
|
||||
...config,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export class OverreactRequest {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
|
||||
if (!this.id || !this.requestContract) {
|
||||
throw new Error('id and requestContract are required fields to build request object');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
// this hook is used to create/retrieve a dataRefId, which
|
||||
// will then be used/shared amongst useFetch/useMutation/usePagination/etc hooks.
|
||||
// In a React component settings, this will act as a "component ID", where
|
||||
// all the custom hooks share a same dataRefId, so they can work on the same
|
||||
// set of data records.
|
||||
// For example, a "usePagination" hook will retrieve a set of records, and store
|
||||
// the data IDs in a certain dataRefId "A". Consequent "useMutation" call
|
||||
// bound to the same dataRefId will have immediate access to the current data from pagination.
|
||||
// Next time the mutation call tries to CREATE a new entity, it will now where
|
||||
// to append the new entity.
|
||||
|
||||
// The hook adds/retrieves dataRefId from a pool in context. An optional "name"
|
||||
// can be given so a compnent can restore previous data state even after unmounting.
|
||||
// A direct example will be when a Paginated timeline component was unmounted/remounted,
|
||||
// previously loaded feeds and scroll position can be reinstiated.
|
||||
import { useRef } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useEnvironment } from '../environment';
|
||||
|
||||
function generateNewDataRefId() {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
export function useDataRefId(name, environmentLookupFn) {
|
||||
const environment = useEnvironment(environmentLookupFn);
|
||||
const dataRefIdRef = useRef(null);
|
||||
|
||||
if (!name && dataRefIdRef.current) {
|
||||
return dataRefIdRef.current;
|
||||
}
|
||||
|
||||
if (environment) {
|
||||
if (name) {
|
||||
// when using named dataRefId, we'll want to preserve it.
|
||||
if (!environment.dataRefIdPool[name]) {
|
||||
environment.dataRefIdPool[name] = generateNewDataRefId();
|
||||
}
|
||||
|
||||
dataRefIdRef.current = environment.dataRefIdPool[name];
|
||||
} else {
|
||||
dataRefIdRef.current = generateNewDataRefId();
|
||||
}
|
||||
}
|
||||
|
||||
return dataRefIdRef.current;
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { useRef, useEffect, useDebugValue } from 'react';
|
||||
import { isEqual } from 'underscore';
|
||||
|
||||
export function useDeepEqualEffect(fn, deps) {
|
||||
const isFirst = useRef(true);
|
||||
const prevDeps = useRef(deps);
|
||||
|
||||
useEffect(() => {
|
||||
const isSame = prevDeps.current.every((obj, key) => isEqual(obj, deps[key]));
|
||||
|
||||
if (isFirst.current || !isSame) {
|
||||
fn();
|
||||
}
|
||||
|
||||
isFirst.current = false;
|
||||
prevDeps.current = deps;
|
||||
}, [deps, fn]);
|
||||
|
||||
useDebugValue('useDeepEqualEffect');
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
import _ from 'underscore';
|
||||
import { useRef, useReducer, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useEnvironment } from '../environment';
|
||||
import { useDeepEqualEffect } from './use-deep-equal-effect';
|
||||
import { getDataNode, createDataNode, getRecords, getDataRef, getRecordsById, updateDataRefWithIds } from '../store';
|
||||
import { OverreactRequest } from './overreact-request';
|
||||
import { getCacheIds } from './lookup-cache';
|
||||
import { getDataFromRecords, getLookupCacheFn } from './helper';
|
||||
|
||||
export function equalityFn(newData, oldData) {
|
||||
return _.isEqual(newData, oldData);
|
||||
}
|
||||
|
||||
export function useFetch(dataRefId, spec, variables, config) {
|
||||
const {
|
||||
requestContract,
|
||||
responseContract,
|
||||
environmentLookupFn,
|
||||
} = spec;
|
||||
|
||||
const requestRequired = useRef(true);
|
||||
const currentData = useRef(undefined);
|
||||
const currentError = useRef(undefined);
|
||||
|
||||
const [, forceRender] = useReducer(x => x + 1, 0);
|
||||
|
||||
const environment = useEnvironment(environmentLookupFn);
|
||||
const { postponeRead, lookupCacheByVariables } = config || {};
|
||||
const lookupFn = useMemo(
|
||||
() => getLookupCacheFn(lookupCacheByVariables, spec, requestContract.fetchPolicy),
|
||||
[lookupCacheByVariables, requestContract.fetchPolicy, spec]
|
||||
);
|
||||
|
||||
// TODO: dataCallback shall take a param, which is a set of
|
||||
// overreact IDs, to efficiently compare data and force render
|
||||
const dataCallback = useCallback(() => {
|
||||
// fetch data from store
|
||||
if (environment) {
|
||||
const { store } = environment;
|
||||
|
||||
const records = getRecords(store, requestContract, dataRefId);
|
||||
|
||||
if (records) {
|
||||
const data = getDataFromRecords(records, responseContract);
|
||||
if (equalityFn(data, currentData.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentData.current = data;
|
||||
|
||||
forceRender();
|
||||
}
|
||||
}
|
||||
}, [dataRefId, environment, requestContract, responseContract]);
|
||||
|
||||
const errorCallback = useCallback(() => {
|
||||
if (environment) {
|
||||
const { store } = environment;
|
||||
|
||||
const dataRef = getDataRef(store, requestContract, dataRefId);
|
||||
const {
|
||||
status: {
|
||||
error,
|
||||
} = {},
|
||||
} = dataRef;
|
||||
|
||||
if (equalityFn(error, currentError.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentError.current = error;
|
||||
forceRender();
|
||||
}
|
||||
}, [dataRefId, environment, requestContract]);
|
||||
|
||||
const dataObserver = useMemo(() => ({
|
||||
update: dataCallback,
|
||||
onError: errorCallback,
|
||||
}), [dataCallback, errorCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment) {
|
||||
const schemaNode = requestContract.getSchemaNode();
|
||||
const { store } = environment;
|
||||
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
|
||||
const dataRef = dataNode.getDataRef(dataRefId);
|
||||
dataRef.subscribe(dataObserver);
|
||||
|
||||
return () => dataRef.unsubscribe(dataObserver);
|
||||
}
|
||||
return _.noop;
|
||||
}, [dataObserver, dataRefId, environment, requestContract]);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment && !currentData.current && _.isFunction(lookupFn)) {
|
||||
const schemaNode = requestContract.getSchemaNode();
|
||||
const { store } = environment;
|
||||
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
|
||||
const dataRef = dataNode.getDataRef(dataRefId);
|
||||
|
||||
try {
|
||||
const overreactIds = getCacheIds({
|
||||
store,
|
||||
requestContract,
|
||||
variables,
|
||||
lookupFn,
|
||||
});
|
||||
|
||||
if (!_.isEmpty(overreactIds)) {
|
||||
updateDataRefWithIds(dataRef, overreactIds);
|
||||
const records = getRecordsById(store, requestContract, overreactIds);
|
||||
currentData.current = getDataFromRecords(records, responseContract);
|
||||
requestRequired.current = false;
|
||||
forceRender();
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO: log error and send request
|
||||
}
|
||||
}
|
||||
// we only try hit cache once when environment is ready
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [environment]);
|
||||
|
||||
useDeepEqualEffect(() => {
|
||||
if (requestRequired.current && environment) {
|
||||
if (!postponeRead) {
|
||||
const request = new OverreactRequest({
|
||||
id: dataRefId,
|
||||
requestContract,
|
||||
spec,
|
||||
variables,
|
||||
data: null,
|
||||
});
|
||||
environment.pushRequest(request);
|
||||
}
|
||||
requestRequired.current = false;
|
||||
}
|
||||
// TODO: need to unregister this request
|
||||
}, [dataCallback, environment, requestContract, spec, variables]);
|
||||
|
||||
return [currentData.current, currentError.current];
|
||||
}
|
|
@ -0,0 +1,258 @@
|
|||
import { useCallback, useRef } from 'react';
|
||||
import { useEnvironment } from '../environment';
|
||||
import { getRecords, getDataRef, getRecordGroup, getRecordsByEntityKey } from '../store';
|
||||
import { specTypes } from '../spec/spec-types';
|
||||
import { OVERREACT_ID_FIELD_NAME } from '../store/consts';
|
||||
import { responseTypes } from '../spec/response-types';
|
||||
import { OverreactRequest } from './overreact-request';
|
||||
import { getMergedConfig } from './merge-config';
|
||||
|
||||
function mutateRecords(
|
||||
store,
|
||||
requestContract,
|
||||
recordsBeforeMutation,
|
||||
newRecords
|
||||
) {
|
||||
if (store) {
|
||||
const recordGroup = getRecordGroup(store, requestContract);
|
||||
|
||||
recordGroup.addOrUpdateRecords(recordsBeforeMutation);
|
||||
recordGroup.addOrUpdateRecords(newRecords);
|
||||
}
|
||||
}
|
||||
|
||||
function addRecords(
|
||||
store,
|
||||
requestContract,
|
||||
records
|
||||
) {
|
||||
if (store && records && records.length > 0) {
|
||||
const recordGroup = getRecordGroup(store, requestContract);
|
||||
recordGroup.addOrUpdateRecords(records);
|
||||
const ids = records.map(record => record[OVERREACT_ID_FIELD_NAME]);
|
||||
|
||||
// notify hooks, so that they can adjust the refIds accordingly
|
||||
recordGroup.notify('entitiesCreated', ids);
|
||||
}
|
||||
}
|
||||
|
||||
function deleteRecords(store, requestContract, records = []) {
|
||||
if (store) {
|
||||
const recordGroup = getRecordGroup(store, requestContract);
|
||||
const ids = records.map(record => record[OVERREACT_ID_FIELD_NAME]);
|
||||
|
||||
recordGroup.deleteRecords(ids);
|
||||
recordGroup.notify('entitiesDeleted', ids);
|
||||
}
|
||||
}
|
||||
|
||||
function addPreemptiveRecords(store, requestContract, request, records) {
|
||||
if (store) {
|
||||
store.addPreemptiveRecords(request, records);
|
||||
addRecords(store, requestContract, records);
|
||||
}
|
||||
}
|
||||
|
||||
function replacePreemptiveRecords(store, requestContract, request, records) {
|
||||
const preemptiveRecords = store.removePreemptiveRecords(request);
|
||||
deleteRecords(store, requestContract, preemptiveRecords);
|
||||
addRecords(store, requestContract, records);
|
||||
}
|
||||
|
||||
function applyId(variables, spec, data, specType) {
|
||||
const { locator } = variables;
|
||||
const { requestContract, responseContract } = spec;
|
||||
const { parentKeySelector } = requestContract;
|
||||
let parentId = parentKeySelector ? parentKeySelector(variables) : undefined;
|
||||
|
||||
const { descriptor, order } = locator;
|
||||
|
||||
if (responseContract.responseType === responseTypes.COLL || specType === specTypes.ADD) {
|
||||
// when requests for COLL or create entity, the parentId is the last element in the locator
|
||||
if (!parentId && order.length > 0) {
|
||||
parentId = descriptor[order[order.length - 1]];
|
||||
}
|
||||
const dataArr = Array.isArray(data) ? data : [data];
|
||||
return dataArr.map(entity => responseContract.applyId(entity, parentId));
|
||||
} else if (responseContract.responseType === responseTypes.ENTITY) {
|
||||
if (!parentId && order.length > 1) {
|
||||
parentId = descriptor[order[order.length - 2]];
|
||||
}
|
||||
|
||||
// step 1 - generate _overreact_id
|
||||
return responseContract.applyId(data, parentId);
|
||||
}
|
||||
|
||||
throw new Error('unknown response type, cannot apply id');
|
||||
}
|
||||
|
||||
export function useMutation(dataRefId, spec, config) {
|
||||
const {
|
||||
requestContract,
|
||||
responseContract,
|
||||
specType,
|
||||
environmentLookupFn,
|
||||
} = spec;
|
||||
const dataItemsBeforeMutation = useRef();
|
||||
const environment = useEnvironment(environmentLookupFn);
|
||||
|
||||
const dataCallback = useCallback((processedResponse, request) => {
|
||||
if (environment) {
|
||||
const { store } = environment;
|
||||
const dataRef = getDataRef(store, requestContract, dataRefId);
|
||||
dataRef.clearError();
|
||||
|
||||
const {
|
||||
onComplete,
|
||||
preemptiveResponseFn,
|
||||
} = (request && request.mergedConfig) || {};
|
||||
|
||||
if (preemptiveResponseFn) {
|
||||
if (specType === specTypes.MUTATION) {
|
||||
const current = dataItemsBeforeMutation.current || [];
|
||||
mutateRecords(
|
||||
store,
|
||||
requestContract,
|
||||
current,
|
||||
processedResponse
|
||||
);
|
||||
} else if (specType === specTypes.ADD) {
|
||||
replacePreemptiveRecords(
|
||||
store,
|
||||
requestContract,
|
||||
request,
|
||||
processedResponse
|
||||
);
|
||||
}
|
||||
} else if (specType === specTypes.ADD) {
|
||||
addRecords(store, requestContract, processedResponse);
|
||||
} else if (specType === specTypes.MUTATION) {
|
||||
mutateRecords(
|
||||
store,
|
||||
requestContract,
|
||||
[],
|
||||
processedResponse
|
||||
);
|
||||
}
|
||||
|
||||
if (specType === specTypes.DELETE) {
|
||||
const { data } = request;
|
||||
const dataArray = Array.isArray(data) ? data : [data];
|
||||
const { keySelector } = responseContract;
|
||||
const keys = dataArray.map(d => keySelector(d));
|
||||
const records = getRecordsByEntityKey(store, spec, keys);
|
||||
const recordsData = records && records.map(record => record.getData());
|
||||
|
||||
deleteRecords(store, requestContract, recordsData);
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(processedResponse);
|
||||
}
|
||||
}
|
||||
}, [dataRefId, environment, requestContract, responseContract, spec, specType]);
|
||||
|
||||
const errorCallback = useCallback((error, request) => {
|
||||
if (environment) {
|
||||
const { store } = environment;
|
||||
const {
|
||||
onError,
|
||||
preemptiveResponseFn,
|
||||
} = (request && request.mergedConfig) || {};
|
||||
|
||||
if (dataItemsBeforeMutation.current) {
|
||||
// revert change
|
||||
mutateRecords(
|
||||
store,
|
||||
requestContract,
|
||||
dataItemsBeforeMutation.current,
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
if (preemptiveResponseFn) {
|
||||
if (specType === specTypes.MUTATION) {
|
||||
const current = dataItemsBeforeMutation.current || [];
|
||||
mutateRecords(
|
||||
store,
|
||||
requestContract,
|
||||
current,
|
||||
[]
|
||||
);
|
||||
} else if (specType === specTypes.ADD) {
|
||||
replacePreemptiveRecords(
|
||||
store,
|
||||
requestContract,
|
||||
request,
|
||||
[]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
}, [environment, requestContract, specType]);
|
||||
|
||||
const mutateFn = useCallback((variables, mutationData, ...rest) => {
|
||||
if (environment) {
|
||||
const { store } = environment;
|
||||
const requestConfig = rest.slice(-1)[0];
|
||||
const mergedConfig = getMergedConfig(requestConfig, config);
|
||||
const { preemptiveResponseFn } = mergedConfig;
|
||||
|
||||
// stash current value. This will be useful when preemptive updates is enabled,
|
||||
// and we need to revert to original state before applying actual response from server.
|
||||
if (dataRefId) {
|
||||
const recordsBeforeMutation = getRecords(store, requestContract, dataRefId);
|
||||
if (recordsBeforeMutation) {
|
||||
dataItemsBeforeMutation.current = recordsBeforeMutation.map(r => r.getData());
|
||||
}
|
||||
}
|
||||
|
||||
const request = new OverreactRequest({
|
||||
id: dataRefId,
|
||||
requestContract,
|
||||
spec,
|
||||
variables,
|
||||
data: mutationData,
|
||||
dataCb: dataCallback,
|
||||
errorCb: errorCallback,
|
||||
mergedConfig,
|
||||
});
|
||||
|
||||
// we can perform preemptive updates right now (before actual request)
|
||||
// the store will be reverted/merged with response in data callbacks - when the response
|
||||
// comes back.
|
||||
if (preemptiveResponseFn) {
|
||||
if (specType === specTypes.MUTATION) {
|
||||
const current = dataItemsBeforeMutation.current || [];
|
||||
mutateRecords(
|
||||
store,
|
||||
requestContract,
|
||||
current,
|
||||
preemptiveResponseFn(current, mutationData)
|
||||
);
|
||||
} else if (specType === specTypes.ADD) {
|
||||
const data = preemptiveResponseFn(mutationData);
|
||||
const records = applyId(request.variables, spec, data, specType);
|
||||
addPreemptiveRecords(
|
||||
store,
|
||||
requestContract,
|
||||
request,
|
||||
records
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register the request to be issued - in this implementation the request will go to the
|
||||
// request queue in DataFetcher, waiting to be invoked. So one shall not assume
|
||||
// the request will go out immediately.
|
||||
environment.pushRequest(request);
|
||||
}
|
||||
// eslint-disable-next-line max-len
|
||||
}, [config, dataCallback, dataRefId, environment, errorCallback, requestContract, spec, specType]);
|
||||
|
||||
return mutateFn;
|
||||
}
|
|
@ -0,0 +1,223 @@
|
|||
import _ from 'underscore';
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
|
||||
import { useEnvironment } from '../environment';
|
||||
import { getDataNode, createDataNode, getRecords, getDataRef, getRecordsById, updateDataRefWithIds } from '../store';
|
||||
import { OVERREACT_ID_FIELD_NAME } from '../store/consts';
|
||||
import { OverreactRequest } from './overreact-request';
|
||||
import { getMergedConfig } from './merge-config';
|
||||
import { getLookupCacheFn } from './helper';
|
||||
import { getCacheIds } from './lookup-cache';
|
||||
|
||||
const getRecordsDataInDataRef = (store, requestContract, dataRefId) => {
|
||||
const records = getRecords(store, requestContract, dataRefId);
|
||||
|
||||
return records && records.map(record => record.getData());
|
||||
};
|
||||
|
||||
const getRawData = data => data && _.map(data, d => _.omit(d, OVERREACT_ID_FIELD_NAME));
|
||||
|
||||
const getRecordsDataById = (store, requestContract, ids) => {
|
||||
const records = getRecordsById(store, requestContract, ids);
|
||||
return records && records.map(record => record.getData());
|
||||
};
|
||||
|
||||
export function usePagination(dataRefId, spec, config) {
|
||||
const {
|
||||
fetchVariables,
|
||||
strictMode = false,
|
||||
mergeNewRecords,
|
||||
lookupCacheByVariables,
|
||||
} = config;
|
||||
const refId = useRef(dataRefId);
|
||||
const { requestContract, environmentLookupFn } = spec;
|
||||
|
||||
const cursorIndex = useRef(0);
|
||||
const loadingId = useRef(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const environment = useEnvironment(environmentLookupFn);
|
||||
|
||||
const [data, setData] = useState(undefined);
|
||||
const [error, setError] = useState(undefined);
|
||||
|
||||
const resetInternalState = useCallback(() => {
|
||||
loadingId.current = null;
|
||||
setIsLoading(false);
|
||||
cursorIndex.current = 0;
|
||||
}, []);
|
||||
|
||||
if (refId.current !== dataRefId) {
|
||||
refId.current = dataRefId;
|
||||
resetInternalState();
|
||||
}
|
||||
|
||||
const setInternalStateOnResponse = useCallback((records) => {
|
||||
cursorIndex.current = records.length;
|
||||
}, []);
|
||||
|
||||
// using registerRequest to fire request, we cannot get the total count
|
||||
// so we set it to true for now
|
||||
const hasMore = useCallback(() => true, []);
|
||||
|
||||
const loadMoreCallback = useCallback((__, updatedIds, request) => {
|
||||
loadingId.current = null;
|
||||
setIsLoading(false);
|
||||
|
||||
const { store } = environment;
|
||||
const recordsData = getRecordsDataInDataRef(store, requestContract, dataRefId);
|
||||
const rawData = getRawData(recordsData);
|
||||
const dataRef = getDataRef(store, requestContract, dataRefId);
|
||||
dataRef.clearError();
|
||||
setInternalStateOnResponse(rawData);
|
||||
setData(rawData);
|
||||
|
||||
const {
|
||||
onComplete,
|
||||
} = (request && request.mergedConfig) || {};
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(rawData);
|
||||
}
|
||||
}, [dataRefId, environment, requestContract, setData, setInternalStateOnResponse]);
|
||||
|
||||
const onErrorCallback = useCallback((__, err, request) => {
|
||||
if (environment) {
|
||||
const { store } = environment;
|
||||
|
||||
const dataRef = getDataRef(store, requestContract, dataRefId);
|
||||
const {
|
||||
status: {
|
||||
error: currentError,
|
||||
} = {},
|
||||
} = dataRef;
|
||||
|
||||
if (currentError) {
|
||||
loadingId.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
setError(currentError);
|
||||
|
||||
const {
|
||||
onError,
|
||||
} = (request && request.mergedConfig) || {};
|
||||
|
||||
if (onError && !_.isUndefined(err)) {
|
||||
onError(err);
|
||||
}
|
||||
}
|
||||
}, [dataRefId, environment, requestContract]);
|
||||
|
||||
const onEntitiesCreated = useCallback((dataRef, newIds) => {
|
||||
if (_.isFunction(mergeNewRecords)) {
|
||||
const { store } = environment;
|
||||
const records = getRecordsDataInDataRef(store, requestContract, dataRefId);
|
||||
const newRecords = getRecordsDataById(store, requestContract, newIds);
|
||||
const recordsToShow = mergeNewRecords(records, newRecords);
|
||||
const ids = recordsToShow.map(record => record[OVERREACT_ID_FIELD_NAME]);
|
||||
|
||||
dataRef.reset(ids);
|
||||
}
|
||||
}, [dataRefId, environment, mergeNewRecords, requestContract]);
|
||||
|
||||
const dataObserver = useMemo(() => ({
|
||||
update: loadMoreCallback,
|
||||
onError: onErrorCallback,
|
||||
onEntitiesCreated,
|
||||
}), [loadMoreCallback, onEntitiesCreated, onErrorCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment) {
|
||||
const schemaNode = requestContract.getSchemaNode();
|
||||
const { store } = environment;
|
||||
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
|
||||
const dataRef = dataNode.getDataRef(dataRefId);
|
||||
dataRef.subscribe(dataObserver);
|
||||
|
||||
return () => dataRef.unsubscribe(dataObserver);
|
||||
}
|
||||
return () => {};
|
||||
}, [dataObserver, dataRefId, environment, requestContract]);
|
||||
|
||||
const loadMore = useCallback((requestConfig) => {
|
||||
if (loadingId.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
return;
|
||||
}
|
||||
const { pageSize } = fetchVariables;
|
||||
const { store } = environment;
|
||||
const recordsData = getRecordsDataInDataRef(store, requestContract, dataRefId);
|
||||
const rawData = getRawData(recordsData);
|
||||
|
||||
if (rawData && rawData.length >= cursorIndex.current + pageSize) {
|
||||
cursorIndex.current = rawData.length;
|
||||
setData(rawData);
|
||||
return;
|
||||
}
|
||||
|
||||
const lookupFn = getLookupCacheFn(lookupCacheByVariables, spec, requestContract.fetchPolicy);
|
||||
|
||||
if (_.isFunction(lookupFn)) {
|
||||
try {
|
||||
const overreactIds = getCacheIds({
|
||||
store,
|
||||
requestContract,
|
||||
variables: fetchVariables,
|
||||
lookupFn,
|
||||
});
|
||||
|
||||
if (!_.isEmpty(overreactIds)) {
|
||||
const dataRef = getDataRef(store, requestContract, dataRefId);
|
||||
|
||||
updateDataRefWithIds(dataRef, overreactIds);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// TODO: log error and send request
|
||||
}
|
||||
}
|
||||
|
||||
const requestVars = {
|
||||
...fetchVariables,
|
||||
cursorIndex: strictMode
|
||||
? cursorIndex.current - (cursorIndex.current % pageSize)
|
||||
: cursorIndex.current,
|
||||
};
|
||||
|
||||
const myId = _.uniqueId();
|
||||
|
||||
loadingId.current = myId;
|
||||
setIsLoading(true);
|
||||
|
||||
const mergedConfig = getMergedConfig(requestConfig, config);
|
||||
const request = new OverreactRequest({
|
||||
id: dataRefId,
|
||||
requestContract,
|
||||
spec,
|
||||
variables: requestVars,
|
||||
data: null,
|
||||
mergedConfig,
|
||||
});
|
||||
environment.pushRequest(request);
|
||||
}, [
|
||||
config,
|
||||
dataRefId,
|
||||
environment,
|
||||
fetchVariables,
|
||||
lookupCacheByVariables,
|
||||
requestContract,
|
||||
spec,
|
||||
strictMode,
|
||||
]);
|
||||
|
||||
const ret = useMemo(() => [{ data, error }, {
|
||||
isLoading,
|
||||
hasMore,
|
||||
loadMore,
|
||||
}], [data, error, hasMore, isLoading, loadMore]);
|
||||
|
||||
return ret;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { useRef, useEffect } from 'react';
|
||||
|
||||
export function usePrevious(value) {
|
||||
const ref = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
|
||||
return ref.current;
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { useMemo } from 'react';
|
||||
import Promise from 'bluebird';
|
||||
|
||||
export function usePromisify(actions) {
|
||||
return useMemo(() =>
|
||||
actions.map(action => (...args) => new Promise((resolve, reject) => {
|
||||
const configBuilder = config => ({
|
||||
...config,
|
||||
onComplete: (processedResponse) => {
|
||||
if (config.onComplete) {
|
||||
config.onComplete(processedResponse);
|
||||
}
|
||||
resolve(processedResponse);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (config.onError) {
|
||||
config.onError(error);
|
||||
}
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
const mergedArgs = [...args, configBuilder];
|
||||
action(...mergedArgs);
|
||||
})), actions); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
import _ from 'underscore';
|
||||
import { useCallback } from 'react';
|
||||
import { useEnvironment } from '../environment';
|
||||
import { FetchPolicy } from '../middleware';
|
||||
import { getRecords, getDataRef, updateDataRefWithIds } from '../store';
|
||||
import { OverreactRequest } from './overreact-request';
|
||||
import { getMergedConfig } from './merge-config';
|
||||
import { getCacheIds } from './lookup-cache';
|
||||
import { getDataFromRecords, getLookupCacheFn } from './helper';
|
||||
|
||||
export function useRefetch(dataRefId, spec, config) {
|
||||
const {
|
||||
requestContract,
|
||||
responseContract,
|
||||
environmentLookupFn,
|
||||
} = spec;
|
||||
const environment = useEnvironment(environmentLookupFn);
|
||||
|
||||
const dataCallback = useCallback((dataItems, request) => {
|
||||
if (environment) {
|
||||
const {
|
||||
onComplete,
|
||||
} = (request && request.mergedConfig) || {};
|
||||
|
||||
const { store } = environment;
|
||||
const records = getRecords(store, requestContract, dataRefId);
|
||||
const data = getDataFromRecords(records, responseContract);
|
||||
|
||||
if (onComplete) {
|
||||
onComplete(data);
|
||||
}
|
||||
}
|
||||
}, [dataRefId, environment, requestContract, responseContract]);
|
||||
|
||||
const errorCallback = useCallback((error, request) => {
|
||||
const {
|
||||
onError = _.noop,
|
||||
} = (request && request.mergedConfig) || {};
|
||||
onError(error);
|
||||
}, []);
|
||||
|
||||
const refetchFn = useCallback((parameter, ...rest) => {
|
||||
if (environment) {
|
||||
const { store } = environment;
|
||||
const requestConfig = rest.slice(-1)[0];
|
||||
const mergedConfig = getMergedConfig(requestConfig, config);
|
||||
const { lookupCacheByVariables } = mergedConfig;
|
||||
|
||||
const {
|
||||
variables,
|
||||
payload,
|
||||
} = parameter;
|
||||
const { options: { fetchPolicy: fetchPolicyInReq } = {} } = variables || {};
|
||||
|
||||
const fetchPolicy =
|
||||
fetchPolicyInReq || requestContract.fetchPolicy || FetchPolicy.NetworkOnly;
|
||||
|
||||
const request = new OverreactRequest({
|
||||
id: dataRefId,
|
||||
requestContract,
|
||||
spec,
|
||||
variables,
|
||||
data: payload,
|
||||
dataCb: dataCallback,
|
||||
errorCb: errorCallback,
|
||||
mergedConfig,
|
||||
});
|
||||
|
||||
const lookupFn = getLookupCacheFn(lookupCacheByVariables, spec, fetchPolicy);
|
||||
|
||||
if (_.isFunction(lookupFn)) {
|
||||
try {
|
||||
const overreactIds = getCacheIds({
|
||||
store,
|
||||
requestContract,
|
||||
variables,
|
||||
lookupFn,
|
||||
});
|
||||
|
||||
if (!_.isEmpty(overreactIds)) {
|
||||
const dataRef = getDataRef(store, requestContract, dataRefId);
|
||||
|
||||
updateDataRefWithIds(dataRef, overreactIds);
|
||||
|
||||
const records = getRecords(store, requestContract, dataRefId);
|
||||
const data = getDataFromRecords(records, responseContract);
|
||||
|
||||
dataCallback(data, request);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO: log error and send request
|
||||
}
|
||||
}
|
||||
environment.pushRequest(request);
|
||||
}
|
||||
}, [
|
||||
config,
|
||||
dataCallback,
|
||||
dataRefId,
|
||||
environment,
|
||||
errorCallback,
|
||||
requestContract,
|
||||
responseContract,
|
||||
spec,
|
||||
]);
|
||||
|
||||
return refetchFn;
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import _ from 'underscore';
|
||||
|
||||
export function createErrorMiddleware(errorMiddlewareOptions) {
|
||||
const {
|
||||
errorProcessor = _.noop,
|
||||
errorConvertor: envErrorConverter = _.noop,
|
||||
} = errorMiddlewareOptions || {};
|
||||
return next => async (req) => {
|
||||
const {
|
||||
spec: {
|
||||
responseContract: {
|
||||
errorConvertFn,
|
||||
} = {},
|
||||
} = {},
|
||||
} = req || {};
|
||||
const errorConvertor = errorConvertFn || envErrorConverter;
|
||||
|
||||
const response = next(req).then((res) => {
|
||||
const error = errorConvertor(res);
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return res;
|
||||
}).catch((error) => {
|
||||
const processedError = errorConvertor(error) || error;
|
||||
errorProcessor(processedError);
|
||||
throw error;
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import _ from 'underscore';
|
||||
|
||||
import {
|
||||
FetchPolicy,
|
||||
IsNetworkPolicy,
|
||||
IsStorePolicy,
|
||||
IsStoreSecondaryPolicy,
|
||||
getFetchPolicy,
|
||||
getDataFromStore,
|
||||
shouldForceNetwork,
|
||||
updateDataRefStatus,
|
||||
DEFAULT_STORE_EXPIRATION_DURATION,
|
||||
} from './fetch-policy-utils';
|
||||
|
||||
export function createFetchPolicyMiddleware(fetchPolicyMiddlewareOptions) {
|
||||
const {
|
||||
fetchPolicy = FetchPolicy.StoreOrNetwork,
|
||||
cacheExpirationDuration = DEFAULT_STORE_EXPIRATION_DURATION,
|
||||
} = fetchPolicyMiddlewareOptions || {};
|
||||
|
||||
return next => async (req) => {
|
||||
const fetchPolicyInReq =
|
||||
(req.variables && req.variables.options && req.variables.options.fetchPolicy)
|
||||
|| req.spec.requestContract.fetchPolicy;
|
||||
const requestFetchPolicy =
|
||||
getFetchPolicy(req.spec.specType, fetchPolicyInReq, fetchPolicy);
|
||||
|
||||
const isStoreSecondaryPolicy = IsStoreSecondaryPolicy(requestFetchPolicy);
|
||||
let dataInStore = null;
|
||||
const isNetworkPolicy = IsNetworkPolicy(requestFetchPolicy);
|
||||
const isStorePolicy = IsStorePolicy(requestFetchPolicy);
|
||||
|
||||
const currentTimestamp = Date.now();
|
||||
const isForceNetwork = shouldForceNetwork({
|
||||
store: req.store,
|
||||
spec: req.spec,
|
||||
dataRefId: req.dataRefId,
|
||||
variables: _.omit(req.variables, 'options'),
|
||||
storeExpirationDuration: cacheExpirationDuration,
|
||||
currentTimestamp,
|
||||
requestFetchPolicy,
|
||||
});
|
||||
|
||||
if (isStorePolicy && !isForceNetwork && !isStoreSecondaryPolicy) {
|
||||
dataInStore = getDataFromStore(req.store, req.spec, req.dataRefId);
|
||||
if (!_.isEmpty(dataInStore)) {
|
||||
req.middlewareStates.isResponseFromStore = true;
|
||||
return dataInStore;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNetworkPolicy) {
|
||||
const res = await next(req)
|
||||
.then((response) => {
|
||||
updateDataRefStatus({
|
||||
store: req.store,
|
||||
spec: req.spec,
|
||||
dataRefId: req.dataRefId,
|
||||
variables: _.omit(req.variables, 'options'),
|
||||
currentTimestamp,
|
||||
});
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
// we only try roll back to previous data if policy is networkOrStore
|
||||
// if policy is storeOrNetwork and we go here, it means we already miss the cache
|
||||
// or we decided to not trust the cache.
|
||||
if (requestFetchPolicy === FetchPolicy.NetworkOrStore) {
|
||||
dataInStore = getDataFromStore(req.store, req.spec, req.dataRefId);
|
||||
if (!_.isEmpty(dataInStore)) {
|
||||
req.middlewareStates.isResponseFromStore = true;
|
||||
return dataInStore;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
return next(req);
|
||||
};
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import _ from 'underscore';
|
||||
import stringify from 'json-stable-stringify';
|
||||
|
||||
import { getRecords, getDataRef } from '../../store';
|
||||
import { specTypes } from '../../spec/spec-types';
|
||||
import { responseTypes } from '../../spec/response-types';
|
||||
|
||||
export const DEFAULT_STORE_EXPIRATION_DURATION = 5 * 60 * 1000;
|
||||
|
||||
export const FetchPolicy = {
|
||||
StoreOnly: 'StoreOnly',
|
||||
NetworkOnly: 'NetworkOnly',
|
||||
StoreOrNetwork: 'StoreOrNetwork',
|
||||
NetworkOrStore: 'NetworkOrStore',
|
||||
};
|
||||
|
||||
export function IsNetworkPolicy(fetchPolicy) {
|
||||
return fetchPolicy === FetchPolicy.NetworkOnly
|
||||
|| fetchPolicy === FetchPolicy.NetworkOrStore
|
||||
|| fetchPolicy === FetchPolicy.StoreOrNetwork;
|
||||
}
|
||||
|
||||
export function IsStorePolicy(fetchPolicy) {
|
||||
return fetchPolicy === FetchPolicy.StoreOnly
|
||||
|| fetchPolicy === FetchPolicy.StoreOrNetwork
|
||||
|| fetchPolicy === FetchPolicy.NetworkOrStore;
|
||||
}
|
||||
|
||||
export function IsStoreSecondaryPolicy(fetchPolicy) {
|
||||
return fetchPolicy === FetchPolicy.NetworkOrStore;
|
||||
}
|
||||
|
||||
export function getFetchPolicy(specType, fetchPolicyOption, fetchPolicyInEnv) {
|
||||
if (specType === specTypes.FETCH
|
||||
|| specType === specTypes.PAGINATION
|
||||
|| fetchPolicyOption !== FetchPolicy.StoreOnly) {
|
||||
return fetchPolicyOption || fetchPolicyInEnv;
|
||||
}
|
||||
|
||||
return fetchPolicyInEnv;
|
||||
}
|
||||
|
||||
export function getDataFromStore(store, spec, dataRefId) {
|
||||
const { requestContract } = spec;
|
||||
const records = getRecords(store, requestContract, dataRefId);
|
||||
|
||||
const dataInStore = _.map(records, record => record.getData());
|
||||
if (spec.responseContract.responseType === responseTypes.ENTITY
|
||||
&& !_.isEmpty(dataInStore) && _.isArray(dataInStore)) {
|
||||
return dataInStore[0];
|
||||
}
|
||||
return dataInStore;
|
||||
}
|
||||
|
||||
export function shouldForceNetwork({
|
||||
store, spec, dataRefId, variables, storeExpirationDuration, currentTimestamp, requestFetchPolicy,
|
||||
}) {
|
||||
const { requestContract } = spec;
|
||||
const dataRef = getDataRef(store, requestContract, dataRefId);
|
||||
const {
|
||||
status: {
|
||||
previousVariables,
|
||||
lastUpdateTimestamp,
|
||||
} = {},
|
||||
} = dataRef || {};
|
||||
return requestFetchPolicy !== FetchPolicy.StoreOnly
|
||||
&& (stringify(variables) !== stringify(previousVariables)
|
||||
|| currentTimestamp - lastUpdateTimestamp > storeExpirationDuration);
|
||||
}
|
||||
|
||||
export function updateDataRefStatus({
|
||||
store, spec, dataRefId, variables, currentTimestamp,
|
||||
}) {
|
||||
const { requestContract } = spec;
|
||||
const dataRef = getDataRef(store, requestContract, dataRefId);
|
||||
if (dataRef) {
|
||||
dataRef.updateStatus({
|
||||
previousVariables: variables,
|
||||
lastUpdateTimestamp: currentTimestamp,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export { createFetchPolicyMiddleware } from './fetch-policy/fetch-policy-middleware';
|
||||
export { FetchPolicy } from './fetch-policy/fetch-policy-utils';
|
||||
export { requestWithMiddleware } from './request-with-middleware';
|
||||
export { WrappedRequestor } from './wrapped-requestor';
|
||||
export { middlewareTypes } from './middleware-types';
|
||||
export { createErrorMiddleware } from './error/error-middleware';
|
||||
export { createInstrumentationMiddleware } from './instrumentation/instrumentation-middleware';
|
|
@ -0,0 +1,23 @@
|
|||
import _ from 'underscore';
|
||||
|
||||
import { defaultStubOptions } from './instrumentation-utils';
|
||||
|
||||
export class InstrumentationContext {
|
||||
constructor(parameter) {
|
||||
const {
|
||||
pageTrackingId,
|
||||
errorMappers,
|
||||
url,
|
||||
requestId,
|
||||
httpMethod,
|
||||
stubOptions,
|
||||
} = parameter;
|
||||
|
||||
this.pageTrackingId = pageTrackingId;
|
||||
this.errorMappers = errorMappers;
|
||||
this.url = url;
|
||||
this.requestId = requestId;
|
||||
this.httpMethod = httpMethod;
|
||||
this.stubOptions = _.defaults(stubOptions || {}, defaultStubOptions);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
import _ from 'underscore';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { InstrumentationContext } from './instrumentation-context';
|
||||
import { beforeSendHandler, successHandler, errorHandler } from './instrumentation-utils';
|
||||
|
||||
export function createInstrumentationMiddleware(instrumentationOptions) {
|
||||
const {
|
||||
pageTrackingId,
|
||||
errorMappers,
|
||||
stubOptions,
|
||||
loggerFunc: {
|
||||
traceFunc = _.noop,
|
||||
errorFunc = _.noop,
|
||||
perfFunc = _.noop,
|
||||
} = {},
|
||||
} = instrumentationOptions || {};
|
||||
|
||||
const shouldAddHeaders = instrumentationOptions.shouldAddHeaders || _.constant(true);
|
||||
|
||||
function isUserError(instrumentationContext) {
|
||||
return _.any(errorMappers, errorMapper => errorMapper.check(instrumentationContext));
|
||||
}
|
||||
|
||||
return next => async (req) => {
|
||||
const {
|
||||
header: {
|
||||
'x-ms-requestid': requestId = uuidv4(),
|
||||
} = {},
|
||||
} = req;
|
||||
|
||||
const instrumentationContext = new InstrumentationContext({
|
||||
pageTrackingId,
|
||||
errorMappers,
|
||||
url: req.uri,
|
||||
requestId,
|
||||
httpMethod: req.spec.requestContract.verb,
|
||||
stubOptions,
|
||||
});
|
||||
|
||||
beforeSendHandler(instrumentationContext, req, perfFunc, shouldAddHeaders);
|
||||
|
||||
const response = next(req).then((res) => {
|
||||
successHandler(instrumentationContext, res, errorFunc, perfFunc);
|
||||
return res;
|
||||
}).catch((error) => {
|
||||
errorHandler(instrumentationContext, error, isUserError, traceFunc, errorFunc, perfFunc);
|
||||
throw error;
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
import _ from 'underscore';
|
||||
|
||||
import { getTimestamp } from '../../utils/primitive-utilities';
|
||||
|
||||
export const defaultStubOptions = {
|
||||
serverErrorCodes: [-1],
|
||||
|
||||
detectError(respData, serverErrorCodes) {
|
||||
const result = {
|
||||
pass: true,
|
||||
};
|
||||
|
||||
if (respData &&
|
||||
respData.Errors &&
|
||||
respData.Errors[0] &&
|
||||
respData.Errors[0].Code &&
|
||||
_.isArray(serverErrorCodes) &&
|
||||
_.contains(serverErrorCodes, respData.Errors[0].Code)) {
|
||||
result.pass = false;
|
||||
result.message = respData.Errors[0].Message;
|
||||
result.impactUser = true;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getServerPerf(response) {
|
||||
if (response && _.isFunction(response.getResponseHeader)) {
|
||||
const perfTimings = response.getResponseHeader('PerfTimings');
|
||||
if (perfTimings) {
|
||||
return perfTimings;
|
||||
}
|
||||
const perf = {};
|
||||
_.each(
|
||||
['x-ms-mte2eelapsedtimems', 'x-ms-odataapie2eelapsedtimems', 'x-ms-odataapionlye2eelapsedtimems'],
|
||||
(header) => {
|
||||
const value = response.getResponseHeader(header);
|
||||
if (value) {
|
||||
perf[header] = value;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return JSON.stringify(perf);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
};
|
||||
|
||||
export function beforeSendHandler(instrumentationContext, req, perfFunc, shouldAddHeaders) {
|
||||
instrumentationContext.requestStartTime = getTimestamp();
|
||||
|
||||
if (shouldAddHeaders(instrumentationContext) === true) {
|
||||
req.header = {
|
||||
...req.header,
|
||||
'x-ms-pagetrackingid': instrumentationContext.pageTrackingId,
|
||||
'x-ms-lcid': instrumentationContext.stubOptions.lcid,
|
||||
lcid: instrumentationContext.stubOptions.lcid,
|
||||
'x-ms-requestid': instrumentationContext.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
perfFunc({
|
||||
requestId: instrumentationContext.requestId,
|
||||
api: instrumentationContext.url,
|
||||
isMethodEnter: true,
|
||||
httpMethod: instrumentationContext.httpMethod,
|
||||
timeTaken: 0,
|
||||
pass: true,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
|
||||
export function successHandler(instrumentationContext, response, errorFunc, perfFunc) {
|
||||
const requestTimeTaken = getTimestamp() - instrumentationContext.requestStartTime;
|
||||
const result = instrumentationContext.stubOptions.detectError(response);
|
||||
instrumentationContext.requestTimeTaken = requestTimeTaken;
|
||||
|
||||
if (!result.pass) {
|
||||
instrumentationContext.requestResult = false;
|
||||
instrumentationContext.error = result.message;
|
||||
|
||||
errorFunc({
|
||||
message: instrumentationContext.error,
|
||||
api: instrumentationContext.url,
|
||||
requestId: instrumentationContext.requestId,
|
||||
impactUser: result.impactUser,
|
||||
httpMethod: instrumentationContext.httpMethod,
|
||||
});
|
||||
}
|
||||
|
||||
perfFunc({
|
||||
requestId: instrumentationContext.requestId,
|
||||
api: instrumentationContext.url,
|
||||
isMethodEnter: false,
|
||||
httpMethod: instrumentationContext.httpMethod,
|
||||
timeTaken: instrumentationContext.requestTimeTaken,
|
||||
pass: instrumentationContext.requestResult,
|
||||
message: instrumentationContext.stubOptions.getServerPerf(response),
|
||||
});
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
instrumentationContext, error, isUserError,
|
||||
traceFunc, errorFunc, perfFunc
|
||||
) {
|
||||
const requestTimeTaken = getTimestamp() - instrumentationContext.requestStartTime;
|
||||
instrumentationContext.requestTimeTaken = requestTimeTaken;
|
||||
instrumentationContext.responseJSON = error.responseJSON;
|
||||
|
||||
if (error.status !== 0 && error.status) {
|
||||
instrumentationContext.requestResult = false;
|
||||
|
||||
instrumentationContext.error = `Overreact server response error: [${error.status}]`;
|
||||
|
||||
if (error.textStatus && error.textStatus.trim() !== '') {
|
||||
instrumentationContext.error += (`, ${error.textStatus}`);
|
||||
if (error.responseText && error.responseText.trim() !== '') {
|
||||
instrumentationContext.error += (`, ${error.responseText}`);
|
||||
} else if (error.responseXML && error.responseXML.trim() !== '') {
|
||||
instrumentationContext.error += (`, ${error.responseXML}`);
|
||||
}
|
||||
}
|
||||
|
||||
switch (error.status) {
|
||||
case 400:
|
||||
case 404:
|
||||
// If error maps to a user error, log at trace level
|
||||
if (isUserError(instrumentationContext)) {
|
||||
instrumentationContext.requestResult = true;
|
||||
}
|
||||
break;
|
||||
case 401:
|
||||
// Log 401 unauthorized error at trace level
|
||||
instrumentationContext.requestResult = true;
|
||||
break;
|
||||
default:
|
||||
instrumentationContext.requestResult = false;
|
||||
}
|
||||
} else {
|
||||
instrumentationContext.requestResult = false;
|
||||
instrumentationContext.error = `Overreact general error: [${JSON.stringify(error)}]`;
|
||||
}
|
||||
|
||||
const logFunc = instrumentationContext.requestResult ? traceFunc : errorFunc;
|
||||
logFunc({
|
||||
message: instrumentationContext.error,
|
||||
api: instrumentationContext.url,
|
||||
requestId: instrumentationContext.requestId,
|
||||
httpMethod: instrumentationContext.httpMethod,
|
||||
});
|
||||
|
||||
perfFunc({
|
||||
requestId: instrumentationContext.requestId,
|
||||
api: instrumentationContext.url,
|
||||
isMethodEnter: false,
|
||||
httpMethod: instrumentationContext.httpMethod,
|
||||
timeTaken: instrumentationContext.requestTimeTaken,
|
||||
pass: instrumentationContext.requestResult,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export const middlewareTypes = {
|
||||
ERROR: 'ERROR',
|
||||
FETCH_POLICY: 'FETCH_POLICY',
|
||||
INSTRUMENTATION: 'INSTRUMENTATION',
|
||||
};
|
|
@ -0,0 +1,20 @@
|
|||
import { compose, omit, values, pick } from 'underscore';
|
||||
import { middlewareTypes } from './middleware-types';
|
||||
|
||||
function executeRequestor(wrappedRequestor) {
|
||||
return wrappedRequestor.executeRequest();
|
||||
}
|
||||
|
||||
export function requestWithMiddleware(wrappedRequestor, middlewares) {
|
||||
const baseMiddlewares = values(omit(middlewares, middlewareTypes.INSTRUMENTATION));
|
||||
const instrumentationMiddleware = values(pick(middlewares, middlewareTypes.INSTRUMENTATION));
|
||||
const wrappedRequest =
|
||||
compose(...baseMiddlewares, ...instrumentationMiddleware)(executeRequestor);
|
||||
|
||||
// eslint-disable-next-line arrow-body-style
|
||||
return wrappedRequest(wrappedRequestor).then((response) => {
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
export class WrappedRequestor {
|
||||
constructor(parameter) {
|
||||
const {
|
||||
requestor,
|
||||
uri,
|
||||
verb,
|
||||
header,
|
||||
payload,
|
||||
spec,
|
||||
variables,
|
||||
store,
|
||||
dataRefId,
|
||||
middlewareStates,
|
||||
} = parameter;
|
||||
|
||||
this.requestor = requestor;
|
||||
this.uri = uri;
|
||||
this.verb = verb;
|
||||
this.header = header;
|
||||
this.payload = payload;
|
||||
this.variables = variables;
|
||||
this.spec = spec;
|
||||
this.store = store;
|
||||
this.dataRefId = dataRefId;
|
||||
this.middlewareStates = middlewareStates;
|
||||
|
||||
this.executeRequest = this.executeRequest.bind(this);
|
||||
}
|
||||
|
||||
executeRequest() {
|
||||
return this.requestor(this.uri, this.verb, this.header, this.payload);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import { SchemaNode } from './schema-node';
|
||||
|
||||
const PATH_DELIMITER = ':';
|
||||
|
||||
export class Schema {
|
||||
constructor(schemaToModelMapping, modelGetter) {
|
||||
this.root = new SchemaNode('$ROOT', null, null);
|
||||
this.schemaToModelMapping = schemaToModelMapping;
|
||||
this.modelGetter = modelGetter;
|
||||
|
||||
this.modelToSchemaMapping = {};
|
||||
|
||||
this.buildModelToSchemaMap();
|
||||
|
||||
this.insert = this.insert.bind(this);
|
||||
this.getSchemaNames = this.getSchemaNames.bind(this);
|
||||
}
|
||||
|
||||
buildModelToSchemaMap() {
|
||||
const keys = Object.keys(this.schemaToModelMapping);
|
||||
keys.forEach((schemaName) => {
|
||||
const modelName = this.schemaToModelMapping[schemaName];
|
||||
if (!this.modelToSchemaMapping[modelName]) {
|
||||
this.modelToSchemaMapping[modelName] = [];
|
||||
}
|
||||
this.modelToSchemaMapping[modelName].push(schemaName);
|
||||
});
|
||||
}
|
||||
|
||||
getSchemaNames(modelName) {
|
||||
return this.modelToSchemaMapping[modelName];
|
||||
}
|
||||
|
||||
insert(path) {
|
||||
const parts = path.split(PATH_DELIMITER);
|
||||
let currentNode = this.root;
|
||||
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const partName = parts[i];
|
||||
|
||||
let existingPathFound = false;
|
||||
for (let j = 0; j < currentNode.childNodes.length; j += 1) {
|
||||
const t = currentNode.childNodes[j];
|
||||
if (t.name === partName) {
|
||||
currentNode = t;
|
||||
existingPathFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingPathFound) {
|
||||
const modelName = this.schemaToModelMapping[partName];
|
||||
const modelSchema = this.modelGetter(this.schemaToModelMapping[partName]);
|
||||
const newNode = new SchemaNode(partName, modelName, currentNode, modelSchema);
|
||||
|
||||
currentNode.childNodes.push(newNode);
|
||||
currentNode = newNode;
|
||||
}
|
||||
}
|
||||
|
||||
return currentNode;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
export class SchemaNode {
|
||||
constructor(name, modelName, parentNode, modelSchema) {
|
||||
this.name = name;
|
||||
this.childNodes = [];
|
||||
this.parentNode = parentNode;
|
||||
this.modelName = modelName;
|
||||
this.modelSchema = modelSchema;
|
||||
|
||||
// extensions will serve as an extension point
|
||||
// where we can attach arbitrary information to
|
||||
// a schema node.
|
||||
// this is useful when we want to extend the schema
|
||||
// tree to include data.
|
||||
this.extensions = {};
|
||||
|
||||
this.append = this.append.bind(this);
|
||||
this.setExtension = this.setExtension.bind(this);
|
||||
this.getExtension = this.getExtension.bind(this);
|
||||
}
|
||||
|
||||
append(node) {
|
||||
this.childNodes.push(node);
|
||||
}
|
||||
|
||||
setExtension(name, ext) {
|
||||
this.extensions[name] = ext;
|
||||
}
|
||||
|
||||
getExtension(name) {
|
||||
return this.extensions[name];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export * from './response-types';
|
||||
export * from './spec-types';
|
||||
export * from './request-verbs';
|
||||
|
||||
export * from './request-contract';
|
||||
export * from './response-contract';
|
||||
export * from './spec';
|
|
@ -0,0 +1,63 @@
|
|||
export class RequestContract {
|
||||
constructor({
|
||||
schema,
|
||||
dataPath,
|
||||
verb,
|
||||
fetchPolicy,
|
||||
uriFactoryFn,
|
||||
headerFactoryFn,
|
||||
payloadFactoryFn,
|
||||
keySelector,
|
||||
parentKeySelector,
|
||||
}) {
|
||||
this.schema = schema;
|
||||
this.dataPath = dataPath;
|
||||
this.verb = verb;
|
||||
this.fetchPolicy = fetchPolicy;
|
||||
this.uriFactoryFn = uriFactoryFn;
|
||||
this.headerFactoryFn = headerFactoryFn;
|
||||
this.payloadFactoryFn = payloadFactoryFn;
|
||||
this.keySelector = keySelector;
|
||||
this.parentKeySelector = parentKeySelector;
|
||||
|
||||
this.getSchemaNode = this.getSchemaNode.bind(this);
|
||||
}
|
||||
|
||||
getSchemaNode() {
|
||||
// schema is the root of the schema tree that current app has built
|
||||
// need to either find an existing path in the tree,
|
||||
// or construct a new path in the tree
|
||||
|
||||
// note that the schema tree is only a sub-tree (or more precisely a sub-graph)
|
||||
// of the original data schema, such as one found in OData.
|
||||
return this.schema.insert(this.dataPath);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRequestContract({
|
||||
schema,
|
||||
dataPath,
|
||||
verb,
|
||||
fetchPolicy,
|
||||
uriFactoryFn,
|
||||
headerFactoryFn,
|
||||
payloadFactoryFn,
|
||||
keySelector,
|
||||
parentKeySelector,
|
||||
}) {
|
||||
if (!dataPath) {
|
||||
throw new Error('dataPath cannot be empty');
|
||||
}
|
||||
|
||||
return new RequestContract({
|
||||
schema,
|
||||
dataPath,
|
||||
verb,
|
||||
fetchPolicy,
|
||||
uriFactoryFn,
|
||||
headerFactoryFn,
|
||||
payloadFactoryFn,
|
||||
keySelector,
|
||||
parentKeySelector,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
export const requestVerbs = {
|
||||
GET: 'GET',
|
||||
POST: 'POST',
|
||||
DELETE: 'DELETE',
|
||||
PATCH: 'PATCH',
|
||||
PUT: 'PUT',
|
||||
};
|
|
@ -0,0 +1,94 @@
|
|||
/* eslint-disable no-console */
|
||||
/* eslint-disable max-len */
|
||||
import { OVERREACT_ID_FIELD_NAME, createOverreactId } from '../store/consts';
|
||||
import { specTypes } from './spec-types';
|
||||
|
||||
import fetchResponseHandler from './response-handler/fetch';
|
||||
import mutationResponseHandler from './response-handler/mutation';
|
||||
import refetchResponseHandler from './response-handler/refetch';
|
||||
import errorResponseHandler from './response-handler/error';
|
||||
|
||||
export class ResponseContract {
|
||||
constructor({
|
||||
requestContract, responseType, keySelector, processorFn, errorConvertFn,
|
||||
}) {
|
||||
this.requestContract = requestContract;
|
||||
this.responseType = responseType;
|
||||
this.keySelector = keySelector;
|
||||
this.processorFn = processorFn;
|
||||
this.errorConvertFn = errorConvertFn;
|
||||
this.schemaNode = this.requestContract.getSchemaNode();
|
||||
|
||||
this.selectKey = this.selectKey.bind(this);
|
||||
this.onGetResponse = this.onGetResponse.bind(this);
|
||||
this.onGetError = this.onGetError.bind(this);
|
||||
|
||||
this.applyId = this.applyId.bind(this);
|
||||
}
|
||||
|
||||
selectKey(variables) {
|
||||
return this.keySelector(variables);
|
||||
}
|
||||
|
||||
applyId(entity, parentId) {
|
||||
// create a new OVERREACT_ID_FIELD in the entity object,
|
||||
// to uniquely identify a record.
|
||||
// Basically we'll append the entity's parent type and ID
|
||||
// to the ID of the entity itself, which will comes handy
|
||||
// during the clean-up if the parent entity is deleted.
|
||||
|
||||
const overreactId = createOverreactId(
|
||||
this.keySelector(entity),
|
||||
parentId,
|
||||
this.schemaNode.parentNode.name
|
||||
);
|
||||
|
||||
return {
|
||||
...entity,
|
||||
[OVERREACT_ID_FIELD_NAME]: overreactId,
|
||||
};
|
||||
}
|
||||
|
||||
onGetResponse(environment, response, request) {
|
||||
let processedResponse = response;
|
||||
const { spec } = request;
|
||||
const { specType } = spec;
|
||||
|
||||
if (!request.middlewareStates.isResponseFromStore && this.processorFn) {
|
||||
processedResponse = this.processorFn(response, request);
|
||||
}
|
||||
|
||||
switch (specType) {
|
||||
case specTypes.FETCH:
|
||||
case specTypes.PAGINATION: {
|
||||
return fetchResponseHandler(environment, processedResponse, request)(this);
|
||||
}
|
||||
case specTypes.ADD:
|
||||
case specTypes.DELETE:
|
||||
case specTypes.MUTATION: {
|
||||
return mutationResponseHandler(environment, processedResponse, request)(this);
|
||||
}
|
||||
case specTypes.REFETCH: {
|
||||
return refetchResponseHandler(environment, processedResponse, request)(this);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
onGetError(environment, request, error) {
|
||||
return errorResponseHandler(environment, request, error)(this);
|
||||
}
|
||||
}
|
||||
|
||||
export function createResponseContract({
|
||||
requestContract,
|
||||
responseType,
|
||||
keySelector,
|
||||
processorFn,
|
||||
errorConvertFn,
|
||||
}) {
|
||||
return new ResponseContract({
|
||||
requestContract, responseType, keySelector, processorFn, errorConvertFn,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import _ from 'underscore';
|
||||
|
||||
import { getDataNode, createDataNode } from '../../store';
|
||||
|
||||
export default function handler(environment, request, error) {
|
||||
return (context) => {
|
||||
const { id: dataRefId, errorCb } = request;
|
||||
|
||||
const { store } = environment;
|
||||
|
||||
let dataNode = getDataNode(context.schemaNode);
|
||||
|
||||
if (!dataNode) {
|
||||
dataNode = createDataNode(context.schemaNode, store);
|
||||
}
|
||||
|
||||
const dataRef = dataNode.getDataRef(dataRefId);
|
||||
dataRef.onError(error, request);
|
||||
|
||||
if (_.isFunction(errorCb)) {
|
||||
errorCb(error, request);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import _ from 'underscore';
|
||||
import { getDataNode, createDataNode } from '../../store';
|
||||
import { responseTypes } from '../response-types';
|
||||
import { OVERREACT_ID_FIELD_NAME } from '../../store/consts';
|
||||
import { getSideEffectCacheStoreHelpers } from './sideEffectFnHelper';
|
||||
|
||||
export default function handler(environment, processedResponse, request) {
|
||||
return (context) => {
|
||||
const { id: dataRefId, variables, spec } = request;
|
||||
const { locator } = variables;
|
||||
|
||||
const { store } = environment;
|
||||
|
||||
let dataNode = getDataNode(context.schemaNode);
|
||||
|
||||
if (!dataNode) {
|
||||
dataNode = createDataNode(context.schemaNode, store);
|
||||
}
|
||||
|
||||
const dataRef = dataNode.getDataRef(dataRefId);
|
||||
dataRef.clearError();
|
||||
|
||||
const { descriptor, order } = locator;
|
||||
const { requestContract, sideEffectFn } = spec;
|
||||
const { parentKeySelector } = requestContract;
|
||||
let parentId = parentKeySelector ? parentKeySelector(variables) : undefined;
|
||||
let dataWithId = null;
|
||||
|
||||
// after the response has been processed, it will either be
|
||||
// - a single entity
|
||||
// - an array of entities
|
||||
if (context.responseType === responseTypes.ENTITY) {
|
||||
if (!parentId && order.length > 1) {
|
||||
parentId = descriptor[order[order.length - 2]];
|
||||
}
|
||||
|
||||
// step 1 - generate _overreact_id
|
||||
dataWithId = context.applyId(processedResponse, parentId);
|
||||
const overreactId = dataWithId[OVERREACT_ID_FIELD_NAME];
|
||||
|
||||
// step 2 - add/merge _overreact_id to current dataRef
|
||||
dataRef.add(overreactId);
|
||||
|
||||
// step 3 - insert the data into store, this will trigger callbacks
|
||||
// in data nodes, which will in turn trigger dataRef refresh.
|
||||
store.getRecordGroup(context.schemaNode.modelName)
|
||||
.addOrUpdateRecords([dataWithId], request);
|
||||
} else if (context.responseType === responseTypes.COLL) {
|
||||
// when requests for COLL, the parentId is the last element in the locator
|
||||
if (!parentId && order.length > 0) {
|
||||
parentId = descriptor[order[order.length - 1]];
|
||||
}
|
||||
dataWithId = processedResponse.map((entity) => {
|
||||
const data = context.applyId(entity, parentId);
|
||||
const overreactId = data[OVERREACT_ID_FIELD_NAME];
|
||||
|
||||
dataRef.add(overreactId);
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
store.getRecordGroup(context.schemaNode.modelName)
|
||||
.addOrUpdateRecords(dataWithId, request);
|
||||
|
||||
// when the return value is empty, we still need to notify the hook who trigger this call
|
||||
if (dataWithId.length === 0) {
|
||||
dataRef.notify('update', [], request);
|
||||
}
|
||||
}
|
||||
|
||||
if (sideEffectFn && !_.isEmpty(dataWithId)) {
|
||||
const cacheStoreHelper = getSideEffectCacheStoreHelpers(environment);
|
||||
sideEffectFn(dataWithId, request, spec, cacheStoreHelper);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { responseTypes } from '../response-types';
|
||||
import { specTypes } from '../spec-types';
|
||||
import { getSideEffectCacheStoreHelpers } from './sideEffectFnHelper';
|
||||
|
||||
export default function handler(environment, processedResponse, request) {
|
||||
return (context) => {
|
||||
const { variables, dataCb, spec } = request;
|
||||
const { locator } = variables;
|
||||
const { requestContract, sideEffectFn } = spec;
|
||||
const { parentKeySelector } = requestContract;
|
||||
let parentId = parentKeySelector ? parentKeySelector(variables) : undefined;
|
||||
let dataWithId = null;
|
||||
|
||||
const { descriptor, order } = locator;
|
||||
if (context.responseType === responseTypes.COLL || request.spec.specType === specTypes.ADD) {
|
||||
// when requests for COLL or create entity, the parentId is the last element in the locator
|
||||
if (!parentId && order.length > 0) {
|
||||
parentId = descriptor[order[order.length - 1]];
|
||||
}
|
||||
const responseArr = Array.isArray(processedResponse)
|
||||
? processedResponse
|
||||
: [processedResponse];
|
||||
dataWithId = responseArr.map(entity => context.applyId(entity, parentId));
|
||||
|
||||
dataCb(dataWithId, request);
|
||||
} else if (context.responseType === responseTypes.ENTITY) {
|
||||
if (!parentId && order.length > 1) {
|
||||
parentId = descriptor[order[order.length - 2]];
|
||||
}
|
||||
|
||||
// step 1 - generate _overreact_id
|
||||
dataWithId = context.applyId(processedResponse, parentId);
|
||||
|
||||
dataCb([dataWithId], request);
|
||||
} else if (context.responseType === responseTypes.NONE) {
|
||||
// TODO: we need to deal with DELETE
|
||||
dataCb(null, request);
|
||||
}
|
||||
|
||||
if (sideEffectFn) {
|
||||
const cacheStoreHelper = getSideEffectCacheStoreHelpers(environment);
|
||||
sideEffectFn(dataWithId, request, spec, cacheStoreHelper);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import { getDataNode, createDataNode } from '../../store';
|
||||
import { responseTypes } from '../response-types';
|
||||
import { OVERREACT_ID_FIELD_NAME } from '../../store/consts';
|
||||
import { getSideEffectCacheStoreHelpers } from './sideEffectFnHelper';
|
||||
|
||||
export default function handler(environment, processedResponse, request) {
|
||||
return (context) => {
|
||||
const {
|
||||
id: dataRefId,
|
||||
variables,
|
||||
dataCb,
|
||||
spec,
|
||||
} = request;
|
||||
const { locator } = variables;
|
||||
const { store } = environment;
|
||||
|
||||
let dataNode = getDataNode(context.schemaNode);
|
||||
|
||||
if (!dataNode) {
|
||||
dataNode = createDataNode(context.schemaNode, store);
|
||||
}
|
||||
|
||||
const dataRef = dataNode.getDataRef(dataRefId);
|
||||
dataRef.clear();
|
||||
|
||||
const { descriptor, order } = locator;
|
||||
const { requestContract, sideEffectFn } = spec;
|
||||
const { parentKeySelector } = requestContract;
|
||||
let parentId = parentKeySelector ? parentKeySelector(variables) : undefined;
|
||||
let dataWithId = null;
|
||||
|
||||
if (context.responseType === responseTypes.ENTITY) {
|
||||
if (!parentId && order.length > 1) {
|
||||
parentId = descriptor[order[order.length - 2]];
|
||||
}
|
||||
|
||||
dataWithId = context.applyId(processedResponse, parentId);
|
||||
const overreactId = dataWithId[OVERREACT_ID_FIELD_NAME];
|
||||
|
||||
dataRef.add(overreactId);
|
||||
|
||||
store.getRecordGroup(context.schemaNode.modelName)
|
||||
.addOrUpdateRecords([dataWithId], request);
|
||||
|
||||
dataCb(dataWithId, request);
|
||||
} else if (context.responseType === responseTypes.COLL) {
|
||||
if (!parentId && order.length > 0) {
|
||||
parentId = descriptor[order[order.length - 1]];
|
||||
}
|
||||
dataWithId = processedResponse.map((entity) => {
|
||||
const data = context.applyId(entity, parentId);
|
||||
const overreactId = data[OVERREACT_ID_FIELD_NAME];
|
||||
|
||||
dataRef.add(overreactId);
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
store.getRecordGroup(context.schemaNode.modelName)
|
||||
.addOrUpdateRecords(dataWithId, request);
|
||||
|
||||
dataCb(dataWithId, request);
|
||||
}
|
||||
|
||||
if (sideEffectFn) {
|
||||
const cacheStoreHelper = getSideEffectCacheStoreHelpers(environment);
|
||||
sideEffectFn(dataWithId, request, spec, cacheStoreHelper);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import _ from 'underscore';
|
||||
import { OVERREACT_ID_FIELD_NAME, createOverreactId } from '../../store/consts';
|
||||
|
||||
export function getSideEffectCacheStoreHelpers(environment) {
|
||||
const { store, schema: { schemaToModelMapping } } = environment;
|
||||
const tryMergeItemsToCacheStore = ({
|
||||
items,
|
||||
schemaName,
|
||||
itemKeySelector,
|
||||
parentSchameName,
|
||||
parentId,
|
||||
}) => {
|
||||
const modelName = schemaToModelMapping[schemaName];
|
||||
const recordGroup = store.getRecordGroup(modelName);
|
||||
const keysInCache = recordGroup.records.map(r => itemKeySelector(r.data));
|
||||
const itemsToAdd = _.filter(items, item => !_.include(keysInCache, itemKeySelector(item)));
|
||||
const itemsToAddWithOverreactId = itemsToAdd.map((item) => {
|
||||
const overreactId = createOverreactId(
|
||||
itemKeySelector(item),
|
||||
parentId,
|
||||
parentSchameName
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
[OVERREACT_ID_FIELD_NAME]: overreactId,
|
||||
};
|
||||
});
|
||||
recordGroup.addOrUpdateRecords(itemsToAddWithOverreactId);
|
||||
const ids = itemsToAddWithOverreactId.map(item => item[OVERREACT_ID_FIELD_NAME]);
|
||||
recordGroup.notify('entitiesCreated', ids);
|
||||
|
||||
const itemsToMerge = _.filter(items, item => _.include(keysInCache, itemKeySelector(item)));
|
||||
const itemsMerged = itemsToMerge.map((itemToMerge) => {
|
||||
const key = itemKeySelector(itemToMerge);
|
||||
const records = recordGroup.getRecordsByEntityKeys(itemKeySelector, [key]);
|
||||
|
||||
return {
|
||||
...records[0].data,
|
||||
...itemToMerge,
|
||||
[OVERREACT_ID_FIELD_NAME]: records[0].id,
|
||||
};
|
||||
});
|
||||
recordGroup.addOrUpdateRecords(itemsMerged);
|
||||
};
|
||||
|
||||
const tryDeleteItemsInCacheStore = ({
|
||||
keysToDelete,
|
||||
schemaName,
|
||||
itemKeySelector,
|
||||
}) => {
|
||||
const modelName = schemaToModelMapping[schemaName];
|
||||
const recordGroup = store.getRecordGroup(modelName);
|
||||
|
||||
const recordsToRemove = recordGroup.getRecordsByEntityKeys(itemKeySelector, keysToDelete);
|
||||
const recordsIdsToRemove = recordsToRemove.map(r => r.id);
|
||||
|
||||
recordGroup.deleteRecords(recordsIdsToRemove);
|
||||
recordGroup.notify('entitiesDeleted', recordsIdsToRemove);
|
||||
};
|
||||
|
||||
return {
|
||||
tryMergeItemsToCacheStore,
|
||||
tryDeleteItemsInCacheStore,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export const responseTypes = {
|
||||
ENTITY: 'ENTITY',
|
||||
COLL: 'COLL',
|
||||
NONE: 'NONE', // useful when deleting
|
||||
};
|
|
@ -0,0 +1,8 @@
|
|||
export const specTypes = {
|
||||
ADD: 'ADD',
|
||||
DELETE: 'DELETE',
|
||||
FETCH: 'FETCH',
|
||||
MUTATION: 'MUTATION',
|
||||
PAGINATION: 'PAGINATION',
|
||||
REFETCH: 'REFETCH',
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
export function createSpec(
|
||||
requestContract,
|
||||
responseContract,
|
||||
specType,
|
||||
sideEffectFn,
|
||||
environmentLookupFn
|
||||
) {
|
||||
// FIXME: only the basics. Need to create more based on specType
|
||||
return {
|
||||
requestContract,
|
||||
responseContract,
|
||||
specType,
|
||||
sideEffectFn,
|
||||
environmentLookupFn,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export const OVERREACT_ID_FIELD_NAME = '_OVERREACT_ID';
|
||||
|
||||
export function createOverreactId(id, parentId, parentType) {
|
||||
return `${parentType}:${parentId}:${id}`;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
export * from './schema-extension/schema-extension';
|
||||
export * from './store';
|
||||
export {
|
||||
getRecordGroup,
|
||||
getRecordsById,
|
||||
getRecordsByEntityKey,
|
||||
getRecords,
|
||||
getDataRef,
|
||||
updateDataRefWithIds,
|
||||
} from './record-group-helper';
|
|
@ -0,0 +1,54 @@
|
|||
import _ from 'underscore';
|
||||
import { getDataNode, createDataNode } from './schema-extension/schema-extension';
|
||||
|
||||
export function getRecordGroup(store, requestContract) {
|
||||
if (store) {
|
||||
const schemaNode = requestContract.getSchemaNode();
|
||||
const recordGroup = store.getRecordGroup(schemaNode.modelName);
|
||||
|
||||
return recordGroup;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getRecordsById(store, requestContract, ids) {
|
||||
const recordGroup = getRecordGroup(store, requestContract);
|
||||
return recordGroup.getRecords(ids);
|
||||
}
|
||||
|
||||
export function getRecordsByEntityKey(store, spec, keys) {
|
||||
const { requestContract, responseContract } = spec;
|
||||
const recordGroup = getRecordGroup(store, requestContract);
|
||||
const { keySelector } = responseContract;
|
||||
return recordGroup.getRecordsByEntityKeys(keySelector, keys);
|
||||
}
|
||||
|
||||
export function getRecords(store, requestContract, key) {
|
||||
if (store) {
|
||||
const schemaNode = requestContract.getSchemaNode();
|
||||
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
|
||||
|
||||
const dataIds = dataNode.getDataRef(key).idRefs;
|
||||
const recordGroup = getRecordGroup(store, requestContract);
|
||||
|
||||
return recordGroup.getRecords(dataIds);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDataRef(store, requestContract, dataRefId) {
|
||||
if (store) {
|
||||
const schemaNode = requestContract.getSchemaNode();
|
||||
const dataNode = getDataNode(schemaNode) || createDataNode(schemaNode, store);
|
||||
return dataNode && dataNode.getDataRef(dataRefId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function updateDataRefWithIds(dataRef, ids) {
|
||||
dataRef.clear();
|
||||
_.map(ids, (id) => {
|
||||
dataRef.add(id);
|
||||
});
|
||||
dataRef.onUpdate(ids);
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import _ from 'underscore';
|
||||
import { Record } from './record';
|
||||
import { OVERREACT_ID_FIELD_NAME } from './consts';
|
||||
import { Subject } from '../utils/observer-pattern'
|
||||
|
||||
export class RecordGroup extends Subject {
|
||||
constructor(schemaType) {
|
||||
super();
|
||||
|
||||
this.schemaType = schemaType;
|
||||
|
||||
// records will be kept in chronological order
|
||||
// new records will always be kept at the end of the list
|
||||
this.records = [];
|
||||
|
||||
this.addOrUpdateRecords = this.addOrUpdateRecords.bind(this);
|
||||
|
||||
this.getRecords = this.getRecords.bind(this);
|
||||
this.getRecordsByEntityKeys = this.getRecordsByEntityKeys.bind(this);
|
||||
}
|
||||
|
||||
findIndex(dataId) {
|
||||
return this.records.findIndex(r => r.id === dataId);
|
||||
}
|
||||
|
||||
addOrUpdateRecordInternal(data) {
|
||||
const dataId = data[OVERREACT_ID_FIELD_NAME];
|
||||
const recordId = this.findIndex(dataId);
|
||||
|
||||
if (recordId > -1) {
|
||||
// we're updating
|
||||
this.records[recordId].setData(data);
|
||||
} else {
|
||||
// "add" record - because we'll be appending
|
||||
const newRecord = new Record(dataId, this.schemaType, data);
|
||||
this.records.push(newRecord);
|
||||
}
|
||||
|
||||
return dataId;
|
||||
}
|
||||
|
||||
addOrUpdateRecords(dataItems, request) {
|
||||
const updatedDataIDs = dataItems.map(data => this.addOrUpdateRecordInternal(data));
|
||||
this.notify('dataRefIdsUpdate', updatedDataIDs, request);
|
||||
}
|
||||
|
||||
deleteRecords(ids) {
|
||||
this.records = this.records.filter(record => !_.contains(ids, record.id));
|
||||
}
|
||||
|
||||
getRecords(ids) {
|
||||
return ids.map(id =>
|
||||
this.records.find(r => r.id === id));
|
||||
}
|
||||
|
||||
getRecordsByEntityKeys(keySelector, keys) {
|
||||
return _.chain(keys)
|
||||
.map(key => this.records.find(r => keySelector(r.getData()) === key))
|
||||
.compact()
|
||||
.value();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
export class Record {
|
||||
constructor(id, type, data) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
|
||||
this.setValue = this.setValue.bind(this);
|
||||
this.getValue = this.getValue.bind(this);
|
||||
this.setData = this.setData.bind(this);
|
||||
this.getData = this.getData.bind(this);
|
||||
}
|
||||
|
||||
setValue(key, value) {
|
||||
this.data = {
|
||||
...this.data,
|
||||
[key]: value,
|
||||
};
|
||||
}
|
||||
|
||||
getValue(key) {
|
||||
return this.data[key];
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.data = {
|
||||
...this.data,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
getData() {
|
||||
return this.data;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
import { DataRef } from './data-ref';
|
||||
|
||||
// this will be attached to the schema node as an extension
|
||||
// and represent all data stored under the data path given by the schema node
|
||||
// each data node contains an array of data IDs
|
||||
// which refers to the actual data record in the store.
|
||||
export class DataNode {
|
||||
constructor() {
|
||||
this.dataRefs = {};
|
||||
|
||||
this.getDataRef = this.getDataRef.bind(this);
|
||||
this.dataRefIdsUpdate = this.dataRefIdsUpdate.bind(this);
|
||||
this.entitiesCreated = this.entitiesCreated.bind(this);
|
||||
this.entitiesDeleted = this.entitiesDeleted.bind(this);
|
||||
}
|
||||
|
||||
getDataRef(key) {
|
||||
if (!this.dataRefs[key]) {
|
||||
this.dataRefs[key] = new DataRef(key);
|
||||
}
|
||||
|
||||
return this.dataRefs[key];
|
||||
}
|
||||
|
||||
dataRefIdsUpdate(recordGroup, updatedIds, request) {
|
||||
Object.keys(this.dataRefs).forEach(key => this.dataRefs[key].onUpdate(updatedIds, request));
|
||||
}
|
||||
|
||||
entitiesCreated(recordGroup, ids) {
|
||||
Object.keys(this.dataRefs).forEach(key => this.dataRefs[key].onEntitiesCreated(ids));
|
||||
}
|
||||
|
||||
entitiesDeleted(recordGroup, ids) {
|
||||
Object.keys(this.dataRefs).forEach(key => this.dataRefs[key].onEntitiesDeleted(ids));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
import _ from 'underscore';
|
||||
import { Subject } from '../../utils/observer-pattern';
|
||||
|
||||
export class DataRef extends Subject {
|
||||
constructor(key) {
|
||||
super();
|
||||
this.key = key;
|
||||
this.idRefs = [];
|
||||
this.status = {
|
||||
previousVariables: undefined,
|
||||
lastUpdateTimestamp: Date.now(),
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
this.includes = this.includes.bind(this);
|
||||
this.push = this.push.bind(this);
|
||||
this.add = this.add.bind(this);
|
||||
this.delete = this.delete.bind(this);
|
||||
this.onUpdate = this.onUpdate.bind(this);
|
||||
this.updateStatus = this.updateStatus.bind(this);
|
||||
this.onError = this.onError.bind(this);
|
||||
this.clearError = this.clearError.bind(this);
|
||||
this.onEntitiesCreated = this.onEntitiesCreated.bind(this);
|
||||
this.onEntitiesDeleted = this.onEntitiesDeleted.bind(this);
|
||||
this.reset = this.reset.bind(this);
|
||||
|
||||
// TODO: use "cursor" for specific pagination needs
|
||||
this.cursor = {};
|
||||
}
|
||||
|
||||
includes(id) {
|
||||
return this.idRefs.includes(id);
|
||||
}
|
||||
|
||||
push(id) {
|
||||
const ret = this.idRefs.push(id);
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
add(id) {
|
||||
// add/merge an id. If id exists in idRefs, do nothing
|
||||
if (this.includes(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.push(id);
|
||||
}
|
||||
|
||||
delete(ids) {
|
||||
if (_.intersection(this.idRefs, ids).length > 0) {
|
||||
this.idRefs = _.difference(this.idRefs, ids);
|
||||
}
|
||||
}
|
||||
|
||||
onError(error, ...args) {
|
||||
this.status.error = error;
|
||||
this.notify('onError', error, ...args);
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.onError(undefined);
|
||||
}
|
||||
|
||||
// currently, we will always add something to this data ref after clear, and trigger event then
|
||||
// so here we don't trigger a notification anymore: this.notify('update', []);
|
||||
// otherwise UI component will get a incorrect empty status for short time
|
||||
clear() {
|
||||
this.idRefs = [];
|
||||
this.clearError();
|
||||
}
|
||||
|
||||
reset(ids) {
|
||||
this.idRefs = ids;
|
||||
this.notify('update', ids);
|
||||
}
|
||||
|
||||
updateStatus(newStatus) {
|
||||
this.status = {
|
||||
...this.status,
|
||||
...newStatus,
|
||||
};
|
||||
}
|
||||
|
||||
onUpdate(updatedIds, request) {
|
||||
if (_.intersection(this.idRefs, updatedIds).length > 0) {
|
||||
this.notify('update', updatedIds, request);
|
||||
}
|
||||
}
|
||||
|
||||
onEntitiesCreated(ids) {
|
||||
this.notify('onEntitiesCreated', ids);
|
||||
}
|
||||
|
||||
onEntitiesDeleted(ids) {
|
||||
if (_.intersection(this.idRefs, ids).length > 0) {
|
||||
this.delete(ids);
|
||||
this.notify('update', this.idRefs);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import { DataNode } from './data-node';
|
||||
|
||||
const EXTENSION_NAME = 'DATA';
|
||||
|
||||
export function createDataNode(schemaNode, store) {
|
||||
if (!schemaNode) {
|
||||
throw new Error('Invalid schema node');
|
||||
}
|
||||
|
||||
const dataNode = new DataNode();
|
||||
store.getRecordGroup(schemaNode.modelName).subscribe(dataNode);
|
||||
|
||||
schemaNode.setExtension(EXTENSION_NAME, dataNode);
|
||||
|
||||
return dataNode;
|
||||
}
|
||||
|
||||
export function getDataNode(schemaNode) {
|
||||
if (!schemaNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return schemaNode.getExtension(EXTENSION_NAME);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/* global Map */
|
||||
// Use the basic APIs of ES6 Map. If your site supports browsers not
|
||||
// having Map natively, you should use a polyfill
|
||||
import { RecordGroup } from './record-group';
|
||||
|
||||
export class Store {
|
||||
constructor() {
|
||||
this.recordGroups = {};
|
||||
this.preemptiveRecords = new Map();
|
||||
|
||||
this.getRecordGroup = this.getRecordGroup.bind(this);
|
||||
}
|
||||
|
||||
getRecordGroup(modelName) {
|
||||
if (!this.recordGroups[modelName]) {
|
||||
this.recordGroups[modelName] = new RecordGroup(modelName);
|
||||
}
|
||||
return this.recordGroups[modelName];
|
||||
}
|
||||
|
||||
addPreemptiveRecords(key, records) {
|
||||
this.preemptiveRecords.set(key, records);
|
||||
}
|
||||
|
||||
removePreemptiveRecords(key) {
|
||||
const records = this.preemptiveRecords.get(key);
|
||||
this.preemptiveRecords.delete(key);
|
||||
return records;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { Subject } from './subject';
|
|
@ -0,0 +1,48 @@
|
|||
/* global Set */
|
||||
// Use the basic APIs of ES6 Set. If your site supports browsers not
|
||||
// having Set natively, you should use a polyfill
|
||||
|
||||
/* Subject to observe */
|
||||
export class Subject {
|
||||
constructor() {
|
||||
this.observers = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe an observer to the subject
|
||||
* @param {Object} observer - The observer subscribing to the subject
|
||||
* @returns {void}
|
||||
*/
|
||||
subscribe(observer) {
|
||||
if (!(observer instanceof Object)) {
|
||||
throw new Error('Invalid observer');
|
||||
}
|
||||
this.observers.add(observer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe an observer from the subject
|
||||
* @param {Object} observer - The observer to unsubscribe
|
||||
* @returns {void}
|
||||
*/
|
||||
unsubscribe(observer) {
|
||||
this.observers.delete(observer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the observers for a certain action
|
||||
* @param {string} action -
|
||||
* Name of the action, it will map to the handler method name on the observer
|
||||
* @param {...*} args - Additional arguments
|
||||
* @returns {void}
|
||||
*/
|
||||
notify(action, ...args) {
|
||||
this.observers.forEach((observer) => {
|
||||
if (typeof observer[action] === 'function') {
|
||||
observer[action](this, ...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
|
||||
/**
|
||||
* Get the current timestamp
|
||||
* @returns {number} - the timestamp since epoch in milliseconds
|
||||
*/
|
||||
export function getTimestamp() {
|
||||
// TODO: this method should not be here, as it depends on Web API performance
|
||||
if (window.performance && window.performance.now && window.performance.timing) {
|
||||
return window.performance.timing.navigationStart + window.performance.now();
|
||||
}
|
||||
|
||||
return Date.now();
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './date';
|
Загрузка…
Ссылка в новой задаче