diff --git a/docs/manifest.json b/docs/manifest.json index 91bebab753a3bd..e39cc0a286217d 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1709,6 +1709,12 @@ "markdown_source": "../packages/postcss-themes/README.md", "parent": "packages" }, + { + "title": "@wordpress/preferences-persistence", + "slug": "packages-preferences-persistence", + "markdown_source": "../packages/preferences-persistence/README.md", + "parent": "packages" + }, { "title": "@wordpress/preferences", "slug": "packages-preferences", diff --git a/lib/compat/wordpress-6.1/persisted-preferences.php b/lib/compat/wordpress-6.1/persisted-preferences.php new file mode 100644 index 00000000000000..e62c62d3185ac9 --- /dev/null +++ b/lib/compat/wordpress-6.1/persisted-preferences.php @@ -0,0 +1,106 @@ +get_blog_prefix() . 'persisted_preferences'; + + register_meta( + 'user', + $meta_key, + array( + 'type' => 'object', + 'single' => true, + 'show_in_rest' => array( + 'name' => 'persisted_preferences', + 'type' => 'object', + 'context' => array( 'edit' ), + 'schema' => array( + 'type' => 'object', + 'properties' => array( + '_modified' => array( + 'description' => __( 'The date and time the preferences were updated.', 'default' ), + 'type' => 'string', + 'format' => 'date-time', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + ), + 'additionalProperties' => true, + ), + ), + ) + ); +} + +add_action( 'init', 'gutenberg_register_persisted_preferences_meta' ); + +/** + * Configures the preferences package to use user meta persistence. + */ +function gutenberg_configure_persisted_preferences() { + $user_id = get_current_user_id(); + if ( empty( $user_id ) ) { + return; + } + + global $wpdb; + $meta_key = $wpdb->get_blog_prefix() . 'persisted_preferences'; + + $preload_data = get_user_meta( $user_id, $meta_key, true ); + + wp_add_inline_script( + 'wp-preferences', + sprintf( + '( function() { + var serverData = %s; + var userId = "%s"; + var persistenceLayer = wp.preferencesPersistence.__unstableCreatePersistenceLayer( serverData, userId ); + var preferencesStore = wp.preferences.store; + wp.data.dispatch( preferencesStore ).setPersistenceLayer( persistenceLayer ); + } ) ();', + wp_json_encode( $preload_data ), + $user_id + ), + 'after' + ); + +} + +add_action( 'admin_init', 'gutenberg_configure_persisted_preferences' ); + +/** + * Register dependencies for the inline script that configures the persistence layer. + * + * Note: When porting this to core update the code here: + * https://github.com/WordPress/wordpress-develop/blob/d2ab3d183740c3d1252cb921b18005495007e022/src/wp-includes/script-loader.php#L251-L258 + * + * And make the same update to the gutenberg client assets file here: + * https://github.com/WordPress/gutenberg/blob/3f3c8df23c70a37b7ac4dddebc82030362133593/lib/client-assets.php#L242-L254 + * + * The update should be adding a new case like this like this: + * ``` + * case 'wp-preferences': + * array_push( $dependencies, 'wp-preferences-persistence' ); + * break; + * ``` + * + * @param WP_Scripts $scripts An instance of WP_Scripts. + */ +function gutenberg_update_preferences_persistence_deps( $scripts ) { + $persistence_script = $scripts->query( 'wp-preferences', 'registered' ); + if ( isset( $persistence_script->deps ) ) { + array_push( $persistence_script->deps, 'wp-preferences-persistence' ); + } +} + +add_action( 'wp_default_scripts', 'gutenberg_update_preferences_persistence_deps', 11 ); diff --git a/lib/load.php b/lib/load.php index 5d153d72b791b8..3c08f8dc3b46b4 100644 --- a/lib/load.php +++ b/lib/load.php @@ -124,6 +124,7 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.1 compat. require __DIR__ . '/compat/wordpress-6.1/blocks.php'; +require __DIR__ . '/compat/wordpress-6.1/persisted-preferences.php'; // Experimental features. require __DIR__ . '/experimental/block-editor-settings-mobile.php'; diff --git a/package-lock.json b/package-lock.json index 9d236ac987e5f6..72330017f4534c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18083,6 +18083,13 @@ "classnames": "^2.3.1" } }, + "@wordpress/preferences-persistence": { + "version": "file:packages/preferences-persistence", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/api-fetch": "file:packages/api-fetch" + } + }, "@wordpress/prettier-config": { "version": "file:packages/prettier-config", "dev": true diff --git a/package.json b/package.json index 5daf6096d8115d..0000b8d3dd249f 100755 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@wordpress/nux": "file:packages/nux", "@wordpress/plugins": "file:packages/plugins", "@wordpress/preferences": "file:packages/preferences", + "@wordpress/preferences-persistence": "file:packages/preferences-persistence", "@wordpress/primitives": "file:packages/primitives", "@wordpress/priority-queue": "file:packages/priority-queue", "@wordpress/react-i18n": "file:packages/react-i18n", diff --git a/packages/data/src/plugins/persistence/index.js b/packages/data/src/plugins/persistence/index.js index f00f8a8fc2b185..2052c7c58b0fee 100644 --- a/packages/data/src/plugins/persistence/index.js +++ b/packages/data/src/plugins/persistence/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { merge, isPlainObject, identity } from 'lodash'; +import { merge, isPlainObject } from 'lodash'; /** * Internal dependencies @@ -222,424 +222,6 @@ function persistencePlugin( registry, pluginOptions ) { }; } -/** - * Move the 'features' object in local storage from the sourceStoreName to the - * preferences store. - * - * @param {Object} persistence The persistence interface. - * @param {string} sourceStoreName The name of the store that has persisted - * preferences to migrate to the preferences - * package. - */ -export function migrateFeaturePreferencesToPreferencesStore( - persistence, - sourceStoreName -) { - const preferencesStoreName = 'core/preferences'; - const interfaceStoreName = 'core/interface'; - - const state = persistence.get(); - - // Features most recently (and briefly) lived in the interface package. - // If data exists there, prioritize using that for the migration. If not - // also check the original package as the user may have updated from an - // older block editor version. - const interfaceFeatures = - state[ interfaceStoreName ]?.preferences?.features?.[ sourceStoreName ]; - const sourceFeatures = state[ sourceStoreName ]?.preferences?.features; - const featuresToMigrate = interfaceFeatures - ? interfaceFeatures - : sourceFeatures; - - if ( featuresToMigrate ) { - const existingPreferences = state[ preferencesStoreName ]?.preferences; - - // Avoid migrating features again if they've previously been migrated. - if ( ! existingPreferences?.[ sourceStoreName ] ) { - // Set the feature values in the interface store, the features - // object is keyed by 'scope', which matches the store name for - // the source. - persistence.set( preferencesStoreName, { - preferences: { - ...existingPreferences, - [ sourceStoreName ]: featuresToMigrate, - }, - } ); - - // Remove migrated feature preferences from `interface`. - if ( interfaceFeatures ) { - const otherInterfaceState = state[ interfaceStoreName ]; - const otherInterfaceScopes = - state[ interfaceStoreName ]?.preferences?.features; - - persistence.set( interfaceStoreName, { - ...otherInterfaceState, - preferences: { - features: { - ...otherInterfaceScopes, - [ sourceStoreName ]: undefined, - }, - }, - } ); - } - - // Remove migrated feature preferences from the source. - if ( sourceFeatures ) { - const otherSourceState = state[ sourceStoreName ]; - const sourcePreferences = state[ sourceStoreName ]?.preferences; - - persistence.set( sourceStoreName, { - ...otherSourceState, - preferences: { - ...sourcePreferences, - features: undefined, - }, - } ); - } - } - } -} - -/** - * Migrates an individual item inside the `preferences` object for a store. - * - * @param {Object} persistence The persistence interface. - * @param {Object} migrate An options object that contains details of the migration. - * @param {string} migrate.from The name of the store to migrate from. - * @param {string} migrate.scope The scope in the preferences store to migrate to. - * @param {string} key The key in the preferences object to migrate. - * @param {?Function} convert A function that converts preferences from one format to another. - */ -export function migrateIndividualPreferenceToPreferencesStore( - persistence, - { from: sourceStoreName, scope }, - key, - convert = identity -) { - const preferencesStoreName = 'core/preferences'; - const state = persistence.get(); - const sourcePreference = state[ sourceStoreName ]?.preferences?.[ key ]; - - // There's nothing to migrate, exit early. - if ( sourcePreference === undefined ) { - return; - } - - const targetPreference = - state[ preferencesStoreName ]?.preferences?.[ scope ]?.[ key ]; - - // There's existing data at the target, so don't overwrite it, exit early. - if ( targetPreference ) { - return; - } - - const otherScopes = state[ preferencesStoreName ]?.preferences; - const otherPreferences = - state[ preferencesStoreName ]?.preferences?.[ scope ]; - - // Pass an object with the key and value as this allows the convert - // function to convert to a data structure that has different keys. - const convertedPreferences = convert( { [ key ]: sourcePreference } ); - - persistence.set( preferencesStoreName, { - preferences: { - ...otherScopes, - [ scope ]: { - ...otherPreferences, - ...convertedPreferences, - }, - }, - } ); - - // Remove migrated feature preferences from the source. - const otherSourceState = state[ sourceStoreName ]; - const allSourcePreferences = state[ sourceStoreName ]?.preferences; - persistence.set( sourceStoreName, { - ...otherSourceState, - preferences: { - ...allSourcePreferences, - [ key ]: undefined, - }, - } ); -} - -/** - * Convert from: - * ``` - * { - * panels: { - * tags: { - * enabled: true, - * opened: true, - * }, - * permalinks: { - * enabled: false, - * opened: false, - * }, - * }, - * } - * ``` - * - * to: - * { - * inactivePanels: [ - * 'permalinks', - * ], - * openPanels: [ - * 'tags', - * ], - * } - * - * @param {Object} preferences A preferences object. - * - * @return {Object} The converted data. - */ -export function convertEditPostPanels( preferences ) { - const panels = preferences?.panels ?? {}; - return Object.keys( panels ).reduce( - ( convertedData, panelName ) => { - const panel = panels[ panelName ]; - - if ( panel?.enabled === false ) { - convertedData.inactivePanels.push( panelName ); - } - - if ( panel?.opened === true ) { - convertedData.openPanels.push( panelName ); - } - - return convertedData; - }, - { inactivePanels: [], openPanels: [] } - ); -} - -export function migrateThirdPartyFeaturePreferencesToPreferencesStore( - persistence -) { - const interfaceStoreName = 'core/interface'; - const preferencesStoreName = 'core/preferences'; - - let state = persistence.get(); - - const interfaceScopes = state[ interfaceStoreName ]?.preferences?.features; - - for ( const scope in interfaceScopes ) { - // Don't migrate any core 'scopes'. - if ( scope.startsWith( 'core' ) ) { - continue; - } - - // Skip this scope if there are no features to migrate. - const featuresToMigrate = interfaceScopes[ scope ]; - if ( ! featuresToMigrate ) { - continue; - } - - const existingPreferences = state[ preferencesStoreName ]?.preferences; - - // Add the data to the preferences store structure. - persistence.set( preferencesStoreName, { - preferences: { - ...existingPreferences, - [ scope ]: featuresToMigrate, - }, - } ); - - // Remove the data from the interface store structure. - // Call `persistence.get` again to make sure `state` is up-to-date with - // any changes from the previous iteration of this loop. - state = persistence.get(); - const otherInterfaceState = state[ interfaceStoreName ]; - const otherInterfaceScopes = - state[ interfaceStoreName ]?.preferences?.features; - - persistence.set( interfaceStoreName, { - ...otherInterfaceState, - preferences: { - features: { - ...otherInterfaceScopes, - [ scope ]: undefined, - }, - }, - } ); - } -} - -/** - * Migrates interface 'enableItems' data to the preferences store. - * - * The interface package stores this data in this format: - * ```js - * { - * enableItems: { - * singleEnableItems: { - * complementaryArea: { - * 'core/edit-post': 'edit-post/document', - * 'core/edit-site': 'edit-site/global-styles', - * } - * }, - * multipleEnableItems: { - * pinnedItems: { - * 'core/edit-post': { - * 'plugin-1': true, - * }, - * 'core/edit-site': { - * 'plugin-2': true, - * }, - * }, - * } - * } - * } - * ``` - * and it should be migrated it to: - * ```js - * { - * 'core/edit-post': { - * complementaryArea: 'edit-post/document', - * pinnedItems: { - * 'plugin-1': true, - * }, - * }, - * 'core/edit-site': { - * complementaryArea: 'edit-site/global-styles', - * pinnedItems: { - * 'plugin-2': true, - * }, - * }, - * } - * ``` - * - * @param {Object} persistence The persistence interface. - */ -export function migrateInterfaceEnableItemsToPreferencesStore( persistence ) { - const interfaceStoreName = 'core/interface'; - const preferencesStoreName = 'core/preferences'; - const state = persistence.get(); - const sourceEnableItems = state[ interfaceStoreName ]?.enableItems; - - // There's nothing to migrate, exit early. - if ( ! sourceEnableItems ) { - return; - } - - const allPreferences = state[ preferencesStoreName ]?.preferences ?? {}; - - // First convert complementaryAreas into the right format. - // Use the existing preferences as the accumulator so that the data is - // merged. - const sourceComplementaryAreas = - sourceEnableItems?.singleEnableItems?.complementaryArea ?? {}; - - const convertedComplementaryAreas = Object.keys( - sourceComplementaryAreas - ).reduce( ( accumulator, scope ) => { - const data = sourceComplementaryAreas[ scope ]; - - // Don't overwrite any existing data in the preferences store. - if ( accumulator[ scope ]?.complementaryArea ) { - return accumulator; - } - - return { - ...accumulator, - [ scope ]: { - ...accumulator[ scope ], - complementaryArea: data, - }, - }; - }, allPreferences ); - - // Next feed the converted complementary areas back into a reducer that - // converts the pinned items, resulting in the fully migrated data. - const sourcePinnedItems = - sourceEnableItems?.multipleEnableItems?.pinnedItems ?? {}; - const allConvertedData = Object.keys( sourcePinnedItems ).reduce( - ( accumulator, scope ) => { - const data = sourcePinnedItems[ scope ]; - // Don't overwrite any existing data in the preferences store. - if ( accumulator[ scope ]?.pinnedItems ) { - return accumulator; - } - - return { - ...accumulator, - [ scope ]: { - ...accumulator[ scope ], - pinnedItems: data, - }, - }; - }, - convertedComplementaryAreas - ); - - persistence.set( preferencesStoreName, { - preferences: allConvertedData, - } ); - - // Remove migrated preferences. - const otherInterfaceItems = state[ interfaceStoreName ]; - persistence.set( interfaceStoreName, { - ...otherInterfaceItems, - enableItems: undefined, - } ); -} - -persistencePlugin.__unstableMigrate = ( pluginOptions ) => { - const persistence = createPersistenceInterface( pluginOptions ); - - // Boolean feature preferences. - migrateFeaturePreferencesToPreferencesStore( - persistence, - 'core/edit-widgets' - ); - migrateFeaturePreferencesToPreferencesStore( - persistence, - 'core/customize-widgets' - ); - migrateFeaturePreferencesToPreferencesStore( - persistence, - 'core/edit-post' - ); - migrateFeaturePreferencesToPreferencesStore( - persistence, - 'core/edit-site' - ); - migrateThirdPartyFeaturePreferencesToPreferencesStore( persistence ); - - // Other ad-hoc preferences. - migrateIndividualPreferenceToPreferencesStore( - persistence, - { from: 'core/edit-post', scope: 'core/edit-post' }, - 'hiddenBlockTypes' - ); - migrateIndividualPreferenceToPreferencesStore( - persistence, - { from: 'core/edit-post', scope: 'core/edit-post' }, - 'editorMode' - ); - migrateIndividualPreferenceToPreferencesStore( - persistence, - { from: 'core/edit-post', scope: 'core/edit-post' }, - 'preferredStyleVariations' - ); - migrateIndividualPreferenceToPreferencesStore( - persistence, - { from: 'core/edit-post', scope: 'core/edit-post' }, - 'panels', - convertEditPostPanels - ); - migrateIndividualPreferenceToPreferencesStore( - persistence, - { from: 'core/editor', scope: 'core/edit-post' }, - 'isPublishSidebarEnabled' - ); - migrateIndividualPreferenceToPreferencesStore( - persistence, - { from: 'core/edit-site', scope: 'core/edit-site' }, - 'editorMode' - ); - migrateInterfaceEnableItemsToPreferencesStore( persistence ); -}; +persistencePlugin.__unstableMigrate = () => {}; export default persistencePlugin; diff --git a/packages/data/src/plugins/persistence/test/index.js b/packages/data/src/plugins/persistence/test/index.js index 9e442a63daa7e9..818f075640ae56 100644 --- a/packages/data/src/plugins/persistence/test/index.js +++ b/packages/data/src/plugins/persistence/test/index.js @@ -6,15 +6,7 @@ import deepFreeze from 'deep-freeze'; /** * Internal dependencies */ -import plugin, { - createPersistenceInterface, - withLazySameState, - migrateFeaturePreferencesToPreferencesStore, - migrateThirdPartyFeaturePreferencesToPreferencesStore, - migrateIndividualPreferenceToPreferencesStore, - convertEditPostPanels, - migrateInterfaceEnableItemsToPreferencesStore, -} from '../'; +import plugin, { createPersistenceInterface, withLazySameState } from '../'; import objectStorage from '../storage/object'; import { createRegistry } from '../../../'; @@ -385,771 +377,3 @@ describe( 'persistence', () => { } ); } ); } ); - -describe( 'migrateFeaturePreferencesToPreferencesStore', () => { - it( 'migrates multiple preferences from persisted source stores to preferences', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - const sourceStateA = { - preferences: { - features: { - featureA: true, - featureB: false, - featureC: true, - }, - }, - }; - - const sourceStateB = { - preferences: { - features: { - featureD: true, - featureE: false, - featureF: true, - }, - }, - }; - - persistenceInterface.set( 'core/test-a', sourceStateA ); - persistenceInterface.set( 'core/test-b', sourceStateB ); - - migrateFeaturePreferencesToPreferencesStore( - persistenceInterface, - 'core/test-a' - ); - - migrateFeaturePreferencesToPreferencesStore( - persistenceInterface, - 'core/test-b' - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/test-a': { - featureA: true, - featureB: false, - featureC: true, - }, - 'core/test-b': { - featureD: true, - featureE: false, - featureF: true, - }, - }, - }, - 'core/test-a': { - preferences: { - features: undefined, - }, - }, - 'core/test-b': { - preferences: { - features: undefined, - }, - }, - } ); - } ); - - it( 'migrates multiple preferences from the persisted interface store to preferences, with interface state taking precedence over source stores', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - const sourceStateA = { - preferences: { - features: { - featureA: true, - featureB: false, - featureC: true, - }, - }, - }; - - const sourceStateB = { - preferences: { - features: { - featureD: true, - featureE: false, - featureF: true, - }, - }, - }; - - const interfaceState = { - otherData: { - test: 1, - }, - preferences: { - features: { - 'core/test-a': { - featureG: true, - featureH: false, - featureI: true, - }, - 'core/test-b': { - featureJ: true, - featureK: false, - featureL: true, - }, - }, - }, - }; - - persistenceInterface.set( 'core/test-a', sourceStateA ); - persistenceInterface.set( 'core/test-b', sourceStateB ); - persistenceInterface.set( 'core/interface', interfaceState ); - - migrateFeaturePreferencesToPreferencesStore( - persistenceInterface, - 'core/test-a' - ); - - migrateFeaturePreferencesToPreferencesStore( - persistenceInterface, - 'core/test-b' - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/test-a': { - featureG: true, - featureH: false, - featureI: true, - }, - 'core/test-b': { - featureJ: true, - featureK: false, - featureL: true, - }, - }, - }, - 'core/interface': { - otherData: { - test: 1, - }, - preferences: { - features: { - 'core/test-a': undefined, - 'core/test-b': undefined, - }, - }, - }, - 'core/test-a': { - preferences: { - features: undefined, - }, - }, - 'core/test-b': { - preferences: { - features: undefined, - }, - }, - } ); - } ); - - it( 'only migrates persisted preference data for the source name from source stores', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - const sourceStateA = { - otherData: { - test: 1, - }, - preferences: { - features: { - featureA: true, - featureB: false, - featureC: true, - }, - }, - }; - - const sourceStateB = { - otherData: { - test: 2, - }, - preferences: { - features: { - featureD: true, - featureE: false, - featureF: true, - }, - }, - }; - - persistenceInterface.set( 'core/test-a', sourceStateA ); - persistenceInterface.set( 'core/test-b', sourceStateB ); - - migrateFeaturePreferencesToPreferencesStore( - persistenceInterface, - 'core/test-a' - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/test-a': { - featureA: true, - featureB: false, - featureC: true, - }, - }, - }, - 'core/test-a': { - otherData: { - test: 1, - }, - preferences: { - features: undefined, - }, - }, - 'core/test-b': { - otherData: { - test: 2, - }, - preferences: { - features: { - featureD: true, - featureE: false, - featureF: true, - }, - }, - }, - } ); - } ); - - it( 'only migrates persisted data for the source name from interface', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - const interfaceState = { - preferences: { - features: { - 'core/test-a': { - featureG: true, - featureH: false, - featureI: true, - }, - 'core/test-b': { - featureJ: true, - featureK: false, - featureL: true, - }, - }, - }, - }; - - persistenceInterface.set( 'core/interface', interfaceState ); - - migrateFeaturePreferencesToPreferencesStore( - persistenceInterface, - 'core/test-a' - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/test-a': { - featureG: true, - featureH: false, - featureI: true, - }, - }, - }, - 'core/interface': { - preferences: { - features: { - 'core/test-a': undefined, - 'core/test-b': { - featureJ: true, - featureK: false, - featureL: true, - }, - }, - }, - }, - } ); - } ); -} ); - -describe( 'migrateIndividualPreferenceToPreferencesStore', () => { - it( 'migrates an individual preference from the source to the preferences store', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - const initialState = { - preferences: { - myPreference: '123', - }, - }; - - persistenceInterface.set( 'core/test', initialState ); - - migrateIndividualPreferenceToPreferencesStore( - persistenceInterface, - { from: 'core/test', scope: 'core/test' }, - 'myPreference' - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/test': { - myPreference: '123', - }, - }, - }, - 'core/test': { - preferences: { - myPreference: undefined, - }, - }, - } ); - } ); - - it( 'does not overwrite other preferences in the preferences store', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - const initialState = { - otherData: { - test: 1, - }, - preferences: { - myPreference: '123', - }, - }; - - persistenceInterface.set( 'core/test', initialState ); - persistenceInterface.set( 'core/preferences', { - preferences: { - 'core/other-store': { - preferenceA: 1, - preferenceB: 2, - }, - 'core/test': { - unrelatedPreference: 'unrelated-value', - }, - }, - } ); - - migrateIndividualPreferenceToPreferencesStore( - persistenceInterface, - { from: 'core/test', scope: 'core/test' }, - 'myPreference' - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/other-store': { - preferenceA: 1, - preferenceB: 2, - }, - 'core/test': { - unrelatedPreference: 'unrelated-value', - myPreference: '123', - }, - }, - }, - 'core/test': { - otherData: { - test: 1, - }, - preferences: { - myPreference: undefined, - }, - }, - } ); - } ); - - it( 'supports moving data to a scope that is differently named to the source store', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - const initialState = { - preferences: { - myPreference: '123', - }, - }; - - persistenceInterface.set( 'core/source', initialState ); - - migrateIndividualPreferenceToPreferencesStore( - persistenceInterface, - { from: 'core/source', scope: 'core/destination' }, - 'myPreference' - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/destination': { - myPreference: '123', - }, - }, - }, - 'core/source': { - preferences: { - myPreference: undefined, - }, - }, - } ); - } ); - - it( 'does not migrate data if there is already a matching preference key at the target', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - persistenceInterface.set( 'core/test', { - preferences: { - myPreference: '123', - }, - } ); - - persistenceInterface.set( 'core/preferences', { - preferences: { - 'core/test': { - myPreference: 'already-set', - }, - }, - } ); - - migrateIndividualPreferenceToPreferencesStore( - persistenceInterface, - { from: 'core/test', scope: 'core/test' }, - 'myPreference' - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/test': { - myPreference: 'already-set', - }, - }, - }, - 'core/test': { - preferences: { - myPreference: '123', - }, - }, - } ); - } ); - - it( 'migrates preferences that have a `false` value', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - persistenceInterface.set( 'core/test', { - preferences: { - myFalsePreference: false, - }, - } ); - - migrateIndividualPreferenceToPreferencesStore( - persistenceInterface, - { from: 'core/test', scope: 'core/test' }, - 'myFalsePreference' - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/test': { - myFalsePreference: false, - }, - }, - }, - 'core/test': { - preferences: {}, - }, - } ); - } ); -} ); - -describe( 'migrateThirdPartyFeaturePreferencesToPreferencesStore', () => { - it( 'migrates multiple scopes from the interface package to the preferences package', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - const interfaceState = { - otherData: { - test: 1, - }, - preferences: { - features: { - 'plugin-a': { - featureA: true, - featureB: false, - featureC: true, - }, - 'plugin-b': { - featureD: true, - featureE: false, - featureF: true, - }, - }, - }, - }; - persistenceInterface.set( 'core/interface', interfaceState ); - - migrateThirdPartyFeaturePreferencesToPreferencesStore( - persistenceInterface - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'plugin-a': { - featureA: true, - featureB: false, - featureC: true, - }, - 'plugin-b': { - featureD: true, - featureE: false, - featureF: true, - }, - }, - }, - 'core/interface': { - otherData: { - test: 1, - }, - preferences: { - features: { - 'plugin-a': undefined, - 'plugin-b': undefined, - }, - }, - }, - } ); - } ); - - it( 'ignores any core scopes', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - const interfaceState = { - preferences: { - features: { - 'plugin-a': { - featureA: true, - featureB: false, - featureC: true, - }, - 'core/edit-post': { - featureD: true, - featureE: false, - featureF: true, - }, - }, - }, - }; - persistenceInterface.set( 'core/interface', interfaceState ); - - migrateThirdPartyFeaturePreferencesToPreferencesStore( - persistenceInterface - ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'plugin-a': { - featureA: true, - featureB: false, - featureC: true, - }, - }, - }, - 'core/interface': { - preferences: { - features: { - 'plugin-a': undefined, - 'core/edit-post': { - featureD: true, - featureE: false, - featureF: true, - }, - }, - }, - }, - } ); - } ); -} ); - -describe( 'convertEditPostPanels', () => { - it( 'converts from one format to another', () => { - expect( - convertEditPostPanels( { - panels: { - tags: { - enabled: true, - opened: true, - }, - permalinks: { - enabled: false, - opened: false, - }, - categories: { - enabled: true, - opened: false, - }, - excerpt: { - enabled: false, - opened: true, - }, - discussion: { - enabled: false, - }, - template: { - opened: true, - }, - }, - } ) - ).toEqual( { - inactivePanels: [ 'permalinks', 'excerpt', 'discussion' ], - openPanels: [ 'tags', 'excerpt', 'template' ], - } ); - } ); - - it( 'returns empty arrays when there is no data to convert', () => { - expect( convertEditPostPanels( {} ) ).toEqual( { - inactivePanels: [], - openPanels: [], - } ); - } ); -} ); - -describe( 'migrateInterfaceEnableItemsToPreferencesStore', () => { - it( 'migrates enableItems to the preferences store', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - persistenceInterface.set( 'core/interface', { - enableItems: { - singleEnableItems: { - complementaryArea: { - 'core/edit-post': 'edit-post/document', - 'core/edit-site': 'edit-site/global-styles', - }, - }, - multipleEnableItems: { - pinnedItems: { - 'core/edit-post': { - 'plugin-1': true, - }, - 'core/edit-site': { - 'plugin-2': true, - }, - }, - }, - }, - } ); - - migrateInterfaceEnableItemsToPreferencesStore( persistenceInterface ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/edit-post': { - complementaryArea: 'edit-post/document', - pinnedItems: { - 'plugin-1': true, - }, - }, - 'core/edit-site': { - complementaryArea: 'edit-site/global-styles', - pinnedItems: { - 'plugin-2': true, - }, - }, - }, - }, - 'core/interface': { - enableItems: undefined, - }, - } ); - } ); - - it( 'merges pinnedItems and complementaryAreas with existing preferences store data', () => { - const persistenceInterface = createPersistenceInterface( { - storageKey: 'test-username', - } ); - - persistenceInterface.set( 'core/interface', { - enableItems: { - singleEnableItems: { - complementaryArea: { - 'core/edit-post': 'edit-post/document', - 'core/edit-site': 'edit-site/global-styles', - }, - }, - multipleEnableItems: { - pinnedItems: { - 'core/edit-post': { - 'plugin-1': true, - }, - 'core/edit-site': { - 'plugin-2': true, - }, - }, - }, - }, - } ); - - persistenceInterface.set( 'core/preferences', { - preferences: { - 'core/edit-post': { - preferenceA: 1, - preferenceB: 2, - }, - 'core/edit-site': { - preferenceC: true, - }, - }, - } ); - - migrateInterfaceEnableItemsToPreferencesStore( persistenceInterface ); - - expect( persistenceInterface.get() ).toEqual( { - 'core/preferences': { - preferences: { - 'core/edit-post': { - preferenceA: 1, - preferenceB: 2, - complementaryArea: 'edit-post/document', - pinnedItems: { - 'plugin-1': true, - }, - }, - 'core/edit-site': { - preferenceC: true, - complementaryArea: 'edit-site/global-styles', - pinnedItems: { - 'plugin-2': true, - }, - }, - }, - }, - 'core/interface': { - enableItems: undefined, - }, - } ); - } ); -} ); diff --git a/packages/e2e-test-utils-playwright/src/request/index.ts b/packages/e2e-test-utils-playwright/src/request/index.ts index 28d08220b8d25a..31a2df63448bd2 100644 --- a/packages/e2e-test-utils-playwright/src/request/index.ts +++ b/packages/e2e-test-utils-playwright/src/request/index.ts @@ -18,6 +18,7 @@ import { deleteAllTemplates } from './templates'; import { activateTheme } from './themes'; import { deleteAllBlocks } from './blocks'; import { deleteAllPosts } from './posts'; +import { resetPreferences } from './preferences'; import { deleteAllWidgets, addWidgetBlock } from './widgets'; interface StorageState { @@ -120,6 +121,7 @@ class RequestUtils { deleteAllWidgets = deleteAllWidgets; addWidgetBlock = addWidgetBlock; deleteAllTemplates = deleteAllTemplates; + resetPreferences = resetPreferences; } export type { StorageState }; diff --git a/packages/e2e-test-utils-playwright/src/request/preferences.ts b/packages/e2e-test-utils-playwright/src/request/preferences.ts new file mode 100644 index 00000000000000..9696803a6beab5 --- /dev/null +++ b/packages/e2e-test-utils-playwright/src/request/preferences.ts @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ +import type { RequestUtils } from './index'; + +/** + * Reset user preferences + * + * @param {this} this Request utils. + */ +export async function resetPreferences( this: RequestUtils ) { + await this.rest( { + path: '/wp/v2/users/me', + method: 'PUT', + data: { + meta: { + persisted_preferences: {}, + }, + }, + } ); +} diff --git a/packages/e2e-test-utils-playwright/src/test.ts b/packages/e2e-test-utils-playwright/src/test.ts index c01a150602ef13..b6867cad21629f 100644 --- a/packages/e2e-test-utils-playwright/src/test.ts +++ b/packages/e2e-test-utils-playwright/src/test.ts @@ -131,6 +131,7 @@ const test = base.extend< requestUtils.activateTheme( 'twentytwentyone' ), requestUtils.deleteAllPosts(), requestUtils.deleteAllBlocks(), + requestUtils.resetPreferences(), ] ); await use( requestUtils ); diff --git a/packages/e2e-test-utils/README.md b/packages/e2e-test-utils/README.md index db839113e77d23..47a914b2d816f0 100644 --- a/packages/e2e-test-utils/README.md +++ b/packages/e2e-test-utils/README.md @@ -687,6 +687,10 @@ _Returns_ - `Promise`: Promise resolving when publish is complete. +### resetPreferences + +Clears all user meta preferences. + ### saveDraft Saves the post as a draft, resolving once the request is complete (once the diff --git a/packages/e2e-test-utils/src/index.js b/packages/e2e-test-utils/src/index.js index 0ae7d379516696..3543fd3dfebb5b 100644 --- a/packages/e2e-test-utils/src/index.js +++ b/packages/e2e-test-utils/src/index.js @@ -67,6 +67,7 @@ export { openDocumentSettingsSidebar } from './open-document-settings-sidebar'; export { openPublishPanel } from './open-publish-panel'; export { openTypographyToolsPanelMenu } from './open-typography-tools-panel-menu'; export { trashAllPosts } from './posts'; +export { resetPreferences } from './preferences'; export { pressKeyTimes } from './press-key-times'; export { pressKeyWithModifier, diff --git a/packages/e2e-test-utils/src/preferences.js b/packages/e2e-test-utils/src/preferences.js new file mode 100644 index 00000000000000..ce43b35834a637 --- /dev/null +++ b/packages/e2e-test-utils/src/preferences.js @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ +import { rest } from './rest-api'; + +/** + * Clears all user meta preferences. + */ +export async function resetPreferences() { + await rest( { + path: '/wp/v2/users/me', + method: 'PUT', + data: { + meta: { + persisted_preferences: {}, + }, + }, + } ); +} diff --git a/packages/e2e-tests/config/setup-test-framework.js b/packages/e2e-tests/config/setup-test-framework.js index fcf63e0bda002f..ed1d92f67bf197 100644 --- a/packages/e2e-tests/config/setup-test-framework.js +++ b/packages/e2e-tests/config/setup-test-framework.js @@ -13,6 +13,7 @@ import { clearLocalStorage, enablePageDialogAccept, isOfflineMode, + resetPreferences, setBrowserViewport, trashAllPosts, } from '@wordpress/e2e-test-utils'; @@ -242,6 +243,7 @@ beforeAll( async () => { enablePageDialogAccept(); observeConsoleLogging(); await simulateAdverseConditions(); + await resetPreferences(); await activateTheme( 'twentytwentyone' ); await trashAllPosts(); await trashAllPosts( 'wp_block' ); @@ -253,6 +255,7 @@ beforeAll( async () => { } ); afterEach( async () => { + await resetPreferences(); await setupBrowser(); } ); diff --git a/packages/e2e-tests/specs/editor/plugins/nonce.test.js b/packages/e2e-tests/specs/editor/plugins/nonce.test.js index f2be63aa9beaad..669ebf041a9e57 100644 --- a/packages/e2e-tests/specs/editor/plugins/nonce.test.js +++ b/packages/e2e-tests/specs/editor/plugins/nonce.test.js @@ -9,19 +9,18 @@ import { } from '@wordpress/e2e-test-utils'; describe( 'Nonce', () => { - beforeAll( async () => { + // While using beforeEach/afterEach is suboptimal for multiple tests, they + // are used here to ensure that the nonce plugin doesn't interfere with API + // calls made in global before/after calls, which may perform API requests. + beforeEach( async () => { await activatePlugin( 'gutenberg-test-plugin-nonce' ); } ); - - afterAll( async () => { + afterEach( async () => { await deactivatePlugin( 'gutenberg-test-plugin-nonce' ); } ); - beforeEach( async () => { - await createNewPost(); - } ); - it( 'should refresh when expired', async () => { + await createNewPost(); await page.keyboard.press( 'Enter' ); // eslint-disable-next-line no-restricted-syntax await page.waitForTimeout( 5000 ); diff --git a/packages/preferences-persistence/.npmrc b/packages/preferences-persistence/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/preferences-persistence/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/preferences-persistence/CHANGELOG.md b/packages/preferences-persistence/CHANGELOG.md new file mode 100644 index 00000000000000..9b07b3bbe4d1d2 --- /dev/null +++ b/packages/preferences-persistence/CHANGELOG.md @@ -0,0 +1,7 @@ + + +## Unreleased + +## 1.0.0 (2022-03-11) + +- Initial version of the package. diff --git a/packages/preferences-persistence/README.md b/packages/preferences-persistence/README.md new file mode 100644 index 00000000000000..3279d73417d2f1 --- /dev/null +++ b/packages/preferences-persistence/README.md @@ -0,0 +1,59 @@ +# Preferences persistence + +Persistence utilities for `wordpress/preferences`. + +Includes a persistence layer that saves data to WordPress user meta via the REST API. If for any reason data cannot be saved to the database, this persistence layer also uses local storage as a fallback. + +## Installation + +Install the module + +```bash +npm install @wordpress/preferences-persistence --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ + +## Usage + +Call the `create` function to create a persistence layer. + +```js +const persistenceLayer = create(); +``` + +Next, configure the preferences package to use this persistence layer: + +```js +wp.data( 'core/preferences' ).setPersistenceLayer( persistenceLayer ); +``` + +## Reference + + + +### create + +Creates a persistence layer that stores data in WordPress user meta via the +REST API. + +_Parameters_ + +- _options_ `Object`: +- _options.preloadedData_ `?Object`: Any persisted preferences data that should be preloaded. When set, the persistence layer will avoid fetching data from the REST API. +- _options.localStorageRestoreKey_ `?string`: The key to use for restoring the localStorage backup, used when the persistence layer calls `localStorage.getItem` or `localStorage.setItem`. +- _options.requestDebounceMS_ `?number`: Debounce requests to the API so that they only occur at minimum every `requestDebounceMS` milliseconds, and don't swamp the server. Defaults to 2500ms. + +_Returns_ + +- `Object`: A persistence layer for WordPress user meta. + + + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/preferences-persistence/package.json b/packages/preferences-persistence/package.json new file mode 100644 index 00000000000000..0ddfb044c8d72d --- /dev/null +++ b/packages/preferences-persistence/package.json @@ -0,0 +1,37 @@ +{ + "name": "@wordpress/preferences-persistence", + "version": "1.0.0", + "description": "Persistence utilities for `wordpress/preferences`.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "preferences", + "settings" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/preferences-persistence/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/preferences-persistence" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "types": "build-types", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/api-fetch": "file:../api-fetch" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/preferences-persistence/src/create/debounce-async.js b/packages/preferences-persistence/src/create/debounce-async.js new file mode 100644 index 00000000000000..445110f082d129 --- /dev/null +++ b/packages/preferences-persistence/src/create/debounce-async.js @@ -0,0 +1,57 @@ +/** + * Performs a leading edge debounce of async functions. + * + * If three functions are throttled at the same time: + * - The first happens immediately. + * - The second is never called. + * - The third happens `delayMS` milliseconds after the first has resolved. + * + * This is distinct from `lodash.debounce` in that it waits for promise + * resolution. + * + * @param {Function} func A function that returns a promise. + * @param {number} delayMS A delay in milliseconds. + * + * @return {Function} A function that debounce whatever function is passed + * to it. + */ +export default function debounceAsync( func, delayMS ) { + let timeoutId; + let activePromise; + + return async function debounced( ...args ) { + // This is a leading edge debounce. If there's no promise or timeout + // in progress, + if ( ! activePromise && ! timeoutId ) { + // Keep a reference to the promise. + activePromise = func( ...args ).finally( () => { + // As soon this promise is complete, clear the way for the + // next one to happen immediately. + activePromise = null; + } ); + return; + } + + if ( activePromise ) { + // Let any active promises finish before queuing the next request. + await activePromise; + } + + // Clear any active timeouts, abandoning any requests that have + // been queued but not been made. + if ( timeoutId ) { + clearTimeout( timeoutId ); + timeoutId = null; + } + + // Schedule the next request but with a delay. + timeoutId = setTimeout( () => { + activePromise = func( ...args ).finally( () => { + // As soon this promise is complete, clear the way for the + // next one to happen immediately. + activePromise = null; + timeoutId = null; + } ); + }, delayMS ); + }; +} diff --git a/packages/preferences-persistence/src/create/index.js b/packages/preferences-persistence/src/create/index.js new file mode 100644 index 00000000000000..081458e40100c9 --- /dev/null +++ b/packages/preferences-persistence/src/create/index.js @@ -0,0 +1,110 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import debounceAsync from './debounce-async'; + +const EMPTY_OBJECT = {}; +const localStorage = window.localStorage; + +/** + * Creates a persistence layer that stores data in WordPress user meta via the + * REST API. + * + * @param {Object} options + * @param {?Object} options.preloadedData Any persisted preferences data that should be preloaded. + * When set, the persistence layer will avoid fetching data + * from the REST API. + * @param {?string} options.localStorageRestoreKey The key to use for restoring the localStorage backup, used + * when the persistence layer calls `localStorage.getItem` or + * `localStorage.setItem`. + * @param {?number} options.requestDebounceMS Debounce requests to the API so that they only occur at + * minimum every `requestDebounceMS` milliseconds, and don't + * swamp the server. Defaults to 2500ms. + * + * @return {Object} A persistence layer for WordPress user meta. + */ +export default function create( { + preloadedData, + localStorageRestoreKey = 'WP_PREFERENCES_RESTORE_DATA', + requestDebounceMS = 2500, +} = {} ) { + let cache = preloadedData; + const debouncedApiFetch = debounceAsync( apiFetch, requestDebounceMS ); + + async function get() { + if ( cache ) { + return cache; + } + + const user = await apiFetch( { + path: '/wp/v2/users/me?context=edit', + } ); + + const serverData = user?.meta?.persisted_preferences; + const localData = JSON.parse( + localStorage.getItem( localStorageRestoreKey ) + ); + + // Date parse returns NaN for invalid input. Coerce anything invalid + // into a conveniently comparable zero. + const serverTimestamp = Date.parse( serverData?._modified ) || 0; + const localTimestamp = Date.parse( localData?._modified ) || 0; + + // Prefer server data if it exists and is more recent. + // Otherwise fallback to localStorage data. + if ( serverData && serverTimestamp >= localTimestamp ) { + cache = serverData; + } else if ( localData ) { + cache = localData; + } else { + cache = EMPTY_OBJECT; + } + + return cache; + } + + function set( newData ) { + const dataWithTimestamp = { + ...newData, + _modified: new Date().toISOString(), + }; + cache = dataWithTimestamp; + + // Store data in local storage as a fallback. If for some reason the + // api request does not complete or becomes unavailable, this data + // can be used to restore preferences. + localStorage.setItem( + localStorageRestoreKey, + JSON.stringify( dataWithTimestamp ) + ); + + // The user meta endpoint seems susceptible to errors when consecutive + // requests are made in quick succession. Ensure there's a gap between + // any consecutive requests. + debouncedApiFetch( { + path: '/wp/v2/users/me', + method: 'PUT', + // `keepalive` will still send the request in the background, + // even when a browser unload event might interrupt it. + // This should hopefully make things more resilient. + // This does have a size limit of 64kb, but the data is usually + // much less. + keepalive: true, + data: { + meta: { + persisted_preferences: dataWithTimestamp, + }, + }, + } ); + } + + return { + get, + set, + }; +} diff --git a/packages/preferences-persistence/src/create/test/debounce-async.js b/packages/preferences-persistence/src/create/test/debounce-async.js new file mode 100644 index 00000000000000..a2520e666f5192 --- /dev/null +++ b/packages/preferences-persistence/src/create/test/debounce-async.js @@ -0,0 +1,80 @@ +/** + * Internal dependencies + */ +import debounceAsync from '../debounce-async'; + +// See https://stackoverflow.com/questions/52177631/jest-timer-and-promise-dont-work-well-settimeout-and-async-function. +// Jest fake timers and async functions don't mix too well, since queued up +// promises can prevent jest from calling timeouts. +// This function flushes promises in the queue. +function flushPromises() { + return new Promise( jest.requireActual( 'timers' ).setImmediate ); +} + +// Promisify a timeout for use with jest.fn. +function timeout( milliseconds ) { + return new Promise( ( resolve ) => setTimeout( resolve, milliseconds ) ); +} + +describe( 'debounceAsync', () => { + it( 'uses a leading debounce, the first call happens immediately', () => { + const fn = jest.fn( async () => {} ); + const debounced = debounceAsync( fn, 20 ); + debounced(); + expect( fn ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'calls the function on the leading edge and then once on the trailing edge when there are multiple calls', async () => { + jest.useFakeTimers(); + const fn = jest.fn( async () => {} ); + const debounced = debounceAsync( fn, 20 ); + + debounced( 'A' ); + + expect( fn ).toHaveBeenCalledTimes( 1 ); + + debounced( 'B' ); + debounced( 'C' ); + debounced( 'D' ); + + await flushPromises(); + jest.runAllTimers(); + + expect( fn ).toHaveBeenCalledTimes( 2 ); + expect( fn ).toHaveBeenCalledWith( 'A' ); + expect( fn ).toHaveBeenCalledWith( 'D' ); + } ); + + it( 'ensures the delay has elapsed between calls', async () => { + jest.useFakeTimers(); + const fn = jest.fn( async () => timeout( 10 ) ); + const debounced = debounceAsync( fn, 20 ); + + // The first call has been triggered, but will take 10ms to resolve. + debounced(); + debounced(); + debounced(); + debounced(); + expect( fn ).toHaveBeenCalledTimes( 1 ); + + // The first call has resolved. The delay period has started but has yet to finish. + await flushPromises(); + jest.advanceTimersByTime( 11 ); + expect( fn ).toHaveBeenCalledTimes( 1 ); + + // The second call is about to commence, but hasn't yet. + await flushPromises(); + jest.advanceTimersByTime( 18 ); + expect( fn ).toHaveBeenCalledTimes( 1 ); + + // The second call has now commenced. + await flushPromises(); + jest.advanceTimersByTime( 2 ); + expect( fn ).toHaveBeenCalledTimes( 2 ); + + // No more calls happen. + await flushPromises(); + jest.runAllTimers(); + expect( fn ).toHaveBeenCalledTimes( 2 ); + } ); +} ); diff --git a/packages/preferences-persistence/src/create/test/index.js b/packages/preferences-persistence/src/create/test/index.js new file mode 100644 index 00000000000000..acf28a9c51ff07 --- /dev/null +++ b/packages/preferences-persistence/src/create/test/index.js @@ -0,0 +1,178 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import create from '..'; + +jest.mock( '@wordpress/api-fetch' ); + +describe( 'create', () => { + afterEach( () => { + apiFetch.mockReset(); + } ); + + describe( 'set', () => { + it( 'stores backup restoration data in localStorage', () => { + apiFetch.mockResolvedValueOnce(); + const spy = jest.spyOn( global.Storage.prototype, 'setItem' ); + + const localStorageRestoreKey = 'test'; + const { set } = create( { localStorageRestoreKey } ); + + const data = { test: 1 }; + set( data ); + + expect( spy ).toHaveBeenCalledWith( + localStorageRestoreKey, + expect.any( String ) + ); + + // The second param of the call to `setItem` has been JSON.stringified. + // Parse it to check it contains the data. + const setItemDataParm = spy.mock.calls[ 0 ][ 1 ]; + expect( JSON.parse( setItemDataParm ) ).toEqual( + expect.objectContaining( data ) + ); + } ); + + it( 'sends data to the `users/me` endpoint', () => { + apiFetch.mockResolvedValueOnce(); + + const { set } = create(); + + const data = { test: 1 }; + set( data ); + + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/users/me', + method: 'PUT', + keepalive: true, + data: { + meta: { + persisted_preferences: expect.objectContaining( data ), + }, + }, + } ); + } ); + } ); + + describe( 'get', () => { + it( 'avoids using the REST API or local storage when data is preloaded', async () => { + const getItemSpy = jest.spyOn( + global.Storage.prototype, + 'getItem' + ); + + const preloadedData = { preloaded: true }; + const { get } = create( { preloadedData } ); + expect( await get() ).toBe( preloadedData ); + expect( getItemSpy ).not.toHaveBeenCalled(); + expect( apiFetch ).not.toHaveBeenCalled(); + } ); + + it( 'returns from a local cache once `set` has been called', async () => { + const getItemSpy = jest.spyOn( + global.Storage.prototype, + 'getItem' + ); + apiFetch.mockResolvedValueOnce(); + + const data = { cached: true }; + const { get, set } = create(); + + // apiFetch was called as a result of calling `set`. + set( data ); + expect( apiFetch ).toHaveBeenCalled(); + apiFetch.mockClear(); + + // Neither localStorage.getItem or apiFetch are called as a result + // of the call to `get`. A local cache is used. + expect( await get() ).toEqual( expect.objectContaining( data ) ); + expect( getItemSpy ).not.toHaveBeenCalled(); + expect( apiFetch ).not.toHaveBeenCalled(); + } ); + + it( 'returns data from the users/me endpoint if there is no data in localStorage', async () => { + const data = { + __timestamp: 0, + test: 2, + }; + apiFetch.mockResolvedValueOnce( { + meta: { persisted_preferences: data }, + } ); + + jest.spyOn( + global.Storage.prototype, + 'getItem' + ).mockReturnValueOnce( 'null' ); + + const { get } = create(); + expect( await get() ).toEqual( data ); + } ); + + it( 'returns data from the REST API if it has a more recent modified date than localStorage', async () => { + const data = { + _modified: '2022-04-22T00:00:00.000Z', + test: 'api', + }; + apiFetch.mockResolvedValueOnce( { + meta: { persisted_preferences: data }, + } ); + + jest.spyOn( + global.Storage.prototype, + 'getItem' + ).mockReturnValueOnce( + JSON.stringify( { + _modified: '2022-04-21T00:00:00.000Z', + test: 'localStorage', + } ) + ); + + const { get } = create(); + expect( await get() ).toEqual( data ); + } ); + + it( 'returns data from localStorage if it has a more recent modified date than data from the REST API', async () => { + apiFetch.mockResolvedValueOnce( { + meta: { + persisted_preferences: { + _modified: '2022-04-21T00:00:00.000Z', + test: 'api', + }, + }, + } ); + + const data = { + _modified: '2022-04-22T00:00:00.000Z', + test: 'localStorage', + }; + jest.spyOn( + global.Storage.prototype, + 'getItem' + ).mockReturnValueOnce( JSON.stringify( data ) ); + + const { get } = create(); + expect( await get() ).toEqual( data ); + } ); + + it( 'returns an empty object if neither local storage or the REST API return any data', async () => { + apiFetch.mockResolvedValueOnce( { + meta: { + persisted_preferences: null, + }, + } ); + jest.spyOn( + global.Storage.prototype, + 'getItem' + ).mockReturnValueOnce( 'null' ); + + const { get } = create(); + expect( await get() ).toEqual( {} ); + } ); + } ); +} ); diff --git a/packages/preferences-persistence/src/index.js b/packages/preferences-persistence/src/index.js new file mode 100644 index 00000000000000..a28fc411ece778 --- /dev/null +++ b/packages/preferences-persistence/src/index.js @@ -0,0 +1,49 @@ +/** + * Internal dependencies + */ +import create from './create'; +import convertLegacyLocalStorageData from './migrations/legacy-local-storage-data'; + +export { create }; + +/** + * Creates the persistence layer with preloaded data. + * + * It prioritizes any data from the server, but falls back first to localStorage + * restore data, and then to any legacy data. + * + * This function is used internally by WordPress in an inline script, so + * prefixed with `__unstable`. + * + * @param {Object} serverData Preferences data preloaded from the server. + * @param {string} userId The user id. + * + * @return {Object} The persistence layer initialized with the preloaded data. + */ +export function __unstableCreatePersistenceLayer( serverData, userId ) { + const localStorageRestoreKey = `WP_PREFERENCES_USER_${ userId }`; + const localData = JSON.parse( + window.localStorage.getItem( localStorageRestoreKey ) + ); + + // Date parse returns NaN for invalid input. Coerce anything invalid + // into a conveniently comparable zero. + const serverModified = + Date.parse( serverData && serverData._modified ) || 0; + const localModified = Date.parse( localData && localData._modified ) || 0; + + let preloadedData; + if ( serverData && serverModified >= localModified ) { + preloadedData = serverData; + } else if ( localData ) { + preloadedData = localData; + } else { + // Check if there is data in the legacy format from the old persistence system. + preloadedData = convertLegacyLocalStorageData( userId ); + } + + return create( { + preloadedData, + localStorageRestoreKey, + } ); +} diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/README.md b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/README.md new file mode 100644 index 00000000000000..29187e4f0f33e8 --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/README.md @@ -0,0 +1,42 @@ +# Legacy local storage migrations + +This folder contains all the migration code for converting from the old `data` package persistence system. + +## History + +Previously, some packages could configure a store to persist particular parts of state. In this example, the post editor is configured to persist the state for its `preferences` reducer to local storage: +```js +registerStore( + 'core/edit-post', { + selectors, + actions, + reducer, + persist: [ 'preferences' ], + }, +); +``` + +This would result in local storage data being saved in this format: +```json +{ + "core/edit-post": { + "preferences": { + // ... preferences state from the post editor. + } + }, + // ... other persisted state from other editors. +} +``` + +And when an editor was reloaded, this would become the initial store state. + +The preferences package was later introduced, and this became a centralized place for managing and persisting preferences for other packages. The job of these migration functions is to migrate data from the old persistence system to the new format for the preferences store: +```json +{ + "preferences": { + "core/edit-post": { + // ... preferences for the post editor. + } + // ... preferences for other editors + } +} diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/convert-edit-post-panels.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/convert-edit-post-panels.js new file mode 100644 index 00000000000000..8a534b02bbc2db --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/convert-edit-post-panels.js @@ -0,0 +1,50 @@ +/** + * Convert the post editor's panels state from: + * ``` + * { + * panels: { + * tags: { + * enabled: true, + * opened: true, + * }, + * permalinks: { + * enabled: false, + * opened: false, + * }, + * }, + * } + * ``` + * + * to a new, more concise data structure: + * { + * inactivePanels: [ + * 'permalinks', + * ], + * openPanels: [ + * 'tags', + * ], + * } + * + * @param {Object} preferences A preferences object. + * + * @return {Object} The converted data. + */ +export default function convertEditPostPanels( preferences ) { + const panels = preferences?.panels ?? {}; + return Object.keys( panels ).reduce( + ( convertedData, panelName ) => { + const panel = panels[ panelName ]; + + if ( panel?.enabled === false ) { + convertedData.inactivePanels.push( panelName ); + } + + if ( panel?.opened === true ) { + convertedData.openPanels.push( panelName ); + } + + return convertedData; + }, + { inactivePanels: [], openPanels: [] } + ); +} diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/index.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/index.js new file mode 100644 index 00000000000000..9ac1f354920306 --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/index.js @@ -0,0 +1,102 @@ +/** + * Internal dependencies + */ +import moveFeaturePreferences from './move-feature-preferences'; +import moveThirdPartyFeaturePreferences from './move-third-party-feature-preferences'; +import moveIndividualPreference from './move-individual-preference'; +import moveInterfaceEnableItems from './move-interface-enable-items'; +import convertEditPostPanels from './convert-edit-post-panels'; + +/** + * Gets the legacy local storage data for a given user. + * + * @param {string | number} userId The user id. + * + * @return {Object | null} The local storage data. + */ +function getLegacyData( userId ) { + const key = `WP_DATA_USER_${ userId }`; + const unparsedData = window.localStorage.getItem( key ); + return JSON.parse( unparsedData ); +} + +/** + * Converts data from the old `@wordpress/data` package format. + * + * @param {Object | null | undefined} data The legacy data in its original format. + * + * @return {Object | undefined} The converted data or `undefined` if there was + * nothing to convert. + */ +export function convertLegacyData( data ) { + if ( ! data ) { + return; + } + + // Move boolean feature preferences from each editor into the + // preferences store data structure. + data = moveFeaturePreferences( data, 'core/edit-widgets' ); + data = moveFeaturePreferences( data, 'core/customize-widgets' ); + data = moveFeaturePreferences( data, 'core/edit-post' ); + data = moveFeaturePreferences( data, 'core/edit-site' ); + + // Move third party boolean feature preferences from the interface package + // to the preferences store data structure. + data = moveThirdPartyFeaturePreferences( data ); + + // Move and convert the interface store's `enableItems` data into the + // preferences data structure. + data = moveInterfaceEnableItems( data ); + + // Move individual ad-hoc preferences from various packages into the + // preferences store data structure. + data = moveIndividualPreference( + data, + { from: 'core/edit-post', to: 'core/edit-post' }, + 'hiddenBlockTypes' + ); + data = moveIndividualPreference( + data, + { from: 'core/edit-post', to: 'core/edit-post' }, + 'editorMode' + ); + data = moveIndividualPreference( + data, + { from: 'core/edit-post', to: 'core/edit-post' }, + 'preferredStyleVariations' + ); + data = moveIndividualPreference( + data, + { from: 'core/edit-post', to: 'core/edit-post' }, + 'panels', + convertEditPostPanels + ); + data = moveIndividualPreference( + data, + { from: 'core/editor', to: 'core/edit-post' }, + 'isPublishSidebarEnabled' + ); + data = moveIndividualPreference( + data, + { from: 'core/edit-site', to: 'core/edit-site' }, + 'editorMode' + ); + + // The new system is only concerned with persisting + // 'core/preferences' preferences reducer, so only return that. + return data?.[ 'core/preferences' ]?.preferences; +} + +/** + * Gets the legacy local storage data for the given user and returns the + * data converted to the new format. + * + * @param {string | number} userId The user id. + * + * @return {Object | undefined} The converted data or undefined if no local + * storage data could be found. + */ +export default function convertLegacyLocalStorageData( userId ) { + const data = getLegacyData( userId ); + return convertLegacyData( data ); +} diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-feature-preferences.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-feature-preferences.js new file mode 100644 index 00000000000000..6f7989a8557bf5 --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-feature-preferences.js @@ -0,0 +1,135 @@ +/** + * Move the 'features' object in local storage from the sourceStoreName to the + * preferences store data structure. + * + * Previously, editors used a data structure like this for feature preferences: + * ```js + * { + * 'core/edit-post': { + * preferences: { + * features; { + * topToolbar: true, + * // ... other boolean 'feature' preferences + * }, + * }, + * }, + * } + * ``` + * + * And for a while these feature preferences lived in the interface package: + * ```js + * { + * 'core/interface': { + * preferences: { + * features: { + * 'core/edit-post': { + * topToolbar: true + * } + * } + * } + * } + * } + * ``` + * + * In the preferences store, 'features' aren't considered special, they're + * merged to the root level of the scope along with other preferences: + * ```js + * { + * 'core/preferences': { + * preferences: { + * 'core/edit-post': { + * topToolbar: true, + * // ... any other preferences. + * } + * } + * } + * } + * ``` + * + * This function handles moving from either the source store or the interface + * store to the preferences data structure. + * + * @param {Object} state The state before migration. + * @param {string} sourceStoreName The name of the store that has persisted + * preferences to migrate to the preferences + * package. + * @return {Object} The migrated state + */ +export default function moveFeaturePreferences( state, sourceStoreName ) { + const preferencesStoreName = 'core/preferences'; + const interfaceStoreName = 'core/interface'; + + // Features most recently (and briefly) lived in the interface package. + // If data exists there, prioritize using that for the migration. If not + // also check the original package as the user may have updated from an + // older block editor version. + const interfaceFeatures = + state?.[ interfaceStoreName ]?.preferences?.features?.[ + sourceStoreName + ]; + const sourceFeatures = state?.[ sourceStoreName ]?.preferences?.features; + const featuresToMigrate = interfaceFeatures + ? interfaceFeatures + : sourceFeatures; + + if ( ! featuresToMigrate ) { + return state; + } + + const existingPreferences = state?.[ preferencesStoreName ]?.preferences; + + // Avoid migrating features again if they've previously been migrated. + if ( existingPreferences?.[ sourceStoreName ] ) { + return state; + } + + let updatedInterfaceState; + if ( interfaceFeatures ) { + const otherInterfaceState = state?.[ interfaceStoreName ]; + const otherInterfaceScopes = + state?.[ interfaceStoreName ]?.preferences?.features; + + updatedInterfaceState = { + [ interfaceStoreName ]: { + ...otherInterfaceState, + preferences: { + features: { + ...otherInterfaceScopes, + [ sourceStoreName ]: undefined, + }, + }, + }, + }; + } + + let updatedSourceState; + if ( sourceFeatures ) { + const otherSourceState = state?.[ sourceStoreName ]; + const sourcePreferences = state?.[ sourceStoreName ]?.preferences; + + updatedSourceState = { + [ sourceStoreName ]: { + ...otherSourceState, + preferences: { + ...sourcePreferences, + features: undefined, + }, + }, + }; + } + + // Set the feature values in the interface store, the features + // object is keyed by 'scope', which matches the store name for + // the source. + return { + ...state, + [ preferencesStoreName ]: { + preferences: { + ...existingPreferences, + [ sourceStoreName ]: featuresToMigrate, + }, + }, + ...updatedInterfaceState, + ...updatedSourceState, + }; +} diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-individual-preference.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-individual-preference.js new file mode 100644 index 00000000000000..ff688462aa976d --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-individual-preference.js @@ -0,0 +1,90 @@ +const identity = ( arg ) => arg; + +/** + * Migrates an individual item inside the `preferences` object for a package's store. + * + * Previously, some packages had individual 'preferences' of any data type, and many used + * complex nested data structures. For example: + * ```js + * { + * 'core/edit-post': { + * preferences: { + * panels: { + * publish: { + * opened: true, + * enabled: true, + * } + * }, + * // ...other preferences. + * }, + * }, + * } + * + * This function supports moving an individual preference like 'panels' above into the + * preferences package data structure. + * + * It supports moving a preference to a particular scope in the preferences store and + * optionally converting the data using a `convert` function. + * + * ``` + * + * @param {Object} state The original state. + * @param {Object} migrate An options object that contains details of the migration. + * @param {string} migrate.from The name of the store to migrate from. + * @param {string} migrate.to The scope in the preferences store to migrate to. + * @param {string} key The key in the preferences object to migrate. + * @param {?Function} convert A function that converts preferences from one format to another. + */ +export default function moveIndividualPreferenceToPreferences( + state, + { from: sourceStoreName, to: scope }, + key, + convert = identity +) { + const preferencesStoreName = 'core/preferences'; + const sourcePreference = state?.[ sourceStoreName ]?.preferences?.[ key ]; + + // There's nothing to migrate, exit early. + if ( sourcePreference === undefined ) { + return state; + } + + const targetPreference = + state?.[ preferencesStoreName ]?.preferences?.[ scope ]?.[ key ]; + + // There's existing data at the target, so don't overwrite it, exit early. + if ( targetPreference ) { + return state; + } + + const otherScopes = state?.[ preferencesStoreName ]?.preferences; + const otherPreferences = + state?.[ preferencesStoreName ]?.preferences?.[ scope ]; + + const otherSourceState = state?.[ sourceStoreName ]; + const allSourcePreferences = state?.[ sourceStoreName ]?.preferences; + + // Pass an object with the key and value as this allows the convert + // function to convert to a data structure that has different keys. + const convertedPreferences = convert( { [ key ]: sourcePreference } ); + + return { + ...state, + [ preferencesStoreName ]: { + preferences: { + ...otherScopes, + [ scope ]: { + ...otherPreferences, + ...convertedPreferences, + }, + }, + }, + [ sourceStoreName ]: { + ...otherSourceState, + preferences: { + ...allSourcePreferences, + [ key ]: undefined, + }, + }, + }; +} diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-interface-enable-items.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-interface-enable-items.js new file mode 100644 index 00000000000000..e1c701e3b138aa --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-interface-enable-items.js @@ -0,0 +1,120 @@ +/** + * Migrates interface 'enableItems' data to the preferences store. + * + * The interface package stores this data in this format: + * ```js + * { + * enableItems: { + * singleEnableItems: { + * complementaryArea: { + * 'core/edit-post': 'edit-post/document', + * 'core/edit-site': 'edit-site/global-styles', + * } + * }, + * multipleEnableItems: { + * pinnedItems: { + * 'core/edit-post': { + * 'plugin-1': true, + * }, + * 'core/edit-site': { + * 'plugin-2': true, + * }, + * }, + * } + * } + * } + * ``` + * + * and it should be converted it to: + * ```js + * { + * 'core/edit-post': { + * complementaryArea: 'edit-post/document', + * pinnedItems: { + * 'plugin-1': true, + * }, + * }, + * 'core/edit-site': { + * complementaryArea: 'edit-site/global-styles', + * pinnedItems: { + * 'plugin-2': true, + * }, + * }, + * } + * ``` + * + * @param {Object} state The local storage state. + */ +export default function moveInterfaceEnableItems( state ) { + const interfaceStoreName = 'core/interface'; + const preferencesStoreName = 'core/preferences'; + const sourceEnableItems = state?.[ interfaceStoreName ]?.enableItems; + + // There's nothing to migrate, exit early. + if ( ! sourceEnableItems ) { + return state; + } + + const allPreferences = state?.[ preferencesStoreName ]?.preferences ?? {}; + + // First convert complementaryAreas into the right format. + // Use the existing preferences as the accumulator so that the data is + // merged. + const sourceComplementaryAreas = + sourceEnableItems?.singleEnableItems?.complementaryArea ?? {}; + + const preferencesWithConvertedComplementaryAreas = Object.keys( + sourceComplementaryAreas + ).reduce( ( accumulator, scope ) => { + const data = sourceComplementaryAreas[ scope ]; + + // Don't overwrite any existing data in the preferences store. + if ( accumulator?.[ scope ]?.complementaryArea ) { + return accumulator; + } + + return { + ...accumulator, + [ scope ]: { + ...accumulator[ scope ], + complementaryArea: data, + }, + }; + }, allPreferences ); + + // Next feed the converted complementary areas back into a reducer that + // converts the pinned items, resulting in the fully migrated data. + const sourcePinnedItems = + sourceEnableItems?.multipleEnableItems?.pinnedItems ?? {}; + const allConvertedData = Object.keys( sourcePinnedItems ).reduce( + ( accumulator, scope ) => { + const data = sourcePinnedItems[ scope ]; + // Don't overwrite any existing data in the preferences store. + if ( accumulator?.[ scope ]?.pinnedItems ) { + return accumulator; + } + + return { + ...accumulator, + [ scope ]: { + ...accumulator[ scope ], + pinnedItems: data, + }, + }; + }, + preferencesWithConvertedComplementaryAreas + ); + + const otherInterfaceItems = state[ interfaceStoreName ]; + + return { + ...state, + [ preferencesStoreName ]: { + preferences: allConvertedData, + }, + [ interfaceStoreName ]: { + ...otherInterfaceItems, + enableItems: undefined, + }, + }; +} diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-third-party-feature-preferences.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-third-party-feature-preferences.js new file mode 100644 index 00000000000000..e0f765fe920443 --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/move-third-party-feature-preferences.js @@ -0,0 +1,98 @@ +/** + * The interface package previously had a public API that could be used by + * plugins to set persisted boolean 'feature' preferences. + * + * While usage was likely non-existent or very small, this function ensures + * those are migrated to the preferences data structure. The interface + * package's APIs have now been deprecated and use the preferences store. + * + * This will convert data that looks like this: + * ```js + * { + * 'core/interface': { + * preferences: { + * features: { + * 'my-plugin': { + * myPluginFeature: true + * } + * } + * } + * } + * } + * ``` + * + * To this: + * ```js + * * { + * 'core/preferences': { + * preferences: { + * 'my-plugin': { + * myPluginFeature: true + * } + * } + * } + * } + * ``` + * + * @param {Object} state The local storage state + * + * @return {Object} The state with third party preferences moved to the + * preferences data structure. + */ +export default function moveThirdPartyFeaturePreferencesToPreferences( state ) { + const interfaceStoreName = 'core/interface'; + const preferencesStoreName = 'core/preferences'; + + const interfaceScopes = + state?.[ interfaceStoreName ]?.preferences?.features; + const interfaceScopeKeys = interfaceScopes + ? Object.keys( interfaceScopes ) + : []; + + if ( ! interfaceScopeKeys?.length ) { + return state; + } + + return interfaceScopeKeys.reduce( function ( convertedState, scope ) { + if ( scope.startsWith( 'core' ) ) { + return convertedState; + } + + const featuresToMigrate = interfaceScopes?.[ scope ]; + if ( ! featuresToMigrate ) { + return convertedState; + } + + const existingMigratedData = + convertedState?.[ preferencesStoreName ]?.preferences?.[ scope ]; + + if ( existingMigratedData ) { + return convertedState; + } + + const otherPreferencesScopes = + convertedState?.[ preferencesStoreName ]?.preferences; + const otherInterfaceState = convertedState?.[ interfaceStoreName ]; + const otherInterfaceScopes = + convertedState?.[ interfaceStoreName ]?.preferences?.features; + + return { + ...convertedState, + [ preferencesStoreName ]: { + preferences: { + ...otherPreferencesScopes, + [ scope ]: featuresToMigrate, + }, + }, + [ interfaceStoreName ]: { + ...otherInterfaceState, + preferences: { + features: { + ...otherInterfaceScopes, + [ scope ]: undefined, + }, + }, + }, + }; + }, state ); +} diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/convert-edit-post-panels.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/convert-edit-post-panels.js new file mode 100644 index 00000000000000..579ed661a673ce --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/convert-edit-post-panels.js @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import convertEditPostPanels from '../convert-edit-post-panels'; + +describe( 'convertEditPostPanels', () => { + it( 'converts from one format to another', () => { + expect( + convertEditPostPanels( { + panels: { + tags: { + enabled: true, + opened: true, + }, + permalinks: { + enabled: false, + opened: false, + }, + categories: { + enabled: true, + opened: false, + }, + excerpt: { + enabled: false, + opened: true, + }, + discussion: { + enabled: false, + }, + template: { + opened: true, + }, + }, + } ) + ).toEqual( { + inactivePanels: [ 'permalinks', 'excerpt', 'discussion' ], + openPanels: [ 'tags', 'excerpt', 'template' ], + } ); + } ); + + it( 'returns empty arrays when there is no data to convert', () => { + expect( convertEditPostPanels( {} ) ).toEqual( { + inactivePanels: [], + openPanels: [], + } ); + } ); +} ); diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/index.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/index.js new file mode 100644 index 00000000000000..a58da11a94dde0 --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/index.js @@ -0,0 +1,229 @@ +/** + * Internal dependencies + */ +import { convertLegacyData } from '..'; + +const legacyData = { + 'core/interface': { + enableItems: { + singleEnableItems: { + complementaryArea: { + 'core/edit-post': 'edit-post/document', + 'core/edit-site': 'edit-site/global-styles', + 'core/edit-widgets': 'edit-widgets/block-areas', + }, + }, + multipleEnableItems: { + pinnedItems: { + 'core/edit-post': { + 'my-sidebar-plugin/title-sidebar': false, + }, + }, + }, + }, + preferences: { + features: { + 'core/edit-post': { welcomeGuide: false, fixedToolbar: true }, + 'core/edit-widgets': { + welcomeGuide: false, + fixedToolbar: true, + keepCaretInsideBlock: true, + }, + 'core/customize-widgets': { + welcomeGuide: false, + fixedToolbar: true, + keepCaretInsideBlock: true, + }, + 'third-party-plugin': { + thirdPartyFeature: true, + }, + }, + }, + }, + 'core/edit-post': { + preferences: { + panels: { + 'post-status': { opened: true }, + 'post-excerpt': { enabled: false }, + 'taxonomy-panel-category': { opened: true }, + }, + editorMode: 'text', + hiddenBlockTypes: [ 'core/heading', 'core/list' ], + preferredStyleVariations: { 'core/quote': 'plain' }, + localAutosaveInterval: 15, + }, + }, + 'core/edit-site': { + preferences: { + features: { + welcomeGuide: false, + welcomeGuideStyles: false, + fixedToolbar: true, + focusMode: true, + }, + }, + }, +}; + +const alreadyConvertedData = { + 'core/block-editor': { + preferences: { + insertUsage: { + 'core/paragraph': { + time: 1649320988011, + count: 2, + insert: { + name: 'core/paragraph', + }, + }, + 'core/quote': { + time: 1649320934860, + count: 1, + insert: { + name: 'core/quote', + }, + }, + 'core/image': { + time: 1649321017053, + count: 1, + insert: { + name: 'core/image', + }, + }, + 'core/group': { + time: 1649321017077, + count: 1, + insert: { + name: 'core/group', + }, + }, + }, + }, + }, + 'core/preferences': { + preferences: { + 'core/edit-widgets': { + welcomeGuide: false, + fixedToolbar: true, + showBlockBreadcrumbs: false, + complementaryArea: 'edit-widgets/block-areas', + }, + 'core/edit-post': { + welcomeGuide: false, + fixedToolbar: true, + fullscreenMode: false, + hiddenBlockTypes: [ 'core/audio', 'core/cover' ], + editorMode: 'visual', + preferredStyleVariations: { + 'core/quote': 'large', + }, + inactivePanels: [], + openPanels: [ 'post-status' ], + complementaryArea: 'edit-post/block', + pinnedItems: { + 'my-sidebar-plugin/title-sidebar': false, + }, + }, + 'core/edit-site': { + welcomeGuide: false, + welcomeGuideStyles: false, + fixedToolbar: true, + complementaryArea: 'edit-site/global-styles', + }, + }, + }, +}; + +describe( 'convertLegacyData', () => { + it( 'converts to the expected format', () => { + expect( convertLegacyData( legacyData ) ).toMatchInlineSnapshot( ` + Object { + "core/customize-widgets": Object { + "fixedToolbar": true, + "keepCaretInsideBlock": true, + "welcomeGuide": false, + }, + "core/edit-post": Object { + "complementaryArea": "edit-post/document", + "editorMode": "text", + "fixedToolbar": true, + "hiddenBlockTypes": Array [ + "core/heading", + "core/list", + ], + "inactivePanels": Array [ + "post-excerpt", + ], + "openPanels": Array [ + "post-status", + "taxonomy-panel-category", + ], + "pinnedItems": Object { + "my-sidebar-plugin/title-sidebar": false, + }, + "preferredStyleVariations": Object { + "core/quote": "plain", + }, + "welcomeGuide": false, + }, + "core/edit-site": Object { + "complementaryArea": "edit-site/global-styles", + "fixedToolbar": true, + "focusMode": true, + "welcomeGuide": false, + "welcomeGuideStyles": false, + }, + "core/edit-widgets": Object { + "complementaryArea": "edit-widgets/block-areas", + "fixedToolbar": true, + "keepCaretInsideBlock": true, + "welcomeGuide": false, + }, + "third-party-plugin": Object { + "thirdPartyFeature": true, + }, + } + ` ); + } ); + + it( 'retains already converted data', () => { + expect( convertLegacyData( alreadyConvertedData ) ) + .toMatchInlineSnapshot( ` + Object { + "core/edit-post": Object { + "complementaryArea": "edit-post/block", + "editorMode": "visual", + "fixedToolbar": true, + "fullscreenMode": false, + "hiddenBlockTypes": Array [ + "core/audio", + "core/cover", + ], + "inactivePanels": Array [], + "openPanels": Array [ + "post-status", + ], + "pinnedItems": Object { + "my-sidebar-plugin/title-sidebar": false, + }, + "preferredStyleVariations": Object { + "core/quote": "large", + }, + "welcomeGuide": false, + }, + "core/edit-site": Object { + "complementaryArea": "edit-site/global-styles", + "fixedToolbar": true, + "welcomeGuide": false, + "welcomeGuideStyles": false, + }, + "core/edit-widgets": Object { + "complementaryArea": "edit-widgets/block-areas", + "fixedToolbar": true, + "showBlockBreadcrumbs": false, + "welcomeGuide": false, + }, + } + ` ); + } ); +} ); diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-feature-preferences.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-feature-preferences.js new file mode 100644 index 00000000000000..120f8526d749a1 --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-feature-preferences.js @@ -0,0 +1,260 @@ +/** + * Internal dependencies + */ +import moveFeaturePreferences from '../move-feature-preferences'; + +describe( 'moveFeaturePreferences', () => { + it( 'migrates multiple preferences from persisted source stores to preferences without overwriting data', () => { + const state = { + 'core/test-a': { + preferences: { + features: { + featureA: true, + featureB: false, + featureC: true, + }, + }, + }, + 'core/test-b': { + preferences: { + features: { + featureD: true, + featureE: false, + featureF: true, + }, + }, + }, + }; + + let convertedState = moveFeaturePreferences( state, 'core/test-a' ); + + convertedState = moveFeaturePreferences( + convertedState, + 'core/test-b' + ); + + expect( convertedState ).toEqual( { + 'core/preferences': { + preferences: { + 'core/test-a': { + featureA: true, + featureB: false, + featureC: true, + }, + 'core/test-b': { + featureD: true, + featureE: false, + featureF: true, + }, + }, + }, + 'core/test-a': { + preferences: { + features: undefined, + }, + }, + 'core/test-b': { + preferences: { + features: undefined, + }, + }, + } ); + } ); + + it( 'migrates multiple preferences from the persisted interface store to preferences, with interface state taking precedence over source stores', () => { + const state = { + 'core/test-a': { + preferences: { + features: { + featureA: true, + featureB: false, + featureC: true, + }, + }, + }, + 'core/test-b': { + preferences: { + features: { + featureD: true, + featureE: false, + featureF: true, + }, + }, + }, + 'core/interface': { + otherData: { + test: 1, + }, + preferences: { + features: { + 'core/test-a': { + featureG: true, + featureH: false, + featureI: true, + }, + 'core/test-b': { + featureJ: true, + featureK: false, + featureL: true, + }, + }, + }, + }, + }; + + let convertedState = moveFeaturePreferences( state, 'core/test-a' ); + + convertedState = moveFeaturePreferences( + convertedState, + 'core/test-b' + ); + + expect( convertedState ).toEqual( { + 'core/preferences': { + preferences: { + 'core/test-a': { + featureG: true, + featureH: false, + featureI: true, + }, + 'core/test-b': { + featureJ: true, + featureK: false, + featureL: true, + }, + }, + }, + 'core/interface': { + otherData: { + test: 1, + }, + preferences: { + features: { + 'core/test-a': undefined, + 'core/test-b': undefined, + }, + }, + }, + 'core/test-a': { + preferences: { + features: undefined, + }, + }, + 'core/test-b': { + preferences: { + features: undefined, + }, + }, + } ); + } ); + + it( 'only migrates persisted preference data for the source name from source stores', () => { + const state = { + 'core/test-a': { + otherData: { + test: 1, + }, + preferences: { + features: { + featureA: true, + featureB: false, + featureC: true, + }, + }, + }, + 'core/test-b': { + otherData: { + test: 2, + }, + preferences: { + features: { + featureD: true, + featureE: false, + featureF: true, + }, + }, + }, + }; + + const convertedState = moveFeaturePreferences( state, 'core/test-a' ); + + expect( convertedState ).toEqual( { + 'core/preferences': { + preferences: { + 'core/test-a': { + featureA: true, + featureB: false, + featureC: true, + }, + }, + }, + 'core/test-a': { + otherData: { + test: 1, + }, + preferences: { + features: undefined, + }, + }, + 'core/test-b': { + otherData: { + test: 2, + }, + preferences: { + features: { + featureD: true, + featureE: false, + featureF: true, + }, + }, + }, + } ); + } ); + + it( 'only migrates persisted data for the source name from interface', () => { + const state = { + 'core/interface': { + preferences: { + features: { + 'core/test-a': { + featureG: true, + featureH: false, + featureI: true, + }, + 'core/test-b': { + featureJ: true, + featureK: false, + featureL: true, + }, + }, + }, + }, + }; + + const convertedState = moveFeaturePreferences( state, 'core/test-a' ); + + expect( convertedState ).toEqual( { + 'core/preferences': { + preferences: { + 'core/test-a': { + featureG: true, + featureH: false, + featureI: true, + }, + }, + }, + 'core/interface': { + preferences: { + features: { + 'core/test-a': undefined, + 'core/test-b': { + featureJ: true, + featureK: false, + featureL: true, + }, + }, + }, + }, + } ); + } ); +} ); diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-individual-preference.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-individual-preference.js new file mode 100644 index 00000000000000..e043c55d7697ef --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-individual-preference.js @@ -0,0 +1,188 @@ +/** + * Internal dependencies + */ +import moveIndividualPreference from '../move-individual-preference'; + +describe( 'moveIndividualPreference', () => { + it( 'migrates an individual preference from the source to the preferences store', () => { + const initialState = { + 'core/test': { + preferences: { + myPreference: '123', + }, + }, + }; + + const convertedData = moveIndividualPreference( + initialState, + { from: 'core/test', to: 'core/test' }, + 'myPreference' + ); + + expect( convertedData ).toEqual( { + 'core/preferences': { + preferences: { + 'core/test': { + myPreference: '123', + }, + }, + }, + 'core/test': { + preferences: { + myPreference: undefined, + }, + }, + } ); + } ); + + it( 'does not overwrite other preferences in the preferences store', () => { + const initialState = { + 'core/test': { + otherData: { + test: 1, + }, + preferences: { + myPreference: '123', + }, + }, + 'core/preferences': { + preferences: { + 'core/other-store': { + preferenceA: 1, + preferenceB: 2, + }, + 'core/test': { + unrelatedPreference: 'unrelated-value', + }, + }, + }, + }; + + const convertedData = moveIndividualPreference( + initialState, + { from: 'core/test', to: 'core/test' }, + 'myPreference' + ); + + expect( convertedData ).toEqual( { + 'core/preferences': { + preferences: { + 'core/other-store': { + preferenceA: 1, + preferenceB: 2, + }, + 'core/test': { + unrelatedPreference: 'unrelated-value', + myPreference: '123', + }, + }, + }, + 'core/test': { + otherData: { + test: 1, + }, + preferences: { + myPreference: undefined, + }, + }, + } ); + } ); + + it( 'supports moving data to a scope that is differently named to the source store', () => { + const initialState = { + 'core/source': { + preferences: { + myPreference: '123', + }, + }, + }; + + const convertedData = moveIndividualPreference( + initialState, + { from: 'core/source', to: 'core/destination' }, + 'myPreference' + ); + + expect( convertedData ).toEqual( { + 'core/preferences': { + preferences: { + 'core/destination': { + myPreference: '123', + }, + }, + }, + 'core/source': { + preferences: { + myPreference: undefined, + }, + }, + } ); + } ); + + it( 'does not migrate data if there is already a matching preference key at the target', () => { + const initialState = { + 'core/test': { + preferences: { + myPreference: '123', + }, + }, + 'core/preferences': { + preferences: { + 'core/test': { + myPreference: 'already-set', + }, + }, + }, + }; + + const convertedData = moveIndividualPreference( + initialState, + { from: 'core/test', to: 'core/test' }, + 'myPreference' + ); + + expect( convertedData ).toEqual( { + 'core/preferences': { + preferences: { + 'core/test': { + myPreference: 'already-set', + }, + }, + }, + 'core/test': { + preferences: { + myPreference: '123', + }, + }, + } ); + } ); + + it( 'migrates preferences that have a `false` value', () => { + const initialState = { + 'core/test': { + preferences: { + myFalsePreference: false, + }, + }, + }; + + const convertedData = moveIndividualPreference( + initialState, + { from: 'core/test', to: 'core/test' }, + 'myFalsePreference' + ); + + expect( convertedData ).toEqual( { + 'core/preferences': { + preferences: { + 'core/test': { + myFalsePreference: false, + }, + }, + }, + 'core/test': { + preferences: {}, + }, + } ); + } ); +} ); diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-interface-enable-items.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-interface-enable-items.js new file mode 100644 index 00000000000000..bbfa89970f6ecb --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-interface-enable-items.js @@ -0,0 +1,118 @@ +/** + * Internal dependencies + */ +import moveInterfaceEnableItems from '../move-interface-enable-items'; + +describe( 'moveInterfaceEnableItems', () => { + it( 'migrates enableItems to the preferences store', () => { + const state = { + 'core/interface': { + enableItems: { + singleEnableItems: { + complementaryArea: { + 'core/edit-post': 'edit-post/document', + 'core/edit-site': 'edit-site/global-styles', + }, + }, + multipleEnableItems: { + pinnedItems: { + 'core/edit-post': { + 'plugin-1': true, + }, + 'core/edit-site': { + 'plugin-2': true, + }, + }, + }, + }, + }, + }; + + const convertedState = moveInterfaceEnableItems( state ); + + expect( convertedState ).toEqual( { + 'core/preferences': { + preferences: { + 'core/edit-post': { + complementaryArea: 'edit-post/document', + pinnedItems: { + 'plugin-1': true, + }, + }, + 'core/edit-site': { + complementaryArea: 'edit-site/global-styles', + pinnedItems: { + 'plugin-2': true, + }, + }, + }, + }, + 'core/interface': { + enableItems: undefined, + }, + } ); + } ); + + it( 'merges pinnedItems and complementaryAreas with existing preferences store data', () => { + const state = { + 'core/interface': { + enableItems: { + singleEnableItems: { + complementaryArea: { + 'core/edit-post': 'edit-post/document', + 'core/edit-site': 'edit-site/global-styles', + }, + }, + multipleEnableItems: { + pinnedItems: { + 'core/edit-post': { + 'plugin-1': true, + }, + 'core/edit-site': { + 'plugin-2': true, + }, + }, + }, + }, + }, + 'core/preferences': { + preferences: { + 'core/edit-post': { + preferenceA: 1, + preferenceB: 2, + }, + 'core/edit-site': { + preferenceC: true, + }, + }, + }, + }; + + const convertedState = moveInterfaceEnableItems( state ); + + expect( convertedState ).toEqual( { + 'core/preferences': { + preferences: { + 'core/edit-post': { + preferenceA: 1, + preferenceB: 2, + complementaryArea: 'edit-post/document', + pinnedItems: { + 'plugin-1': true, + }, + }, + 'core/edit-site': { + preferenceC: true, + complementaryArea: 'edit-site/global-styles', + pinnedItems: { + 'plugin-2': true, + }, + }, + }, + }, + 'core/interface': { + enableItems: undefined, + }, + } ); + } ); +} ); diff --git a/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-third-party-feature-preferences.js b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-third-party-feature-preferences.js new file mode 100644 index 00000000000000..9f7d6fdc63b47d --- /dev/null +++ b/packages/preferences-persistence/src/migrations/legacy-local-storage-data/test/move-third-party-feature-preferences.js @@ -0,0 +1,107 @@ +/** + * Internal dependencies + */ +import moveThirdPartyFeaturePreferences from '../move-third-party-feature-preferences'; + +describe( 'moveThirdPartyFeaturePreferences', () => { + it( 'migrates multiple scopes from the interface package to the preferences package', () => { + const state = { + 'core/interface': { + otherData: { + test: 1, + }, + preferences: { + features: { + 'plugin-a': { + featureA: true, + featureB: false, + featureC: true, + }, + 'plugin-b': { + featureD: true, + featureE: false, + featureF: true, + }, + }, + }, + }, + }; + + const convertedState = moveThirdPartyFeaturePreferences( state ); + + expect( convertedState ).toEqual( { + 'core/preferences': { + preferences: { + 'plugin-a': { + featureA: true, + featureB: false, + featureC: true, + }, + 'plugin-b': { + featureD: true, + featureE: false, + featureF: true, + }, + }, + }, + 'core/interface': { + otherData: { + test: 1, + }, + preferences: { + features: { + 'plugin-a': undefined, + 'plugin-b': undefined, + }, + }, + }, + } ); + } ); + + it( 'ignores any core scopes', () => { + const state = { + 'core/interface': { + preferences: { + features: { + 'plugin-a': { + featureA: true, + featureB: false, + featureC: true, + }, + 'core/edit-post': { + featureD: true, + featureE: false, + featureF: true, + }, + }, + }, + }, + }; + + const convertedState = moveThirdPartyFeaturePreferences( state ); + + expect( convertedState ).toEqual( { + 'core/preferences': { + preferences: { + 'plugin-a': { + featureA: true, + featureB: false, + featureC: true, + }, + }, + }, + 'core/interface': { + preferences: { + features: { + 'plugin-a': undefined, + 'core/edit-post': { + featureD: true, + featureE: false, + featureF: true, + }, + }, + }, + }, + } ); + } ); +} ); diff --git a/packages/preferences/CHANGELOG.md b/packages/preferences/CHANGELOG.md index 853787086b6818..3e52410949c968 100644 --- a/packages/preferences/CHANGELOG.md +++ b/packages/preferences/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Breaking change + +- The preferences package is no longer compatible with the `@wordpress/data` persistence plugin. Please use the new `setPersistenceLayer` API. ([#39795](https://github.com/WordPress/gutenberg/pull/39795)) + +### Enhancement + +- A new `setPersistenceLayer` action has been introduced. ([#39795](https://github.com/WordPress/gutenberg/pull/39795)) + ## 1.3.0 (2022-04-21) ## 1.2.0 (2022-04-08) diff --git a/packages/preferences/README.md b/packages/preferences/README.md index 72cc2fb1d9e884..74e27f9dc7c8b4 100644 --- a/packages/preferences/README.md +++ b/packages/preferences/README.md @@ -1,6 +1,6 @@ # Preferences -Utilities for storing WordPress preferences. +A key/value store for application preferences. ## Installation @@ -12,12 +12,28 @@ npm install @wordpress/preferences --save _This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ +## Key concepts + +### Scope + +Many API calls require a 'scope' parameter that acts like a namespace. If you have multiple parameters with the same key but they apply to different parts of your application, using scopes is the best way to segregate them. + +### Key + +Each preference is set against a key that should be a string. + +### Value + +Values can be of any type, but the types supported may be limited by the persistence layer configure. For example if preferences are saved to browser localStorage in JSON format, only JSON serializable types should be used. + +### Defaults + +Defaults are the value returned when a preference is `undefined`. These are not persisted, they are only kept in memory. They should be during the initialization of an application. + ## Examples ### Data store -Preferences are persisted values of any kind. - Set the default preferences for any features on initialization by dispatching an action: ```js @@ -38,7 +54,7 @@ function initialize() { } ``` -Or the `get` selector to get a preference value, and the `set` action to update a preference to any value: +Use the `get` selector to get a preference value, and the `set` action to update a preference: ```js wp.data @@ -66,6 +82,58 @@ wp.data .get( 'namespace/editor-or-plugin-name', 'myPreferenceName' ); // false ``` +#### Setting up a persistence layer + +By default, this package only stores values in-memory. But it can be configured to persist preferences to browser storage or a database via an optional persistence layer. + +Use the `setPersistenceLayer` action to configure how the store persists its preference values. + +```js +wp.data.dispatch( 'core/preferences' ).setPersistenceLayer( { + // `get` is asynchronous to support persisting preferences using a REST API. + // it will immediately be called by `setPersistenceLayer` and the returned + // value used as the initial state of the preferences. + async get() { + return JSON.parse( window.localStorage.getItem( 'MY_PREFERENCES' ) ); + }, + + // `set` is synchronous. It's ok to use asynchronous code, but the + // preferences store won't wait for a promise to resolve, the function is + // 'fire and forget'. + set( preferences ) { + window.localStorage.setItem( + 'MY_PREFERENCES', + JSON.stringify( preferences ) + ); + }, +} ); +``` + +For application that persist data to an asynchronous API, a concern is that loading preferences can lead to slower application start up. + +A recommendation is to pre-load any persistence layer data and keep it in a local cache particularly if you're using an asynchronous API to persist data. + +Note: currently `get` is called only when `setPersistenceLayer` is triggered. This may change in the future, so it's sensible to optimize `get` using a local cache, as shown in the example below. + +```js +// Preloaded data from the server. +let cache = preloadedData; +wp.data.dispatch( 'core/preferences' ).setPersistenceLayer( { + async get() { + if ( cache ) { + return cache; + } + + // Call to a made-up async API. + return await api.preferences.get(); + }, + set( preferences ) { + cache = preferences; + api.preferences.set( { data: preferences } ); + }, +} ); +``` + ### Components The `PreferenceToggleMenuItem` components can be used with a `DropdownMenu` to implement a menu for changing preferences. @@ -130,6 +198,27 @@ _Returns_ - `Object`: Action object. +#### setPersistenceLayer + +Sets the persistence layer. + +When a persistence layer is set, the preferences store will: + +- call `get` immediately and update the store state to the value returned. +- call `set` with all preferences whenever a preference changes value. + +`setPersistenceLayer` should ideally be dispatched at the start of an +application's lifecycle, before any other actions have been dispatched to +the preferences store. + +_Parameters_ + +- _persistenceLayer_ `WPPreferencesPersistenceLayer`: The persistence layer. + +_Returns_ + +- `Object`: Action object. + #### toggle Returns an action object used in signalling that a preference should be diff --git a/packages/preferences/src/store/actions.js b/packages/preferences/src/store/actions.js index 820a762c5d51e7..7fcedd49d35297 100644 --- a/packages/preferences/src/store/actions.js +++ b/packages/preferences/src/store/actions.js @@ -47,3 +47,36 @@ export function setDefaults( scope, defaults ) { defaults, }; } + +/** @typedef {() => Promise} WPPreferencesPersistenceLayerGet */ +/** @typedef {(*) => void} WPPreferencesPersistenceLayerSet */ +/** + * @typedef WPPreferencesPersistenceLayer + * + * @property {WPPreferencesPersistenceLayerGet} get An async function that gets data from the persistence layer. + * @property {WPPreferencesPersistenceLayerSet} set A function that sets data in the persistence layer. + */ + +/** + * Sets the persistence layer. + * + * When a persistence layer is set, the preferences store will: + * - call `get` immediately and update the store state to the value returned. + * - call `set` with all preferences whenever a preference changes value. + * + * `setPersistenceLayer` should ideally be dispatched at the start of an + * application's lifecycle, before any other actions have been dispatched to + * the preferences store. + * + * @param {WPPreferencesPersistenceLayer} persistenceLayer The persistence layer. + * + * @return {Object} Action object. + */ +export async function setPersistenceLayer( persistenceLayer ) { + const persistedData = await persistenceLayer.get(); + return { + type: 'SET_PERSISTENCE_LAYER', + persistenceLayer, + persistedData, + }; +} diff --git a/packages/preferences/src/store/index.js b/packages/preferences/src/store/index.js index 6c7b560f0720cb..0c2421966a0d79 100644 --- a/packages/preferences/src/store/index.js +++ b/packages/preferences/src/store/index.js @@ -1,11 +1,8 @@ /** * WordPress dependencies */ -import { createReduxStore, registerStore } from '@wordpress/data'; +import { createReduxStore, register } from '@wordpress/data'; -/** - * Internal dependencies - */ /** * Internal dependencies */ @@ -25,14 +22,6 @@ export const store = createReduxStore( STORE_NAME, { reducer, actions, selectors, - persist: [ 'preferences' ], } ); -// Once we build a more generic persistence plugin that works across types of stores -// we'd be able to replace this with a register call. -registerStore( STORE_NAME, { - reducer, - actions, - selectors, - persist: [ 'preferences' ], -} ); +register( store ); diff --git a/packages/preferences/src/store/reducer.js b/packages/preferences/src/store/reducer.js index 7e4752a22ef249..0d2f463ab01de3 100644 --- a/packages/preferences/src/store/reducer.js +++ b/packages/preferences/src/store/reducer.js @@ -29,6 +29,37 @@ export function defaults( state = {}, action ) { return state; } +/** + * Higher order reducer that does the following: + * - Merges any data from the persistence layer into the state when the + * `SET_PERSISTENCE_LAYER` action is received. + * - Passes any preferences changes to the persistence layer. + * + * @param {Function} reducer The preferences reducer. + * + * @return {Function} The enhanced reducer. + */ +function withPersistenceLayer( reducer ) { + let persistenceLayer; + + return ( state, action ) => { + // Setup the persistence layer, and return the persisted data + // as the state. + if ( action.type === 'SET_PERSISTENCE_LAYER' ) { + const { persistenceLayer: persistence, persistedData } = action; + persistenceLayer = persistence; + return persistedData; + } + + const nextState = reducer( state, action ); + if ( action.type === 'SET_PREFERENCE_VALUE' ) { + persistenceLayer?.set( nextState ); + } + + return nextState; + }; +} + /** * Reducer returning the user preferences. * @@ -37,7 +68,7 @@ export function defaults( state = {}, action ) { * * @return {Object} Updated state. */ -export function preferences( state = {}, action ) { +export const preferences = withPersistenceLayer( ( state = {}, action ) => { if ( action.type === 'SET_PREFERENCE_VALUE' ) { const { scope, name, value } = action; return { @@ -50,7 +81,7 @@ export function preferences( state = {}, action ) { } return state; -} +} ); export default combineReducers( { defaults, diff --git a/packages/preferences/src/store/test/actions.js b/packages/preferences/src/store/test/actions.js new file mode 100644 index 00000000000000..493210210a53ef --- /dev/null +++ b/packages/preferences/src/store/test/actions.js @@ -0,0 +1,27 @@ +/** + * Internal dependencies + */ +import { setPersistenceLayer } from '../actions'; + +describe( 'setPersistenceLayer', () => { + it( 'returns an action that contains the persistence layer and the result of calling `persistenceLayer.get`', async () => { + const result = { + testA: 1, + testB: 2, + }; + const testPersistenceLayer = { + async get() { + return result; + }, + set() {}, + }; + + const action = await setPersistenceLayer( testPersistenceLayer ); + + expect( action ).toEqual( { + type: 'SET_PERSISTENCE_LAYER', + persistenceLayer: testPersistenceLayer, + persistedData: result, + } ); + } ); +} ); diff --git a/packages/preferences/src/store/test/reducer.js b/packages/preferences/src/store/test/reducer.js new file mode 100644 index 00000000000000..985959acb2f000 --- /dev/null +++ b/packages/preferences/src/store/test/reducer.js @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import { preferences } from '../reducer'; + +describe( 'withPersistenceLayer( preferences )', () => { + it( 'updates the store state to the persisted data when called with the `SET_PERSISTENCE_LAYER` action', () => { + const persistedData = { + a: 1, + b: 2, + }; + + const action = { + type: 'SET_PERSISTENCE_LAYER', + persistedData, + }; + + expect( preferences( {}, action ) ).toEqual( persistedData ); + } ); + + it( 'calls the persistence layer `set` function with the updated store state whenever the `SET_PREFERENCE_VALUE` action is dispatched', () => { + const set = jest.fn(); + const persistenceLayer = { + set, + }; + + const setPersistenceLayerAction = { + type: 'SET_PERSISTENCE_LAYER', + persistenceLayer, + persistedData: {}, + }; + + // Set the persistence layer. + preferences( {}, setPersistenceLayerAction ); + + // Update a value. + const setPreferenceValueAction = { + type: 'SET_PREFERENCE_VALUE', + name: 'myPreference', + value: 'myValue', + }; + + const state = preferences( {}, setPreferenceValueAction ); + + expect( set ).toHaveBeenCalledWith( state ); + } ); +} );