diff --git a/package.json b/package.json index df6c97e87d43a..e8dc429621412 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@wordpress/plugins": "file:packages/plugins", "@wordpress/redux-routine": "file:packages/redux-routine", "@wordpress/shortcode": "file:packages/shortcode", + "@wordpress/storage": "file:packages/storage", "@wordpress/token-list": "file:packages/token-list", "@wordpress/url": "file:packages/url", "@wordpress/viewport": "file:packages/viewport", @@ -48,6 +49,7 @@ "classnames": "2.2.5", "equivalent-key-map": "0.2.1", "eslint-plugin-wordpress": "git://github.com/WordPress-Coding-Standards/eslint-plugin-wordpress.git#1774343f6226052a46b081e01db3fca8793cc9f1", + "is-online": "7.0.0", "jquery": "3.3.1", "lodash": "4.17.10", "prop-types": "15.5.10", diff --git a/packages/api-fetch/src/index.js b/packages/api-fetch/src/index.js index a2ebeeff37a9a..e41a3a40d95dd 100644 --- a/packages/api-fetch/src/index.js +++ b/packages/api-fetch/src/index.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import isOnline from 'is-online'; + /** * WordPress dependencies */ @@ -63,6 +68,22 @@ function apiFetch( options ) { .then( checkStatus ) .then( parseResponse ) .catch( ( response ) => { + // If the error meets this criteria, that means we couldn't reach the + // server and are likely offline. In that case we'll dispatch an error + // saying we're offline so the UI has the option to display a notice + // or warning instead of an error. + if ( + response.name === 'TypeError' && + response.message === 'NetworkError when attempting to fetch resource.' + ) { + console.debug( 'HTTP Status was zero; no internet connection.' ); + const noNetworkConnectionError = { + code: 'no_network_connection', + message: __( 'Could not reach server; you may be offline.' ), + }; + throw noNetworkConnectionError; + } + if ( ! parse ) { throw response; } diff --git a/packages/editor/src/store/effects.js b/packages/editor/src/store/effects.js index 76030f1512e64..13206eb4fd782 100644 --- a/packages/editor/src/store/effects.js +++ b/packages/editor/src/store/effects.js @@ -52,6 +52,7 @@ import { requestPostUpdate, requestPostUpdateSuccess, requestPostUpdateFailure, + requestPostUpdatePending, trashPost, trashPostFailure, refreshPost, @@ -64,6 +65,7 @@ export default { }, REQUEST_POST_UPDATE_SUCCESS: requestPostUpdateSuccess, REQUEST_POST_UPDATE_FAILURE: requestPostUpdateFailure, + REQUEST_POST_UPDATE_PENDING: requestPostUpdatePending, TRASH_POST: ( action, store ) => { trashPost( action, store ); }, diff --git a/packages/editor/src/store/effects/posts.js b/packages/editor/src/store/effects/posts.js index 46ddaa04374fe..8899f426d0772 100644 --- a/packages/editor/src/store/effects/posts.js +++ b/packages/editor/src/store/effects/posts.js @@ -9,6 +9,7 @@ import { pick, includes } from 'lodash'; */ import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; +import storage from '@wordpress/storage'; import { addQueryArgs } from '@wordpress/url'; /** @@ -89,6 +90,11 @@ export const requestPostUpdate = async ( action, store ) => { const postType = await resolveSelector( 'core', 'getPostType', getCurrentPostType( state ) ); + const postsDatabase = storage( 'wp_posts' ); + console.debug( 'post', post ); + console.debug( 'toSend', toSend ); + await postsDatabase.setItem( `post-id-${ post.id }`, { ...post, ...toSend } ); + dispatch( { type: 'REQUEST_POST_UPDATE_START', optimist: { type: BEGIN, id: POST_UPDATE_TRANSACTION_ID }, @@ -155,6 +161,18 @@ export const requestPostUpdate = async ( action, store ) => { isAutosave, } ); } catch ( error ) { + if ( error.code === 'no_network_connection' ) { + console.debug( 'Could not save; saving post offline.', error ); + + dispatch( { + type: 'REQUEST_POST_UPDATE_PENDING', + post, + isAutosave, + } ); + + return; + } + dispatch( { type: 'REQUEST_POST_UPDATE_FAILURE', optimist: { type: REVERT, id: POST_UPDATE_TRANSACTION_ID }, @@ -280,6 +298,34 @@ export const requestPostUpdateFailure = ( action, store ) => { dispatch( createErrorNotice( cloudflaredMessage, { id: SAVE_POST_NOTICE_ID } ) ); }; +/** + * Request Post Update Pending Effect handler + * + * @param {Object} action action object. + * @param {Object} store Redux Store. + */ +export const requestPostUpdatePending = ( action, store ) => { + const { isAutosave } = action; + const { dispatch } = store; + + // Autosaves are neither shown a notice nor redirected. + if ( isAutosave ) { + console.debug( 'Autosave pending' ); + return; + } + + // Generic fallback notice. + const noticeMessage = __( 'Your connection appears offline, so this post was saved locally.' ); + + console.debug( 'Save pending' ); + if ( noticeMessage ) { + dispatch( createSuccessNotice( +

