├── 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 |
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 |
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 |
--------------------------------------------------------------------------------