├── public ├── favicon.ico ├── manifest.json └── index.html ├── src ├── view │ ├── Shared │ │ ├── Utils.js │ │ ├── Link.js │ │ ├── Typography.js │ │ └── Structural.js │ ├── Dashboard.js │ ├── Routes.js │ ├── App.js │ ├── theme.js │ └── Login.js ├── selectors.js ├── state │ ├── sagas │ │ ├── index.js │ │ ├── routes.js │ │ └── login.js │ ├── reducers │ │ └── index.js │ └── store.js ├── types.js ├── actions.js ├── index.js └── router.js ├── .gitignore ├── README.md └── package.json /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bfillmer/formik-saga/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/view/Shared/Utils.js: -------------------------------------------------------------------------------- 1 | 2 | // Leverage React 16 to render multiple components without a wrapper div. 3 | export const Spread = ({children}) => children 4 | -------------------------------------------------------------------------------- /src/selectors.js: -------------------------------------------------------------------------------- 1 | 2 | // Literally maps to the type used by the action for navigation. 3 | export const routeType = state => state.location.response && state.location.response.name 4 | -------------------------------------------------------------------------------- /src/state/sagas/index.js: -------------------------------------------------------------------------------- 1 | 2 | import {fork} from 'redux-saga/effects' 3 | 4 | import {routes} from 'state/sagas/routes' 5 | 6 | export function * sagas () { 7 | yield fork(routes) 8 | } 9 | -------------------------------------------------------------------------------- /src/state/reducers/index.js: -------------------------------------------------------------------------------- 1 | 2 | import {combineReducers} from 'redux' 3 | import {curiReducer as location} from '@curi/redux' 4 | 5 | export const reducers = combineReducers({ 6 | location 7 | }) 8 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | 2 | // ROUTES 3 | export const ROUTE_LOGIN = 'routes/ROUTE_LOGIN' 4 | export const ROUTE_DASHBOARD = 'routes/DASHBOARD' 5 | 6 | // LOGIN ACTIONS 7 | export const SUBMIT_LOGIN = 'login/SUBMIT_LOGIN' 8 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | 2 | import {createAction} from 'redux-actions' 3 | 4 | import {SUBMIT_LOGIN} from 'types' 5 | 6 | // LOGIN 7 | // Attach our Formik actions as meta-data to our action. 8 | export const submitLogin = createAction( 9 | SUBMIT_LOGIN, 10 | ({values}) => values, 11 | ({actions}) => actions 12 | ) 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import {Provider} from 'react-redux' 4 | 5 | import {store} from 'state/store' 6 | import {App} from 'view/App' 7 | 8 | ReactDOM.render(( 9 | 10 | 11 | 12 | ), document.getElementById('root')) 13 | -------------------------------------------------------------------------------- /src/view/Dashboard.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | 4 | import {Section} from 'view/Shared/Structural' 5 | import {H2, P} from 'view/Shared/Typography' 6 | import {Link} from 'view/Shared/Link' 7 | 8 | export const Dashboard = () => ( 9 |
10 |

Dashboard

11 |

Logout

