-
Notifications
You must be signed in to change notification settings - Fork 2k
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
Plugins Browser: Redux :allthethings: #8786
Conversation
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 some great work!
Everything is working as described, and tests are passing ✅ .
I've found a bug with plugin results while testing: #8877. Good news - it was there before, so it's not caused by this PR. At first glance, it appears to be an API issue.
I've added some notes, mostly minor stuff. Good job 👍
import QueryPluginLists from 'components/data/query-plugin-lists'; | ||
|
||
const SHORT_LIST_LENGTH = 6; | ||
const FIST_PAGE = 1; |
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.
There's a small typo here - should probably be FIRST_PAGE
.
```jsx | ||
function PluginBrowser() { | ||
const categories = [ 'new', 'popular' ]; | ||
return <QueryPluginLists categories={ categories } />; |
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.
Probably too trivial, but let's also add the import
of the QueryPluginLists
component into the example code.
page: page, | ||
category: category, | ||
search: searchTerm | ||
}, function( error, data ) { |
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.
How about using a Promise here? I can see that wporg.fetchPluginsList
uses superagent
, so that appears to be a good occasion for refactoring that method to use a Promise.
_fetchingLists[ category ] = null; | ||
_totalPagesPerCategory[ category ] = data.info.pages; | ||
dispatch( { | ||
type: WPORG_PLUGIN_RECEIVE_LIST, |
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 generally prefer to dispatch 2 separate actions for success and failure - provides more granularity.
|
||
function updatePluginsList( state = defaultPluginsListState, category, page, list ) { | ||
if ( ! page || page === _DEFAULT_FIRST_PAGE ) { | ||
state.shortLists[ category ] = Object.assign( [], list.slice( 0, 6 ) ); |
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 think we shouldn't be hardcoding 6
- how about importing SHORT_LIST_LENGTH
here?
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.
right! 👍
done(); | ||
}, 2 ) ); | ||
} ); | ||
} ); |
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.
We could use some more action tests for all dispatched actions.
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.
Yeah, you're right! let's add them in another PR to not further complicate this one 👍
@@ -24,4 +24,36 @@ const isFetched = function( state, pluginSlug ) { | |||
return !! plugin.fetched; | |||
}; | |||
|
|||
export default { getPlugin, isFetching, isFetched }; | |||
const canFetchList = function( state, category, page, searchTerm ) { |
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.
It would be awesome to have tests for these selectors. We can do it in another PR too (I'd love to help with that).
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.
Yeah, you're right! let's add them in another PR to not further complicate this one 👍
@@ -1,13 +1,23 @@ | |||
/** | |||
* External dependencies |
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.
It would be awesome to have tests for the reducer. We can do it in another PR too (I'd love to help with that).
// We need to debounce this method to avoid mixing diferent dispatch batches (and get an invariant violation from react) | ||
// Since the infinite scroll mixin is launching a bunch of fetch requests at the same time, without debounce is too easy | ||
// to get two of those requests running at (almost) the same time and getting react to freak out. | ||
_lastFetchedPagePerCategory[ category ] = typeof _lastFetchedPagePerCategory[ category ] === 'undefined' |
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.
We could simplify this by using Object.hasOwnProperty()
here.
}; | ||
|
||
const getList = function( state, category ) { | ||
return state.plugins.wporg.lists.fullLists[ category ] ? state.plugins.wporg.lists.fullLists[ category ] : []; |
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.
We could use lodash's get
to simplify these.
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 really think that _.get is less legible than just using the proper object chain of properties, when possible
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 fine with leaving it as-is, as long as there's no way state.plugins.wporg.lists.fullLists[ category ]
would throw an error because a prop in the chain is not defined.
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.
@johnHackworth why do you find get()
less legible? the safety is provides us is very valuable, plus in this case it cuts the line in half and at a glance shows what happens on missing data…
const getList = ( state, category ) =>
get( state, `plugins.wporg.lists.fullLists[ ${ category } ]`, [] );
props.categories.forEach( ( category ) => { | ||
this.props.fetchPluginsList( category ); | ||
} ); | ||
} |
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 there a reason we create request()
if it's only ever called once?
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.
alternatively, we could shorten this down…
props.categories.forEach( props.fetchPluginsList );
|
||
request( props ) { | ||
props.categories.forEach( ( category ) => { | ||
this.props.fetchPluginsList( category ); |
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.
warning here - we're using this.props
on this line but using the passed-in props in the rest of the method
} | ||
|
||
QueryPluginLists.propTypes = { | ||
categories: PropTypes.array.isRequired |
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.
might as well define what the array is of…
categories: PropTypes.arrayOf( PropTypes.string ).isRequired,
PluginsActions.fetchNextCategoryPage( this.props.category ); | ||
componentWillReceiveProps( nextProps ) { | ||
if ( nextProps.search ) { | ||
if ( nextProps.search !== this.props.search ) { |
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 nesting is redundant. even if nextProps.search
is undefined
it will successfully test for equality. also if we want to make sure we don't search for undefined
then we can simply join the conditional
if ( nextProps.search && nextProps.search !== this.props.search ) {
this.fetchNextPagePlugins( nextProps.search );
}
getPluginsFullList( listName ) { | ||
return this.state.fullLists[ listName ] ? this.state.fullLists[ listName ].list : []; | ||
fetchNextPagePlugins( searchTerm ) { | ||
searchTerm = typeof searchTerm === 'string' ? searchTerm : this.props.search; |
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 like a logical error. above we were avoiding calling this function if the search term weren't available, but here we're handling that case. could this ever be called without a string parameter? should it be called without a string parameter? what does it mean in that case?
if ( this.props.canFetchList( this.props.category, lastFetchedPage + 1 ) ) { | ||
this.props.fetchPluginsList( this.props.category, lastFetchedPage + 1 ); | ||
} | ||
} |
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.
something about this is hard for me to parse. there is much nesting going on and I'm not sure the sequence of what should be happening is clear. could we flatten this down maybe?
const {
canFetchList,
category,
currentSearchTerm,
fetchPluginList,
lastPage,
} = this.props;
if ( searchTerm && currentSearchTerm !== searchTerm ) {
return fetchPluginList( 'search', FIRST_PAGE, searchTerm );
}
// note we are coercing the boolean here to zero
// out the function when less than FIRST PAGE
const nextSearchPage = ( lastPage.search + 1 ) * ( lastPage.search >= FIRST_PAGE );
if ( searchTerm && canFetchList( 'search', nextSearchPage, searchTerm ) ) {
return fetchPluginList( 'search', nextSearchPage, searchTerm );
}
const nextCategoryPage = ( lastPage[ category ] + 1 ) * ( lastPage[ category ] >= FIRST_PAGE );
if ( category && canFetchList( category, nextCategoryPage ) ) {
return fetchPluginsList( category, nextCategoryPage );
}
@@ -2,18 +2,35 @@ | |||
* External dependencies | |||
*/ | |||
const debug = require( 'debug' )( 'calypso:wporg-data:actions' ); | |||
|
|||
import debounce from 'lodash/debounce'; |
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.
we're using direct imports from lodash
and a Babel transform minimizes them appropriately
import { debounce } from 'lodash';
/** | ||
* Internal dependencies | ||
*/ | ||
import wporg from 'lib/wporg'; | ||
import utils from 'lib/plugins/utils'; | ||
import { WPORG_PLUGIN_DATA_RECEIVE, FETCH_WPORG_PLUGIN_DATA } from 'state/action-types'; | ||
import { WPORG_PLUGIN_DATA_RECEIVE, FETCH_WPORG_PLUGIN_DATA, WPORG_PLUGIN_RECEIVE_LIST, WPORG_PLUGIN_FETCH_LIST } from 'state/action-types'; | ||
import { _LIST_DEFAULT_SIZE, _DEFAULT_FIRST_PAGE } from './constants'; |
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.
Please check in the style guide: we avoid leading underscores in names. This comment is valid for this entire file.
return false; | ||
} | ||
return 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.
@johnHackworth our actions should all be pure functions which we can call at any time and get the same result. storing state here in the actions file is really going to confuse us and lead to bugs later on. we should be storing these values in the state tree and allowing the reducers to update them.
if you would like any help or review of the Redux flow and model, I would be more than happy to do a quick session with you and anyone else interested.
}, | ||
|
||
fetchPluginsList: function( category, page, searchTerm ) { | ||
return debounce( ( dispatch ) => { |
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.
let's be very careful here with debounce. let's add it when we realize we need it instead of in advance.
did you test to make sure this works? it looks like we won't actually be debouncing anything…
const f1 = fetchPluginsList( 'myCategory', 1, 'splines' ) // === debounced thunk
const f2 = fetchPluginsList( 'myCategory', 1, 'splines' ) // === debounced thunk
f1 === f2 // false - we have to separate debounced functions, thus no debouncing
|
||
if ( ! canFetchList( category, page, searchTerm ) ) { | ||
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.
should this not belong at the very top of this function?
if ( searchTerm ) { | ||
searchTerm = searchTerm.trim(); | ||
_currentSearchTerm = searchTerm; | ||
} |
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.
why not make the search term default to the empty value for its given type, ( type, emptyValue ) => ( string, '' )
, and then use the idempotent trim()
operation on it?
// assume this code isn't actually modifying data in actions.js
{ currentSearchTerm: searchTerm.trim() }
no if
is necessary
type: WPORG_PLUGIN_FETCH_LIST, | ||
page: page, | ||
category: category, | ||
searchTerm: searchTerm |
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.
with the object shorthand syntax this becomes…
{
type: WPORG_PLUGIN_FETCH_LIST,
page,
category,
searchTerm,
}
currentSearchTerm: null, | ||
fetching: {}, | ||
lastFetchedPage: {} | ||
} ); |
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 like to challenge us to never create complex initial states like this. using combineReducers()
gives us the ability to create compositions of very simple reducers, each of whose initial state is a simple value.
|
||
function updatePluginsList( state = defaultPluginsListState, category, page, list ) { | ||
if ( ! page || page === _DEFAULT_FIRST_PAGE ) { | ||
state.shortLists[ category ] = Object.assign( [], list.slice( 0, 6 ) ); |
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.
notice that by directly mutating state
here we will be making a change without changing the value
of state
, thus all pure components which only update on state changes won't update as a result of this change.
instead, we need to be splitting reducers into different units and composing them together. I'd be more than happy to give a short demo/workshop
const { type, category, page, data } = action; | ||
switch ( type ) { | ||
case WPORG_PLUGIN_RECEIVE_LIST: | ||
return Object.assign( {}, state, updatePluginsList( defaultPluginsListState, category, page, data ) ); |
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 often see this pattern, and it's one of the first things that might come to mind when we are creating Redux reducers, but it's actually perpendicular to what we want to be using Redux for. This mangles the different pieces of data together when we want nice separation of data.
Imagine that instead of having one updater which updates five pieces of data that we had five updaters each updating their own simple piece of data in response to this same type.
const isFetching = ( state = {}, { type, category } ) => get( {
[ WPORG_PLUGIN_RECEIVE_LIST ]: { ...state, [ category ]: false },
[ WPORG_PLUGIN_FETCH_LIST ]: { ...state, [ category ]: true },
}, type, state );
const lastFetchedPage = ( state = {}, { type, category, page } ) => get( {
[ WPORG_PLUGINS_RECEIVE_LIST ]: { ...state, [ category ]: page },
}, type, state );
export default combineReducers(
isFetching,
lastFetchedPage,
);
I only included a couple of the simple reducers, but hopefully you can see how we're removing the logic about those different data pieces from big reducers and splitting them into several small reducers. There's some redundancy in indicating that we're listening for specific action types, but that's the nature of the distributed and independent Redux core.
return false; | ||
} | ||
|
||
return 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.
another way to look at this is a simple list where any truth value means the whole thing fails…
// if any of the rules below are true then we can't fetch the list
const canFetchList = ( { plugins: { wporg: { lists } } }, category, page, searchTerm ) => ! [
searchTerm && lists.fetching.search ),
lists.fetching[ category ],
lists.lastFetchedPage[ category ] >= page,
].some( identity );
return state.plugins.wporg.lists.shortLists[ category ] ? state.plugins.wporg.lists.shortLists[ category ] : []; | ||
}; | ||
|
||
export default { getPlugin, isFetching, isFetched, isFetchingList, canFetchList, getList, getShortList, getCurrentSearchTerm }; |
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.
let's export each of these in the place they are defined instead of exporting the default export as an object.
export const getShortList = ( state, category ) => get( state, …, [] );
describe( 'Plugin lists', function() { | ||
it( 'Actions should have method fetchPluginData', function() { | ||
assert.isFunction( WPorgActions.fetchPluginsList ); | ||
} ); |
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.
some may argue this point and think differently than me, but I find these tests not too useful. I guess it does provide some constraints.
@johnHackworth I'm very excited to see some changes to the plugins to use Redux state instead of Flux stores and other data stores. I think there's quite a bit to do here before we could merge it, however, and some of that might just be seeing what it means to build inside of the Redux idiom. Please don't hesitate to ask any questions. |
@johnHackworth This one hasn't had any activity in about 3 months. Should we close it, review and fix things and merge it in? |
@lancewillett well... I'm not sure. This was Anyway, if after AT we start wanting to change / improve the plugins browser (which is pretty possible) this migration to redux will be needed more sooner than later... |
@johnHackworth This PR needs a rebase |
Closing as this PR is stale - please reopen if necessary |
Calypso's plugin browser was still using flux for its data. This PR migrates all the data structures used for it to our redux state tree.
A couple of disclaimers:
For example, once you request page 1, we dispatch an event to update the state tree value "lastPageFetched", so the next time we try to load the next page, we know that we should call the action for the second page... but it doesn't mind, because by then, infiniteScroll would already have called the 'fetchNextPage' method 10 times, all of the for the first page. Storing this "lastPageCalled" as a variable within the actions module allows us to increment it immediately after the action is called and not depend on the events firing in the correct order.
How to test
ping @enejb @ryelle @tyxla