{ noticeMessage }

, + { id: SAVE_POST_NOTICE_ID, spokenMessage: noticeMessage } + ) ); + } +}; + /** * Trash Post Effect handler * diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js index 09d58a57be956..e6b9c93da68e0 100644 --- a/packages/editor/src/store/reducer.js +++ b/packages/editor/src/store/reducer.js @@ -841,6 +841,13 @@ export function saving( state = {}, action ) { error: null, }; + case 'REQUEST_POST_UPDATE_PENDING': + return { + requesting: false, + successful: false, + error: null, + }; + case 'REQUEST_POST_UPDATE_FAILURE': return { requesting: false, diff --git a/packages/storage/README.md b/packages/storage/README.md new file mode 100644 index 0000000000000..5ce2d61888909 --- /dev/null +++ b/packages/storage/README.md @@ -0,0 +1,13 @@ +# Storage + +Storage module for WordPress; built using localForage. + +## Installation + +Install the module + +```bash +npm install @wordpress/storage --save +``` + +

Code is Poetry.

diff --git a/packages/storage/package.json b/packages/storage/package.json new file mode 100644 index 0000000000000..e09881ef2136d --- /dev/null +++ b/packages/storage/package.json @@ -0,0 +1,28 @@ +{ + "name": "@wordpress/storage", + "version": "0.0.1", + "description": "Offline storage module for WordPress.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "shortcode" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/storage/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "dependencies": { + "@babel/runtime-corejs2": "7.0.0-beta.56", + "localforage": "^1.6.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/storage/src/index.js b/packages/storage/src/index.js new file mode 100644 index 0000000000000..7b7f45e136207 --- /dev/null +++ b/packages/storage/src/index.js @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import localforage from 'localforage'; + +/** + * WordPress dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Returns a localForage instance given a storeName. + * + * @param {string} storeName alphanumeric+underscore identifier for + * a store. Passed to localForage's `config()` + * method as the `storeName` prop. + * + * @return {localforage} localForage instance + */ +export default function createStorage( storeName ) { + if ( ! storeName || ! storeName.length ) { + throw new Error( 'storeName is required for editor/utils/storage' ); + } + + return localforage.createInstance( { + name: 'WordPress Editor', + storeName, + } ); +} + +const DEFAULT_STATE = {}; + +const actions = { + getItem( storeName, key ) { + return { + type: 'GET_STORAGE_ITEM', + key, + storeName, + }; + }, + + getItemFromStorageBackend( storeName, key ) { + return { + type: 'GET_STORAGE_ITEM_FROM_STORAGE_BACKEND', + key, + storeName, + }; + }, + + setItem( storeName, key, value ) { + return { + type: 'SET_STORAGE_ITEM', + key, + storeName, + value, + }; + }, +}; + +registerStore( 'storage', { + reducer( state = DEFAULT_STATE, action ) { + switch ( action.type ) { + case 'RETRIEVE_STORAGE_ITEM': + return { + ...state, + [ action.storageName ]: { + [ action.key ]: action.value, + }, + }; + } + + return state; + }, + + actions, + + selectors: {}, + + controls: { + GET_STORAGE_ITEM_FROM_STORAGE_BACKEND( action ) { + return createStorage( action.storeName ).getItem( action.key ); + }, + SET_ITEM( action ) { + return createStorage( action.storeName ).setItem( action.key, action.value ); + }, + }, + + resolvers: { + * getItem( state, storeName, key ) { + const value = yield actions.getItemFromStorageBackend( storeName, key ); + + return actions.retrieveStorageItem( storeName, key, value ); + }, + * setItem( state, storeName, key, value ) { + yield actions.setItemInStorageBackend( storeName, key, value ); + + return actions.retrieveStorageItem( storeName, key, value ); + }, + }, +} ); diff --git a/packages/storage/src/test/index.js b/packages/storage/src/test/index.js new file mode 100644 index 0000000000000..7789f34eb7f62 --- /dev/null +++ b/packages/storage/src/test/index.js @@ -0,0 +1,10 @@ +/** + * Internal dependencies + */ +// import { next, replace, attrs } from '../'; + +describe( 'storage', () => { + it( 'should work', () => { + expect( true ).toEqual( true ); + } ); +} );