12 |
13 | ) 14 | -------------------------------------------------------------------------------- /.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 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "formik-saga", 3 | "name": "Formik and Sagas Proof of Concept", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/router.js: -------------------------------------------------------------------------------- 1 | 2 | import Browser from '@hickory/browser' 3 | import curi from '@curi/core' 4 | 5 | import {ROUTE_DASHBOARD, ROUTE_LOGIN} from 'types' 6 | 7 | export const history = Browser() 8 | 9 | const routes = [ 10 | { 11 | name: ROUTE_DASHBOARD, 12 | path: 'dashboard' 13 | }, 14 | { 15 | name: ROUTE_LOGIN, 16 | path: '(.*)' 17 | } 18 | ] 19 | 20 | export const router = curi(history, routes) 21 | -------------------------------------------------------------------------------- /src/view/Shared/Link.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | 4 | import {history} from 'router' 5 | 6 | // Wrap hickory's navigate function for our Link component. 7 | const makeLinkAction = href => e => { 8 | e.preventDefault() 9 | history.navigate(href) 10 | } 11 | 12 | export const Link = ({href, children, ...additionalProps}) => ( 13 | {children} 14 | ) 15 | -------------------------------------------------------------------------------- /src/view/Shared/Typography.js: -------------------------------------------------------------------------------- 1 | 2 | import styled from 'styled-components' 3 | 4 | import {getTheme} from 'view/theme' 5 | 6 | export const H1 = styled.h1` 7 | font-size: 2.5rem; 8 | font-weight: 400; 9 | letter-spacing: -0.05em; 10 | color: ${getTheme('colors', 'primary')}; 11 | ` 12 | 13 | export const H2 = styled.h2` 14 | font-size: 1.5rem; 15 | font-weight: 400; 16 | text-transform: uppercase; 17 | color: ${getTheme('colors', 'accent')}; 18 | ` 19 | 20 | export const P = styled.p` 21 | font-size: ${({small}) => small ? '0.8rem' : '1rem'}; 22 | line-height: 1.5em; 23 | margin-bottom: ${getTheme('margins', 'bottom')}; 24 | ` 25 | -------------------------------------------------------------------------------- /src/view/Routes.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import {connect} from 'react-redux' 4 | 5 | import {routeType} from 'selectors' 6 | import {ROUTE_LOGIN, ROUTE_DASHBOARD} from 'types' 7 | 8 | import {Login} from 'view/Login' 9 | import {Dashboard} from 'view/Dashboard' 10 | 11 | const routesMap = { 12 | [ROUTE_LOGIN]: Login, 13 | [ROUTE_DASHBOARD]: Dashboard 14 | } 15 | 16 | const mapStateToProps = state => ({ 17 | route: routeType(state) 18 | }) 19 | 20 | const Container = ({route}) => { 21 | const Route = routesMap[route] ? routesMap[route] : routesMap[ROUTE_LOGIN] 22 | return () 23 | } 24 | 25 | export const Routes = connect(mapStateToProps)(Container) 26 | -------------------------------------------------------------------------------- /src/view/App.js: -------------------------------------------------------------------------------- 1 | 2 | import React, {Component} from 'react' 3 | import {ThemeProvider} from 'styled-components' 4 | 5 | import {Wrapper, Header} from 'view/Shared/Structural' 6 | import {Routes} from 'view/Routes' 7 | 8 | import {theme} from 'view/theme' 9 | 10 | // Basic error boundary (https://reactjs.org/blog/2017/07/26/error-handling-in-react-16.html) 11 | export class App extends Component { 12 | componentDidCatch (error, info) { 13 | console.error('React Error', error, info) 14 | } 15 | 16 | render () { 17 | return ( 18 | 19 | 20 |
21 | 22 | 23 | 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/view/Shared/Structural.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import styled from 'styled-components' 4 | 5 | import {H1} from 'view/Shared/Typography' 6 | 7 | import {getTheme} from 'view/theme' 8 | 9 | // GLOBAL WRAPPER 10 | export const Wrapper = styled.main` 11 | display: flex; 12 | flex-direction: column; 13 | height: 100vh; 14 | ` 15 | 16 | // HEADER COMPONENTS 17 | const HeaderWrapper = styled.header` 18 | text-align: center; 19 | padding-top: ${getTheme('paddings', 'double')}; 20 | padding-bottom: ${getTheme('paddings', 'double')}; 21 | ` 22 | 23 | // HEADER COMPOSITION 24 | export const Header = () => ( 25 | 26 |

FormikSaga

