├── .editorconfig ├── .env ├── .flowconfig ├── .gitignore ├── .npmrc ├── .nvmrc ├── .travis.yml ├── README.md ├── cypress.env.json.example ├── cypress.json ├── cypress ├── integration │ └── auth │ │ └── login.spec.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── flow-typed ├── custom │ └── keymirror-nested_v1.x.x.js └── npm │ ├── axios_v0.18.x.js │ ├── react-helmet_v5.x.x.js │ ├── react-redux_v5.x.x.js │ └── redux_v4.x.x.js ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── app │ ├── article │ │ ├── addComment.js │ │ ├── createArticle.js │ │ ├── editArticle.js │ │ ├── getArticle.js │ │ ├── getAuthorFavoritesFeed.js │ │ ├── getAuthorFeed.js │ │ ├── getGlobalFeed.js │ │ ├── getTagFeed.js │ │ ├── getUserFeed.js │ │ ├── removeArticle.js │ │ ├── removeComment.js │ │ └── toggleArticleFavoriteStatus.js │ ├── author │ │ ├── getAuthor.js │ │ └── toggleAuthorFollowStatus.js │ ├── tag │ │ └── getPopularTags.js │ └── user │ │ ├── __tests__ │ │ ├── registerUser.test.js │ │ └── signInUser.test.js │ │ ├── changeSettings.js │ │ ├── getUser.js │ │ ├── registerUser.js │ │ └── signInUser.js ├── container.js ├── domain │ ├── article │ │ └── index.js │ ├── author │ │ └── index.js │ ├── tag │ │ └── index.js │ └── user │ │ └── index.js ├── index.js ├── infra │ ├── article │ │ ├── articleRepository.js │ │ └── commentRepository.js │ ├── author │ │ └── authorRepository.js │ ├── cache │ │ └── index.js │ ├── conduit │ │ └── conduitApiService.js │ ├── serviceWorker │ │ └── registerServiceWorker.js │ ├── tag │ │ └── tagRepository.js │ └── user │ │ ├── __tests__ │ │ └── userRepository.test.js │ │ └── userRepository.js ├── state │ ├── actionTypes.js │ ├── article │ │ ├── article.js │ │ ├── editor.js │ │ ├── feed.js │ │ └── index.js │ ├── auth │ │ └── index.js │ ├── author │ │ └── index.js │ ├── settings │ │ └── index.js │ ├── store.js │ ├── tag │ │ └── index.js │ ├── user │ │ └── index.js │ └── withCurrentUser.js └── view │ ├── Application.js │ ├── Router.js │ ├── article │ ├── Article.js │ ├── ArticleEditor.js │ ├── ArticleLink.js │ ├── ArticleMeta.js │ ├── ArticlePage.js │ ├── ArticlePreview.js │ ├── CreateArticlePage.js │ ├── EditArticlePage.js │ ├── FavoriteButton.js │ ├── Feed.js │ └── RemoveButton.js │ ├── auth │ ├── AuthBoundary.js │ ├── AuthPage.js │ ├── LoginPage.js │ ├── RegisterPage.js │ └── controlledRoute.js │ ├── author │ ├── AuthorImage.js │ ├── AuthorLink.js │ ├── FollowButton.js │ └── ProfilePage.js │ ├── comment │ ├── Comment.js │ └── CommentForm.js │ ├── date │ └── FormattedDate.js │ ├── error │ └── ErrorMessages.js │ ├── home │ └── HomePage.js │ ├── layout │ ├── Footer.js │ ├── Head.js │ ├── Layout.js │ └── Nav.js │ ├── settings │ ├── EditProfileButton.js │ └── SettingsPage.js │ └── tag │ ├── PopularTagList.js │ └── TagList.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=https://conduit.productionready.io/api 2 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | ./src/*.js 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # cypress 13 | /cypress/screenshots 14 | /cypress/videos 15 | cypress.env.json 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.yarnpkg.com/ 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | script: 3 | - yarn test 4 | - yarn e2e:ci 5 | - yarn flow 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is an experiment with clean architecture in React/Redux applications. 2 | 3 | [![Build Status](https://travis-ci.org/talyssonoc/react-redux-ddd.svg?branch=master)](https://travis-ci.org/talyssonoc/react-redux-ddd) 4 | -------------------------------------------------------------------------------- /cypress.env.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "TEST_EMAIL": "", 3 | "TEST_PASSWORD": "" 4 | } 5 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000" 3 | } 4 | -------------------------------------------------------------------------------- /cypress/integration/auth/login.spec.js: -------------------------------------------------------------------------------- 1 | context('Login', () => { 2 | context('when the user exists', () => { 3 | it('redirects to home page', () => { 4 | cy.loginWith(Cypress.env('TEST_EMAIL'), Cypress.env('TEST_PASSWORD')); 5 | 6 | cy.url().should('be', '/'); 7 | }); 8 | }); 9 | 10 | context('when the user does not exist', () => { 11 | it('shows an error message', () => { 12 | cy.loginWith('fake@email.com', '---'); 13 | 14 | cy.get('.error-messages').should(($errorMessages) => { 15 | expect($errorMessages).to.contain('email or password is invalid'); 16 | }); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | Cypress.Commands.add('loginWith', (email, password) => { 2 | cy.visit('/login'); 3 | 4 | cy.getByPlaceholder('Email') 5 | .type(email); 6 | 7 | cy.getByPlaceholder('Password') 8 | .type(password); 9 | 10 | cy.get('button').contains('Sign in') 11 | .click(); 12 | 13 | cy.getByPlaceholder('Email') 14 | .should('not.be.disabled'); 15 | }); 16 | 17 | Cypress.Commands.add('getByPlaceholder', (placeholder) => { 18 | return cy.get(`[placeholder=${placeholder}`); 19 | }); 20 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /flow-typed/custom/keymirror-nested_v1.x.x.js: -------------------------------------------------------------------------------- 1 | declare module 'keymirror-nested' { 2 | 3 | declare type $MirrorKey = (k:K) => K; 4 | 5 | declare type KeyMirrorNested = (obj: O, glue: string, prefix: string) => $ObjMap; 6 | 7 | declare export default KeyMirrorNested; 8 | } 9 | -------------------------------------------------------------------------------- /flow-typed/npm/axios_v0.18.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: cd165e759d189df883676defc85ce252 2 | // flow-typed version: efe563fdbd/axios_v0.18.x/flow_>=v0.75.x 3 | 4 | declare module "axios" { 5 | declare interface AxiosTransformer { 6 | (data: T, headers?: Object): Object; 7 | } 8 | declare interface ProxyConfig { 9 | host: string; 10 | port: number; 11 | auth?: { 12 | username: string, 13 | password: string 14 | }; 15 | } 16 | declare interface Cancel { 17 | constructor(message?: string): Cancel; 18 | message: string; 19 | } 20 | declare interface Canceler { 21 | (message?: string): void; 22 | } 23 | declare interface CancelTokenSource { 24 | token: CancelToken; 25 | cancel: Canceler; 26 | } 27 | declare class CancelToken { 28 | constructor(executor: (cancel: Canceler) => void): CancelToken; 29 | static source(): CancelTokenSource; 30 | promise: Promise; 31 | reason?: Cancel; 32 | throwIfRequested(): void; 33 | } 34 | declare interface AxiosXHRConfigBase { 35 | adapter?: (config: AxiosXHRConfig) => Promise>; 36 | auth?: { 37 | username: string, 38 | password: string 39 | }; 40 | baseURL?: string; 41 | cancelToken?: CancelToken; 42 | headers?: Object; 43 | httpAgent?: mixed; // Missing the type in the core flow node libdef 44 | httpsAgent?: mixed; // Missing the type in the core flow node libdef 45 | maxContentLength?: number; 46 | maxRedirects?: number; 47 | params?: Object; 48 | paramsSerializer?: (params: Object) => string; 49 | progress?: (progressEvent: Event) => void | mixed; 50 | proxy?: ProxyConfig | false; 51 | responseType?: 52 | | "arraybuffer" 53 | | "blob" 54 | | "document" 55 | | "json" 56 | | "text" 57 | | "stream"; 58 | timeout?: number; 59 | transformRequest?: AxiosTransformer | Array>; 60 | transformResponse?: AxiosTransformer | Array>; 61 | validateStatus?: (status: number) => boolean; 62 | withCredentials?: boolean; 63 | xsrfCookieName?: string; 64 | xsrfHeaderName?: string; 65 | } 66 | declare type $AxiosXHRConfigBase = AxiosXHRConfigBase; 67 | declare interface AxiosXHRConfig extends AxiosXHRConfigBase { 68 | data?: T; 69 | method?: string; 70 | url: string; 71 | } 72 | declare type $AxiosXHRConfig = AxiosXHRConfig; 73 | declare class AxiosXHR { 74 | config: AxiosXHRConfig; 75 | data: R; 76 | headers?: Object; 77 | status: number; 78 | statusText: string; 79 | request: http$ClientRequest | XMLHttpRequest; 80 | } 81 | declare type $AxiosXHR = AxiosXHR; 82 | declare type AxiosInterceptorIdent = number; 83 | declare class AxiosRequestInterceptor { 84 | use( 85 | successHandler: ?( 86 | response: AxiosXHRConfig 87 | ) => Promise> | AxiosXHRConfig<*,*>, 88 | errorHandler: ?(error: mixed) => mixed 89 | ): AxiosInterceptorIdent; 90 | eject(ident: AxiosInterceptorIdent): void; 91 | } 92 | declare class AxiosResponseInterceptor { 93 | use( 94 | successHandler: ?(response: AxiosXHR) => mixed, 95 | errorHandler: ?(error: $AxiosError) => mixed 96 | ): AxiosInterceptorIdent; 97 | eject(ident: AxiosInterceptorIdent): void; 98 | } 99 | declare type AxiosPromise = Promise>; 100 | declare class Axios { 101 | constructor(config?: AxiosXHRConfigBase): void; 102 | [[call]](config: AxiosXHRConfig | string, config?: AxiosXHRConfig): AxiosPromise; 103 | request(config: AxiosXHRConfig): AxiosPromise; 104 | delete(url: string, config?: AxiosXHRConfigBase): AxiosPromise; 105 | get(url: string, config?: AxiosXHRConfigBase): AxiosPromise; 106 | head(url: string, config?: AxiosXHRConfigBase): AxiosPromise; 107 | post( 108 | url: string, 109 | data?: mixed, 110 | config?: AxiosXHRConfigBase 111 | ): AxiosPromise; 112 | put( 113 | url: string, 114 | data?: mixed, 115 | config?: AxiosXHRConfigBase 116 | ): AxiosPromise; 117 | patch( 118 | url: string, 119 | data?: mixed, 120 | config?: AxiosXHRConfigBase 121 | ): AxiosPromise; 122 | interceptors: { 123 | request: AxiosRequestInterceptor, 124 | response: AxiosResponseInterceptor 125 | }; 126 | defaults: { headers: Object } & AxiosXHRConfig<*,*>; 127 | } 128 | 129 | declare class AxiosError extends Error { 130 | config: AxiosXHRConfig; 131 | request?: http$ClientRequest | XMLHttpRequest; 132 | response?: AxiosXHR; 133 | code?: string; 134 | } 135 | 136 | declare type $AxiosError = AxiosError; 137 | 138 | declare interface AxiosExport extends Axios { 139 | [[call]](config: AxiosXHRConfig | string, config?: AxiosXHRConfig): AxiosPromise; 140 | Axios: typeof Axios; 141 | Cancel: Class; 142 | CancelToken: Class; 143 | isCancel(value: any): boolean; 144 | create(config?: AxiosXHRConfigBase): Axios; 145 | all: typeof Promise.all; 146 | spread(callback: Function): (arr: Array) => Function; 147 | } 148 | declare module.exports: AxiosExport; 149 | } 150 | -------------------------------------------------------------------------------- /flow-typed/npm/react-helmet_v5.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: afa3502910d5b2aef93707cc683f52b8 2 | // flow-typed version: 492c298a82/react-helmet_v5.x.x/flow_>=v0.53.x 3 | 4 | declare module 'react-helmet' { 5 | declare type Props = { 6 | base?: Object, 7 | bodyAttributes?: Object, 8 | children?: React$Node, 9 | defaultTitle?: string, 10 | defer?: boolean, 11 | encodeSpecialCharacters?: boolean, 12 | htmlAttributes?: Object, 13 | link?: Array, 14 | meta?: Array, 15 | noscript?: Array, 16 | onChangeClientState?: ( 17 | newState?: Object, 18 | addedTags?: Object, 19 | removeTags?: Object 20 | ) => any, 21 | script?: Array, 22 | style?: Array, 23 | title?: string, 24 | titleAttributes?: Object, 25 | titleTemplate?: string, 26 | } 27 | 28 | declare interface TagMethods { 29 | toString(): string; 30 | toComponent(): [React$Element<*>] | React$Element<*> | Array; 31 | } 32 | 33 | declare interface AttributeTagMethods { 34 | toString(): string; 35 | toComponent(): {[string]: *}; 36 | } 37 | 38 | declare interface StateOnServer { 39 | base: TagMethods; 40 | bodyAttributes: AttributeTagMethods, 41 | htmlAttributes: AttributeTagMethods; 42 | link: TagMethods; 43 | meta: TagMethods; 44 | noscript: TagMethods; 45 | script: TagMethods; 46 | style: TagMethods; 47 | title: TagMethods; 48 | } 49 | 50 | declare class Helmet extends React$Component { 51 | static rewind(): StateOnServer; 52 | static renderStatic(): StateOnServer; 53 | static canUseDom(canUseDOM: boolean): void; 54 | } 55 | 56 | declare export default typeof Helmet 57 | declare export var Helmet: typeof Helmet 58 | } 59 | 60 | -------------------------------------------------------------------------------- /flow-typed/npm/react-redux_v5.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: 3d2adf9e3c8823252a60ff4631b486a3 2 | // flow-typed version: 844b6ca3d3/react-redux_v5.x.x/flow_>=v0.63.0 3 | 4 | import type { Dispatch, Store } from "redux"; 5 | 6 | declare module "react-redux" { 7 | import type { ComponentType, ElementConfig } from 'react'; 8 | 9 | declare export class Provider extends React$Component<{ 10 | store: Store, 11 | children?: any 12 | }> {} 13 | 14 | declare export function createProvider( 15 | storeKey?: string, 16 | subKey?: string 17 | ): Provider<*, *>; 18 | 19 | /* 20 | 21 | S = State 22 | A = Action 23 | OP = OwnProps 24 | SP = StateProps 25 | DP = DispatchProps 26 | MP = Merge props 27 | MDP = Map dispatch to props object 28 | RSP = Returned state props 29 | RDP = Returned dispatch props 30 | RMP = Returned merge props 31 | CP = Props for returned component 32 | Com = React Component 33 | ST = Static properties of Com 34 | */ 35 | 36 | declare type MapStateToProps = (state: S, props: SP) => RSP; 37 | 38 | declare type MapDispatchToProps = (dispatch: Dispatch, ownProps: OP) => RDP; 39 | 40 | declare type MergeProps = ( 41 | stateProps: SP, 42 | dispatchProps: DP, 43 | ownProps: MP 44 | ) => RMP; 45 | 46 | declare type ConnectOptions = {| 47 | pure?: boolean, 48 | withRef?: boolean, 49 | areStatesEqual?: (next: S, prev: S) => boolean, 50 | areOwnPropsEqual?: (next: OP, prev: OP) => boolean, 51 | areStatePropsEqual?: (next: RSP, prev: RSP) => boolean, 52 | areMergedPropsEqual?: (next: RMP, prev: RMP) => boolean, 53 | storeKey?: string 54 | |}; 55 | 56 | declare type OmitDispatch = $Diff}>; 57 | 58 | declare export function connect< 59 | Com: ComponentType<*>, 60 | S: Object, 61 | SP: Object, 62 | RSP: Object, 63 | CP: $Diff>, RSP>, 64 | ST: {[_: $Keys]: any} 65 | >( 66 | mapStateToProps: MapStateToProps, 67 | mapDispatchToProps?: null 68 | ): (component: Com) => ComponentType & $Shape; 69 | 70 | declare export function connect< 71 | Com: ComponentType<*>, 72 | ST: {[_: $Keys]: any} 73 | >( 74 | mapStateToProps?: null, 75 | mapDispatchToProps?: null 76 | ): (component: Com) => ComponentType>> & $Shape; 77 | 78 | declare export function connect< 79 | Com: ComponentType<*>, 80 | A, 81 | S: Object, 82 | DP: Object, 83 | SP: Object, 84 | RSP: Object, 85 | RDP: Object, 86 | CP: $Diff<$Diff, RSP>, RDP>, 87 | ST: $Subtype<{[_: $Keys]: any}> 88 | >( 89 | mapStateToProps: MapStateToProps, 90 | mapDispatchToProps: MapDispatchToProps 91 | ): (component: Com) => ComponentType & $Shape; 92 | 93 | declare export function connect< 94 | Com: ComponentType<*>, 95 | A, 96 | OP: Object, 97 | DP: Object, 98 | PR: Object, 99 | CP: $Diff, DP>, 100 | ST: $Subtype<{[_: $Keys]: any}> 101 | >( 102 | mapStateToProps?: null, 103 | mapDispatchToProps: MapDispatchToProps 104 | ): (Com) => ComponentType; 105 | 106 | declare export function connect< 107 | Com: ComponentType<*>, 108 | MDP: Object, 109 | ST: $Subtype<{[_: $Keys]: any}> 110 | >( 111 | mapStateToProps?: null, 112 | mapDispatchToProps: MDP 113 | ): (component: Com) => ComponentType<$Diff, MDP>> & $Shape; 114 | 115 | declare export function connect< 116 | Com: ComponentType<*>, 117 | S: Object, 118 | SP: Object, 119 | RSP: Object, 120 | MDP: Object, 121 | CP: $Diff, RSP>, 122 | ST: $Subtype<{[_: $Keys]: any}> 123 | >( 124 | mapStateToProps: MapStateToProps, 125 | mapDispatchToProps: MDP 126 | ): (component: Com) => ComponentType<$Diff & SP> & $Shape; 127 | 128 | declare export function connect< 129 | Com: ComponentType<*>, 130 | A, 131 | S: Object, 132 | DP: Object, 133 | SP: Object, 134 | RSP: Object, 135 | RDP: Object, 136 | MP: Object, 137 | RMP: Object, 138 | CP: $Diff, RMP>, 139 | ST: $Subtype<{[_: $Keys]: any}> 140 | >( 141 | mapStateToProps: MapStateToProps, 142 | mapDispatchToProps: ?MapDispatchToProps, 143 | mergeProps: MergeProps 144 | ): (component: Com) => ComponentType & $Shape; 145 | 146 | declare export function connect< 147 | Com: ComponentType<*>, 148 | A, 149 | S: Object, 150 | DP: Object, 151 | SP: Object, 152 | RSP: Object, 153 | RDP: Object, 154 | MDP: Object, 155 | MP: Object, 156 | RMP: Object, 157 | CP: $Diff, RMP>, 158 | ST: $Subtype<{[_: $Keys]: any}> 159 | >( 160 | mapStateToProps: MapStateToProps, 161 | mapDispatchToProps: MDP, 162 | mergeProps: MergeProps 163 | ): (component: Com) => ComponentType & $Shape; 164 | 165 | declare export function connect, 166 | A, 167 | S: Object, 168 | DP: Object, 169 | SP: Object, 170 | RSP: Object, 171 | RDP: Object, 172 | MP: Object, 173 | RMP: Object, 174 | ST: $Subtype<{[_: $Keys]: any}> 175 | >( 176 | mapStateToProps: ?MapStateToProps, 177 | mapDispatchToProps: ?MapDispatchToProps, 178 | mergeProps: ?MergeProps, 179 | options: ConnectOptions 180 | ): (component: Com) => ComponentType<$Diff, RMP> & SP & DP & MP> & $Shape; 181 | 182 | declare export function connect, 183 | A, 184 | S: Object, 185 | DP: Object, 186 | SP: Object, 187 | RSP: Object, 188 | RDP: Object, 189 | MDP: Object, 190 | MP: Object, 191 | RMP: Object, 192 | ST: $Subtype<{[_: $Keys]: any}> 193 | >( 194 | mapStateToProps: ?MapStateToProps, 195 | mapDispatchToProps: ?MapDispatchToProps, 196 | mergeProps: MDP, 197 | options: ConnectOptions 198 | ): (component: Com) => ComponentType<$Diff, RMP> & SP & DP & MP> & $Shape; 199 | 200 | declare export default { 201 | Provider: typeof Provider, 202 | createProvider: typeof createProvider, 203 | connect: typeof connect, 204 | }; 205 | } 206 | -------------------------------------------------------------------------------- /flow-typed/npm/redux_v4.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: df80bdd535bfed9cf3223e077f3b4543 2 | // flow-typed version: c4c8963c9c/redux_v4.x.x/flow_>=v0.55.x 3 | 4 | declare module 'redux' { 5 | 6 | /* 7 | 8 | S = State 9 | A = Action 10 | D = Dispatch 11 | 12 | */ 13 | 14 | declare export type DispatchAPI = (action: A) => A; 15 | declare export type Dispatch }> = DispatchAPI; 16 | 17 | declare export type MiddlewareAPI> = { 18 | dispatch: D; 19 | getState(): S; 20 | }; 21 | 22 | declare export type Store> = { 23 | // rewrite MiddlewareAPI members in order to get nicer error messages (intersections produce long messages) 24 | dispatch: D; 25 | getState(): S; 26 | subscribe(listener: () => void): () => void; 27 | replaceReducer(nextReducer: Reducer): void 28 | }; 29 | 30 | declare export type Reducer = (state: S | void, action: A) => S; 31 | 32 | declare export type CombinedReducer = (state: $Shape & {} | void, action: A) => S; 33 | 34 | declare export type Middleware> = 35 | (api: MiddlewareAPI) => 36 | (next: D) => D; 37 | 38 | declare export type StoreCreator> = { 39 | (reducer: Reducer, enhancer?: StoreEnhancer): Store; 40 | (reducer: Reducer, preloadedState: S, enhancer?: StoreEnhancer): Store; 41 | }; 42 | 43 | declare export type StoreEnhancer> = (next: StoreCreator) => StoreCreator; 44 | 45 | declare export function createStore(reducer: Reducer, enhancer?: StoreEnhancer): Store; 46 | declare export function createStore(reducer: Reducer, preloadedState?: S, enhancer?: StoreEnhancer): Store; 47 | 48 | declare export function applyMiddleware(...middlewares: Array>): StoreEnhancer; 49 | 50 | declare export type ActionCreator = (...args: Array) => A; 51 | declare export type ActionCreators = { [key: K]: ActionCreator }; 52 | 53 | declare export function bindActionCreators, D: DispatchAPI>(actionCreator: C, dispatch: D): C; 54 | declare export function bindActionCreators, D: DispatchAPI>(actionCreators: C, dispatch: D): C; 55 | 56 | declare export function combineReducers(reducers: O): CombinedReducer<$ObjMap(r: Reducer) => S>, A>; 57 | 58 | declare export var compose: $Compose; 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-ddd", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.18.0", 7 | "classnames": "^2.2.6", 8 | "keymirror-nested": "^1.0.3", 9 | "lodash.throttle": "^4.1.1", 10 | "react": "^16.4.1", 11 | "react-dom": "^16.4.1", 12 | "react-helmet": "^5.2.0", 13 | "react-markdown": "^3.4.1", 14 | "react-redux": "^5.0.7", 15 | "react-router-dom": "^4.3.1", 16 | "react-scripts": "1.1.4", 17 | "redux": "^4.0.0", 18 | "redux-devtools-extension": "^2.13.5", 19 | "redux-thunk": "^2.3.0" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test --env=jsdom", 25 | "e2e": "BROWSER=none server-test start http-get://localhost:3000 cy:open", 26 | "e2e:ci": "BROWSER=none server-test start http-get://localhost:3000 cy:run", 27 | "cy:open": "cypress open", 28 | "cy:run": "cypress run" 29 | }, 30 | "devDependencies": { 31 | "cypress": "^3.0.2", 32 | "flow-bin": "^0.77.0", 33 | "flow-watch": "^1.1.3", 34 | "start-server-and-test": "^1.5.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/talyssonoc/react-redux-ddd/981ea92e5516ff7a57d3f36f79b97552b05be0d4/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 18 | 22 | 31 | Conduit 32 | 33 | 34 | 37 |
38 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/app/article/addComment.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { 3 | ArticleSlug, 4 | Comment, 5 | CommentRepository 6 | } from '../../domain/article'; 7 | 8 | import type { WithCurrentUser } from '../../domain/user'; 9 | 10 | type Dependencies = { 11 | commentRepository: CommentRepository 12 | }; 13 | 14 | type Config = WithCurrentUser & { 15 | articleSlug: ArticleSlug 16 | }; 17 | 18 | type Callbacks = { 19 | onSuccess: (Comment) => void, 20 | onError: (Error) => void 21 | }; 22 | 23 | export default ({ commentRepository }: Dependencies) => { 24 | return async (commentBody: string, { articleSlug, currentUser }: Config, { onSuccess, onError }: Callbacks) => { 25 | try { 26 | const comment = { 27 | body: commentBody 28 | }; 29 | 30 | const globalFeed = await commentRepository.add(comment, { 31 | articleSlug, 32 | currentUser 33 | }); 34 | 35 | onSuccess(globalFeed); 36 | } catch(error) { 37 | onError(error); 38 | } 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/app/article/createArticle.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { WithCurrentUser } from '../../domain/user'; 3 | import type { Article, EditingArticle, ArticleRepository } from '../../domain/article'; 4 | 5 | type Dependencies = { 6 | articleRepository: ArticleRepository 7 | }; 8 | 9 | type Options = WithCurrentUser; 10 | 11 | type Callbacks = { 12 | onSuccess: (Article) => void, 13 | onError: (Object) => void 14 | }; 15 | 16 | export default ({ articleRepository }: Dependencies) => { 17 | return async (editingArticle: EditingArticle, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 18 | try { 19 | const article = await articleRepository.add(editingArticle, { currentUser }); 20 | onSuccess(article); 21 | } catch(error) { 22 | onError(error); 23 | } 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/article/editArticle.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { WithCurrentUser } from '../../domain/user'; 3 | import type { 4 | Article, Comment, EditingArticle, 5 | ArticleRepository, CommentRepository 6 | } from '../../domain/article'; 7 | 8 | type Dependencies = { 9 | articleRepository: ArticleRepository, 10 | commentRepository: CommentRepository 11 | }; 12 | 13 | type Options = WithCurrentUser; 14 | 15 | type SuccessCallback = { 16 | article: Article, 17 | comments: Array 18 | }; 19 | 20 | type Callbacks = { 21 | onSuccess: (SuccessCallback) => void, 22 | onError: (Object) => void 23 | }; 24 | 25 | export default ({ articleRepository, commentRepository }: Dependencies) => { 26 | return async (editingArticle: EditingArticle, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 27 | try { 28 | const article = await articleRepository.update(editingArticle, { currentUser }); 29 | const comments = await commentRepository.fromArticle(article.slug); 30 | onSuccess({ article, comments }); 31 | } catch(error) { 32 | onError(error); 33 | } 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/app/article/getArticle.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { 3 | Article, 4 | Comment, 5 | ArticleSlug, 6 | ArticleRepository, 7 | CommentRepository 8 | } from '../../domain/article'; 9 | import type { WithCurrentUser } from '../../domain/user'; 10 | 11 | type Dependencies = { 12 | articleRepository: ArticleRepository, 13 | commentRepository: CommentRepository 14 | }; 15 | 16 | type Options = WithCurrentUser & { 17 | withComments: bool 18 | }; 19 | 20 | type SuccessResult = { 21 | article: Article, 22 | comments: ?Array 23 | }; 24 | 25 | type Callbacks = { 26 | onSuccess: (SuccessResult) => void, 27 | onError: (Error) => void 28 | }; 29 | 30 | export default ({ articleRepository, commentRepository }: Dependencies) => { 31 | return async (slug: ArticleSlug, options: Options, { onSuccess, onError }: Callbacks) => { 32 | const { withComments, currentUser } = options; 33 | 34 | try { 35 | const [ article, comments ] = await Promise.all([ 36 | articleRepository.getArticle(slug, { currentUser }), 37 | withComments ? commentRepository.fromArticle(slug) : undefined 38 | ]); 39 | 40 | onSuccess({ article, comments }); 41 | 42 | } catch(error) { 43 | onError(error); 44 | } 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/app/article/getAuthorFavoritesFeed.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { WithCurrentUser } from '../../domain/user'; 3 | import type { Feed, ArticleRepository } from '../../domain/article'; 4 | 5 | type Dependencies = { 6 | articleRepository: ArticleRepository 7 | }; 8 | 9 | type Options = WithCurrentUser; 10 | 11 | type Callbacks = { 12 | onSuccess: (Feed) => void, 13 | onError: (Error) => void 14 | }; 15 | 16 | export default ({ articleRepository }: Dependencies) => { 17 | return async (authorUsername: string, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 18 | try { 19 | const authorFeed = await articleRepository.fromAuthorFavorites(authorUsername, { currentUser }); 20 | onSuccess(authorFeed); 21 | } catch(error) { 22 | onError(error); 23 | } 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/article/getAuthorFeed.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { WithCurrentUser } from '../../domain/user'; 3 | import type { Feed, ArticleRepository } from '../../domain/article'; 4 | 5 | type Dependencies = { 6 | articleRepository: ArticleRepository 7 | }; 8 | 9 | type Options = WithCurrentUser; 10 | 11 | type Callbacks = { 12 | onSuccess: (Feed) => void, 13 | onError: (Error) => void 14 | }; 15 | 16 | export default ({ articleRepository }: Dependencies) => { 17 | return async (authorUsername: string, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 18 | try { 19 | const authorFeed = await articleRepository.fromAuthorFeed(authorUsername, { currentUser }); 20 | onSuccess(authorFeed); 21 | } catch(error) { 22 | onError(error); 23 | } 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/article/getGlobalFeed.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { WithCurrentUser } from '../../domain/user'; 3 | import type { Feed, ArticleRepository } from '../../domain/article'; 4 | 5 | type Dependencies = { 6 | articleRepository: ArticleRepository 7 | }; 8 | 9 | type Options = WithCurrentUser; 10 | 11 | type Callbacks = { 12 | onSuccess: (Feed) => void, 13 | onError: (Error) => void 14 | }; 15 | 16 | export default ({ articleRepository }: Dependencies) => { 17 | return async ({ currentUser }: Options, { onSuccess, onError }: Callbacks) => { 18 | try { 19 | const globalFeed = await articleRepository.fromGlobalFeed({ currentUser }); 20 | onSuccess(globalFeed); 21 | } catch(error) { 22 | onError(error); 23 | } 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/article/getTagFeed.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { WithCurrentUser } from '../../domain/user'; 3 | import type { Feed, ArticleRepository } from '../../domain/article'; 4 | import type { Tag } from '../../domain/tag'; 5 | 6 | type Dependencies = { 7 | articleRepository: ArticleRepository 8 | }; 9 | 10 | type Options = WithCurrentUser; 11 | 12 | type Callbacks = { 13 | onSuccess: (Feed) => void, 14 | onError: (Error) => void 15 | }; 16 | 17 | export default ({ articleRepository }: Dependencies) => { 18 | return async (tag: Tag, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 19 | try { 20 | const globalFeed = await articleRepository.fromTagFeed(tag, { currentUser }); 21 | onSuccess(globalFeed); 22 | } catch(error) { 23 | onError(error); 24 | } 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/app/article/getUserFeed.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { User } from '../../domain/user'; 3 | import type { Feed, ArticleRepository } from '../../domain/article'; 4 | 5 | type Dependencies = { 6 | articleRepository: ArticleRepository 7 | }; 8 | 9 | type Callbacks = { 10 | onSuccess: (Feed) => void, 11 | onError: (Error) => void 12 | }; 13 | 14 | export default ({ articleRepository }: Dependencies) => { 15 | return async (user: ?User, { onSuccess, onError }: Callbacks) => { 16 | try { 17 | const userFeed = await articleRepository.fromUserFeed(user); 18 | onSuccess(userFeed); 19 | } catch(error) { 20 | onError(error); 21 | } 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/article/removeArticle.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { WithCurrentUser } from '../../domain/user'; 3 | import type { Article, ArticleRepository } from '../../domain/article'; 4 | 5 | type Dependencies = { 6 | articleRepository: ArticleRepository 7 | }; 8 | 9 | type Options = WithCurrentUser; 10 | 11 | type Callbacks = { 12 | onSuccess: () => void, 13 | onError: (Object) => void 14 | }; 15 | 16 | export default ({ articleRepository }: Dependencies) => { 17 | return async (article: Article, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 18 | try { 19 | await articleRepository.remove(article, { currentUser }); 20 | onSuccess(); 21 | } catch(error) { 22 | onError(error); 23 | } 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/app/article/removeComment.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { 3 | ArticleSlug, 4 | Comment, 5 | CommentRepository 6 | } from '../../domain/article'; 7 | 8 | import type { WithCurrentUser } from '../../domain/user'; 9 | 10 | type Dependencies = { 11 | commentRepository: CommentRepository 12 | }; 13 | 14 | type Config = WithCurrentUser & { 15 | articleSlug: ArticleSlug 16 | }; 17 | 18 | type Callbacks = { 19 | onSuccess: () => void, 20 | onError: (Error) => void 21 | }; 22 | 23 | export default ({ commentRepository }: Dependencies) => { 24 | return async (comment: Comment, { articleSlug, currentUser }: Config, { onSuccess, onError }: Callbacks) => { 25 | try { 26 | await commentRepository.remove(comment, { 27 | articleSlug, 28 | currentUser 29 | }); 30 | 31 | onSuccess(); 32 | } catch(error) { 33 | onError(error); 34 | } 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/app/article/toggleArticleFavoriteStatus.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { WithCurrentUser } from '../../domain/user'; 3 | import type { Article, ArticleRepository } from '../../domain/article'; 4 | 5 | type Dependencies = { 6 | articleRepository: ArticleRepository 7 | }; 8 | 9 | type Options = WithCurrentUser; 10 | 11 | type Callbacks = { 12 | onSuccess: (Article) => void, 13 | onError: (Object) => void 14 | }; 15 | 16 | export default ({ articleRepository }: Dependencies) => { 17 | return async (article: Article, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 18 | try { 19 | let editedArticle; 20 | 21 | if(article.favorited) { 22 | editedArticle = await articleRepository.unsetAsFavorite(article.slug, { currentUser }); 23 | } else { 24 | editedArticle = await articleRepository.setAsFavorite(article.slug, { currentUser }); 25 | } 26 | 27 | onSuccess(editedArticle); 28 | } catch(error) { 29 | onError(error); 30 | } 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/author/getAuthor.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Author, AuthorRepository } from '../../domain/author'; 3 | import type { WithCurrentUser } from '../../domain/user'; 4 | 5 | type Dependencies = { 6 | authorRepository: AuthorRepository 7 | }; 8 | 9 | type Options = WithCurrentUser; 10 | 11 | type Callbacks = { 12 | onSuccess: (Author) => void, 13 | onError: (Error) => void 14 | }; 15 | 16 | export default ({ authorRepository }: Dependencies) => { 17 | return async (authorUsername: string, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 18 | try { 19 | const author = await authorRepository.getByUsername(authorUsername, { 20 | currentUser 21 | }); 22 | 23 | onSuccess(author); 24 | } catch(error) { 25 | onError(error); 26 | } 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/author/toggleAuthorFollowStatus.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { WithCurrentUser } from '../../domain/user'; 3 | import type { Author, AuthorRepository } from '../../domain/author'; 4 | 5 | type Dependencies = { 6 | authorRepository: AuthorRepository 7 | }; 8 | 9 | type Options = WithCurrentUser; 10 | 11 | type Callbacks = { 12 | onSuccess: (Author) => void, 13 | onError: (Object) => void 14 | }; 15 | 16 | export default ({ authorRepository }: Dependencies) => { 17 | return async (author: Author, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 18 | try { 19 | let editedAuthor; 20 | 21 | if(author.following) { 22 | editedAuthor = await authorRepository.unsetAsFollowing(author.username, { currentUser }); 23 | } else { 24 | editedAuthor = await authorRepository.setAsFollowing(author.username, { currentUser }); 25 | } 26 | 27 | onSuccess(editedAuthor); 28 | } catch(error) { 29 | onError(error); 30 | } 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/tag/getPopularTags.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Tag, TagRepository } from '../../domain/tag'; 3 | 4 | type Dependencies = { 5 | tagRepository: TagRepository 6 | }; 7 | 8 | type Callbacks = { 9 | onSuccess: (Array) => void, 10 | onError: (Error) => void 11 | }; 12 | 13 | export default ({ tagRepository }: Dependencies) => { 14 | return async ({ onSuccess, onError }: Callbacks) => { 15 | try { 16 | const popularTags = await tagRepository.getPopularTags(); 17 | onSuccess(popularTags); 18 | } catch(error) { 19 | onError(error); 20 | } 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/app/user/__tests__/registerUser.test.js: -------------------------------------------------------------------------------- 1 | import makeRegisterUser from '../registerUser'; 2 | 3 | describe('App :: User :: registerUser', () => { 4 | let registerUser; 5 | let mockUserRepository; 6 | 7 | it('passes the user data to the repository', async () => { 8 | mockUserRepository = { 9 | add: jest.fn() 10 | }; 11 | 12 | registerUser = makeRegisterUser({ 13 | userRepository: mockUserRepository 14 | }); 15 | 16 | const onSuccess = () => {}; 17 | const onError = () => {}; 18 | 19 | await registerUser('userData', { onSuccess, onError }); 20 | 21 | expect(mockUserRepository.add).toBeCalledWith('userData'); 22 | }); 23 | 24 | describe('when it succeeds', () => { 25 | beforeEach(() => { 26 | mockUserRepository = { 27 | add: jest.fn().mockReturnValue('newUser') 28 | }; 29 | 30 | registerUser = makeRegisterUser({ 31 | userRepository: mockUserRepository 32 | }); 33 | }); 34 | 35 | it('calls onSuccess callback with the new user', async () => { 36 | const onSuccess = jest.fn(); 37 | const onError = jest.fn(); 38 | 39 | await registerUser('userData', { onSuccess, onError }); 40 | 41 | expect(onSuccess).toBeCalledWith('newUser'); 42 | expect(onError).not.toBeCalled(); 43 | }); 44 | }); 45 | 46 | describe('when it fails', () => { 47 | beforeEach(() => { 48 | mockUserRepository = { 49 | add: jest.fn().mockImplementation(() => { 50 | throw new Error('Nop!') 51 | }) 52 | }; 53 | 54 | registerUser = makeRegisterUser({ 55 | userRepository: mockUserRepository 56 | }); 57 | }); 58 | 59 | it('calls onError callback with the error', async () => { 60 | const onSuccess = jest.fn(); 61 | const onError = jest.fn(); 62 | 63 | await registerUser('userData', { onSuccess, onError }); 64 | 65 | expect(onError).toBeCalledWith(new Error('Nop!')); 66 | expect(onSuccess).not.toBeCalled(); 67 | }); 68 | }); 69 | }); -------------------------------------------------------------------------------- /src/app/user/__tests__/signInUser.test.js: -------------------------------------------------------------------------------- 1 | import makeSignInUser from '../signInUser'; 2 | 3 | describe('App :: User :: signInUser', () => { 4 | let signInUser; 5 | let mockUserRepository; 6 | 7 | it('passes the user auth info to the repository', async () => { 8 | mockUserRepository = { 9 | byAuthInfo: jest.fn() 10 | }; 11 | 12 | signInUser = makeSignInUser({ 13 | userRepository: mockUserRepository 14 | }); 15 | 16 | const onSuccess = () => {}; 17 | const onError = () => {}; 18 | 19 | await signInUser('userAuthInfo', { onSuccess, onError }); 20 | 21 | expect(mockUserRepository.byAuthInfo).toBeCalledWith('userAuthInfo'); 22 | }); 23 | 24 | describe('when it succeeds', () => { 25 | beforeEach(() => { 26 | mockUserRepository = { 27 | byAuthInfo: jest.fn().mockReturnValue('signedInUser') 28 | }; 29 | 30 | signInUser = makeSignInUser({ 31 | userRepository: mockUserRepository 32 | }); 33 | }); 34 | 35 | it('calls onSuccess callback with the signed in user', async () => { 36 | const onSuccess = jest.fn(); 37 | const onError = jest.fn(); 38 | 39 | await signInUser('userAuthInfo', { onSuccess, onError }); 40 | 41 | expect(onSuccess).toBeCalledWith('signedInUser'); 42 | expect(onError).not.toBeCalled(); 43 | }); 44 | }); 45 | 46 | describe('when it fails', () => { 47 | beforeEach(() => { 48 | mockUserRepository = { 49 | byAuthInfo: jest.fn().mockImplementation(() => { 50 | throw new Error('Nop!') 51 | }) 52 | }; 53 | 54 | signInUser = makeSignInUser({ 55 | userRepository: mockUserRepository 56 | }); 57 | }); 58 | 59 | it('calls onError callback with the error', async () => { 60 | const onSuccess = jest.fn(); 61 | const onError = jest.fn(); 62 | 63 | await signInUser('userAuthInfo', { onSuccess, onError }); 64 | 65 | expect(onError).toBeCalledWith(new Error('Nop!')); 66 | expect(onSuccess).not.toBeCalled(); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/app/user/changeSettings.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { User, EditingUser, UserRepository } from '../../domain/user'; 3 | import type { WithCurrentUser } from '../../domain/user'; 4 | 5 | type Callbacks = { 6 | onSuccess: (User) => void, 7 | onError: ($Subtype) => void 8 | }; 9 | 10 | type Options = WithCurrentUser; 11 | 12 | type Dependencies = { 13 | userRepository: UserRepository 14 | }; 15 | 16 | export default ({ userRepository }: Dependencies) => { 17 | return async (editingUser: EditingUser, { currentUser }: Options, { onSuccess, onError }: Callbacks) => { 18 | try { 19 | if(!currentUser) { 20 | throw new Error('User is not signed in'); 21 | } 22 | 23 | const newUser = await userRepository.update(editingUser, { 24 | currentUser 25 | }); 26 | 27 | return onSuccess(newUser); 28 | } catch(error) { 29 | return onError(error); 30 | } 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/app/user/getUser.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { User, UserRepository, WithCurrentUser } from '../../domain/user'; 3 | 4 | type Dependencies = { 5 | userRepository: UserRepository 6 | }; 7 | 8 | type Options = WithCurrentUser; 9 | 10 | type Callbacks = { 11 | onSuccess: (User) => void, 12 | onError: ($Subtype) => void 13 | }; 14 | 15 | export default ({ userRepository }: Dependencies) => { 16 | return async ({ currentUser }: Options, { onSuccess, onError }: Callbacks) => { 17 | try { 18 | const user = await userRepository.getByToken({ currentUser }); 19 | 20 | return onSuccess(user); 21 | } catch(error) { 22 | return onError(error); 23 | } 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/app/user/registerUser.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { User, UserAuthInfo, UserRepository } from '../../domain/user'; 3 | 4 | type Callbacks = { 5 | onSuccess: (User) => void, 6 | onError: ($Subtype) => void 7 | }; 8 | 9 | type Dependencies = { 10 | userRepository: UserRepository 11 | }; 12 | 13 | export default ({ userRepository }: Dependencies) => { 14 | return async (userInfo: UserAuthInfo, { onSuccess, onError }: Callbacks) => { 15 | try { 16 | const newUser = await userRepository.add(userInfo); 17 | 18 | return onSuccess(newUser); 19 | } catch(error) { 20 | return onError(error); 21 | } 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/app/user/signInUser.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { User, UserAuthInfo, UserRepository } from '../../domain/user'; 3 | 4 | type Callbacks = { 5 | onSuccess: (User) => void, 6 | onError: ($Subtype) => void 7 | }; 8 | 9 | type Dependencies = { 10 | userRepository: UserRepository 11 | }; 12 | 13 | export default ({ userRepository }: Dependencies) => { 14 | return async (userAuthInfo: UserAuthInfo, { onSuccess, onError }: Callbacks) => { 15 | try { 16 | const authorizedUser = await userRepository.byAuthInfo(userAuthInfo); 17 | 18 | return onSuccess(authorizedUser); 19 | } catch(error) { 20 | return onError(error); 21 | } 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/container.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import * as conduitApiService from './infra/conduit/conduitApiService'; 3 | import makeUserRepository from './infra/user/userRepository'; 4 | import makeArticleRepository from './infra/article/articleRepository'; 5 | import makeCommentRepository from './infra/article/commentRepository'; 6 | import makeTagRepository from './infra/tag/tagRepository'; 7 | import makeAuthorRepository from './infra/author/authorRepository'; 8 | import makeSignInUser from './app/user/signInUser'; 9 | import makeRegisterUser from './app/user/registerUser'; 10 | import makeGetUser from './app/user/getUser'; 11 | import makeChangeSettings from './app/user/changeSettings'; 12 | import makeGetGlobalFeed from './app/article/getGlobalFeed'; 13 | import makeGetUserFeed from './app/article/getUserFeed'; 14 | import makeGetTagFeed from './app/article/getTagFeed'; 15 | import makeGetAuthorFeed from './app/article/getAuthorFeed'; 16 | import makeGetAuthorFavoritesFeed from './app/article/getAuthorFavoritesFeed'; 17 | import makeGetArticle from './app/article/getArticle'; 18 | import makeAddComment from './app/article/addComment'; 19 | import makeRemoveComment from './app/article/removeComment'; 20 | import makeCreateArticle from './app/article/createArticle'; 21 | import makeEditArticle from './app/article/editArticle'; 22 | import makeRemoveArticle from './app/article/removeArticle'; 23 | import makeToggleArticleFavoriteStatus from './app/article/toggleArticleFavoriteStatus'; 24 | import makeGetPopularTags from './app/tag/getPopularTags'; 25 | import makeGetAuthor from './app/author/getAuthor'; 26 | import makeToggleAuthorFollowStatus from './app/author/toggleAuthorFollowStatus'; 27 | 28 | // Infra 29 | const userRepository = makeUserRepository({ 30 | conduitApiService 31 | }); 32 | 33 | const articleRepository = makeArticleRepository({ 34 | conduitApiService 35 | }); 36 | 37 | const tagRepository = makeTagRepository({ 38 | conduitApiService 39 | }); 40 | 41 | const commentRepository = makeCommentRepository({ 42 | conduitApiService 43 | }); 44 | 45 | const authorRepository = makeAuthorRepository({ 46 | conduitApiService 47 | }); 48 | 49 | //App 50 | const signInUser = makeSignInUser({ 51 | userRepository 52 | }); 53 | 54 | const registerUser = makeRegisterUser({ 55 | userRepository 56 | }); 57 | 58 | const getUser = makeGetUser({ 59 | userRepository 60 | }); 61 | 62 | const changeSettings = makeChangeSettings({ 63 | userRepository 64 | }); 65 | 66 | const getGlobalFeed = makeGetGlobalFeed({ 67 | articleRepository 68 | }); 69 | 70 | const getUserFeed = makeGetUserFeed({ 71 | articleRepository 72 | }); 73 | 74 | const getTagFeed = makeGetTagFeed({ 75 | articleRepository 76 | }); 77 | 78 | const getAuthorFeed = makeGetAuthorFeed({ 79 | articleRepository 80 | }); 81 | 82 | const getAuthorFavoritesFeed = makeGetAuthorFavoritesFeed({ 83 | articleRepository 84 | }); 85 | 86 | const getArticle = makeGetArticle({ 87 | articleRepository, 88 | commentRepository 89 | }); 90 | 91 | const addComment = makeAddComment({ 92 | commentRepository 93 | }); 94 | 95 | const removeComment = makeRemoveComment({ 96 | commentRepository 97 | }); 98 | 99 | const createArticle = makeCreateArticle({ 100 | articleRepository 101 | }); 102 | 103 | const editArticle = makeEditArticle({ 104 | articleRepository, 105 | commentRepository 106 | }); 107 | 108 | const removeArticle = makeRemoveArticle({ 109 | articleRepository 110 | }); 111 | 112 | const toggleArticleFavoriteStatus = makeToggleArticleFavoriteStatus({ 113 | articleRepository 114 | }); 115 | 116 | const getPopularTags = makeGetPopularTags({ 117 | tagRepository 118 | }); 119 | 120 | const getAuthor = makeGetAuthor({ 121 | authorRepository 122 | }); 123 | 124 | const toggleAuthorFollowStatus = makeToggleAuthorFollowStatus({ 125 | authorRepository 126 | }); 127 | 128 | export { 129 | signInUser, 130 | registerUser, 131 | getGlobalFeed, 132 | getUserFeed, 133 | getTagFeed, 134 | getAuthorFeed, 135 | getAuthorFavoritesFeed, 136 | getPopularTags, 137 | getArticle, 138 | addComment, 139 | removeComment, 140 | createArticle, 141 | editArticle, 142 | getAuthor, 143 | toggleArticleFavoriteStatus, 144 | toggleAuthorFollowStatus, 145 | changeSettings, 146 | getUser, 147 | removeArticle 148 | }; 149 | -------------------------------------------------------------------------------- /src/domain/article/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { User, WithCurrentUser } from '../user'; 3 | import type { Tag } from '../tag'; 4 | import type { Authorable } from '../author'; 5 | 6 | export type ArticleSlug = string; 7 | 8 | export type Article = Authorable & { 9 | title: string, 10 | description: string, 11 | slug: ArticleSlug, 12 | createdAt: Date, 13 | favoritesCount: number, 14 | body: string, 15 | favorited: bool, 16 | tagList: Array 17 | }; 18 | 19 | export type EditingArticle = { 20 | title: string, 21 | description: string, 22 | slug?: ArticleSlug, 23 | body: string, 24 | tagList: Array 25 | }; 26 | 27 | export type Feed = { 28 | articles: Array
, 29 | articlesCount: number 30 | }; 31 | 32 | export type ArticleRepository = { 33 | fromGlobalFeed: (WithCurrentUser) => Promise, 34 | fromUserFeed: (?User) => Promise, 35 | fromTagFeed: (Tag, WithCurrentUser) => Promise, 36 | fromAuthorFeed: (string, WithCurrentUser) => Promise, 37 | fromAuthorFavorites: (string, WithCurrentUser) => Promise, 38 | getArticle: (ArticleSlug, WithCurrentUser) => Promise
, 39 | add: (EditingArticle, WithCurrentUser) => Promise
, 40 | remove: (Article, WithCurrentUser) => Promise, 41 | update: (EditingArticle, WithCurrentUser) => Promise
, 42 | setAsFavorite: (ArticleSlug, WithCurrentUser) => Promise
, 43 | unsetAsFavorite: (ArticleSlug, WithCurrentUser) => Promise
44 | }; 45 | 46 | export type NewComment = { 47 | body: string 48 | }; 49 | 50 | export type Comment = Authorable & NewComment & { 51 | id: number, 52 | createdAt: Date 53 | }; 54 | 55 | export type CommentRepository = { 56 | fromArticle: (ArticleSlug) => Promise>, 57 | add: (NewComment, WithCurrentUser & { articleSlug: ArticleSlug } ) => Promise, 58 | remove: (Comment, WithCurrentUser & { articleSlug: ArticleSlug } ) => Promise 59 | }; 60 | 61 | export const updateArticle = (oldArticle: ?Article, updatedArticle: ?Article): ?Article => ( 62 | isSameArticle(oldArticle, updatedArticle) ? updatedArticle : oldArticle 63 | ); 64 | 65 | export const isSameArticle = (articleA: ?Article, articleB: ?Article): bool => ( 66 | !!(articleA && articleB) && (articleA.slug === articleB.slug) 67 | ); 68 | -------------------------------------------------------------------------------- /src/domain/author/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { User, WithCurrentUser } from '../user'; 3 | 4 | export type Author = { 5 | username: string, 6 | image: ?string, 7 | bio: ?string, 8 | following: bool 9 | }; 10 | 11 | export type Authorable = { 12 | author: Author 13 | }; 14 | 15 | export type AuthorRepository = { 16 | getByUsername: (string, WithCurrentUser) => Promise, 17 | setAsFollowing: (string, WithCurrentUser) => Promise, 18 | unsetAsFollowing: (string, WithCurrentUser) => Promise 19 | }; 20 | 21 | export const isAuthoredBy = (authorable: Authorable, user: ?User): bool => ( 22 | !!user && isSame(authorable.author, user) 23 | ); 24 | 25 | export const updateAuthor = (authorable: ?Authorable, author: Author): ?$Subtype => { 26 | if(!authorable) { 27 | return null; 28 | } 29 | 30 | return { 31 | ...authorable, 32 | author: isSame(authorable.author, author) ? author : authorable.author 33 | }; 34 | }; 35 | 36 | type WithUsername = { username: string }; 37 | 38 | export const isSame = (a: ?WithUsername, b: ?WithUsername) => ( 39 | (a && b) ? a.username === b.username : false 40 | ); 41 | -------------------------------------------------------------------------------- /src/domain/tag/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export type Tag = string; 3 | 4 | export type TagRepository = { 5 | getPopularTags: () => Promise> 6 | }; 7 | -------------------------------------------------------------------------------- /src/domain/user/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export type User = { 3 | email: string, 4 | token: string, 5 | username: string, 6 | bio: string, 7 | image: ?string 8 | }; 9 | 10 | export type EditingUser = { 11 | email: string, 12 | username: string, 13 | bio: ?string, 14 | image: ?string, 15 | password: ?string 16 | }; 17 | 18 | export type UserAuthInfo = { 19 | username?: string, 20 | email?: string, 21 | password?: ?string 22 | }; 23 | 24 | export type WithCurrentUser = { 25 | currentUser: ?User 26 | }; 27 | 28 | export type UserRepository = { 29 | byAuthInfo: (UserAuthInfo) => Promise, 30 | add: (UserAuthInfo) => Promise, 31 | update: (EditingUser, WithCurrentUser) => Promise, 32 | getByToken: (WithCurrentUser) => Promise 33 | }; 34 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Application from './view/Application'; 4 | import createStore from './state/store'; 5 | import * as container from './container'; 6 | import registerServiceWorker from './infra/serviceWorker/registerServiceWorker'; 7 | import * as cache from './infra/cache'; 8 | import throttle from 'lodash.throttle'; 9 | 10 | const store = createStore({ 11 | container, 12 | initialState: cache.getCachedState() 13 | }); 14 | 15 | store.subscribe(throttle(() => { 16 | cache.cacheState(store.getState()); 17 | }, 1000)); 18 | 19 | ReactDOM.render(, document.getElementById('root')); 20 | registerServiceWorker(); 21 | -------------------------------------------------------------------------------- /src/infra/article/articleRepository.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { EditingArticle, ArticleRepository } from '../../domain/article'; 3 | import typeof * as ConduitApiService from '../conduit/conduitApiService'; 4 | 5 | type Dependencies = { 6 | conduitApiService: ConduitApiService 7 | }; 8 | 9 | export default ({ conduitApiService }: Dependencies): ArticleRepository => ({ 10 | async fromGlobalFeed({ currentUser }) { 11 | const { data } = await conduitApiService.authGet('articles', currentUser); 12 | 13 | return { 14 | ...data, 15 | articles: data.articles.map(this._coerceArticle) 16 | }; 17 | }, 18 | 19 | async fromUserFeed(user) { 20 | const { data } = await conduitApiService.authGet('articles/feed', user); 21 | 22 | return { 23 | ...data, 24 | articles: data.articles.map(this._coerceArticle) 25 | }; 26 | }, 27 | 28 | async fromTagFeed(tag, { currentUser }) { 29 | const { data } = await conduitApiService.authGet('articles', currentUser, { 30 | params: { tag } 31 | }); 32 | 33 | return { 34 | ...data, 35 | articles: data.articles.map(this._coerceArticle) 36 | }; 37 | }, 38 | 39 | async fromAuthorFeed(authorUsername, { currentUser }) { 40 | const { data } = await conduitApiService.authGet('articles', currentUser, { 41 | params: { author: authorUsername } 42 | }); 43 | 44 | return { 45 | ...data, 46 | articles: data.articles.map(this._coerceArticle) 47 | }; 48 | }, 49 | 50 | async fromAuthorFavorites(authorUsername, { currentUser }) { 51 | const { data } = await conduitApiService.authGet('articles', currentUser, { 52 | params: { favorited: authorUsername } 53 | }); 54 | 55 | return { 56 | ...data, 57 | articles: data.articles.map(this._coerceArticle) 58 | }; 59 | }, 60 | 61 | async getArticle(slug, { currentUser }) { 62 | const { data } = await conduitApiService.authGet(`articles/${slug}`, currentUser); 63 | 64 | return this._coerceArticle(data.article); 65 | }, 66 | 67 | async add(editingArticle, { currentUser }) { 68 | const { data } = await conduitApiService.authPost('articles', currentUser, { 69 | article: editingArticle 70 | }); 71 | 72 | return this._coerceArticle(data.article); 73 | }, 74 | 75 | async remove(article, { currentUser }) { 76 | await conduitApiService.authDel(`articles/${article.slug}`, currentUser); 77 | }, 78 | 79 | async update(article, { currentUser }) { 80 | const slug = article.slug || ''; 81 | 82 | const { data } = await conduitApiService.authPut(`articles/${slug}`, currentUser, { 83 | article: this._serializeArticle(article) 84 | }); 85 | 86 | return this._coerceArticle(data.article); 87 | }, 88 | 89 | async setAsFavorite(slug, { currentUser }) { 90 | const { data } = await conduitApiService.authPost(`articles/${slug}/favorite`, currentUser); 91 | 92 | return this._coerceArticle(data.article); 93 | }, 94 | 95 | async unsetAsFavorite(slug, { currentUser }) { 96 | const { data } = await conduitApiService.authDel(`articles/${slug}/favorite`, currentUser); 97 | 98 | return this._coerceArticle(data.article); 99 | }, 100 | 101 | _coerceArticle(rawArticle: any) { 102 | return { 103 | ...rawArticle, 104 | createdAt: new Date(rawArticle.createdAt) 105 | }; 106 | }, 107 | 108 | _serializeArticle(article: EditingArticle) { 109 | const { 110 | title, 111 | description, 112 | body, 113 | tagList 114 | } = article; 115 | 116 | return { 117 | title, 118 | description, 119 | body, 120 | tagList 121 | }; 122 | } 123 | }); 124 | -------------------------------------------------------------------------------- /src/infra/article/commentRepository.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { CommentRepository } from '../../domain/article'; 3 | import typeof * as ConduitApiService from '../conduit/conduitApiService'; 4 | 5 | type Dependencies = { 6 | conduitApiService: ConduitApiService 7 | }; 8 | 9 | export default ({ conduitApiService }: Dependencies): CommentRepository => ({ 10 | async fromArticle(slug) { 11 | const { data } = await conduitApiService.get(`articles/${slug}/comments`); 12 | 13 | return data.comments.map(this._coerceComment); 14 | }, 15 | 16 | async add(comment, { articleSlug, currentUser }) { 17 | const { data } = await conduitApiService.authPost( 18 | `articles/${articleSlug}/comments`, currentUser, 19 | { comment } 20 | ); 21 | 22 | return this._coerceComment(data.comment); 23 | }, 24 | 25 | remove(comment, { articleSlug, currentUser }) { 26 | return conduitApiService.authDel( 27 | `articles/${articleSlug}/comments/${comment.id}`, 28 | currentUser 29 | ); 30 | }, 31 | 32 | _coerceComment(rawComment: any) { 33 | return { 34 | ...rawComment, 35 | createdAt: new Date(rawComment.createdAt) 36 | }; 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/infra/author/authorRepository.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { AuthorRepository } from '../../domain/author'; 3 | import typeof * as ConduitApiService from '../conduit/conduitApiService'; 4 | 5 | type Dependencies = { 6 | conduitApiService: ConduitApiService 7 | }; 8 | 9 | export default ({ conduitApiService }: Dependencies): AuthorRepository => ({ 10 | async getByUsername(authorUsername, { currentUser }) { 11 | const { data } = await conduitApiService 12 | .authGet(this._profileUrl(authorUsername), currentUser); 13 | 14 | return data.profile; 15 | }, 16 | 17 | async setAsFollowing(authorUsername, { currentUser }) { 18 | const { data } = await conduitApiService 19 | .authPost(`${this._profileUrl(authorUsername)}/follow`, currentUser); 20 | 21 | return data.profile; 22 | }, 23 | 24 | async unsetAsFollowing(authorUsername, { currentUser }) { 25 | const { data } = await conduitApiService 26 | .authDel(`${this._profileUrl(authorUsername)}/follow`, currentUser); 27 | 28 | return data.profile; 29 | }, 30 | 31 | _profileUrl(authorUsername) { 32 | return `/profiles/${authorUsername}`; 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /src/infra/cache/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | export const get = (key: string) => { 3 | try { 4 | const rawValue = ((localStorage.getItem(key): any): string); 5 | return JSON.parse(rawValue); 6 | } catch(err) { 7 | console.error('CacheError', err); 8 | return null; 9 | } 10 | }; 11 | 12 | export const set = (key: string, value: any) => { 13 | try { 14 | localStorage.setItem(key, JSON.stringify(value)); 15 | } catch(err) { 16 | console.error('CacheError', err); 17 | } 18 | }; 19 | 20 | export const PERSISTED_STATE_KEY = 'persistedState'; 21 | 22 | export const getCachedState = () => get(PERSISTED_STATE_KEY) || {}; 23 | export const cacheState = (state: any) => set(PERSISTED_STATE_KEY, extractPersistableState(state)); 24 | 25 | const extractPersistableState = ({ user }) => ({ 26 | user: { 27 | user: user.user 28 | ? { token: user.user.token } 29 | : null 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /src/infra/conduit/conduitApiService.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import Axios, { type AxiosXHRConfigBase, type $AxiosError } from 'axios'; 3 | import type { User } from '../../domain/user'; 4 | 5 | const API_URL: string = process.env.REACT_APP_API_URL || ''; 6 | 7 | const axios = Axios.create({ 8 | baseURL: API_URL, 9 | headers: { 10 | 'Content-Type': 'application/json; charset=utf-8' 11 | } 12 | }); 13 | 14 | type Options = $Shape>; 15 | 16 | type RequestWithoutData = (string, Options) => Promise; 17 | type RequestWithData = (string, mixed, Options) => Promise; 18 | type AuthRequestWithoutData = (string, ?User, Options) => Promise; 19 | type AuthRequestWithData = (string, ?User, mixed, Options) => Promise; 20 | type Request = RequestWithoutData | RequestWithData; 21 | 22 | type ConduitError = Error & { 23 | errors: Object 24 | }; 25 | 26 | const wrapErrorExtraction = (request: Request): any => async (...args) => { 27 | try { 28 | return await request(...args); 29 | } catch(error) { 30 | throw extractErrors(error); 31 | } 32 | }; 33 | 34 | export const post = wrapErrorExtraction(axios.post); 35 | export const get = wrapErrorExtraction(axios.get); 36 | const del = wrapErrorExtraction(axios.delete); 37 | const put = wrapErrorExtraction(axios.put); 38 | 39 | export const authGet: AuthRequestWithoutData = (url, user, options = {}) => 40 | get(url, withUserToken(options, user)); 41 | 42 | export const authPost: AuthRequestWithData = (url, user, data = {}, options = {}) => 43 | post(url, data, withUserToken(options, user)); 44 | 45 | export const authDel: AuthRequestWithoutData = (url, user, options = {}) => 46 | del(url, withUserToken(options, user)); 47 | 48 | export const authPut: AuthRequestWithData = (url, user, data = {}, options = {}) => 49 | put(url, data, withUserToken(options, user)); 50 | 51 | type SuccessResponse = Object; 52 | 53 | type FailureResponse = { 54 | errors: Object 55 | }; 56 | 57 | type Response = SuccessResponse | FailureResponse; 58 | 59 | const extractErrors = (ajaxError: $AxiosError) => { 60 | const error = ((new Error(): any): ConduitError); 61 | 62 | if(!ajaxError.response) { 63 | return ajaxError; 64 | } 65 | 66 | error.errors = ajaxError.response.data.errors; 67 | 68 | return error; 69 | }; 70 | 71 | const withUserToken = (options: Options, user: ?User): Options =>{ 72 | if(!user) { 73 | return options; 74 | } 75 | 76 | return { 77 | ...options, 78 | headers: { 79 | ...options.headers, 80 | Authorization: `Token ${user.token}` 81 | } 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /src/infra/serviceWorker/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/infra/tag/tagRepository.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { TagRepository } from '../../domain/tag'; 3 | import typeof * as ConduitApiService from '../conduit/conduitApiService'; 4 | 5 | type Dependencies = { 6 | conduitApiService: ConduitApiService 7 | }; 8 | 9 | export default ({ conduitApiService }: Dependencies): TagRepository => ({ 10 | async getPopularTags() { 11 | const { data } = await conduitApiService.get('tags'); 12 | 13 | return data.tags; 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /src/infra/user/__tests__/userRepository.test.js: -------------------------------------------------------------------------------- 1 | import makeUserRepository from '../userRepository'; 2 | 3 | describe('Infra :: User :: userRepository', () => { 4 | let userRepository; 5 | let conduitApiService; 6 | const successResponse = Promise.resolve({ data: { user: 'user' }}); 7 | const failedResponse = Promise.reject({ 8 | errors: ['fail', 'boom'] 9 | }); 10 | 11 | describe('#byAuthInfo', () => { 12 | it('uses conduitApiService to make the request', async () => { 13 | conduitApiService = { 14 | post: jest.fn().mockReturnValue(successResponse) 15 | }; 16 | 17 | userRepository = makeUserRepository({ conduitApiService }); 18 | 19 | await userRepository.byAuthInfo('userAuthInfo'); 20 | 21 | expect(conduitApiService.post).toBeCalledWith('users/login', { user: 'userAuthInfo' }); 22 | }); 23 | 24 | describe('when the request succeeds', () => { 25 | beforeEach(() => { 26 | conduitApiService = { 27 | post: jest.fn().mockReturnValue(successResponse) 28 | }; 29 | 30 | userRepository = makeUserRepository({ conduitApiService }); 31 | }); 32 | 33 | it('resolves with the authorized user', () => { 34 | expect(userRepository.byAuthInfo('userAuthInfo')) 35 | .resolves.toEqual('user'); 36 | }); 37 | }); 38 | 39 | describe('when the request fails', () => { 40 | beforeEach(() => { 41 | conduitApiService = { 42 | post: jest.fn().mockReturnValue(failedResponse) 43 | }; 44 | 45 | userRepository = makeUserRepository({ conduitApiService }); 46 | }); 47 | 48 | it('rejects with the errors', () => { 49 | expect(userRepository.byAuthInfo('userAuthInfo')) 50 | .rejects.toMatchObject({ 51 | errors: ['fail', 'boom'] 52 | }); 53 | }); 54 | }); 55 | }); 56 | 57 | describe('#add', () => { 58 | it('uses conduitApiService to make the request', async () => { 59 | conduitApiService = { 60 | post: jest.fn().mockReturnValue(successResponse) 61 | }; 62 | 63 | userRepository = makeUserRepository({ conduitApiService }); 64 | 65 | await userRepository.add('userData'); 66 | 67 | expect(conduitApiService.post).toBeCalledWith('users', { user: 'userData' }); 68 | }); 69 | 70 | describe('when the request succeeds', () => { 71 | beforeEach(() => { 72 | conduitApiService = { 73 | post: jest.fn().mockReturnValue(successResponse) 74 | }; 75 | 76 | userRepository = makeUserRepository({ conduitApiService }); 77 | }); 78 | 79 | it('resolves with the authorized user', () => { 80 | expect(userRepository.add('userData')) 81 | .resolves.toEqual('user'); 82 | }); 83 | }); 84 | 85 | describe('when the request fails', () => { 86 | beforeEach(() => { 87 | conduitApiService = { 88 | post: jest.fn().mockReturnValue(failedResponse) 89 | }; 90 | 91 | userRepository = makeUserRepository({ conduitApiService }); 92 | }); 93 | 94 | it('rejects with the errors', () => { 95 | expect(userRepository.add('userData')) 96 | .rejects.toMatchObject({ 97 | errors: ['fail', 'boom'] 98 | }); 99 | }); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/infra/user/userRepository.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { UserRepository } from '../../domain/user'; 3 | import typeof * as ConduitApiService from '../conduit/conduitApiService'; 4 | 5 | type Dependencies = { 6 | conduitApiService: ConduitApiService 7 | }; 8 | 9 | export default ({ conduitApiService }: Dependencies): UserRepository => ({ 10 | byAuthInfo(userAuthInfo) { 11 | return this._authUser(userAuthInfo, 'users/login'); 12 | }, 13 | 14 | add(user) { 15 | return this._authUser(user, 'users'); 16 | }, 17 | 18 | async update(editingUser, { currentUser }) { 19 | const { data } = await conduitApiService.authPut('user', currentUser, { 20 | user: this._serializeUserData(editingUser) 21 | }); 22 | 23 | return data.user; 24 | }, 25 | 26 | async getByToken({ currentUser }) { 27 | const { data } = await conduitApiService.authGet('user', currentUser); 28 | 29 | return data.user; 30 | }, 31 | 32 | async _authUser(user, url) { 33 | const { data } = await conduitApiService.post(url, { user }); 34 | 35 | return data.user; 36 | }, 37 | 38 | _serializeUserData({ email, username, bio, image, password }) { 39 | return { 40 | email, 41 | username, 42 | bio, 43 | image, 44 | password 45 | }; 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /src/state/actionTypes.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import keyMirror from 'keymirror-nested'; 3 | 4 | const glue = '/'; 5 | 6 | export const AUTH = keyMirror({ 7 | SIGN_IN_REQUEST: null, 8 | SIGN_IN_SUCCESS: null, 9 | SIGN_IN_ERROR: null, 10 | SIGN_OUT_USER: null, 11 | REGISTER_REQUEST: null, 12 | REGISTER_SUCCESS: null, 13 | REGISTER_ERROR: null, 14 | UPDATE_AUTH_FIELD: null 15 | }, glue, 'AUTH'); 16 | 17 | export const ARTICLE = keyMirror({ 18 | LOAD_ARTICLE_REQUEST: null, 19 | LOAD_ARTICLE_SUCCESS: null, 20 | LOAD_ARTICLE_ERROR: null, 21 | UNLOAD_ARTICLE: null, 22 | ADD_COMMENT_REQUEST: null, 23 | ADD_COMMENT_SUCCESS: null, 24 | ADD_COMMENT_ERROR: null, 25 | REMOVE_COMMENT_REQUEST: null, 26 | REMOVE_COMMENT_SUCCESS: null, 27 | REMOVE_COMMENT_ERROR: null, 28 | CREATE_ARTICLE_REQUEST: null, 29 | CREATE_ARTICLE_SUCCESS: null, 30 | CREATE_ARTICLE_ERROR: null, 31 | EDIT_ARTICLE_REQUEST: null, 32 | EDIT_ARTICLE_SUCCESS: null, 33 | EDIT_ARTICLE_ERROR: null, 34 | TOGGLE_ARTICLE_FAVORITE_STATUS_REQUEST: null, 35 | TOGGLE_ARTICLE_FAVORITE_STATUS_SUCCESS: null, 36 | TOGGLE_ARTICLE_FAVORITE_STATUS_ERROR: null, 37 | REMOVE_ARTICLE_REQUEST: null, 38 | REMOVE_ARTICLE_SUCCESS: null, 39 | REMOVE_ARTICLE_ERROR: null 40 | }, glue, 'ARTICLE'); 41 | 42 | export const FEED = keyMirror({ 43 | LOAD_GLOBAL_FEED_REQUEST: null, 44 | LOAD_USER_FEED_REQUEST: null, 45 | LOAD_TAG_FEED_REQUEST: null, 46 | LOAD_AUTHOR_FEED_REQUEST: null, 47 | LOAD_AUTHOR_FAVORITES_FEED_REQUEST: null, 48 | LOAD_FEED_SUCCESS: null, 49 | LOAD_FEED_ERROR: null 50 | }, glue, 'FEED'); 51 | 52 | export const EDITOR = keyMirror({ 53 | UPDATE_FIELD: null, 54 | ADD_TAG: null, 55 | REMOVE_TAG: null, 56 | RESET: null, 57 | SET_EDITING_ARTICLE_REQUEST: null, 58 | SET_EDITING_ARTICLE_SUCCESS: null, 59 | SET_EDITING_ARTICLE_ERROR: null 60 | }, glue, 'EDITOR'); 61 | 62 | export const TAG = keyMirror({ 63 | LOAD_POPULAR_TAGS_REQUEST: null, 64 | LOAD_POPULAR_TAGS_SUCCESS: null, 65 | LOAD_POPULAR_TAGS_ERROR: null 66 | }, glue, 'TAG'); 67 | 68 | export const AUTHOR = keyMirror({ 69 | LOAD_AUTHOR_REQUEST: null, 70 | LOAD_AUTHOR_SUCCESS: null, 71 | LOAD_AUTHOR_ERROR: null, 72 | TOGGLE_AUTHOR_FOLLOW_STATUS_REQUEST: null, 73 | TOGGLE_AUTHOR_FOLLOW_STATUS_SUCCESS: null, 74 | TOGGLE_AUTHOR_FOLLOW_STATUS_ERROR: null 75 | }, glue, 'AUTHOR'); 76 | 77 | export const SETTINGS = keyMirror({ 78 | LOAD_EDITING_USER: null, 79 | UPDATE_USER_FIELD: null, 80 | RESET: null 81 | }, glue, 'SETTINGS'); 82 | 83 | export const USER = keyMirror({ 84 | UPDATE_SETTINGS_REQUEST: null, 85 | UPDATE_SETTINGS_SUCCESS: null, 86 | UPDATE_SETTINGS_ERROR: null, 87 | LOAD_USER_REQUEST: null, 88 | LOAD_USER_SUCCESS: null, 89 | LOAD_USER_ERROR: null, 90 | SET_USER_SIGNED_OUT: null 91 | }, glue, 'USER'); 92 | -------------------------------------------------------------------------------- /src/state/article/article.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Dispatch, Reducer } from 'redux'; 3 | import { 4 | updateArticle, 5 | type Article, 6 | type ArticleSlug, 7 | type Comment, 8 | type EditingArticle 9 | } from '../../domain/article'; 10 | import { updateAuthor } from '../../domain/author'; 11 | import typeof * as Container from '../../container'; 12 | import type { GetState } from '../store'; 13 | import withCurrentUser from '../withCurrentUser'; 14 | import { ARTICLE, AUTHOR } from '../actionTypes'; 15 | 16 | export const ArticleStatuses = { 17 | INIT: 'INIT', 18 | LOADING: 'LOADING', 19 | LOADED: 'LOADED', 20 | FAILED_LOADING: 'FAILED_LOADING', 21 | REMOVING: 'REMOVING', 22 | REMOVED: 'REMOVED' 23 | }; 24 | 25 | export type ArticleStatus = $Keys; 26 | 27 | export type ArticleState = {| 28 | article: ?Article, 29 | status: ArticleStatus, 30 | error: ?Object, 31 | comments: Array, 32 | favoritingArticle: ?Article 33 | |}; 34 | 35 | const initialState: ArticleState = { 36 | article: null, 37 | status: ArticleStatuses.INIT, 38 | error: null, 39 | comments: [], 40 | favoritingArticle: null 41 | }; 42 | 43 | export const articleReducer: Reducer = (state = initialState, action) => { 44 | switch(action.type) { 45 | case ARTICLE.LOAD_ARTICLE_REQUEST: 46 | return { 47 | ...state, 48 | status: ArticleStatuses.LOADING, 49 | error: null 50 | }; 51 | 52 | case ARTICLE.LOAD_ARTICLE_SUCCESS: 53 | case ARTICLE.CREATE_ARTICLE_SUCCESS: 54 | case ARTICLE.EDIT_ARTICLE_SUCCESS: 55 | return { 56 | ...state, 57 | status: ArticleStatuses.LOADED, 58 | article: action.article, 59 | comments: action.comments 60 | }; 61 | 62 | case ARTICLE.LOAD_ARTICLE_ERROR: 63 | return { 64 | ...state, 65 | status: ArticleStatuses.FAILED_LOADING, 66 | error: action.error 67 | }; 68 | 69 | case ARTICLE.ADD_COMMENT_SUCCESS: 70 | return { 71 | ...state, 72 | comments: [ 73 | action.comment, 74 | ...state.comments 75 | ] 76 | }; 77 | 78 | case ARTICLE.REMOVE_COMMENT_SUCCESS: 79 | return { 80 | ...state, 81 | comments: state.comments.filter((comment) => comment.id !== action.comment.id) 82 | }; 83 | 84 | case ARTICLE.TOGGLE_ARTICLE_FAVORITE_STATUS_REQUEST: 85 | return { 86 | ...state, 87 | favoritingArticle: action.article 88 | }; 89 | 90 | case ARTICLE.TOGGLE_ARTICLE_FAVORITE_STATUS_SUCCESS: 91 | return { 92 | ...state, 93 | favoritingArticle: null, 94 | article: updateArticle(state.article, action.article) 95 | }; 96 | 97 | case AUTHOR.TOGGLE_AUTHOR_FOLLOW_STATUS_SUCCESS: 98 | return { 99 | ...state, 100 | article: updateAuthor(state.article, action.author) 101 | }; 102 | 103 | case ARTICLE.REMOVE_ARTICLE_REQUEST: 104 | return { 105 | ...state, 106 | status: ArticleStatuses.REMOVING 107 | }; 108 | 109 | case ARTICLE.REMOVE_ARTICLE_SUCCESS: 110 | return { 111 | ...state, 112 | status: ArticleStatuses.REMOVED 113 | }; 114 | 115 | case ARTICLE.REMOVE_ARTICLE_ERROR: 116 | return { 117 | ...state, 118 | status: ArticleStatuses.LOADED 119 | }; 120 | 121 | case ARTICLE.UNLOAD_ARTICLE: 122 | return initialState; 123 | 124 | default: 125 | return state; 126 | } 127 | }; 128 | 129 | export const loadArticle = (slug: ArticleSlug) => { 130 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 131 | const state = getState(); 132 | 133 | if(!shouldLoadArticle(state, slug)) { 134 | return; 135 | } 136 | 137 | const options = { 138 | ...withCurrentUser(state), 139 | withComments: true 140 | }; 141 | 142 | dispatch(loadArticleRequest); 143 | 144 | return container.getArticle(slug, options, { 145 | onSuccess: ({ article, comments }) => dispatch(loadArticleSuccess(article, comments)), 146 | onError: (error) => dispatch(loadArticleError(error)) 147 | }); 148 | }; 149 | }; 150 | 151 | const shouldLoadArticle = (state, slug) => { 152 | const { article } = state; 153 | 154 | return !article.article || !(article.article.slug === slug); 155 | }; 156 | 157 | const loadArticleRequest = { 158 | type: ARTICLE.LOAD_ARTICLE_REQUEST 159 | }; 160 | 161 | const loadArticleSuccess = (article, comments) => ({ 162 | type: ARTICLE.LOAD_ARTICLE_SUCCESS, 163 | article, 164 | comments 165 | }); 166 | 167 | const loadArticleError = (error) => ({ 168 | type: ARTICLE.LOAD_ARTICLE_ERROR, 169 | error 170 | }); 171 | 172 | export const addComment = (commentBody: string, articleSlug: ArticleSlug) => { 173 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 174 | dispatch(addCommentRequest); 175 | 176 | const options = { 177 | ...withCurrentUser(getState()), 178 | articleSlug 179 | }; 180 | 181 | container.addComment(commentBody, options, { 182 | onSuccess: (comment) => dispatch(addCommentSuccess(comment)), 183 | onError: (error) => dispatch(addCommentError(error)) 184 | }); 185 | }; 186 | }; 187 | 188 | const addCommentRequest = { 189 | type: ARTICLE.ADD_COMMENT_REQUEST 190 | }; 191 | 192 | const addCommentSuccess = (comment) => ({ 193 | type: ARTICLE.ADD_COMMENT_SUCCESS, 194 | comment 195 | }); 196 | 197 | const addCommentError = (error) => ({ 198 | type: ARTICLE.ADD_COMMENT_ERROR, 199 | error 200 | }); 201 | 202 | export const removeComment = (comment: Comment, articleSlug: ArticleSlug) => { 203 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 204 | dispatch(removeCommentRequest); 205 | 206 | const options = { 207 | ...withCurrentUser(getState()), 208 | articleSlug 209 | }; 210 | 211 | container.removeComment(comment, options, { 212 | onSuccess: () => dispatch(removeCommentSuccess(comment)), 213 | onError: (error) => dispatch(removeCommentError(error)) 214 | }); 215 | }; 216 | }; 217 | 218 | const removeCommentRequest = { 219 | type: ARTICLE.REMOVE_COMMENT_REQUEST 220 | }; 221 | 222 | const removeCommentSuccess = (comment) => ({ 223 | type: ARTICLE.REMOVE_COMMENT_SUCCESS, 224 | comment 225 | }); 226 | 227 | const removeCommentError = (error) => ({ 228 | type: ARTICLE.REMOVE_COMMENT_ERROR, 229 | error 230 | }); 231 | 232 | export const unloadArticle = () => ({ 233 | type: ARTICLE.UNLOAD_ARTICLE 234 | }); 235 | 236 | export const createArticle = (editingArticle: EditingArticle) => { 237 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 238 | dispatch(createArticleRequest); 239 | 240 | const options = withCurrentUser(getState()); 241 | 242 | container.createArticle(editingArticle, options, { 243 | onSuccess: (article: Article) => dispatch(createArticleSuccess(article)), 244 | onError: (error) => dispatch(createArticleError(error.errors)) 245 | }); 246 | }; 247 | }; 248 | 249 | const createArticleRequest = { 250 | type: ARTICLE.CREATE_ARTICLE_REQUEST 251 | }; 252 | 253 | const createArticleSuccess = (article) => ({ 254 | type: ARTICLE.CREATE_ARTICLE_SUCCESS, 255 | article, 256 | comments: [] 257 | }); 258 | 259 | const createArticleError = (errors) => ({ 260 | type: ARTICLE.CREATE_ARTICLE_ERROR, 261 | errors 262 | }); 263 | 264 | export const editArticle = (editingArticle: EditingArticle) => { 265 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 266 | dispatch(editArticleRequest); 267 | 268 | const options = withCurrentUser(getState()); 269 | 270 | container.editArticle(editingArticle, options, { 271 | onSuccess: ({ article, comments }) => dispatch(editArticleSuccess(article, comments)), 272 | onError: (error) => dispatch(editArticleError(error.errors)) 273 | }); 274 | }; 275 | }; 276 | 277 | const editArticleRequest = { 278 | type: ARTICLE.EDIT_ARTICLE_REQUEST 279 | }; 280 | 281 | const editArticleSuccess = (article, comments) => ({ 282 | type: ARTICLE.EDIT_ARTICLE_SUCCESS, 283 | article, 284 | comments 285 | }); 286 | 287 | const editArticleError = (errors) => ({ 288 | type: ARTICLE.EDIT_ARTICLE_ERROR, 289 | errors 290 | }); 291 | 292 | export const toggleArticleFavoriteStatus = (article: Article) => { 293 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 294 | dispatch(toggleArticleFavoriteStatusRequest(article)); 295 | 296 | const options = withCurrentUser(getState()); 297 | 298 | container.toggleArticleFavoriteStatus(article, options, { 299 | onSuccess: (article) => dispatch(toggleArticleFavoriteStatusSuccess(article)), 300 | onError: (error) => dispatch(toggleArticleFavoriteStatusError(error.errors)) 301 | }); 302 | }; 303 | }; 304 | 305 | const toggleArticleFavoriteStatusRequest = (article) => ({ 306 | type: ARTICLE.TOGGLE_ARTICLE_FAVORITE_STATUS_REQUEST, 307 | article 308 | }); 309 | 310 | const toggleArticleFavoriteStatusSuccess = (article) => ({ 311 | type: ARTICLE.TOGGLE_ARTICLE_FAVORITE_STATUS_SUCCESS, 312 | article 313 | }); 314 | 315 | const toggleArticleFavoriteStatusError = (errors) => ({ 316 | type: ARTICLE.TOGGLE_ARTICLE_FAVORITE_STATUS_ERROR, 317 | errors 318 | }); 319 | 320 | export const removeArticle = (article: Article) => { 321 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 322 | dispatch(removeArticleRequest); 323 | 324 | const options = withCurrentUser(getState()); 325 | 326 | container.removeArticle(article, options, { 327 | onSuccess: (article) => dispatch(removeArticleSuccess), 328 | onError: (error) => dispatch(removeArticleError(error.errors)) 329 | }); 330 | }; 331 | }; 332 | 333 | const removeArticleRequest = { 334 | type: ARTICLE.REMOVE_ARTICLE_REQUEST 335 | }; 336 | 337 | const removeArticleSuccess = { 338 | type: ARTICLE.REMOVE_ARTICLE_SUCCESS, 339 | }; 340 | 341 | const removeArticleError = (errors) => ({ 342 | type: ARTICLE.REMOVE_ARTICLE_ERROR, 343 | errors 344 | }); 345 | -------------------------------------------------------------------------------- /src/state/article/editor.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Reducer } from 'redux'; 3 | import type { EditingArticle, ArticleSlug } from '../../domain/article'; 4 | import type { Tag } from '../../domain/tag'; 5 | import typeof * as Container from '../../container'; 6 | import type { GetState } from '../store'; 7 | import withCurrentUser from '../withCurrentUser'; 8 | import { ARTICLE, EDITOR } from '../actionTypes'; 9 | 10 | export const EditorStatuses = { 11 | INIT: 'INIT', 12 | LOADING: 'LOADING', 13 | LOADED: 'LOADED', 14 | FAILED_LOADING: 'FAILED_LOADING', 15 | SAVING: 'SAVING', 16 | SAVED: 'SAVED', 17 | FAILED_SAVING: 'FAILED_SAVING' 18 | }; 19 | 20 | export type EditorStatus = $Keys; 21 | 22 | export type EditorState = {| 23 | status: EditorStatus, 24 | article: EditingArticle, 25 | errors: ?Object 26 | |}; 27 | 28 | const initialState: EditorState = { 29 | status: EditorStatuses.INIT, 30 | article: { 31 | title: '', 32 | description: '', 33 | body: '', 34 | tagList: [], 35 | author: null 36 | }, 37 | errors: null 38 | }; 39 | 40 | export const editorReducer: Reducer = (state = initialState, action) => { 41 | switch(action.type) { 42 | case EDITOR.UPDATE_FIELD: 43 | return { 44 | ...state, 45 | article: { 46 | ...state.article, 47 | [action.key]: action.value 48 | } 49 | }; 50 | 51 | case EDITOR.ADD_TAG: 52 | if(state.article.tagList.includes(action.tag)) { 53 | return state; 54 | } 55 | 56 | return { 57 | ...state, 58 | article: { 59 | ...state.article, 60 | tagList: [ 61 | ...state.article.tagList, 62 | action.tag 63 | ] 64 | } 65 | }; 66 | 67 | case EDITOR.REMOVE_TAG: 68 | return { 69 | ...state, 70 | article: { 71 | ...state.article, 72 | tagList: state.article.tagList.filter((tag) => tag !== action.tag) 73 | } 74 | }; 75 | 76 | case ARTICLE.CREATE_ARTICLE_REQUEST: 77 | case ARTICLE.EDIT_ARTICLE_REQUEST: 78 | return { 79 | ...state, 80 | status: EditorStatuses.SAVING 81 | }; 82 | 83 | case ARTICLE.CREATE_ARTICLE_SUCCESS: 84 | case ARTICLE.EDIT_ARTICLE_SUCCESS: 85 | return { 86 | ...state, 87 | status: EditorStatuses.SAVED 88 | }; 89 | 90 | case ARTICLE.CREATE_ARTICLE_ERROR: 91 | case ARTICLE.EDIT_ARTICLE_ERROR: 92 | return { 93 | ...state, 94 | status: EditorStatuses.FAILED_SAVING, 95 | errors: action.errors 96 | }; 97 | 98 | case EDITOR.RESET: 99 | return initialState; 100 | 101 | case EDITOR.SET_EDITING_ARTICLE_REQUEST: 102 | return { 103 | ...state, 104 | status: EditorStatuses.LOADING 105 | }; 106 | 107 | case EDITOR.SET_EDITING_ARTICLE_SUCCESS: 108 | return { 109 | ...state, 110 | article: action.article, 111 | status: EditorStatuses.LOADED 112 | }; 113 | 114 | case EDITOR.SET_EDITING_ARTICLE_ERROR: 115 | return { 116 | ...state, 117 | errors: action.error, 118 | status: EditorStatuses.FAILED_LOADING 119 | }; 120 | 121 | default: 122 | return state; 123 | } 124 | }; 125 | 126 | export const resetEditor = () => ({ 127 | type: EDITOR.RESET 128 | }); 129 | 130 | export const updateField = (key: string, value: string) => ({ 131 | type: EDITOR.UPDATE_FIELD, 132 | key, 133 | value 134 | }); 135 | 136 | export const addTag = (tag: Tag) => ({ 137 | type: EDITOR.ADD_TAG, 138 | tag 139 | }); 140 | 141 | export const removeTag = (tag: Tag) => ({ 142 | type: EDITOR.REMOVE_TAG, 143 | tag 144 | }); 145 | 146 | export const setEditingArticle = (articleSlug: ArticleSlug) => { 147 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 148 | dispatch(setEditingArticleRequest); 149 | 150 | const options = { 151 | ...withCurrentUser(getState()), 152 | withComments: false 153 | }; 154 | 155 | container.getArticle(articleSlug, options, { 156 | onSuccess: ({ article }) => dispatch(setEditingArticleSuccess(article)), 157 | onError: (error) => dispatch(setEditingArticleError(error)) 158 | }); 159 | }; 160 | }; 161 | 162 | const setEditingArticleRequest = { 163 | type: EDITOR.SET_EDITING_ARTICLE_REQUEST 164 | }; 165 | 166 | const setEditingArticleSuccess = (article) => ({ 167 | type: EDITOR.SET_EDITING_ARTICLE_SUCCESS, 168 | article 169 | }); 170 | 171 | const setEditingArticleError = (error) => ({ 172 | type: EDITOR.SET_EDITING_ARTICLE_ERROR, 173 | error 174 | }); 175 | -------------------------------------------------------------------------------- /src/state/article/feed.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Dispatch, Reducer } from 'redux'; 3 | import { updateArticle, type Article } from '../../domain/article'; 4 | import type { Tag } from '../../domain/tag'; 5 | import typeof * as Container from '../../container'; 6 | import type { GetState } from '../store'; 7 | import withCurrentUser from '../withCurrentUser'; 8 | import { FEED, ARTICLE } from '../actionTypes'; 9 | 10 | export type FeedState = {| 11 | articles: Array
, 12 | isLoading: bool, 13 | error: ?Error 14 | |}; 15 | 16 | const initialState: FeedState = { 17 | articles: [], 18 | isLoading: false, 19 | error: null 20 | }; 21 | 22 | export const feedReducer: Reducer = (state = initialState, action) => { 23 | switch(action.type) { 24 | case FEED.LOAD_GLOBAL_FEED_REQUEST: 25 | case FEED.LOAD_USER_FEED_REQUEST: 26 | case FEED.LOAD_TAG_FEED_REQUEST: 27 | case FEED.LOAD_AUTHOR_FEED_REQUEST: 28 | case FEED.LOAD_AUTHOR_FAVORITES_FEED_REQUEST: 29 | return { 30 | ...state, 31 | isLoading: true, 32 | error: null 33 | }; 34 | 35 | case FEED.LOAD_FEED_SUCCESS: 36 | return { 37 | ...state, 38 | isLoading: false, 39 | articles: action.feed.articles 40 | }; 41 | 42 | case FEED.LOAD_FEED_ERROR: 43 | return { 44 | ...state, 45 | isLoading: false, 46 | error: action.error 47 | }; 48 | 49 | case ARTICLE.TOGGLE_ARTICLE_FAVORITE_STATUS_SUCCESS: 50 | return { 51 | ...state, 52 | articles: state.articles.map((article) => 53 | ((updateArticle(article, action.article): any): Article) 54 | ) 55 | }; 56 | 57 | default: 58 | return state; 59 | } 60 | }; 61 | 62 | export const loadGlobalFeed = () => (dispatch: Dispatch, getState: GetState, container: Container) => { 63 | dispatch(loadGlobalFeedRequest); 64 | 65 | const options = withCurrentUser(getState()); 66 | 67 | container.getGlobalFeed(options, { 68 | onSuccess: (feed) => dispatch(loadFeedSuccess(feed)), 69 | onError: (error) => dispatch(loadFeedError(error)) 70 | }); 71 | }; 72 | 73 | const loadGlobalFeedRequest = { 74 | type: FEED.LOAD_GLOBAL_FEED_REQUEST 75 | }; 76 | 77 | 78 | export const loadUserFeed = () => (dispatch: Dispatch, getState: GetState, container: Container) => { 79 | dispatch(loadUserFeedRequest); 80 | 81 | const { user } = getState().user; 82 | 83 | container.getUserFeed(user, { 84 | onSuccess: (feed) => dispatch(loadFeedSuccess(feed)), 85 | onError: (error) => dispatch(loadFeedError(error)) 86 | }); 87 | }; 88 | 89 | const loadUserFeedRequest = { 90 | type: FEED.LOAD_USER_FEED_REQUEST 91 | }; 92 | 93 | export const loadTagFeed = (tag: Tag) => { 94 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 95 | dispatch(loadTagFeedRequest); 96 | 97 | const options = withCurrentUser(getState()); 98 | 99 | container.getTagFeed(tag, options, { 100 | onSuccess: (feed) => dispatch(loadFeedSuccess(feed)), 101 | onError: (error) => dispatch(loadFeedError(error)) 102 | }); 103 | }; 104 | }; 105 | 106 | export const loadAuthorFeed = (authorUsername: string) => { 107 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 108 | dispatch(loadAuthorFeedRequest); 109 | 110 | const options = withCurrentUser(getState()); 111 | 112 | container.getAuthorFeed(authorUsername, options, { 113 | onSuccess: (feed) => dispatch(loadFeedSuccess(feed)), 114 | onError: (error) => dispatch(loadFeedError(error)) 115 | }); 116 | }; 117 | }; 118 | 119 | export const loadAuthorFavoritesFeed = (authorUsername: string) => { 120 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 121 | dispatch(loadAuthorFavoritesFeedRequest); 122 | 123 | const options = withCurrentUser(getState()); 124 | 125 | container.getAuthorFavoritesFeed(authorUsername, options, { 126 | onSuccess: (feed) => dispatch(loadFeedSuccess(feed)), 127 | onError: (error) => dispatch(loadFeedError(error)) 128 | }); 129 | } 130 | }; 131 | 132 | const loadAuthorFeedRequest = { 133 | type: FEED.LOAD_AUTHOR_FEED_REQUEST 134 | }; 135 | 136 | const loadAuthorFavoritesFeedRequest = { 137 | type: FEED.LOAD_AUTHOR_FAVORITES_FEED_REQUEST 138 | }; 139 | 140 | const loadTagFeedRequest = { 141 | type: FEED.LOAD_TAG_FEED_REQUEST 142 | }; 143 | 144 | const loadFeedSuccess = (feed) => ({ 145 | type: FEED.LOAD_FEED_SUCCESS, 146 | feed 147 | }); 148 | 149 | const loadFeedError = (error) => ({ 150 | type: FEED.LOAD_FEED_ERROR, 151 | error 152 | }); 153 | -------------------------------------------------------------------------------- /src/state/article/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import * as article from './article'; 3 | import * as feed from './feed'; 4 | import * as editor from './editor'; 5 | 6 | export type { FeedState } from './feed'; 7 | export type { ArticleState } from './article'; 8 | export type { EditorState, EditorStatus } from './editor'; 9 | 10 | export { 11 | article, 12 | feed, 13 | editor 14 | }; 15 | -------------------------------------------------------------------------------- /src/state/auth/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Dispatch, Reducer } from 'redux'; 3 | import typeof * as Container from '../../container'; 4 | import type { User, UserAuthInfo } from '../../domain/user'; 5 | import { AUTH } from '../actionTypes'; 6 | 7 | export type AuthState = {| 8 | userAuthInfo: UserAuthInfo, 9 | errors: ?Object, 10 | isLoading: bool 11 | |}; 12 | 13 | const initialState: AuthState = { 14 | userAuthInfo: {}, 15 | errors: null, 16 | isLoading: false 17 | }; 18 | 19 | export const authReducer: Reducer = (state = initialState, action) => { 20 | switch(action.type) { 21 | case AUTH.UPDATE_AUTH_FIELD: 22 | return { 23 | ...state, 24 | userAuthInfo: { 25 | ...state.userAuthInfo, 26 | ...action.userAuthInfo 27 | } 28 | }; 29 | case AUTH.SIGN_IN_REQUEST: 30 | case AUTH.REGISTER_REQUEST: 31 | return { 32 | ...state, 33 | isLoading: true, 34 | errors: null 35 | }; 36 | case AUTH.SIGN_IN_SUCCESS: 37 | case AUTH.REGISTER_SUCCESS: 38 | return { 39 | ...state, 40 | userAuthInfo: {}, 41 | isLoading: false 42 | }; 43 | case AUTH.SIGN_IN_ERROR: 44 | case AUTH.REGISTER_ERROR: 45 | return { 46 | ...state, 47 | userAuthInfo: { 48 | ...state.userAuthInfo, 49 | password: null 50 | }, 51 | errors: action.errors, 52 | isLoading: false 53 | }; 54 | default: 55 | return state; 56 | } 57 | }; 58 | 59 | export const updateAuthField = (userAuthInfo: UserAuthInfo) => ({ 60 | type: AUTH.UPDATE_AUTH_FIELD, 61 | userAuthInfo 62 | }); 63 | 64 | export const signInUser = (userInfo: UserAuthInfo) => { 65 | return (dispatch: Dispatch, _: any, container: Container) => { 66 | dispatch(signInUserRequest); 67 | 68 | return container.signInUser(userInfo, { 69 | onSuccess: (user) => dispatch(signInUserSuccess(user)), 70 | onError: (error) => dispatch(signInUserError(error.errors)) 71 | }); 72 | }; 73 | } 74 | 75 | const signInUserRequest = { 76 | type: AUTH.SIGN_IN_REQUEST 77 | }; 78 | 79 | const signInUserSuccess = (user: User) => ({ 80 | type: AUTH.SIGN_IN_SUCCESS, 81 | user 82 | }); 83 | 84 | const signInUserError = (errors) => ({ 85 | type: AUTH.SIGN_IN_ERROR, 86 | errors 87 | }); 88 | 89 | export const registerUser = (userAuthInfo: UserAuthInfo) => { 90 | return (dispatch: Dispatch, _: any, container: Container) => { 91 | dispatch(registerUserRequest); 92 | 93 | return container.registerUser(userAuthInfo, { 94 | onSuccess: (user) => dispatch(registerUserSuccess(user)), 95 | onError: (error) => dispatch(registerUserError(error.errors)) 96 | }); 97 | }; 98 | } 99 | 100 | const registerUserRequest = { 101 | type: AUTH.REGISTER_REQUEST 102 | }; 103 | 104 | const registerUserSuccess = (user: User) => ({ 105 | type: AUTH.REGISTER_SUCCESS, 106 | user 107 | }); 108 | 109 | const registerUserError = (errors) => ({ 110 | type: AUTH.REGISTER_ERROR, 111 | errors 112 | }); 113 | 114 | export const signOutUser = () => ({ 115 | type: AUTH.SIGN_OUT_USER 116 | }); 117 | -------------------------------------------------------------------------------- /src/state/author/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Dispatch, Reducer } from 'redux'; 3 | import typeof * as Container from '../../container'; 4 | import type { GetState } from '../store'; 5 | import type { Author } from '../../domain/author'; 6 | import withCurrentUser from '../withCurrentUser'; 7 | import { AUTHOR } from '../actionTypes'; 8 | 9 | export type AuthorState = {| 10 | author: ?Author, 11 | errors: ?Object, 12 | isLoading: bool, 13 | followingAuthor: ?Author 14 | |}; 15 | 16 | const initialState: AuthorState = { 17 | author: null, 18 | errors: null, 19 | isLoading: false, 20 | followingAuthor: null 21 | }; 22 | 23 | export const authorReducer: Reducer = (state = initialState, action) => { 24 | switch(action.type) { 25 | case AUTHOR.LOAD_AUTHOR_REQUEST: 26 | return { 27 | ...state, 28 | author: null, 29 | isLoading: true, 30 | errors: null 31 | }; 32 | 33 | case AUTHOR.LOAD_AUTHOR_SUCCESS: 34 | return { 35 | ...state, 36 | isLoading: false, 37 | author: action.author 38 | }; 39 | 40 | case AUTHOR.LOAD_AUTHOR_ERROR: 41 | return { 42 | ...state, 43 | isLoading: false, 44 | errors: action.errors 45 | }; 46 | 47 | case AUTHOR.TOGGLE_AUTHOR_FOLLOW_STATUS_REQUEST: 48 | return { 49 | ...state, 50 | followingAuthor: action.author 51 | }; 52 | 53 | case AUTHOR.TOGGLE_AUTHOR_FOLLOW_STATUS_SUCCESS: 54 | return { 55 | ...state, 56 | followingAuthor: null, 57 | author: action.author 58 | }; 59 | 60 | default: 61 | return state; 62 | } 63 | }; 64 | 65 | export const loadAuthor = (authorUsername: string) => { 66 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 67 | dispatch(loadAuthorRequest); 68 | 69 | const options = withCurrentUser(getState()); 70 | 71 | container.getAuthor(authorUsername, options, { 72 | onSuccess: (author) => dispatch(loadAuthorSuccess(author)), 73 | onError: (error) => dispatch(loadAuthorError(error)) 74 | }); 75 | }; 76 | }; 77 | 78 | const loadAuthorRequest = { 79 | type: AUTHOR.LOAD_AUTHOR_REQUEST 80 | }; 81 | 82 | const loadAuthorSuccess = (author) => ({ 83 | type: AUTHOR.LOAD_AUTHOR_SUCCESS, 84 | author 85 | }); 86 | 87 | const loadAuthorError = (error) => ({ 88 | type: AUTHOR.LOAD_AUTHOR_ERROR, 89 | error 90 | }); 91 | 92 | export const toggleAuthorFollowStatus = (author: Author) => { 93 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 94 | dispatch(toggleAuthorFollowStatusRequest(author)); 95 | 96 | const state = getState(); 97 | 98 | if(!state.user.user) { 99 | return dispatch(toggleAuthorFollowStatusError(new Error('Not authenticated'))); 100 | } 101 | 102 | const options = withCurrentUser(state); 103 | 104 | container.toggleAuthorFollowStatus(author, options, { 105 | onSuccess: (author) => dispatch(toggleAuthorFollowStatusSuccess(author)), 106 | onError: (error) => dispatch(toggleAuthorFollowStatusError(error.errors)) 107 | }); 108 | }; 109 | }; 110 | 111 | const toggleAuthorFollowStatusRequest = (author) => ({ 112 | type: AUTHOR.TOGGLE_AUTHOR_FOLLOW_STATUS_REQUEST, 113 | author 114 | }); 115 | 116 | const toggleAuthorFollowStatusSuccess = (author) => ({ 117 | type: AUTHOR.TOGGLE_AUTHOR_FOLLOW_STATUS_SUCCESS, 118 | author 119 | }); 120 | 121 | const toggleAuthorFollowStatusError = (errors) => ({ 122 | type: AUTHOR.TOGGLE_AUTHOR_FOLLOW_STATUS_ERROR, 123 | errors 124 | }); 125 | -------------------------------------------------------------------------------- /src/state/settings/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Dispatch, Reducer } from 'redux'; 3 | import typeof * as Container from '../../container'; 4 | import type { EditingUser } from '../../domain/user'; 5 | import type { GetState } from '../store'; 6 | import { SETTINGS, USER } from '../actionTypes'; 7 | 8 | export const SettingsStatuses = { 9 | INIT: 'INIT', 10 | SAVING: 'SAVING', 11 | SAVED: 'SAVED', 12 | FAILED_SAVING: 'FAILED_SAVING' 13 | }; 14 | 15 | export type SettingsStatus = $Keys; 16 | 17 | export type SettingsState = { 18 | user: EditingUser, 19 | status: SettingsStatus 20 | }; 21 | 22 | const initialState = { 23 | user: { 24 | email: '', 25 | username: '', 26 | bio: '', 27 | image: '', 28 | password: '' 29 | }, 30 | status: SettingsStatuses.INIT 31 | }; 32 | 33 | export const settingsReducer: Reducer = (state = initialState, action) => { 34 | switch(action.type) { 35 | case SETTINGS.LOAD_EDITING_USER: 36 | return { 37 | ...state, 38 | status: SettingsStatuses.INIT, 39 | user: action.user 40 | }; 41 | 42 | case SETTINGS.UPDATE_USER_FIELD: 43 | return { 44 | ...state, 45 | user: { 46 | ...state.user, 47 | ...action.user 48 | } 49 | }; 50 | 51 | case USER.UPDATE_SETTINGS_REQUEST: 52 | return { 53 | ...state, 54 | status: SettingsStatuses.SAVING 55 | }; 56 | 57 | case USER.UPDATE_SETTINGS_SUCCESS: 58 | return { 59 | ...state, 60 | status: SettingsStatuses.SAVED 61 | }; 62 | 63 | case USER.UPDATE_SETTINGS_ERROR: 64 | return { 65 | ...state, 66 | status: SettingsStatuses.FAILED_SAVING 67 | }; 68 | 69 | case SETTINGS.RESET: 70 | return initialState; 71 | 72 | default: 73 | return state; 74 | } 75 | }; 76 | 77 | export const loadEditingUser = () => (dispatch: Dispatch, getState: GetState) => { 78 | const { user } = getState().user; 79 | 80 | dispatch({ 81 | type: SETTINGS.LOAD_EDITING_USER, 82 | user 83 | }); 84 | }; 85 | 86 | export const updateUserField = (user: $Shape) => ({ 87 | type: SETTINGS.UPDATE_USER_FIELD, 88 | user 89 | }); 90 | 91 | export const updateSettings = () => { 92 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 93 | dispatch(updateSettingsRequest); 94 | 95 | const { 96 | user: { user: currentUser }, 97 | settings: { user: editingUser } 98 | } = getState(); 99 | 100 | container.changeSettings(editingUser, { currentUser }, { 101 | onSuccess: (updatedUser) => dispatch(updateSettingsSuccess(updatedUser)), 102 | onError: (errors) => dispatch(updateSettingsError(errors)) 103 | }); 104 | }; 105 | }; 106 | 107 | const updateSettingsRequest = { 108 | type: USER.UPDATE_SETTINGS_REQUEST 109 | }; 110 | 111 | const updateSettingsSuccess = (updatedUser) => ({ 112 | type: USER.UPDATE_SETTINGS_SUCCESS, 113 | user: updatedUser 114 | }); 115 | 116 | const updateSettingsError = (errors) => ({ 117 | type: USER.UPDATE_SETTINGS_ERROR, 118 | errors 119 | }); 120 | 121 | export const resetSettingsPage = () => ({ 122 | type: SETTINGS.RESET 123 | }); 124 | -------------------------------------------------------------------------------- /src/state/store.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import { 3 | createStore, 4 | applyMiddleware, 5 | combineReducers, 6 | type CombinedReducer, 7 | type Store as ReduxStore 8 | } from 'redux'; 9 | import { composeWithDevTools } from 'redux-devtools-extension'; 10 | import thunk from 'redux-thunk'; 11 | import typeof * as Container from '../container'; 12 | import { authReducer as auth, type AuthState } from './auth'; 13 | import { userReducer as user, type UserState } from './user'; 14 | import { authorReducer as author, type AuthorState } from './author'; 15 | import { popularTagsReducer as popularTags, type PopularTagsState } from './tag'; 16 | import { settingsReducer as settings, type SettingsState } from './settings'; 17 | 18 | import { 19 | feed, 20 | article, 21 | editor, 22 | type FeedState, 23 | type ArticleState, 24 | type EditorState 25 | } from './article'; 26 | 27 | export type State = {| 28 | auth: AuthState, 29 | user: UserState, 30 | popularTags: PopularTagsState, 31 | article: ArticleState, 32 | feed: FeedState, 33 | editor: EditorState, 34 | author: AuthorState, 35 | settings: SettingsState 36 | |}; 37 | 38 | export type Store = ReduxStore; 39 | 40 | export type GetState = $PropertyType; 41 | 42 | type Dependencies = { 43 | container: Container, 44 | initialState: State 45 | }; 46 | 47 | const reducer: CombinedReducer = combineReducers({ 48 | auth, 49 | user, 50 | popularTags, 51 | author, 52 | settings, 53 | article: article.articleReducer, 54 | feed: feed.feedReducer, 55 | editor: editor.editorReducer 56 | }); 57 | 58 | export default ({ container, initialState }: Dependencies): Store => ( 59 | createStore( 60 | reducer, 61 | initialState, 62 | composeWithDevTools( 63 | applyMiddleware(thunk.withExtraArgument(container)) 64 | ) 65 | ) 66 | ); 67 | -------------------------------------------------------------------------------- /src/state/tag/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Dispatch, Reducer } from 'redux'; 3 | import typeof * as Container from '../../container'; 4 | import type { Tag } from '../../domain/tag'; 5 | import { TAG } from '../actionTypes'; 6 | 7 | export type PopularTagsState = {| 8 | tags: Array, 9 | error: any, 10 | isLoading: bool 11 | |}; 12 | 13 | const initialState: PopularTagsState = { 14 | tags: [], 15 | error: null, 16 | isLoading: false 17 | }; 18 | 19 | export const popularTagsReducer: Reducer = (state = initialState, action) => { 20 | switch(action.type) { 21 | case TAG.LOAD_POPULAR_TAGS_REQUEST: 22 | return { 23 | ...state, 24 | isLoading: true, 25 | error: null 26 | }; 27 | 28 | case TAG.LOAD_POPULAR_TAGS_SUCCESS: 29 | return { 30 | ...state, 31 | isLoading: false, 32 | tags: action.tags 33 | }; 34 | 35 | case TAG.LOAD_POPULAR_TAGS_ERROR: 36 | return { 37 | ...state, 38 | isLoading: false, 39 | error: action.error 40 | }; 41 | 42 | default: 43 | return state; 44 | } 45 | }; 46 | 47 | export const loadPopularTags = () => { 48 | return (dispatch: Dispatch, _: any, container: Container) => { 49 | dispatch(loadPopularTagsRequest); 50 | 51 | container.getPopularTags({ 52 | onSuccess: (tags) => dispatch(loadPopularTagsSuccess(tags)), 53 | onError: (error) => dispatch(loadPopularTagsError(error)) 54 | }); 55 | }; 56 | }; 57 | 58 | const loadPopularTagsRequest = { 59 | type: TAG.LOAD_POPULAR_TAGS_REQUEST 60 | }; 61 | 62 | const loadPopularTagsSuccess = (tags) => ({ 63 | type: TAG.LOAD_POPULAR_TAGS_SUCCESS, 64 | tags 65 | }); 66 | 67 | const loadPopularTagsError = (error) => ({ 68 | type: TAG.LOAD_POPULAR_TAGS_ERROR, 69 | error 70 | }); 71 | -------------------------------------------------------------------------------- /src/state/user/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { Dispatch, Reducer } from 'redux'; 3 | import type { User } from '../../domain/user'; 4 | import typeof * as Container from '../../container'; 5 | import type { GetState } from '../store'; 6 | import withCurrentUser from '../withCurrentUser'; 7 | import { AUTH, USER } from '../actionTypes'; 8 | 9 | export const UserStatuses = { 10 | INIT: 'INIT', 11 | SIGNING: 'SIGNING', 12 | SIGNED_IN: 'SIGNED_IN', 13 | SIGNED_OUT: 'SIGNED_OUT' 14 | }; 15 | 16 | export type UserStatus = $Keys; 17 | 18 | export type UserState = { 19 | user: ?User, 20 | status: UserStatus 21 | }; 22 | 23 | const initialState = { 24 | user: null, 25 | status: UserStatuses.INIT 26 | }; 27 | 28 | export const userReducer: Reducer = (state = initialState, action) => { 29 | switch(action.type) { 30 | case AUTH.SIGN_IN_SUCCESS: 31 | case AUTH.REGISTER_SUCCESS: 32 | case USER.UPDATE_SETTINGS_SUCCESS: 33 | case USER.LOAD_USER_SUCCESS: 34 | return { 35 | ...state, 36 | user: action.user, 37 | status: UserStatuses.SIGNED_IN 38 | }; 39 | 40 | case AUTH.SIGN_OUT_USER: 41 | return { 42 | ...state, 43 | user: null, 44 | status: UserStatuses.SIGNED_OUT 45 | }; 46 | 47 | case USER.LOAD_USER_REQUEST: 48 | return { 49 | ...state, 50 | status: UserStatuses.SIGNING 51 | }; 52 | 53 | case USER.LOAD_USER_ERROR: 54 | case USER.SET_USER_SIGNED_OUT: 55 | return { 56 | ...state, 57 | status: UserStatuses.SIGNED_OUT 58 | }; 59 | 60 | default: 61 | return state; 62 | } 63 | }; 64 | 65 | export const loadUser = () => { 66 | return (dispatch: Dispatch, getState: GetState, container: Container) => { 67 | const state = getState(); 68 | 69 | if(!state.user.user) { 70 | return dispatch(setUserSignedOut); 71 | } 72 | 73 | dispatch(loadUserRequest); 74 | 75 | container.getUser(withCurrentUser(state), { 76 | onSuccess: (user) => dispatch(loadUserSuccess(user)), 77 | onError: (error) => dispatch(loadUserError) 78 | }); 79 | }; 80 | }; 81 | 82 | const loadUserRequest = { 83 | type: USER.LOAD_USER_REQUEST 84 | }; 85 | 86 | 87 | const loadUserSuccess = (user) => ({ 88 | type: USER.LOAD_USER_SUCCESS, 89 | user 90 | }); 91 | 92 | const loadUserError = { 93 | type: USER.LOAD_USER_ERROR, 94 | }; 95 | 96 | const setUserSignedOut = { 97 | type: USER.SET_USER_SIGNED_OUT 98 | }; 99 | -------------------------------------------------------------------------------- /src/state/withCurrentUser.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import type { State } from './store'; 3 | import type { WithCurrentUser } from '../domain/user'; 4 | 5 | const withCurrentUser = (state: State): WithCurrentUser => ({ 6 | currentUser: state.user.user 7 | }); 8 | 9 | export default withCurrentUser; 10 | -------------------------------------------------------------------------------- /src/view/Application.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import type { Store } from '../state/store'; 5 | import Router from './Router'; 6 | 7 | export type Props = { 8 | store: Store 9 | }; 10 | 11 | const Application = (props: Props) => { 12 | const { store } = props; 13 | 14 | return ( 15 | 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default Application; 22 | -------------------------------------------------------------------------------- /src/view/Router.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import { BrowserRouter, Route, Switch, Redirect } from 'react-router-dom'; 4 | import { PrivateRoute, PublicOnlyRoute } from './auth/controlledRoute'; 5 | import Layout from './layout/Layout'; 6 | import HomePage from './home/HomePage'; 7 | import AuthBoundary from './auth/AuthBoundary'; 8 | import LoginPage from './auth/LoginPage'; 9 | import RegisterPage from './auth/RegisterPage'; 10 | import ArticlePage from './article/ArticlePage'; 11 | import CreateArticlePage from './article/CreateArticlePage'; 12 | import EditArticlePage from './article/EditArticlePage'; 13 | import ProfilePage from './author/ProfilePage'; 14 | import SettingsPage from './settings/SettingsPage'; 15 | 16 | const Router = () => ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | 36 | export default Router; 37 | -------------------------------------------------------------------------------- /src/view/article/Article.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Fragment } from 'react'; 3 | import Markdown from 'react-markdown'; 4 | import { type User } from '../../domain/user'; 5 | import type { Comment as CommentType, Article as ArticleType } from '../../domain/article'; 6 | import TagList from '../tag/TagList'; 7 | import ArticleMeta from './ArticleMeta'; 8 | import CommentForm from '../comment/CommentForm'; 9 | import Comment from '../comment/Comment'; 10 | 11 | type Props = { 12 | user: User, 13 | article: ArticleType, 14 | isRemoving: bool, 15 | comments: Array, 16 | addComment: (*) => void, 17 | removeComment: (*) => void 18 | }; 19 | 20 | const Article = ({ user, article, isRemoving, comments, addComment, removeComment }: Props) => ( 21 | 22 |
23 |
24 |

{ article.title }

25 | 26 | 31 |
32 |
33 | 34 |
35 |
36 |
37 | 38 | 42 |
43 |
44 | 45 |
46 | 47 |
48 | 53 |
54 | 55 |
56 | 57 |
58 | 59 | { 60 | user && ( 61 | 65 | ) 66 | } 67 | 68 | { 69 | comments.map((comment) => 70 | 76 | ) 77 | } 78 |
79 |
80 |
81 |
82 | ); 83 | 84 | export default Article; 85 | -------------------------------------------------------------------------------- /src/view/article/ArticleEditor.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Redirect } from 'react-router-dom'; 5 | import { editor, type EditorState, type ArticleState } from '../../state/article'; 6 | import TagList from '../tag/TagList'; 7 | import ErrorMessages from '../error/ErrorMessages'; 8 | 9 | const { EditorStatuses } = editor; 10 | 11 | type Props = { 12 | article: $PropertyType, 13 | errors: $PropertyType, 14 | status: $PropertyType, 15 | savedArticle: $PropertyType, 16 | addTag: typeof editor.addTag, 17 | removeTag: typeof editor.removeTag, 18 | resetEditor: typeof editor.resetEditor, 19 | updateField: typeof editor.updateField, 20 | onSubmit: ($PropertyType) => * 21 | }; 22 | 23 | type State = { 24 | editingTag: string 25 | }; 26 | 27 | const Keys = { 28 | ENTER: 'Enter', 29 | BACKSPACE: 'Backspace' 30 | }; 31 | 32 | const EventTypes = { 33 | CHANGE: 'change', 34 | KEYDOWN: 'keydown' 35 | }; 36 | 37 | class ArticleEditor extends Component { 38 | constructor(props: Props) { 39 | super(props); 40 | 41 | this.state = { 42 | editingTag: '' 43 | }; 44 | } 45 | 46 | componentWillUnmount() { 47 | this.props.resetEditor(); 48 | } 49 | 50 | handleChange = (fieldName) => (event) => { 51 | this.props.updateField(fieldName, event.target.value); 52 | }; 53 | 54 | handleTagInputEvent = (event) => { 55 | const tag = event.target.value; 56 | 57 | if(event.type === EventTypes.CHANGE) { 58 | return this.setEditingTag(tag); 59 | } 60 | 61 | if(event.type === EventTypes.KEYDOWN) { 62 | if(event.key === Keys.ENTER && tag.trim()) { 63 | event.preventDefault(); 64 | return this.addTag(tag); 65 | } 66 | 67 | if(event.key === Keys.BACKSPACE && !tag) { 68 | this.removeLastTag(); 69 | } 70 | } 71 | }; 72 | 73 | setEditingTag(editingTag) { 74 | this.setState({ editingTag }); 75 | }; 76 | 77 | addTag(tag) { 78 | this.props.addTag(tag); 79 | this.setEditingTag(''); 80 | } 81 | 82 | removeLastTag() { 83 | const { tagList } = this.props.article; 84 | 85 | if(tagList.length) { 86 | this.props.removeTag(tagList[tagList.length - 1]); 87 | } 88 | }; 89 | 90 | handleSubmit = (event) => { 91 | event.preventDefault(); 92 | 93 | const { article, onSubmit } = this.props; 94 | 95 | onSubmit(article); 96 | }; 97 | 98 | render() { 99 | const { 100 | status, 101 | article, 102 | removeTag, 103 | savedArticle, 104 | errors 105 | } = this.props; 106 | 107 | const { editingTag } = this.state; 108 | 109 | const isSaving = status === EditorStatuses.SAVING; 110 | 111 | if(status === EditorStatuses.SAVED && savedArticle) { 112 | return ; 113 | } 114 | 115 | return ( 116 |
117 |
118 |
119 | 120 |
121 | 122 | 123 |
124 |
125 |
126 | 135 |
136 |
137 | 146 |
147 |
148 | 157 |
158 |
159 | 168 | 173 |
174 | 181 |
182 |
183 |
184 |
185 |
186 |
187 | ); 188 | } 189 | } 190 | 191 | const mapStateToProps = ({ editor, article }) => ({ 192 | status: editor.status, 193 | article: editor.article, 194 | errors: editor.errors, 195 | savedArticle: article.article 196 | }); 197 | 198 | const mapDispatchToProps = { 199 | resetEditor: editor.resetEditor, 200 | updateField: editor.updateField, 201 | addTag: editor.addTag, 202 | removeTag: editor.removeTag 203 | }; 204 | 205 | export default connect(mapStateToProps, mapDispatchToProps)(ArticleEditor); 206 | -------------------------------------------------------------------------------- /src/view/article/ArticleLink.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | import type { Article } from '../../domain/article'; 5 | 6 | type Props = { 7 | article: Article, 8 | [string]: * 9 | }; 10 | 11 | const ArticleLink = ({ article, ...props}: Props) => ( 12 | 16 | ); 17 | 18 | export default ArticleLink; 19 | -------------------------------------------------------------------------------- /src/view/article/ArticleMeta.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Fragment } from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | import { type User } from '../../domain/user'; 5 | import { type Article } from '../../domain/article'; 6 | import { isAuthoredBy } from '../../domain/author'; 7 | import { WideFavoriteButton } from './FavoriteButton'; 8 | import RemoveButton from './RemoveButton'; 9 | import FormattedDate from '../date/FormattedDate'; 10 | import AuthorLink from '../author/AuthorLink'; 11 | import FollowButton from '../author/FollowButton'; 12 | import AuthorImage from '../author/AuthorImage'; 13 | 14 | type Props = { 15 | isRemoving: bool, 16 | article: Article, 17 | currentUser: ?User 18 | }; 19 | 20 | const ArticleMeta = ({ article, currentUser, isRemoving }: Props) => ( 21 |
22 | 25 | 26 | 27 |
28 | 32 | { article.author.username } 33 | 34 | 35 | 36 | 37 |
38 | { 39 | isAuthoredBy(article, currentUser) ? ( 40 | 41 | 45 | 46 |   47 | Edit Article 48 | 49 |    50 | 51 | 52 | ) : ( 53 | 54 | 55 |    56 | 57 | 58 | ) 59 | } 60 |
61 | ); 62 | 63 | export default ArticleMeta; 64 | -------------------------------------------------------------------------------- /src/view/article/ArticlePage.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Redirect } from 'react-router-dom'; 5 | import Head from '../layout/Head'; 6 | import type { ArticleSlug } from '../../domain/article'; 7 | import { type UserState } from '../../state/user'; 8 | import { article, type ArticleState } from '../../state/article'; 9 | import Article from './Article'; 10 | 11 | const { ArticleStatuses } = article; 12 | 13 | type Props = { 14 | articleSlug: ArticleSlug, 15 | user: $PropertyType, 16 | article: $PropertyType, 17 | comments: $PropertyType, 18 | status: $PropertyType, 19 | loadArticle: typeof article.loadArticle, 20 | addComment: typeof article.addComment, 21 | removeComment: typeof article.removeComment, 22 | unloadArticle: typeof article.unloadArticle 23 | }; 24 | 25 | class ArticlePage extends Component { 26 | componentDidMount() { 27 | const { 28 | loadArticle, 29 | articleSlug, 30 | article 31 | } = this.props; 32 | 33 | if(!article) { 34 | loadArticle(articleSlug); 35 | } 36 | } 37 | 38 | componentWillUnmount() { 39 | this.props.unloadArticle(); 40 | } 41 | 42 | addComment = (commentBody) => { 43 | const { 44 | addComment, 45 | articleSlug 46 | } = this.props; 47 | 48 | addComment(commentBody, articleSlug); 49 | } 50 | 51 | removeComment = (comment) => { 52 | const { 53 | removeComment, 54 | articleSlug 55 | } = this.props; 56 | 57 | removeComment(comment, articleSlug); 58 | } 59 | 60 | render() { 61 | const { 62 | user, 63 | article, comments, 64 | status 65 | } = this.props; 66 | 67 | if(status === ArticleStatuses.FAILED_LOADING || status === ArticleStatuses.REMOVED) { 68 | return ; 69 | } 70 | 71 | if(status === ArticleStatuses.LOADING || !article || !user) { 72 | return null; 73 | } 74 | 75 | return ( 76 |
77 | 78 | 79 |
87 |
88 | ); 89 | } 90 | } 91 | 92 | const mapStateToProps = ({ article, user }, props) => ({ 93 | user: user.user, 94 | article: article.article, 95 | comments: article.comments, 96 | isLoading: article.isLoading, 97 | status: article.status, 98 | articleSlug: props.match.params.slug 99 | }); 100 | 101 | const mapDispatchToProps = { 102 | loadArticle: article.loadArticle, 103 | addComment: article.addComment, 104 | removeComment: article.removeComment, 105 | unloadArticle: article.unloadArticle 106 | }; 107 | 108 | export default connect(mapStateToProps, mapDispatchToProps)(ArticlePage); 109 | -------------------------------------------------------------------------------- /src/view/article/ArticlePreview.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import type { Article } from '../../domain/article'; 4 | import AuthorLink from '../author/AuthorLink'; 5 | import ArticleLink from './ArticleLink'; 6 | import { NarrowFavoriteButton } from './FavoriteButton'; 7 | import FormattedDate from '../date/FormattedDate'; 8 | import TagList from '../tag/TagList'; 9 | import AuthorImage from '../author/AuthorImage'; 10 | 11 | type Props = { 12 | article: Article 13 | }; 14 | 15 | const ArticlePreview = ({ article }: Props) => ( 16 |
17 |
18 | 19 | 20 | 21 |
22 | 23 | { article.author.username } 24 | 25 | 26 | 27 | 28 |
29 | 30 |
31 | 35 |

{ article.title }

36 |

37 | { article.description } 38 |

39 | Read more... 40 | 45 |
46 |
47 | ); 48 | 49 | export default ArticlePreview; 50 | -------------------------------------------------------------------------------- /src/view/article/CreateArticlePage.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Fragment } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import Head from '../layout/Head'; 5 | import { article } from '../../state/article'; 6 | import ArticleEditor from './ArticleEditor'; 7 | 8 | type Props = { 9 | createArticle: typeof article.createArticle 10 | }; 11 | 12 | const CreateArticlePage = (props: Props) => ( 13 | 14 | 15 | 18 | 19 | ); 20 | 21 | const mapDispatchToProps = { 22 | createArticle: article.createArticle 23 | }; 24 | 25 | export default connect(null, mapDispatchToProps)(CreateArticlePage); 26 | -------------------------------------------------------------------------------- /src/view/article/EditArticlePage.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component, Fragment } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Redirect } from 'react-router-dom'; 5 | import Head from '../layout/Head'; 6 | import type { Article, ArticleSlug } from '../../domain/article'; 7 | import { type UserState } from '../../state/user'; 8 | import { isAuthoredBy } from '../../domain/author'; 9 | import { article, editor, type EditorState } from '../../state/article'; 10 | import ArticleEditor from './ArticleEditor'; 11 | 12 | const { EditorStatuses } = editor; 13 | 14 | type Props = { 15 | currentUser: $PropertyType, 16 | article: $PropertyType, 17 | status: $PropertyType, 18 | articleSlug: ArticleSlug, 19 | editArticle: typeof article.editArticle, 20 | setEditingArticle: typeof editor.setEditingArticle, 21 | resetEditor: typeof editor.resetEditor 22 | }; 23 | 24 | class EditArticlePage extends Component { 25 | componentDidMount() { 26 | const { setEditingArticle, articleSlug } = this.props; 27 | 28 | setEditingArticle(articleSlug); 29 | } 30 | 31 | componentWillUnmout() { 32 | this.props.resetEditor(); 33 | } 34 | 35 | render() { 36 | const { 37 | status, 38 | article, 39 | editArticle, 40 | currentUser 41 | } = this.props; 42 | 43 | 44 | if(status === EditorStatuses.INIT || status === EditorStatuses.LOADING) { 45 | return null; 46 | } 47 | 48 | if(status === EditorStatuses.FAILED_LOADING || !isAuthoredBy(((article: any): Article), currentUser)) { 49 | return ; 50 | } 51 | 52 | return ( 53 | 54 | 55 | 58 | 59 | ); 60 | } 61 | } 62 | 63 | const mapStateToProps = ({ editor, user }, props) => ({ 64 | status: editor.status, 65 | article: editor.article, 66 | articleSlug: props.match.params.slug, 67 | currentUser: user.user 68 | }); 69 | 70 | const mapDispatchToProps = { 71 | editArticle: article.editArticle, 72 | setEditingArticle: editor.setEditingArticle, 73 | resetEditor: editor.resetEditor 74 | }; 75 | 76 | export default connect(mapStateToProps, mapDispatchToProps)(EditArticlePage); 77 | -------------------------------------------------------------------------------- /src/view/article/FavoriteButton.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Redirect } from 'react-router-dom'; 5 | import classNames from 'classnames'; 6 | import { isSameArticle, type Article } from '../../domain/article'; 7 | import { article } from '../../state/article'; 8 | 9 | type Props = { 10 | isFavoriting: bool, 11 | isAuthenticated: bool, 12 | article: Article, 13 | className?: string, 14 | toggleArticleFavoriteStatus: typeof article.toggleArticleFavoriteStatus 15 | }; 16 | 17 | const FavoriteButton = (FavoriteButton) => (props: Props) => { 18 | const { isFavoriting, isAuthenticated } = props; 19 | 20 | if(isFavoriting && !isAuthenticated) { 21 | return ; 22 | } 23 | 24 | return 25 | }; 26 | 27 | const _NarrowFavoriteButton = FavoriteButton((props: Props) => { 28 | const { 29 | article, 30 | className, 31 | toggleArticleFavoriteStatus, 32 | isFavoriting 33 | } = props; 34 | 35 | return ( 36 | 48 | ); 49 | }); 50 | 51 | const _WideFavoriteButton = FavoriteButton((props: Props) => { 52 | const { 53 | article, 54 | className, 55 | toggleArticleFavoriteStatus, 56 | isFavoriting 57 | } = props; 58 | 59 | return ( 60 | 76 | ); 77 | }); 78 | 79 | const mapStateToProps = ({ article: { favoritingArticle }, user }, props) => ({ 80 | isFavoriting: isSameArticle(favoritingArticle, props.article), 81 | isAuthenticated: Boolean(user.user) 82 | }); 83 | 84 | const mapDispatchToProps = { 85 | toggleArticleFavoriteStatus: article.toggleArticleFavoriteStatus 86 | }; 87 | 88 | export const NarrowFavoriteButton = connect(mapStateToProps, mapDispatchToProps)(_NarrowFavoriteButton); 89 | export const WideFavoriteButton = connect(mapStateToProps, mapDispatchToProps)(_WideFavoriteButton); 90 | 91 | -------------------------------------------------------------------------------- /src/view/article/Feed.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import type { FeedState } from '../../state/article'; 4 | import ArticlePreview from './ArticlePreview'; 5 | 6 | type Props = { 7 | feed: FeedState 8 | }; 9 | 10 | const Feed = ({ feed }: Props) => { 11 | if(feed.error) { 12 | return
Error while loading articles.
; 13 | } 14 | 15 | if(feed.isLoading) { 16 | return
Loading articles...
; 17 | } 18 | 19 | if(!feed.articles.length) { 20 | return
No articles are here... yet.
; 21 | } 22 | 23 | return feed.articles.map((article) => 24 | 25 | ); 26 | }; 27 | 28 | export default Feed; 29 | -------------------------------------------------------------------------------- /src/view/article/RemoveButton.js: -------------------------------------------------------------------------------- 1 | /* @ flow */ 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { type Article } from '../../domain/article'; 5 | import { article } from '../../state/article'; 6 | 7 | type Props = { 8 | article: Article, 9 | isRemoving: bool, 10 | removeArticle: typeof article.removeArticle 11 | }; 12 | 13 | const RemoveButton = ({ article, removeArticle, isRemoving }: Props) => ( 14 | 23 | ); 24 | 25 | const mapDispatchToProps = { 26 | removeArticle: article.removeArticle 27 | }; 28 | 29 | export default connect(null, mapDispatchToProps)(RemoveButton); 30 | -------------------------------------------------------------------------------- /src/view/auth/AuthBoundary.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import { Component, type Node } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { loadUser, UserStatuses, type UserState } from '../../state/user'; 5 | 6 | type Props = { 7 | user: UserState, 8 | loadUser: typeof loadUser, 9 | children: Node 10 | }; 11 | 12 | class AuthBoundary extends Component { 13 | componentDidMount() { 14 | this.props.loadUser(); 15 | } 16 | 17 | render() { 18 | const { user, children } = this.props; 19 | 20 | if(user.status === UserStatuses.INIT || user.status === UserStatuses.SIGNING) { 21 | return null; 22 | } 23 | 24 | return children; 25 | } 26 | } 27 | 28 | const mapStateToProps = ({ user }) => ({ 29 | user 30 | }); 31 | 32 | const mapDispatchToProps = { 33 | loadUser 34 | }; 35 | 36 | export default connect(mapStateToProps, mapDispatchToProps)(AuthBoundary); 37 | -------------------------------------------------------------------------------- /src/view/auth/AuthPage.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component, type Node } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Redirect } from 'react-router-dom'; 5 | import Head from '../layout/Head'; 6 | import type { AuthState } from '../../state/auth'; 7 | import type { UserState } from '../../state/user'; 8 | import ErrorMessages from '../error/ErrorMessages'; 9 | import * as auth from '../../state/auth'; 10 | 11 | type Props = { 12 | user: $PropertyType, 13 | userAuthInfo: $PropertyType, 14 | isLoading: $PropertyType, 15 | errors?: $PropertyType, 16 | actionTitle?: string, 17 | showUsernameField: boolean, 18 | onSubmit: ($PropertyType) => *, 19 | updateAuthField: typeof auth.updateAuthField, 20 | renderSwitch: () => Node 21 | }; 22 | 23 | class AuthPage extends Component { 24 | static defaultProps = { 25 | showUsernameField: false 26 | }; 27 | 28 | updateField = (event) => { 29 | const { name, value } = event.target; 30 | 31 | this.props.updateAuthField({ [name]: value }); 32 | }; 33 | 34 | handleSubmit = (event) => { 35 | event.preventDefault(); 36 | 37 | const { 38 | userAuthInfo, 39 | onSubmit 40 | } = this.props 41 | 42 | onSubmit(userAuthInfo); 43 | }; 44 | 45 | render() { 46 | const { 47 | actionTitle, 48 | user, 49 | userAuthInfo, 50 | errors, 51 | isLoading, 52 | showUsernameField, 53 | renderSwitch 54 | } = this.props; 55 | 56 | if(user) { 57 | return ; 58 | } 59 | 60 | return ( 61 |
62 | 63 |
64 |
65 | 66 |
67 |

{ actionTitle }

68 |

69 | { renderSwitch() } 70 |

71 | 72 | 73 | 74 |
75 |
76 | { 77 | showUsernameField && ( 78 |
79 | 88 |
89 | ) 90 | } 91 | 92 |
93 | 102 |
103 | 104 |
105 | 114 |
115 | 116 | 123 |
124 |
125 |
126 | 127 |
128 |
129 |
130 | ); 131 | } 132 | } 133 | 134 | const mapStateToProps = ({ auth, user }) => ({ 135 | user: user.user, 136 | userAuthInfo: auth.userAuthInfo, 137 | isLoading: auth.isLoading, 138 | errors: auth.errors 139 | }); 140 | 141 | const mapDispatchToProps = { 142 | updateAuthField: auth.updateAuthField 143 | }; 144 | 145 | export default connect(mapStateToProps, mapDispatchToProps)(AuthPage); 146 | -------------------------------------------------------------------------------- /src/view/auth/LoginPage.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Link } from 'react-router-dom'; 5 | import AuthPage from './AuthPage'; 6 | import * as auth from '../../state/auth'; 7 | 8 | type Props = { 9 | signInUser: typeof auth.signInUser 10 | }; 11 | 12 | class LoginPage extends Component { 13 | render() { 14 | const { signInUser } = this.props; 15 | 16 | return ( 17 | Need an account? } 21 | /> 22 | ); 23 | } 24 | } 25 | 26 | const mapDispatchToProps = { 27 | signInUser: auth.signInUser 28 | }; 29 | 30 | export default connect(null, mapDispatchToProps)(LoginPage); 31 | -------------------------------------------------------------------------------- /src/view/auth/RegisterPage.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Link } from 'react-router-dom'; 5 | import AuthPage from './AuthPage'; 6 | import * as auth from '../../state/auth'; 7 | 8 | type Props = { 9 | registerUser: typeof auth.registerUser 10 | }; 11 | 12 | class Register extends Component { 13 | render() { 14 | const { registerUser } = this.props; 15 | 16 | return ( 17 | Have an account? } 21 | showUsernameField 22 | /> 23 | ); 24 | } 25 | } 26 | 27 | const mapDispatchToProps = { 28 | registerUser: auth.registerUser 29 | }; 30 | 31 | export default connect(null, mapDispatchToProps)(Register); 32 | -------------------------------------------------------------------------------- /src/view/auth/controlledRoute.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { type ComponentType } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Route, Redirect } from 'react-router-dom'; 5 | 6 | type Props = { 7 | isAuthenticated: bool, 8 | component: ComponentType, 9 | [string]: any 10 | }; 11 | 12 | const _PrivateRoute = ({ isAuthenticated, component: Component, ...props }: Props) => ( 13 | ( 16 | isAuthenticated 17 | ? 18 | : 19 | ) } 20 | /> 21 | ); 22 | 23 | const _PublicOnlyRoute = ({ isAuthenticated, component: Component, ...props }: Props) => ( 24 | ( 27 | !isAuthenticated 28 | ? 29 | : 30 | ) } 31 | /> 32 | ); 33 | 34 | const mapStateToProps = ({ user }) => ({ 35 | isAuthenticated: Boolean(user.user) 36 | }); 37 | 38 | export const PrivateRoute = connect(mapStateToProps)(_PrivateRoute); 39 | export const PublicOnlyRoute = connect(mapStateToProps)(_PublicOnlyRoute); 40 | -------------------------------------------------------------------------------- /src/view/author/AuthorImage.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | 4 | type Imageable = { 5 | username: string, 6 | image: ?string 7 | }; 8 | 9 | type Props = { 10 | author: Imageable, 11 | className?: string 12 | }; 13 | 14 | const AuthorImage = ({ author, className }: Props) => ( 15 | author.image ? ( 16 | { 21 | ) : null 22 | ); 23 | 24 | export default AuthorImage; 25 | -------------------------------------------------------------------------------- /src/view/author/AuthorLink.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { type ComponentType } from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | import type { User } from '../../domain/user'; 5 | import type { Author } from '../../domain/author'; 6 | 7 | type Props = { 8 | author: Author | User, 9 | as?: ComponentType, 10 | [string]: * 11 | }; 12 | 13 | const AuthorLink = ({ as: Component, author, ...props}: Props) => { 14 | Component = Component || Link; 15 | 16 | return ( 17 | 21 | ); 22 | }; 23 | 24 | export default AuthorLink; 25 | -------------------------------------------------------------------------------- /src/view/author/FollowButton.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Redirect } from 'react-router-dom'; 5 | import classNames from 'classnames'; 6 | import { isSame, type Author } from '../../domain/author'; 7 | import { toggleAuthorFollowStatus } from '../../state/author'; 8 | 9 | type Props = { 10 | isFollowing: bool, 11 | isAuthenticated: bool, 12 | author: Author, 13 | className?: string, 14 | toggleAuthorFollowStatus: typeof toggleAuthorFollowStatus 15 | }; 16 | 17 | const FollowButton = (props: Props) => { 18 | const { 19 | author, 20 | className, 21 | toggleAuthorFollowStatus, 22 | isFollowing, isAuthenticated 23 | } = props; 24 | 25 | if(isFollowing && !isAuthenticated) { 26 | return ; 27 | } 28 | 29 | return ( 30 | 44 | ) 45 | }; 46 | 47 | const mapStateToProps = ({ author: { followingAuthor }, user }, props) => ({ 48 | isAuthenticated: Boolean(user.user), 49 | isFollowing: isSame(followingAuthor, props.author) 50 | }); 51 | 52 | const mapDispatchToProps = { 53 | toggleAuthorFollowStatus: toggleAuthorFollowStatus 54 | }; 55 | 56 | export default connect(mapStateToProps, mapDispatchToProps)(FollowButton); 57 | -------------------------------------------------------------------------------- /src/view/author/ProfilePage.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import classNames from 'classnames'; 5 | import Head from '../layout/Head'; 6 | import { isSame } from '../../domain/author'; 7 | import { type UserState } from '../../state/user'; 8 | import { loadAuthor, type AuthorState } from '../../state/author'; 9 | import { feed, type FeedState } from '../../state/article'; 10 | import Feed from '../article/Feed'; 11 | import FollowButton from './FollowButton'; 12 | import EditProfileButton from '../settings/EditProfileButton'; 13 | import AuthorImage from './AuthorImage'; 14 | 15 | const Tabs = { 16 | ARTICLES: 'ARTICLES', 17 | FAVORITES: 'FAVORITES' 18 | }; 19 | 20 | type Tab = $Keys; 21 | 22 | type Props = { 23 | authorUsername: string, 24 | user: $PropertyType, 25 | author: $PropertyType, 26 | feed: FeedState, 27 | loadAuthor: typeof loadAuthor, 28 | loadAuthorFeed: typeof feed.loadAuthorFeed, 29 | loadAuthorFavoritesFeed: typeof feed.loadAuthorFavoritesFeed 30 | }; 31 | 32 | type State = { 33 | selectedTab: Tab 34 | }; 35 | 36 | class AuthorPage extends Component { 37 | constructor(props) { 38 | super(props); 39 | 40 | this.state = { 41 | selectedTab: Tabs.ARTICLES 42 | }; 43 | } 44 | 45 | componentDidMount() { 46 | this.loadAuthorAndArticles(); 47 | } 48 | 49 | componentDidUpdate(prevProps) { 50 | if(prevProps.authorUsername === this.props.authorUsername) { 51 | return; 52 | } 53 | 54 | this.setState({ 55 | selectedTab: Tabs.ARTICLES 56 | }); 57 | 58 | this.loadAuthorAndArticles(); 59 | } 60 | 61 | loadAuthorAndArticles() { 62 | const { 63 | authorUsername, 64 | loadAuthor 65 | } = this.props; 66 | 67 | const { 68 | selectedTab 69 | } = this.state; 70 | 71 | loadAuthor(authorUsername); 72 | 73 | this.loadTab(selectedTab); 74 | } 75 | 76 | loadTab(selectedTab: Tab) { 77 | const { 78 | loadAuthorFeed, 79 | loadAuthorFavoritesFeed, 80 | authorUsername 81 | } = this.props; 82 | 83 | switch(selectedTab) { 84 | case Tabs.FAVORITES: 85 | return loadAuthorFavoritesFeed(authorUsername); 86 | case Tabs.ARTICLES: 87 | default: 88 | return loadAuthorFeed(authorUsername); 89 | } 90 | } 91 | 92 | handleChangeTab(tab: Tab) { 93 | this.loadTab(tab); 94 | this.setState({ selectedTab: tab }); 95 | } 96 | 97 | render() { 98 | const { 99 | author, 100 | feed, 101 | user 102 | } = this.props; 103 | 104 | const { 105 | selectedTab 106 | } = this.state; 107 | 108 | return ( 109 |
110 | 111 | 112 |
113 |
114 |
115 | { 116 | author && ( 117 |
118 | 119 |

{ author.username }

120 |

121 | { author.bio } 122 |

123 | { 124 | isSame(user, author) 125 | ? 126 | : 127 | } 128 |
129 | ) 130 | } 131 |
132 |
133 |
134 | 135 |
136 |
137 | 138 |
139 |
140 |
    141 |
  • 142 | this.handleChangeTab(Tabs.ARTICLES) } 150 | > 151 | My Articles 152 | 153 |
  • 154 |
  • 155 | this.handleChangeTab(Tabs.FAVORITES) } 163 | > 164 | Favorited Articles 165 | 166 |
  • 167 |
168 |
169 | 170 | 171 |
172 | 173 |
174 |
175 | 176 |
177 | ); 178 | } 179 | } 180 | 181 | const mapStateToProps = ({ user, author, feed }, props) => ({ 182 | user: user.user, 183 | feed, 184 | author: author.author, 185 | authorUsername: props.match.params.username 186 | }); 187 | 188 | const mapDispatchToProps = { 189 | loadAuthor, 190 | loadAuthorFeed: feed.loadAuthorFeed, 191 | loadAuthorFavoritesFeed: feed.loadAuthorFavoritesFeed 192 | }; 193 | 194 | export default connect(mapStateToProps, mapDispatchToProps)(AuthorPage); 195 | -------------------------------------------------------------------------------- /src/view/comment/Comment.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import { type User } from '../../domain/user'; 4 | import { type Comment as CommentType } from '../../domain/article'; 5 | import { isAuthoredBy } from '../../domain/author'; 6 | import AuthorLink from '../author/AuthorLink'; 7 | import AuthorImage from '../author/AuthorImage'; 8 | import FormattedDate from '../date/FormattedDate'; 9 | 10 | type Props = { 11 | comment: CommentType, 12 | currentUser: ?User, 13 | onClickDelete: (CommentType) => void 14 | }; 15 | 16 | const Comment = ({ comment, currentUser, onClickDelete }: Props) => ( 17 |
18 |
19 |

{ comment.body }

20 |
21 |
22 | 23 | 24 | 25 |   26 | 27 | { comment.author.username } 28 | 29 | 30 | 31 | 32 | { 33 | isAuthoredBy(comment, currentUser) && ( 34 | onClickDelete(comment) } 37 | > 38 | 39 | 40 | ) 41 | } 42 |
43 |
44 | ); 45 | 46 | export default Comment; 47 | -------------------------------------------------------------------------------- /src/view/comment/CommentForm.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import { type User } from '../../domain/user'; 4 | import AuthorImage from '../author/AuthorImage'; 5 | 6 | type Props = { 7 | currentUser: User, 8 | onSubmit: (string) => void 9 | }; 10 | 11 | type State = { 12 | commentBody: string 13 | }; 14 | 15 | class CommentForm extends Component { 16 | constructor(props: Props) { 17 | super(props); 18 | 19 | this.state = { 20 | commentBody: '' 21 | }; 22 | } 23 | 24 | updateBody(commentBody: string) { 25 | this.setState({ commentBody }); 26 | } 27 | 28 | handleSubmit = (event: Event) => { 29 | event.preventDefault(); 30 | this.props.onSubmit(this.state.commentBody); 31 | this.setState({ commentBody: '' }); 32 | }; 33 | 34 | render() { 35 | const { currentUser } = this.props; 36 | const { commentBody } = this.state; 37 | 38 | return ( 39 |
43 |
44 | 51 |
52 |
53 | 54 | 57 |
58 |
59 | ); 60 | } 61 | } 62 | 63 | export default CommentForm; 64 | -------------------------------------------------------------------------------- /src/view/date/FormattedDate.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | type Props = { 3 | date: Date 4 | }; 5 | 6 | const FormattedDate = ({ date }: Props) => ( 7 | date.toLocaleString('en-us', { 8 | day: 'numeric', 9 | month: 'long', 10 | year: 'numeric' 11 | }) 12 | ); 13 | 14 | export default FormattedDate; 15 | -------------------------------------------------------------------------------- /src/view/error/ErrorMessages.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | export type Props = { errors: ?Object }; 4 | 5 | const ErrorMessages = (props: Props) => { 6 | const { errors } = props; 7 | 8 | return errors ? ( 9 |
    10 | { 11 | mapErrors(errors, (fieldName, errorMessage) => 12 |
  • 13 | { fieldName } { errorMessage } 14 |
  • 15 | ) 16 | } 17 |
18 | ) : null; 19 | }; 20 | 21 | const mapErrors = (errors, mapper) => ( 22 | toArray(errors).reduce((errorMessages, [fieldName, fieldErrors]) => [ 23 | ...errorMessages, 24 | ...fieldErrors.map((errorMessage) => ( 25 | mapper(fieldName, errorMessage) 26 | )) 27 | ], []) 28 | ); 29 | 30 | const toArray = (errors) => ( 31 | Object.keys(errors).map((fieldName) => [fieldName, errors[fieldName]]) 32 | ); 33 | 34 | export default ErrorMessages; 35 | -------------------------------------------------------------------------------- /src/view/home/HomePage.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import Head from '../layout/Head'; 5 | import classNames from 'classnames'; 6 | import Feed from '../article/Feed'; 7 | import PopularTagList from '../tag/PopularTagList'; 8 | import type { Tag } from '../../domain/tag'; 9 | import type { UserState } from '../../state/user'; 10 | import { 11 | feed, 12 | type FeedState 13 | } from '../../state/article'; 14 | 15 | const Tabs = { 16 | USER: 'USER', 17 | GLOBAL: 'GLOBAL', 18 | TAG: 'TAG' 19 | }; 20 | 21 | type Tab = $Keys; 22 | 23 | type Props = { 24 | user: $PropertyType, 25 | feed: FeedState, 26 | loadGlobalFeed: typeof feed.loadGlobalFeed, 27 | loadUserFeed: typeof feed.loadUserFeed, 28 | loadTagFeed: typeof feed.loadTagFeed 29 | }; 30 | 31 | type State = { 32 | selectedTab: Tab, 33 | selectedTag?: ?Tag 34 | }; 35 | 36 | class HomePage extends Component { 37 | constructor(props: Props) { 38 | super(props); 39 | 40 | this.state = { 41 | selectedTab: props.user ? Tabs.USER : Tabs.GLOBAL 42 | }; 43 | } 44 | 45 | componentDidMount() { 46 | this.loadTab(this.state.selectedTab); 47 | } 48 | 49 | handleChangeTab(tab: Tab, tag: ?Tag = null) { 50 | this.loadTab(tab, tag); 51 | 52 | this.setState({ 53 | selectedTab: tab, 54 | selectedTag: tag 55 | }); 56 | } 57 | 58 | loadTab(tab: Tab, tag: ?Tag) { 59 | switch(tab) { 60 | case Tabs.USER: 61 | return this.props.loadUserFeed(); 62 | case Tabs.TAG: 63 | return this.props.loadTagFeed(((tag: any): Tag)) 64 | case Tabs.GLOBAL: 65 | default: 66 | return this.props.loadGlobalFeed(); 67 | } 68 | } 69 | 70 | render() { 71 | const { user, feed } = this.props; 72 | const { selectedTab, selectedTag } = this.state; 73 | 74 | return ( 75 |
76 | 77 | 78 | { 79 | !user && ( 80 |
81 |
82 |

conduit

83 |

A place to share your knowledge.

84 |
85 |
86 | ) 87 | } 88 | 89 |
90 |
91 |
92 |
93 |
    94 | { 95 | user && ( 96 |
  • 97 | this.handleChangeTab(Tabs.USER) } 105 | > 106 | Your Feed 107 | 108 |
  • 109 | ) 110 | } 111 |
  • 112 | this.handleChangeTab(Tabs.GLOBAL) } 120 | > 121 | Global Feed 122 | 123 |
  • 124 | 125 | { 126 | selectedTag && ( 127 |
  • 128 | 132 | { selectedTag } 133 | 134 |
  • 135 | ) 136 | } 137 |
138 |
139 | 140 | 141 |
142 | 143 |
144 |
145 |

Popular Tags

146 | 147 | this.handleChangeTab(Tabs.TAG, tag) } 149 | /> 150 |
151 |
152 |
153 |
154 |
155 | ); 156 | } 157 | } 158 | 159 | const mapStateToProps = ({ user, feed }) => ({ 160 | user: user.user, 161 | feed 162 | }); 163 | 164 | const mapDispatchToProps = { 165 | loadGlobalFeed: feed.loadGlobalFeed, 166 | loadUserFeed: feed.loadUserFeed, 167 | loadTagFeed: feed.loadTagFeed 168 | }; 169 | 170 | export default connect(mapStateToProps, mapDispatchToProps)(HomePage); 171 | -------------------------------------------------------------------------------- /src/view/layout/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const Footer = () => ( 5 |
14 | ); 15 | 16 | export default Footer; 17 | -------------------------------------------------------------------------------- /src/view/layout/Head.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React from 'react'; 3 | import { Helmet } from 'react-helmet'; 4 | 5 | type Props = { 6 | title?: ?string 7 | }; 8 | 9 | const Head = ({ title }: Props) => ( 10 | 11 | 12 | { 13 | title 14 | ? `${title} — Conduit` 15 | : 'Conduit' 16 | } 17 | 18 | 19 | ); 20 | 21 | export default Head; 22 | -------------------------------------------------------------------------------- /src/view/layout/Layout.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | import React, { Fragment, type Node } from 'react'; 3 | import Head from './Head'; 4 | import Nav from './Nav'; 5 | import Footer from './Footer'; 6 | 7 | export type Props = { children: Node }; 8 | 9 | const Layout = (props: Props) => { 10 | const { children } = props; 11 | 12 | return ( 13 | 14 | 15 |