From 3240c36f7b73619c52ecad57c2a5c6aa1ac22e95 Mon Sep 17 00:00:00 2001 From: Like Zhu Date: Mon, 7 Jun 2021 14:36:52 -0700 Subject: [PATCH] Direct copy of source code, need to remove internal dependencies --- .gitignore | 2 + packages/overreact/README.md | 24 ++ packages/overreact/index.js | 13 + packages/overreact/package.json | 31 +++ packages/overreact/src/data-fetcher/index.js | 117 ++++++++ packages/overreact/src/environment/context.js | 3 + .../overreact/src/environment/environment.js | 107 ++++++++ packages/overreact/src/environment/index.js | 4 + .../src/environment/use-environment.js | 14 + packages/overreact/src/hooks/helper.js | 31 +++ packages/overreact/src/hooks/index.js | 6 + packages/overreact/src/hooks/lookup-cache.js | 23 ++ packages/overreact/src/hooks/merge-config.js | 19 ++ .../overreact/src/hooks/overreact-request.js | 9 + .../overreact/src/hooks/use-data-ref-id.js | 46 ++++ .../src/hooks/use-deep-equal-effect.js | 20 ++ packages/overreact/src/hooks/use-fetch.js | 142 ++++++++++ packages/overreact/src/hooks/use-mutation.js | 258 ++++++++++++++++++ .../overreact/src/hooks/use-pagination.js | 223 +++++++++++++++ packages/overreact/src/hooks/use-previous.js | 11 + packages/overreact/src/hooks/use-promisify.js | 26 ++ packages/overreact/src/hooks/use-refetch.js | 110 ++++++++ .../src/middleware/error/error-middleware.js | 32 +++ .../fetch-policy/fetch-policy-middleware.js | 82 ++++++ .../fetch-policy/fetch-policy-utils.js | 82 ++++++ packages/overreact/src/middleware/index.js | 7 + .../instrumentation-context.js | 23 ++ .../instrumentation-middleware.js | 54 ++++ .../instrumentation/instrumentation-utils.js | 161 +++++++++++ .../src/middleware/middleware-types.js | 5 + .../src/middleware/request-with-middleware.js | 20 ++ .../src/middleware/wrapped-requestor.js | 33 +++ packages/overreact/src/schema/index.js | 63 +++++ packages/overreact/src/schema/schema-node.js | 32 +++ packages/overreact/src/spec/index.js | 7 + .../overreact/src/spec/request-contract.js | 63 +++++ packages/overreact/src/spec/request-verbs.js | 7 + .../overreact/src/spec/response-contract.js | 94 +++++++ .../src/spec/response-handler/error.js | 24 ++ .../src/spec/response-handler/fetch.js | 76 ++++++ .../src/spec/response-handler/mutation.js | 45 +++ .../src/spec/response-handler/refetch.js | 70 +++++ .../response-handler/sideEffectFnHelper.js | 66 +++++ packages/overreact/src/spec/response-types.js | 5 + packages/overreact/src/spec/spec-types.js | 8 + packages/overreact/src/spec/spec.js | 16 ++ packages/overreact/src/store/consts.js | 5 + packages/overreact/src/store/index.js | 10 + .../src/store/record-group-helper.js | 54 ++++ packages/overreact/src/store/record-group.js | 62 +++++ packages/overreact/src/store/record.js | 34 +++ .../src/store/schema-extension/data-node.js | 36 +++ .../src/store/schema-extension/data-ref.js | 101 +++++++ .../schema-extension/schema-extension.js | 24 ++ packages/overreact/src/store/store.js | 30 ++ 55 files changed, 2670 insertions(+) create mode 100644 packages/overreact/README.md create mode 100644 packages/overreact/index.js create mode 100644 packages/overreact/package.json create mode 100644 packages/overreact/src/data-fetcher/index.js create mode 100644 packages/overreact/src/environment/context.js create mode 100644 packages/overreact/src/environment/environment.js create mode 100644 packages/overreact/src/environment/index.js create mode 100644 packages/overreact/src/environment/use-environment.js create mode 100644 packages/overreact/src/hooks/helper.js create mode 100644 packages/overreact/src/hooks/index.js create mode 100644 packages/overreact/src/hooks/lookup-cache.js create mode 100644 packages/overreact/src/hooks/merge-config.js create mode 100644 packages/overreact/src/hooks/overreact-request.js create mode 100644 packages/overreact/src/hooks/use-data-ref-id.js create mode 100644 packages/overreact/src/hooks/use-deep-equal-effect.js create mode 100644 packages/overreact/src/hooks/use-fetch.js create mode 100644 packages/overreact/src/hooks/use-mutation.js create mode 100644 packages/overreact/src/hooks/use-pagination.js create mode 100644 packages/overreact/src/hooks/use-previous.js create mode 100644 packages/overreact/src/hooks/use-promisify.js create mode 100644 packages/overreact/src/hooks/use-refetch.js create mode 100644 packages/overreact/src/middleware/error/error-middleware.js create mode 100644 packages/overreact/src/middleware/fetch-policy/fetch-policy-middleware.js create mode 100644 packages/overreact/src/middleware/fetch-policy/fetch-policy-utils.js create mode 100644 packages/overreact/src/middleware/index.js create mode 100644 packages/overreact/src/middleware/instrumentation/instrumentation-context.js create mode 100644 packages/overreact/src/middleware/instrumentation/instrumentation-middleware.js create mode 100644 packages/overreact/src/middleware/instrumentation/instrumentation-utils.js create mode 100644 packages/overreact/src/middleware/middleware-types.js create mode 100644 packages/overreact/src/middleware/request-with-middleware.js create mode 100644 packages/overreact/src/middleware/wrapped-requestor.js create mode 100644 packages/overreact/src/schema/index.js create mode 100644 packages/overreact/src/schema/schema-node.js create mode 100644 packages/overreact/src/spec/index.js create mode 100644 packages/overreact/src/spec/request-contract.js create mode 100644 packages/overreact/src/spec/request-verbs.js create mode 100644 packages/overreact/src/spec/response-contract.js create mode 100644 packages/overreact/src/spec/response-handler/error.js create mode 100644 packages/overreact/src/spec/response-handler/fetch.js create mode 100644 packages/overreact/src/spec/response-handler/mutation.js create mode 100644 packages/overreact/src/spec/response-handler/refetch.js create mode 100644 packages/overreact/src/spec/response-handler/sideEffectFnHelper.js create mode 100644 packages/overreact/src/spec/response-types.js create mode 100644 packages/overreact/src/spec/spec-types.js create mode 100644 packages/overreact/src/spec/spec.js create mode 100644 packages/overreact/src/store/consts.js create mode 100644 packages/overreact/src/store/index.js create mode 100644 packages/overreact/src/store/record-group-helper.js create mode 100644 packages/overreact/src/store/record-group.js create mode 100644 packages/overreact/src/store/record.js create mode 100644 packages/overreact/src/store/schema-extension/data-node.js create mode 100644 packages/overreact/src/store/schema-extension/data-ref.js create mode 100644 packages/overreact/src/store/schema-extension/schema-extension.js create mode 100644 packages/overreact/src/store/store.js diff --git a/.gitignore b/.gitignore index f90b199..4f36053 100644 --- a/.gitignore +++ b/.gitignore @@ -344,3 +344,5 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + +*.orig diff --git a/packages/overreact/README.md b/packages/overreact/README.md new file mode 100644 index 0000000..5019a90 --- /dev/null +++ b/packages/overreact/README.md @@ -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'; + +/// ... +``` diff --git a/packages/overreact/index.js b/packages/overreact/index.js new file mode 100644 index 0000000..0216ada --- /dev/null +++ b/packages/overreact/index.js @@ -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'; diff --git a/packages/overreact/package.json b/packages/overreact/package.json new file mode 100644 index 0000000..a0fb1af --- /dev/null +++ b/packages/overreact/package.json @@ -0,0 +1,31 @@ +{ + "name": "@microsoft/overreact-core", + "description": "TO BE ADDED", + "version": "0.1.0", + "main": "dist/index", + "license": "MIT", + "dependencies": { + "@bingads-webui-universal/observer-pattern": "*", + "@bingads-webui-universal/primitive-utilities": "^1.4.5", + "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": { + "@bingads-webui/mca-odata-schemas": ">=1.0.0-alpha.2019091919", + "@bingads-webui/edm-core": ">=1.0.0", + "@bingads-webui/edm-odata": "^1.1.6", + "@bingads-webui/edm-resource-identifiers": ">=1.0.0", + "@bingads-webui/http-util": "^1.0.0", + "@bingads-webui/reflection": ">=1.0.0", + "fetch-mock": "^9.10.1", + "jquery": "2.2.4", + "json-stable-stringify": "*", + "query-string": "*" + } +} diff --git a/packages/overreact/src/data-fetcher/index.js b/packages/overreact/src/data-fetcher/index.js new file mode 100644 index 0000000..c77262b --- /dev/null +++ b/packages/overreact/src/data-fetcher/index.js @@ -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 ( + + {children} + + ); +}); + +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, +}; diff --git a/packages/overreact/src/environment/context.js b/packages/overreact/src/environment/context.js new file mode 100644 index 0000000..4ed7c98 --- /dev/null +++ b/packages/overreact/src/environment/context.js @@ -0,0 +1,3 @@ +import React from 'react'; + +export const EnvironmentContext = React.createContext(); diff --git a/packages/overreact/src/environment/environment.js b/packages/overreact/src/environment/environment.js new file mode 100644 index 0000000..bf742f9 --- /dev/null +++ b/packages/overreact/src/environment/environment.js @@ -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; + } +} diff --git a/packages/overreact/src/environment/index.js b/packages/overreact/src/environment/index.js new file mode 100644 index 0000000..0402a8d --- /dev/null +++ b/packages/overreact/src/environment/index.js @@ -0,0 +1,4 @@ +export * from './environment'; +export * from './context'; +export * from './use-environment'; + diff --git a/packages/overreact/src/environment/use-environment.js b/packages/overreact/src/environment/use-environment.js new file mode 100644 index 0000000..5c82027 --- /dev/null +++ b/packages/overreact/src/environment/use-environment.js @@ -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); +} diff --git a/packages/overreact/src/hooks/helper.js b/packages/overreact/src/hooks/helper.js new file mode 100644 index 0000000..96a7f89 --- /dev/null +++ b/packages/overreact/src/hooks/helper.js @@ -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; +} diff --git a/packages/overreact/src/hooks/index.js b/packages/overreact/src/hooks/index.js new file mode 100644 index 0000000..322823f --- /dev/null +++ b/packages/overreact/src/hooks/index.js @@ -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'; diff --git a/packages/overreact/src/hooks/lookup-cache.js b/packages/overreact/src/hooks/lookup-cache.js new file mode 100644 index 0000000..c29b27f --- /dev/null +++ b/packages/overreact/src/hooks/lookup-cache.js @@ -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; +} diff --git a/packages/overreact/src/hooks/merge-config.js b/packages/overreact/src/hooks/merge-config.js new file mode 100644 index 0000000..61e3aae --- /dev/null +++ b/packages/overreact/src/hooks/merge-config.js @@ -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, + }; +} diff --git a/packages/overreact/src/hooks/overreact-request.js b/packages/overreact/src/hooks/overreact-request.js new file mode 100644 index 0000000..1f1fed0 --- /dev/null +++ b/packages/overreact/src/hooks/overreact-request.js @@ -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'); + } + } +} diff --git a/packages/overreact/src/hooks/use-data-ref-id.js b/packages/overreact/src/hooks/use-data-ref-id.js new file mode 100644 index 0000000..538a6a8 --- /dev/null +++ b/packages/overreact/src/hooks/use-data-ref-id.js @@ -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; +} diff --git a/packages/overreact/src/hooks/use-deep-equal-effect.js b/packages/overreact/src/hooks/use-deep-equal-effect.js new file mode 100644 index 0000000..a7bd45f --- /dev/null +++ b/packages/overreact/src/hooks/use-deep-equal-effect.js @@ -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'); +} diff --git a/packages/overreact/src/hooks/use-fetch.js b/packages/overreact/src/hooks/use-fetch.js new file mode 100644 index 0000000..fbec906 --- /dev/null +++ b/packages/overreact/src/hooks/use-fetch.js @@ -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]; +} diff --git a/packages/overreact/src/hooks/use-mutation.js b/packages/overreact/src/hooks/use-mutation.js new file mode 100644 index 0000000..88f5503 --- /dev/null +++ b/packages/overreact/src/hooks/use-mutation.js @@ -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; +} diff --git a/packages/overreact/src/hooks/use-pagination.js b/packages/overreact/src/hooks/use-pagination.js new file mode 100644 index 0000000..0f2b0a6 --- /dev/null +++ b/packages/overreact/src/hooks/use-pagination.js @@ -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; +} diff --git a/packages/overreact/src/hooks/use-previous.js b/packages/overreact/src/hooks/use-previous.js new file mode 100644 index 0000000..6a44fba --- /dev/null +++ b/packages/overreact/src/hooks/use-previous.js @@ -0,0 +1,11 @@ +import { useRef, useEffect } from 'react'; + +export function usePrevious(value) { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} diff --git a/packages/overreact/src/hooks/use-promisify.js b/packages/overreact/src/hooks/use-promisify.js new file mode 100644 index 0000000..a6254f6 --- /dev/null +++ b/packages/overreact/src/hooks/use-promisify.js @@ -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 +} diff --git a/packages/overreact/src/hooks/use-refetch.js b/packages/overreact/src/hooks/use-refetch.js new file mode 100644 index 0000000..724de2d --- /dev/null +++ b/packages/overreact/src/hooks/use-refetch.js @@ -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; +} diff --git a/packages/overreact/src/middleware/error/error-middleware.js b/packages/overreact/src/middleware/error/error-middleware.js new file mode 100644 index 0000000..9162d42 --- /dev/null +++ b/packages/overreact/src/middleware/error/error-middleware.js @@ -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; + }; +} diff --git a/packages/overreact/src/middleware/fetch-policy/fetch-policy-middleware.js b/packages/overreact/src/middleware/fetch-policy/fetch-policy-middleware.js new file mode 100644 index 0000000..5d4ef28 --- /dev/null +++ b/packages/overreact/src/middleware/fetch-policy/fetch-policy-middleware.js @@ -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); + }; +} diff --git a/packages/overreact/src/middleware/fetch-policy/fetch-policy-utils.js b/packages/overreact/src/middleware/fetch-policy/fetch-policy-utils.js new file mode 100644 index 0000000..7cdd3f2 --- /dev/null +++ b/packages/overreact/src/middleware/fetch-policy/fetch-policy-utils.js @@ -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, + }); + } +} diff --git a/packages/overreact/src/middleware/index.js b/packages/overreact/src/middleware/index.js new file mode 100644 index 0000000..c81b1b2 --- /dev/null +++ b/packages/overreact/src/middleware/index.js @@ -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'; diff --git a/packages/overreact/src/middleware/instrumentation/instrumentation-context.js b/packages/overreact/src/middleware/instrumentation/instrumentation-context.js new file mode 100644 index 0000000..e6a708c --- /dev/null +++ b/packages/overreact/src/middleware/instrumentation/instrumentation-context.js @@ -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); + } +} diff --git a/packages/overreact/src/middleware/instrumentation/instrumentation-middleware.js b/packages/overreact/src/middleware/instrumentation/instrumentation-middleware.js new file mode 100644 index 0000000..83ab47f --- /dev/null +++ b/packages/overreact/src/middleware/instrumentation/instrumentation-middleware.js @@ -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; + }; +} diff --git a/packages/overreact/src/middleware/instrumentation/instrumentation-utils.js b/packages/overreact/src/middleware/instrumentation/instrumentation-utils.js new file mode 100644 index 0000000..bdc37c2 --- /dev/null +++ b/packages/overreact/src/middleware/instrumentation/instrumentation-utils.js @@ -0,0 +1,161 @@ +/* eslint-disable no-param-reassign */ +import _ from 'underscore'; + +import { getTimestamp } from '@bingads-webui-universal/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, + }); +} diff --git a/packages/overreact/src/middleware/middleware-types.js b/packages/overreact/src/middleware/middleware-types.js new file mode 100644 index 0000000..279eae1 --- /dev/null +++ b/packages/overreact/src/middleware/middleware-types.js @@ -0,0 +1,5 @@ +export const middlewareTypes = { + ERROR: 'ERROR', + FETCH_POLICY: 'FETCH_POLICY', + INSTRUMENTATION: 'INSTRUMENTATION', +}; diff --git a/packages/overreact/src/middleware/request-with-middleware.js b/packages/overreact/src/middleware/request-with-middleware.js new file mode 100644 index 0000000..9ae6495 --- /dev/null +++ b/packages/overreact/src/middleware/request-with-middleware.js @@ -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; + }); +} diff --git a/packages/overreact/src/middleware/wrapped-requestor.js b/packages/overreact/src/middleware/wrapped-requestor.js new file mode 100644 index 0000000..56b7f0c --- /dev/null +++ b/packages/overreact/src/middleware/wrapped-requestor.js @@ -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); + } +} diff --git a/packages/overreact/src/schema/index.js b/packages/overreact/src/schema/index.js new file mode 100644 index 0000000..1d61f14 --- /dev/null +++ b/packages/overreact/src/schema/index.js @@ -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; + } +} diff --git a/packages/overreact/src/schema/schema-node.js b/packages/overreact/src/schema/schema-node.js new file mode 100644 index 0000000..2008e9f --- /dev/null +++ b/packages/overreact/src/schema/schema-node.js @@ -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]; + } +} diff --git a/packages/overreact/src/spec/index.js b/packages/overreact/src/spec/index.js new file mode 100644 index 0000000..b13d0a0 --- /dev/null +++ b/packages/overreact/src/spec/index.js @@ -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'; diff --git a/packages/overreact/src/spec/request-contract.js b/packages/overreact/src/spec/request-contract.js new file mode 100644 index 0000000..8afa09a --- /dev/null +++ b/packages/overreact/src/spec/request-contract.js @@ -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, + }); +} diff --git a/packages/overreact/src/spec/request-verbs.js b/packages/overreact/src/spec/request-verbs.js new file mode 100644 index 0000000..77e4c07 --- /dev/null +++ b/packages/overreact/src/spec/request-verbs.js @@ -0,0 +1,7 @@ +export const requestVerbs = { + GET: 'GET', + POST: 'POST', + DELETE: 'DELETE', + PATCH: 'PATCH', + PUT: 'PUT', +}; diff --git a/packages/overreact/src/spec/response-contract.js b/packages/overreact/src/spec/response-contract.js new file mode 100644 index 0000000..ed8b032 --- /dev/null +++ b/packages/overreact/src/spec/response-contract.js @@ -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, + }); +} diff --git a/packages/overreact/src/spec/response-handler/error.js b/packages/overreact/src/spec/response-handler/error.js new file mode 100644 index 0000000..1430636 --- /dev/null +++ b/packages/overreact/src/spec/response-handler/error.js @@ -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); + } + }; +} diff --git a/packages/overreact/src/spec/response-handler/fetch.js b/packages/overreact/src/spec/response-handler/fetch.js new file mode 100644 index 0000000..b892227 --- /dev/null +++ b/packages/overreact/src/spec/response-handler/fetch.js @@ -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); + } + }; +} diff --git a/packages/overreact/src/spec/response-handler/mutation.js b/packages/overreact/src/spec/response-handler/mutation.js new file mode 100644 index 0000000..8b59b5d --- /dev/null +++ b/packages/overreact/src/spec/response-handler/mutation.js @@ -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); + } + }; +} diff --git a/packages/overreact/src/spec/response-handler/refetch.js b/packages/overreact/src/spec/response-handler/refetch.js new file mode 100644 index 0000000..89b6a10 --- /dev/null +++ b/packages/overreact/src/spec/response-handler/refetch.js @@ -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); + } + }; +} diff --git a/packages/overreact/src/spec/response-handler/sideEffectFnHelper.js b/packages/overreact/src/spec/response-handler/sideEffectFnHelper.js new file mode 100644 index 0000000..4174f87 --- /dev/null +++ b/packages/overreact/src/spec/response-handler/sideEffectFnHelper.js @@ -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, + }; +} diff --git a/packages/overreact/src/spec/response-types.js b/packages/overreact/src/spec/response-types.js new file mode 100644 index 0000000..80f1c44 --- /dev/null +++ b/packages/overreact/src/spec/response-types.js @@ -0,0 +1,5 @@ +export const responseTypes = { + ENTITY: 'ENTITY', + COLL: 'COLL', + NONE: 'NONE', // useful when deleting +}; diff --git a/packages/overreact/src/spec/spec-types.js b/packages/overreact/src/spec/spec-types.js new file mode 100644 index 0000000..55d5295 --- /dev/null +++ b/packages/overreact/src/spec/spec-types.js @@ -0,0 +1,8 @@ +export const specTypes = { + ADD: 'ADD', + DELETE: 'DELETE', + FETCH: 'FETCH', + MUTATION: 'MUTATION', + PAGINATION: 'PAGINATION', + REFETCH: 'REFETCH', +}; diff --git a/packages/overreact/src/spec/spec.js b/packages/overreact/src/spec/spec.js new file mode 100644 index 0000000..364ce73 --- /dev/null +++ b/packages/overreact/src/spec/spec.js @@ -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, + }; +} diff --git a/packages/overreact/src/store/consts.js b/packages/overreact/src/store/consts.js new file mode 100644 index 0000000..ef5c411 --- /dev/null +++ b/packages/overreact/src/store/consts.js @@ -0,0 +1,5 @@ +export const OVERREACT_ID_FIELD_NAME = '_OVERREACT_ID'; + +export function createOverreactId(id, parentId, parentType) { + return `${parentType}:${parentId}:${id}`; +} diff --git a/packages/overreact/src/store/index.js b/packages/overreact/src/store/index.js new file mode 100644 index 0000000..8430843 --- /dev/null +++ b/packages/overreact/src/store/index.js @@ -0,0 +1,10 @@ +export * from './schema-extension/schema-extension'; +export * from './store'; +export { + getRecordGroup, + getRecordsById, + getRecordsByEntityKey, + getRecords, + getDataRef, + updateDataRefWithIds, +} from './record-group-helper'; diff --git a/packages/overreact/src/store/record-group-helper.js b/packages/overreact/src/store/record-group-helper.js new file mode 100644 index 0000000..647ce18 --- /dev/null +++ b/packages/overreact/src/store/record-group-helper.js @@ -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); +} diff --git a/packages/overreact/src/store/record-group.js b/packages/overreact/src/store/record-group.js new file mode 100644 index 0000000..05559db --- /dev/null +++ b/packages/overreact/src/store/record-group.js @@ -0,0 +1,62 @@ +import { Subject } from '@bingads-webui-universal/observer-pattern'; +import _ from 'underscore'; +import { Record } from './record'; +import { OVERREACT_ID_FIELD_NAME } from './consts'; + +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(); + } +} diff --git a/packages/overreact/src/store/record.js b/packages/overreact/src/store/record.js new file mode 100644 index 0000000..c2c466a --- /dev/null +++ b/packages/overreact/src/store/record.js @@ -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; + } +} diff --git a/packages/overreact/src/store/schema-extension/data-node.js b/packages/overreact/src/store/schema-extension/data-node.js new file mode 100644 index 0000000..9ba3023 --- /dev/null +++ b/packages/overreact/src/store/schema-extension/data-node.js @@ -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)); + } +} diff --git a/packages/overreact/src/store/schema-extension/data-ref.js b/packages/overreact/src/store/schema-extension/data-ref.js new file mode 100644 index 0000000..a4dcb64 --- /dev/null +++ b/packages/overreact/src/store/schema-extension/data-ref.js @@ -0,0 +1,101 @@ +import { Subject } from '@bingads-webui-universal/observer-pattern'; +import _ from 'underscore'; + +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); + } + } +} diff --git a/packages/overreact/src/store/schema-extension/schema-extension.js b/packages/overreact/src/store/schema-extension/schema-extension.js new file mode 100644 index 0000000..72323d4 --- /dev/null +++ b/packages/overreact/src/store/schema-extension/schema-extension.js @@ -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); +} diff --git a/packages/overreact/src/store/store.js b/packages/overreact/src/store/store.js new file mode 100644 index 0000000..7e88cd1 --- /dev/null +++ b/packages/overreact/src/store/store.js @@ -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; + } +}