diff --git a/client/fresh-data-plugin/actions.js b/client/fresh-data-plugin/actions.js new file mode 100644 index 00000000000..f152a4c94d6 --- /dev/null +++ b/client/fresh-data-plugin/actions.js @@ -0,0 +1,21 @@ +/** @format */ + +export function dataRequested( api, clientKey, resourceNames, time = new Date() ) { + return { + type: 'RESOURCES_REQUESTED', + api, + clientKey, + resourceNames, + time, + }; +} + +export function dataReceived( api, clientKey, resources, time = new Date() ) { + return { + type: 'RESOURCES_RECEIVED', + api, + clientKey, + resources, + time, + }; +} diff --git a/client/fresh-data-plugin/index.js b/client/fresh-data-plugin/index.js new file mode 100644 index 00000000000..a04a7e62be4 --- /dev/null +++ b/client/fresh-data-plugin/index.js @@ -0,0 +1,127 @@ +/** + * External dependencies + * + * @format + */ + +import { FreshDataApi } from '@fresh-data/framework'; + +/** + * Internal dependencies + */ +import * as actions from './actions'; +import reducer from './reducer'; + +export const SECOND = 1000; +export const MINUTE = 60 * SECOND; +export const HOUR = 60 * MINUTE; +export { default as withResources } from './with-resources'; + +/** + * Fresh data API. + * + * @property {Methods} methods Functions that correspond to API verbs like + * "GET" or "POST" and used by operations. + * @property {Operations} operations Functions to handle operations on the + * api, such as "read", or "update". + * @property {Selectors} selectors Selector functions which can require + * resources and return their current values. + * @property {Mutations} mutations Mutation functions that map to operations and + * designed to be used by the application. + * + * @typedef {WPFreshDataApiSpec} + */ + +/** + * Fresh data store options. + * + * @property {WPFreshDataAPI} apiSpec A fresh-data API Specification. + * + * @typedef {WPFreshDataStoreOptions} + */ + +/** + * Creates a fresh-data api client for use in @wordpress/data. + * + * @param {Object} apiSpec The api specification for the client. + * @param {Object} dispatchActions `dataRequested` and `dataReceived` action creators + * wrapped in a dispatch function. + * + * @return {Object} An ApiClient ready to be used. + */ +export function createApiClient( apiSpec, dispatchActions ) { + if ( ! apiSpec.methods ) { + throw new TypeError( 'apiSpec must specify methods' ); + } + if ( ! apiSpec.operations ) { + throw new TypeError( 'apiSpec must specify operations' ); + } + + // TODO: Remove ApiClass after fresh-data 0.3.0 is released. + class ApiClass extends FreshDataApi { + constructor() { + super(); + this.methods = apiSpec.methods; + this.operations = apiSpec.operations; + this.mutations = apiSpec.mutations; + this.selectors = apiSpec.selectors || {}; + } + } + + const api = new ApiClass(); + api.setDataHandlers( dispatchActions ); + return api.createClient( 'client' ); +} + +/** + * Registers a fresh-data api. + * + * @param {WPDataRegistry} registry Data registry. + * @param {string} reducerKey Name of reducerKey and api instance. + * @param {WPFreshDataStoreOptions} options Options given to registerStore. + * + * @return {Object} The api client created. + */ +export function registerApiClient( registry, reducerKey, options ) { + const { apiSpec } = options; + + const store = registry.registerReducer( reducerKey, reducer, options.persist ); + registry.registerActions( reducerKey, actions ); + + const dispatchActions = registry.dispatch( reducerKey ); + const apiClient = createApiClient( apiSpec, dispatchActions ); + + // Subscribe to the store directly so we don't get all the global events + store.subscribe( () => { + const state = store.getState() || {}; + const clientState = state.client || {}; // TODO: Remove this after fresh-data 0.3.0 + apiClient.setState( clientState ); + } ); + + return { store, apiClient }; +} + +/** + * Data plugin to map api data to component requirements. + * + * @param {WPDataRegistry} registry Data registry. + * + * @return {WPDataPlugin} Data plugin. + */ +export default function( registry ) { + const apisByReducerKey = {}; + + return { + registerStore( reducerKey, options ) { + if ( options.apiSpec ) { + const { store, apiClient } = registerApiClient( registry, reducerKey, options ); + apisByReducerKey[ reducerKey ] = apiClient; + return store; + } + return registry.registerStore( reducerKey, options ); + }, + getApiClient( reducerKey ) { + return apisByReducerKey[ reducerKey ] || null; + }, + }; +} diff --git a/client/fresh-data-plugin/reducer.js b/client/fresh-data-plugin/reducer.js new file mode 100644 index 00000000000..768dda6fc50 --- /dev/null +++ b/client/fresh-data-plugin/reducer.js @@ -0,0 +1,51 @@ +/** @format */ + +export function reduceRequested( state, action ) { + const { resourceNames, time } = action; + const apiState = state || {}; + const clientState = apiState.client || {}; + const existingResources = clientState.resources || {}; + + const resources = resourceNames.reduce( ( newResources, resourceName ) => { + const existingResource = existingResources[ resourceName ]; + newResources[ resourceName ] = { ...existingResource, lastRequested: time }; + return newResources; + }, existingResources ); + + return { ...state, client: { resources: resources } }; +} + +export function reduceReceived( state = {}, action ) { + const { resources, time } = action; + const apiState = state || {}; + const clientState = apiState.client || {}; + const existingResources = clientState.resources || {}; + + const updatedResources = Object.keys( resources ).reduce( ( newResources, resourceName ) => { + const existingResource = existingResources[ resourceName ]; + const resource = action.resources[ resourceName ]; + if ( resource.data ) { + resource.lastReceived = time; + } + if ( resource.error ) { + resource.lastError = time; + } + newResources[ resourceName ] = { ...existingResource, ...resource }; + return newResources; + }, existingResources ); + + return { ...state, client: { resources: updatedResources } }; +} + +const reducer = ( state = {}, action ) => { + switch ( action.type ) { + case 'RESOURCES_REQUESTED': + return reduceRequested( state, action ); + case 'RESOURCES_RECEIVED': + return reduceReceived( state, action ); + default: + return state; + } +}; + +export default reducer; diff --git a/client/fresh-data-plugin/with-resources.js b/client/fresh-data-plugin/with-resources.js new file mode 100644 index 00000000000..6d6d9932338 --- /dev/null +++ b/client/fresh-data-plugin/with-resources.js @@ -0,0 +1,144 @@ +/** @format */ +/** + * External dependencies + */ +import { Component } from '@wordpress/element'; +import isShallowEqual from '@wordpress/is-shallow-equal'; +import { remountOnPropChange, createHigherOrderComponent } from '@wordpress/compose'; +import { RegistryConsumer } from '@wordpress/data'; + +/** + * Higher-order component used to inject state-derived props using registered + * selectors. + * + * @param {string} apiName Name of api from which resources are needed. + * @param {Function} mapSelectorsToProps Function called on every state change, + * expected to return object of props to + * merge with the component's own props. + * @param {Function} [mapMutationsToProps] Function called which maps resource + * mutations to event handle callbacks. + * @return {Component} Enhanced component with merged state data props. + */ +const withResources = ( apiName, mapSelectorsToProps, mapMutationsToProps ) => + createHigherOrderComponent( WrappedComponent => { + /** + * Default merge props. A constant value is used as the fallback since it + * can be more efficiently shallow compared in case component is repeatedly + * rendered without its own merge props. + * + * @type {Object} + */ + const DEFAULT_MERGE_PROPS = {}; + + const ComponentWithSelectors = remountOnPropChange( 'registry' )( + class extends Component { + constructor( props ) { + super( props ); + + this.subscribe(); + + this.mergeProps = this.getNextMergeProps( props ); + } + + /** + * Given a props object, returns the next merge props by mapStateToProps. + * + * @param {Object} props Props to pass as argument to mapStateToProps. + * + * @return {Object} Props to merge into rendered wrapped element. + */ + getNextMergeProps( props ) { + const apiClient = props.registry.getApiClient( apiName ); + let selectorProps = DEFAULT_MERGE_PROPS; + let mutationProps = {}; + + apiClient.setComponentData( this, selectors => { + selectorProps = mapSelectorsToProps( selectors, props.ownProps ); + } ); + + if ( mapMutationsToProps ) { + const mutations = apiClient.getMutations(); + mutationProps = mapMutationsToProps( mutations, this.props ); + } + + return { + ...selectorProps, + ...mutationProps, + }; + } + + componentDidMount() { + this.canRunSelection = true; + } + + componentWillUnmount() { + this.canRunSelection = false; + this.unsubscribe(); + } + + shouldComponentUpdate( nextProps, nextState ) { + const hasPropsChanged = ! isShallowEqual( this.props.ownProps, nextProps.ownProps ); + + // Only render if props have changed or merge props have been updated + // from the store subscriber. + if ( this.state === nextState && ! hasPropsChanged ) { + return false; + } + + // If merge props change as a result of the incoming props, they + // should be reflected as such in the upcoming render. + if ( hasPropsChanged ) { + const nextMergeProps = this.getNextMergeProps( nextProps ); + if ( ! isShallowEqual( this.mergeProps, nextMergeProps ) ) { + // Side effects are typically discouraged in lifecycle methods, but + // this component is heavily used and this is the most performant + // code we've found thus far. + // Prior efforts to use `getDerivedStateFromProps` have demonstrated + // miserable performance. + this.mergeProps = nextMergeProps; + } + } + + return true; + } + + subscribe() { + const apiClient = this.props.registry.getApiClient( apiName ); + this.unsubscribe = apiClient.subscribe( () => { + if ( ! this.canRunSelection ) { + return; + } + + const nextMergeProps = this.getNextMergeProps( this.props ); + if ( isShallowEqual( this.mergeProps, nextMergeProps ) ) { + return; + } + + this.mergeProps = nextMergeProps; + + // Schedule an update. Merge props are not assigned to state + // because derivation of merge props from incoming props occurs + // within shouldComponentUpdate, where setState is not allowed. + // setState is used here instead of forceUpdate because forceUpdate + // bypasses shouldComponentUpdate altogether, which isn't desireable + // if both state and props change within the same render. + // Unfortunately this requires that next merge props are generated + // twice. + this.setState( {} ); + } ); + } + + render() { + return ; + } + } + ); + + return ownProps => ( + + { registry => } + + ); + }, 'withResources' ); + +export default withResources; diff --git a/client/index.js b/client/index.js index 02f69a5c991..a0152bbc9d2 100644 --- a/client/index.js +++ b/client/index.js @@ -6,7 +6,7 @@ import { APIProvider } from '@wordpress/components'; import { pick } from 'lodash'; import { render } from '@wordpress/element'; import { Provider as SlotFillProvider } from 'react-slot-fill'; -import { plugins, use, RegistryProvider } from '@wordpress/data'; +import { use, RegistryProvider } from '@wordpress/data'; import 'react-dates/initialize'; /** @@ -14,9 +14,10 @@ import 'react-dates/initialize'; */ import './stylesheets/_index.scss'; import { PageLayout } from './layout'; +import freshDataPlugin from './fresh-data-plugin'; import wcApiSpec from 'wc-api-spec'; -const registry = use( plugins.freshData ); +const registry = use( freshDataPlugin ); registry.registerStore( 'wc-api', { apiSpec: wcApiSpec } ); diff --git a/client/layout/activity-panel/panels/orders.js b/client/layout/activity-panel/panels/orders.js index e73426c0679..45f361a4fee 100644 --- a/client/layout/activity-panel/panels/orders.js +++ b/client/layout/activity-panel/panels/orders.js @@ -5,7 +5,6 @@ import { __, _n, sprintf } from '@wordpress/i18n'; import { Button } from '@wordpress/components'; import { compose } from '@wordpress/compose'; -import { withResources, plugins } from '@wordpress/data'; import { Component, Fragment } from '@wordpress/element'; import PropTypes from 'prop-types'; import { noop } from 'lodash'; @@ -23,8 +22,7 @@ import Gravatar from 'components/gravatar'; import Flag from 'components/flag'; import OrderStatus from 'components/order-status'; import { Section } from 'layout/section'; - -const { MINUTE } = plugins.freshData; +import { MINUTE, withResources } from 'fresh-data-plugin'; class OrdersPanel extends Component { constructor() { diff --git a/package-lock.json b/package-lock.json index 5db9a3d821c..9deea2916d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2138,6 +2138,28 @@ } } }, + "@fresh-data/framework": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@fresh-data/framework/-/framework-0.2.0.tgz", + "integrity": "sha512-oOB766DhLU5cD8wn9XXyz3NZq1jlX3gKMbWxBJXrpeRDsVxSRpx13v/9M5dRkLpf1Av4qp1ZCqWGz9st2jiWUg==", + "dev": true, + "requires": { + "postinstall-build": "5.0.1", + "prop-types": "15.6.2" + }, + "dependencies": { + "prop-types": { + "version": "15.6.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", + "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", + "dev": true, + "requires": { + "loose-envify": "1.3.1", + "object-assign": "4.1.1" + } + } + } + }, "@mrmlnc/readdir-enhanced": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", @@ -12227,6 +12249,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz", "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg==" }, + "lodash-es": { + "version": "4.17.10", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.10.tgz", + "integrity": "sha512-iesFYPmxYYGTcmQK0sL8bX3TGHyM6b2qREaB4kamHfQyfPJP0xgoGxp19nsH16nsfquLdiyKyX3mQkfiSGV8Rg==", + "dev": true + }, "lodash._baseisequal": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/lodash._baseisequal/-/lodash._baseisequal-3.0.7.tgz", @@ -14965,6 +14993,12 @@ "uniqs": "2.0.0" } }, + "postinstall-build": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/postinstall-build/-/postinstall-build-5.0.1.tgz", + "integrity": "sha1-uRepB5smF42aJK9aXNjLSpkdEbk=", + "dev": true + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -15588,6 +15622,28 @@ "prop-types": "15.6.1" } }, + "react-redux": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.7.tgz", + "integrity": "sha512-5VI8EV5hdgNgyjfmWzBbdrqUkrVRKlyTKk1sGH3jzM2M2Mhj/seQgPXaz6gVAj2lz/nz688AdTqMO18Lr24Zhg==", + "dev": true, + "requires": { + "hoist-non-react-statics": "2.5.5", + "invariant": "2.2.4", + "lodash": "4.17.10", + "lodash-es": "4.17.10", + "loose-envify": "1.3.1", + "prop-types": "15.6.1" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", + "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==", + "dev": true + } + } + }, "react-router": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-4.3.1.tgz", @@ -17547,9 +17603,9 @@ "dev": true }, "trough": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.2.tgz", - "integrity": "sha512-FHkoUZvG6Egrv9XZAyYGKEyb1JMsFphgPjoczkZC2y6W93U1jswcVURB8MUvtsahEPEVACyxD47JAL63vF4JsQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.3.tgz", + "integrity": "sha512-fwkLWH+DimvA4YCy+/nvJd61nWQQ2liO/nF/RjkTpiOGi+zxZzVkhb1mvbHIIW4b/8nDsYI8uTmAlc0nNkRMOw==", "dev": true }, "true-case-path": { @@ -17791,7 +17847,7 @@ "bail": "1.0.3", "extend": "3.0.1", "is-plain-obj": "1.1.0", - "trough": "1.0.2", + "trough": "1.0.3", "vfile": "2.3.0", "x-is-function": "1.0.4", "x-is-string": "0.1.0" diff --git a/package.json b/package.json index 8c06bc55810..005df715c38 100755 --- a/package.json +++ b/package.json @@ -26,9 +26,9 @@ "test:watch": "npm run test -- --watch" }, "devDependencies": { - "autoprefixer": "9.0.1", "@babel/core": "^7.0.0-beta.54", "@babel/plugin-transform-async-to-generator": "^7.0.0-beta.54", + "@fresh-data/framework": "^0.2.0", "@wordpress/api-fetch": "^1.0.1", "@wordpress/babel-plugin-import-jsx-pragma": "^1.0.1", "@wordpress/babel-plugin-makepot": "^2.0.1", @@ -44,6 +44,7 @@ "@wordpress/keycodes": "^1.0.1", "@wordpress/postcss-themes": "^1.0.1", "@wordpress/scripts": "^2.0.2", + "autoprefixer": "9.0.1", "babel-core": "^7.0.0-bridge.0", "babel-eslint": "^8.2.6", "babel-loader": "^8.0.0-beta.4", @@ -69,11 +70,12 @@ "postcss-loader": "^2.1.6", "prettier": "github:automattic/calypso-prettier#c56b4251", "prop-types": "^15.6.1", - "raw-loader": "^0.5.1", "qs": "^6.5.2", + "raw-loader": "^0.5.1", "react": "^16.3.2", "react-click-outside": "2.3.1", "react-dom": "^16.3.2", + "react-redux": "^5.0.7", "react-router-dom": "^4.2.2", "react-world-flags": "^1.2.4", "readline-sync": "^1.4.9",