diff --git a/cypress/integration/selectors-spec.js b/cypress/integration/selectors-spec.js index 4fdcc38e..d8fae891 100644 --- a/cypress/integration/selectors-spec.js +++ b/cypress/integration/selectors-spec.js @@ -1,13 +1,13 @@ /// -import {getVisibleTodos} from '../../src/selectors' +import { getVisibleTodos } from '../../src/slices/visibilityFilter' describe('getVisibleTodos', () => { it('throws an error for unknown visibility filter', () => { expect(() => { getVisibleTodos({ todos: [], - visibilityFilter: 'unknown-filter' + visibilityFilter: 'unknown-filter', }) }).to.throw() }) diff --git a/package.json b/package.json index 9b05756f..8c1648ff 100644 --- a/package.json +++ b/package.json @@ -50,13 +50,12 @@ "start-server-and-test": "1.12.1" }, "dependencies": { + "@reduxjs/toolkit": "^1.4.0", "classnames": "2.2.6", "prop-types": "15.7.2", "react": "17.0.2", "react-dom": "17.0.2", "react-redux": "6.0.1", - "redux": "4.0.5", - "reselect": "4.0.0", "todomvc-app-css": "2.3.0" } } diff --git a/src/actions/index.cy-spec.js b/src/actions/index.cy-spec.js deleted file mode 100644 index 5f2f2237..00000000 --- a/src/actions/index.cy-spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import * as types from '../constants/ActionTypes' -import * as actions from './index' - -describe('todo actions', () => { - it('addTodo should create ADD_TODO action', () => { - expect(actions.addTodo('Use Redux')).to.deep.equal({ - type: types.ADD_TODO, - text: 'Use Redux' - }) - }) - - it('deleteTodo should create DELETE_TODO action', () => { - expect(actions.deleteTodo(1)).to.deep.equal({ - type: types.DELETE_TODO, - id: 1 - }) - }) - - it('editTodo should create EDIT_TODO action', () => { - expect(actions.editTodo(1, 'Use Redux everywhere')).to.deep.equal({ - type: types.EDIT_TODO, - id: 1, - text: 'Use Redux everywhere' - }) - }) - - it('completeTodo should create COMPLETE_TODO action', () => { - expect(actions.completeTodo(1)).to.deep.equal({ - type: types.COMPLETE_TODO, - id: 1 - }) - }) - - it('completeAll should create COMPLETE_ALL action', () => { - expect(actions.completeAllTodos()).to.deep.equal({ - type: types.COMPLETE_ALL_TODOS - }) - }) - - it('clearCompleted should create CLEAR_COMPLETED action', () => { - expect(actions.clearCompleted()).to.deep.equal({ - type: types.CLEAR_COMPLETED - }) - }) -}) diff --git a/src/actions/index.js b/src/actions/index.js deleted file mode 100644 index 9b3e8252..00000000 --- a/src/actions/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import * as types from '../constants/ActionTypes' - -export const addTodo = text => ({ type: types.ADD_TODO, text }) -export const deleteTodo = id => ({ type: types.DELETE_TODO, id }) -export const editTodo = (id, text) => ({ type: types.EDIT_TODO, id, text }) -export const completeTodo = id => ({ type: types.COMPLETE_TODO, id }) -export const completeAllTodos = () => ({ type: types.COMPLETE_ALL_TODOS }) -export const clearCompleted = () => ({ type: types.CLEAR_COMPLETED }) -export const setVisibilityFilter = filter => ({ type: types.SET_VISIBILITY_FILTER, filter}) diff --git a/src/actions/index.spec.js b/src/actions/index.spec.js deleted file mode 100644 index 8dce63a6..00000000 --- a/src/actions/index.spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import * as types from '../constants/ActionTypes' -import * as actions from './index' - -describe('todo actions', () => { - it('addTodo should create ADD_TODO action', () => { - expect(actions.addTodo('Use Redux')).toEqual({ - type: types.ADD_TODO, - text: 'Use Redux' - }) - }) - - it('deleteTodo should create DELETE_TODO action', () => { - expect(actions.deleteTodo(1)).toEqual({ - type: types.DELETE_TODO, - id: 1 - }) - }) - - it('editTodo should create EDIT_TODO action', () => { - expect(actions.editTodo(1, 'Use Redux everywhere')).toEqual({ - type: types.EDIT_TODO, - id: 1, - text: 'Use Redux everywhere' - }) - }) - - it('completeTodo should create COMPLETE_TODO action', () => { - expect(actions.completeTodo(1)).toEqual({ - type: types.COMPLETE_TODO, - id: 1 - }) - }) - - it('completeAll should create COMPLETE_ALL action', () => { - expect(actions.completeAllTodos()).toEqual({ - type: types.COMPLETE_ALL_TODOS - }) - }) - - it('clearCompleted should create CLEAR_COMPLETED action', () => { - expect(actions.clearCompleted()).toEqual({ - type: types.CLEAR_COMPLETED - }) - }) -}) diff --git a/src/components/App.cy-spec.js b/src/components/App.cy-spec.js index 8960e0c0..4efea128 100644 --- a/src/components/App.cy-spec.js +++ b/src/components/App.cy-spec.js @@ -2,13 +2,11 @@ // compare to App.spec.js import React from 'react' import App from './App' -import {mount} from 'cypress-react-unit-test' +import { mount } from 'cypress-react-unit-test' // we are making mini application - thus we need a store! import { Provider } from 'react-redux' -import { createStore } from 'redux' -import reducer from '../reducers' -import {addTodo, completeTodo} from '../actions' -const store = createStore(reducer) +import { store } from '../store' +import { addTodo, completeTodo } from '../slices/todos' describe('components', () => { const setup = () => { @@ -35,14 +33,17 @@ describe('components', () => { it('should render a couple todos', () => { // use application code to interact with store - store.dispatch(addTodo('write app code')) - store.dispatch(addTodo('test components using Cypress')) - store.dispatch(completeTodo(1)) + store.dispatch(addTodo({ text: 'write app code' })) + store.dispatch(addTodo({ text: 'test components using Cypress' })) + store.dispatch(completeTodo({ id: 1 })) setup() // make sure the list of items is correctly checked cy.get('.todo').should('have.length', 2) cy.contains('.todo', 'write app code').should('not.have.class', 'completed') - cy.contains('.todo', 'test components using Cypress').should('have.class', 'completed') + cy.contains('.todo', 'test components using Cypress').should( + 'have.class', + 'completed' + ) }) }) diff --git a/src/components/Footer.cy-spec.js b/src/components/Footer.cy-spec.js index 18ed40a4..3bfedf94 100644 --- a/src/components/Footer.cy-spec.js +++ b/src/components/Footer.cy-spec.js @@ -6,16 +6,17 @@ import { mount } from 'cypress-react-unit-test' // we are making mini application - thus we need a store! import { Provider } from 'react-redux' -import { createStore } from 'redux' -import reducer from '../reducers' -const store = createStore(reducer) +import { store } from '../store' -const setup = propOverrides => { - const props = Object.assign({ - completedCount: 0, - activeCount: 0, - onClearCompleted: cy.stub().as('clear'), - }, propOverrides) +const setup = (propOverrides) => { + const props = Object.assign( + { + completedCount: 0, + activeCount: 0, + onClearCompleted: cy.stub().as('clear'), + }, + propOverrides + ) mount( @@ -38,7 +39,8 @@ describe('components', () => { it('should render filters', () => { setup() - cy.get('footer li').should('have.length', 3) + cy.get('footer li') + .should('have.length', 3) .should((li) => { expect(li[0]).to.have.text('All') expect(li[1]).to.have.text('Active') @@ -53,7 +55,10 @@ describe('components', () => { it('should render clear button when completed todos', () => { setup({ completedCount: 1 }) - cy.contains('button', 'Clear completed').should('have.class', 'clear-completed') + cy.contains('button', 'Clear completed').should( + 'have.class', + 'clear-completed' + ) }) it('should call onClearCompleted on clear button click', () => { diff --git a/src/components/Header.cy-spec.js b/src/components/Header.cy-spec.js index a5b19c60..fa1e5603 100644 --- a/src/components/Header.cy-spec.js +++ b/src/components/Header.cy-spec.js @@ -1,18 +1,17 @@ /// import React from 'react' import Header from './Header' -import {mount} from 'cypress-react-unit-test' +import { mount } from 'cypress-react-unit-test' + // we are making mini application - thus we need a store! import { Provider } from 'react-redux' -import { createStore } from 'redux' -import reducer from '../reducers' -const store = createStore(reducer) +import { store } from '../store' describe('components', () => { describe('Header', () => { beforeEach(() => { const props = { - addTodo: cy.stub().as('addTodo') + addTodo: cy.stub().as('addTodo'), } mount( @@ -22,9 +21,12 @@ describe('components', () => { }) it('should render correctly', () => { - cy.get('header').should('have.class', 'header') - .contains('h1', 'todos') - cy.get('header input').should('have.attr', 'placeholder', 'What needs to be done?') + cy.get('header').should('have.class', 'header').contains('h1', 'todos') + cy.get('header input').should( + 'have.attr', + 'placeholder', + 'What needs to be done?' + ) }) it('should call addTodo if length of text is greater than 0', () => { diff --git a/src/components/Header.js b/src/components/Header.js index f4a59f74..b48e688f 100644 --- a/src/components/Header.js +++ b/src/components/Header.js @@ -3,24 +3,24 @@ import React from 'react' import TodoTextInput from './TodoTextInput' const Header = ({ addTodo }) => ( - + todos { + onSave={(text) => { if (text.length !== 0) { // simulate delayed application logic // setTimeout(addTodo, 1000, text) - addTodo(text) + addTodo({ text }) } }} - placeholder='What needs to be done?' + placeholder="What needs to be done?" /> ) Header.propTypes = { - addTodo: PropTypes.func.isRequired + addTodo: PropTypes.func.isRequired, } export default Header diff --git a/src/components/MainSection.cy-spec.js b/src/components/MainSection.cy-spec.js index 1c051f41..d8796d93 100644 --- a/src/components/MainSection.cy-spec.js +++ b/src/components/MainSection.cy-spec.js @@ -5,22 +5,23 @@ import { mount } from 'cypress-react-unit-test' // we are making mini application - thus we need a store! import { Provider } from 'react-redux' -import { createStore } from 'redux' -import reducer from '../reducers' -const store = createStore(reducer) +import { store } from '../store' -const setup = propOverrides => { - const props = Object.assign({ - todosCount: 2, - completedCount: 1, - actions: { - editTodo: cy.stub().as('edit'), - deleteTodo: cy.stub().as('delete'), - completeTodo: cy.stub().as('complete'), - completeAllTodos: cy.stub().as('completeAll'), - clearCompleted: cy.stub().as('clearCompleted') - } - }, propOverrides) +const setup = (propOverrides) => { + const props = Object.assign( + { + todosCount: 2, + completedCount: 1, + actions: { + editTodo: cy.stub().as('edit'), + deleteTodo: cy.stub().as('delete'), + completeTodo: cy.stub().as('complete'), + completeAllTodos: cy.stub().as('completeAll'), + clearCompleted: cy.stub().as('clearCompleted'), + }, + }, + propOverrides + ) mount( @@ -47,10 +48,9 @@ describe('components', () => { it('should be checked if all todos completed', () => { setup({ - completedCount: 2 + completedCount: 2, }) - cy.get('input[type=checkbox]') - .should('be.checked') + cy.get('input[type=checkbox]').should('be.checked') }) it('should call completeAllTodos on change', () => { @@ -84,7 +84,7 @@ describe('components', () => { it('should not render if there are no todos', () => { setup({ todosCount: 0, - completedCount: 0 + completedCount: 0, }) cy.get('li').should('have.length', 0) }) diff --git a/src/components/MainSection.js b/src/components/MainSection.js index 0a8919fa..c1180b88 100644 --- a/src/components/MainSection.js +++ b/src/components/MainSection.js @@ -4,17 +4,17 @@ import Footer from './Footer' import VisibleTodoList from '../containers/VisibleTodoList' const MainSection = ({ todosCount, completedCount, actions }) => ( - + {!!todosCount && ( actions.completeAllTodos()} + onChange={() => actions.completeAllTodos()} /> - + actions.completeAllTodos()} /> )} @@ -22,7 +22,7 @@ const MainSection = ({ todosCount, completedCount, actions }) => ( @@ -31,7 +31,7 @@ const MainSection = ({ todosCount, completedCount, actions }) => ( MainSection.propTypes = { todosCount: PropTypes.number.isRequired, completedCount: PropTypes.number.isRequired, - actions: PropTypes.object.isRequired + actions: PropTypes.object.isRequired, } export default MainSection diff --git a/src/components/TodoItem.cy-spec.js b/src/components/TodoItem.cy-spec.js index b9bc5897..29301e24 100644 --- a/src/components/TodoItem.cy-spec.js +++ b/src/components/TodoItem.cy-spec.js @@ -2,18 +2,18 @@ import React from 'react' import TodoItem from './TodoItem' import { StoreProvider } from '../store' -import {mount} from 'cypress-react-unit-test' +import { mount } from 'cypress-react-unit-test' -const setup = ( editing = false ) => { +const setup = (editing = false) => { const props = { todo: { id: 0, text: 'Use Redux', - completed: false + completed: false, }, editTodo: cy.stub().as('edit'), deleteTodo: cy.stub().as('delete'), - completeTodo: cy.stub().as('completeTodo') + completeTodo: cy.stub().as('completeTodo'), } // because our CSS styles are global, they assume @@ -38,8 +38,10 @@ describe('components', () => { it('initial render', () => { setup() - cy.get('li').should('have.class', 'todo') - .find('div').should('have.class', 'view') + cy.get('li') + .should('have.class', 'todo') + .find('div') + .should('have.class', 'view') cy.get('input[type=checkbox]') .should('have.class', 'toggle') @@ -52,14 +54,14 @@ describe('components', () => { it('input onChange should call completeTodo', () => { setup() cy.get('input').check() - cy.get('@completeTodo').should('have.been.calledWith', 0) + cy.get('@completeTodo').should('have.been.calledWith', { id: 0 }) }) it('button onClick should call deleteTodo', () => { setup() // button only becomes visible on hover - cy.get('.destroy').click({force: true}) - cy.get('@delete').should('have.been.calledWith', 0) + cy.get('.destroy').click({ force: true }) + cy.get('@delete').should('have.been.calledWith', { id: 0 }) }) it('label onDoubleClick should put component in edit state', () => { @@ -71,21 +73,22 @@ describe('components', () => { it('edit state render', () => { setup(true) cy.get('li').should('have.class', 'editing') - cy.get('input') - .should('be.visible') - .and('have.value', 'Use Redux') + cy.get('input').should('be.visible').and('have.value', 'Use Redux') }) it('TodoTextInput onSave should call editTodo', () => { setup(true) cy.focused().type('{enter}') - cy.get('@edit').should('have.been.calledWith', 0, 'Use Redux') + cy.get('@edit').should('have.been.calledWith', { + id: 0, + text: 'Use Redux', + }) }) it('TodoTextInput onSave should call deleteTodo if text is empty', () => { setup(true) cy.focused().clear().type('{enter}') - cy.get('@delete').should('have.been.calledWith', 0) + cy.get('@delete').should('have.been.calledWith', { id: 0 }) }) it('TodoTextInput onSave should exit component from edit state', () => { diff --git a/src/components/TodoItem.js b/src/components/TodoItem.js index b070f718..fbf1ff92 100644 --- a/src/components/TodoItem.js +++ b/src/components/TodoItem.js @@ -1,70 +1,70 @@ -import React, { Component } from 'react' +import React, { useState } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -import TodoTextInput from './TodoTextInput' -export default class TodoItem extends Component { - static propTypes = { - todo: PropTypes.object.isRequired, - editTodo: PropTypes.func.isRequired, - deleteTodo: PropTypes.func.isRequired, - completeTodo: PropTypes.func.isRequired - } +import TodoTextInput from './TodoTextInput' - state = { - editing: false - } +function TodoItem({ todo, editTodo, deleteTodo, completeTodo }) { + const [editing, setEditing] = useState(false) - handleDoubleClick = () => { - this.setState({ editing: true }) + const handleDoubleClick = () => { + setEditing(true) } - handleSave = (id, text) => { + const handleSave = (id, text) => { if (text.length === 0) { - this.props.deleteTodo(id) + deleteTodo({ id }) } else { - this.props.editTodo(id, text) + editTodo({ id, text }) } - this.setState({ editing: false }) + setEditing(false) } - render () { - const { todo, completeTodo, deleteTodo } = this.props - - let element - if (this.state.editing) { - element = ( - this.handleSave(todo.id, text)} + let element + if (editing) { + element = ( + handleSave(todo.id, text)} + /> + ) + } else { + element = ( + + completeTodo({ id: todo.id })} /> - ) - } else { - element = ( - - completeTodo(todo.id)} - /> - {todo.text} - deleteTodo(todo.id)} /> - - ) - } - - return ( - - {element} - + {todo.text} + deleteTodo({ id: todo.id })} + /> + ) } + + return ( + + {element} + + ) } + +TodoItem.propTypes = { + todo: PropTypes.object.isRequired, + editTodo: PropTypes.func.isRequired, + deleteTodo: PropTypes.func.isRequired, + completeTodo: PropTypes.func.isRequired, +} + +export default TodoItem diff --git a/src/components/TodoTextInput.js b/src/components/TodoTextInput.js index 2707f1f3..fab4d668 100644 --- a/src/components/TodoTextInput.js +++ b/src/components/TodoTextInput.js @@ -1,54 +1,52 @@ -import React, { Component } from 'react' +import React, { useState } from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' -export default class TodoTextInput extends Component { - static propTypes = { - onSave: PropTypes.func.isRequired, - text: PropTypes.string, - placeholder: PropTypes.string, - editing: PropTypes.bool, - newTodo: PropTypes.bool - } - - state = { - text: this.props.text || '' - } +function TodoTextInput(props) { + const [text, setText] = useState(props.text || '') - handleSubmit = e => { - const text = e.target.value.trim() - if (e.which === 13) { - this.props.onSave(text) - if (this.props.newTodo) { - this.setState({ text: '' }) + const handleSubmit = (event) => { + const text = event.target.value.trim() + if (event.which === 13) { + props.onSave(text) + if (props.newTodo) { + setText('') } } } - handleChange = e => { - this.setState({ text: e.target.value }) + const handleChange = (event) => { + setText(event.target.value) } - handleBlur = e => { - if (!this.props.newTodo) { - this.props.onSave(e.target.value) + const handleBlur = (event) => { + if (!props.newTodo) { + props.onSave(event.target.value) } } - render() { - return ( - - ) - } + return ( + + ) +} +TodoTextInput.propTypes = { + onSave: PropTypes.func.isRequired, + text: PropTypes.string, + placeholder: PropTypes.string, + editing: PropTypes.bool, + newTodo: PropTypes.bool, } + +export default TodoTextInput diff --git a/src/constants/ActionTypes.js b/src/constants/ActionTypes.js deleted file mode 100644 index 66c12280..00000000 --- a/src/constants/ActionTypes.js +++ /dev/null @@ -1,7 +0,0 @@ -export const ADD_TODO = 'ADD_TODO' -export const DELETE_TODO = 'DELETE_TODO' -export const EDIT_TODO = 'EDIT_TODO' -export const COMPLETE_TODO = 'COMPLETE_TODO' -export const COMPLETE_ALL_TODOS = 'COMPLETE_ALL_TODOS' -export const CLEAR_COMPLETED = 'CLEAR_COMPLETED' -export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER' diff --git a/src/containers/FilterLink.js b/src/containers/FilterLink.js index bf4e4731..ec8fd468 100644 --- a/src/containers/FilterLink.js +++ b/src/containers/FilterLink.js @@ -1,18 +1,15 @@ import { connect } from 'react-redux' -import { setVisibilityFilter } from '../actions' +import { setVisibilityFilter } from '../slices/visibilityFilter' import Link from '../components/Link' const mapStateToProps = (state, ownProps) => ({ - active: ownProps.filter === state.visibilityFilter + active: ownProps.filter === state.visibilityFilter, }) const mapDispatchToProps = (dispatch, ownProps) => ({ setFilter: () => { dispatch(setVisibilityFilter(ownProps.filter)) - } + }, }) -export default connect( - mapStateToProps, - mapDispatchToProps -)(Link) +export default connect(mapStateToProps, mapDispatchToProps)(Link) diff --git a/src/containers/Header.js b/src/containers/Header.js index b15f143e..3174651a 100644 --- a/src/containers/Header.js +++ b/src/containers/Header.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux' import Header from '../components/Header' -import { addTodo } from '../actions' +import { addTodo } from '../slices/todos' export default connect(null, { addTodo })(Header) diff --git a/src/containers/MainSection.js b/src/containers/MainSection.js index 0dc095f8..80e9074e 100644 --- a/src/containers/MainSection.js +++ b/src/containers/MainSection.js @@ -1,23 +1,34 @@ import { connect } from 'react-redux' -import * as TodoActions from '../actions' import { bindActionCreators } from 'redux' import MainSection from '../components/MainSection' -import { getCompletedTodoCount } from '../selectors' +import { + getTodos, + getCompletedTodoCount, + addTodo, + deleteTodo, + editTodo, + completeTodo, + completeAllTodos, + clearCompleted, +} from '../slices/todos' - -const mapStateToProps = state => ({ - todosCount: state.todos.length, - completedCount: getCompletedTodoCount(state) +const mapStateToProps = (state) => ({ + todosCount: getTodos(state).length, + completedCount: getCompletedTodoCount(state), }) - -const mapDispatchToProps = dispatch => ({ - actions: bindActionCreators(TodoActions, dispatch) +const mapDispatchToProps = (dispatch) => ({ + actions: bindActionCreators( + { + addTodo, + deleteTodo, + editTodo, + completeTodo, + completeAllTodos, + clearCompleted, + }, + dispatch + ), }) - -export default connect( - mapStateToProps, - mapDispatchToProps -)(MainSection) - +export default connect(mapStateToProps, mapDispatchToProps)(MainSection) diff --git a/src/containers/VisibleTodoList.js b/src/containers/VisibleTodoList.js index 33f0bb95..ba44c1fd 100644 --- a/src/containers/VisibleTodoList.js +++ b/src/containers/VisibleTodoList.js @@ -1,21 +1,34 @@ import { connect } from 'react-redux' import { bindActionCreators } from 'redux' -import * as TodoActions from '../actions' +import { + addTodo, + deleteTodo, + editTodo, + completeTodo, + completeAllTodos, + clearCompleted, +} from '../slices/todos' import TodoList from '../components/TodoList' -import { getVisibleTodos } from '../selectors' +import { getVisibleTodos } from '../slices/visibilityFilter' -const mapStateToProps = state => ({ - filteredTodos: getVisibleTodos(state) +const mapStateToProps = (state) => ({ + filteredTodos: getVisibleTodos(state), }) -const mapDispatchToProps = dispatch => ({ - actions: bindActionCreators(TodoActions, dispatch) +const mapDispatchToProps = (dispatch) => ({ + actions: bindActionCreators( + { + addTodo, + deleteTodo, + editTodo, + completeTodo, + completeAllTodos, + clearCompleted, + }, + dispatch + ), }) - -const VisibleTodoList = connect( - mapStateToProps, - mapDispatchToProps -)(TodoList) +const VisibleTodoList = connect(mapStateToProps, mapDispatchToProps)(TodoList) export default VisibleTodoList diff --git a/src/reducers/index.js b/src/reducers/index.js deleted file mode 100644 index 430c5035..00000000 --- a/src/reducers/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import { combineReducers } from 'redux' -import todos from './todos' -import visibilityFilter from './visibilityFilter' - -const rootReducer = combineReducers({ - todos, - visibilityFilter -}) - -export default rootReducer diff --git a/src/reducers/todos.js b/src/reducers/todos.js deleted file mode 100644 index c18180b7..00000000 --- a/src/reducers/todos.js +++ /dev/null @@ -1,54 +0,0 @@ -import { - ADD_TODO, - CLEAR_COMPLETED, - COMPLETE_ALL_TODOS, - COMPLETE_TODO, - DELETE_TODO, - EDIT_TODO -} from '../constants/ActionTypes' - -// either use stub todos or an empty list -const initialState = (window.Cypress && window.initialState) || [] - -export default function todos (state = initialState, action) { - switch (action.type) { - case ADD_TODO: - return [ - ...state, - { - id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1, - completed: false, - text: action.text - } - ] - - case DELETE_TODO: - return state.filter(todo => todo.id !== action.id) - - case EDIT_TODO: - return state.map( - todo => (todo.id === action.id ? { ...todo, text: action.text } : todo) - ) - - case COMPLETE_TODO: - return state.map( - todo => - (todo.id === action.id - ? { ...todo, completed: !todo.completed } - : todo) - ) - - case COMPLETE_ALL_TODOS: - const areAllMarked = state.every(todo => todo.completed) - return state.map(todo => ({ - ...todo, - completed: !areAllMarked - })) - - case CLEAR_COMPLETED: - return state.filter(todo => todo.completed === false) - - default: - return state - } -} diff --git a/src/reducers/todos.spec.js b/src/reducers/todos.spec.js deleted file mode 100644 index 55168f2f..00000000 --- a/src/reducers/todos.spec.js +++ /dev/null @@ -1,288 +0,0 @@ -import todos from './todos' -import * as types from '../constants/ActionTypes' - -describe('todos reducer', () => { - it('should handle initial state', () => { - expect( - todos(undefined, {}) - ).toEqual([ - { - text: 'Use Redux', - completed: false, - id: 0 - } - ]) - }) - - it('should handle ADD_TODO', () => { - expect( - todos([], { - type: types.ADD_TODO, - text: 'Run the tests' - }) - ).toEqual([ - { - text: 'Run the tests', - completed: false, - id: 0 - } - ]) - - expect( - todos([ - { - text: 'Use Redux', - completed: false, - id: 0 - } - ], { - type: types.ADD_TODO, - text: 'Run the tests' - }) - ).toEqual([ - { - text: 'Use Redux', - completed: false, - id: 0 - }, - { - text: 'Run the tests', - completed: false, - id: 1 - } - ]) - - expect( - todos([ - { - text: 'Use Redux', - completed: false, - id: 0 - }, { - text: 'Run the tests', - completed: false, - id: 1 - } - ], { - type: types.ADD_TODO, - text: 'Fix the tests' - }) - ).toEqual([ - { - text: 'Use Redux', - completed: false, - id: 0 - }, - { - text: 'Run the tests', - completed: false, - id: 1 - }, - { - text: 'Fix the tests', - completed: false, - id: 2 - } - ]) - }) - - it('should handle DELETE_TODO', () => { - expect( - todos([ - { - text: 'Use Redux', - completed: false, - id: 0 - }, - { - text: 'Run the tests', - completed: false, - id: 1 - } - ], { - type: types.DELETE_TODO, - id: 1 - }) - ).toEqual([ - { - text: 'Use Redux', - completed: false, - id: 0 - } - ]) - }) - - it('should handle EDIT_TODO', () => { - expect( - todos([ - { - text: 'Run the tests', - completed: false, - id: 1 - }, { - text: 'Use Redux', - completed: false, - id: 0 - } - ], { - type: types.EDIT_TODO, - text: 'Fix the tests', - id: 1 - }) - ).toEqual([ - { - text: 'Fix the tests', - completed: false, - id: 1 - }, { - text: 'Use Redux', - completed: false, - id: 0 - } - ]) - }) - - it('should handle COMPLETE_TODO', () => { - expect( - todos([ - { - text: 'Run the tests', - completed: false, - id: 1 - }, { - text: 'Use Redux', - completed: false, - id: 0 - } - ], { - type: types.COMPLETE_TODO, - id: 1 - }) - ).toEqual([ - { - text: 'Run the tests', - completed: true, - id: 1 - }, { - text: 'Use Redux', - completed: false, - id: 0 - } - ]) - }) - - it('should handle COMPLETE_ALL_TODOS', () => { - expect( - todos([ - { - text: 'Run the tests', - completed: true, - id: 1 - }, { - text: 'Use Redux', - completed: false, - id: 0 - } - ], { - type: types.COMPLETE_ALL_TODOS - }) - ).toEqual([ - { - text: 'Run the tests', - completed: true, - id: 1 - }, { - text: 'Use Redux', - completed: true, - id: 0 - } - ]) - - // Unmark if all todos are currently completed - expect( - todos([ - { - text: 'Run the tests', - completed: true, - id: 1 - }, { - text: 'Use Redux', - completed: true, - id: 0 - } - ], { - type: types.COMPLETE_ALL_TODOS - }) - ).toEqual([ - { - text: 'Run the tests', - completed: false, - id: 1 - }, { - text: 'Use Redux', - completed: false, - id: 0 - } - ]) - }) - - it('should handle CLEAR_COMPLETED', () => { - expect( - todos([ - { - text: 'Run the tests', - completed: true, - id: 1 - }, { - text: 'Use Redux', - completed: false, - id: 0 - } - ], { - type: types.CLEAR_COMPLETED - }) - ).toEqual([ - { - text: 'Use Redux', - completed: false, - id: 0 - } - ]) - }) - - it('should not generate duplicate ids after CLEAR_COMPLETED', () => { - expect( - [ - { - type: types.COMPLETE_TODO, - id: 0 - }, { - type: types.CLEAR_COMPLETED - }, { - type: types.ADD_TODO, - text: 'Write more tests' - } - ].reduce(todos, [ - { - id: 0, - completed: false, - text: 'Use Redux' - }, { - id: 1, - completed: false, - text: 'Write tests' - } - ]) - ).toEqual([ - { - text: 'Write tests', - completed: false, - id: 1 - }, { - text: 'Write more tests', - completed: false, - id: 2 - } - ]) - }) -}) diff --git a/src/reducers/visibilityFilter.js b/src/reducers/visibilityFilter.js deleted file mode 100644 index c40e10c0..00000000 --- a/src/reducers/visibilityFilter.js +++ /dev/null @@ -1,13 +0,0 @@ -import { SET_VISIBILITY_FILTER } from '../constants/ActionTypes' -import { SHOW_ALL } from '../constants/TodoFilters' - -const visibilityFilter = (state = SHOW_ALL, action) => { - switch (action.type) { - case SET_VISIBILITY_FILTER: - return action.filter - default: - return state - } -} - -export default visibilityFilter \ No newline at end of file diff --git a/src/selectors/index.js b/src/selectors/index.js deleted file mode 100644 index a88a4239..00000000 --- a/src/selectors/index.js +++ /dev/null @@ -1,27 +0,0 @@ -import { createSelector } from 'reselect' -import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters' - -const getVisibilityFilter = state => state.visibilityFilter -const getTodos = state => state.todos - -export const getVisibleTodos = createSelector( - [getVisibilityFilter, getTodos], - (visibilityFilter, todos) => { - switch (visibilityFilter) { - case SHOW_ALL: - return todos - case SHOW_COMPLETED: - return todos.filter(t => t.completed) - case SHOW_ACTIVE: - return todos.filter(t => !t.completed) - default: - throw new Error('Unknown filter: ' + visibilityFilter) - } - } -) - -export const getCompletedTodoCount = createSelector( - [getTodos], - todos => - todos.reduce((count, todo) => (todo.completed ? count + 1 : count), 0) -) diff --git a/src/slices/todos.cy-spec.js b/src/slices/todos.cy-spec.js new file mode 100644 index 00000000..b890e0d0 --- /dev/null +++ b/src/slices/todos.cy-spec.js @@ -0,0 +1,305 @@ +/// +import todosReducer, { + addTodo, + deleteTodo, + editTodo, + completeTodo, + completeAllTodos, + clearCompleted, +} from './todos' + +describe('todos reducer', () => { + it('should handle initial state', () => { + expect(todosReducer(undefined, {})).to.deep.equal([]) + }) + + it(`should handle ${addTodo}`, () => { + expect(todosReducer([], addTodo({ text: 'Run the tests' }))).to.deep.equal([ + { + text: 'Run the tests', + completed: false, + id: 0, + }, + ]) + + expect( + todosReducer( + [ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + addTodo({ + text: 'Run the tests', + }) + ) + ).to.deep.equal([ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + { + text: 'Run the tests', + completed: false, + id: 1, + }, + ]) + + expect( + todosReducer( + [ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + { + text: 'Run the tests', + completed: false, + id: 1, + }, + ], + addTodo({ + text: 'Fix the tests', + }) + ) + ).to.deep.equal([ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + { + text: 'Run the tests', + completed: false, + id: 1, + }, + { + text: 'Fix the tests', + completed: false, + id: 2, + }, + ]) + }) + + it(`should handle ${deleteTodo}`, () => { + expect( + todosReducer( + [ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + { + text: 'Run the tests', + completed: false, + id: 1, + }, + ], + deleteTodo({ + id: 1, + }) + ) + ).to.deep.equal([ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should handle ${editTodo}`, () => { + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: false, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + editTodo({ + text: 'Fix the tests', + id: 1, + }) + ) + ).to.deep.equal([ + { + text: 'Fix the tests', + completed: false, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should handle ${completeTodo}`, () => { + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: false, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + completeTodo({ + id: 1, + }) + ) + ).to.deep.equal([ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should handle ${completeAllTodos}`, () => { + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + completeAllTodos() + ) + ).to.deep.equal([ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: true, + id: 0, + }, + ]) + + // Unmark if all todos are currently completed + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: true, + id: 0, + }, + ], + completeAllTodos() + ) + ).to.deep.equal([ + { + text: 'Run the tests', + completed: false, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should handle ${clearCompleted}`, () => { + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + clearCompleted() + ) + ).to.deep.equal([ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should not generate duplicate ids after ${clearCompleted}`, () => { + expect( + [ + completeTodo({ + id: 0, + }), + clearCompleted(), + addTodo({ + text: 'Write more tests', + }), + ].reduce(todosReducer, [ + { + id: 0, + completed: false, + text: 'Use Redux', + }, + { + id: 1, + completed: false, + text: 'Write tests', + }, + ]) + ).to.deep.equal([ + { + text: 'Write tests', + completed: false, + id: 1, + }, + { + text: 'Write more tests', + completed: false, + id: 2, + }, + ]) + }) +}) diff --git a/src/slices/todos.js b/src/slices/todos.js new file mode 100644 index 00000000..cd517a01 --- /dev/null +++ b/src/slices/todos.js @@ -0,0 +1,56 @@ +import { createSlice, createSelector } from '@reduxjs/toolkit' + +export const TODOS_FEATURE_KEY = 'todos' + +const initialState = (window.Cypress && window.initialState) || [] + +const todosSlice = createSlice({ + name: TODOS_FEATURE_KEY, + initialState, + reducers: { + addTodo: (state, action) => { + state.push({ + id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1, + completed: false, + text: action.payload.text, + }) + }, + deleteTodo: (state, action) => { + return state.filter((todo) => todo.id !== action.payload.id) + }, + editTodo: (state, action) => { + const index = state.findIndex((todo) => todo.id === action.payload.id) + state[index] = { ...state[index], text: action.payload.text } + }, + completeTodo: (state, action) => { + const index = state.findIndex((todo) => todo.id === action.payload.id) + state[index] = { ...state[index], completed: !state[index].completed } + }, + completeAllTodos: (state) => { + const areAllMarked = state.every((todo) => todo.completed) + + return state.map((todo) => ({ + ...todo, + completed: !areAllMarked, + })) + }, + clearCompleted: (state) => state.filter((todo) => todo.completed === false), + }, +}) + +export const { + addTodo, + deleteTodo, + editTodo, + completeTodo, + completeAllTodos, + clearCompleted, +} = todosSlice.actions + +export default todosSlice.reducer + +export const getTodos = (state) => state[TODOS_FEATURE_KEY] + +export const getCompletedTodoCount = createSelector(getTodos, (todos) => + todos.reduce((count, todo) => (todo.completed ? count + 1 : count), 0) +) diff --git a/src/slices/todos.spec.js b/src/slices/todos.spec.js new file mode 100644 index 00000000..e086c563 --- /dev/null +++ b/src/slices/todos.spec.js @@ -0,0 +1,304 @@ +import todosReducer, { + addTodo, + deleteTodo, + editTodo, + completeTodo, + completeAllTodos, + clearCompleted, +} from './todos' + +describe('todos reducer', () => { + it('should handle initial state', () => { + expect(todosReducer(undefined, {})).toEqual([]) + }) + + it(`should handle ${addTodo}`, () => { + expect(todosReducer([], addTodo({ text: 'Run the tests' }))).toEqual([ + { + text: 'Run the tests', + completed: false, + id: 0, + }, + ]) + + expect( + todosReducer( + [ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + addTodo({ + text: 'Run the tests', + }) + ) + ).toEqual([ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + { + text: 'Run the tests', + completed: false, + id: 1, + }, + ]) + + expect( + todosReducer( + [ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + { + text: 'Run the tests', + completed: false, + id: 1, + }, + ], + addTodo({ + text: 'Fix the tests', + }) + ) + ).toEqual([ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + { + text: 'Run the tests', + completed: false, + id: 1, + }, + { + text: 'Fix the tests', + completed: false, + id: 2, + }, + ]) + }) + + it(`should handle ${deleteTodo}`, () => { + expect( + todosReducer( + [ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + { + text: 'Run the tests', + completed: false, + id: 1, + }, + ], + deleteTodo({ + id: 1, + }) + ) + ).toEqual([ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should handle ${editTodo}`, () => { + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: false, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + editTodo({ + text: 'Fix the tests', + id: 1, + }) + ) + ).toEqual([ + { + text: 'Fix the tests', + completed: false, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should handle ${completeTodo}`, () => { + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: false, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + completeTodo({ + id: 1, + }) + ) + ).toEqual([ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should handle ${completeAllTodos}`, () => { + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + completeAllTodos() + ) + ).toEqual([ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: true, + id: 0, + }, + ]) + + // Unmark if all todos are currently completed + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: true, + id: 0, + }, + ], + completeAllTodos() + ) + ).toEqual([ + { + text: 'Run the tests', + completed: false, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should handle ${clearCompleted}`, () => { + expect( + todosReducer( + [ + { + text: 'Run the tests', + completed: true, + id: 1, + }, + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ], + clearCompleted() + ) + ).toEqual([ + { + text: 'Use Redux', + completed: false, + id: 0, + }, + ]) + }) + + it(`should not generate duplicate ids after ${clearCompleted}`, () => { + expect( + [ + completeTodo({ + id: 0, + }), + clearCompleted(), + addTodo({ + text: 'Write more tests', + }), + ].reduce(todosReducer, [ + { + id: 0, + completed: false, + text: 'Use Redux', + }, + { + id: 1, + completed: false, + text: 'Write tests', + }, + ]) + ).toEqual([ + { + text: 'Write tests', + completed: false, + id: 1, + }, + { + text: 'Write more tests', + completed: false, + id: 2, + }, + ]) + }) +}) diff --git a/src/slices/visibilityFilter.js b/src/slices/visibilityFilter.js new file mode 100644 index 00000000..91fe4e2f --- /dev/null +++ b/src/slices/visibilityFilter.js @@ -0,0 +1,42 @@ +import { createSlice, createSelector } from '@reduxjs/toolkit' + +import { SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE } from '../constants/TodoFilters' +import { getTodos } from './todos' + +export const VISIBILITY_FILTER_FEATURE_KEY = 'visibilityFilter' + +const visibilityFilterSlice = createSlice({ + name: VISIBILITY_FILTER_FEATURE_KEY, + initialState: SHOW_ALL, + reducers: { + setVisibilityFilter: (_, action) => action.payload, + }, +}) + +export const { + setVisibilityFilter, + showAll, + showCompleted, + showActive, +} = visibilityFilterSlice.actions + +export default visibilityFilterSlice.reducer + +const getVisibilityFilter = (state) => state[VISIBILITY_FILTER_FEATURE_KEY] + +export const getVisibleTodos = createSelector( + getVisibilityFilter, + getTodos, + (visibilityFilter, todos) => { + switch (visibilityFilter) { + case SHOW_ALL: + return todos + case SHOW_COMPLETED: + return todos.filter((t) => t.completed) + case SHOW_ACTIVE: + return todos.filter((t) => !t.completed) + default: + throw new Error('Unknown filter: ' + visibilityFilter) + } + } +) diff --git a/src/store.js b/src/store.js index 59598553..6346c508 100644 --- a/src/store.js +++ b/src/store.js @@ -1,10 +1,21 @@ import React from 'react' -import { createStore } from 'redux' +import { configureStore } from '@reduxjs/toolkit' import { Provider } from 'react-redux' -import reducer from './reducers' -export const store = createStore(reducer) -export const StoreProvider = ({children}) => ({children}) +import todosReducer, { TODOS_FEATURE_KEY } from './slices/todos' +import visibilityFilterReducer, { + VISIBILITY_FILTER_FEATURE_KEY, +} from './slices/visibilityFilter' + +export const store = configureStore({ + reducer: { + [TODOS_FEATURE_KEY]: todosReducer, + [VISIBILITY_FILTER_FEATURE_KEY]: visibilityFilterReducer, + }, +}) +export const StoreProvider = ({ children }) => ( + {children} +) // expose store during tests /* istanbul ignore else */ diff --git a/yarn.lock b/yarn.lock index 0ca494d9..cdfeb067 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1667,6 +1667,16 @@ "@parcel/utils" "^1.11.0" physical-cpu-count "^2.0.0" +"@reduxjs/toolkit@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.4.0.tgz#ee2e2384cc3d1d76780d844b9c2da3580d32710d" + integrity sha512-hkxQwVx4BNVRsYdxjNF6cAseRmtrkpSlcgJRr3kLUcHPIAMZAmMJkXmHh/eUEGTMqPzsYpJLM7NN2w9fxQDuGw== + dependencies: + immer "^7.0.3" + redux "^4.0.0" + redux-thunk "^2.3.0" + reselect "^4.0.0" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -4600,6 +4610,11 @@ ignore@^5.1.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf" integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A== +immer@^7.0.3: + version "7.0.8" + resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.8.tgz#41dcbc5669a76500d017bef3ad0d03ce0a1d7c1e" + integrity sha512-XnpIN8PXBBaOD43U8Z17qg6RQiKQYGDGGCIbz1ixmLGwBkSWwmrmx5X7d+hTtXDM8ur7m5OdLE0PiO+y5RB3pw== + import-fresh@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-2.0.0.tgz#d81355c15612d386c61f9ddd3922d4304822a546" @@ -6967,7 +6982,12 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" -redux@4.0.5: +redux-thunk@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.3.0.tgz#51c2c19a185ed5187aaa9a2d08b666d0d6467622" + integrity sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw== + +redux@^4.0.0: version "4.0.5" resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -7123,7 +7143,7 @@ require-main-filename@^2.0.0: resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== -reselect@4.0.0: +reselect@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==