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

Framework: Convert NotificationsSettings store to redux & remove Immutable from module. #28653

Merged
merged 18 commits into from
Dec 14, 2018

Conversation

spen
Copy link
Contributor

@spen spen commented Nov 18, 2018

Changes proposed in this Pull Request

This PR started as an effort to remove ImmutableJS from Calypso (#28652), replacing it's usage in the NotificationsSettings store with plain JS. Doing that took us most of the way to replacing the entire flux store with redux state - one step closer to removing Immutable (there's now only one module left) and one step closer to removing flux altogether too.

Testing instructions

Visit each of the following links (you can use the tabs in Calypso) and try toggling some settings on each (they each use the different toggleState methods).
Hit save and refresh the page to ensure that the settings have persisted.

http://calypso.localhost:3000/me/notifications
http://calypso.localhost:3000/me/notifications/comments
http://calypso.localhost:3000/me/notifications/updates

You should also notice either a success or error notice depending on the response of the save request (you can mock a failure by 'throttling' your network to mimick being offline).

Notes:

blogs/blog in toggleState

NotificationSettingsStore.getStateFor gets called with the values 'other', 'wpcom' and 'blogs' whereas toggleState has the methods other, wpcom and blog. This is accounted for by toggleSetting, which defaults to calling the blog method when a source is passed in that it doesn't have a method to match. I don't think that the blog method ever actually gets called directly, only via defaulting. I believe it likely that we can just rename the blog method to blogs and never have the need for defaulting - this would need investigation though, to be sure.

@matticbot
Copy link
Contributor

@spen spen added Framework [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. [Type] Janitorial labels Nov 18, 2018
@spen spen mentioned this pull request Nov 18, 2018
7 tasks
@spen spen force-pushed the update/refactor-notifications-toggle-state branch 2 times, most recently from 9eca5b0 to efdf1e4 Compare November 19, 2018 04:29
@jsnajdr
Copy link
Member

jsnajdr commented Nov 22, 2018

Hello @spen, thanks for making progress on removing Immutable and also converting Flux stores to Redux 🥇

After looking at this PR, I concluded I would be perfectly comfortable reviewing one big PR that does the whole migration at once.

The most tricky part (toggle-state) is already done here, and the rest are rather simple flags to tracking fetching status etc.

Then the store can be converted to Redux and the usage in UI can stop subscribing to store updates and start connecting to Redux and calling selectors (these need to be written) instead of state.settings.get( option ) etc.

What do you think?

},
},
},
};
Copy link
Member

Choose a reason for hiding this comment

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

An idea to make the tests less verbose and repetitive: create helper for creating a mock state:

const startingState = createState( [ 'settings', 'dirty', 'wpcom', settingToToggle ], true );

and check the expected value by:

expect( get( state, [ 'settings', 'dirty', 'wpcom', settingToToggle ] ) ).toBe( false );

extracting a path variable makes this particular test even shorter:

const path = [ 'settings', 'dirty', 'wpcom', settingToToggle ];

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 tend to prefer avoiding doing any abstractions in the tests if it can be helped, instead opting for each test to show as clearly as possible and with as little indirection as possible what the input and output should be. I like the idea of createState somewhat but I'd still prefer to not have the future reader have to look at what createState is doing and how it relates to the test.

In terms of check with expect( get( state, ... ) ).toBe( false ); I'd also err away from such a direct check. Reason being that our unit logic might change by mistake to add other things to the object. The settingToToggle might be successfully toggled but other side effects could sneak in unchecked.

That said, I've changed the responsibility of toggle-state.js a little and so now it returns the minimum object shape needed (i.e. no extra settings.dirty.x), so that's an improvement at least :)

Hope that makes sense and is reasonable?

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 that tests should be written the same way as any other program. Avoid repetitive code, extract it to functions. Test the program behavior (did it toggle the setting?) rather than internal implementation (exact shape of the state object).

A good criterion for a well written test would be: if I make one-line change in the implementation, is the test update also an one-line change? Or do I need to update 50 repetitive tests?

I'm just offering a personal opinion here, no need to update the PR if you don't agree enthusiastically 🙂

],
},
},
};
Copy link
Member

Choose a reason for hiding this comment

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

Use combineReducers and keyedReducer to avoid this horrible object spread chain:

combineReducers ( {
  settings: combineReducers( {
    dirty: combineReducers( {
      blogs
    } )
  } )
} )

Now, it's the blogs reducer that contains the code that really matters. And it's the first reducer in the chain that needs to run the isNaN( stream ) condition.

Don't write repetitive code -- reorganize it instead and create helpers.

@spen spen force-pushed the update/refactor-notifications-toggle-state branch from efdf1e4 to 6f2db3e Compare November 23, 2018 16:18
} );