27 |
28 | ) 29 | 30 | // PRIMARY CONTENT AREA 31 | export const Section = styled.section` 32 | flex: 1; 33 | width: 33rem; 34 | margin: 0 auto; 35 | ` 36 | -------------------------------------------------------------------------------- /src/state/store.js: -------------------------------------------------------------------------------- 1 | 2 | import {applyMiddleware, compose, createStore} from 'redux' 3 | import createSagaMiddleware from 'redux-saga' 4 | import {composeWithDevTools} from 'redux-devtools-extension' 5 | import {syncResponses} from '@curi/redux' 6 | 7 | import {sagas} from 'state/sagas' 8 | import {reducers} from 'state/reducers' 9 | import {router} from 'router' 10 | 11 | const sagasMiddleware = createSagaMiddleware() 12 | 13 | const composeMiddlewares = applyMiddleware(sagasMiddleware) 14 | 15 | // Use Redux DevTools Extension in development. 16 | const composeEnhancers = (middlewares) => 17 | typeof window !== 'undefined' 18 | ? composeWithDevTools(middlewares) 19 | : compose(middlewares) 20 | 21 | export const store = createStore( 22 | reducers, 23 | composeEnhancers(composeMiddlewares) 24 | ) 25 | 26 | // Boot up saga middleware and our routing. 27 | sagasMiddleware.run(sagas) 28 | syncResponses(store, router) 29 | -------------------------------------------------------------------------------- /src/state/sagas/routes.js: -------------------------------------------------------------------------------- 1 | 2 | import {LOCATION_CHANGE} from '@curi/redux' 3 | import {cancel, fork, take, takeEvery} from 'redux-saga/effects' 4 | 5 | import {ROUTE_LOGIN} from 'types' 6 | 7 | // Route Sagas 8 | import {init as initLogin} from 'state/sagas/login' 9 | 10 | // Routes that require side effects on load are mapped here, [type]: saga. 11 | const routesMap = { 12 | [ROUTE_LOGIN]: initLogin 13 | } 14 | 15 | // Run the saga for a given route if one exists, then watch for the next location change 16 | // and cancel the previously running saga. 17 | function * handleLocationChange ({response}) { 18 | if (response.name && routesMap[response.name]) { 19 | const routeSaga = yield fork(routesMap[response.name]) 20 | yield take(LOCATION_CHANGE) 21 | yield cancel(routeSaga) 22 | } 23 | } 24 | 25 | // Watch for all actions dispatched that have an action type in our saga routesMap. 26 | export function * routes () { 27 | yield takeEvery(LOCATION_CHANGE, handleLocationChange) 28 | } 29 | -------------------------------------------------------------------------------- /src/view/theme.js: -------------------------------------------------------------------------------- 1 | 2 | import {injectGlobal} from 'styled-components' 3 | 4 | const colors = { 5 | primary: '#0eb1d2', 6 | accent: '#02182b', 7 | error: '#d7263d', 8 | contrast: '#dee5e5' 9 | } 10 | 11 | const margins = { 12 | bottom: '1.5rem' 13 | } 14 | 15 | const paddings = { 16 | quarter: '0.25rem', 17 | half: '0.5rem', 18 | base: '1rem', 19 | double: '2rem' 20 | } 21 | 22 | // Reusable definitions for colors, spacings, etc. 23 | export const theme = { 24 | colors, 25 | margins, 26 | paddings 27 | } 28 | 29 | // Inject some global styles that are most likely to be coupled to theme variables. 30 | injectGlobal` 31 | body { 32 | font-size: 16px; 33 | font-weight: normal; 34 | font-family: sans-serif; 35 | background-color: ${colors.contrast}; 36 | } 37 | ` 38 | 39 | // Simple helper function, takes in any number of props mapping to properties within the theme 40 | // object and returns the value. 41 | export const getTheme = (...props) => ({theme}) => props.reduce((t, p) => t[p], theme) 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Formik Saga 3 | 4 | Example application leveraging Formik & Redux Saga to handle typical form & API scenarios. 5 | 6 | This project was bootstrapped with [Greenfield](https://github.com/bfillmer/greenfield). 7 | 8 | ## Commands 9 | 10 | ```bash 11 | yarn start # development server 12 | yarn build # production build 13 | yarn test # Jest in watch-mode 14 | yarn coverage # Jest coverage report 15 | yarn lint # fix basic linting errors 16 | ``` 17 | 18 | ## Overview 19 | 20 | * Commands include `NODE_PATH` to leverage absolute pathing to `src/` for cleaner imports. 21 | * `standardjs` linting (https://standardjs.com/) 22 | * `styled-components` css-in-js (https://www.styled-components.com) 23 | * `curi` routing (https://curi.js.org/) 24 | * `redux-saga` side-effects (https://redux-saga.js.org/) 25 | * `redux-actions` simplify actions boilerplate (https://github.com/acdlite/redux-actions) 26 | * `redux-data-structures` simplify reducer boilerplate (https://redux-data-structures.js.org/) 27 | * `axios` just-works http client (https://github.com/axios/axios) 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "formik-saga", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@curi/core": "1.0.0-beta.25", 7 | "@curi/redux": "^1.0.0-beta.2", 8 | "@hickory/browser": "^1.0.0-beta.5", 9 | "axios": "0.17.1", 10 | "formik": "^0.10.5", 11 | "react": "16.2.0", 12 | "react-dom": "16.2.0", 13 | "react-redux": "^5.0.6", 14 | "redux": "^3.7.2", 15 | "redux-actions": "^2.2.1", 16 | "redux-data-structures": "^0.1.6", 17 | "redux-saga": "0.16.0", 18 | "styled-components": "3.1.4", 19 | "yup": "^0.24.0" 20 | }, 21 | "devDependencies": { 22 | "react-scripts": "1.1.0", 23 | "redux-devtools-extension": "^2.13.2", 24 | "serve": "^6.4.9", 25 | "standard": "^10.0.3" 26 | }, 27 | "scripts": { 28 | "start": "NODE_PATH=src/ react-scripts start", 29 | "now-start": "serve -s ./build", 30 | "build": "NODE_PATH=src/ react-scripts build", 31 | "test": "NODE_PATH=src/ react-scripts test --env=jsdom", 32 | "coverage": "yarn test -- --coverage", 33 | "lint": "standard --fix" 34 | }, 35 | "jest": { 36 | "collectCoverageFrom": [ 37 | "src/**/*.{js,jsx}", 38 | "!node_modules/", 39 | "!src/state/store.js", 40 | "!src/state/sagas/*", 41 | "!src/index.js" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/state/sagas/login.js: -------------------------------------------------------------------------------- 1 | 2 | import {delay} from 'redux-saga' 3 | import {call, takeLatest} from 'redux-saga/effects' 4 | 5 | import {history} from 'router' 6 | import {SUBMIT_LOGIN} from 'types' 7 | 8 | // Fake API call with appropriate responses based on inputs. 9 | function * loginAPI (username, password) { 10 | // Simulate async call. 11 | yield delay(500) 12 | if (username !== 'formik') { 13 | throw new Error('Username not found.') 14 | } 15 | if (password !== 'is3asy') { 16 | throw new Error('Invalid password.') 17 | } 18 | return 'fake-API-token' 19 | } 20 | 21 | // Function for storing our API token, perhaps in localStorage or Redux state. 22 | function * storeToken (token) {} 23 | 24 | // Our SUBMIT_LOGIN action passes along the form values as the payload and form actions as 25 | // meta data. This allows us to not only use the values to do whatever API calls and such 26 | // we need, but also to maintain control flow here in our saga. 27 | function * submitLogin ({payload: values, meta: actions}) { 28 | const {resetForm, setErrors, setSubmitting} = actions 29 | try { 30 | // Connect to our "API" and get an API token for future API calls. 31 | const response = yield call(loginAPI, values.username, values.password) 32 | yield call(storeToken, response) 33 | // Reset the form just to be clean, then send the user to our Dashboard which "requires" 34 | // authentication. 35 | yield call(resetForm) 36 | yield call([history, 'navigate'], 'dashboard') 37 | } catch (e) { 38 | // If our API throws an error we will leverage Formik's existing error system to pass it along 39 | // to the view layer, as well as clearing the loading indicator. 40 | yield call(setErrors, {authentication: e.message}) 41 | yield call(setSubmitting, false) 42 | } 43 | } 44 | 45 | export function * init () { 46 | yield takeLatest(SUBMIT_LOGIN, submitLogin) 47 | } 48 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 21 | Formik Saga 22 | 42 | 43 | 44 | 47 |
48 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/view/Login.js: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react' 3 | import {connect} from 'react-redux' 4 | import styled from 'styled-components' 5 | import {Formik, Form, Field as FormikField} from 'formik' 6 | import yup from 'yup' 7 | 8 | import {Section as DefaultSection} from 'view/Shared/Structural' 9 | import {P} from 'view/Shared/Typography' 10 | import {Spread} from 'view/Shared/Utils' 11 | 12 | import {submitLogin} from 'actions' 13 | import {getTheme} from 'view/theme' 14 | 15 | // VISUAL COMPONENTS 16 | // @NOTE In a larger application than this tutorial most of the following visual components 17 | // would move into view/Shared and be reused. 18 | const Section = DefaultSection.extend` 19 | width: 15rem; 20 | ` 21 | 22 | const Label = styled.label` 23 | font-size: 0.75rem; 24 | text-transform: uppercase; 25 | ` 26 | 27 | const Field = styled(FormikField)` 28 | display: block; 29 | width: 100%; 30 | font-size: 1rem; 31 | padding-top: ${getTheme('paddings', 'quarter')}; 32 | padding-bottom: ${getTheme('paddings', 'quarter')}; 33 | padding-left: ${getTheme('paddings', 'half')}; 34 | padding-right: ${getTheme('paddings', 'half')}; 35 | border: 1px solid ${getTheme('colors', 'accent')}; 36 | ` 37 | 38 | const Button = styled.button` 39 | display: block; 40 | width: 100%; 41 | font-size: 0.75rem; 42 | text-transform: uppercase; 43 | color: ${getTheme('colors', 'contrast')}; 44 | background-color: ${getTheme('colors', 'primary')}; 45 | padding-top: ${getTheme('paddings', 'half')}; 46 | padding-bottom: ${getTheme('paddings', 'half')}; 47 | padding-left: ${getTheme('paddings', 'half')}; 48 | padding-right: ${getTheme('paddings', 'half')}; 49 | border: none; 50 | border-radius: 0; 51 | &:hover { 52 | cursor: pointer; 53 | background-color: ${getTheme('colors', 'accent')}; 54 | } 55 | ` 56 | 57 | const Error = styled.span` 58 | display: block; 59 | font-size: 0.75rem; 60 | color: ${getTheme('colors', 'error')}; 61 | ` 62 | 63 | // LOGIN FORM 64 | // @NOTE For forms that can be reused for both create/update you would move this form to its own 65 | // file and import it with different initialValues depending on the use-case. An over-optimization 66 | // for this simple login form however. 67 | const LoginForm = ({errors, isSubmitting, values}) => ( 68 |
69 | {errors.authentication &&

{errors.authentication}

} 70 |

71 | 72 | {errors.username && {errors.username}} 73 |

74 |

75 | 76 | {errors.password && {errors.password}} 77 |

78 |

79 | 82 |

83 | 84 | ) 85 | 86 | // FORM CONFIGURATION 87 | const initialValues = { 88 | username: '', 89 | password: '' 90 | } 91 | 92 | const validationSchema = yup.object().shape({ 93 | username: yup.string().required().label('Username'), 94 | password: yup.string().required().label('Password') 95 | }) 96 | 97 | // LOGIN CONTAINER 98 | const mapDispatchToProps = dispatch => ({ 99 | onSubmit: (values, actions) => dispatch(submitLogin({values, actions})) 100 | }) 101 | 102 | const Container = ({onSubmit}) => ( 103 | 104 |
105 | } 112 | /> 113 |
114 |
115 |

There is one valid username & password combination:

116 |

Username: formik
Password: is3asy

117 |

Invalid username & password combination return an error from our fake API call.

118 |
119 |
120 | ) 121 | 122 | export const Login = connect(null, mapDispatchToProps)(Container) 123 | --------------------------------------------------------------------------------