diff --git a/examples/counter/index.html b/examples/counter/index.html index 696101a536..0f72d6a041 100644 --- a/examples/counter/index.html +++ b/examples/counter/index.html @@ -1,6 +1,6 @@ - Redux Counter Example + Redux counter example
diff --git a/examples/counter/package.json b/examples/counter/package.json index 80e397c2cf..59599191f3 100644 --- a/examples/counter/package.json +++ b/examples/counter/package.json @@ -1,7 +1,7 @@ { - "name": "counter-redux", + "name": "redux-counter-example", "version": "0.0.0", - "description": "Counter example for redux", + "description": "Redux counter example", "main": "server.js", "scripts": { "start": "node server.js", diff --git a/examples/real-world/.babelrc b/examples/real-world/.babelrc new file mode 100644 index 0000000000..cab5d10d92 --- /dev/null +++ b/examples/real-world/.babelrc @@ -0,0 +1,3 @@ +{ + "stage": 2 +} diff --git a/examples/real-world/actions/index.js b/examples/real-world/actions/index.js new file mode 100644 index 0000000000..9e02f21c09 --- /dev/null +++ b/examples/real-world/actions/index.js @@ -0,0 +1,148 @@ +import { CALL_API, Schemas } from '../middleware/api'; + +export const USER_REQUEST = 'USER_REQUEST'; +export const USER_SUCCESS = 'USER_SUCCESS'; +export const USER_FAILURE = 'USER_FAILURE'; +/** + * Fetches a single user from Github API. + * Relies on the custom API middleware defined in ../middleware/api.js. + */ +function fetchUser(login) { + return { + [CALL_API]: { + types: [USER_REQUEST, USER_SUCCESS, USER_FAILURE], + endpoint: `users/${login}`, + schema: Schemas.USER + } + }; +} +/** + * Fetches a single user from Github API unless it is cached. + * Relies on Redux Thunk middleware. + */ +export function loadUser(login, requiredFields = []) { + return (dispatch, getState) => { + const user = getState().entities.users[login]; + if (user && requiredFields.every(key => user.hasOwnProperty(key))) { + return null; + } + + return dispatch(fetchUser(login)); + }; +} + +export const REPO_REQUEST = 'REPO_REQUEST'; +export const REPO_SUCCESS = 'REPO_SUCCESS'; +export const REPO_FAILURE = 'REPO_FAILURE'; +/** + * Fetches a single repository from Github API. + * Relies on the custom API middleware defined in ../middleware/api.js. + */ +function fetchRepo(fullName) { + return { + [CALL_API]: { + types: [REPO_REQUEST, REPO_SUCCESS, REPO_FAILURE], + endpoint: `repos/${fullName}`, + schema: Schemas.REPO + } + }; +} +/** + * Loads a single user from Github API unless it is cached. + * Relies on Redux Thunk middleware. + */ +export function loadRepo(fullName, requiredFields = []) { + return (dispatch, getState) => { + const repo = getState().entities.repos[fullName]; + if (repo && requiredFields.every(key => repo.hasOwnProperty(key))) { + return null; + } + + return dispatch(fetchRepo(fullName)); + }; +} + +export const STARRED_REQUEST = 'STARRED_REQUEST'; +export const STARRED_SUCCESS = 'STARRED_SUCCESS'; +export const STARRED_FAILURE = 'STARRED_FAILURE'; +/** + * Fetches a page of starred repos by a particular user. + * Relies on the custom API middleware defined in ../middleware/api.js. + */ +function fetchStarred(login, nextPageUrl) { + return { + login, + [CALL_API]: { + types: [STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE], + endpoint: nextPageUrl, + schema: Schemas.REPO_ARRAY + } + }; +} +/** + * Loads a page of starred repos by a particular user. + * Bails out if page is cached and user didn’t specifically request next page. + * Relies on Redux Thunk middleware. + */ +export function loadStarred(login, nextPage) { + return (dispatch, getState) => { + const { + nextPageUrl = `users/${login}/starred`, + pageCount = 0 + } = getState().pagination.starredByUser[login] || {}; + + if (pageCount > 0 && !nextPage) { + return null; + } + + return dispatch(fetchStarred(login, nextPageUrl)); + }; +} + + +export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST'; +export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS'; +export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE'; +/** + * Fetches a page of stargazers for a particular repo. + * Relies on the custom API middleware defined in ../middleware/api.js. + */ +function fetchStargazers(fullName, nextPageUrl) { + return { + fullName, + [CALL_API]: { + types: [STARGAZERS_REQUEST, STARGAZERS_SUCCESS, STARGAZERS_FAILURE], + endpoint: nextPageUrl, + schema: Schemas.USER_ARRAY + } + }; +} +/** + * Loads a page of stargazers for a particular repo. + * Bails out if page is cached and user didn’t specifically request next page. + * Relies on Redux Thunk middleware. + */ +export function loadStargazers(fullName, nextPage) { + return (dispatch, getState) => { + const { + nextPageUrl = `repos/${fullName}/stargazers`, + pageCount = 0 + } = getState().pagination.stargazersByRepo[fullName] || {}; + + if (pageCount > 0 && !nextPage) { + return null; + } + + return dispatch(fetchStargazers(fullName, nextPageUrl)); + }; +} + +export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE'; +/** + * Resets the currently visible error message. + */ +export function resetErrorMessage() { + return { + type: RESET_ERROR_MESSAGE + }; +} diff --git a/examples/real-world/components/Explore.js b/examples/real-world/components/Explore.js new file mode 100644 index 0000000000..7787aa5d7c --- /dev/null +++ b/examples/real-world/components/Explore.js @@ -0,0 +1,61 @@ +import React, { Component, PropTypes, findDOMNode } from 'react'; + +const GITHUB_REPO = 'https://github.com/gaearon/redux'; + +export default class Explore extends Component { + constructor(props) { + super(props); + this.handleKeyUp = this.handleKeyUp.bind(this); + this.handleGoClick = this.handleGoClick.bind(this); + } + + getInputValue() { + return findDOMNode(this.refs.input).value; + } + + setInputValue(val) { + // Generally mutating DOM is a bad idea in React components, + // but doing this for a single uncontrolled field is less fuss + // than making it controlled and maintaining a state for it. + findDOMNode(this.refs.input).value = val; + } + + componentWillReceiveProps(nextProps) { + if (nextProps.value !== this.props.value) { + this.setInputValue(nextProps.value); + } + } + + render() { + return ( +
+

Type a username or repo full name and hit 'Go':

+ + +

+ Code on Github. +

+
+ ); + } + + handleKeyUp(e) { + if (e.keyCode === 13) { + this.handleGoClick(); + } + } + + handleGoClick() { + this.props.onChange(this.getInputValue()) + } +} + +Explore.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired +}; diff --git a/examples/real-world/components/List.js b/examples/real-world/components/List.js new file mode 100644 index 0000000000..83a218870e --- /dev/null +++ b/examples/real-world/components/List.js @@ -0,0 +1,50 @@ +import React, { Component, PropTypes } from 'react'; + +export default class List extends Component { + render() { + const { + isFetching, nextPageUrl, pageCount, + items, renderItem, loadingLabel + } = this.props; + + const isEmpty = items.length === 0; + if (isEmpty && isFetching) { + return

{loadingLabel}

; + } + + const isLastPage = !nextPageUrl; + if (isEmpty && isLastPage) { + return

Nothing here!

; + } + + return ( +
+ {items.map(renderItem)} + {pageCount > 0 && !isLastPage && this.renderLoadMore()} +
+ ); + } + + renderLoadMore() { + const { isFetching, onLoadMoreClick } = this.props; + return ( + + ); + } +} + +List.propTypes = { + loadingLabel: PropTypes.string.isRequired, + isFetching: PropTypes.bool.isRequired, + onLoadMoreClick: PropTypes.func.isRequired, + nextPageUrl: PropTypes.string +}; + +List.defaultProps = { + isFetching: true, + loadingLabel: 'Loading...' +}; diff --git a/examples/real-world/components/Repo.js b/examples/real-world/components/Repo.js new file mode 100644 index 0000000000..b9bb4fa8b0 --- /dev/null +++ b/examples/real-world/components/Repo.js @@ -0,0 +1,37 @@ +import React, { PropTypes } from 'react'; +import { Link } from 'react-router'; + +export default class Repo { + static propTypes = { + repo: PropTypes.shape({ + name: PropTypes.string.isRequired, + description: PropTypes.string + }).isRequired, + owner: PropTypes.shape({ + login: PropTypes.string.isRequired + }).isRequired + } + + render() { + const { repo, owner } = this.props; + const { login } = owner; + const { name, description } = repo; + + return ( +
+

+ + {name} + + {' by '} + + {login} + +

+ {description && +

{description}

+ } +
+ ); + } +} diff --git a/examples/real-world/components/User.js b/examples/real-world/components/User.js new file mode 100644 index 0000000000..995926738a --- /dev/null +++ b/examples/real-world/components/User.js @@ -0,0 +1,27 @@ +import React, { Component, PropTypes } from 'react'; +import { Link } from 'react-router'; + +export default class User extends Component { + render() { + const { login, avatarUrl, name } = this.props.user; + + return ( +
+ + +

+ {login} {name && ({name})} +

+ +
+ ); + } +} + +User.propTypes = { + user: PropTypes.shape({ + login: PropTypes.string.isRequired, + avatarUrl: PropTypes.string.isRequired, + name: PropTypes.string + }).isRequired +}; diff --git a/examples/real-world/containers/App.js b/examples/real-world/containers/App.js new file mode 100644 index 0000000000..654c978f8d --- /dev/null +++ b/examples/real-world/containers/App.js @@ -0,0 +1,84 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import Explore from '../components/Explore'; +import { resetErrorMessage } from '../actions'; + +class App extends Component { + constructor(props) { + super(props); + this.handleChange = this.handleChange.bind(this); + this.handleDismissClick = this.handleDismissClick.bind(this); + } + + render() { + // Injected by React Router + const { location, children } = this.props; + const { pathname } = location; + const value = pathname.substring(1); + + return ( +
+ +
+ {this.renderErrorMessage()} + {children} +
+ ); + } + + renderErrorMessage() { + const { errorMessage } = this.props; + if (!errorMessage) { + return null; + } + + return ( +

+ {errorMessage} + {' '} + ( + Dismiss + ) +

+ ); + } + + handleDismissClick(e) { + this.props.resetErrorMessage(); + e.preventDefault(); + } + + handleChange(nextValue) { + // Available thanks to contextTypes below + const { router } = this.context; + router.transitionTo(`/${nextValue}`); + } +} + +App.propTypes = { + errorMessage: PropTypes.string, + location: PropTypes.shape({ + pathname: PropTypes.string.isRequired + }), + params: PropTypes.shape({ + userLogin: PropTypes.string, + repoName: PropTypes.string + }).isRequired +}; + +App.contextTypes = { + router: PropTypes.object.isRequired +}; + +function mapStateToProps(state) { + return { + errorMessage: state.errorMessage + }; +} + +export default connect( + mapStateToProps, + { resetErrorMessage } +)(App); diff --git a/examples/real-world/containers/RepoPage.js b/examples/real-world/containers/RepoPage.js new file mode 100644 index 0000000000..3499cffbaa --- /dev/null +++ b/examples/real-world/containers/RepoPage.js @@ -0,0 +1,106 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { loadRepo, loadStargazers } from '../actions'; +import Repo from '../components/Repo'; +import User from '../components/User'; +import List from '../components/List'; + +function loadData(props) { + const { fullName } = props; + props.loadRepo(fullName, ['description']); + props.loadStargazers(fullName); +} + +class RepoPage extends Component { + constructor(props) { + super(props); + this.renderUser = this.renderUser.bind(this); + this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this); + } + + componentWillMount() { + loadData(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.fullName !== this.props.fullName) { + loadData(nextProps); + } + } + + render() { + const { repo, owner, name } = this.props; + if (!repo || !owner) { + return

Loading {name} details...

; + } + + const { stargazers, stargazersPagination } = this.props; + return ( +
+ +
+ +
+ ); + } + + renderUser(user) { + return ( + + ); + } + + handleLoadMoreClick() { + this.props.loadStargazers(this.props.fullName, true); + } +} + +RepoPage.propTypes = { + repo: PropTypes.object, + fullName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + stargazers: PropTypes.array.isRequired, + stargazersPagination: PropTypes.object, + loadRepo: PropTypes.func.isRequired, + loadStargazers: PropTypes.func.isRequired +}; + +function mapStateToProps(state) { + return { + entities: state.entities, + stargazersByRepo: state.pagination.stargazersByRepo + }; +} + +function mergeProps(stateProps, dispatchProps, ownProps) { + const { entities, stargazersByRepo } = stateProps; + const { login, name } = ownProps.params; + + const fullName = `${login}/${name}`; + const repo = entities.repos[fullName]; + const owner = entities.users[login]; + + const stargazersPagination = stargazersByRepo[fullName] || { ids: [] }; + const stargazers = stargazersPagination.ids.map(id => entities.users[id]); + + return Object.assign({}, dispatchProps, { + fullName, + name, + repo, + owner, + stargazers, + stargazersPagination + }); +} + +export default connect( + mapStateToProps, + { loadRepo, loadStargazers }, + mergeProps +)(RepoPage); diff --git a/examples/real-world/containers/Root.js b/examples/real-world/containers/Root.js new file mode 100644 index 0000000000..355e786cd1 --- /dev/null +++ b/examples/real-world/containers/Root.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import { Provider } from 'react-redux'; +import { Router, Route } from 'react-router'; +import createAsyncExampleStore from '../store/createAsyncExampleStore'; +import App from './App'; +import UserPage from './UserPage'; +import RepoPage from './RepoPage'; + +const store = createAsyncExampleStore(); + +export default class Root extends Component { + render() { + return ( +
+ + {() => + + + + + + + } + +
+ ); + } +} diff --git a/examples/real-world/containers/UserPage.js b/examples/real-world/containers/UserPage.js new file mode 100644 index 0000000000..4b333a8012 --- /dev/null +++ b/examples/real-world/containers/UserPage.js @@ -0,0 +1,104 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { loadUser, loadStarred } from '../actions'; +import User from '../components/User'; +import Repo from '../components/Repo'; +import List from '../components/List'; +import zip from 'lodash/array/zip'; + +function loadData(props) { + const { login } = props; + props.loadUser(login, ['name']); + props.loadStarred(login); +} + +class UserPage extends Component { + constructor(props) { + super(props); + this.renderRepo = this.renderRepo.bind(this); + this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this); + } + + componentWillMount() { + loadData(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.login !== this.props.login) { + loadData(nextProps); + } + } + + render() { + const { user, login } = this.props; + if (!user) { + return

Loading {login}’s profile...

; + } + + const { starredRepos, starredRepoOwners, starredPagination } = this.props; + return ( +
+ +
+ +
+ ); + } + + renderRepo([repo, owner]) { + return ( + + ); + } + + handleLoadMoreClick() { + this.props.loadStarred(this.props.login, true); + } +} + +UserPage.propTypes = { + login: PropTypes.string.isRequired, + user: PropTypes.object, + starredPagination: PropTypes.object, + starredRepos: PropTypes.array.isRequired, + starredRepoOwners: PropTypes.array.isRequired, + loadUser: PropTypes.func.isRequired, + loadStarred: PropTypes.func.isRequired +}; + +function mapStateToProps(state) { + return { + entities: state.entities, + starredByUser: state.pagination.starredByUser + }; +} + +function mergeProps(stateProps, dispatchProps, ownProps) { + const { entities, starredByUser } = stateProps; + const { login } = ownProps.params; + + const user = entities.users[login]; + const starredPagination = starredByUser[login] || { ids: [] }; + const starredRepos = starredPagination.ids.map(id => entities.repos[id]); + const starredRepoOwners = starredRepos.map(repo => entities.users[repo.owner]); + + return Object.assign({}, dispatchProps, { + login, + user, + starredPagination, + starredRepos, + starredRepoOwners + }); +} + +export default connect( + mapStateToProps, + { loadUser, loadStarred }, + mergeProps +)(UserPage); diff --git a/examples/real-world/index.html b/examples/real-world/index.html new file mode 100644 index 0000000000..c26e4dad44 --- /dev/null +++ b/examples/real-world/index.html @@ -0,0 +1,10 @@ + + + Redux real-world example + + +
+
+ + + diff --git a/examples/real-world/index.js b/examples/real-world/index.js new file mode 100644 index 0000000000..7127e34fa7 --- /dev/null +++ b/examples/real-world/index.js @@ -0,0 +1,9 @@ +import 'babel-core/polyfill'; +import React from 'react'; +import Root from './containers/Root'; +import BrowserHistory from 'react-router/lib/BrowserHistory'; + +React.render( + , + document.getElementById('root') +); diff --git a/examples/real-world/middleware/api.js b/examples/real-world/middleware/api.js new file mode 100644 index 0000000000..1b898d45e8 --- /dev/null +++ b/examples/real-world/middleware/api.js @@ -0,0 +1,142 @@ +import { Schema, arrayOf, normalize } from 'normalizr'; +import { camelizeKeys } from 'humps'; +import 'isomorphic-fetch'; + +/** + * Extracts the next page URL from Github API response. + */ +function getNextPageUrl(response) { + const link = response.headers.get('link'); + if (!link) { + return null; + } + + const nextLink = link.split(',').filter(s => s.indexOf('rel="next"') > -1)[0]; + if (!nextLink) { + return null; + } + + return nextLink.split(';')[0].slice(1, -1); +} + +const API_ROOT = 'https://api.github.com/'; + +/** + * Fetches an API response and normalizes the result JSON according to schema. + * This makes every API response have the same shape, regardless of how nested it was. + */ +function callApi(endpoint, schema) { + if (endpoint.indexOf(API_ROOT) === -1) { + endpoint = API_ROOT + endpoint; + } + + return fetch(endpoint) + .then(response => + response.json().then(json => ({ json, response})) + ).then(({ json, response }) => { + if (!response.ok) { + return Promise.reject(json); + } + + const camelizedJson = camelizeKeys(json); + const nextPageUrl = getNextPageUrl(response) || undefined; + + return { + ...normalize(camelizedJson, schema), + nextPageUrl + }; + }); +} + +// We use this Normalizr schemas to transform API responses from a nested form +// to a flat form where repos and users are placed in `entities`, and nested +// JSON objects are replaced with their IDs. This is very convenient for +// consumption by reducers, because we can easily build a normalized tree +// and keep it updated as we fetch more data. + +// Read more about Normalizr: https://github.com/gaearon/normalizr + +const userSchema = new Schema('users', { + idAttribute: 'login' +}); + +const repoSchema = new Schema('repos', { + idAttribute: 'fullName' +}); + +repoSchema.define({ + owner: userSchema +}); + +/** + * Schemas for Github API responses. + */ +export const Schemas = { + USER: userSchema, + USER_ARRAY: arrayOf(userSchema), + REPO: repoSchema, + REPO_ARRAY: arrayOf(repoSchema) +}; + +/** + * Action key that carries API call info interpreted by this Redux middleware. + */ +export const CALL_API = Symbol('Call API'); + +/** + * A Redux middleware that interprets actions with CALL_API info specified. + * Performs the call and promises when such actions are dispatched. + */ +export default store => next => action => { + const callAPI = action[CALL_API]; + if (typeof callAPI === 'undefined') { + return next(action); + } + + let { endpoint } = callAPI; + const { schema, types, bailout } = callAPI; + + if (typeof endpoint === 'function') { + endpoint = endpoint(store.getState()); + } + + if (typeof endpoint !== 'string') { + throw new Error('Specify a string endpoint URL.'); + } + if (!schema) { + throw new Error('Specify on of the exported Schemas.'); + } + if (!Array.isArray(types) || types.length !== 3) { + throw new Error('Expected an array of three action types.'); + } + if (!types.every(type => typeof type === 'string')) { + throw new Error('Expected action types to be strings.'); + } + if (typeof bailout !== 'undefined' && typeof bailout !== 'function') { + throw new Error('Expected bailout to either be undefined or a function.'); + } + + if (bailout && bailout(store.getState())) { + return Promise.resolve(); + } + + function actionWith(data) { + const finalAction = Object.assign({}, action, data); + delete finalAction[CALL_API]; + return finalAction; + } + + const [requestType, successType, failureType] = types; + next(actionWith({ type: requestType })); + + return callApi(endpoint, schema).then( + response => next(actionWith({ + response, + type: successType + })), + error => next(actionWith({ + type: failureType, + error: error.message || 'Something bad happened' + })) + ); +}; diff --git a/examples/real-world/package.json b/examples/real-world/package.json new file mode 100644 index 0000000000..7b24a18702 --- /dev/null +++ b/examples/real-world/package.json @@ -0,0 +1,47 @@ +{ + "name": "redux-real-world-example", + "version": "0.0.0", + "description": "Redux real-world example", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/gaearon/redux.git" + }, + "keywords": [ + "react", + "reactjs", + "hot", + "reload", + "hmr", + "live", + "edit", + "webpack", + "flux" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/gaearon/redux/issues" + }, + "homepage": "https://github.com/gaearon/redux#readme", + "dependencies": { + "humps": "^0.6.0", + "isomorphic-fetch": "^2.1.1", + "lodash": "^3.10.1", + "normalizr": "^0.1.3", + "react": "^0.13.3", + "react-redux": "^0.8.0", + "react-router": "^1.0.0-beta3", + "redux": "^1.0.0-rc", + "redux-thunk": "^0.1.0" + }, + "devDependencies": { + "babel-core": "^5.6.18", + "babel-loader": "^5.1.4", + "react-hot-loader": "^1.2.7", + "webpack": "^1.9.11", + "webpack-dev-server": "^1.9.0" + } +} diff --git a/examples/real-world/reducers/index.js b/examples/real-world/reducers/index.js new file mode 100644 index 0000000000..125a0f5c4e --- /dev/null +++ b/examples/real-world/reducers/index.js @@ -0,0 +1,52 @@ +import * as ActionTypes from '../actions'; +import merge from 'lodash/object/merge'; +import paginate from './paginate'; +import { combineReducers } from 'redux'; + +/** + * Updates an entity cache in response to any action with response.entities. + */ +export function entities(state = { users: {}, repos: {} }, action) { + if (action.response && action.response.entities) { + return merge({}, state, action.response.entities); + } + + return state; +} + +/** + * Updates error message to notify about the failed fetches. + */ +export function errorMessage(state = null, action) { + const { type, error } = action; + + if (type === ActionTypes.RESET_ERROR_MESSAGE) { + return null; + } else if (error) { + return action.error; + } + + return state; +} + +/** + * Updates the pagination data for different actions. + */ +export const pagination = combineReducers({ + starredByUser: paginate({ + mapActionToKey: action => action.login, + types: [ + ActionTypes.STARRED_REQUEST, + ActionTypes.STARRED_SUCCESS, + ActionTypes.STARRED_FAILURE + ] + }), + stargazersByRepo: paginate({ + mapActionToKey: action => action.fullName, + types: [ + ActionTypes.STARGAZERS_REQUEST, + ActionTypes.STARGAZERS_SUCCESS, + ActionTypes.STARGAZERS_FAILURE + ] + }) +}); diff --git a/examples/real-world/reducers/paginate.js b/examples/real-world/reducers/paginate.js new file mode 100644 index 0000000000..05c682edd5 --- /dev/null +++ b/examples/real-world/reducers/paginate.js @@ -0,0 +1,64 @@ +import merge from 'lodash/object/merge'; +import union from 'lodash/array/union'; + +/** + * Creates a reducer managing pagination, given the action types to handle, + * and a function telling how to extract the key from an action. + */ +export default function paginate({ types, mapActionToKey }) { + if (!Array.isArray(types) || types.length !== 3) { + throw new Error('Expected types to be an array of three elements.'); + } + if (!types.every(t => typeof t === 'string')) { + throw new Error('Expected types to be strings.'); + } + if (typeof mapActionToKey !== 'function') { + throw new Error('Expected mapActionToKey to be a function.'); + } + + const [requestType, successType, failureType] = types; + + function updatePagination(state = { + isFetching: false, + nextPageUrl: undefined, + pageCount: 0, + ids: [] + }, action) { + switch (action.type) { + case requestType: + return merge({}, state, { + isFetching: true + }); + case successType: + return merge({}, state, { + isFetching: false, + ids: union(state.ids, action.response.result), + nextPageUrl: action.response.nextPageUrl, + pageCount: state.pageCount + 1 + }); + case failureType: + return merge({}, state, { + isFetching: false + }); + default: + return state; + } + } + + return function updatePaginationByKey(state = {}, action) { + switch (action.type) { + case requestType: + case successType: + case failureType: + const key = mapActionToKey(action); + if (typeof key !== 'string') { + throw new Error('Expected key to be a string.'); + } + return merge({}, state, { + [key]: updatePagination(state[key], action) + }); + default: + return state; + } + }; +} diff --git a/examples/real-world/server.js b/examples/real-world/server.js new file mode 100644 index 0000000000..ff92aa06b6 --- /dev/null +++ b/examples/real-world/server.js @@ -0,0 +1,18 @@ +var webpack = require('webpack'); +var WebpackDevServer = require('webpack-dev-server'); +var config = require('./webpack.config'); + +new WebpackDevServer(webpack(config), { + publicPath: config.output.publicPath, + hot: true, + historyApiFallback: true, + stats: { + colors: true + } +}).listen(3000, 'localhost', function (err) { + if (err) { + console.log(err); + } + + console.log('Listening at localhost:3000'); +}); diff --git a/examples/real-world/store/createAsyncExampleStore.js b/examples/real-world/store/createAsyncExampleStore.js new file mode 100644 index 0000000000..7f8d4f6ea7 --- /dev/null +++ b/examples/real-world/store/createAsyncExampleStore.js @@ -0,0 +1,17 @@ +import { createStore, applyMiddleware, combineReducers } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import apiMiddleware from '../middleware/api'; +import * as reducers from '../reducers'; + +const reducer = combineReducers(reducers); +const createStoreWithMiddleware = applyMiddleware( + thunkMiddleware, + apiMiddleware +)(createStore); + +/** + * Creates a preconfigured store for this example. + */ +export default function createAsyncExampleStore(initialState) { + return createStoreWithMiddleware(reducer, initialState); +} diff --git a/examples/real-world/webpack.config.js b/examples/real-world/webpack.config.js new file mode 100644 index 0000000000..2061e19a48 --- /dev/null +++ b/examples/real-world/webpack.config.js @@ -0,0 +1,38 @@ +var path = require('path'); +var webpack = require('webpack'); + +module.exports = { + devtool: 'eval', + entry: [ + 'webpack-dev-server/client?http://localhost:3000', + 'webpack/hot/only-dev-server', + './index' + ], + output: { + path: path.join(__dirname, 'dist'), + filename: 'bundle.js', + publicPath: '/static/' + }, + plugins: [ + new webpack.HotModuleReplacementPlugin(), + new webpack.NoErrorsPlugin() + ], + resolve: { + alias: { + 'redux': path.join(__dirname, '..', '..', 'src') + }, + extensions: ['', '.js'] + }, + module: { + loaders: [{ + test: /\.js$/, + loaders: ['react-hot', 'babel'], + exclude: /node_modules/, + include: __dirname + }, { + test: /\.js$/, + loaders: ['babel'], + include: path.join(__dirname, '..', '..', 'src') + }] + } +}; diff --git a/examples/todomvc/index.html b/examples/todomvc/index.html index ce8e8222e9..3a5d65bf3e 100644 --- a/examples/todomvc/index.html +++ b/examples/todomvc/index.html @@ -1,6 +1,6 @@ - Redux TodoMVC + Redux TodoMVC example
diff --git a/examples/todomvc/package.json b/examples/todomvc/package.json index fbe0328dcc..be44925d67 100644 --- a/examples/todomvc/package.json +++ b/examples/todomvc/package.json @@ -1,7 +1,7 @@ { - "name": "todomvc", + "name": "redux-todomvc-example", "version": "0.0.0", - "description": "TodoMVC example for redux", + "description": "Redux TodoMVC example", "main": "server.js", "scripts": { "start": "node server.js",