refactor(redux): Switch to redux middleware & normalizr for Rest#457
refactor(redux): Switch to redux middleware & normalizr for Rest#457machour wants to merge 42 commits intogitpoint:masterfrom machour:multi-client-redux-middleware
Conversation
|
Just wow, will take a look this week! Thanks for the huge effort! |
|
@machour I will try to see this PR in detail as soon as possible, but with a cursory examination it is clear that a very cool work! 👏 |
|
Thanks so much for this @machour, this is going to take some time to digest so I'll have some time blocked hopefully in a few days to go through this in detail. 🎉 |
|
Since last comment, I did the following: Clean up:
Refactor:
UI ❤️ :
|
|
@ all Guys, I'm stepping aside on this PR, I think I don't have enough context right now to make a good decision about what should/should not be done and this is becoming larger (100+ comments + 1.8k lines is already quite difficult to catch up) |
|
@alejandronanez I totally understand your concern about the size of the PR.. I'm supposed to have a one on one review with @housseindjirdeh tomorrow about this proposal. |
lex111
left a comment
There was a problem hiding this comment.
An interesting implementation, I've never used Proxy, but this is a good case of using it, and it's not that difficult actually.
| Alert.alert('API Error', error); | ||
| }; | ||
|
|
||
| export const withReducers = Provider => { |
There was a problem hiding this comment.
Can rename to createDispatchProxy?
| return new Proxy(withReducers, { | ||
| get: (c, namespace) => { | ||
| return new Proxy(client[namespace], { | ||
| get: (endpoint, call) => (...args) => (dispatch, getState) => { |
There was a problem hiding this comment.
Maybe rename to call -> method?
Or somehow in a different way, because it creates a feeling that this is a function (callback).
Although I like the name action, but it is already in use, although can Actions[actionName] be just the type of action?
const actionType = Actions[actionName]; // and if so?There was a problem hiding this comment.
You're right, that should be "method"
Could you check the last version I just pushed for namings ?
|
|
||
| // Identify if we have our magical last argument | ||
| const declaredArgsNumber = endpoint[call].length; | ||
| const isMagicArgAvailable = args.length === declaredArgsNumber; |
There was a problem hiding this comment.
Magic is not a good name... what if rename to isAdditionalArgsExist or isExtraArgsExists?
I think, can use all the same plural (for the future)?
There was a problem hiding this comment.
I really didn't know how to name this extra last argument which should be implemented in all methods definition..
This is an argument that is always at the end, and is used for both internal & external stuff. AdditionalArg doesn't seem to describe it enough
There was a problem hiding this comment.
I think it would be easier if we considered that there could be more than one magic parameters, the more it is quite possible, given how it is currently used:
const { loadMore = false } = magicArg; // then maybe `param2`, `param3`... and these are additional parameters| const pureArgs = isMagicArgAvailable | ||
| ? args.slice(0, args.length - 1) | ||
| : args; | ||
| const magicArg = isMagicArgAvailable ? args[args.length - 1] : {}; |
There was a problem hiding this comment.
Rename to magicArg -> additionalArg or extraArg.
And can use the plural? magicArg -> additionalArgs or extraArgs.
You can do this: args[args.length - 1] -> args.slice(-1)
| const isMagicArgAvailable = args.length === declaredArgsNumber; | ||
|
|
||
| const pureArgs = isMagicArgAvailable | ||
| ? args.slice(0, args.length - 1) |
| this.authHeaders = { Authorization: `token ${accessToken}` }; | ||
| }; | ||
|
|
||
| getNextPageUrl = response => { |
There was a problem hiding this comment.
Proposal: I think that if pass the Link header instead of the entire response?
There was a problem hiding this comment.
This is correct for Github, but for Gitlab for example, next link information is contained in the JSON response. This is why I decided to pass Response
| * Gets an organization by its id | ||
| * | ||
| * @param {string} orgId | ||
| */ |
There was a problem hiding this comment.
There is no description for params... but I think, but does JSDoc really need it? Usually we do not use it, can remove it, especially when soon will be use Flow?
There was a problem hiding this comment.
Description for params will be the same for all methods. I wanted to avoid duplicating doc comments before finding a good name.
The JSDoc is in Github for now, but I'll move it to the Rest class for later. (We're going to add all the blanks prototypes there along with the JSDocs to be able to generate an API documentation like this: https://octokit.github.io/node-github/#api-activity-markNotificationThreadAsRead)
Does that sound ok to you?
| * | ||
| * @param {string} userId | ||
| */ | ||
| getEventsReceived: async (userId, params) => { |
There was a problem hiding this comment.
Maybe rename to simply getEvents, if the activity is understood as the main screen?
There was a problem hiding this comment.
activity is a reflection of GitHub's endpoint, which contains events, notifications, watching, etc : https://developer.github.com/v3/activity/
I'm sticking with node-github naming mostly for now, as it almost reflects the URL called
| underlayColor={colors.greyLight} | ||
| />} | ||
|
|
||
| {!!entity.webSite && |
There was a problem hiding this comment.
This is something new? Is this exactly supposed to be here?
There was a problem hiding this comment.
The block above should be removed once the new API is implemented on Users.
For now I duplicated the block instead of putting 3 entity.web_site ? entity.web_site : entity.webSite in the existing block
There was a problem hiding this comment.
@lex111 left you some more comments on gitter to avoid writing another page here :)
| if (paginator) { | ||
| const { loadMore = false } = magicArg; | ||
| const { pageCount = 0, isFetching = false, nextPageUrl } = | ||
| paginator[pureArgs.join('-')] || {}; |
There was a problem hiding this comment.
Oh, I see, although it's not very clear.
jouderianjr
left a comment
There was a problem hiding this comment.
Just some comments :D I'm reviewing the code, but overall this is pretty great man 💃 Congratz
| finalUrl = url; | ||
| // add explicitely specified parameters | ||
| if (params.per_page) { | ||
| finalUrl = `${finalUrl}${finalUrl.indexOf('?') !== -1 |
There was a problem hiding this comment.
I think we can use here the method includes = finalUrl.includes('?')
| } | ||
| } | ||
|
|
||
| if (finalUrl.indexOf(this.API_ROOT) === -1) { |
There was a problem hiding this comment.
The same, in order do improve the readability, we can use includes
| method, | ||
| headers: { | ||
| 'Cache-Control': 'no-cache', | ||
| ...this.authHeaders, |
There was a problem hiding this comment.
Hey, where the this.authHeaders comes from? 😄
There was a problem hiding this comment.
Oh, I just noted that the child class(github/client.js) have this attribute. Maybe should be more clear include a default implementation here? maybe authHeaders = {}
| schema: Schemas.USER_ARRAY, | ||
| }, | ||
| params | ||
| ).then(struct => { |
There was a problem hiding this comment.
This a code style minor thing but you can remove the return key using:
).then(struct => ({
...struct,
nextPageUrl: this.getNextPageUrl(struct.response),
}));
| }, | ||
| { | ||
| idAttribute: event => event.id, | ||
| processStrategy: entity => { |
There was a problem hiding this comment.
I think that can be refactor in order to avoid processed. repetition
proccessStrategy: entity => ({
...initSchema(),
id: entity.id,
....
There was a problem hiding this comment.
Thanks a lot for your review man! Just went through your remarks and applied the suggestions. I didn't know about String.includes, nice one :)
I just kept EventSchema like that for the moment, cause it still needs some work that may require that processed const. All of this will be polished at the end of the PR.
| if (isInMinimalisticForm(entity)) { | ||
| processed.id = entity.name.toLowerCase(); | ||
| processed.fullName = entity.name; | ||
| processed.shortName = entity.name.substring( |
There was a problem hiding this comment.
It is possible have more than one '/' in the string ?
|
Dears, After validating this PR principle with @housseindjirdeh & @lex111, and after hearing from @jouderianjr & @alejandronanez I came up with a smoother plan to integrate this PR:
Once this new PR is digested by everyone & merged in, we'll set a plan to migrate the rest of the App. Thank you all for your constructive feedback! <3 |


Hi,
This is a first draft for a big refactoring of our current store implementation.
It relies on a redux middleware & normalizr to organise the store.
I started implementing it in the Organizations section, and I think it looks solid
enough to showcase this refactoring and start a discussion.
Start Reactotron & subscribe to the following reducers :
Initial state:

Follow the described navigation, and start witnessing your subscriptions changes:
The organization profile screen:
Go to the "GitPoint" organization profile screen.
Two api calls were made:
providers/github/repos.js@getById()&&providers/github/repos.js@getMembers().The first API call checked for the org in

entities, did not find it, so dispatched an action interpreted by the result middleware.After the
fetch()call inperformApiRequest(), the returned JSON was normalized using theorgSchema, and stored by theentitiesreducers like so :The second API call, checked for the existence of "gitpoint" in
pagination.membersByOrg, did not find it, so dispatched an action. The returned response from Github contained two things:entitiesusing theuserSchema:Linkheader, which was parsed and stored by thepaginationreducer inpagination.membersByOrg.gitpoint.Because we specified that we expected an array of
userSchema, normalizr was able to store only the usernames of the members in the pagination, instead of the full user information. (this is reason number 1 that normalizr() is awesome)(I specifically asked for 8 users per page of result to demonstrate the pagination)
By passing the two functions (
getByIdandgetMembers) asmapDispatchToPropsin the OrganizationProfileScreen, we can define the followingmapStateToPropsfunction:Scrolling the members list
Remember how I said I limited the pagination to 8 members per page? Let's scroll to the right in the avatars list. By listening on
onEndReached, we're able to re-trigger a call togetMembers()with an additional parameter this time:getMembers(orgId, { loadMore: true })This second parameter makes the next call to Github use the URL stored in
pagination.memberByOrg.gitpoint.nextPageUrlthat appeared in last screenshot, and that asks for the next page of results. New results will be reduced using the same process as earlier, and our usernames array will grow with the new references to users.As this was the last page of results,
nextPageUrlgot set to null, and no more apis calls will be made.In and out of the organization screen
Go back in navigation, then forward to the organization screen again.
Both
getById()andgetMembers()were called again, but as everything was already in the store, the calls to Github api were blocked. (seegetById()code)Refreshing the organization screen
Unfold
entities.orgs.gitpointand observe the_fetchedAtproperty. It's a timestamp of the moment we normalized the entity in the schema.Pull down the screen to refresh.
getById(orgId)&getMembers(orgId)will be called again, this time with aforceRefreshoption, that will perform an API call to Github, regardless of what we have or not in our store.Once the response is fetched, the usual reducing/normalization takes places, and the entity is swapped in the store. See how
_fetchedAtchanged? Pull the screen again. Such amaze. Much wow.(:warning: I still haven't fully implemented forceRefreshing on paginated list like getMembers(), so its pagination in the store will look off.)
Browsing the organization repositories
Click the number of repos on the organization screen.
This time, we'll be fetching
entities.reposentities, and paginating using thereposByOrgentry in the store.If you use the search, we we'll be using
reposBySearchinstead, and indexing with the complete query string sent to GitHub. Here's an example searching forsiteMoar data
Well, ok, 2 repositories & 12 members are not enough to really enjoy this baby.
Let's navigate to the "search" tab, and search for "ionic", select the first repo in results, and then click on the owner.
You should now be on the Organization page for the "Ionic" team.
Wait. Navigate back to the tab you came from.
Notice how data is still related to gitpoint, instead of being swapped to Ionic?
You can also try and go to 4 different organizations on the 4 tabs, scroll down their repositories, and switch beetween tabs.
One of our major flaws just got fixed ! 🕺
Moar amaze
Schemas are wonderful
By extensively using the
processStrategyof normalizr schemas, we are able to craft our stored entities with care to only keep attributes that are used by the app, and minimize the store size (we don't store the dozen of urls that comes with every response from github for example)We can standardize some useful internal attributes:
You already know about
_fetchedAt(that we will be able to use later to leverage conditional request.There is also
_entityUrlwhich points to the entity on github website. This will be usefull for #285There is also
_isCompletewhich tells us if the entity was completely or partially received. (For example, the members returned by getMember() are incomplete users, in comparison to what you get on the/user/:username). (Usage still WIP)Isn't that too much for the store ?
If we're worried about the store growing too much in size, we can add a
_lastUsedproperty.In
getById()for example, when we check the store and find thatgitpointis already in the store, we could dispatch aUPDATE_USED_ATaction to update the_lastUsedattribute.Then, we could implement an other action, triggered on low memory warning, (or when we have more than N entities) to go through entities and remove the old entries.
Not persisted
entities,paginationanderrorMessageare blacklisted from the persistent store. Fresh data every time we open the app.Gitlab? Bitbucket?
With this new redux middleware, and the use of schemas, we could add support for Gitlab & Bitbucket by keeping things standardized.
By implementing provider-specific schemas, and adopting an internal convention for those, we will be able to produce entities with the exact same structure for all 3 providers.
Which means that all screens/components using these entities, can display the data regardless of where they came from.
Of course, for now, our first objective is to bring the Github provider up and 100% making use of this new redux store.
I intendedly added an extra
providers/githubpath in this PR, for us to keep standardization in mind.Voilà!