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",