-
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
Framework: Convert NotificationsSettings store to redux & remove Immutable from module. #28653
Conversation
9eca5b0
to
efdf1e4
Compare
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 ( 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 What do you think? |
}, | ||
}, | ||
}, | ||
}; |
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.
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 ];
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 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?
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 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 🙂
], | ||
}, | ||
}, | ||
}; |
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.
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.
efdf1e4
to
6f2db3e
Compare
} ); | ||
|
||
const toggleInDevice = ( devices, deviceId, setting ) => { | ||
const device = find( devices, ( { device_id } ) => device_id === parseInt( deviceId, 10 ) ); |
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.
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.
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 spot :)
6e59d32
to
8eaaf3c
Compare
const filteredSettings = { | ||
...omit( settings, [ 'blog_id', 'devices' ] ), | ||
email: omit( get( settings, 'email', {} ), 'achievement' ), | ||
}; |
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.
Lodash omit
can also remove nested properties:
omit( settings, [ 'blog_id', 'devices', 'email.achievement' ] );
omit( settings, [ 'blog_id', 'devices', [ 'email', 'achievement' ] ] );
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.
TIL :) Lodash always pleasantly surprises 👌
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 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? |
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.
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).
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.
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.
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 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 ) ] ) | ||
); |
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.
_.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 ); |
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.
settings = find( settings, { device_id: stream } );
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 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 ) ); |
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.
Use the find( this.props.settings, { blog_id } )
shorthand.
.updateNotificationSettings( | ||
buildSavePayload( source, settings ), | ||
applyToAll, | ||
( 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.
Use promise version here and avoid the if/else conditional.
|
||
return { | ||
blogs: [ | ||
...without( blogs, blog ), |
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.
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 ]
.
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.
Ahh good point! Instead we could slice
to clone immutably and mutate the clones value.
}; | ||
}, | ||
|
||
blog( state, source, stream, setting ) { |
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.
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 ); |
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.
The toggleState.blog
function should be renamed to toggleState.blogs
and then we can always use the toggleState[ source ]
version.
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 definitely want to get rid of the need for this intermediate toggleSetting
step and instead have the code be more explicitly 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.
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 :/
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.
Oh, now I see the code that passes blogId
as source
, too. Makes sense 👍
}, | ||
}, | ||
}, | ||
}; |
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 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 🙂
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 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 ) => { |
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.
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: |
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 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 ); |
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 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 ), |
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.
Ahh good point! Instead we could slice
to clone immutably and mutate the clones value.
…lter extra settings
8eaaf3c
to
56f5063
Compare
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 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' ), | ||
}; |
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 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, |
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 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, { |
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.
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.
1bfdf87
to
02558f8
Compare
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 for working on this big migration and addressing all review comments. Let's 🚢
Heaps of thanks for reviewing the different iterations of this and offering up some nice tips and insight along the way! 🙏💚 |
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
intoggleState
NotificationSettingsStore.getStateFor
gets called with the values'other'
,'wpcom'
and'blogs'
whereastoggleState
has the methodsother
,wpcom
andblog
. This is accounted for bytoggleSetting
, which defaults to calling theblog
method when asource
is passed in that it doesn't have a method to match. I don't think that theblog
method ever actually gets called directly, only via defaulting. I believe it likely that we can just rename theblog
method to blogs and never have the need for defaulting - this would need investigation though, to be sure.