diff --git a/actions.js b/actions.js index 7ee5d03..2e238b3 100644 --- a/actions.js +++ b/actions.js @@ -4,9 +4,10 @@ export const ADD_TODO = 'ADD_TODO' export const COMPLETE_TODO = 'COMPLETE_TODO' -export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER' +export const SET_PANE_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER' export const CHANGE_THEME = 'CHANGE_THEME' -export const UPDATE_SEARCH = 'UPDATE_SEARCH'; +export const UPDATE_PANE_SEARCH = 'UPDATE_SEARCH'; +export const ADD_PANE = 'ADD_PANE' /* * other constants @@ -22,6 +23,10 @@ export const VisibilityFilters = { * action creators */ +export function addPane() { + return { type: ADD_PANE }; +} + export function addTodo(text) { return { type: ADD_TODO, text } } @@ -30,17 +35,18 @@ export function completeTodo(index) { return { type: COMPLETE_TODO, index } } -export function setVisibilityFilter(filter) { - return { type: SET_VISIBILITY_FILTER, filter } +export function setPaneVisibilityFilter(paneIdx, filter) { + return { type: SET_PANE_VISIBILITY_FILTER, index: paneIdx, filter } } export function changeTheme() { return { type: CHANGE_THEME }; } -export function updateSearch(searchTerm) { +export function updatePaneSearch(paneIdx, searchTerm) { return { - type: UPDATE_SEARCH, + type: UPDATE_PANE_SEARCH, + index: paneIdx, searchTerm }; } diff --git a/components/Pane.js b/components/Pane.js new file mode 100644 index 0000000..bfdf23a --- /dev/null +++ b/components/Pane.js @@ -0,0 +1,44 @@ +import React, { PropTypes, Component } from 'react'; +import { addTodo, completeTodo } from '../actions' +import AddTodo from './AddTodo'; +import TodoList from './TodoList'; +import Footer from './Footer'; + +class Pane extends Component { + render() { + const { + dispatch, + pane, + matchingVisibleTodosForPaneFactory, + updateSearch, + setVisibilityFilter, + ...props + } = this.props; + + const { visibilityFilter, searchTerm } = pane; + + console.log("Rendering pane: ") + console.log(pane); + + const visibleTodos = matchingVisibleTodosForPaneFactory(visibilityFilter, searchTerm); + + return ( +
+ {pane.key} + Search: +
+ dispatch(addTodo(text)) } /> + dispatch(completeTodo(index)) } + /> +
+ ); + } +} + +export default Pane; diff --git a/containers/App.js b/containers/App.js index ce71cd8..2007d90 100644 --- a/containers/App.js +++ b/containers/App.js @@ -1,96 +1,49 @@ import React, { Component, PropTypes } from 'react' import { connect } from 'react-redux' -import { addTodo, completeTodo, setVisibilityFilter, changeTheme, VisibilityFilters, updateSearch } from '../actions' -import AddTodo from '../components/AddTodo' -import TodoList from '../components/TodoList' -import Footer from '../components/Footer' +import { setPaneVisibilityFilter, changeTheme, VisibilityFilters, updatePaneSearch, addPane } from '../actions' import { memoize, createMemoizedFunction } from '../memoize' import { createSelector, createStructuredSelector } from 'reselect'; +import { appSelector } from '../selectors/AppSelector'; +import Pane from '../components/Pane'; -class App extends Component { - updateSearch = function(e) { - const { dispatch } = this.props; - dispatch(updateSearch(e.target.value)); - } +class App extends Component { render() { - console.log(this.props); - // Injected by connect() call: - const { dispatch, matchingVisibleTodos, currentTheme, searchTerm, visibilityFilter } = this.props + const { dispatch, panes, currentTheme, ...props } = this.props + + var createUpdatePaneSearch = function(paneIdx) { + return (e) => { + dispatch(updatePaneSearch(paneIdx, e.target.value)); + }; + } + + var createSetVisibilityFilter = function(paneIdx) { + return (filter) => { + dispatch(setPaneVisibilityFilter(paneIdx, filter)); + } + } + + let paneComponents = panes.map((pane, idx) => + + ); + return (
- Search:
- - dispatch(addTodo(text)) - } /> - - dispatch(completeTodo(index)) - } /> -
- dispatch(setVisibilityFilter(nextFilter)) - } /> + {paneComponents} +
) } } -App.propTypes = { - todos: PropTypes.arrayOf(PropTypes.shape({ - text: PropTypes.string.isRequired, - completed: PropTypes.bool.isRequired - })), - visibilityFilter: PropTypes.oneOf([ - 'SHOW_ALL', - 'SHOW_COMPLETED', - 'SHOW_ACTIVE' - ]).isRequired -} - -function selectVisibleTodos(todos, filter) { - console.log("Recalculating selectTodos"); - switch (filter) { - case VisibilityFilters.SHOW_ALL: - return todos - case VisibilityFilters.SHOW_COMPLETED: - return todos.filter(todo => todo.completed) - case VisibilityFilters.SHOW_ACTIVE: - return todos.filter(todo => !todo.completed) - } -} - -function selectMatchingTodos(todos, search) { - console.log("Recalculating matchingTodos"); - return todos.filter((todo) => { return todo.text.search(search) >= 0; }); -} - -const todosSelector = state => state.todos; -const visibilityFilterSelector = state => state.visibilityFilter; -const currentThemeSelector = state => state.currentTheme; -const searchTermSelector = state => state.searchTerm; - -const visibleTodosSelector = createSelector( - [todosSelector, visibilityFilterSelector], - selectVisibleTodos -); - -const matchingVisibleTodosSelector = createSelector( - [visibleTodosSelector, searchTermSelector], - selectMatchingTodos -); - -const select = createStructuredSelector({ - matchingVisibleTodos: matchingVisibleTodosSelector, - visibilityFilter: visibilityFilterSelector, - currentTheme: currentThemeSelector, - searchTerm: searchTermSelector -}); - // Wrap the component to inject dispatch and state into it -export default connect(select)(App) +export default connect(appSelector)(App); diff --git a/index.js b/index.js index 9fd15be..1ff0843 100644 --- a/index.js +++ b/index.js @@ -3,14 +3,25 @@ import { render } from 'react-dom' import { createStore } from 'redux' import { Provider } from 'react-redux' import App from './containers/App' -import todoApp from './reducers' - -let store = createStore(todoApp) +import { DevTools, LogMonitor, DebugPanel } from 'redux-devtools/lib/react'; +import configureStore, {USE_DEV_TOOLS} from './store/configureStore' +let store = configureStore() let rootElement = document.getElementById('root') + +let debugPannel = USE_DEV_TOOLS ? ( + + + ) : null; + + render( - - - , +
+ + + + {debugPannel} + +
, rootElement ) diff --git a/package.json b/package.json index 64d9ed9..e13f981 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,14 @@ "homepage": "http://rackt.github.io/redux", "dependencies": { "classnames": "^2.1.2", + "lodash": "^3.10.1", "react": "^0.14.0", "react-dom": "^0.14.0", "react-redux": "^4.0.0", "redux": "^3.0.0", - "reselect": "^2.0.0" + "redux-devtools": "^2.1.5", + "reselect": "^2.0.0", + "uid": "0.0.2" }, "devDependencies": { "babel-core": "^5.6.18", diff --git a/reducers.js b/reducers.js deleted file mode 100644 index bc73343..0000000 --- a/reducers.js +++ /dev/null @@ -1,63 +0,0 @@ -import { combineReducers } from 'redux' -import { ADD_TODO, COMPLETE_TODO, SET_VISIBILITY_FILTER, CHANGE_THEME, VisibilityFilters, UPDATE_SEARCH } from './actions' -const { SHOW_ALL } = VisibilityFilters - -function visibilityFilter(state = SHOW_ALL, action) { - switch (action.type) { - case SET_VISIBILITY_FILTER: - return action.filter - default: - return state - } -} - -function todos(state = [], action) { - switch (action.type) { - case ADD_TODO: - return [ - ...state, - { - text: action.text, - completed: false - } - ] - case COMPLETE_TODO: - return [ - ...state.slice(0, action.index), - Object.assign({}, state[action.index], { - completed: true - }), - ...state.slice(action.index + 1) - ] - default: - return state - } -} - -function currentTheme(state = 'theme-green', action) { - switch (action.type) { - case CHANGE_THEME: - return state == 'theme-green' ? 'theme-blue' : 'theme-green'; - default: - return state - } -} - -function searchTerm(state = '', action) { - switch (action.type) { - case UPDATE_SEARCH: - return action.searchTerm; - default: - return state; - } -} - - -const todoApp = combineReducers({ - visibilityFilter, - todos, - currentTheme, - searchTerm -}) - -export default todoApp diff --git a/reducers/CurrentThemeReducer.js b/reducers/CurrentThemeReducer.js new file mode 100644 index 0000000..66f3012 --- /dev/null +++ b/reducers/CurrentThemeReducer.js @@ -0,0 +1,10 @@ +import { CHANGE_THEME } from '../actions' + +export function currentTheme(state = 'theme-green', action) { + switch (action.type) { + case CHANGE_THEME: + return state == 'theme-green' ? 'theme-blue' : 'theme-green'; + default: + return state + } +} diff --git a/reducers/PanesReducer.js b/reducers/PanesReducer.js new file mode 100644 index 0000000..d77cae0 --- /dev/null +++ b/reducers/PanesReducer.js @@ -0,0 +1,41 @@ +import { combineReducers } from 'redux' +import { ADD_PANE, SET_PANE_VISIBILITY_FILTER, VisibilityFilters, UPDATE_PANE_SEARCH } from '../actions' +import combineCollectionReducers from './combineCollectionReducers' +import uid from 'uid'; + +const { SHOW_ALL } = VisibilityFilters + +function searchTerm(state = '', action) { + switch (action.type) { + case UPDATE_PANE_SEARCH: + return action.searchTerm; + default: + return state; + } +} + +function visibilityFilter(state = SHOW_ALL, action) { + switch (action.type) { + case SET_PANE_VISIBILITY_FILTER: + return action.filter + default: + return state + } +} + +const paneElementReducer = combineReducers({ + key: () => { return uid() }, + visibilityFilter, + searchTerm +}); + +function paneCollectionReducer(state = [], action) { + switch(action.type) { + case ADD_PANE: + return state.concat(paneElementReducer(undefined, {type: null})); + default: + return state; + } +} + +export const panes = combineCollectionReducers(paneCollectionReducer, paneElementReducer); diff --git a/reducers/TodosReducer.js b/reducers/TodosReducer.js new file mode 100644 index 0000000..2bf0b88 --- /dev/null +++ b/reducers/TodosReducer.js @@ -0,0 +1,28 @@ +import { ADD_TODO, COMPLETE_TODO } from '../actions' +import combineCollectionReducers from './combineCollectionReducers' + +function todoElementReducer(state, action) { + switch(action.type) { + case COMPLETE_TODO: + return Object.assign({}, state, { completed: true }); + default: + return state; + } +} + +function todoCollectionReducer(state = [], action) { + switch(action.type) { + case ADD_TODO: + return [ + ...state, + { + text: action.text, + completed: false + } + ] + default: + return state; + } +} + +export const todos = combineCollectionReducers(todoCollectionReducer, todoElementReducer); diff --git a/reducers/combineCollectionReducers.js b/reducers/combineCollectionReducers.js new file mode 100644 index 0000000..abc4346 --- /dev/null +++ b/reducers/combineCollectionReducers.js @@ -0,0 +1,29 @@ +// There is a common pattern where the state contains +// a collection of like objects, with some actions +// needing to affect an element of the collection (i.e. complete a given todo element) +// while other actions affect the collection as a whole (i.e. add a new todo element). +// +// Generally actions, that affect a given todo need to pass in an `index` so we know +// which todo the action should affect. +// +// This function takes two arguments: the first is a collectionReducer that knows how +// to handle actions against the collection as a whole. +// The second argument is an elementReducer that knows how to handle +// actions against a specific element. +// +// The goal is to extract the "index-detection" and state reconstruction logic, so +// that you only have to focus on how to reduce collection actions and element actions. + +export default function combineCollectionReducers(collectionReducer, elementReducer) { + return function(state = collectionReducer(undefined, { type: null }), action) { + if(action.index !== undefined) { + let oldElement = state[action.index]; + let newElement = elementReducer(oldElement, action); + if(newElement !== oldElement) { + return state.map((element, idx) => idx === action.index ? newElement : element); + } + return state; + } + return collectionReducer(state, action); + } +} diff --git a/reducers/index.js b/reducers/index.js new file mode 100644 index 0000000..bc3d23f --- /dev/null +++ b/reducers/index.js @@ -0,0 +1,10 @@ +import { combineReducers } from 'redux' +import { currentTheme } from './CurrentThemeReducer'; +import { todos } from './TodosReducer'; +import { panes } from './PanesReducer'; + +export default combineReducers({ + panes, + todos, + currentTheme, +}); diff --git a/selectors/AppSelector.js b/selectors/AppSelector.js new file mode 100644 index 0000000..2de421d --- /dev/null +++ b/selectors/AppSelector.js @@ -0,0 +1,51 @@ +import { createSelector, createStructuredSelector } from 'reselect'; +import { VisibilityFilters } from '../actions'; +import { memoize } from 'lodash'; + +const resolver = function() { + var args = Array.prototype.slice.call(arguments); + return args.join(); +} + +const todosSelector = state => state.todos; +const currentThemeSelector = state => state.currentTheme; +const panesSelector = state => state.panes; + +const visibleTodosForFilterFactorySelector = createSelector( + todosSelector, + (todos) => { + console.log("(reselect) Generating a new visibleTodosFilterFactory because todos changed"); + return _.memoize((visibilityFilter) => { + console.log("Lodash calling visible function for new filter " + visibilityFilter); + switch (visibilityFilter) { + case VisibilityFilters.SHOW_ALL: + return todos + case VisibilityFilters.SHOW_COMPLETED: + return todos.filter(todo => todo.completed) + case VisibilityFilters.SHOW_ACTIVE: + return todos.filter(todo => !todo.completed) + } + }, resolver); + } +) + +const matchingVisibleTodosForPaneFactorySelector = createSelector( + visibleTodosForFilterFactorySelector, + (visibleTodosForFilterFactory) => { + console.log("(reselect) generating a new matchingVisibleTodosForPaneFactory because visibleTodosFilterFactory changed"); + return _.memoize((visibilityFilter, searchTerm) => { + console.log("Lodash calling matching function for " + visibilityFilter + " and " + searchTerm); + const visibleTodos = visibleTodosForFilterFactory(visibilityFilter) + console.log("Calculating matchingTodos for " + searchTerm); + return visibleTodos.filter((todo) => { + return todo.text.search(searchTerm) >= 0; + }); + }, resolver); + } +); + +export const appSelector = createStructuredSelector({ + panes: panesSelector, + matchingVisibleTodosForPaneFactory: matchingVisibleTodosForPaneFactorySelector, + currentTheme: currentThemeSelector +}); diff --git a/store/configureStore.js b/store/configureStore.js index 479c02c..1957bc7 100644 --- a/store/configureStore.js +++ b/store/configureStore.js @@ -1,8 +1,14 @@ -import { createStore } from 'redux' +import { compose, createStore } from 'redux'; import rootReducer from '../reducers' +import { devTools } from 'redux-devtools'; + +export const USE_DEV_TOOLS = false; export default function configureStore(initialState) { - const store = createStore(rootReducer, initialState) + + const finalCreateStore = USE_DEV_TOOLS ? compose(devTools())(createStore) : createStore; + + const store = finalCreateStore(rootReducer, initialState) if (module.hot) { // Enable Webpack hot module replacement for reducers