-
Notifications
You must be signed in to change notification settings - Fork 783
refactor(redux): Switch to redux middleware & normalizr for Rest #457
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
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. |
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 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) |
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.
Or can simply args.slice(0, -1)
this.authHeaders = { Authorization: `token ${accessToken}` }; | ||
}; | ||
|
||
getNextPageUrl = response => { |
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.
Proposal: I think that if pass the Link
header instead of the entire response
?
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 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe rename to simply getEvents
, if the activity
is understood as the main screen?
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.
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
@@ -143,6 +143,22 @@ export const EntityInfo = ({ entity, orgs, language, navigation }: Props) => { | |||
onPress={() => Communications.web(getBlogLink(entity.blog))} | |||
underlayColor={colors.greyLight} | |||
/>} | |||
|
|||
{!!entity.webSite && |
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 something new? Is this exactly supposed to be 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.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why need .join('-')
?
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 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, I see, although it's not very clear.
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.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey, where the this.authHeaders
comes from? 😄
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, 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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is possible have more than one '/' in the string ?
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.
not for a repo
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 theentities
reducers 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:entities
using theuserSchema
:Link
header, which was parsed and stored by thepagination
reducer 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 (
getById
andgetMembers
) asmapDispatchToProps
in the OrganizationProfileScreen, we can define the followingmapStateToProps
function: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.nextPageUrl
that 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,
nextPageUrl
got 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.gitpoint
and observe the_fetchedAt
property. 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 aforceRefresh
option, 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
_fetchedAt
changed? 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.repos
entities, and paginating using thereposByOrg
entry in the store.If you use the search, we we'll be using
reposBySearch
instead, and indexing with the complete query string sent to GitHub. Here's an example searching forsite
Moar 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
processStrategy
of 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
_entityUrl
which points to the entity on github website. This will be usefull for #285There is also
_isComplete
which 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
_lastUsed
property.In
getById()
for example, when we check the store and find thatgitpoint
is already in the store, we could dispatch aUPDATE_USED_AT
action to update the_lastUsed
attribute.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
,pagination
anderrorMessage
are 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/github
path in this PR, for us to keep standardization in mind.Voilà!