const toggleInDevice = ( devices, deviceId, setting ) => {
const device = find( devices, ( { device_id } ) => device_id === parseInt( deviceId, 10 ) );
Copy link
Member

@jsnajdr jsnajdr Nov 24, 2018

Choose a reason for hiding this comment

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

Nit:

const deviceIdNum = parseInt( deviceId, 10 );
const device = find( devices, { device_id: deviceIdNum } );

parses the string into a number only once and uses lodash.find shorthand for matching a property value.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good spot :)

@spen spen added [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. and removed [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. labels Nov 26, 2018
@spen spen changed the title Refactor notifications toggleState Framework: Convert NotificationsSettings store to redux & remove Immutable from module. Nov 26, 2018
@spen spen force-pushed the update/refactor-notifications-toggle-state branch from 6e59d32 to 8eaaf3c Compare November 26, 2018 18:05
@spen spen added [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. and removed [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. labels Nov 26, 2018
const filteredSettings = {
...omit( settings, [ 'blog_id', 'devices' ] ),
email: omit( get( settings, 'email', {} ), 'achievement' ),
};
Copy link
Member

Choose a reason for hiding this comment

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

Lodash omit can also remove nested properties:

omit( settings, [ 'blog_id', 'devices', 'email.achievement' ] );
omit( settings, [ 'blog_id', 'devices', [ 'email', 'achievement' ] ] );

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 :) Lodash always pleasantly surprises 👌

Copy link
Member

Choose a reason for hiding this comment

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

This could still be one omit call with array of nested properties. Or did you choose to keep the code as it is?

// - timeline.store_order
// - email.store_order
// - email.scheduled_publicize
// Should we filter these also?
Copy link
Member

Choose a reason for hiding this comment

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

Notifications exceptions are defined here. Should we use this constant also to filter notification types in the getLegend function?

timeline.store_order is a valid notification, but only present on sites that have a Woo store. See #19196 -- a small PR where the store_order notification is added.

I don't know why some notification emails are disabled, namely Achievements, Publicize events and Store Orders. Pinging people who worked on the more recent additions: @artpi (publicize) and @justinshreve (store).

Copy link
Contributor

Choose a reason for hiding this comment

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

Store order emails are disabled because WooCommerce currently handles and sends out its own suite of email notifications. These are not tied in with WordPress.com's email/notification system, and we didn't want to implement double notifications.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the info @justinshreve - for now I'll update the logic to omit those items. The only consequence in this section is the copy shown in the section header. I'll leave a ref to this comment thread too.


const [ onSettings, offSettings ] = partition(
flatten( [ ...map( filteredSettings, values ), ...map( devicesSettings, values ) ] )
);
Copy link
Member

@jsnajdr jsnajdr Nov 27, 2018

Choose a reason for hiding this comment

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

_.countBy could be useful: the result is [ onSettingsCount, offSettingsCount ] instead of two arrays. Better for our purpose here.

By the way, this toggle-counting code would be more readable if it had some comments that explain the shape of the arrays and objects being processed and what is being done to them.

I think my Lodash-fu is quite good, but I still had serious trouble figuring out what all these Lodash omit -> map -> values -> flatten -> partition transforms do. The most difficult question is: "What shape does this particular object have? Is it array? Array of what? Object? What are the keys and values?"

@@ -37,7 +33,7 @@ class NotificationSettingsFormStream extends PureComponent {

if ( this.props.devices && size( this.props.devices ) > 0 ) {
stream = parseInt( this.state.selectedDeviceId || first( this.props.devices ).id, 10 );
settings = this.props.settings.find( device => device.get( 'device_id' ) === stream );
settings = this.props.settings.find( ( { device_id } ) => device_id === stream );
Copy link
Member

Choose a reason for hiding this comment

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

settings = find( settings, { device_id: stream } );

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 was very close to pulling in find here! Makes sense to have it follow what we see in similar files

this.state.settings.find( blog => blog.get( 'blog_id' ) === parseInt( blogId, 10 ) );
const onSave = blogId => saveSettings( 'blogs', findSettingsForBlog( blogId ) );
const onSaveToAll = blogId => saveSettings( 'blogs', findSettingsForBlog( blogId ), true );
this.props.settings.find( ( { blog_id } ) => blog_id === parseInt( blogId, 10 ) );
Copy link
Member

Choose a reason for hiding this comment

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

Use the find( this.props.settings, { blog_id } ) shorthand.

.updateNotificationSettings(
buildSavePayload( source, settings ),
applyToAll,
( 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.

Use promise version here and avoid the if/else conditional.


return {
blogs: [
...without( blogs, blog ),
Copy link
Member

Choose a reason for hiding this comment

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

Toggling a setting in an array by removing the old one and appending the updated one to the end has one disadvantage: when I toggle a setting on and the off again, I expect _.isEqual( oldArray, newArray ) to be true. It works for objects, as the order of properties doesn't matter, but doesn't work for arrays. [ 1, 2 ] is not equal to [ 2, 1 ].

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ahh good point! Instead we could slice to clone immutably and mutate the clones value.

};
},

blog( state, source, stream, setting ) {
Copy link
Member

Choose a reason for hiding this comment

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

Can you rename this to blogs? The state stores the blogs settings as blogs, the getNotificationSettings selector is always called with blogs parameter... I don't know how this inconsistency was introduced, but I believe it's time to fix it.

return toggleState[ source ].apply( this, arguments );
}

return toggleState.blog.apply( this, arguments );
Copy link
Member

Choose a reason for hiding this comment

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

The toggleState.blog function should be renamed to toggleState.blogs and then we can always use the toggleState[ source ] version.

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 definitely want to get rid of the need for this intermediate toggleSetting step and instead have the code be more explicitly defined.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It turns out that this is somewhat necessary, or at least convenient... Sometimes the source is a number (blog id). I'm going to leave this as is for now just because I'm not sure how far reaching any changes would be :/

Copy link
Member

Choose a reason for hiding this comment

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

Oh, now I see the code that passes blogId as source, too. Makes sense 👍

},
},
},
};
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 that tests should be written the same way as any other program. Avoid repetitive code, extract it to functions. Test the program behavior (did it toggle the setting?) rather than internal implementation (exact shape of the state object).

A good criterion for a well written test would be: if I make one-line change in the implementation, is the test update also an one-line change? Or do I need to update 50 repetitive tests?

I'm just offering a personal opinion here, no need to update the PR if you don't agree enthusiastically 🙂

Copy link
Contributor Author

@spen spen left a comment

Choose a reason for hiding this comment

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

Thanks for another round of reviewing @jsnajdr - much appreciated!
I'll get to fixing up those items by the end of the week :)

wpcom
.undocumented()
.me()
.getNotificationSettings( ( error, data ) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed! I figured I'd just get it copied through but it is technically a new file - I'll update soon :)

return {
other: settings,
};
default:
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 thought so too and I've made some note of this. I haven't purposely tested that change yet though but I'll update it once I have :)

return toggleState[ source ].apply( this, arguments );
}

return toggleState.blog.apply( this, arguments );
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 definitely want to get rid of the need for this intermediate toggleSetting step and instead have the code be more explicitly defined.


return {
blogs: [
...without( blogs, blog ),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ahh good point! Instead we could slice to clone immutably and mutate the clones value.

@spen spen force-pushed the update/refactor-notifications-toggle-state branch from 8eaaf3c to 56f5063 Compare December 11, 2018 17:04
Copy link
Member

@jsnajdr jsnajdr left a comment

Choose a reason for hiding this comment

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

Let's fix one prop type warning and have a closer look at replaceAtIndex when an index can't be found. Other than that, I think we're almost ready to 🚢

const filteredSettings = {
...omit( settings, [ 'blog_id', 'devices' ] ),
email: omit( get( settings, 'email', {} ), 'achievement' ),
};
Copy link
Member

Choose a reason for hiding this comment

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

This could still be one omit call with array of nested properties. Or did you choose to keep the code as it is?

PropTypes.instanceOf( Immutable.List ),
PropTypes.instanceOf( Immutable.Map ),
] ).isRequired,
settings: PropTypes.object.isRequired,
Copy link
Member

Choose a reason for hiding this comment

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

This prop type should be "array or object". When rendering settings for mobile devices, the settings prop is an array of object (one for each device). And it's just one object otherwise.

See also how the getStreamSettings method (few lines below) behaves differently when props.devices is present.

I found this bug when testing and seeing a prop type warning from React in console.

const deviceSetting = get( device, setting );

return {
devices: replaceAtIndex( devices, deviceIndex, {
Copy link
Member

Choose a reason for hiding this comment

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

What if deviceIndex is -1, i.e., the device is not yet in the array? The old code supported the "replace or add" behavior, didn't it?

It seems that it doesn't matter, as the REST API response (and Redux state) has fields for all possible settings for all your blogs and devices. Therefore a field always exists when we toggle it. But I'm not sure and the previous version of the code was more forgiving.

@spen spen force-pushed the update/refactor-notifications-toggle-state branch from 1bfdf87 to 02558f8 Compare December 13, 2018 17:37
Copy link
Member

@jsnajdr jsnajdr left a comment

Choose a reason for hiding this comment

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

Thanks for working on this big migration and addressing all review comments. Let's 🚢

@spen spen merged commit b8a3d4d into master Dec 14, 2018
@spen spen deleted the update/refactor-notifications-toggle-state branch December 14, 2018 12:54
@spen
Copy link
Contributor Author

spen commented Dec 14, 2018

Heaps of thanks for reviewing the different iterations of this and offering up some nice tips and insight along the way! 🙏💚

@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 Dec 14, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants