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

Plugins Browser: Redux :allthethings: #8786

Closed
wants to merge 10 commits into from
Closed

Conversation

johnHackworth
Copy link
Contributor

@johnHackworth johnHackworth commented Oct 17, 2016

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:

  • Sites are still being read from the old sites-list. The browser uses some selectors that are still not migrated. This will be updated once we migrate everything from sites-list.
  • there's some logic in the action that fetches the plugin lists from the API. It's the logic related with controlling when a page can be requested and when we should skip it (for example, because there's already a request for this page going on). I tried to add this logic to the state tree instead, but since we are using the infiniteScroll mixin for this, the way infiniteScroll triggers update events conflicts with the order where the data is updated in the state tree.
    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

  1. Go to http://calypso.localhost:3000/plugins/browse. You should see something like this
    image
  2. Browse the different categories. Make sure that next pages are loaded and new plugins are appended when you scroll down
  3. Search for any word. Make sure that the results you get make sense and you can scroll down and load further pages.
  4. Everything should work the same than it currently does in https://wordpress.com/plugins/browse

ping @enejb @ryelle @tyxla

@matticbot
Copy link
Contributor

@matticbot matticbot 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 Oct 17, 2016
@lancewillett lancewillett added [Status] In Progress and removed [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. labels Oct 17, 2016
@johnHackworth johnHackworth changed the title Add/plugins reduxify Plugins Browser: Redux :allthethings: Oct 19, 2016
@johnHackworth johnHackworth 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 Oct 19, 2016
Copy link
Member

@tyxla tyxla left a 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;
Copy link
Member

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 } />;
Copy link
Member

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 ) {
Copy link
Member

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,
Copy link
Member

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 ) );
Copy link
Member

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

right! 👍

done();
}, 2 ) );
} );
} );
Copy link
Member

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.

Copy link
Contributor Author

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 ) {
Copy link
Member

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).

Copy link
Contributor Author

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
Copy link
Member

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'
Copy link
Member

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 ] : [];
Copy link
Member

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.

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 really think that _.get is less legible than just using the proper object chain of properties, when possible

Copy link
Member

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.

Copy link
Member

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 );
} );
}
Copy link
Member

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?

Copy link
Member

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 );
Copy link
Member

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
Copy link
Member

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 ) {
Copy link
Member

@dmsnell dmsnell Nov 16, 2016

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;
Copy link
Member

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 );
}
}
Copy link
Member

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';
Copy link
Member

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';
Copy link
Member

@dmsnell dmsnell Nov 16, 2016

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;
};
Copy link
Member

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 ) => {
Copy link
Member

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;
}
Copy link
Member

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;
}
Copy link
Member

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
Copy link
Member

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: {}
} );
Copy link
Member

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 ) );
Copy link
Member

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 ) );
Copy link
Member

@dmsnell dmsnell Nov 16, 2016

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;
Copy link
Member

@dmsnell dmsnell Nov 16, 2016

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 };
Copy link
Member

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 );
} );
Copy link
Member

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.

@dmsnell
Copy link
Member

dmsnell commented Nov 16, 2016

@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.

@lancewillett
Copy link
Contributor

@johnHackworth This one hasn't had any activity in about 3 months. Should we close it, review and fix things and merge it in?

@johnHackworth
Copy link
Contributor Author

johnHackworth commented Feb 6, 2017

@lancewillett well... I'm not sure. This was almost ready to go pretty advanced, but with quite some work left to be done, and also is very out of date now. I'll pass the ball to @enejb and @lezama , who are in command of this part of the codebase, to decide what to do with this :)

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...

@matticbot
Copy link
Contributor

@johnHackworth This PR needs a rebase

@matticbot matticbot added the [Size] XL Probably needs to be broken down into multiple smaller issues label Mar 30, 2017
@alisterscott
Copy link
Contributor

Closing as this PR is stale - please reopen if necessary

@alisterscott alisterscott deleted the add/plugins-reduxify branch October 24, 2017 04:49
@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 Oct 24, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Jetpack [Size] XL Probably needs to be broken down into multiple smaller issues [Status] Needs Rebase
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants