diff --git a/docs/designers-developers/developers/backwards-compatibility/deprecations.md b/docs/designers-developers/developers/backwards-compatibility/deprecations.md index 804f55c0a2c19a..83a875545e851a 100644 --- a/docs/designers-developers/developers/backwards-compatibility/deprecations.md +++ b/docs/designers-developers/developers/backwards-compatibility/deprecations.md @@ -2,6 +2,10 @@ Gutenberg's deprecation policy is intended to support backwards-compatibility for releases, when possible. The current deprecations are listed below and are grouped by _the version at which they will be removed completely_. If your plugin depends on these behaviors, you must update to the recommended alternative before the noted version. +## 4.6.0 + +- `wp.data` `registry.use` has been deprecated. Please use `registry.registerGenericStore` for custom functionality instead. + ## 4.5.0 - `Dropdown.refresh()` has been deprecated as the contained `Popover` is now automatically refreshed. - `wp.editor.PostPublishPanelToggle` has been deprecated in favor of `wp.editor.PostPublishButton`. diff --git a/lib/client-assets.php b/lib/client-assets.php index fd1553c3cfd87b..53d639bec6d292 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -207,9 +207,7 @@ function gutenberg_register_scripts_and_styles() { '( function() {', ' var userId = ' . get_current_user_ID() . ';', ' var storageKey = "WP_DATA_USER_" + userId;', - ' wp.data', - ' .use( wp.data.plugins.persistence, { storageKey: storageKey } )', - ' .use( wp.data.plugins.controls );', + ' wp.data.setPersistenceDefaults( { storageKey: storageKey } );', '} )()', ) ) diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md index 18139501460cc3..df2c96ceed2b08 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -1,3 +1,13 @@ +## 4.1.0 (Unreleased) + +### Deprecations + +- `registry.use` has been deprecated. Use `registry.registerGenericStore` for custom functionality instead. + +### Internal + +- Change internal plugins `persistence` and `controls` to built-in features of `namespace-store` + ## 4.0.1 (2018-11-20) ## 4.0.0 (2018-11-15) diff --git a/packages/data/README.md b/packages/data/README.md index f6ca4c665c8fff..30cd6b124ceebb 100644 --- a/packages/data/README.md +++ b/packages/data/README.md @@ -131,14 +131,16 @@ The `resolvers` option should be passed as an object where each key is the name ### `controls` -_**Note:** Controls are an opt-in feature, enabled via `use` (the [Plugins API](https://github.com/WordPress/gutenberg/tree/master/packages/data/src/plugins))._ - A **control** defines the execution flow behavior associated with a specific action type. This can be particularly useful in implementing asynchronous data flows for your store. By defining your action creator or resolvers as a generator which yields specific controlled action types, the execution will proceed as defined by the control handler. The `controls` option should be passed as an object where each key is the name of the action type to act upon, the value a function which receives the original action object. It should returns either a promise which is to resolve when evaluation of the action should continue, or a value. The value or resolved promise value is assigned on the return value of the yield assignment. If the control handler returns undefined, the execution is not continued. Refer to the [documentation of `@wordpress/redux-routine`](https://github.com/WordPress/gutenberg/tree/master/packages/redux-routine/) for more information. +### `persist` + +The `persist` option can be passed as an array of strings indicating which top-level keys for the store should be persisted. This modifies the store to persist data as each action is resolved, and then load from persistence upon start. + ## Data Access and Manipulation It is very rare that you should access store methods directly. Instead, the following suite of functions and higher-order components is provided for the most common data access and manipulation needs. diff --git a/packages/data/src/index.js b/packages/data/src/index.js index e1b9a5fcb1e555..7293011450f230 100644 --- a/packages/data/src/index.js +++ b/packages/data/src/index.js @@ -7,13 +7,12 @@ import combineReducers from 'turbo-combine-reducers'; * Internal dependencies */ import defaultRegistry from './default-registry'; -import * as plugins from './plugins'; export { default as withSelect } from './components/with-select'; export { default as withDispatch } from './components/with-dispatch'; export { default as RegistryProvider, RegistryConsumer } from './components/registry-provider'; export { createRegistry } from './registry'; -export { plugins }; +export { setDefaults as setPersistenceDefaults } from './persistence'; /** * The combineReducers helper function turns an object whose values are different diff --git a/packages/data/src/namespace-store.js b/packages/data/src/namespace-store.js index 2b8c283c3a478f..d35aecbf532134 100644 --- a/packages/data/src/namespace-store.js +++ b/packages/data/src/namespace-store.js @@ -3,16 +3,23 @@ */ import { createStore, applyMiddleware } from 'redux'; import { + flow, flowRight, get, mapValues, } from 'lodash'; +/** + * WordPress dependencies + */ +import createMiddleware from '@wordpress/redux-routine'; + /** * Internal dependencies */ import promise from './promise-middleware'; import createResolversCacheMiddleware from './resolvers-cache-middleware'; +import { createPersistOnChange, createPersistenceInterface, withInitialState } from './persistence'; /** * Creates a namespace object with a store derived from the reducer given. @@ -24,8 +31,27 @@ import createResolversCacheMiddleware from './resolvers-cache-middleware'; * @return {Object} Store Object. */ export default function createNamespace( key, options, registry ) { - const reducer = options.reducer; - const store = createReduxStore( reducer, key, registry ); + let reducer = options.reducer; + let store; + + if ( options.persist ) { + const persistence = createPersistenceInterface( options.persistenceOptions || {} ); + + const initialState = persistence.get()[ key ]; + reducer = withInitialState( options.reducer, initialState ); + store = createReduxStore( reducer, key, registry ); + store.dispatch = flow( [ + store.dispatch, + createPersistOnChange( + store.getState, + key, + options.persist, + persistence + ), + ] ); + } else { + store = createReduxStore( reducer, key, registry ); + } let selectors, actions, resolvers; if ( options.actions ) { @@ -40,6 +66,12 @@ export default function createNamespace( key, options, registry ) { resolvers = result.resolvers; selectors = result.selectors; } + if ( options.controls ) { + const middleware = createMiddleware( options.controls ); + const enhancer = applyMiddleware( middleware ); + + Object.assign( store, enhancer( () => store )( reducer ) ); + } const getSelectors = () => selectors; const getActions = () => actions; diff --git a/packages/data/src/persistence/index.js b/packages/data/src/persistence/index.js new file mode 100644 index 00000000000000..2fdf5a9d81c2b2 --- /dev/null +++ b/packages/data/src/persistence/index.js @@ -0,0 +1,134 @@ +/** + * External dependencies + */ +import { pick } from 'lodash'; + +/** + * Internal dependencies + */ +import defaultStorage from './storage/default'; + +/** + * Persistence options. + * + * @property {Storage} storage Persistent storage implementation. This must + * at least implement `getItem` and `setItem` of + * the Web Storage API. + * @property {string} storageKey Key on which to set in persistent storage. + * + * @typedef {WPDataPersistenceOptions} + */ + +/** + * Default persistence options. + */ +let DEFAULT_OPTIONS = { + storage: defaultStorage, + storageKey: 'WP_DATA', +}; + +/** + * Sets default options for the persistence. + * This function merges the options given with the defaults already set. + * + * @param {WPDataPersistenceOptions} options The defaults to be set (e.g. 'storage', or 'storageKey') + */ +export function setDefaults( options ) { + DEFAULT_OPTIONS = { ...DEFAULT_OPTIONS, ...options }; +} + +/** + * Higher-order reducer to provides an initial value when state is undefined. + * + * @param {Function} reducer Original reducer. + * @param {*} initialState Value to use as initial state. + * + * @return {Function} Enhanced reducer. + */ +export function withInitialState( reducer, initialState ) { + return ( state = initialState, action ) => { + return reducer( state, action ); + }; +} + +/** + * Creates a persistence interface, exposing getter and setter methods (`get` + * and `set` respectively). + * + * @param {WPDataPersistenceOptions} options Persistence options. + * + * @return {Object} Persistence interface. + */ +export function createPersistenceInterface( options ) { + const { storage, storageKey } = { ...DEFAULT_OPTIONS, ...options }; + + let data; + + /** + * Returns the persisted data as an object, defaulting to an empty object. + * + * @return {Object} Persisted data. + */ + function get() { + if ( data === undefined ) { + // If unset, getItem is expected to return null. Fall back to + // empty object. + const persisted = storage.getItem( storageKey ); + if ( persisted === null ) { + data = {}; + } else { + try { + data = JSON.parse( persisted ); + } catch ( error ) { + // Similarly, should any error be thrown during parse of + // the string (malformed JSON), fall back to empty object. + data = {}; + } + } + } + + return data; + } + + /** + * Merges an updated reducer state into the persisted data. + * + * @param {string} key Key to update. + * @param {*} value Updated value. + */ + function set( key, value ) { + data = { ...data, [ key ]: value }; + storage.setItem( storageKey, JSON.stringify( data ) ); + } + + return { get, set }; +} + +/** + * Creates an enhanced store dispatch function, triggering the state of the + * given reducer key to be persisted when changed. + * + * @param {Function} getState Function which returns current state. + * @param {string} reducerKey Reducer key. + * @param {?Array} keys Optional subset of keys to save. + * @param {Object} persistence The persistence interface to be used. + * + * @return {Function} Enhanced dispatch function. + */ +export function createPersistOnChange( getState, reducerKey, keys, persistence ) { + let lastState = getState(); + + return ( result ) => { + let state = getState(); + if ( state !== lastState ) { + if ( Array.isArray( keys ) ) { + state = pick( state, keys ); + } + + persistence.set( reducerKey, state ); + lastState = state; + } + + return result; + }; +} diff --git a/packages/data/src/plugins/persistence/storage/default.js b/packages/data/src/persistence/storage/default.js similarity index 100% rename from packages/data/src/plugins/persistence/storage/default.js rename to packages/data/src/persistence/storage/default.js diff --git a/packages/data/src/plugins/persistence/storage/object.js b/packages/data/src/persistence/storage/object.js similarity index 100% rename from packages/data/src/plugins/persistence/storage/object.js rename to packages/data/src/persistence/storage/object.js diff --git a/packages/data/src/plugins/persistence/storage/test/object.js b/packages/data/src/persistence/storage/test/object.js similarity index 100% rename from packages/data/src/plugins/persistence/storage/test/object.js rename to packages/data/src/persistence/storage/test/object.js diff --git a/packages/data/src/persistence/test/index.js b/packages/data/src/persistence/test/index.js new file mode 100644 index 00000000000000..a702d511dfa0a6 --- /dev/null +++ b/packages/data/src/persistence/test/index.js @@ -0,0 +1,166 @@ +/** + * External dependencies + */ +import { pick } from 'lodash'; + +/** + * Internal dependencies + */ +import { + createPersistenceInterface, + createPersistOnChange, + setDefaults, + withInitialState, +} from '../'; +import objectStorage from '../storage/object'; + +describe( 'persistence', () => { + beforeAll( () => { + jest.spyOn( objectStorage, 'setItem' ); + } ); + + beforeEach( () => { + objectStorage.clear(); + objectStorage.setItem.mockClear(); + } ); + + describe( 'createPersistOnChange', () => { + it( 'should not persist when state matches initial', () => { + // Caveat: State is compared by strict equality. This doesn't work for + // object types in rehydrating from persistence, since: + // JSON.parse( {} ) !== JSON.parse( {} ) + // It's more important for runtime to check equal-ness, which is + // expected to be reflected even for object types by reducer. + const state = 1; + const persistence = createPersistenceInterface( { storage: objectStorage, storageKey: 'FOO' } ); + + objectStorage.setItem( 'WP_DATA', JSON.stringify( { test: state } ) ); + objectStorage.setItem.mockClear(); + + const persist = createPersistOnChange( () => state, 'test', true, persistence ); + persist(); + + expect( objectStorage.setItem ).not.toHaveBeenCalled(); + } ); + + it( 'should persist when state changes', () => { + const initialState = { value: 'initial' }; + const nextState = { value: 'next' }; + + const persistence = createPersistenceInterface( { storage: objectStorage, storageKey: 'FOO' } ); + + let state = initialState; + function getState() { + return state; + } + const persist = createPersistOnChange( getState, 'test', true, persistence ); + + state = nextState; + persist(); + + expect( objectStorage.setItem ).toHaveBeenCalledTimes( 1 ); + expect( objectStorage.setItem ).toHaveBeenCalledWith( 'FOO', JSON.stringify( { test: nextState } ) ); + } ); + + it( 'should persist a subset of keys', () => { + const initialState = { persistValue: 'initial', nonPersistedValue: 'initial' }; + const nextState = { persistValue: 'next', nonPersistedValue: 'next' }; + + const persistence = createPersistenceInterface( { storage: objectStorage, storageKey: 'FOO' } ); + + let state = initialState; + function getState() { + return state; + } + const persist = createPersistOnChange( getState, 'test', [ 'persistValue' ], persistence ); + + state = nextState; + persist(); + + const pickState = pick( nextState, [ 'persistValue' ] ); + expect( objectStorage.setItem ).toHaveBeenCalledTimes( 1 ); + expect( objectStorage.setItem ).toHaveBeenCalledWith( 'FOO', JSON.stringify( { test: pickState } ) ); + } ); + } ); + + describe( 'createPersistenceInterface', () => { + const storage = objectStorage; + const storageKey = 'FOO'; + + let get, set; + beforeEach( () => { + ( { get, set } = createPersistenceInterface( { storage, storageKey } ) ); + } ); + + describe( 'get', () => { + it( 'returns an empty object if not set', () => { + const data = get(); + + expect( data ).toEqual( {} ); + } ); + + it( 'returns the current value', () => { + objectStorage.setItem( storageKey, '{"test":{}}' ); + const data = get(); + + expect( data ).toEqual( { test: {} } ); + } ); + } ); + + describe( 'set', () => { + it( 'sets JSON by object', () => { + set( 'test', {} ); + + expect( objectStorage.setItem ).toHaveBeenCalledWith( storageKey, '{"test":{}}' ); + } ); + + it( 'merges to existing', () => { + set( 'test1', {} ); + set( 'test2', {} ); + + expect( objectStorage.setItem ).toHaveBeenCalledWith( storageKey, '{"test1":{}}' ); + expect( objectStorage.setItem ).toHaveBeenCalledWith( storageKey, '{"test1":{},"test2":{}}' ); + } ); + } ); + } ); + + describe( 'withInitialState', () => { + it( 'should return a reducer function', () => { + const reducer = ( state = 1 ) => state; + const enhanced = withInitialState( reducer ); + + expect( enhanced() ).toBe( 1 ); + } ); + + it( 'should assign a default state by argument', () => { + const reducer = ( state = 1 ) => state; + const enhanced = withInitialState( reducer, 2 ); + + expect( enhanced() ).toBe( 2 ); + } ); + } ); + + describe( 'setDefaults', () => { + it( 'sets default options for persistence', () => { + const mockStorage = { + getItem: jest.fn( ( key ) => ( key + '_VALUE' ) ), + setItem: jest.fn(), + }; + + setDefaults( { storage: mockStorage, storageKey: 'testStorage' } ); + + const persistence = createPersistenceInterface(); + + persistence.get(); + expect( mockStorage.getItem ).toHaveBeenCalledTimes( 1 ); + expect( mockStorage.getItem ).toHaveBeenCalledWith( 'testStorage' ); + + persistence.set( 'myKey', 'myValue' ); + expect( mockStorage.setItem ).toHaveBeenCalledTimes( 1 ); + expect( mockStorage.setItem ).toHaveBeenCalledWith( + 'testStorage', + JSON.stringify( { myKey: 'myValue' } ) + ); + } ); + } ); +} ); diff --git a/packages/data/src/plugins/README.md b/packages/data/src/plugins/README.md deleted file mode 100644 index 42e48d1813777c..00000000000000 --- a/packages/data/src/plugins/README.md +++ /dev/null @@ -1,17 +0,0 @@ -Data Plugins -============ - -Included here are a set of default plugin integrations for the WordPress data module. - -## Usage - -For any of the plugins included here as directories, call the `use` method to include its behaviors in the registry. - -```js -// npm Usage -import { plugins, use } from '@wordpress/data'; -use( plugins.persistence ); - -// WordPress Globals Usage -wp.data.use( wp.data.plugins.persistence ); -``` diff --git a/packages/data/src/plugins/controls/index.js b/packages/data/src/plugins/controls/index.js deleted file mode 100644 index bc19c9cfad897f..00000000000000 --- a/packages/data/src/plugins/controls/index.js +++ /dev/null @@ -1,32 +0,0 @@ -/** - * External dependencies - */ -import { applyMiddleware } from 'redux'; - -/** - * WordPress dependencies - */ -import createMiddleware from '@wordpress/redux-routine'; - -export default function( registry ) { - return { - registerStore( reducerKey, options ) { - const store = registry.registerStore( reducerKey, options ); - - if ( options.controls ) { - const middleware = createMiddleware( options.controls ); - const enhancer = applyMiddleware( middleware ); - const createStore = () => store; - - Object.assign( - store, - enhancer( createStore )( options.reducer ) - ); - - registry.namespaces[ reducerKey ].supportControls = true; - } - - return store; - }, - }; -} diff --git a/packages/data/src/plugins/index.js b/packages/data/src/plugins/index.js deleted file mode 100644 index 587768f415911a..00000000000000 --- a/packages/data/src/plugins/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as controls } from './controls'; -export { default as persistence } from './persistence'; diff --git a/packages/data/src/plugins/persistence/README.md b/packages/data/src/plugins/persistence/README.md deleted file mode 100644 index ed21472ee595de..00000000000000 --- a/packages/data/src/plugins/persistence/README.md +++ /dev/null @@ -1,36 +0,0 @@ -Persistence Plugin -================== - -The persistence plugin enhances a registry to enable registered stores to opt in to persistent storage. - -By default, persistence occurs by [`localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). This can be changed using the [`setStorage` function](#api) defined on the plugin. Unless set otherwise, state will be persisted on the `WP_DATA` key in storage. - -## Usage - -Call the `use` method on the default or your own registry to include the persistence plugin: - -```js -wp.data.use( wp.data.plugins.persistence, { storageKey: 'example' } ); -``` - -Then, when registering a store, set a `persist` property as `true` (persist all state) or an array of state keys to persist. - -```js -wp.data.registerStore( 'my-plugin', { - // ... - - persist: [ 'preferences' ], -} ); -``` - -## Options - -### `storage` - -Persistent storage implementation. This must at least implement `getItem` and `setItem` of the Web Storage API. - -See: https://developer.mozilla.org/en-US/docs/Web/API/Storage - -### `storageKey` - -The key on which to set in persistent storage. diff --git a/packages/data/src/plugins/persistence/index.js b/packages/data/src/plugins/persistence/index.js deleted file mode 100644 index b619f2622b068a..00000000000000 --- a/packages/data/src/plugins/persistence/index.js +++ /dev/null @@ -1,172 +0,0 @@ -/** - * External dependencies - */ -import { pick, flow } from 'lodash'; - -/** - * Internal dependencies - */ -import defaultStorage from './storage/default'; - -/** - * Persistence plugin options. - * - * @property {Storage} storage Persistent storage implementation. This must - * at least implement `getItem` and `setItem` of - * the Web Storage API. - * @property {string} storageKey Key on which to set in persistent storage. - * - * @typedef {WPDataPersistencePluginOptions} - */ - -/** - * Default plugin storage. - * - * @type {Storage} - */ -const DEFAULT_STORAGE = defaultStorage; - -/** - * Default plugin storage key. - * - * @type {string} - */ -const DEFAULT_STORAGE_KEY = 'WP_DATA'; - -/** - * Higher-order reducer to provides an initial value when state is undefined. - * - * @param {Function} reducer Original reducer. - * @param {*} initialState Value to use as initial state. - * - * @return {Function} Enhanced reducer. - */ -export function withInitialState( reducer, initialState ) { - return ( state = initialState, action ) => { - return reducer( state, action ); - }; -} - -/** - * Creates a persistence interface, exposing getter and setter methods (`get` - * and `set` respectively). - * - * @param {WPDataPersistencePluginOptions} options Plugin options. - * - * @return {Object} Persistence interface. - */ -export function createPersistenceInterface( options ) { - const { - storage = DEFAULT_STORAGE, - storageKey = DEFAULT_STORAGE_KEY, - } = options; - - let data; - - /** - * Returns the persisted data as an object, defaulting to an empty object. - * - * @return {Object} Persisted data. - */ - function get() { - if ( data === undefined ) { - // If unset, getItem is expected to return null. Fall back to - // empty object. - const persisted = storage.getItem( storageKey ); - if ( persisted === null ) { - data = {}; - } else { - try { - data = JSON.parse( persisted ); - } catch ( error ) { - // Similarly, should any error be thrown during parse of - // the string (malformed JSON), fall back to empty object. - data = {}; - } - } - } - - return data; - } - - /** - * Merges an updated reducer state into the persisted data. - * - * @param {string} key Key to update. - * @param {*} value Updated value. - */ - function set( key, value ) { - data = { ...data, [ key ]: value }; - storage.setItem( storageKey, JSON.stringify( data ) ); - } - - return { get, set }; -} - -/** - * Data plugin to persist store state into a single storage key. - * - * @param {WPDataRegistry} registry Data registry. - * @param {?WPDataPersistencePluginOptions} pluginOptions Plugin options. - * - * @return {WPDataPlugin} Data plugin. - */ -export default function( registry, pluginOptions ) { - const persistence = createPersistenceInterface( pluginOptions ); - - /** - * Creates an enhanced store dispatch function, triggering the state of the - * given reducer key to be persisted when changed. - * - * @param {Function} getState Function which returns current state. - * @param {string} reducerKey Reducer key. - * @param {?Array} keys Optional subset of keys to save. - * - * @return {Function} Enhanced dispatch function. - */ - function createPersistOnChange( getState, reducerKey, keys ) { - let lastState = getState(); - - return ( result ) => { - let state = getState(); - if ( state !== lastState ) { - if ( Array.isArray( keys ) ) { - state = pick( state, keys ); - } - - persistence.set( reducerKey, state ); - lastState = state; - } - - return result; - }; - } - - return { - registerStore( reducerKey, options ) { - if ( ! options.persist ) { - return registry.registerStore( reducerKey, options ); - } - - const initialState = persistence.get()[ reducerKey ]; - - options = { - ...options, - reducer: withInitialState( options.reducer, initialState ), - }; - - const store = registry.registerStore( reducerKey, options ); - - store.dispatch = flow( [ - store.dispatch, - createPersistOnChange( - store.getState, - reducerKey, - options.persist - ), - ] ); - - return store; - }, - }; -} diff --git a/packages/data/src/plugins/persistence/test/index.js b/packages/data/src/plugins/persistence/test/index.js deleted file mode 100644 index fc04e395cb4a2d..00000000000000 --- a/packages/data/src/plugins/persistence/test/index.js +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Internal dependencies - */ -import plugin, { - createPersistenceInterface, - withInitialState, -} from '../'; -import objectStorage from '../storage/object'; -import { createRegistry } from '../../../'; - -describe( 'persistence', () => { - let registry, originalRegisterStore; - - beforeAll( () => { - jest.spyOn( objectStorage, 'setItem' ); - } ); - - beforeEach( () => { - objectStorage.clear(); - objectStorage.setItem.mockClear(); - - // Since the exposed `registerStore` is a proxying function, mimic - // intercept of original call by adding an initial plugin. - // TODO: Remove the `use` function in favor of `registerGenericStore` - registry = createRegistry() - .use( ( originalRegistry ) => { - originalRegisterStore = jest.spyOn( originalRegistry, 'registerStore' ); - return {}; - } ) - .use( plugin, { storage: objectStorage } ); - } ); - - it( 'should not mutate options', () => { - const options = Object.freeze( { persist: true, reducer() {} } ); - - registry.registerStore( 'test', options ); - } ); - - it( 'override values passed to registerStore', () => { - const options = { persist: true, reducer() {} }; - - registry.registerStore( 'test', options ); - - expect( originalRegisterStore ).toHaveBeenCalledWith( 'test', { - persist: true, - reducer: expect.any( Function ), - } ); - // Replaced reducer: - expect( originalRegisterStore.mock.calls[ 0 ][ 1 ].reducer ).not.toBe( options.reducer ); - } ); - - it( 'should not persist if option not passed', () => { - const initialState = { foo: 'bar', baz: 'qux' }; - function reducer( state = initialState, action ) { - return action.nextState || state; - } - - registry.registerStore( 'test', { - reducer, - actions: { - setState( nextState ) { - return { type: 'SET_STATE', nextState }; - }, - }, - } ); - - registry.dispatch( 'test' ).setState( { ok: true } ); - - expect( objectStorage.setItem ).not.toHaveBeenCalled(); - } ); - - it( 'should not persist when state matches initial', () => { - // Caveat: State is compared by strict equality. This doesn't work for - // object types in rehydrating from persistence, since: - // JSON.parse( {} ) !== JSON.parse( {} ) - // It's more important for runtime to check equal-ness, which is - // expected to be reflected even for object types by reducer. - const state = 1; - const reducer = () => state; - - objectStorage.setItem( 'WP_DATA', JSON.stringify( { test: state } ) ); - objectStorage.setItem.mockClear(); - - registry.registerStore( 'test', { - reducer, - persist: true, - actions: { - doNothing() { - return { type: 'NOTHING' }; - }, - }, - } ); - - registry.dispatch( 'test' ).doNothing(); - - expect( objectStorage.setItem ).not.toHaveBeenCalled(); - } ); - - it( 'should persist when state changes', () => { - const initialState = { foo: 'bar', baz: 'qux' }; - function reducer( state = initialState, action ) { - return action.nextState || state; - } - - registry.registerStore( 'test', { - reducer, - persist: true, - actions: { - setState( nextState ) { - return { type: 'SET_STATE', nextState }; - }, - }, - } ); - - registry.dispatch( 'test' ).setState( { ok: true } ); - - expect( objectStorage.setItem ).toHaveBeenCalledWith( 'WP_DATA', '{"test":{"ok":true}}' ); - } ); - - it( 'should persist a subset of keys', () => { - const initialState = { foo: 'bar', baz: 'qux' }; - function reducer( state = initialState, action ) { - return action.nextState || state; - } - - registry.registerStore( 'test', { - reducer, - persist: [ 'foo' ], - actions: { - setState( nextState ) { - return { type: 'SET_STATE', nextState }; - }, - }, - } ); - - registry.dispatch( 'test' ).setState( { foo: 1, baz: 2 } ); - - expect( objectStorage.setItem ).toHaveBeenCalledWith( 'WP_DATA', '{"test":{"foo":1}}' ); - } ); - - describe( 'createPersistenceInterface', () => { - const storage = objectStorage; - const storageKey = 'FOO'; - - let get, set; - beforeEach( () => { - ( { get, set } = createPersistenceInterface( { storage, storageKey } ) ); - } ); - - describe( 'get', () => { - it( 'returns an empty object if not set', () => { - const data = get(); - - expect( data ).toEqual( {} ); - } ); - - it( 'returns the current value', () => { - objectStorage.setItem( storageKey, '{"test":{}}' ); - const data = get(); - - expect( data ).toEqual( { test: {} } ); - } ); - } ); - - describe( 'set', () => { - it( 'sets JSON by object', () => { - set( 'test', {} ); - - expect( objectStorage.setItem ).toHaveBeenCalledWith( storageKey, '{"test":{}}' ); - } ); - - it( 'merges to existing', () => { - set( 'test1', {} ); - set( 'test2', {} ); - - expect( objectStorage.setItem ).toHaveBeenCalledWith( storageKey, '{"test1":{}}' ); - expect( objectStorage.setItem ).toHaveBeenCalledWith( storageKey, '{"test1":{},"test2":{}}' ); - } ); - } ); - } ); - - describe( 'withInitialState', () => { - it( 'should return a reducer function', () => { - const reducer = ( state = 1 ) => state; - const enhanced = withInitialState( reducer ); - - expect( enhanced() ).toBe( 1 ); - } ); - - it( 'should assign a default state by argument', () => { - const reducer = ( state = 1 ) => state; - const enhanced = withInitialState( reducer, 2 ); - - expect( enhanced() ).toBe( 2 ); - } ); - } ); -} ); diff --git a/packages/data/src/registry.js b/packages/data/src/registry.js index acca73b93129a3..b0f01000904536 100644 --- a/packages/data/src/registry.js +++ b/packages/data/src/registry.js @@ -6,6 +6,11 @@ import { mapValues, } from 'lodash'; +/** + * WordPress dependencies + */ +import deprecated from '@wordpress/deprecated'; + /** * Internal dependencies */ @@ -154,10 +159,15 @@ export function createRegistry( storeConfigs = {} ) { }; // - // TODO: - // This function will be deprecated as soon as it is no longer internally referenced. + // Deprecated // function use( plugin, options ) { + deprecated( 'registry.use', { + alternative: 'registry.registerGenericStore', + plugin: 'Gutenberg', + version: '4.6.0', + } ); + registry = { ...registry, ...plugin( registry, options ), diff --git a/packages/data/src/test/namespace-store.js b/packages/data/src/test/namespace-store.js new file mode 100644 index 00000000000000..fad292da520f9a --- /dev/null +++ b/packages/data/src/test/namespace-store.js @@ -0,0 +1,128 @@ +/** + * External dependencies + */ +import { pick } from 'lodash'; + +/** + * Internal dependencies + */ +import createNamespace from '../namespace-store'; +import { createRegistry } from '../registry'; +import objectStorage from '../persistence/storage/object'; + +describe( 'createNamespace', () => { + let registry; + + beforeAll( () => { + jest.spyOn( objectStorage, 'getItem' ); + jest.spyOn( objectStorage, 'setItem' ); + } ); + + beforeEach( () => { + objectStorage.clear(); + objectStorage.getItem.mockClear(); + objectStorage.setItem.mockClear(); + + registry = createRegistry(); + } ); + + it( 'should not mutate options', () => { + const options = Object.freeze( { persist: true, reducer() {} } ); + + createNamespace( 'test', options, registry ); + } ); + + it( 'overrides values passed to registerStore', () => { + const options = { persist: true, reducer() {} }; + + const namespace = createNamespace( 'test', options, registry ); + + expect( namespace.reducer ).not.toBe( options.reducer ); + } ); + + it( 'should not persist if option not passed', () => { + const options = { + reducer: ( state ) => state, + selectors: {}, + persistenceOptions: { + storage: objectStorage, + storageKey: 'FOO', + }, + }; + createNamespace( 'test', options, registry ); + + expect( objectStorage.getItem ).not.toHaveBeenCalled(); + } ); + + it( 'loads initial state from persistence', () => { + const options = { + reducer: ( state ) => state, + selectors: {}, + persist: true, + persistenceOptions: { + storage: objectStorage, + storageKey: 'FOO', + }, + }; + createNamespace( 'test', options, registry ); + + expect( objectStorage.getItem ).toHaveBeenCalledTimes( 1 ); + expect( objectStorage.getItem ).toHaveBeenCalledWith( 'FOO' ); + } ); + + it( 'saves state to persistence upon action dispatch', () => { + const initialState = { value: 'initial' }; + const nextState = { value: 'next' }; + + const options = { + reducer: ( state = initialState, action ) => { + return ( 'GO' === action.type ? action.state : state ); + }, + selectors: {}, + actions: { + go() { + return { type: 'GO', state: nextState }; + }, + }, + persist: true, + persistenceOptions: { + storage: objectStorage, + storageKey: 'FOO', + }, + }; + const namespace = createNamespace( 'test', options, registry ); + namespace.getActions().go(); + + expect( objectStorage.setItem ).toHaveBeenCalledTimes( 1 ); + expect( objectStorage.setItem ).toHaveBeenCalledWith( 'FOO', JSON.stringify( { test: nextState } ) ); + } ); + + it( 'only persists the keys given', () => { + const initialState = { persistedValue: 'initial', nonPersistedValue: 'initial' }; + const nextState = { persistedValue: 'next', nonPersistedValue: 'next' }; + + const options = { + reducer: ( state = initialState, action ) => { + return ( 'GO' === action.type ? action.state : state ); + }, + selectors: {}, + actions: { + go() { + return { type: 'GO', state: nextState }; + }, + }, + persist: [ 'persistedValue' ], + persistenceOptions: { + storage: objectStorage, + storageKey: 'FOO', + }, + }; + const namespace = createNamespace( 'test', options, registry ); + namespace.getActions().go(); + + const pickState = pick( nextState, [ 'persistedValue' ] ); + expect( objectStorage.setItem ).toHaveBeenCalledTimes( 1 ); + expect( objectStorage.setItem ).toHaveBeenCalledWith( 'FOO', JSON.stringify( { test: pickState } ) ); + } ); +} ); + diff --git a/packages/data/src/test/registry.js b/packages/data/src/test/registry.js index 70dc39fae0a070..0b9e596459d353 100644 --- a/packages/data/src/test/registry.js +++ b/packages/data/src/test/registry.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { castArray, mapValues } from 'lodash'; +import { castArray } from 'lodash'; /** * Internal dependencies @@ -537,48 +537,4 @@ describe( 'createRegistry', () => { expect( store.getState() ).toBe( 5 ); } ); } ); - - describe( 'use', () => { - it( 'should pass through options object to plugin', () => { - const expectedOptions = {}; - let actualOptions; - - function plugin( _registry, options ) { - // The registry passed to a plugin is not the same as the one - // returned by createRegistry, as the former uses the internal - // representation of the object, the latter applying its - // function proxying. - expect( _registry ).toMatchObject( - mapValues( registry, ( value, key ) => { - if ( key === 'stores' ) { - return expect.any( Object ); - } - // TODO: Remove this after namsespaces is removed. - if ( key === 'namespaces' ) { - return registry.stores; - } - return expect.any( Function ); - } ) - ); - - actualOptions = options; - - return {}; - } - - registry.use( plugin, expectedOptions ); - - expect( actualOptions ).toBe( expectedOptions ); - } ); - - it( 'should override base method', () => { - function plugin( _registry, options ) { - return { select: () => options.value }; - } - - registry.use( plugin, { value: 10 } ); - - expect( registry.select() ).toBe( 10 ); - } ); - } ); } ); diff --git a/test/unit/__mocks__/@wordpress/data.js b/test/unit/__mocks__/@wordpress/data.js index 1de2900bc8b200..4cc6eeae166002 100644 --- a/test/unit/__mocks__/@wordpress/data.js +++ b/test/unit/__mocks__/@wordpress/data.js @@ -1,3 +1 @@ -import { use, plugins } from '../../../../packages/data/src'; -use( plugins.controls ); export * from '../../../../packages/data/src';