-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow saving and publishing posts #594
Conversation
Does this include what the Contributor role would see? That role can only save as draft, not publish. Don't the messages come from the server? |
That's a good point about some users not being able to publish. I think this is another reason to limit the plugin to create new drafts or update existing posts for now - keep in mind that we haven't built the controls to change post status yet. |
buttonText = wp.i18n.__( 'Update' ); | ||
} else { | ||
buttonText = wp.i18n.__( 'Publish' ); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know some people don't like it but I prefer to use switch (true)
in these cases.
const buttonEnabled = ! isRequesting;
switch( true ) {
case isRequesting && requestIsNewPost:
buttonText = wp.i18n.__( 'Publishing...' );
break;
case isRequesting && !requestIsNewPost:
buttonText = wp.i18n.__( 'Publishing...' );
break;
// ...
}
IMO it's more readable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd probably change this to a single if
chain without nesting instead, like if ( isRequesting && requestIsNewPost )
. I'll try it
post, | ||
isNew, | ||
} ); | ||
} ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it time to start separating the action creators into their own actions.js
file. This would ease testing these actions. I'm also thinking about testing the mergeWithPrevious
action this way.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, this definitely doesn't belong here.
editor/state.js
Outdated
* @param {Object} action Dispatched action | ||
* @return {string} Updated state | ||
*/ | ||
export function api( state = {}, action ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be renamed saving
or something like that. Thinking we may have other unrelated requests in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work @nylen, This is testing well!
Should we drop the "Published" or "Updated" message in favor of "Update" when the post is made dirty again?
Yes - do we have the mechanism in place yet to detect dirty content? |
We don't for now, we could do these actions: 'INSERT_BLOCK', 'UPDATE_BLOCK', 'SWITCH_BLOCK_TYPE', 'MOVE_BLOCK_DOWN', 'MOVE_BLOCK_UP' and 'REMOVE_BLOCK'. Granted this is bot very convenient. |
Ok - this should also include typing (content changes), so I'd prefer to wait until this is implemented fully and we have an
|
82c2efd
to
53accc0
Compare
I think this is ready for another round of review. The latest commits address all feedback except the "dirty state" which I've started to explore in #610. |
( dispatch ) => ( { | ||
onUpdate( post, blocks ) { | ||
post.content.raw = wp.blocks.serialize( blocks ); | ||
savePost( dispatch, post ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we should introduce, redux-thunk
or any alternative async middleware to simplify this. Could be done later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Discussed further at #594 (comment). As noted there, I'd prefer to explore this separately.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @nylen. This is a great addition towards allowing people to test the plugin with real posts. 👍
@jasmussen mind giving a quick look at the design implications for the different states? |
Really nice work. This is exciting! UI wise we should try some changes, so the publish button doesn't do it all. Though I don't think these changes specifically have to happen in this PR — it works pretty well and is so exciting we shouldn't let a few UI tweaks prevent it from being merged. Here are some blueprint mockups. First, a publish dropdown that does not need to happen immediately, if at all. I will open a separate ticket for it, but wanted to show it here regardless: Publish flow: Drafting flow: If you agree the above makes sense, I'll open a separate ticket for splitting drafting/saving behavior out into a separate ticket. There are also some thoughts in #486. |
Let's get this one merged as soon as possible and continue design improvements in separate PRs, it's renaming one of the main state keys so there are already a lot of conflicts. I'll clean those up later today and plan to merge unless anyone has further feedback, in particular @aduth I don't think you've taken a look through here yet. |
editor/actions.js
Outdated
export function savePost( dispatch, post ) { | ||
const isNew = ! post.id; | ||
dispatch( { | ||
type: 'POST_UPDATE_REQUEST', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thus far we've taken the convention of prefixing the action verb, not suffixing, i.e. REQUEST_POST_UPDATE
vs. POST_UPDATE_REQUEST
. I don't feel strongly one way or the other, but noting the inconsistency.
editor/state.js
Outdated
export function saving( state = {}, action ) { | ||
switch ( action.type ) { | ||
case 'POST_UPDATE_REQUEST': | ||
return { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a reducer returns an object of consistent keys, I wonder if it may be better to use combineReducers
which has the advantages of not updating state references when the simple object values don't change (reference) and allowing you to ignore keys not relevant to a specific action type (maybe error
in the case of POST_UPDATE_REQUEST_SUCCESS
).
Something like:
export const saving = combineReducers( {
requesting: ( state = false, action ) => { /* ... */ },
successful: ( state = false, action ) => { /* ... */ },
error: ( state = null, action ) => { /* ... */ },
isNew: ( state = false, action ) => { /* ... */ }
} );
Might end up being more sprawling, and benefit might be minimal here, so not something I'd push hard for.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tried this out, and it ended up being longer and (IMO) harder to read:
export const saving = combineReducers( {
requesting: ( state = false, action ) => {
switch ( action.type ) {
case 'REQUEST_POST_UPDATE':
return true;
case 'REQUEST_POST_UPDATE_SUCCESS':
case 'REQUEST_POST_UPDATE_FAILURE':
return false;
}
return state;
},
successful: ( state = false, action ) => {
switch ( action.type ) {
case 'REQUEST_POST_UPDATE_SUCCESS':
return true;
case 'REQUEST_POST_UPDATE_FAILURE':
return false;
}
return state;
},
error: ( state = false, action ) => {
switch ( action.type ) {
case 'REQUEST_POST_UPDATE_SUCCESS':
return false;
case 'REQUEST_POST_UPDATE_FAILURE':
return true;
}
return state;
},
isNew: ( state = false, action ) => {
switch ( action.type ) {
case 'REQUEST_POST_UPDATE':
case 'REQUEST_POST_UPDATE_SUCCESS':
case 'REQUEST_POST_UPDATE_FAILURE':
return action.isNew;
}
return state;
},
} );
In this case, at least, I prefer the explicitness of "here are 3 action types and here is how each property changes in response". I think updating state references and possible re-renders when a network request is fired or completed is OK.
editor/state.js
Outdated
switch ( action.type ) { | ||
case 'REPLACE_BLOCKS': | ||
case 'EDIT_POST': |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Won't this fall out of sync with state.editor.post
when receiving the saved post via POST_UPDATE_REQUEST_SUCCESS
if the content was manipulated on the server? Not clear to me whether we'll need the entire post object in state, or pieces like postId
(all we're using at the moment).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good point (and the above regarding undoable
), this is probably not stored correctly. I think we'll end up needing to maintain something like editor.post.edits
(containing any changed keys, the next one other than content
will likely be status
).
Then, separately, do we need to store the full post object outside of the undoable reducer to represent what we think is the current state of the post on the server, and update it when we receive better information? Maybe we don't need this functionality yet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update - this PR is currently using the latest known version of the post object to determine if the currently edited post is new (has an ID) or not. Also lots of interesting uses for this later: with regular updates from the server, and conflict resolution where appropriate (a huge task, to be fair), we basically have collaborative editing.
editor/state.js
Outdated
return action.post || state; | ||
|
||
case 'POST_UPDATE_REQUEST_SUCCESS': | ||
return action.post; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By including this in the undoable reducer, seems like we'll be able to revert state back to convincing the UI that the post is a new draft, even if we know it's been saved. Will we need to include POST_UPDATE_REQUEST_SUCCESS
in reset types?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. Probably the entire post object shouldn't be included in the undoable state, rather just the list of edits like we do in Calypso. Seems closely related to the thread below as well (#594 (comment)).
}, | ||
|
||
onSaveDraft( post, blocks ) { | ||
post.content.raw = wp.blocks.serialize( blocks ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
post
here is a reference to state.editor.post
, so we're mutating state directly.
http://redux.js.org/docs/introduction/ThreePrinciples.html#state-is-read-only
Might be enough to do:
savePost( dispatch, {
...post,
status: 'draft',
content: {
...post.content,
raw: wp.blocks.serialize( blocks )
}
} );
onUpdate, | ||
onSaveDraft, | ||
} ) { | ||
let buttonEnabled = true; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Minor: We could calculate this at the variable declaration without much trouble:
const isButtonEnabled = ! isRequesting;
if ( isRequesting ) { | ||
buttonEnabled = false; | ||
buttonText = requestIsNewPost | ||
? wp.i18n.__( 'Saving...' ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know that it's documented as a core i18n convention, but in other projects we've enforced that ellipsis be represented with the ellipsis character for semantic meaning, i.e. …
instead of ...
.
https://github.com/Automattic/eslint-plugin-wpcalypso/blob/master/docs/rules/i18n-ellipsis.md
editor/actions.js
Outdated
*/ | ||
import { get } from 'lodash'; | ||
|
||
export function savePost( dispatch, post ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Already noted by @youknowriad that we'll need to consider adopting a pattern for asynchronous data flow. This file is perhaps a bit misleading since at a glance I'd expect it to include a set of action creators, but this isn't a true action creator in that it accepts dispatch
and doesn't return an action object.
type ActionCreator = (...args: any) => Action | AsyncAction
http://redux.js.org/docs/Glossary.html#action-creator
I'm fine to flesh this out separately; redux-thunk
is a popular option, but I've become less a fan of it over time, since thunks are hard to test, less predictable, and with the default implementation, encourage access to state via the second getState
argument (thus breaking the unidirectional data flow).
If I were to suggest an alternative, I think it shouldn't be much trouble to devise a pattern to encourage treating actions with asynchronous effects as expressing intent. Using middleware(s), those intents could be mapped to side effects which themselves may incur additional actions.
Roughly something like: https://gist.github.com/aduth/b36ac70f0d3cd3f61faa0b2b81b98a2a
Not set in stone, specifically:
- Couldn't
savePost
just returnREQUEST
? Maybe we don't need a separate effects mapping. But sometimes the save intent isn't always just about firing a request, but affecting reducer values (e.g.SAVE_POST
provides semantic meaning that of justREQUEST
which should cause the UI to update to reflect in-progress save) - Instead of an effect's
success
property, we could enforce that the request completion itself should be an action expressing intentREQUEST_COMPLETED
that has its own effect(s) to causereceivePost
to be dispatched - Maybe we forgo the
REQUEST
middleware and treat effects as mini-middlewares which have access todispatch
and could just use fetch or wp-api.js in their implementations
Anyways, this has been a bit of a brain dump that'll get lost in pull request history, but jotting down now that it's becoming relevant since this has been on my mind lately.
A few additional resources from side projects where I've been applying these ideas:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would prefer to address this separately as well. Unlike other parts of this PR (how we actually store the post + its edits in state), it should be pretty easy to refactor this later without touching lots of other parts of the code.
editor/actions.js
Outdated
window.history.replaceState( | ||
{}, | ||
'Post ' + newPost.id, | ||
window.location.href.replace( /&post_id=[^&]*$/, '' ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't know how much we care to address this here, but these are very specific assumptions about URLs where the editor would be loaded. Also breaks down on some valid URLs:
'/admin.php?page=gutenberg&post_id=5'.replace( /&post_id=[^&]*$/, '' )
// Correct: "/admin.php?page=gutenberg"
'/admin.php?post_id=5&page=gutenberg'.replace( /&post_id=[^&]*$/, '' )
// Incorrect: "/admin.php?post_id=5&page=gutenberg"
'/admin.php?page=gutenberg&post_id=5&foo=1'.replace( /&post_id=[^&]*$/, '' )
// Incorrect: "/admin.php?page=gutenberg&post_id=5&foo=1"
In a final solution we'll likely want to consider a querystring / URL parser & serializer, such as:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems easy enough to address here.
2479f1e
to
f9a6320
Compare
Addresses these review threads: #594 (comment) #594 (comment) #594 (comment)
1ae4810
to
e938d3e
Compare
I've addressed or at least responded to all review feedback. @aduth is there anything else you think we should do in this PR? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm going to create a handful of follow-up task issues regarding some of the points we've discussed here, but this should serve as a good base for moving forward 👍
This PR adds the capability to publish new posts and update existing posts via the WP REST API. Closes #526.
This PR is the first time we actually need to store all of the data for a post object in the Redux state. It looks like our
combineUndoableReducers
utility expects to have all undoable actions reside within a single key in the state tree, and the post properties should be part of the undo history. Accordingly, I've renamedstate.blocks
tostate.editor
:state.blocks.byUid
→state.editor.blocksByUid
state.blocks.order
→state.editor.blockOrder
state.editor.post
Even though we're not using Backbone in this project, I'm using the
wp-api.js
Backbone client to save posts because the library is already included in WP core and is the simplest way to make an API call. The alternative would be to use jQuery or another AJAX library, which would involve some error-prone bits of setup that are already handled for us (like building the API URLs and making sure they work with all the different permalink structures).Currently the state of the publish/update operation is reflected in the main action button text. I expect we'll want to change this later, but there are 8 possible messages currently:
Until the plugin is more stable, I wonder if we should update the default action to be creating a new draft instead of immediately publishing a post?