Skip to content
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

Revisions #2: Revisions state update and diff-ing #14241

Merged
merged 7 commits into from
Jun 8, 2017

Conversation

bperson
Copy link
Contributor

@bperson bperson commented May 18, 2017

This is the second chunk coming from my WIP PR to introduce post revisions in calypso: #13367. It pushes the normalisation of revisions a bit further and adds support for diff-ing between revisions

changes

It builds upon what was introduced in #12733, adding:

  • Normalise dates from a format with timezone information in the field names (date_gmt) to a format with timezone information embedded in the datetime strings (simply adding the zone designator for the zero UTC offset: Z). This means we can keep strings in Redux ( instead of moment.js objects) and still use components parsing our strings without any timezone issue.
  • Add display/editing normalisation updates
  • Introduce jsdiff as a dependency (no shrinkmap yet, Data Layer: Save response headers in the action meta when redispatching #14157 need to be merged first).

We compute the diff between revisions:

  • when loading the revisions from the API, saving the changes summary ( words added / words removed ) directly in Redux
  • when displaying a revision in the diff viewer (coming in the last PR of this serie), it re-computes the complete diff to display all changes, memoized via the getPostRevisionChanges selector.

testing

Besides unit tests, this is currently used nowhere. You can see how it can be used in the WIP PR #13367, for instance in the diff viewer.

@matticbot matticbot added OSS Citizen [Size] XL Probably needs to be broken down into multiple smaller issues labels May 18, 2017
@bperson bperson changed the title Revisions #2: State Revisions update and diff-ing Revisions #2: Revisions state update and diff-ing May 19, 2017
@bperson bperson requested review from aduth and nylen and removed request for aduth May 19, 2017 13:37
@bperson bperson added [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. and removed [Status] In Progress labels May 19, 2017
@bperson bperson force-pushed the add/fetch-users branch from abb18c8 to 31536b4 Compare May 25, 2017 12:45
@bperson bperson force-pushed the update/state-revisions branch from bcda7f6 to defb645 Compare May 25, 2017 12:58
@bperson
Copy link
Contributor Author

bperson commented May 25, 2017

This has been rebased on the latest add/fetch-users, I still need to get #13828 merged first to provide requestUsers but most of it is standing up on its own.

cc @aduth @nylen or if you know anyone else that might be interested in reviewing (mostly) tests ;)

forEach( normalizedRevisions, ( revision, index ) => {
revision.changes = index === normalizedRevisions.length - 1
? { added: 0, removed: 0 }
: countDiffWords( diffWords( normalizedRevisions[ index + 1 ].content, revision.content ) );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious what's the reasoning behind computing the diff when fetching the revisions? Naively, I'd have let this for a memoized selector, for example, assuming this can be heavy and compute the diff only when needed.

Maybe not needed but what if we want to diff an old revision with the last one for example.

Copy link
Contributor Author

@bperson bperson Jun 4, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went this way precisely because it's quite expansive [0]: persisting the summary results along the revisions speeds up the first draw of the revisions list after a reload compared to the equivalent memoized-selector-based solution. That said, it's also partially un-normalizing the revision data in Redux as you get redundant data (the summary can be recomputed from the contents).

Maybe not needed but what if we want to diff an old revision with the last one for example.

The ability to diff between any 2 revisions is dropped for now (internal reference: p4TIVU-6pW-p2#comment-7039). That said, for those features outside the "main" one, it's quite easy to recompute the diff between two revisions with the utils functions introduced here.

As an interesting footnote here: we can most likely go even further and only update the Redux state if we receive deleted or new revisions if we make the assumption that revisions are immutable, AFAICS, there is no way of editing a revision from the API or in wp-admin.

[0] Myers algorithm (used by jsdiff) has a pretty interesting time complexity for revisions like object: it's in O(ND) with N the sum of the number of words in revisions A and B, and D the size of the shortest edit sequence A -> B. If we suppose that most revisions are incremental updates on a post, D should stay small :)

dispatch( receivePostRevisionsSuccess( siteId, postId ) );
dispatch( receivePostRevisions( siteId, postId, map( revisions, normalizeRevision ) ) );
dispatch( receivePostRevisions( siteId, postId, normalizedRevisions ) );
dispatch( requestUsers( siteId, uniq( map( normalizedRevisions, 'author' ) ) ) );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want to query the users as soon as we receive the revisions? My first thought is that we should probably use a query component for this when needed. Is there a particular reason for this to be fetched here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial reason for it to be in there was to bundle revisions and users by dispatching receivePostRevisions, receivePostRevisionsSuccess and receiveUsers in the same tick. But I dropped it, from a UX pov, having the author names is nice but not as important as accessing the revisions asap: it's better to have a first render of the revisions while we request more information about the various authors imho.

I've updated this PR to remove this and will probably bundle QueryUsers with its "usage" in the last PR for revisions.

@@ -48,6 +48,7 @@
"creditcards": "2.1.2",
"cross-env": "4.0.0",
"debug": "2.2.0",
"diff": "1.4.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, I just saw your note above

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is not depending on #13367 anymore, this can be merged on its own, I've updated the shrinkmap :)

bperson added 2 commits June 4, 2017 23:31
… update `date` and `modified` to contain timezone information in their values
@bperson bperson force-pushed the update/state-revisions branch from defb645 to 1cf76fe Compare June 4, 2017 21:35
@bperson bperson changed the base branch from add/fetch-users to master June 4, 2017 21:35
@bperson bperson force-pushed the update/state-revisions branch from 1cf76fe to a9cc31b Compare June 4, 2017 21:56
@bperson
Copy link
Contributor Author

bperson commented Jun 4, 2017

Updated

  • removed all usage of requestUsers in favor of using a QueryUsers component alongside QueryPostRevisions in the last PR for revisions
  • it's rebased on the latest master. It doesn't directly need anything introduced by add/fetch-users anymore, so I updated the shrinkmap, making this mergeable on its own, no issue running with the last shrinkmap locally.

Copy link
Contributor

@youknowriad youknowriad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

It'd be good to have a second opinion.

/**
* External Dependencies
*/
import JsDiff from 'diff';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we import { diffWords } from 'diff'; here instead of accessing property below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we definitely can :) fixed

@@ -24,4 +30,26 @@ function countWords( content ) {
return 0;
}

export default { countWords };
const diffWords = JsDiff.diffWords;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Destructuring could help avoid some repetition: const { diffWords } = JsDiff;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed it completely as we're directly importing (and re-exporting in the "default" object diffWords)

diffChanges,
( accumulator, change ) => {
const count = countWords( change.value );
change.added && ( accumulator.added += count );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: I found myself staring at this function for minutes convinced we'd be inaccurately incrementing both added and removed for each change, until I noticed that the first half of each line is treated effectively as a condition. This is one case where I'd advocate for the less magical if ( change.added ) { variation.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've no strong feelings about either so I updated it to use ifs

);
}

export default {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor and not related to these changes specifically: Exporting only a default of an object makes it difficult to tree shake (dead code elimination) unused functions within a file. We're not yet on Webpack 2 (#10799, #11204) so this isn't entirely relevant yet anyways, but in anticipation it's typically better to export/import functions individually.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL :)

I don't really want to touch the imports of countWords if at all possible in this PR ;) Keeping it on my todo once this PR is merged though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a new PR to make sure we don't drop this: #14794

it's based on this one so we should merge this one first.

...flow(
pick( [ 'date_gmt', 'modified_gmt' ] ),
mapValues( val => `${ val }Z` ),
mapKeys( key => key.slice( 0, -'_gmt'.length ) )
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Readability: I think it's nice that this isn't a magic number, but at the same time the - is a bit foreign at first glance. Wondering if either -1 * '_gmt'.length or -4 /* "_gmt" */ would be more readable. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we want to truly explain why the -1: -4 /* truncate "_gmt" */? I don't see any way to give the "truncate" meaning to the -1 in code?

@@ -1,21 +1,74 @@
/**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All new selectors should ideally be added to the client/state/selectors directory. selectors.js is a legacy pattern.

https://github.com/Automattic/wp-calypso/blob/master/docs/our-approach-to-data.md#selectors

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


return {
...revision,
...{ author }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified as just author.

return {
	...revision,
	author
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

...flow(
pick( [ 'title', 'content', 'excerpt' ] ),
mapValues( ( val = {} ) => val.rendered )
)( revision ),
...flow(
pick( [ 'date_gmt', 'modified_gmt' ] ),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm bewildered by how this is working, specifically the flow/currying applied to pick. In my own Node console it errors as I'd expect it to, because pick isn't supposed to return a function but rather the object subset:

n_ > _.flow( _.pick( 'foo' ) )( { foo: 'bar' } )
TypeError: Expected a function

Can you explain what I'm missing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first guess would be: _.pick is the non-fp version? which means it returns an empty object {}, and flow doesn't like non-functions as an arg?

Copy link
Contributor Author

@bperson bperson Jun 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, I'm not answering the question in fact :)

Lodash FP follows a few "defaults" ideas, one of them is to re-order arguments in a "data-last" fashion: _.pick( { foo: 'bar' }, 'foo' ) === fpPick( 'foo' )( { foo: 'bar' } )

Once that's established, what you're missing is probably that the pick you see in the code is the FP version not the "classical" one :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But pick here is imported from lodash, not lodash/fp.

And to this point, I don't think we'd want to reintroduce lodash/fp, as we'd made a point previously to try to remove it (#11354).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, it is imported from lodash/fp. I totally missed that. Hmm!

Copy link
Contributor Author

@bperson bperson Jun 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking a bit more about it, I haven't found any usage of Lodash FP in calypso (no import, no usage inside a flow AFAICS), it might not be a good idea to start importing it (even more so if people aren't familiar with it since it can cause confusions like this one ;) and the FP docs aren't amazing :( )

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well didn't refresh :) Will fix it to use the "classical" one

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I actually quite like how this looks here. It's nice to compose the operations this way. But I'd agree we might not want to set this precedent for risk of confusing developers with the presence of both variants (i.e. being consistent on using one vs. the other).

Copy link
Contributor

@aduth aduth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍

@bperson bperson merged commit 9fa4f44 into master Jun 8, 2017
@matticbot matticbot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Jun 8, 2017
@bperson bperson deleted the update/state-revisions branch June 8, 2017 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
OSS Citizen [Size] XL Probably needs to be broken down into multiple smaller issues
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants