From d72dd4009959745faba758c8d943d9561ceab81a Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 11:47:17 +0100 Subject: [PATCH 01/12] WIP --- package.json | 4 + root.reducer.js | 4 + root.store.js | 3 +- src/actions/index.js | 97 +++++ src/api/api.client.js | 466 +++++++++++++++++++++ src/api/api.middleware.js | 78 ++++ src/api/index.js | 417 +----------------- src/auth/auth.action.js | 3 +- src/auth/screens/events.screen.js | 78 +++- src/auth/screens/login.screen.js | 2 +- src/components/user-list-item.component.js | 2 + src/config/reactotron.js | 7 +- src/event/event.action.js | 28 ++ src/event/event.schema.js | 10 + src/event/event.type.js | 3 + src/reducers/index.js | 63 +++ src/reducers/paginate.js | 72 ++++ src/repository/repository.action.js | 34 ++ src/repository/repository.schema.js | 39 ++ src/repository/repository.type.js | 4 + src/user/index.js | 1 + src/user/screens/follower-list.screen.js | 53 ++- src/user/screens/profile.screen.js | 106 ++++- src/user/user.action.js | 44 +- src/user/user.schema.js | 24 ++ src/user/user.type.js | 5 + 26 files changed, 1169 insertions(+), 478 deletions(-) create mode 100644 src/actions/index.js create mode 100644 src/api/api.client.js create mode 100644 src/api/api.middleware.js create mode 100644 src/event/event.action.js create mode 100644 src/event/event.schema.js create mode 100644 src/event/event.type.js create mode 100644 src/reducers/index.js create mode 100644 src/reducers/paginate.js create mode 100644 src/repository/repository.schema.js create mode 100644 src/user/user.schema.js diff --git a/package.json b/package.json index 06c8a1bbe..6d622d846 100644 --- a/package.json +++ b/package.json @@ -44,12 +44,16 @@ }, "dependencies": { "fuzzy-search": "^1.4.0", + "humps": "^2.0.1", + "lodash": "^4.17.4", "lodash.uniqby": "^4.7.0", "lowlight": "^1.5.0", "marked": "^0.3.6", "md5": "^2.2.1", "moment": "^2.17.1", "node-emoji": "^1.7.0", + "normalizr": "^3.2.3", + "omit": "^1.0.1", "parse-diff": "^0.4.0", "query-string": "^4.3.1", "react": "16.0.0-alpha.12", diff --git a/root.reducer.js b/root.reducer.js index 07ca2eabf..25cc333d6 100644 --- a/root.reducer.js +++ b/root.reducer.js @@ -6,8 +6,12 @@ import { organizationReducer } from 'organization'; import { issueReducer } from 'issue'; import { searchReducer } from 'search'; import { notificationsReducer } from 'notifications'; +import { entities, errorMessage, pagination } from 'reducers'; export const rootReducer = combineReducers({ + entities, + errorMessage, + pagination, auth: authReducer, user: userReducer, repository: repositoryReducer, diff --git a/root.store.js b/root.store.js index 51dee6ae2..34424dc66 100644 --- a/root.store.js +++ b/root.store.js @@ -4,10 +4,11 @@ import Reactotron from 'reactotron-react-native'; // eslint-disable-line import/ import createLogger from 'redux-logger'; import reduxThunk from 'redux-thunk'; import 'config/reactotron'; +import apiMiddleware from 'api/api.middleware'; import { rootReducer } from './root.reducer'; const getMiddleware = () => { - const middlewares = [reduxThunk]; + const middlewares = [reduxThunk, apiMiddleware]; if (__DEV__) { if (process.env.LOGGER_ENABLED) { diff --git a/src/actions/index.js b/src/actions/index.js new file mode 100644 index 000000000..3fc5efdc2 --- /dev/null +++ b/src/actions/index.js @@ -0,0 +1,97 @@ +import { CALL_API, Schemas } from '../api/api.middleware'; + +export const STARRED_REQUEST = 'STARRED_REQUEST'; +export const STARRED_SUCCESS = 'STARRED_SUCCESS'; +export const STARRED_FAILURE = 'STARRED_FAILURE'; + +// Fetches a page of starred repos by a particular user. +// Relies on the custom API middleware defined in ../middleware/api.js. +const fetchStarred = (login, nextPageUrl) => ({ + login, + [CALL_API]: { + types: [STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE], + endpoint: nextPageUrl, + schema: Schemas.REPO_ARRAY, + }, +}); + +// Fetches a page of starred repos by a particular user. +// Bails out if page is cached and user didn't specifically request next page. +// Relies on Redux Thunk middleware. +export const loadStarred = (login, nextPage) => (dispatch, getState) => { + const { nextPageUrl = `users/${login}/starred`, pageCount = 0 } = + getState().pagination.starredByUser[login] || {}; + + if (pageCount > 0 && !nextPage) { + return null; + } + + return dispatch(fetchStarred(login, nextPageUrl)); +}; + +export const FOLLOWERS_REQUEST = 'FOLLOWERS_REQUEST'; +export const FOLLOWERS_SUCCESS = 'FOLLOWERS_SUCCESS'; +export const FOLLOWERS_FAILURE = 'FOLLOWERS_FAILURE'; + +// Fetches a page of starred repos by a particular user. +// Relies on the custom API middleware defined in ../middleware/api.js. +const fetchFollowers = (login, nextPageUrl) => ({ + login, + [CALL_API]: { + types: [FOLLOWERS_REQUEST, FOLLOWERS_SUCCESS, FOLLOWERS_FAILURE], + endpoint: nextPageUrl, + schema: Schemas.USER_ARRAY, + }, +}); + +// Fetches a page of followers repos by a particular user. +// Bails out if page is cached and user didn't specifically request next page. +// Relies on Redux Thunk middleware. +export const loadFollowers = (login, nextPage) => (dispatch, getState) => { + console.log('loadFollowers(', login); + const { nextPageUrl = `users/${login}/followers`, pageCount = 0 } = + getState().pagination.followersByUser[login] || {}; + console.log('loadFollowers(', login, nextPage, nextPageUrl, pageCount); + + if ((pageCount > 0 && !nextPage) || !nextPageUrl) { + return null; + } + + return dispatch(fetchFollowers(login, nextPageUrl)); +}; + +export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST'; +export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS'; +export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE'; + +// Fetches a page of stargazers for a particular repo. +// Relies on the custom API middleware defined in ../middleware/api.js. +const fetchStargazers = (fullName, nextPageUrl) => ({ + fullName, + [CALL_API]: { + types: [STARGAZERS_REQUEST, STARGAZERS_SUCCESS, STARGAZERS_FAILURE], + endpoint: nextPageUrl, + schema: Schemas.USER_ARRAY, + }, +}); + +// Fetches a page of stargazers for a particular repo. +// Bails out if page is cached and user didn't specifically request next page. +// Relies on Redux Thunk middleware. +export const loadStargazers = (fullName, nextPage) => (dispatch, getState) => { + const { nextPageUrl = `repos/${fullName}/stargazers`, pageCount = 0 } = + getState().pagination.stargazersByRepo[fullName] || {}; + + if (pageCount > 0 && !nextPage) { + return null; + } + + return dispatch(fetchStargazers(fullName, nextPageUrl)); +}; + +export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE'; + +// Resets the currently visible error message. +export const resetErrorMessage = () => ({ + type: RESET_ERROR_MESSAGE, +}); diff --git a/src/api/api.client.js b/src/api/api.client.js new file mode 100644 index 000000000..7d5787dc8 --- /dev/null +++ b/src/api/api.client.js @@ -0,0 +1,466 @@ +import { normalize, schema } from 'normalizr'; +import { abbreviateNumber } from 'utils'; + +// These keys are for development purposes and do not represent the actual application keys. +// Feel free to use them or use a new set of keys by creating an OAuth application of your own. +// https://github.com/settings/applications/new +export const CLIENT_ID = '87c7f05700c052937cfb'; +export const CLIENT_SECRET = '3a70aee4d5e26c457720a31c3efe2f9062a4997a'; + +export const root = 'https://api.github.com'; +export const USER_ENDPOINT = user => `${root}/users/${user}`; + +const accessTokenParameters = accessToken => ({ + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + 'Cache-Control': 'no-cache', + }, +}); + +const accessTokenParametersPUT = (accessToken, body = {}) => ({ + method: 'PUT', + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + 'Content-Length': 0, + }, + body: JSON.stringify(body), +}); + +const accessTokenParametersDELETE = accessToken => ({ + method: 'DELETE', + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + }, +}); + +const accessTokenParametersPATCH = (editParams, accessToken) => ({ + method: 'PATCH', + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + }, + body: JSON.stringify(editParams), +}); + +const accessTokenParametersPOST = (accessToken, body) => ({ + method: 'POST', + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + }, + body: JSON.stringify(body), +}); + +const accessTokenParametersHTML = accessToken => ({ + headers: { + Accept: 'application/vnd.github.v3.html+json', + Authorization: `token ${accessToken}`, + }, +}); + +const accessTokenParametersDiff = accessToken => ({ + headers: { + Accept: 'application/vnd.github.v3.diff+json', + Authorization: `token ${accessToken}`, + }, +}); + +const accessTokenParametersRaw = accessToken => ({ + headers: { + Accept: 'application/vnd.github.v3.raw+json', + Authorization: `token ${accessToken}`, + }, +}); + +const authParameters = (code, state) => ({ + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code, + state, + }), +}); + +export async function fetchUrl(url, accessToken) { + const response = await fetch(url, accessTokenParameters(accessToken)); + + return response.json(); +} + +export async function fetchUrlNormal(url, accessToken) { + const response = await fetch(url, accessTokenParameters(accessToken)); + + return response; +} + +export async function fetchUrlFile(url, accessToken) { + const response = await fetch(url, accessTokenParametersRaw(accessToken)); + + return response.text(); +} + +export async function fetchCommentHTML(url, accessToken) { + const response = await fetch(url, accessTokenParameters(accessToken)); + + return response.json(); +} + +export async function fetchAccessToken(code, state) { + const GITHUB_OAUTH_ENDPOINT = 'https://github.com/login/oauth/access_token'; + const response = await fetch( + GITHUB_OAUTH_ENDPOINT, + authParameters(code, state) + ); + + return response.json(); +} + +export async function fetchAuthUser(accessToken) { + const FETCH_AUTH_USER_ENDPOINT = `${root}/user`; + const response = await fetch( + FETCH_AUTH_USER_ENDPOINT, + accessTokenParameters(accessToken) + ); + + return response.json(); +} + +export async function fetchAuthUserOrgs(accessToken) { + const ORGS_ENDPOINT = `${root}/user/orgs`; + const response = await fetch( + ORGS_ENDPOINT, + accessTokenParameters(accessToken) + ); + + return response.json(); +} + +export async function fetchUser(user, accessToken) { + const FETCH_USER_ENDPOINT = `${root}/users/${user}`; + const response = await fetch( + FETCH_USER_ENDPOINT, + accessTokenParameters(accessToken) + ); + + return response.json(); +} + +export async function fetchUserOrgs(user, accessToken) { + const ORGS_ENDPOINT = `${root}/users/${user}/orgs`; + const response = await fetch( + ORGS_ENDPOINT, + accessTokenParameters(accessToken) + ); + + return response.json(); +} + +export async function fetchUserEvents(user, accessToken) { + const EVENTS_ENDPOINT = `${root}/users/${user}/received_events?per_page=100`; + const response = await fetch( + EVENTS_ENDPOINT, + accessTokenParameters(accessToken) + ); + + return response.json(); +} + +export async function fetchReadMe(user, repository, accessToken) { + const README_ENDPOINT = `${root}/repos/${user}/${repository}/readme?ref=master`; + const response = await fetch( + README_ENDPOINT, + accessTokenParametersHTML(accessToken) + ); + + return response.text(); +} + +export async function fetchOrg(orgName, accessToken) { + const response = await fetch( + `${root}/orgs/${orgName}`, + accessTokenParameters(accessToken) + ); + + return response.json(); +} + +export async function fetchOrgMembers(orgName, accessToken) { + const response = await fetch( + `${root}/orgs/${orgName}/members`, + accessTokenParameters(accessToken) + ); + + return response.json(); +} + +export async function fetchPostIssueComment( + body, + owner, + repoName, + issueNum, + accessToken +) { + const ENDPOINT = `${root}/repos/${owner}/${repoName}/issues/${issueNum}/comments`; + const response = await fetch( + ENDPOINT, + accessTokenParametersPOST(accessToken, { body }) + ); + + return response.json(); +} + +export async function fetchEditIssue( + owner, + repoName, + issueNum, + editParams, + updateParams, + accessToken +) { + const ENDPOINT = `${root}/repos/${owner}/${repoName}/issues/${issueNum}`; + const response = await fetch( + ENDPOINT, + accessTokenParametersPATCH(editParams, accessToken) + ); + + return response; +} + +export async function fetchChangeIssueLockStatus( + owner, + repoName, + issueNum, + currentStatus, + accessToken +) { + const ENDPOINT = `${root}/repos/${owner}/${repoName}/issues/${issueNum}/lock`; + const response = await fetch( + ENDPOINT, + currentStatus + ? accessTokenParametersDELETE(accessToken) + : accessTokenParametersPUT(accessToken) + ); + + return response; +} + +export async function fetchSearch(type, query, accessToken, params = '') { + const ENDPOINT = `${root}/search/${type}?q=${query}${params}`; + const response = await fetch(ENDPOINT, accessTokenParameters(accessToken)); + + return response.json(); +} + +export async function fetchNotifications(participating, all, accessToken) { + const ENDPOINT = `${root}/notifications?participating=${participating}&all=${all}`; + const response = await fetch(ENDPOINT, accessTokenParameters(accessToken)); + + return response.json(); +} + +export async function fetchMarkNotificationAsRead(notificationID, accessToken) { + const ENDPOINT = `${root}/notifications/threads/${notificationID}`; + const response = await fetch( + ENDPOINT, + accessTokenParametersPATCH(null, accessToken) + ); + + return response; +} + +export async function fetchMarkRepoNotificationAsRead( + repoFullName, + accessToken +) { + const ENDPOINT = `${root}/repos/${repoFullName}/notifications`; + const response = await fetch(ENDPOINT, accessTokenParametersPUT(accessToken)); + + return response; +} + +export async function fetchChangeStarStatusRepo( + owner, + repo, + starred, + accessToken +) { + const ENDPOINT = `${root}/user/starred/${owner}/${repo}`; + const response = await fetch( + ENDPOINT, + starred + ? accessTokenParametersDELETE(accessToken) + : accessTokenParametersPUT(accessToken) + ); + + return response; +} + +export async function fetchForkRepo(owner, repo, accessToken) { + const ENDPOINT = `${root}/repos/${owner}/${repo}/forks`; + const response = await fetch( + ENDPOINT, + accessTokenParametersPOST(accessToken) + ); + + return response; +} + +export async function fetchStarCount(owner) { + const ENDPOINT = `${root}/users/${owner}/starred?per_page=1`; + const response = await fetch(ENDPOINT); + + let linkHeader = response.headers.get('Link'); + let output = ''; + + if (linkHeader == null) { + output = response.json().then(data => { + return data.length; + }); + } else { + linkHeader = linkHeader.match(/page=(\d)+/g).pop(); + output = linkHeader.split('=').pop(); + } + + return abbreviateNumber(output); +} + +export async function watchRepo(isSubscribed, owner, repo, accessToken) { + const ENDPOINT = `${root}/repos/${owner}/${repo}/subscription`; + const response = await fetch( + ENDPOINT, + accessTokenParameters(accessToken, { subscribed: isSubscribed }) + ); + + return response; +} + +export async function unWatchRepo(owner, repo, accessToken) { + const ENDPOINT = `${root}/repos/${owner}/${repo}/subscription`; + const response = await fetch(ENDPOINT, accessTokenParameters(accessToken)); + + return response; +} + +export async function fetchChangeFollowStatus(user, isFollowing, accessToken) { + const ENDPOINT = `${root}/user/following/${user}`; + const response = await fetch( + ENDPOINT, + isFollowing + ? accessTokenParametersDELETE(accessToken) + : accessTokenParametersPUT(accessToken) + ); + + return response; +} + +export async function fetchDiff(url, accessToken) { + const response = await fetch(url, accessTokenParametersDiff(accessToken)); + + return response.text(); +} + +export async function fetchMergeStatus(repo, issueNum, accessToken) { + const ENDPOINT = `${root}/repos/${repo}/pulls/${issueNum}/merge`; + const response = await fetch(ENDPOINT, accessTokenParameters(accessToken)); + + return response; +} + +export async function fetchMergePullRequest( + repo, + issueNum, + commitTitle, + commitMessage, + mergeMethod, + accessToken +) { + const ENDPOINT = `${root}/repos/${repo}/pulls/${issueNum}/merge`; + + const response = await fetch( + ENDPOINT, + accessTokenParametersPUT(accessToken, { + commit_title: commitTitle, + commit_message: commitMessage, + merge_method: mergeMethod, + }) + ); + + return response; +} + +export async function fetchSubmitNewIssue( + owner, + repo, + issueTitle, + issueComment, + accessToken +) { + const ENDPOINT = `${root}/repos/${owner}/${repo}/issues`; + const response = await fetch( + ENDPOINT, + accessTokenParametersPOST(accessToken, { + title: issueTitle, + body: issueComment, + }) + ); + + return response.json(); +} + +// Extracts the next page URL from Github API response. +const getNextPageUrl = response => { + const link = response.headers.get('link'); + + if (!link) { + return null; + } + + const nextLink = link.split(',').find(s => s.indexOf('rel="next"') > -1); + + if (!nextLink) { + return null; + } + + return nextLink.split(';')[0].slice(1, -1); +}; + +/* + * New API + */ + +const API_ROOT = 'https://api.github.com/'; + +// Fetches an API response and normalizes the result JSON according to schema. +// This makes every API response have the same shape, regardless of how nested it was. +export const callApi = (endpoint, schema, accessToken) => { + const fullUrl = + endpoint.indexOf(API_ROOT) === -1 ? API_ROOT + endpoint : endpoint; + + console.log(`[New API] Calling ${accessToken} ${fullUrl}`); + + return fetch(fullUrl, { + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + 'Cache-Control': 'no-cache', + }, + }).then(response => + response.json().then(json => { + if (!response.ok) { + return Promise.reject(json); + } + + const nextPageUrl = getNextPageUrl(response); + + return Object.assign({}, normalize(json, schema), { nextPageUrl }); + }) + ); +}; diff --git a/src/api/api.middleware.js b/src/api/api.middleware.js new file mode 100644 index 000000000..2b0b285d3 --- /dev/null +++ b/src/api/api.middleware.js @@ -0,0 +1,78 @@ +import { userSchema } from '../user/user.schema'; +import { repoSchema } from '../repository/repository.schema'; +import { eventSchema } from '../event/event.schema'; +import { callApi } from '../api/api.client'; + +// Schemas for Github API responses. +export const Schemas = { + EVENT: eventSchema, + EVENT_ARRAY: [eventSchema], + USER: userSchema, + USER_ARRAY: [userSchema], + REPO: repoSchema, + REPO_ARRAY: [repoSchema], +}; + +// Action key that carries API call info interpreted by this Redux middleware. +export const CALL_API = 'Call API'; + +// A Redux middleware that interprets actions with CALL_API info specified. +// Performs the call and promises when such actions are dispatched. +export default store => next => action => { + const callAPI = action[CALL_API]; + + if (typeof callAPI === 'undefined') { + return next(action); + } + + let { endpoint } = callAPI; + const { schema, types } = callAPI; + + if (typeof endpoint === 'function') { + endpoint = endpoint(store.getState()); + } + + if (typeof endpoint !== 'string') { + throw new Error('Specify a string endpoint URL.'); + } + if (!schema) { + throw new Error('Specify one of the exported Schemas.'); + } + if (!Array.isArray(types) || types.length !== 3) { + throw new Error('Expected an array of three action types.'); + } + if (!types.every(type => typeof type === 'string')) { + throw new Error('Expected action types to be strings.'); + } + + const accessToken = store.getState().auth.accessToken; + + const actionWith = data => { + const finalAction = Object.assign({}, action, data); + + delete finalAction[CALL_API]; + + return finalAction; + }; + + const [requestType, successType, failureType] = types; + + next(actionWith({ type: requestType })); + + return callApi(endpoint, schema, accessToken).then( + response => + next( + actionWith({ + response, + type: successType, + }) + ), + error => + next( + actionWith({ + type: failureType, + error: error.message || 'Something bad happened', + }) + ) + ); +}; diff --git a/src/api/index.js b/src/api/index.js index 5b5381a30..4bb2af288 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,415 +1,2 @@ -import { abbreviateNumber } from 'utils'; - -// These keys are for development purposes and do not represent the actual application keys. -// Feel free to use them or use a new set of keys by creating an OAuth application of your own. -// https://github.com/settings/applications/new -export const CLIENT_ID = '87c7f05700c052937cfb'; -export const CLIENT_SECRET = '3a70aee4d5e26c457720a31c3efe2f9062a4997a'; - -export const root = 'https://api.github.com'; -export const USER_ENDPOINT = user => `${root}/users/${user}`; - -const accessTokenParameters = accessToken => ({ - headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token ${accessToken}`, - 'Cache-Control': 'no-cache', - }, -}); - -const accessTokenParametersPUT = (accessToken, body = {}) => ({ - method: 'PUT', - headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token ${accessToken}`, - 'Content-Length': 0, - }, - body: JSON.stringify(body), -}); - -const accessTokenParametersDELETE = accessToken => ({ - method: 'DELETE', - headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token ${accessToken}`, - }, -}); - -const accessTokenParametersPATCH = (editParams, accessToken) => ({ - method: 'PATCH', - headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token ${accessToken}`, - }, - body: JSON.stringify(editParams), -}); - -const accessTokenParametersPOST = (accessToken, body) => ({ - method: 'POST', - headers: { - Accept: 'application/vnd.github.v3+json', - Authorization: `token ${accessToken}`, - }, - body: JSON.stringify(body), -}); - -const accessTokenParametersHTML = accessToken => ({ - headers: { - Accept: 'application/vnd.github.v3.html+json', - Authorization: `token ${accessToken}`, - }, -}); - -const accessTokenParametersDiff = accessToken => ({ - headers: { - Accept: 'application/vnd.github.v3.diff+json', - Authorization: `token ${accessToken}`, - }, -}); - -const accessTokenParametersRaw = accessToken => ({ - headers: { - Accept: 'application/vnd.github.v3.raw+json', - Authorization: `token ${accessToken}`, - }, -}); - -const authParameters = (code, state) => ({ - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - code, - state, - }), -}); - -export async function fetchUrl(url, accessToken) { - const response = await fetch(url, accessTokenParameters(accessToken)); - - return response.json(); -} - -export async function fetchUrlNormal(url, accessToken) { - const response = await fetch(url, accessTokenParameters(accessToken)); - - return response; -} - -export async function fetchUrlFile(url, accessToken) { - const response = await fetch(url, accessTokenParametersRaw(accessToken)); - - return response.text(); -} - -export async function fetchCommentHTML(url, accessToken) { - const response = await fetch(url, accessTokenParameters(accessToken)); - - return response.json(); -} - -export async function fetchAccessToken(code, state) { - const GITHUB_OAUTH_ENDPOINT = 'https://github.com/login/oauth/access_token'; - const response = await fetch( - GITHUB_OAUTH_ENDPOINT, - authParameters(code, state) - ); - - return response.json(); -} - -export async function fetchAuthUser(accessToken) { - const FETCH_AUTH_USER_ENDPOINT = `${root}/user`; - const response = await fetch( - FETCH_AUTH_USER_ENDPOINT, - accessTokenParameters(accessToken) - ); - - return response.json(); -} - -export async function fetchAuthUserOrgs(accessToken) { - const ORGS_ENDPOINT = `${root}/user/orgs`; - const response = await fetch( - ORGS_ENDPOINT, - accessTokenParameters(accessToken) - ); - - return response.json(); -} - -export async function fetchUser(user, accessToken) { - const FETCH_USER_ENDPOINT = `${root}/users/${user}`; - const response = await fetch( - FETCH_USER_ENDPOINT, - accessTokenParameters(accessToken) - ); - - return response.json(); -} - -export async function fetchUserOrgs(user, accessToken) { - const ORGS_ENDPOINT = `${root}/users/${user}/orgs`; - const response = await fetch( - ORGS_ENDPOINT, - accessTokenParameters(accessToken) - ); - - return response.json(); -} - -export async function fetchUserEvents(user, accessToken) { - const EVENTS_ENDPOINT = `${root}/users/${user}/received_events?per_page=100`; - const response = await fetch( - EVENTS_ENDPOINT, - accessTokenParameters(accessToken) - ); - - return response.json(); -} - -export async function fetchReadMe(user, repository, accessToken) { - const README_ENDPOINT = `${root}/repos/${user}/${repository}/readme?ref=master`; - const response = await fetch( - README_ENDPOINT, - accessTokenParametersHTML(accessToken) - ); - - return response.text(); -} - -export async function fetchOrg(orgName, accessToken) { - const response = await fetch( - `${root}/orgs/${orgName}`, - accessTokenParameters(accessToken) - ); - - return response.json(); -} - -export async function fetchOrgMembers(orgName, accessToken) { - const response = await fetch( - `${root}/orgs/${orgName}/members`, - accessTokenParameters(accessToken) - ); - - return response.json(); -} - -export async function fetchPostIssueComment( - body, - owner, - repoName, - issueNum, - accessToken -) { - const ENDPOINT = `${root}/repos/${owner}/${repoName}/issues/${issueNum}/comments`; - const response = await fetch( - ENDPOINT, - accessTokenParametersPOST(accessToken, { body }) - ); - - return response.json(); -} - -export async function fetchEditIssue( - owner, - repoName, - issueNum, - editParams, - updateParams, - accessToken -) { - const ENDPOINT = `${root}/repos/${owner}/${repoName}/issues/${issueNum}`; - const response = await fetch( - ENDPOINT, - accessTokenParametersPATCH(editParams, accessToken) - ); - - return response; -} - -export async function fetchChangeIssueLockStatus( - owner, - repoName, - issueNum, - currentStatus, - accessToken -) { - const ENDPOINT = `${root}/repos/${owner}/${repoName}/issues/${issueNum}/lock`; - const response = await fetch( - ENDPOINT, - currentStatus - ? accessTokenParametersDELETE(accessToken) - : accessTokenParametersPUT(accessToken) - ); - - return response; -} - -export async function fetchSearch(type, query, accessToken, params = '') { - const ENDPOINT = `${root}/search/${type}?q=${query}${params}`; - const response = await fetch(ENDPOINT, accessTokenParameters(accessToken)); - - return response.json(); -} - -export async function fetchNotifications(participating, all, accessToken) { - const ENDPOINT = `${root}/notifications?participating=${participating}&all=${all}`; - const response = await fetch(ENDPOINT, accessTokenParameters(accessToken)); - - return response.json(); -} - -export async function fetchMarkNotificationAsRead(notificationID, accessToken) { - const ENDPOINT = `${root}/notifications/threads/${notificationID}`; - const response = await fetch( - ENDPOINT, - accessTokenParametersPATCH(null, accessToken) - ); - - return response; -} - -export async function fetchMarkRepoNotificationAsRead( - repoFullName, - accessToken -) { - const ENDPOINT = `${root}/repos/${repoFullName}/notifications`; - const response = await fetch(ENDPOINT, accessTokenParametersPUT(accessToken)); - - return response; -} - -export async function fetchChangeStarStatusRepo( - owner, - repo, - starred, - accessToken -) { - const ENDPOINT = `${root}/user/starred/${owner}/${repo}`; - const response = await fetch( - ENDPOINT, - starred - ? accessTokenParametersDELETE(accessToken) - : accessTokenParametersPUT(accessToken) - ); - - return response; -} - -export async function fetchForkRepo(owner, repo, accessToken) { - const ENDPOINT = `${root}/repos/${owner}/${repo}/forks`; - const response = await fetch( - ENDPOINT, - accessTokenParametersPOST(accessToken) - ); - - return response; -} - -export async function fetchStarCount(owner) { - const ENDPOINT = `${root}/users/${owner}/starred?per_page=1`; - const response = await fetch(ENDPOINT); - - let linkHeader = response.headers.get('Link'); - let output = ''; - - if (linkHeader == null) { - output = response.json().then(data => { - return data.length; - }); - } else { - linkHeader = linkHeader.match(/page=(\d)+/g).pop(); - output = linkHeader.split('=').pop(); - } - - return abbreviateNumber(output); -} - -export async function watchRepo(isSubscribed, owner, repo, accessToken) { - const ENDPOINT = `${root}/repos/${owner}/${repo}/subscription`; - const response = await fetch( - ENDPOINT, - accessTokenParameters(accessToken, { subscribed: isSubscribed }) - ); - - return response; -} - -export async function unWatchRepo(owner, repo, accessToken) { - const ENDPOINT = `${root}/repos/${owner}/${repo}/subscription`; - const response = await fetch(ENDPOINT, accessTokenParameters(accessToken)); - - return response; -} - -export async function fetchChangeFollowStatus(user, isFollowing, accessToken) { - const ENDPOINT = `${root}/user/following/${user}`; - const response = await fetch( - ENDPOINT, - isFollowing - ? accessTokenParametersDELETE(accessToken) - : accessTokenParametersPUT(accessToken) - ); - - return response; -} - -export async function fetchDiff(url, accessToken) { - const response = await fetch(url, accessTokenParametersDiff(accessToken)); - - return response.text(); -} - -export async function fetchMergeStatus(repo, issueNum, accessToken) { - const ENDPOINT = `${root}/repos/${repo}/pulls/${issueNum}/merge`; - const response = await fetch(ENDPOINT, accessTokenParameters(accessToken)); - - return response; -} - -export async function fetchMergePullRequest( - repo, - issueNum, - commitTitle, - commitMessage, - mergeMethod, - accessToken -) { - const ENDPOINT = `${root}/repos/${repo}/pulls/${issueNum}/merge`; - - const response = await fetch( - ENDPOINT, - accessTokenParametersPUT(accessToken, { - commit_title: commitTitle, - commit_message: commitMessage, - merge_method: mergeMethod, - }) - ); - - return response; -} - -export async function fetchSubmitNewIssue( - owner, - repo, - issueTitle, - issueComment, - accessToken -) { - const ENDPOINT = `${root}/repos/${owner}/${repo}/issues`; - const response = await fetch( - ENDPOINT, - accessTokenParametersPOST(accessToken, { - title: issueTitle, - body: issueComment, - }) - ); - - return response.json(); -} +export * from './api.client'; +export * from './api.middleware'; diff --git a/src/auth/auth.action.js b/src/auth/auth.action.js index 462333088..999ef4c88 100644 --- a/src/auth/auth.action.js +++ b/src/auth/auth.action.js @@ -11,7 +11,8 @@ import { fetchUserOrgs, fetchUserEvents, fetchStarCount, -} from 'api'; +} from '../api/api.client'; + import { LOGIN, LOGOUT, diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index a08ffc231..938ec1095 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -4,22 +4,42 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { StyleSheet, Text, FlatList, View } from 'react-native'; import moment from 'moment/min/moment-with-locales.min'; +import { denormalize, schema } from 'normalizr'; import { LoadingUserListItem, UserListItem, ViewContainer } from 'components'; import { colors, fonts, normalize } from 'config'; import { emojifyText, translate } from 'utils'; -import { getUserEvents } from '../auth.action'; -const mapStateToProps = state => ({ - user: state.auth.user, - userEvents: state.auth.events, - language: state.auth.language, - isPendingEvents: state.auth.isPendingEvents, -}); +import { loadUser } from '../../user/user.action'; +import { loadEvents } from '../../event/event.action'; +import { eventSchema } from '../../event/event.schema'; +import values from 'lodash/values'; +const loadData = ({ user, getEvents }) => { + console.log('called events', user, getEvents); + getEvents(user.login); +}; + +const mapStateToProps = (state, ownProps) => { + // We need to lower case the login due to the way GitHub's API behaves. + // Have a look at ../middleware/api.js for more details. + const { entities: { users, events } } = state; + + console.log('Got ', state.auth.isPendingEvents, ' events'); + + return { + user: state.auth.user, + // userEvents: state.auth.events, + language: state.auth.language, + isPendingEvents: state.auth.isPendingEvents, + userEvents: events, + users: users, + }; +}; +/* const mapDispatchToProps = dispatch => ({ getUserEvents: user => dispatch(getUserEvents(user)), -}); +});*/ const styles = StyleSheet.create({ descriptionContainer: { @@ -70,19 +90,20 @@ const styles = StyleSheet.create({ class Events extends Component { componentDidMount() { + console.log('componentDidMount', this.props); if (this.props.user.login) { - this.getUserEvents(this.props.user); + loadData(this.props); } } componentWillReceiveProps(nextProps) { if (nextProps.user.login && !this.props.user.login) { - this.getUserEvents(nextProps.user); + loadData(nextProps); } } - getUserEvents = (user = this.props.user) => { - this.props.getUserEvents(user.login); + getUserEvents = () => { + loadData(this.props); }; getAction = userEvent => { @@ -480,13 +501,24 @@ class Events extends Component { }; renderDescription(userEvent) { + userEvent = denormalize( + userEvent, + { events: [eventSchema] }, + this.props.userEvents + ); + console.log(userEvent); + const user = this.props.users[userEvent.actor]; + + console.log(user); + + // return (Description); return ( this.navigateToProfile(userEvent, true)} > - {userEvent.actor.login}{' '} + {user.login}{' '} {this.getAction(userEvent)}{' '} @@ -505,7 +537,13 @@ class Events extends Component { } render() { - const { isPendingEvents, userEvents, language, navigation } = this.props; + const { + users, + isPendingEvents, + userEvents, + language, + navigation, + } = this.props; const linebreaksPattern = /(\r\n|\n|\r)/gm; let content; @@ -514,7 +552,7 @@ class Events extends Component { // eslint-disable-next-line react/no-array-index-key return ; }); - } else if (!isPendingEvents && userEvents && userEvents.length === 0) { + } else if (userEvents && userEvents.length === 0) { content = ( @@ -526,14 +564,14 @@ class Events extends Component { content = ( diff --git a/src/config/reactotron.js b/src/config/reactotron.js index b146052b3..45a61a81a 100644 --- a/src/config/reactotron.js +++ b/src/config/reactotron.js @@ -3,5 +3,10 @@ import Reactotron from 'reactotron-react-native'; import { reactotronRedux } from 'reactotron-redux'; if (__DEV__ && process.env.TRON_ENABLED) { - Reactotron.configure().useReactNative().use(reactotronRedux()).connect(); + Reactotron.configure({ + host: '192.168.1.3', + }) + .useReactNative() + .use(reactotronRedux()) + .connect(); } diff --git a/src/event/event.action.js b/src/event/event.action.js new file mode 100644 index 000000000..2982dfd6e --- /dev/null +++ b/src/event/event.action.js @@ -0,0 +1,28 @@ +import has from 'lodash/has'; + +import { EVENTS_REQUEST, EVENTS_SUCCESS, EVENTS_FAILURE } from './event.type'; + +import { CALL_API, Schemas } from '../api/api.middleware'; + +/** NEW API */ + +const _fetchEvents = login => ({ + [CALL_API]: { + types: [EVENTS_REQUEST, EVENTS_SUCCESS, EVENTS_FAILURE], + endpoint: `users/${login}/received_events`, + schema: Schemas.EVENT_ARRAY, + }, +}); + +export const loadEvents = (login, requiredFields = []) => ( + dispatch, + getState +) => { + const event = getState().entities.events[login]; + + if (event && requiredFields.every(key => has(event, key))) { + return null; + } + + return dispatch(_fetchEvents(login)); +}; diff --git a/src/event/event.schema.js b/src/event/event.schema.js new file mode 100644 index 000000000..93cf3c3c2 --- /dev/null +++ b/src/event/event.schema.js @@ -0,0 +1,10 @@ +import { schema } from 'normalizr'; +// import omit from 'lodash/omit'; + +import { userSchema } from '../user/user.schema'; +import { repoSchema } from '../repository/repository.schema'; + +export const eventSchema = new schema.Entity('events', { + actor: userSchema, + repo: repoSchema, +}); diff --git a/src/event/event.type.js b/src/event/event.type.js new file mode 100644 index 000000000..9a671ea55 --- /dev/null +++ b/src/event/event.type.js @@ -0,0 +1,3 @@ +export const EVENTS_REQUEST = 'EVENTS_REQUEST'; +export const EVENTS_SUCCESS = 'EVENTS_SUCCESS'; +export const EVENTS_FAILURE = 'EVENTS_FAILURE'; diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 000000000..5f11093ee --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,63 @@ +import merge from 'lodash/merge'; +import { combineReducers } from 'redux'; + +import paginate from './paginate'; + +import * as ActionTypes from '../actions'; + +// Updates an entity cache in response to any action with response.entities. +export const entities = ( + state = { + users: {}, + repos: {}, + events: {}, + }, + action +) => { + if (action.response && action.response.entities) { + return merge({}, state, action.response.entities); + } + + return state; +}; + +// Updates error message to notify about the failed fetches. +export const errorMessage = (state = null, action) => { + const { type, error } = action; + + if (type === ActionTypes.RESET_ERROR_MESSAGE) { + return null; + } else if (error) { + return error; + } + + return state; +}; + +// Updates the pagination data for different actions. +export const pagination = combineReducers({ + starredByUser: paginate({ + mapActionToKey: action => action.login, + types: [ + ActionTypes.STARRED_REQUEST, + ActionTypes.STARRED_SUCCESS, + ActionTypes.STARRED_FAILURE, + ], + }), + followersByUser: paginate({ + mapActionToKey: action => action.login, + types: [ + ActionTypes.FOLLOWERS_REQUEST, + ActionTypes.FOLLOWERS_SUCCESS, + ActionTypes.FOLLOWERS_FAILURE, + ], + }), + stargazersByRepo: paginate({ + mapActionToKey: action => action.fullName, + types: [ + ActionTypes.STARGAZERS_REQUEST, + ActionTypes.STARGAZERS_SUCCESS, + ActionTypes.STARGAZERS_FAILURE, + ], + }), +}); diff --git a/src/reducers/paginate.js b/src/reducers/paginate.js new file mode 100644 index 000000000..122b37e47 --- /dev/null +++ b/src/reducers/paginate.js @@ -0,0 +1,72 @@ +import union from 'lodash/union'; + +// Creates a reducer managing pagination, given the action types to handle, +// and a function telling how to extract the key from an action. +const paginate = ({ types, mapActionToKey }) => { + if (!Array.isArray(types) || types.length !== 3) { + throw new Error('Expected types to be an array of three elements.'); + } + if (!types.every(t => typeof t === 'string')) { + throw new Error('Expected types to be strings.'); + } + if (typeof mapActionToKey !== 'function') { + throw new Error('Expected mapActionToKey to be a function.'); + } + + const [requestType, successType, failureType] = types; + console.log('types', types); + const updatePagination = ( + state = { + isFetching: false, + nextPageUrl: undefined, + pageCount: 0, + ids: [], + }, + action + ) => { + switch (action.type) { + case requestType: + return { + ...state, + isFetching: true, + }; + case successType: + return { + ...state, + isFetching: false, + ids: union(state.ids, action.response.result), + nextPageUrl: action.response.nextPageUrl, + pageCount: state.pageCount + 1, + }; + case failureType: + return { + ...state, + isFetching: false, + }; + default: + return state; + } + }; + + return (state = {}, action) => { + // Update pagination by key + switch (action.type) { + case requestType: + case successType: + case failureType: + const key = mapActionToKey(action); + if (typeof key !== 'string') { + throw new Error('Expected key to be a string.'); + } + + return { + ...state, + [key]: updatePagination(state[key], action), + }; + default: + return state; + } + }; +}; + +export default paginate; diff --git a/src/repository/repository.action.js b/src/repository/repository.action.js index 2bf75d3b9..67abd90e1 100644 --- a/src/repository/repository.action.js +++ b/src/repository/repository.action.js @@ -1,3 +1,5 @@ +import has from 'lodash/has'; + import { root as apiRoot, fetchUrl, @@ -27,8 +29,13 @@ import { SEARCH_OPEN_PULLS, SEARCH_CLOSED_PULLS, GET_REPOSITORY_SUBSCRIBED_STATUS, + REPO_FAILURE, + REPO_REQUEST, + REPO_SUCCESS, } from './repository.type'; +import { CALL_API, Schemas } from '../api/api.middleware'; + export const getRepository = url => { return (dispatch, getState) => { const accessToken = getState().auth.accessToken; @@ -455,3 +462,30 @@ export const searchClosedRepoPulls = (query, repoFullName) => { }); }; }; + +/* New API */ + +// Fetches a single repository from Github API. +// Relies on the custom API middleware defined in ../middleware/api.js. +const fetchRepo = fullName => ({ + [CALL_API]: { + types: [REPO_REQUEST, REPO_SUCCESS, REPO_FAILURE], + endpoint: `repos/${fullName}`, + schema: Schemas.REPO, + }, +}); + +// Fetches a single repository from Github API unless it is cached. +// Relies on Redux Thunk middleware. +export const loadRepo = (fullName, requiredFields = []) => ( + dispatch, + getState +) => { + const repo = getState().entities.repos[fullName]; + + if (repo && requiredFields.every(key => has(repo, key))) { + return null; + } + + return dispatch(fetchRepo(fullName)); +}; diff --git a/src/repository/repository.schema.js b/src/repository/repository.schema.js new file mode 100644 index 000000000..6e2d34209 --- /dev/null +++ b/src/repository/repository.schema.js @@ -0,0 +1,39 @@ +import { schema } from 'normalizr'; +import omit from 'lodash/omit'; + +import { userSchema } from '../user/user.schema'; + +/* +export const userSchema = new schema.Entity('users', {}, { + idAttribute: user => user.login.toLowerCase(), + processStrategy: entity => omit(entity, [ + 'url', + 'html_url', + 'followers_url', + 'following_url', + 'gists_url', + 'starred_url', + 'subscriptions_url', + 'organizations_url', + 'repos_url', + 'events_url', + 'received_events_url', + ]), +});*/ + +export const repoSchema = new schema.Entity( + 'repos', + { + owner: userSchema, + }, + { + idAttribute: repo => { + if (typeof repo.full_name === 'string') { + return repo.full_name.toLowerCase(); + } + + // In Events + return repo.name.toLowerCase(); + }, + } +); diff --git a/src/repository/repository.type.js b/src/repository/repository.type.js index f7cb6e85a..89726d6c2 100644 --- a/src/repository/repository.type.js +++ b/src/repository/repository.type.js @@ -23,3 +23,7 @@ export const SEARCH_CLOSED_PULLS = createActionSet('SEARCH_CLOSED_PULLS'); export const GET_REPOSITORY_SUBSCRIBED_STATUS = createActionSet( 'GET_REPOSITORY_SUBSCRIBED_STATUS' ); + +export const REPO_REQUEST = 'REPO_REQUEST'; +export const REPO_SUCCESS = 'REPO_SUCCESS'; +export const REPO_FAILURE = 'REPO_FAILURE'; diff --git a/src/user/index.js b/src/user/index.js index fc5f4dbf9..94d38ef73 100644 --- a/src/user/index.js +++ b/src/user/index.js @@ -1,5 +1,6 @@ export * from './user.action'; export * from './user.reducer'; export * from './user.type'; +export * from './user.schema'; export * from './screens'; diff --git a/src/user/screens/follower-list.screen.js b/src/user/screens/follower-list.screen.js index 1d05dbb9a..321be7497 100644 --- a/src/user/screens/follower-list.screen.js +++ b/src/user/screens/follower-list.screen.js @@ -3,30 +3,43 @@ import { connect } from 'react-redux'; import { FlatList, View } from 'react-native'; import { ViewContainer, UserListItem, LoadingUserListItem } from 'components'; -import { getFollowers } from 'user'; +import { loadFollowers } from './../../actions'; -const mapStateToProps = state => ({ - user: state.user.user, - followers: state.user.followers, - isPendingFollowers: state.user.isPendingFollowers, -}); +const mapStateToProps = (state, ownProps) => { + const login = ownProps.navigation.state.params.user.login.toLowerCase(); -const mapDispatchToProps = dispatch => ({ - getFollowers: (user, type) => dispatch(getFollowers(user, type)), -}); + const { entities: { users }, pagination: { followersByUser } } = state; + + const followersPagination = followersByUser[login] || { ids: [] }; + const followers = followersPagination.ids.map(id => users[id]); + + return { + login, + followers, + followersPagination, + user: users[login], + }; +}; + +const loadData = ({ login, getFollowers }) => { + getFollowers(login); +}; class FollowerList extends Component { props: { getFollowers: Function, + login: String, followers: Array, isPendingFollowers: boolean, navigation: Object, }; - componentDidMount() { - const user = this.props.navigation.state.params.user; + componentWillMount() { + loadData(this.props); + } - this.props.getFollowers(user); + componentDidMount() { + loadData(this.props); } keyExtractor = item => { @@ -34,7 +47,13 @@ class FollowerList extends Component { }; render() { - const { followers, isPendingFollowers, navigation } = this.props; + const { + login, + getFollowers, + followers, + isPendingFollowers, + navigation, + } = this.props; const followerCount = navigation.state.params.followerCount; return ( @@ -56,6 +75,8 @@ class FollowerList extends Component { navigation={navigation} showFullName />} + onEndReachedThreshold={0.5} + onEndReached={() => getFollowers(login, true)} /> } @@ -63,6 +84,6 @@ class FollowerList extends Component { } } -export const FollowerListScreen = connect(mapStateToProps, mapDispatchToProps)( - FollowerList -); +export const FollowerListScreen = connect(mapStateToProps, { + getFollowers: loadFollowers, +})(FollowerList); diff --git a/src/user/screens/profile.screen.js b/src/user/screens/profile.screen.js index 53f45040d..160062bbb 100644 --- a/src/user/screens/profile.screen.js +++ b/src/user/screens/profile.screen.js @@ -5,6 +5,7 @@ import { ActivityIndicator, Dimensions, View, + Text, RefreshControl, } from 'react-native'; import { ListItem } from 'react-native-elements'; @@ -20,8 +21,22 @@ import { } from 'components'; import { emojifyText, translate } from 'utils'; import { colors, fonts } from 'config'; -import { getUserInfo, getStarCount, getIsFollowing, getIsFollower, changeFollowStatus } from '../user.action'; +import { + loadUser, + getUserInfo, + getStarCount, + getIsFollowing, + getIsFollower, + changeFollowStatus, +} from '../user.action'; +import { loadStarred, loadFollowers } from './../../actions'; + +const loadData = ({ login, loadUser, loadStarred }) => { + loadUser(login, ['name']); + loadStarred(login); +}; +/* const mapStateToProps = state => ({ auth: state.auth.user, user: state.user.user, @@ -35,12 +50,13 @@ const mapStateToProps = state => ({ isPendingStarCount: state.user.isPendingStarCount, isPendingCheckFollowing: state.user.isPendingCheckFollowing, isPendingCheckFollower: state.user.isPendingCheckFollower, -}); +});*/ const mapDispatchToProps = dispatch => ({ getUserInfoByDispatch: user => dispatch(getUserInfo(user)), getUserStarCountByDispatch: user => dispatch(getStarCount(user)), - getIsFollowingByDispatch: (user, auth) => dispatch(getIsFollowing(user, auth)), + getIsFollowingByDispatch: (user, auth) => + dispatch(getIsFollowing(user, auth)), getIsFollowerByDispatch: (user, auth) => dispatch(getIsFollower(user, auth)), changeFollowStatusByDispatch: (user, isFollowing) => dispatch(changeFollowStatus(user, isFollowing)), @@ -66,6 +82,7 @@ class Profile extends Component { changeFollowStatusByDispatch: Function, auth: Object, user: Object, + login: String, orgs: Array, starCount: string, language: string, @@ -90,22 +107,32 @@ class Profile extends Component { }; } + componentWillMount() { + loadData(this.props); + } + componentDidMount() { - const user = this.props.navigation.state.params.user; - const auth = this.props.auth; + //const user = this.props.navigation.state.params.user; + /*const auth = this.props.auth; this.props.getUserInfoByDispatch(user.login); this.props.getUserStarCountByDispatch(user.login); this.props.getIsFollowingByDispatch(user.login, auth.login); - this.props.getIsFollowerByDispatch(user.login, auth.login); + this.props.getIsFollowerByDispatch(user.login, auth.login);*/ + + loadData(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.login !== this.props.login) { + loadData(nextProps); + } } getUserInfo = () => { this.setState({ refreshing: true }); - - const user = this.props.navigation.state.params.user; - const auth = this.props.auth; - + loadData(this.props); + /* Promise.all([ this.props.getUserInfoByDispatch(user.login), this.props.getUserStarCountByDispatch(user.login), @@ -113,7 +140,7 @@ class Profile extends Component { this.props.getIsFollowerByDispatch(user.login, auth.login), ]).then(() => { this.setState({ refreshing: false }); - }); + });*/ }; showMenuActionSheet = () => { @@ -131,7 +158,7 @@ class Profile extends Component { render() { const { user, - orgs, + // orgs, starCount, language, isFollowing, @@ -152,6 +179,12 @@ class Profile extends Component { : translate('user.profile.follow', language), ]; + const orgs = []; + + if (!user) { + return LOADING...; + } + return ( { + const login = ownProps.navigation.state.params.user.login + ? ownProps.navigation.state.params.user.login.toLowerCase() + : ownProps.navigation.state.params.user.toLowerCase(); + + const { + // pagination: { starredByUser }, + entities: { users, repos }, + } = state; + + console.log('login=', login); + + // const starredPagination = starredByUser[login] || { ids: [] }; + //const starredRepos = starredPagination.ids.map(id => repos[id]); + //const starredRepoOwners = starredRepos.map(repo => users[repo.owner]); + + return { + login, + auth: state.auth.user, + orgs: state.user.orgs, + starCount: state.user.starCount, + language: state.auth.language, + isFollowing: state.user.isFollowing, + isFollower: state.user.isFollower, + isPendingUser: state.user.isPendingUser, + isPendingOrgs: state.user.isPendingOrgs, + isPendingStarCount: state.user.isPendingStarCount, + isPendingCheckFollowing: state.user.isPendingCheckFollowing, + isPendingCheckFollower: state.user.isPendingCheckFollower, + + // starredRepos, + // starredRepoOwners, + // starredPagination, + user: users[login], + }; +}; + +export const ProfileScreen = connect(mapStateToProps2, { + loadUser, + loadStarred, + loadFollowers, +})(Profile); diff --git a/src/user/user.action.js b/src/user/user.action.js index f8282483f..c7ddc0569 100644 --- a/src/user/user.action.js +++ b/src/user/user.action.js @@ -1,3 +1,5 @@ +import has from 'lodash/has'; + import { fetchUser, fetchUserOrgs, @@ -20,8 +22,13 @@ import { SEARCH_USER_REPOS, CHANGE_FOLLOW_STATUS, GET_STAR_COUNT, + USER_REQUEST, + USER_SUCCESS, + USER_FAILURE, } from './user.type'; +import { CALL_API, Schemas } from '../api/api.middleware'; + const getUser = user => { return (dispatch, getState) => { const accessToken = getState().auth.accessToken; @@ -72,7 +79,10 @@ const checkFollowStatusHelper = (user, followedUser, actionSet) => { dispatch({ type: actionSet.PENDING }); - fetchUrlNormal(`${USER_ENDPOINT(user)}/following/${followedUser}`, accessToken) + fetchUrlNormal( + `${USER_ENDPOINT(user)}/following/${followedUser}`, + accessToken + ) .then(data => { dispatch({ type: actionSet.SUCCESS, @@ -124,10 +134,7 @@ export const getFollowers = user => { export const getUserInfo = user => { return dispatch => { - Promise.all([ - dispatch(getUser(user)), - dispatch(getOrgs(user)), - ]); + Promise.all([dispatch(getUser(user)), dispatch(getOrgs(user))]); }; }; @@ -250,3 +257,30 @@ export const searchUserRepos = (query, user) => { }); }; }; + +/** NEW API */ + +// Fetches a single user from Github API. +// Relies on the custom API middleware defined in ../middleware/api.js. +const _fetchUser = login => ({ + [CALL_API]: { + types: [USER_REQUEST, USER_SUCCESS, USER_FAILURE], + endpoint: `users/${login}`, + schema: Schemas.USER, + }, +}); + +// Fetches a single user from Github API unless it is cached. +// Relies on Redux Thunk middleware. +export const loadUser = (login, requiredFields = []) => ( + dispatch, + getState +) => { + const user = getState().entities.users[login]; + + if (user && requiredFields.every(key => has(user, key))) { + return null; + } + + return dispatch(_fetchUser(login)); +}; diff --git a/src/user/user.schema.js b/src/user/user.schema.js new file mode 100644 index 000000000..9deb36686 --- /dev/null +++ b/src/user/user.schema.js @@ -0,0 +1,24 @@ +import { schema } from 'normalizr'; +import omit from 'lodash/omit'; + +export const userSchema = new schema.Entity( + 'users', + {}, + { + idAttribute: user => user.login.toLowerCase(), + processStrategy: entity => + omit(entity, [ + 'url', + 'html_url', + 'followers_url', + 'following_url', + 'gists_url', + 'starred_url', + 'subscriptions_url', + 'organizations_url', + 'repos_url', + 'events_url', + 'received_events_url', + ]), + } +); diff --git a/src/user/user.type.js b/src/user/user.type.js index 8cbbd1bdd..0a0da8374 100644 --- a/src/user/user.type.js +++ b/src/user/user.type.js @@ -10,3 +10,8 @@ export const GET_FOLLOWING = createActionSet('GET_FOLLOWING'); export const SEARCH_USER_REPOS = createActionSet('SEARCH_USER_REPOS'); export const CHANGE_FOLLOW_STATUS = createActionSet('CHANGE_FOLLOW_STATUS'); export const GET_STAR_COUNT = createActionSet('GET_STAR_COUNT'); + +// New API +export const USER_REQUEST = 'USER_REQUEST'; +export const USER_SUCCESS = 'USER_SUCCESS'; +export const USER_FAILURE = 'USER_FAILURE'; From 6d6149777d115fe01ca1d777c2d7da278393b4f5 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 11:49:14 +0100 Subject: [PATCH 02/12] Clean up --- package.json | 2 -- src/actions/index.js | 2 -- src/api/api.client.js | 5 +++-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 6d622d846..011d0b326 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ }, "dependencies": { "fuzzy-search": "^1.4.0", - "humps": "^2.0.1", "lodash": "^4.17.4", "lodash.uniqby": "^4.7.0", "lowlight": "^1.5.0", @@ -53,7 +52,6 @@ "moment": "^2.17.1", "node-emoji": "^1.7.0", "normalizr": "^3.2.3", - "omit": "^1.0.1", "parse-diff": "^0.4.0", "query-string": "^4.3.1", "react": "16.0.0-alpha.12", diff --git a/src/actions/index.js b/src/actions/index.js index 3fc5efdc2..19555998e 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -48,10 +48,8 @@ const fetchFollowers = (login, nextPageUrl) => ({ // Bails out if page is cached and user didn't specifically request next page. // Relies on Redux Thunk middleware. export const loadFollowers = (login, nextPage) => (dispatch, getState) => { - console.log('loadFollowers(', login); const { nextPageUrl = `users/${login}/followers`, pageCount = 0 } = getState().pagination.followersByUser[login] || {}; - console.log('loadFollowers(', login, nextPage, nextPageUrl, pageCount); if ((pageCount > 0 && !nextPage) || !nextPageUrl) { return null; diff --git a/src/api/api.client.js b/src/api/api.client.js index 7d5787dc8..3c8e41e96 100644 --- a/src/api/api.client.js +++ b/src/api/api.client.js @@ -440,7 +440,8 @@ const API_ROOT = 'https://api.github.com/'; // Fetches an API response and normalizes the result JSON according to schema. // This makes every API response have the same shape, regardless of how nested it was. -export const callApi = (endpoint, schema, accessToken) => { +/* eslint-disable no-unused-vars */ +export const callApi = (endpoint, theSchema, accessToken) => { const fullUrl = endpoint.indexOf(API_ROOT) === -1 ? API_ROOT + endpoint : endpoint; @@ -460,7 +461,7 @@ export const callApi = (endpoint, schema, accessToken) => { const nextPageUrl = getNextPageUrl(response); - return Object.assign({}, normalize(json, schema), { nextPageUrl }); + return Object.assign({}, normalize(json, theSchema), { nextPageUrl }); }) ); }; From 0e9d16255e8e4c1ba55bf042688aaf7341894147 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 12:08:12 +0100 Subject: [PATCH 03/12] refactor(api): adopt reducer --- root.reducer.js | 2 +- src/api/api.client.js | 15 +++++ .../paginate.js => api/api.reducer.js} | 61 +++++++++++++++++- src/api/index.js | 1 + src/reducers/index.js | 63 ------------------- 5 files changed, 77 insertions(+), 65 deletions(-) rename src/{reducers/paginate.js => api/api.reducer.js} (55%) delete mode 100644 src/reducers/index.js diff --git a/root.reducer.js b/root.reducer.js index 25cc333d6..302709ffc 100644 --- a/root.reducer.js +++ b/root.reducer.js @@ -6,7 +6,7 @@ import { organizationReducer } from 'organization'; import { issueReducer } from 'issue'; import { searchReducer } from 'search'; import { notificationsReducer } from 'notifications'; -import { entities, errorMessage, pagination } from 'reducers'; +import { entities, errorMessage, pagination } from './src/api/api.reducer'; export const rootReducer = combineReducers({ entities, diff --git a/src/api/api.client.js b/src/api/api.client.js index 3c8e41e96..5382b97e9 100644 --- a/src/api/api.client.js +++ b/src/api/api.client.js @@ -18,6 +18,15 @@ const accessTokenParameters = accessToken => ({ }, }); +const accessTokenParametersHEAD = accessToken => ({ + method: 'HEAD', + headers: { + Accept: 'application/vnd.github.v3+json', + Authorization: `token ${accessToken}`, + 'Cache-Control': 'no-cache', + }, +}); + const accessTokenParametersPUT = (accessToken, body = {}) => ({ method: 'PUT', headers: { @@ -101,6 +110,12 @@ export async function fetchUrlNormal(url, accessToken) { return response; } +export async function fetchUrlHead(url, accessToken) { + const response = await fetch(url, accessTokenParametersHEAD(accessToken)); + + return response; +} + export async function fetchUrlFile(url, accessToken) { const response = await fetch(url, accessTokenParametersRaw(accessToken)); diff --git a/src/reducers/paginate.js b/src/api/api.reducer.js similarity index 55% rename from src/reducers/paginate.js rename to src/api/api.reducer.js index 122b37e47..c27a41897 100644 --- a/src/reducers/paginate.js +++ b/src/api/api.reducer.js @@ -1,4 +1,8 @@ +import merge from 'lodash/merge'; import union from 'lodash/union'; +import { combineReducers } from 'redux'; + +import * as ActionTypes from '../actions'; // Creates a reducer managing pagination, given the action types to handle, // and a function telling how to extract the key from an action. @@ -69,4 +73,59 @@ const paginate = ({ types, mapActionToKey }) => { }; }; -export default paginate; +// Updates an entity cache in response to any action with response.entities. +export const entities = ( + state = { + users: {}, + repos: {}, + events: {}, + }, + action +) => { + if (action.response && action.response.entities) { + return merge({}, state, action.response.entities); + } + + return state; +}; + +// Updates error message to notify about the failed fetches. +export const errorMessage = (state = null, action) => { + const { type, error } = action; + + if (type === ActionTypes.RESET_ERROR_MESSAGE) { + return null; + } else if (error) { + return error; + } + + return state; +}; + +// Updates the pagination data for different actions. +export const pagination = combineReducers({ + starredByUser: paginate({ + mapActionToKey: action => action.login, + types: [ + ActionTypes.STARRED_REQUEST, + ActionTypes.STARRED_SUCCESS, + ActionTypes.STARRED_FAILURE, + ], + }), + followersByUser: paginate({ + mapActionToKey: action => action.login, + types: [ + ActionTypes.FOLLOWERS_REQUEST, + ActionTypes.FOLLOWERS_SUCCESS, + ActionTypes.FOLLOWERS_FAILURE, + ], + }), + stargazersByRepo: paginate({ + mapActionToKey: action => action.fullName, + types: [ + ActionTypes.STARGAZERS_REQUEST, + ActionTypes.STARGAZERS_SUCCESS, + ActionTypes.STARGAZERS_FAILURE, + ], + }), +}); diff --git a/src/api/index.js b/src/api/index.js index 4bb2af288..4ff6ac51b 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,2 +1,3 @@ export * from './api.client'; export * from './api.middleware'; +export * from './api.reducer'; diff --git a/src/reducers/index.js b/src/reducers/index.js deleted file mode 100644 index 5f11093ee..000000000 --- a/src/reducers/index.js +++ /dev/null @@ -1,63 +0,0 @@ -import merge from 'lodash/merge'; -import { combineReducers } from 'redux'; - -import paginate from './paginate'; - -import * as ActionTypes from '../actions'; - -// Updates an entity cache in response to any action with response.entities. -export const entities = ( - state = { - users: {}, - repos: {}, - events: {}, - }, - action -) => { - if (action.response && action.response.entities) { - return merge({}, state, action.response.entities); - } - - return state; -}; - -// Updates error message to notify about the failed fetches. -export const errorMessage = (state = null, action) => { - const { type, error } = action; - - if (type === ActionTypes.RESET_ERROR_MESSAGE) { - return null; - } else if (error) { - return error; - } - - return state; -}; - -// Updates the pagination data for different actions. -export const pagination = combineReducers({ - starredByUser: paginate({ - mapActionToKey: action => action.login, - types: [ - ActionTypes.STARRED_REQUEST, - ActionTypes.STARRED_SUCCESS, - ActionTypes.STARRED_FAILURE, - ], - }), - followersByUser: paginate({ - mapActionToKey: action => action.login, - types: [ - ActionTypes.FOLLOWERS_REQUEST, - ActionTypes.FOLLOWERS_SUCCESS, - ActionTypes.FOLLOWERS_FAILURE, - ], - }), - stargazersByRepo: paginate({ - mapActionToKey: action => action.fullName, - types: [ - ActionTypes.STARGAZERS_REQUEST, - ActionTypes.STARGAZERS_SUCCESS, - ActionTypes.STARGAZERS_FAILURE, - ], - }), -}); From ba121a01900d2d49871bc0bf8a6bf031a8f3517c Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 12:26:03 +0100 Subject: [PATCH 04/12] get rid of actions/ --- src/actions/index.js | 95 ------------------------ src/api/api.actions.js | 10 +++ src/api/api.reducer.js | 3 +- src/auth/screens/login.screen.js | 2 +- src/repository/repository.type.js | 4 + src/user/screens/follower-list.screen.js | 2 +- src/user/screens/profile.screen.js | 4 +- src/user/user.action.js | 54 ++++++++++++++ src/user/user.type.js | 8 ++ 9 files changed, 83 insertions(+), 99 deletions(-) delete mode 100644 src/actions/index.js create mode 100644 src/api/api.actions.js diff --git a/src/actions/index.js b/src/actions/index.js deleted file mode 100644 index 19555998e..000000000 --- a/src/actions/index.js +++ /dev/null @@ -1,95 +0,0 @@ -import { CALL_API, Schemas } from '../api/api.middleware'; - -export const STARRED_REQUEST = 'STARRED_REQUEST'; -export const STARRED_SUCCESS = 'STARRED_SUCCESS'; -export const STARRED_FAILURE = 'STARRED_FAILURE'; - -// Fetches a page of starred repos by a particular user. -// Relies on the custom API middleware defined in ../middleware/api.js. -const fetchStarred = (login, nextPageUrl) => ({ - login, - [CALL_API]: { - types: [STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE], - endpoint: nextPageUrl, - schema: Schemas.REPO_ARRAY, - }, -}); - -// Fetches a page of starred repos by a particular user. -// Bails out if page is cached and user didn't specifically request next page. -// Relies on Redux Thunk middleware. -export const loadStarred = (login, nextPage) => (dispatch, getState) => { - const { nextPageUrl = `users/${login}/starred`, pageCount = 0 } = - getState().pagination.starredByUser[login] || {}; - - if (pageCount > 0 && !nextPage) { - return null; - } - - return dispatch(fetchStarred(login, nextPageUrl)); -}; - -export const FOLLOWERS_REQUEST = 'FOLLOWERS_REQUEST'; -export const FOLLOWERS_SUCCESS = 'FOLLOWERS_SUCCESS'; -export const FOLLOWERS_FAILURE = 'FOLLOWERS_FAILURE'; - -// Fetches a page of starred repos by a particular user. -// Relies on the custom API middleware defined in ../middleware/api.js. -const fetchFollowers = (login, nextPageUrl) => ({ - login, - [CALL_API]: { - types: [FOLLOWERS_REQUEST, FOLLOWERS_SUCCESS, FOLLOWERS_FAILURE], - endpoint: nextPageUrl, - schema: Schemas.USER_ARRAY, - }, -}); - -// Fetches a page of followers repos by a particular user. -// Bails out if page is cached and user didn't specifically request next page. -// Relies on Redux Thunk middleware. -export const loadFollowers = (login, nextPage) => (dispatch, getState) => { - const { nextPageUrl = `users/${login}/followers`, pageCount = 0 } = - getState().pagination.followersByUser[login] || {}; - - if ((pageCount > 0 && !nextPage) || !nextPageUrl) { - return null; - } - - return dispatch(fetchFollowers(login, nextPageUrl)); -}; - -export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST'; -export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS'; -export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE'; - -// Fetches a page of stargazers for a particular repo. -// Relies on the custom API middleware defined in ../middleware/api.js. -const fetchStargazers = (fullName, nextPageUrl) => ({ - fullName, - [CALL_API]: { - types: [STARGAZERS_REQUEST, STARGAZERS_SUCCESS, STARGAZERS_FAILURE], - endpoint: nextPageUrl, - schema: Schemas.USER_ARRAY, - }, -}); - -// Fetches a page of stargazers for a particular repo. -// Bails out if page is cached and user didn't specifically request next page. -// Relies on Redux Thunk middleware. -export const loadStargazers = (fullName, nextPage) => (dispatch, getState) => { - const { nextPageUrl = `repos/${fullName}/stargazers`, pageCount = 0 } = - getState().pagination.stargazersByRepo[fullName] || {}; - - if (pageCount > 0 && !nextPage) { - return null; - } - - return dispatch(fetchStargazers(fullName, nextPageUrl)); -}; - -export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE'; - -// Resets the currently visible error message. -export const resetErrorMessage = () => ({ - type: RESET_ERROR_MESSAGE, -}); diff --git a/src/api/api.actions.js b/src/api/api.actions.js new file mode 100644 index 000000000..e19555c3c --- /dev/null +++ b/src/api/api.actions.js @@ -0,0 +1,10 @@ +export * from '../event/event.type'; +export * from '../repository/repository.type'; +export * from '../user/user.type'; + +export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE'; + +// Resets the currently visible error message. +export const resetErrorMessage = () => ({ + type: RESET_ERROR_MESSAGE, +}); diff --git a/src/api/api.reducer.js b/src/api/api.reducer.js index c27a41897..4256d2bf9 100644 --- a/src/api/api.reducer.js +++ b/src/api/api.reducer.js @@ -2,11 +2,12 @@ import merge from 'lodash/merge'; import union from 'lodash/union'; import { combineReducers } from 'redux'; -import * as ActionTypes from '../actions'; +import * as ActionTypes from './api.actions'; // Creates a reducer managing pagination, given the action types to handle, // and a function telling how to extract the key from an action. const paginate = ({ types, mapActionToKey }) => { + console.log('first types', types); if (!Array.isArray(types) || types.length !== 3) { throw new Error('Expected types to be an array of three elements.'); } diff --git a/src/auth/screens/login.screen.js b/src/auth/screens/login.screen.js index 0b8685384..ef2955997 100644 --- a/src/auth/screens/login.screen.js +++ b/src/auth/screens/login.screen.js @@ -8,9 +8,9 @@ import queryString from 'query-string'; import { ViewContainer, LoadingContainer } from 'components'; import { colors, fonts, normalize } from 'config'; -import { CLIENT_ID } from '../../api/api.client'; import { auth } from 'auth'; import { openURLInView, translate } from 'utils'; +import { CLIENT_ID } from '../../api/api.client'; const stateRandom = Math.random().toString(); diff --git a/src/repository/repository.type.js b/src/repository/repository.type.js index f9bc841e1..6f584ae1b 100644 --- a/src/repository/repository.type.js +++ b/src/repository/repository.type.js @@ -28,3 +28,7 @@ export const GET_REPOSITORY_SUBSCRIBED_STATUS = createActionSet( export const REPO_REQUEST = 'REPO_REQUEST'; export const REPO_SUCCESS = 'REPO_SUCCESS'; export const REPO_FAILURE = 'REPO_FAILURE'; + +export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST'; +export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS'; +export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE'; diff --git a/src/user/screens/follower-list.screen.js b/src/user/screens/follower-list.screen.js index 321be7497..bc527461c 100644 --- a/src/user/screens/follower-list.screen.js +++ b/src/user/screens/follower-list.screen.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { FlatList, View } from 'react-native'; import { ViewContainer, UserListItem, LoadingUserListItem } from 'components'; -import { loadFollowers } from './../../actions'; +import { loadFollowers } from '../user.action'; const mapStateToProps = (state, ownProps) => { const login = ownProps.navigation.state.params.user.login.toLowerCase(); diff --git a/src/user/screens/profile.screen.js b/src/user/screens/profile.screen.js index 160062bbb..b769416cd 100644 --- a/src/user/screens/profile.screen.js +++ b/src/user/screens/profile.screen.js @@ -28,8 +28,10 @@ import { getIsFollowing, getIsFollower, changeFollowStatus, + // new api + loadStarred, + loadFollowers, } from '../user.action'; -import { loadStarred, loadFollowers } from './../../actions'; const loadData = ({ login, loadUser, loadStarred }) => { loadUser(login, ['name']); diff --git a/src/user/user.action.js b/src/user/user.action.js index c7ddc0569..328ef013d 100644 --- a/src/user/user.action.js +++ b/src/user/user.action.js @@ -25,6 +25,12 @@ import { USER_REQUEST, USER_SUCCESS, USER_FAILURE, + STARRED_REQUEST, + STARRED_SUCCESS, + STARRED_FAILURE, + FOLLOWERS_REQUEST, + FOLLOWERS_SUCCESS, + FOLLOWERS_FAILURE, } from './user.type'; import { CALL_API, Schemas } from '../api/api.middleware'; @@ -284,3 +290,51 @@ export const loadUser = (login, requiredFields = []) => ( return dispatch(_fetchUser(login)); }; + +const fetchFollowers = (login, nextPageUrl) => ({ + login, + [CALL_API]: { + types: [FOLLOWERS_REQUEST, FOLLOWERS_SUCCESS, FOLLOWERS_FAILURE], + endpoint: nextPageUrl, + schema: Schemas.USER_ARRAY, + }, +}); + +// Fetches a page of followers repos by a particular user. +// Bails out if page is cached and user didn't specifically request next page. +// Relies on Redux Thunk middleware. +export const loadFollowers = (login, nextPage) => (dispatch, getState) => { + const { nextPageUrl = `users/${login}/followers`, pageCount = 0 } = + getState().pagination.followersByUser[login] || {}; + + if ((pageCount > 0 && !nextPage) || !nextPageUrl) { + return null; + } + + return dispatch(fetchFollowers(login, nextPageUrl)); +}; + +// Fetches a page of starred repos by a particular user. +// Relies on the custom API middleware defined in ../middleware/api.js. +const fetchStarred = (login, nextPageUrl) => ({ + login, + [CALL_API]: { + types: [STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE], + endpoint: nextPageUrl, + schema: Schemas.REPO_ARRAY, + }, +}); + +// Fetches a page of starred repos by a particular user. +// Bails out if page is cached and user didn't specifically request next page. +// Relies on Redux Thunk middleware. +export const loadStarred = (login, nextPage) => (dispatch, getState) => { + const { nextPageUrl = `users/${login}/starred`, pageCount = 0 } = + getState().pagination.starredByUser[login] || {}; + + if (pageCount > 0 && !nextPage) { + return null; + } + + return dispatch(fetchStarred(login, nextPageUrl)); +}; diff --git a/src/user/user.type.js b/src/user/user.type.js index 0a0da8374..9cb984908 100644 --- a/src/user/user.type.js +++ b/src/user/user.type.js @@ -15,3 +15,11 @@ export const GET_STAR_COUNT = createActionSet('GET_STAR_COUNT'); export const USER_REQUEST = 'USER_REQUEST'; export const USER_SUCCESS = 'USER_SUCCESS'; export const USER_FAILURE = 'USER_FAILURE'; + +export const STARRED_REQUEST = 'STARRED_REQUEST'; +export const STARRED_SUCCESS = 'STARRED_SUCCESS'; +export const STARRED_FAILURE = 'STARRED_FAILURE'; + +export const FOLLOWERS_REQUEST = 'FOLLOWERS_REQUEST'; +export const FOLLOWERS_SUCCESS = 'FOLLOWERS_SUCCESS'; +export const FOLLOWERS_FAILURE = 'FOLLOWERS_FAILURE'; From 623fdf448fa8834cf72a25b80aa6b33993d84a5e Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 12:27:15 +0100 Subject: [PATCH 05/12] Those are types, not actions --- src/api/api.actions.js | 10 ---------- src/api/api.reducer.js | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 src/api/api.actions.js diff --git a/src/api/api.actions.js b/src/api/api.actions.js deleted file mode 100644 index e19555c3c..000000000 --- a/src/api/api.actions.js +++ /dev/null @@ -1,10 +0,0 @@ -export * from '../event/event.type'; -export * from '../repository/repository.type'; -export * from '../user/user.type'; - -export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE'; - -// Resets the currently visible error message. -export const resetErrorMessage = () => ({ - type: RESET_ERROR_MESSAGE, -}); diff --git a/src/api/api.reducer.js b/src/api/api.reducer.js index 4256d2bf9..58edc0d90 100644 --- a/src/api/api.reducer.js +++ b/src/api/api.reducer.js @@ -2,7 +2,7 @@ import merge from 'lodash/merge'; import union from 'lodash/union'; import { combineReducers } from 'redux'; -import * as ActionTypes from './api.actions'; +import * as ActionTypes from './api.type'; // Creates a reducer managing pagination, given the action types to handle, // and a function telling how to extract the key from an action. From d61b23a1c27e63372aef954e6e9d2a080e258a72 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 13:10:25 +0100 Subject: [PATCH 06/12] Use actions sets --- src/api/api.middleware.js | 21 ++++++++++---- src/api/api.reducer.js | 23 ++++++++-------- src/api/api.type.js | 10 +++++++ src/auth/screens/events.screen.js | 11 ++------ src/components/user-list-item.component.js | 2 -- src/event/event.action.js | 6 ++-- src/event/event.type.js | 6 ++-- src/repository/repository.action.js | 32 +++++++++++++++++++--- src/repository/repository.type.js | 9 ++---- src/user/screens/profile.screen.js | 2 -- src/user/user.action.js | 18 ++++-------- src/user/user.type.js | 14 ++-------- 12 files changed, 86 insertions(+), 68 deletions(-) create mode 100644 src/api/api.type.js diff --git a/src/api/api.middleware.js b/src/api/api.middleware.js index 2b0b285d3..236d19c37 100644 --- a/src/api/api.middleware.js +++ b/src/api/api.middleware.js @@ -1,3 +1,5 @@ +import has from 'lodash/has'; + import { userSchema } from '../user/user.schema'; import { repoSchema } from '../repository/repository.schema'; import { eventSchema } from '../event/event.schema'; @@ -25,8 +27,8 @@ export default store => next => action => { return next(action); } - let { endpoint } = callAPI; - const { schema, types } = callAPI; + let { endpoint, types } = callAPI; + const { schema } = callAPI; if (typeof endpoint === 'function') { endpoint = endpoint(store.getState()); @@ -38,10 +40,19 @@ export default store => next => action => { if (!schema) { throw new Error('Specify one of the exported Schemas.'); } - if (!Array.isArray(types) || types.length !== 3) { + + if (typeof types === 'object') { + if ( + !has(types, 'PENDING') || + !has(types, 'SUCCESS') || + !has(types, 'ERROR') + ) { + throw new Error('Expected an object containing the three action types.'); + } + types = [types.PENDING, types.SUCCESS, types.ERROR]; + } else if (!Array.isArray(types) || types.length !== 3) { throw new Error('Expected an array of three action types.'); - } - if (!types.every(type => typeof type === 'string')) { + } else if (!types.every(type => typeof type === 'string')) { throw new Error('Expected action types to be strings.'); } diff --git a/src/api/api.reducer.js b/src/api/api.reducer.js index 58edc0d90..61b8722ee 100644 --- a/src/api/api.reducer.js +++ b/src/api/api.reducer.js @@ -7,7 +7,8 @@ import * as ActionTypes from './api.type'; // Creates a reducer managing pagination, given the action types to handle, // and a function telling how to extract the key from an action. const paginate = ({ types, mapActionToKey }) => { - console.log('first types', types); + console.log('paginate()', types); + if (!Array.isArray(types) || types.length !== 3) { throw new Error('Expected types to be an array of three elements.'); } @@ -19,7 +20,7 @@ const paginate = ({ types, mapActionToKey }) => { } const [requestType, successType, failureType] = types; - console.log('types', types); + const updatePagination = ( state = { isFetching: false, @@ -108,25 +109,25 @@ export const pagination = combineReducers({ starredByUser: paginate({ mapActionToKey: action => action.login, types: [ - ActionTypes.STARRED_REQUEST, - ActionTypes.STARRED_SUCCESS, - ActionTypes.STARRED_FAILURE, + ActionTypes.STARRED.PENDING, + ActionTypes.STARRED.SUCCESS, + ActionTypes.STARRED.ERROR, ], }), followersByUser: paginate({ mapActionToKey: action => action.login, types: [ - ActionTypes.FOLLOWERS_REQUEST, - ActionTypes.FOLLOWERS_SUCCESS, - ActionTypes.FOLLOWERS_FAILURE, + ActionTypes.FOLLOWERS.PENDING, + ActionTypes.FOLLOWERS.SUCCESS, + ActionTypes.FOLLOWERS.ERROR, ], }), stargazersByRepo: paginate({ mapActionToKey: action => action.fullName, types: [ - ActionTypes.STARGAZERS_REQUEST, - ActionTypes.STARGAZERS_SUCCESS, - ActionTypes.STARGAZERS_FAILURE, + ActionTypes.STARGAZERS.PENDING, + ActionTypes.STARGAZERS.SUCCESS, + ActionTypes.STARGAZERS.ERROR, ], }), }); diff --git a/src/api/api.type.js b/src/api/api.type.js new file mode 100644 index 000000000..e19555c3c --- /dev/null +++ b/src/api/api.type.js @@ -0,0 +1,10 @@ +export * from '../event/event.type'; +export * from '../repository/repository.type'; +export * from '../user/user.type'; + +export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE'; + +// Resets the currently visible error message. +export const resetErrorMessage = () => ({ + type: RESET_ERROR_MESSAGE, +}); diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index 938ec1095..f15062234 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -5,17 +5,16 @@ import { connect } from 'react-redux'; import { StyleSheet, Text, FlatList, View } from 'react-native'; import moment from 'moment/min/moment-with-locales.min'; import { denormalize, schema } from 'normalizr'; +import values from 'lodash/values'; import { LoadingUserListItem, UserListItem, ViewContainer } from 'components'; import { colors, fonts, normalize } from 'config'; import { emojifyText, translate } from 'utils'; -import { loadUser } from '../../user/user.action'; import { loadEvents } from '../../event/event.action'; import { eventSchema } from '../../event/event.schema'; -import values from 'lodash/values'; + const loadData = ({ user, getEvents }) => { - console.log('called events', user, getEvents); getEvents(user.login); }; @@ -24,8 +23,6 @@ const mapStateToProps = (state, ownProps) => { // Have a look at ../middleware/api.js for more details. const { entities: { users, events } } = state; - console.log('Got ', state.auth.isPendingEvents, ' events'); - return { user: state.auth.user, // userEvents: state.auth.events, @@ -90,7 +87,6 @@ const styles = StyleSheet.create({ class Events extends Component { componentDidMount() { - console.log('componentDidMount', this.props); if (this.props.user.login) { loadData(this.props); } @@ -506,11 +502,8 @@ class Events extends Component { { events: [eventSchema] }, this.props.userEvents ); - console.log(userEvent); const user = this.props.users[userEvent.actor]; - console.log(user); - // return (Description); return ( diff --git a/src/components/user-list-item.component.js b/src/components/user-list-item.component.js index 45fe1fc17..74d7a9ae3 100644 --- a/src/components/user-list-item.component.js +++ b/src/components/user-list-item.component.js @@ -103,8 +103,6 @@ class UserListItemComponent extends Component { const userScreen = authUser.login === user.login ? 'AuthProfile' : 'Profile'; - console.log(user); - return ( diff --git a/src/event/event.action.js b/src/event/event.action.js index 2982dfd6e..1c4e1215c 100644 --- a/src/event/event.action.js +++ b/src/event/event.action.js @@ -1,6 +1,6 @@ import has from 'lodash/has'; -import { EVENTS_REQUEST, EVENTS_SUCCESS, EVENTS_FAILURE } from './event.type'; +import { EVENTS } from './event.type'; import { CALL_API, Schemas } from '../api/api.middleware'; @@ -8,7 +8,7 @@ import { CALL_API, Schemas } from '../api/api.middleware'; const _fetchEvents = login => ({ [CALL_API]: { - types: [EVENTS_REQUEST, EVENTS_SUCCESS, EVENTS_FAILURE], + types: EVENTS, endpoint: `users/${login}/received_events`, schema: Schemas.EVENT_ARRAY, }, @@ -20,6 +20,8 @@ export const loadEvents = (login, requiredFields = []) => ( ) => { const event = getState().entities.events[login]; + console.log(EVENTS); + if (event && requiredFields.every(key => has(event, key))) { return null; } diff --git a/src/event/event.type.js b/src/event/event.type.js index 9a671ea55..d61473562 100644 --- a/src/event/event.type.js +++ b/src/event/event.type.js @@ -1,3 +1,3 @@ -export const EVENTS_REQUEST = 'EVENTS_REQUEST'; -export const EVENTS_SUCCESS = 'EVENTS_SUCCESS'; -export const EVENTS_FAILURE = 'EVENTS_FAILURE'; +import { createActionSet } from 'utils'; + +export const EVENTS = createActionSet('EVENTS'); diff --git a/src/repository/repository.action.js b/src/repository/repository.action.js index 179053343..913cc8939 100644 --- a/src/repository/repository.action.js +++ b/src/repository/repository.action.js @@ -31,9 +31,8 @@ import { SEARCH_OPEN_PULLS, SEARCH_CLOSED_PULLS, GET_REPOSITORY_SUBSCRIBED_STATUS, - REPO_FAILURE, - REPO_REQUEST, - REPO_SUCCESS, + REPO, + STARGAZERS, } from './repository.type'; import { CALL_API, Schemas } from '../api/api.middleware'; @@ -498,7 +497,7 @@ export const searchClosedRepoPulls = (query, repoFullName) => { // Relies on the custom API middleware defined in ../middleware/api.js. const fetchRepo = fullName => ({ [CALL_API]: { - types: [REPO_REQUEST, REPO_SUCCESS, REPO_FAILURE], + types: REPO, endpoint: `repos/${fullName}`, schema: Schemas.REPO, }, @@ -518,3 +517,28 @@ export const loadRepo = (fullName, requiredFields = []) => ( return dispatch(fetchRepo(fullName)); }; + +// Fetches a page of stargazers for a particular repo. +// Relies on the custom API middleware defined in ../middleware/api.js. +const fetchStargazers = (fullName, nextPageUrl) => ({ + fullName, + [CALL_API]: { + types: STARGAZERS, + endpoint: nextPageUrl, + schema: Schemas.USER_ARRAY, + }, +}); + +// Fetches a page of stargazers for a particular repo. +// Bails out if page is cached and user didn't specifically request next page. +// Relies on Redux Thunk middleware. +export const loadStargazers = (fullName, nextPage) => (dispatch, getState) => { + const { nextPageUrl = `repos/${fullName}/stargazers`, pageCount = 0 } = + getState().pagination.stargazersByRepo[fullName] || {}; + + if (pageCount > 0 && !nextPage) { + return null; + } + + return dispatch(fetchStargazers(fullName, nextPageUrl)); +}; diff --git a/src/repository/repository.type.js b/src/repository/repository.type.js index 6f584ae1b..a40859ed1 100644 --- a/src/repository/repository.type.js +++ b/src/repository/repository.type.js @@ -25,10 +25,5 @@ export const GET_REPOSITORY_SUBSCRIBED_STATUS = createActionSet( 'GET_REPOSITORY_SUBSCRIBED_STATUS' ); -export const REPO_REQUEST = 'REPO_REQUEST'; -export const REPO_SUCCESS = 'REPO_SUCCESS'; -export const REPO_FAILURE = 'REPO_FAILURE'; - -export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST'; -export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS'; -export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE'; +export const REPO = createActionSet('REPO'); +export const STARGAZERS = createActionSet('STARGAZERS'); diff --git a/src/user/screens/profile.screen.js b/src/user/screens/profile.screen.js index b769416cd..f9ad7715b 100644 --- a/src/user/screens/profile.screen.js +++ b/src/user/screens/profile.screen.js @@ -283,8 +283,6 @@ const mapStateToProps2 = (state, ownProps) => { entities: { users, repos }, } = state; - console.log('login=', login); - // const starredPagination = starredByUser[login] || { ids: [] }; //const starredRepos = starredPagination.ids.map(id => repos[id]); //const starredRepoOwners = starredRepos.map(repo => users[repo.owner]); diff --git a/src/user/user.action.js b/src/user/user.action.js index 328ef013d..db3855b9f 100644 --- a/src/user/user.action.js +++ b/src/user/user.action.js @@ -22,15 +22,9 @@ import { SEARCH_USER_REPOS, CHANGE_FOLLOW_STATUS, GET_STAR_COUNT, - USER_REQUEST, - USER_SUCCESS, - USER_FAILURE, - STARRED_REQUEST, - STARRED_SUCCESS, - STARRED_FAILURE, - FOLLOWERS_REQUEST, - FOLLOWERS_SUCCESS, - FOLLOWERS_FAILURE, + USER, + STARRED, + FOLLOWERS, } from './user.type'; import { CALL_API, Schemas } from '../api/api.middleware'; @@ -270,7 +264,7 @@ export const searchUserRepos = (query, user) => { // Relies on the custom API middleware defined in ../middleware/api.js. const _fetchUser = login => ({ [CALL_API]: { - types: [USER_REQUEST, USER_SUCCESS, USER_FAILURE], + types: USER, // [USER.REQUEST, USER.SUCCESS, USER.FAILURE], endpoint: `users/${login}`, schema: Schemas.USER, }, @@ -294,7 +288,7 @@ export const loadUser = (login, requiredFields = []) => ( const fetchFollowers = (login, nextPageUrl) => ({ login, [CALL_API]: { - types: [FOLLOWERS_REQUEST, FOLLOWERS_SUCCESS, FOLLOWERS_FAILURE], + types: FOLLOWERS, endpoint: nextPageUrl, schema: Schemas.USER_ARRAY, }, @@ -319,7 +313,7 @@ export const loadFollowers = (login, nextPage) => (dispatch, getState) => { const fetchStarred = (login, nextPageUrl) => ({ login, [CALL_API]: { - types: [STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE], + types: STARRED, endpoint: nextPageUrl, schema: Schemas.REPO_ARRAY, }, diff --git a/src/user/user.type.js b/src/user/user.type.js index 9cb984908..518838049 100644 --- a/src/user/user.type.js +++ b/src/user/user.type.js @@ -12,14 +12,6 @@ export const CHANGE_FOLLOW_STATUS = createActionSet('CHANGE_FOLLOW_STATUS'); export const GET_STAR_COUNT = createActionSet('GET_STAR_COUNT'); // New API -export const USER_REQUEST = 'USER_REQUEST'; -export const USER_SUCCESS = 'USER_SUCCESS'; -export const USER_FAILURE = 'USER_FAILURE'; - -export const STARRED_REQUEST = 'STARRED_REQUEST'; -export const STARRED_SUCCESS = 'STARRED_SUCCESS'; -export const STARRED_FAILURE = 'STARRED_FAILURE'; - -export const FOLLOWERS_REQUEST = 'FOLLOWERS_REQUEST'; -export const FOLLOWERS_SUCCESS = 'FOLLOWERS_SUCCESS'; -export const FOLLOWERS_FAILURE = 'FOLLOWERS_FAILURE'; +export const USER = createActionSet('USER'); +export const STARRED = createActionSet('STARRED'); +export const FOLLOWERS = createActionSet('FOLLOWERS'); From b7668e189ed4008e3363aac07f2d8cd1bc2712fb Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 13:16:23 +0100 Subject: [PATCH 07/12] refactor(api): create api.schema --- src/api/api.middleware.js | 13 ------------- src/api/api.schema.js | 13 +++++++++++++ src/event/event.action.js | 6 ++---- src/repository/repository.action.js | 3 ++- src/user/user.action.js | 3 ++- 5 files changed, 19 insertions(+), 19 deletions(-) create mode 100644 src/api/api.schema.js diff --git a/src/api/api.middleware.js b/src/api/api.middleware.js index 236d19c37..fbf8c65c1 100644 --- a/src/api/api.middleware.js +++ b/src/api/api.middleware.js @@ -1,20 +1,7 @@ import has from 'lodash/has'; -import { userSchema } from '../user/user.schema'; -import { repoSchema } from '../repository/repository.schema'; -import { eventSchema } from '../event/event.schema'; import { callApi } from '../api/api.client'; -// Schemas for Github API responses. -export const Schemas = { - EVENT: eventSchema, - EVENT_ARRAY: [eventSchema], - USER: userSchema, - USER_ARRAY: [userSchema], - REPO: repoSchema, - REPO_ARRAY: [repoSchema], -}; - // Action key that carries API call info interpreted by this Redux middleware. export const CALL_API = 'Call API'; diff --git a/src/api/api.schema.js b/src/api/api.schema.js new file mode 100644 index 000000000..f48defd6a --- /dev/null +++ b/src/api/api.schema.js @@ -0,0 +1,13 @@ +import { userSchema } from '../user/user.schema'; +import { repoSchema } from '../repository/repository.schema'; +import { eventSchema } from '../event/event.schema'; + +// Schemas for Github API responses. +export const Schemas = { + EVENT: eventSchema, + EVENT_ARRAY: [eventSchema], + USER: userSchema, + USER_ARRAY: [userSchema], + REPO: repoSchema, + REPO_ARRAY: [repoSchema], +}; diff --git a/src/event/event.action.js b/src/event/event.action.js index 1c4e1215c..88c7b4a6c 100644 --- a/src/event/event.action.js +++ b/src/event/event.action.js @@ -1,8 +1,8 @@ import has from 'lodash/has'; import { EVENTS } from './event.type'; - -import { CALL_API, Schemas } from '../api/api.middleware'; +import { Schemas } from './../api/api.schema'; +import { CALL_API } from '../api/api.middleware'; /** NEW API */ @@ -20,8 +20,6 @@ export const loadEvents = (login, requiredFields = []) => ( ) => { const event = getState().entities.events[login]; - console.log(EVENTS); - if (event && requiredFields.every(key => has(event, key))) { return null; } diff --git a/src/repository/repository.action.js b/src/repository/repository.action.js index 913cc8939..19858ff43 100644 --- a/src/repository/repository.action.js +++ b/src/repository/repository.action.js @@ -35,7 +35,8 @@ import { STARGAZERS, } from './repository.type'; -import { CALL_API, Schemas } from '../api/api.middleware'; +import { CALL_API } from '../api/api.middleware'; +import { Schemas } from '../api/api.schema'; export const getRepository = url => { return (dispatch, getState) => { diff --git a/src/user/user.action.js b/src/user/user.action.js index db3855b9f..6b08204e9 100644 --- a/src/user/user.action.js +++ b/src/user/user.action.js @@ -27,7 +27,8 @@ import { FOLLOWERS, } from './user.type'; -import { CALL_API, Schemas } from '../api/api.middleware'; +import { CALL_API } from '../api/api.middleware'; +import { Schemas } from '../api/api.schema'; const getUser = user => { return (dispatch, getState) => { From c05193b6e8150d61d65106490049af56a5c853e3 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 13:40:03 +0100 Subject: [PATCH 08/12] fix events screen --- src/auth/screens/events.screen.js | 21 +++++++++------------ src/components/user-profile.component.js | 14 +++----------- src/user/screens/profile.screen.js | 17 +++++------------ 3 files changed, 17 insertions(+), 35 deletions(-) diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index f15062234..1a506fa73 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -230,7 +230,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent)} > - {userEvent.repo.name} + {userEvent.repo} ); } @@ -251,7 +251,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent)} > - {userEvent.repo.name} + {userEvent.repo} ); case 'GollumEvent': @@ -262,7 +262,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent)} > - {userEvent.repo.name} + {userEvent.repo} {' '} wiki @@ -315,7 +315,7 @@ class Events extends Component { } }} > - {userEvent.repo.name} + {userEvent.repo} ); default: @@ -369,7 +369,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent)} > - {userEvent.repo.name} + {userEvent.repo} ); } @@ -388,7 +388,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent)} > - {userEvent.repo.name} + {userEvent.repo} ); case 'ForkEvent': @@ -488,7 +488,7 @@ class Events extends Component { navigateToProfile = (userEvent, isActor) => { this.props.navigation.navigate('Profile', { - user: !isActor ? userEvent.payload.member : userEvent.actor, + login: !isActor ? userEvent.payload.member.login : userEvent.actor, }); }; @@ -497,13 +497,10 @@ class Events extends Component { }; renderDescription(userEvent) { - userEvent = denormalize( - userEvent, - { events: [eventSchema] }, - this.props.userEvents - ); const user = this.props.users[userEvent.actor]; + console.log(userEvent); + // return (Description); return ( diff --git a/src/components/user-profile.component.js b/src/components/user-profile.component.js index ee5b5ede0..45ec88671 100644 --- a/src/components/user-profile.component.js +++ b/src/components/user-profile.component.js @@ -6,7 +6,6 @@ import { ImageZoom } from 'components'; type Props = { type: string, - initialUser: Object, user: Object, starCount: string, isFollowing: boolean, @@ -94,7 +93,6 @@ const styles = StyleSheet.create({ export const UserProfile = ({ type, - initialUser, user, starCount, isFollowing, @@ -106,21 +104,15 @@ export const UserProfile = ({ {user.name || ' '} - {initialUser.login || ' '} + {user.login || ' '} diff --git a/src/user/screens/profile.screen.js b/src/user/screens/profile.screen.js index f9ad7715b..8ce12d9d1 100644 --- a/src/user/screens/profile.screen.js +++ b/src/user/screens/profile.screen.js @@ -173,7 +173,6 @@ class Profile extends Component { navigation, } = this.props; const { refreshing } = this.state; - const initialUser = navigation.state.params.user; const isPending = isPendingUser || isPendingOrgs; const userActions = [ isFollowing @@ -193,7 +192,6 @@ class Profile extends Component { renderContent={() => } @@ -212,11 +210,7 @@ class Profile extends Component { /> } stickyTitle={user.login} - showMenu={ - !isPendingUser && - !isPendingCheckFollowing && - initialUser.login === user.login - } + showMenu={!isPendingUser && !isPendingCheckFollowing} menuAction={() => this.showMenuActionSheet()} navigateBack navigation={navigation} @@ -229,7 +223,6 @@ class Profile extends Component { />} {!isPending && - initialUser.login === user.login && {!!user.bio && user.bio !== '' && @@ -274,9 +267,9 @@ class Profile extends Component { } const mapStateToProps2 = (state, ownProps) => { - const login = ownProps.navigation.state.params.user.login - ? ownProps.navigation.state.params.user.login.toLowerCase() - : ownProps.navigation.state.params.user.toLowerCase(); + console.log('ownProps', ownProps); + + const login = ownProps.navigation.state.params.login.toLowerCase(); const { // pagination: { starredByUser }, From c9704cfd9b2ace47a0bac3997fff811ca9b8d3f0 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 13:43:11 +0100 Subject: [PATCH 09/12] event.payload.forkee is a repo --- src/event/event.schema.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/event/event.schema.js b/src/event/event.schema.js index 93cf3c3c2..a0e8ee64f 100644 --- a/src/event/event.schema.js +++ b/src/event/event.schema.js @@ -7,4 +7,5 @@ import { repoSchema } from '../repository/repository.schema'; export const eventSchema = new schema.Entity('events', { actor: userSchema, repo: repoSchema, + payload: { forkee: repoSchema }, }); From ff58481dc01aaaec28c56fad53c28119dc607197 Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 16:27:44 +0100 Subject: [PATCH 10/12] clean up --- src/auth/auth.action.js | 6 +++--- src/auth/screens/events.screen.js | 20 ++++---------------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/auth/auth.action.js b/src/auth/auth.action.js index 999ef4c88..14684acca 100644 --- a/src/auth/auth.action.js +++ b/src/auth/auth.action.js @@ -10,7 +10,7 @@ import { fetchAuthUserOrgs, fetchUserOrgs, fetchUserEvents, - fetchStarCount, + // fetchStarCount, } from '../api/api.client'; import { @@ -91,7 +91,7 @@ export const getStarCount = () => { const user = getState().auth.user.login; dispatch({ type: GET_AUTH_STAR_COUNT.PENDING }); - + /* fetchStarCount(user) .then(data => { dispatch({ @@ -104,7 +104,7 @@ export const getStarCount = () => { type: GET_AUTH_STAR_COUNT.ERROR, payload: error, }); - }); + });*/ }; }; diff --git a/src/auth/screens/events.screen.js b/src/auth/screens/events.screen.js index 1a506fa73..c4e683225 100644 --- a/src/auth/screens/events.screen.js +++ b/src/auth/screens/events.screen.js @@ -19,8 +19,6 @@ const loadData = ({ user, getEvents }) => { }; const mapStateToProps = (state, ownProps) => { - // We need to lower case the login due to the way GitHub's API behaves. - // Have a look at ../middleware/api.js for more details. const { entities: { users, events } } = state; return { @@ -283,7 +281,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToProfile(userEvent)} > - {userEvent.payload.member.login} + {userEvent.payload.member} ); case 'PullRequestEvent': @@ -397,7 +395,7 @@ class Events extends Component { style={styles.linkDescription} onPress={() => this.navigateToRepository(userEvent, true)} > - {userEvent.payload.forkee.full_name} + {userEvent.payload.forkee} ); default: @@ -465,14 +463,7 @@ class Events extends Component { navigateToRepository = (userEvent, isForkEvent) => { this.props.navigation.navigate('Repository', { - repository: !isForkEvent - ? { - ...userEvent.repo, - name: userEvent.repo.name.substring( - userEvent.repo.name.indexOf('/') + 1 - ), - } - : userEvent.payload.forkee, + name: !isForkEvent ? userEvent.repo : userEvent.payload.forkee, }); }; @@ -488,7 +479,7 @@ class Events extends Component { navigateToProfile = (userEvent, isActor) => { this.props.navigation.navigate('Profile', { - login: !isActor ? userEvent.payload.member.login : userEvent.actor, + login: !isActor ? userEvent.payload.member : userEvent.actor, }); }; @@ -499,9 +490,6 @@ class Events extends Component { renderDescription(userEvent) { const user = this.props.users[userEvent.actor]; - console.log(userEvent); - - // return (Description); return ( Date: Sat, 23 Sep 2017 17:08:19 +0100 Subject: [PATCH 11/12] User repositories fetched from new API --- src/api/api.client.js | 2 +- src/api/api.reducer.js | 8 ++ .../repository-list-item.component.js | 3 +- src/components/user-list-item.component.js | 8 +- src/event/event.schema.js | 5 +- src/repository/repository.action.js | 7 +- src/repository/screens/repository.screen.js | 76 +++++++++++-------- src/user/screens/repository-list.screen.js | 49 +++++++----- src/user/user.action.js | 31 +++++--- src/user/user.type.js | 1 + 10 files changed, 124 insertions(+), 66 deletions(-) diff --git a/src/api/api.client.js b/src/api/api.client.js index 5382b97e9..56c2f90d5 100644 --- a/src/api/api.client.js +++ b/src/api/api.client.js @@ -460,7 +460,7 @@ export const callApi = (endpoint, theSchema, accessToken) => { const fullUrl = endpoint.indexOf(API_ROOT) === -1 ? API_ROOT + endpoint : endpoint; - console.log(`[New API] Calling ${accessToken} ${fullUrl}`); + console.log(`[New API] Calling ${fullUrl}`); return fetch(fullUrl, { headers: { diff --git a/src/api/api.reducer.js b/src/api/api.reducer.js index 61b8722ee..d53921d03 100644 --- a/src/api/api.reducer.js +++ b/src/api/api.reducer.js @@ -122,6 +122,14 @@ export const pagination = combineReducers({ ActionTypes.FOLLOWERS.ERROR, ], }), + reposByUser: paginate({ + mapActionToKey: action => action.login, + types: [ + ActionTypes.USER_REPOS.PENDING, + ActionTypes.USER_REPOS.SUCCESS, + ActionTypes.USER_REPOS.ERROR, + ], + }), stargazersByRepo: paginate({ mapActionToKey: action => action.fullName, types: [ diff --git a/src/components/repository-list-item.component.js b/src/components/repository-list-item.component.js index c1d3c0f08..856acb2b3 100644 --- a/src/components/repository-list-item.component.js +++ b/src/components/repository-list-item.component.js @@ -108,5 +108,6 @@ export const RepositoryListItem = ({ repository, navigation }: Props) => type: 'octicon', }} underlayColor={colors.greyLight} - onPress={() => navigation.navigate('Repository', { repository })} + onPress={() => + navigation.navigate('Repository', { name: repository.full_name })} />; diff --git a/src/components/user-list-item.component.js b/src/components/user-list-item.component.js index 74d7a9ae3..2199b72df 100644 --- a/src/components/user-list-item.component.js +++ b/src/components/user-list-item.component.js @@ -108,7 +108,9 @@ class UserListItemComponent extends Component { onPress={() => navigation.navigate( user.type === 'User' ? userScreen : 'Organization', - user.type === 'User' ? { user } : { organization: user } + user.type === 'User' + ? { login: user.login } + : { organization: user } )} underlayColor={colors.greyLight} style={!noBorderBottom && styles.borderContainer} @@ -118,14 +120,14 @@ class UserListItemComponent extends Component { style={styles.userInfo} onPress={() => navigation.navigate(userScreen, { - user, + login: user.login, })} > navigation.navigate(userScreen, { - user, + login: user.login, })} > (dispatch, getState) => { }); }; -export const getRepositoryInfo = url => { +export const getRepositoryInfo = name => { return (dispatch, getState) => { - return dispatch(getRepository(url)).then(() => { + console.log('repo name', name); + return dispatch(loadRepo(name)).then(() => { const repo = getState().repository.repository; const contributorsUrl = getState().repository.repository.contributors_url; + + console.log(repo, getState().repository); const issuesUrl = getState().repository.repository.issues_url.replace( '{/number}', '?state=all&per_page=100' diff --git a/src/repository/screens/repository.screen.js b/src/repository/screens/repository.screen.js index 311224037..3cd3a81b4 100644 --- a/src/repository/screens/repository.screen.js +++ b/src/repository/screens/repository.screen.js @@ -25,27 +25,40 @@ import { forkRepo, subscribeToRepo, unSubscribeToRepo, + // new api + loadRepo, } from '../repository.action'; -const mapStateToProps = state => ({ - username: state.auth.user.login, - language: state.auth.language, - repository: state.repository.repository, - contributors: state.repository.contributors, - issues: state.repository.issues, - starred: state.repository.starred, - forked: state.repository.forked, - subscribed: state.repository.subscribed, - hasReadMe: state.repository.hasReadMe, - isPendingRepository: state.repository.isPendingRepository, - isPendingContributors: state.repository.isPendingContributors, - isPendingIssues: state.repository.isPendingIssues, - isPendingCheckReadMe: state.repository.isPendingCheckReadMe, - isPendingCheckStarred: state.repository.isPendingCheckStarred, - isPendingFork: state.repository.isPendingFork, - isPendingSubscribe: state.repository.isPendingSubscribe, -}); +const loadData = ({ name, loadRepo }) => { + loadRepo(name); +}; + +const mapStateToProps = (state, ownProps) => { + const name = ownProps.navigation.state.params.name.toLowerCase(); + const { entities: { repos } } = state; + + return { + username: state.auth.user.login, + language: state.auth.language, + name, + repository: repos[name], + contributors: state.repository.contributors, + issues: state.repository.issues, + starred: state.repository.starred, + forked: state.repository.forked, + subscribed: state.repository.subscribed, + hasReadMe: state.repository.hasReadMe, + isPendingRepository: state.repository.isPendingRepository, + isPendingContributors: state.repository.isPendingContributors, + isPendingIssues: state.repository.isPendingIssues, + isPendingCheckReadMe: state.repository.isPendingCheckReadMe, + isPendingCheckStarred: state.repository.isPendingCheckStarred, + isPendingFork: state.repository.isPendingFork, + isPendingSubscribe: state.repository.isPendingSubscribe, + }; +}; +/* const mapDispatchToProps = dispatch => ({ getRepositoryInfoByDispatch: url => dispatch(getRepositoryInfo(url)), changeStarStatusRepoByDispatch: (owner, repo, starred) => @@ -53,7 +66,7 @@ const mapDispatchToProps = dispatch => ({ forkRepoByDispatch: (owner, repo) => dispatch(forkRepo(owner, repo)), subscribeToRepo: (owner, repo) => dispatch(subscribeToRepo(owner, repo)), unSubscribeToRepo: (owner, repo) => dispatch(unSubscribeToRepo(owner, repo)), -}); +});*/ const styles = StyleSheet.create({ listTitle: { @@ -64,9 +77,10 @@ const styles = StyleSheet.create({ class Repository extends Component { props: { - getRepositoryInfoByDispatch: Function, + name: String, + /* getRepositoryInfoByDispatch: Function, changeStarStatusRepoByDispatch: Function, - forkRepoByDispatch: Function, + forkRepoByDispatch: Function, */ // repositoryName: string, repository: Object, contributors: Array, @@ -107,7 +121,8 @@ class Repository extends Component { repositoryUrl: repoUrl, } = this.props.navigation.state.params; - this.props.getRepositoryInfoByDispatch(repo ? repo.url : repoUrl); + loadData(this.props); + //this.props.getRepositoryInfoByDispatch(repo ? repo.url : repoUrl); } showMenuActionSheet = () => { @@ -119,8 +134,8 @@ class Repository extends Component { starred, subscribed, repository, - changeStarStatusRepoByDispatch, - forkRepoByDispatch, + // changeStarStatusRepoByDispatch, + // forkRepoByDispatch, navigation, username, } = this.props; @@ -155,11 +170,10 @@ class Repository extends Component { } = this.props.navigation.state.params; this.setState({ refreshing: true }); - this.props - .getRepositoryInfoByDispatch(repo ? repo.url : repoUrl) - .then(() => { + loadData(this.props); + /*.then(() => { this.setState({ refreshing: false }); - }); + });*/ }; shareRepository = repository => { @@ -449,6 +463,6 @@ class Repository extends Component { } } -export const RepositoryScreen = connect(mapStateToProps, mapDispatchToProps)( - Repository -); +export const RepositoryScreen = connect(mapStateToProps, { + loadRepo, +})(Repository); diff --git a/src/user/screens/repository-list.screen.js b/src/user/screens/repository-list.screen.js index e9fb87279..9129afef0 100644 --- a/src/user/screens/repository-list.screen.js +++ b/src/user/screens/repository-list.screen.js @@ -9,15 +9,27 @@ import { SearchBar, } from 'components'; import { colors } from 'config'; -import { getRepositories, searchUserRepos } from 'user'; - -const mapStateToProps = state => ({ - user: state.user.user, - repositories: state.user.repositories, - searchedUserRepos: state.user.searchedUserRepos, - isPendingRepositories: state.user.isPendingRepositories, - isPendingSearchUserRepos: state.user.isPendingSearchUserRepos, -}); +import { getRepositories, searchUserRepos, loadRepos } from 'user'; + +const loadData = ({ login, getRepos }) => { + getRepos(login); +}; + +const mapStateToProps = (state, ownProps) => { + const login = ownProps.navigation.state.params.user.login.toLowerCase(); + + const { entities: { repos }, pagination: { reposByUser } } = state; + const reposPagination = reposByUser[login] || { ids: [] }; + const repositories = reposPagination.ids.map(id => repos[id]); + + return { + login, + repositories, + searchedUserRepos: state.user.searchedUserRepos, + isPendingRepositories: state.user.isPendingRepositories, + isPendingSearchUserRepos: state.user.isPendingSearchUserRepos, + }; +}; const mapDispatchToProps = dispatch => ({ getRepositoriesByDispatch: (user, type) => @@ -49,7 +61,9 @@ const styles = StyleSheet.create({ class RepositoryList extends Component { props: { - getRepositoriesByDispatch: Function, + getRepos: Function, + login: String, + // getRepositoriesByDispatch: Function, searchUserReposByDispatch: Function, user: Object, repositories: Array, @@ -79,9 +93,7 @@ class RepositoryList extends Component { } componentDidMount() { - const user = this.props.navigation.state.params.user; - - this.props.getRepositoriesByDispatch(user); + loadData(this.props); } getList = () => { @@ -111,6 +123,8 @@ class RepositoryList extends Component { render() { const { + login, + getRepos, isPendingRepositories, isPendingSearchUserRepos, navigation, @@ -159,6 +173,8 @@ class RepositoryList extends Component { repository={item} navigation={navigation} />} + onEndReachedThreshold={0.5} + onEndReached={() => getRepos(login, true)} /> } @@ -167,7 +183,6 @@ class RepositoryList extends Component { } } -export const RepositoryListScreen = connect( - mapStateToProps, - mapDispatchToProps -)(RepositoryList); +export const RepositoryListScreen = connect(mapStateToProps, { + getRepos: loadRepos, +})(RepositoryList); diff --git a/src/user/user.action.js b/src/user/user.action.js index 6b08204e9..a75adcae9 100644 --- a/src/user/user.action.js +++ b/src/user/user.action.js @@ -23,6 +23,7 @@ import { CHANGE_FOLLOW_STATUS, GET_STAR_COUNT, USER, + USER_REPOS, STARRED, FOLLOWERS, } from './user.type'; @@ -271,8 +272,6 @@ const _fetchUser = login => ({ }, }); -// Fetches a single user from Github API unless it is cached. -// Relies on Redux Thunk middleware. export const loadUser = (login, requiredFields = []) => ( dispatch, getState @@ -295,9 +294,6 @@ const fetchFollowers = (login, nextPageUrl) => ({ }, }); -// Fetches a page of followers repos by a particular user. -// Bails out if page is cached and user didn't specifically request next page. -// Relies on Redux Thunk middleware. export const loadFollowers = (login, nextPage) => (dispatch, getState) => { const { nextPageUrl = `users/${login}/followers`, pageCount = 0 } = getState().pagination.followersByUser[login] || {}; @@ -309,8 +305,26 @@ export const loadFollowers = (login, nextPage) => (dispatch, getState) => { return dispatch(fetchFollowers(login, nextPageUrl)); }; -// Fetches a page of starred repos by a particular user. -// Relies on the custom API middleware defined in ../middleware/api.js. +const fetchRepos = (login, nextPageUrl) => ({ + login, + [CALL_API]: { + types: USER_REPOS, + endpoint: nextPageUrl, + schema: Schemas.REPO_ARRAY, + }, +}); + +export const loadRepos = (login, nextPage) => (dispatch, getState) => { + const { nextPageUrl = `users/${login}/repos`, pageCount = 0 } = + getState().pagination.reposByUser[login] || {}; + + if ((pageCount > 0 && !nextPage) || !nextPageUrl) { + return null; + } + + return dispatch(fetchRepos(login, nextPageUrl)); +}; + const fetchStarred = (login, nextPageUrl) => ({ login, [CALL_API]: { @@ -320,9 +334,6 @@ const fetchStarred = (login, nextPageUrl) => ({ }, }); -// Fetches a page of starred repos by a particular user. -// Bails out if page is cached and user didn't specifically request next page. -// Relies on Redux Thunk middleware. export const loadStarred = (login, nextPage) => (dispatch, getState) => { const { nextPageUrl = `users/${login}/starred`, pageCount = 0 } = getState().pagination.starredByUser[login] || {}; diff --git a/src/user/user.type.js b/src/user/user.type.js index 518838049..f986b6ee5 100644 --- a/src/user/user.type.js +++ b/src/user/user.type.js @@ -13,5 +13,6 @@ export const GET_STAR_COUNT = createActionSet('GET_STAR_COUNT'); // New API export const USER = createActionSet('USER'); +export const USER_REPOS = createActionSet('USER_REPOS'); export const STARRED = createActionSet('STARRED'); export const FOLLOWERS = createActionSet('FOLLOWERS'); From 0bdc1daad0c3ce9870c2d117ab3324522246deea Mon Sep 17 00:00:00 2001 From: Mehdi Achour Date: Sat, 23 Sep 2017 17:56:49 +0100 Subject: [PATCH 12/12] Remove useless keys --- src/repository/repository.schema.js | 59 ++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/src/repository/repository.schema.js b/src/repository/repository.schema.js index 6e2d34209..f43e07072 100644 --- a/src/repository/repository.schema.js +++ b/src/repository/repository.schema.js @@ -6,19 +6,7 @@ import { userSchema } from '../user/user.schema'; /* export const userSchema = new schema.Entity('users', {}, { idAttribute: user => user.login.toLowerCase(), - processStrategy: entity => omit(entity, [ - 'url', - 'html_url', - 'followers_url', - 'following_url', - 'gists_url', - 'starred_url', - 'subscriptions_url', - 'organizations_url', - 'repos_url', - 'events_url', - 'received_events_url', - ]), + });*/ export const repoSchema = new schema.Entity( @@ -35,5 +23,50 @@ export const repoSchema = new schema.Entity( // In Events return repo.name.toLowerCase(); }, + processStrategy: entity => omit(entity, [ + 'url', + 'html_url', + 'url', + 'forks_url', + 'keys_url', + 'collaborators_url', + 'teams_url', + 'hooks_url', + 'issue_events_url', + 'events_url', + 'assignees_url', + 'git_url', + 'ssh_url', + 'clone_url', + 'svn_url', + 'branches_url', + 'tags_url', + 'blobs_url', + 'git_tags_url', + 'git_refs_url', + 'trees_url', + 'statuses_url', + 'languages_url', + 'stargazers_url', + 'contributors_url', + 'subscribers_url', + 'subscription_url', + 'commits_url', + 'git_commits_url', + 'comments_url', + 'issue_comment_url', + 'contents_url', + 'compare_url', + 'merges_url', + 'archive_url', + 'downloads_url', + 'issues_url', + 'pulls_url', + 'milestones_url', + 'notifications_url', + 'labels_url', + 'releases_url', + 'deployments_url', + ]), } );