From 45c7e8b08be76a2ef8324d291e436121179fb7e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Souza?= Date: Tue, 18 Oct 2016 12:32:39 -0200 Subject: [PATCH] Optimistic Updates (#114) * Optimistic Updates structure * Optimistic update for Editorial Workflow --- package.json | 2 + src/actions/editorialWorkflow.js | 83 +++-- src/backends/github/API.js | 13 +- src/backends/github/implementation.js | 2 +- src/components/FindBar/FindBar.js | 65 ++-- src/components/MarkupItReactRenderer/index.js | 10 +- src/components/PreviewPane/PreviewPane.js | 10 +- src/components/ScrollSync/ScrollSync.js | 16 +- src/components/ScrollSync/ScrollSyncPane.js | 2 +- src/components/UI/loader/Loader.js | 10 +- src/components/UnpublishedListing.js | 3 - src/components/Widgets/ImageControl.js | 18 +- src/components/Widgets/MarkdownControl.js | 4 +- .../RawEditor/index.js | 32 +- .../VisualEditor/BlockTypesMenu.js | 21 +- .../VisualEditor/StylesMenu.js | 16 +- .../VisualEditor/index.js | 33 +- .../withPortalAtCursorPosition.js | 18 +- .../MarkdownControlElements/constants.js | 16 +- src/components/Widgets/MarkdownPreview.js | 2 +- src/components/Widgets/richText.js | 40 +-- src/components/stories/FindBar.js | 8 +- .../stories/MarkupItReactRenderer.js | 8 +- src/containers/App.css | 296 ++++++++++++++++-- .../editorialWorkflow/CollectionPageHOC.js | 2 +- src/reducers/combinedReducer.js | 5 +- src/reducers/editorialWorkflow.js | 30 +- src/reducers/index.js | 6 +- 28 files changed, 528 insertions(+), 243 deletions(-) diff --git a/package.json b/package.json index 643db793..1a18b689 100644 --- a/package.json +++ b/package.json @@ -124,12 +124,14 @@ "react-toolbox": "^1.2.1", "react-waypoint": "^3.1.3", "redux": "^3.3.1", + "redux-optimist": "^0.0.2", "redux-notifications": "^2.1.1", "redux-thunk": "^1.0.3", "selection-position": "^1.0.0", "semaphore": "^1.0.5", "slate": "^0.14.14", "slate-drop-or-paste-images": "^0.2.0", + "uuid": "^2.0.3", "whatwg-fetch": "^1.0.0" } } diff --git a/src/actions/editorialWorkflow.js b/src/actions/editorialWorkflow.js index 9b1cb70b..acda56f8 100644 --- a/src/actions/editorialWorkflow.js +++ b/src/actions/editorialWorkflow.js @@ -1,3 +1,5 @@ +import uuid from 'uuid'; +import { BEGIN, COMMIT, REVERT } from 'redux-optimist'; import { currentBackend } from '../backends/backend'; import { getMedia } from '../reducers'; import { EDITORIAL_WORKFLOW } from '../constants/publishModes'; @@ -16,9 +18,12 @@ export const UNPUBLISHED_ENTRY_PERSIST_SUCCESS = 'UNPUBLISHED_ENTRY_PERSIST_SUCC export const UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST'; export const UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS'; +export const UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE = 'UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE'; + export const UNPUBLISHED_ENTRY_PUBLISH_REQUEST = 'UNPUBLISHED_ENTRY_PUBLISH_REQUEST'; export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCCESS'; +export const UNPUBLISHED_ENTRY_PUBLISH_FAILURE = 'UNPUBLISHED_ENTRY_PUBLISH_FAILURE'; /* * Simple Action Creators (Internal) @@ -27,20 +32,20 @@ export const UNPUBLISHED_ENTRY_PUBLISH_SUCCESS = 'UNPUBLISHED_ENTRY_PUBLISH_SUCC function unpublishedEntryLoading(status, slug) { return { type: UNPUBLISHED_ENTRY_REQUEST, - payload: { status, slug } + payload: { status, slug }, }; } function unpublishedEntryLoaded(status, entry) { return { type: UNPUBLISHED_ENTRY_SUCCESS, - payload: { status, entry } + payload: { status, entry }, }; } function unpublishedEntriesLoading() { return { - type: UNPUBLISHED_ENTRIES_REQUEST + type: UNPUBLISHED_ENTRIES_REQUEST, }; } @@ -48,9 +53,9 @@ function unpublishedEntriesLoaded(entries, pagination) { return { type: UNPUBLISHED_ENTRIES_SUCCESS, payload: { - entries: entries, - pages: pagination - } + entries, + pages: pagination, + }, }; } @@ -66,49 +71,69 @@ function unpublishedEntriesFailed(error) { function unpublishedEntryPersisting(entry) { return { type: UNPUBLISHED_ENTRY_PERSIST_REQUEST, - payload: { entry } + payload: { entry }, }; } function unpublishedEntryPersisted(entry) { return { type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, - payload: { entry } + payload: { entry }, }; } function unpublishedEntryPersistedFail(error) { return { type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS, - payload: { error } + payload: { error }, }; } -function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus) { +function unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID) { return { type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST, - payload: { collection, slug, oldStatus, newStatus } + payload: { collection, slug, oldStatus, newStatus }, + optimist: { type: BEGIN, id: transactionID }, }; } -function unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus) { +function unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID) { return { type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS, - payload: { collection, slug, oldStatus, newStatus } + payload: { collection, slug, oldStatus, newStatus }, + optimist: { type: COMMIT, id: transactionID }, }; } -function unpublishedEntryPublishRequest(collection, slug, status) { +function unpublishedEntryStatusChangeError(collection, slug, transactionID) { + return { + type: UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE, + payload: { collection, slug }, + optimist: { type: REVERT, id: transactionID }, + }; +} + +function unpublishedEntryPublishRequest(collection, slug, status, transactionID) { return { type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST, - payload: { collection, slug, status } + payload: { collection, slug, status }, + optimist: { type: BEGIN, id: transactionID }, }; } -function unpublishedEntryPublished(collection, slug, status) { +function unpublishedEntryPublished(collection, slug, status, transactionID) { return { type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS, - payload: { collection, slug, status } + payload: { collection, slug, status }, + optimist: { type: COMMIT, id: transactionID }, + }; +} + +function unpublishedEntryPublishError(collection, slug, transactionID) { + return { + type: UNPUBLISHED_ENTRY_PUBLISH_FAILURE, + payload: { collection, slug }, + optimist: { type: REVERT, id: transactionID }, }; } @@ -122,7 +147,7 @@ export function loadUnpublishedEntry(collection, status, slug) { const backend = currentBackend(state.config); dispatch(unpublishedEntryLoading(status, slug)); backend.unpublishedEntry(collection, slug) - .then((entry) => dispatch(unpublishedEntryLoaded(status, entry))); + .then(entry => dispatch(unpublishedEntryLoaded(status, entry))); }; } @@ -133,8 +158,8 @@ export function loadUnpublishedEntries() { const backend = currentBackend(state.config); dispatch(unpublishedEntriesLoading()); backend.unpublishedEntries().then( - (response) => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)), - (error) => dispatch(unpublishedEntriesFailed(error)) + response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)), + error => dispatch(unpublishedEntriesFailed(error)) ); }; } @@ -149,7 +174,7 @@ export function persistUnpublishedEntry(collection, entry) { () => { dispatch(unpublishedEntryPersisted(entry)); }, - (error) => dispatch(unpublishedEntryPersistedFail(error)) + error => dispatch(unpublishedEntryPersistedFail(error)) ); }; } @@ -158,10 +183,14 @@ export function updateUnpublishedEntryStatus(collection, slug, oldStatus, newSta return (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); - dispatch(unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus)); + const transactionID = uuid.v4(); + dispatch(unpublishedEntryStatusChangeRequest(collection, slug, oldStatus, newStatus, transactionID)); backend.updateUnpublishedEntryStatus(collection, slug, newStatus) .then(() => { - dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus)); + dispatch(unpublishedEntryStatusChangePersisted(collection, slug, oldStatus, newStatus, transactionID)); + }) + .catch(() => { + dispatch(unpublishedEntryStatusChangeError(collection, slug, transactionID)); }); }; } @@ -170,10 +199,14 @@ export function publishUnpublishedEntry(collection, slug, status) { return (dispatch, getState) => { const state = getState(); const backend = currentBackend(state.config); + const transactionID = uuid.v4(); dispatch(unpublishedEntryPublishRequest(collection, slug, status)); - backend.publishUnpublishedEntry(collection, slug, status) + backend.publishUnpublishedEntry(collection, slug, status, transactionID) .then(() => { - dispatch(unpublishedEntryPublished(collection, slug, status)); + dispatch(unpublishedEntryPublished(collection, slug, status, transactionID)); + }) + .catch(() => { + dispatch(unpublishedEntryPublishError(collection, slug, transactionID)); }); }; } diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 71818899..645bd4d3 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -165,7 +165,10 @@ export default class API { } persistFiles(entry, mediaFiles, options) { - let filename, part, parts, subtree; + let filename, + part, + parts, + subtree; const fileTree = {}; const uploadPromises = []; @@ -269,7 +272,7 @@ export default class API { } updateUnpublishedEntryStatus(collection, slug, status) { - const contentKey = collection ? `${ collection }-${ slug }` : slug; + const contentKey = slug; return this.retrieveMetadata(contentKey) .then((metadata) => { return { @@ -281,7 +284,7 @@ export default class API { } publishUnpublishedEntry(collection, slug, status) { - const contentKey = collection ? `${ collection }-${ slug }` : slug; + const contentKey = slug; return this.retrieveMetadata(contentKey) .then((metadata) => { const headSha = metadata.pr && metadata.pr.head; @@ -376,7 +379,9 @@ export default class API { updateTree(sha, path, fileTree) { return this.getTree(sha) .then((tree) => { - let obj, filename, fileOrDir; + let obj, + filename, + fileOrDir; const updates = []; const added = {}; diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 5168edd1..79ab830f 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -83,7 +83,7 @@ export default class GitHub { sem.leave(); } else { const entryPath = data.metaData.objects.entry; - const entry = createEntry('draft', entryPath.split('/').pop().replace(/\.[^\.]+$/, ''), entryPath, { raw: data.file }); + const entry = createEntry('draft', contentKey, entryPath, { raw: data.file }); entry.metaData = data.metaData; resolve(entry); sem.leave(); diff --git a/src/components/FindBar/FindBar.js b/src/components/FindBar/FindBar.js index fe670643..a9e12896 100644 --- a/src/components/FindBar/FindBar.js +++ b/src/components/FindBar/FindBar.js @@ -12,7 +12,7 @@ class FindBar extends Component { commands: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, type: PropTypes.string.isRequired, - pattern: PropTypes.string.isRequired + pattern: PropTypes.string.isRequired, })).isRequired, defaultCommands: PropTypes.arrayOf(PropTypes.string), runCommand: PropTypes.func.isRequired, @@ -23,9 +23,9 @@ class FindBar extends Component { this._compiledCommands = []; this._searchCommand = { search: true, - regexp: `(?:${SEARCH})?(.*)`, + regexp: `(?:${ SEARCH })?(.*)`, param: { name: 'searchTerm', display: '' }, - token: SEARCH + token: SEARCH, }; this.state = { value: '', @@ -56,7 +56,7 @@ class FindBar extends Component { } // Generates a regexp and splits a token and param details for a command - compileCommand = command => { + compileCommand = (command) => { let regexp = ''; let param = null; @@ -75,7 +75,7 @@ class FindBar extends Component { return Object.assign({}, command, { regexp, token, - param + param, }); }; @@ -84,15 +84,15 @@ class FindBar extends Component { matchCommand = () => { const string = this.state.activeScope ? this.state.activeScope + this.state.value : this.state.value; let match; - let command = this._compiledCommands.find(command => { - match = string.match(RegExp(`^${command.regexp}`, 'i')); + let command = this._compiledCommands.find((command) => { + match = string.match(RegExp(`^${ command.regexp }`, 'i')); return match; }); // If no command was found, trigger a search command if (!command) { command = this._searchCommand; - match = string.match(RegExp(`^${this._searchCommand.regexp}`, 'i')); + match = string.match(RegExp(`^${ this._searchCommand.regexp }`, 'i')); } const paramName = command && command.param ? command.param.name : null; @@ -101,7 +101,7 @@ class FindBar extends Component { if (command.search) { this.setState({ activeScope: SEARCH, - placeholder: '' + placeholder: '', }); enteredParamValue && this.props.runCommand(SEARCH, { searchTerm: enteredParamValue }); @@ -112,7 +112,7 @@ class FindBar extends Component { this.setState({ value: '', activeScope: command.token, - placeholder: command.param.display + placeholder: command.param.display, }); } else { // Match @@ -121,7 +121,7 @@ class FindBar extends Component { this.setState({ value: '', placeholder: PLACEHOLDER, - activeScope: null + activeScope: null, }, () => { this._input.blur(); }); @@ -137,7 +137,7 @@ class FindBar extends Component { if (this.state.value.length === 0 && this.state.activeScope) { this.setState({ activeScope: null, - placeholder: PLACEHOLDER + placeholder: PLACEHOLDER, }); } }; @@ -160,7 +160,7 @@ class FindBar extends Component { const results = fuzzy.filter(value, commands, { pre: '', post: '', - extract: el => el.token + extract: el => el.token, }); const returnResults = results.slice(0, 4).map(result => ( @@ -171,8 +171,9 @@ class FindBar extends Component { return returnResults; } - handleKeyDown = event => { - let highlightedIndex, index; + handleKeyDown = (event) => { + let highlightedIndex, + index; switch (event.key) { case 'ArrowDown': event.preventDefault(); @@ -202,7 +203,7 @@ class FindBar extends Component { const command = this.getSuggestions()[this.state.highlightedIndex]; const newState = { isOpen: false, - highlightedIndex: 0 + highlightedIndex: 0, }; if (command && !command.search) { newState.value = command.token; @@ -223,24 +224,24 @@ class FindBar extends Component { highlightedIndex: 0, isOpen: false, activeScope: null, - placeholder: PLACEHOLDER + placeholder: PLACEHOLDER, }); break; case 'Backspace': this.setState({ highlightedIndex: 0, - isOpen: true + isOpen: true, }, this.maybeRemoveActiveScope); break; default: this.setState({ highlightedIndex: 0, - isOpen: true + isOpen: true, }); } }; - handleChange = event => { + handleChange = (event) => { this.setState({ value: event.target.value, }); @@ -250,7 +251,7 @@ class FindBar extends Component { if (this._ignoreBlur) return; this.setState({ isOpen: false, - highlightedIndex: 0 + highlightedIndex: 0, }); }; @@ -261,17 +262,17 @@ class FindBar extends Component { handleInputClick = () => { if (this.state.isOpen === false) - this.setState({ isOpen: true }); + { this.setState({ isOpen: true }); } }; - highlightCommandFromMouse = index => { + highlightCommandFromMouse = (index) => { this.setState({ highlightedIndex: index }); }; - selectCommandFromMouse = command => { + selectCommandFromMouse = (command) => { const newState = { isOpen: false, - highlightedIndex: 0 + highlightedIndex: 0, }; if (command && !command.search) { newState.value = command.token; @@ -283,7 +284,7 @@ class FindBar extends Component { }); }; - setIgnoreBlur = ignore => { + setIgnoreBlur = (ignore) => { this._ignoreBlur = ignore; }; @@ -292,14 +293,14 @@ class FindBar extends Component { let children; if (!command.search) { children = ( - + ); } else { children = ( - {this.state.value.length === 0 ? - Search... : - Search for: + {this.state.value.length === 0 ? + Search... : + Search for: } {this.state.value} @@ -331,7 +332,7 @@ class FindBar extends Component { renderActiveScope() { if (this.state.activeScope === SEARCH) { - return
; + return
; } else { return
{this.state.activeScope}
; } @@ -346,7 +347,7 @@ class FindBar extends Component { {scope} this._input = c} + ref={c => this._input = c} onFocus={this.handleInputFocus} onBlur={this.handleInputBlur} onChange={this.handleChange} diff --git a/src/components/MarkupItReactRenderer/index.js b/src/components/MarkupItReactRenderer/index.js index 24c64d4c..f5ede88b 100644 --- a/src/components/MarkupItReactRenderer/index.js +++ b/src/components/MarkupItReactRenderer/index.js @@ -10,7 +10,7 @@ const defaultSchema = { [BLOCKS.PARAGRAPH]: 'p', [BLOCKS.FOOTNOTE]: 'footnote', [BLOCKS.HTML]: ({ token }) => { - return
; + return
; }, [BLOCKS.HR]: 'hr', [BLOCKS.HEADING_1]: 'h1', @@ -35,7 +35,7 @@ const defaultSchema = { [ENTITIES.LINK]: 'a', [ENTITIES.IMAGE]: 'img', [ENTITIES.FOOTNOTE_REF]: 'sup', - [ENTITIES.HARD_BREAK]: 'br' + [ENTITIES.HARD_BREAK]: 'br', }; const notAllowedAttributes = ['loose']; @@ -50,7 +50,7 @@ function renderToken(schema, token, index = 0, key = '0') { const text = token.get('text'); const tokens = token.get('tokens'); const nodeType = schema[type]; - key = `${key}.${index}`; + key = `${ key }.${ index }`; // Only render if type is registered as renderer if (typeof nodeType !== 'undefined') { @@ -101,6 +101,6 @@ MarkupItReactRenderer.propTypes = { syntax: PropTypes.instanceOf(Syntax).isRequired, schema: PropTypes.objectOf(PropTypes.oneOfType([ PropTypes.string, - PropTypes.func - ])) + PropTypes.func, + ])), }; diff --git a/src/components/PreviewPane/PreviewPane.js b/src/components/PreviewPane/PreviewPane.js index 8134d0a8..5ee0efd2 100644 --- a/src/components/PreviewPane/PreviewPane.js +++ b/src/components/PreviewPane/PreviewPane.js @@ -13,9 +13,9 @@ export default class PreviewPane extends React.Component { this.renderPreview(); } - widgetFor = name => { + widgetFor = (name) => { const { collection, entry, getMedia } = this.props; - const field = collection.get('fields').find((field) => field.get('name') === name); + const field = collection.get('fields').find(field => field.get('name') === name); const widget = resolveWidget(field.get('widget')); return React.createElement(widget.preview, { key: field.get('name'), @@ -29,7 +29,7 @@ export default class PreviewPane extends React.Component { const component = registry.getPreviewTemplate(this.props.collection.get('name')) || Preview; const previewProps = { ...this.props, - widgetFor: this.widgetFor + widgetFor: this.widgetFor, }; // We need to use this API in order to pass context to the iframe ReactDOM.unstable_renderSubtreeIntoContainer( @@ -40,7 +40,7 @@ export default class PreviewPane extends React.Component { , this.previewEl); } - handleIframeRef = ref => { + handleIframeRef = (ref) => { if (ref) { registry.getPreviewStyles().forEach((style) => { const linkEl = document.createElement('link'); @@ -61,7 +61,7 @@ export default class PreviewPane extends React.Component { return null; } - return ; + return