├── .circleci └── config.yml ├── .editorconfig ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── flowtask_preview.gif ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo.png ├── logo144.png ├── manifest.json └── robots.txt ├── src ├── App.js ├── App.test.js ├── assets │ ├── bin.svg │ ├── checkmark.svg │ ├── fonts │ │ └── AvenirNext │ │ │ ├── AvenirNexBoldCn.otf │ │ │ ├── AvenirNextBold.otf │ │ │ ├── AvenirNextDemi.otf │ │ │ ├── AvenirNextDemiCn.otf │ │ │ ├── AvenirNextIt.otf │ │ │ └── AvenirNextRegular.otf │ └── plus.svg ├── components │ ├── Board │ │ ├── Board.js │ │ ├── Board.scss │ │ └── Board.test.js │ ├── Column │ │ ├── Column.js │ │ ├── Column.scss │ │ └── Column.test.js │ ├── Loader │ │ ├── Loader.js │ │ ├── Loader.scss │ │ └── Loader.test.js │ └── Task │ │ ├── Task.js │ │ ├── Task.scss │ │ └── Task.test.js ├── index.js ├── index.scss ├── lib │ ├── Api.js │ └── Api.test.js ├── store │ ├── actions.js │ ├── actions │ │ ├── bootstrap.js │ │ ├── bootstrap.test.js │ │ ├── completeTask.js │ │ ├── completeTask.test.js │ │ ├── createTaskInColumn.js │ │ ├── createTaskInColumn.test.js │ │ ├── moveTask.js │ │ └── moveTask.test.js │ ├── columns │ │ ├── actions │ │ │ ├── loadColumns.js │ │ │ ├── loadColumns.test.js │ │ │ ├── updateColumn.js │ │ │ └── updateColumn.test.js │ │ ├── constants.js │ │ ├── index.js │ │ └── index.test.js │ ├── index.js │ ├── selectors.js │ ├── tasks │ │ ├── actions │ │ │ ├── createTask.js │ │ │ ├── createTask.test.js │ │ │ ├── deleteTaskAndUpdateColumn.js │ │ │ ├── deleteTaskAndUpdateColumn.test.js │ │ │ ├── loadTasks.js │ │ │ ├── loadTasks.test.js │ │ │ ├── updateTask.js │ │ │ └── updateTask.test.js │ │ ├── constants.js │ │ ├── index.js │ │ └── index.test.js │ └── utils.js └── testUtils │ ├── FakeReduxProvider.js │ └── unexpected-react.js └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 # use CircleCI 2.0 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:10 6 | steps: 7 | - checkout # special step to check out source code to working directory 8 | 9 | - restore_cache: # special step to restore the dependency cache 10 | # Read about caching dependencies: https://circleci.com/docs/2.0/caching/ 11 | keys: 12 | - v1-repo-{{ checksum "package-lock.json" }} 13 | 14 | - run: 15 | name: Install dependencies with NPM 16 | command: yarn install # replace with `yarn install` if using yarn 17 | 18 | - save_cache: # special step to save the dependency cache 19 | key: v1-repo-{{ checksum "package-lock.json" }} 20 | paths: 21 | - "node_modules" 22 | 23 | - run: 24 | name: Run tests 25 | command: yarn test --watchAll=false 26 | 27 | workflows: 28 | version: 2 29 | Build and Test: 30 | jobs: 31 | - build 32 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | indent_size = 2 9 | indent_style = space 10 | 11 | [*.i18n] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{css,less,scss,ccss}] 16 | indent_style = space 17 | indent_size = 4 18 | 19 | [*.cjson] 20 | indent_style = space 21 | indent_size = 4 22 | 23 | [*.feature] 24 | indent_style = space 25 | indent_size = 2 26 | 27 | [Makefile] 28 | indent_style = tab 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Ignore css since we are using sass 15 | src/**/*.css 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Bower dependency directory (https://bower.io/) 24 | bower_components 25 | 26 | # node-waf configuration 27 | .lock-wscript 28 | 29 | # Compiled binary addons (https://nodejs.org/api/addons.html) 30 | build/Release 31 | 32 | # TypeScript v1 declaration files 33 | typings/ 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional eslint cache 39 | .eslintcache 40 | 41 | # Optional REPL history 42 | .node_repl_history 43 | 44 | # Output of 'npm pack' 45 | *.tgz 46 | 47 | # Yarn Integrity file 48 | .yarn-integrity 49 | 50 | # dotenv environment variables file 51 | .env 52 | 53 | # next.js build output 54 | .next 55 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 56 | 57 | # dependencies 58 | /node_modules 59 | /.pnp 60 | .pnp.js 61 | 62 | # testing 63 | /coverage 64 | 65 | # production 66 | /build 67 | 68 | # VS code config 69 | .vscode 70 | 71 | # Our simple db files 72 | src/db/*.json 73 | 74 | # misc 75 | .DS_Store 76 | .env.local 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | 81 | npm-debug.log* 82 | yarn-debug.log* 83 | yarn-error.log* 84 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.15.1 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Miroslav Nikolov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 |
7 |
8 | 9 |

10 | Simple yet interactive task board for your mobile browser. Written in React with create-react-app. 11 |

12 | 13 | [![moubi](https://img.shields.io/circleci/build/gh/moubi/flow-task?label=circleci&style=flat-square)](https://circleci.com/gh/moubi/flow-task) [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/moubi/flow-task.svg?style=flat-square&logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/moubi/flow-task/context:javascript) [![moubi](https://img.shields.io/github/license/moubi/flow-task?style=flat-square)](LICENSE) 14 | 15 | A preview of the FlowTask app 16 |

17 | We have live demo too! 18 |

19 |
20 | 21 | ## Getting started 22 | Go to your project folder and 23 | ``` 24 | git clone git@github.com:moubi/flow-task.git 25 | cd flow-task/ 26 | yarn 27 | yarn start 28 | ``` 29 | 30 | That's it. Navigating to http://localhost:3002 will bring the board with some example data from the localStorage. 31 | 32 | ### Prior installation 33 | The project is built with node version **10.15.1**. This is set in the `.nvmrc` file. You may need to additionally install it on your development machine. With `nvm`: 34 | ``` 35 | nvm install 10.15.1 36 | nvm use 37 | ``` 38 | 39 | ## Backend integration 40 | `master` branch represents pure frontend app that uses _**localStorage database**_ for columns and tasks data. This is set in `src/lib/Api.js`. There are also two backend integrations for deployment on php and node enabled hostings. 41 | 42 | - Php ([feature/php-backend](https://github.com/moubi/flow-task/tree/feature/php-server) branch) 43 | - Node ([feature/node-backend](https://github.com/moubi/flow-task/tree/feature/node-server) branch) 44 | 45 | Check corresponding READMEs for more info. 46 | 47 | ## Words on structure 48 | ```bash 49 | . 50 | ├── App.js 51 | ├── App.test.js 52 | ├── assets 53 | ├── components 54 | ├── index.js 55 | ├── index.scss 56 | ├── lib 57 | ├── store 58 | └── testUtils 59 | ``` 60 | 61 | **Some interesting paths:** 62 | 63 | `|-- components` - all React app components (Board, Column, Task and Loader). 64 | 65 | `|-- store` - actions and reducers split between the columns and tasks modules. 66 | 67 | `|-- testUtils` - testing utilities for simulating events, working with store and getting component instalnces. 68 | 69 | `|-- lib` - contains Api.js to handle localStorage queries for data. 70 | 71 | ## Tests 72 | Trigger the test suite by 73 | 74 | ``` 75 | yarn test 76 | ``` 77 | 78 | Tests cover all the components, reducers and actions. Each test (`*.test.js`) is placed next to its target file. 79 | 80 | ## Deployment 81 | To build a `create-react-app` project run: 82 | 83 | ``` 84 | yarn build 85 | ``` 86 | 87 | All the production files are then stored in the `build/` folder. 88 | 89 | [Node](https://github.com/moubi/flow-task/tree/feature/node-server) and [php](https://github.com/moubi/flow-task/tree/feature/php-server) backends have their own build process. 90 | 91 | ## Support 92 | Though, it implements several interesting ideas and UI effects, the board was initially intended to serve personal goals and developed for _**latest iOS Safari**_. 93 | 94 | ## Contributing (aka roadmap) 95 | - Android support - there shouldn't be much missing 96 | - DB implementation (perhaps MongoDB?) 97 | - Keep it simple, quick, but interactive (challenge: having usable, but not sassy UI) 98 | - Avoid being Trello, Jira or Asana board alternative 99 | 100 | Good starting point is the [project board](https://github.com/moubi/flow-task/projects/1). 101 | Open an issue if you need (there is no strict rule or a template) or email me directly. Your contribution is 100% welcome. 102 | 103 | ## Authors 104 | [Miroslav Nikolov](https://webup.org) 105 | 106 | ## License 107 | [MIT](LICENSE) 108 | 109 | ## Acknowledgments 110 | * [swipeable-react](https://github.com/moubi/swipeable-react) - catching swipe interactions 111 | * [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd) - great drag and drop module for React 112 | * [unexpected-dom](https://github.com/unexpectedjs/unexpected-dom) - easily test React components and events 113 | -------------------------------------------------------------------------------- /flowtask_preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/flowtask_preview.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flow-task", 3 | "version": "1.0.1", 4 | "homepage": ".", 5 | "author": "Miroslav Nikolov", 6 | "license": "MIT", 7 | "dependencies": { 8 | "lodash": "^4.17.21", 9 | "node-sass": "^4.13.1", 10 | "react": "^16.13.0", 11 | "react-beautiful-dnd": "13.0.0", 12 | "react-dom": "^16.13.0", 13 | "react-redux": "^7.2.0", 14 | "react-scripts": "3.4.0", 15 | "redux": "^4.0.5", 16 | "swipeable-react": "1.2.1", 17 | "uid": "1.0.0" 18 | }, 19 | "devDependencies": { 20 | "classnames": "2.2.6", 21 | "react-dom-testing": "^1.11.0", 22 | "redux-thunk": "2.3.0", 23 | "sinon": "9.0.0", 24 | "unexpected": "^11.13.0", 25 | "unexpected-dom": "^4.17.0", 26 | "unexpected-reaction": "^2.16.0", 27 | "unexpected-sinon": "^10.11.2" 28 | }, 29 | "scripts": { 30 | "client": "PORT=3002 react-scripts start", 31 | "start": "npm run client", 32 | "build": "react-scripts build", 33 | "test": "react-scripts test", 34 | "eject": "react-scripts eject" 35 | }, 36 | "eslintConfig": { 37 | "extends": "react-app" 38 | }, 39 | "proxy": "http://localhost:8080", 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | FlowTask 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/public/logo.png -------------------------------------------------------------------------------- /public/logo144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/public/logo144.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "FlowTask", 3 | "name": "Simple yet interactive task board for your mobile browser", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo144.png", 12 | "type": "image/png", 13 | "sizes": "144x144" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "fullscreen", 18 | "theme_color": "#000000", 19 | "background_color": "#ffffff" 20 | } 21 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { getColumns, getTasks, isFetching } from "./store/selectors"; 5 | import Board from "./components/Board/Board"; 6 | import Loader from "./components/Loader/Loader"; 7 | 8 | export class App extends Component { 9 | render() { 10 | const { columns, tasks, tasksFetching } = this.props; 11 | 12 | if (Object.keys(columns).length === 0 || tasksFetching) { 13 | return ; 14 | } 15 | 16 | return ; 17 | } 18 | } 19 | 20 | App.propTypes = { 21 | columns: PropTypes.object.isRequired, 22 | tasks: PropTypes.object.isRequired, 23 | tasksFetching: PropTypes.bool.isRequired 24 | }; 25 | 26 | export default connect(state => ({ 27 | columns: getColumns(state), 28 | tasks: getTasks(state), 29 | tasksFetching: isFetching(state) 30 | }))(App); 31 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import expect, { withStore } from "./testUtils/unexpected-react"; 2 | import React from "react"; 3 | 4 | import { App } from "./App"; 5 | 6 | let props; 7 | 8 | // Disable react-beautiful-dnd warnings since they 9 | // do not impract the test results 10 | window["__react-beautiful-dnd-disable-dev-warnings"] = true; 11 | 12 | describe("App", () => { 13 | beforeEach(() => { 14 | props = { 15 | columns: {}, 16 | tasks: {}, 17 | tasksFetching: false 18 | }; 19 | }); 20 | 21 | it("should render default", () => { 22 | expect( 23 | , 24 | "when mounted", 25 | "to exhaustively satisfy", 26 |
Loading...
27 | ); 28 | }); 29 | 30 | it("should show loading if tasks are not ready", () => { 31 | props = { 32 | ...props, 33 | columns: { 34 | "0": { 35 | id: "0", 36 | name: "To do", 37 | tasks: ["1", "2"] 38 | }, 39 | "1": { 40 | id: "1", 41 | name: "Doing", 42 | tasks: ["3"] 43 | }, 44 | "2": { 45 | id: "2", 46 | name: "Done", 47 | tasks: [] 48 | } 49 | }, 50 | tasksFetching: true 51 | }; 52 | 53 | expect( 54 | , 55 | "when mounted", 56 | "to exhaustively satisfy", 57 |
Loading...
58 | ); 59 | }); 60 | 61 | it("should render the board", () => { 62 | props = { 63 | ...props, 64 | columns: { 65 | "0": { 66 | id: "0", 67 | name: "To do", 68 | tasks: ["1", "2"] 69 | }, 70 | "1": { 71 | id: "1", 72 | name: "Doing", 73 | tasks: ["3"] 74 | }, 75 | "2": { 76 | id: "2", 77 | name: "Done", 78 | tasks: [] 79 | } 80 | }, 81 | tasks: { 82 | "1": { text: "Buy some cakes" }, 83 | "2": { text: "Visit parents" }, 84 | "3": { text: "Prepare for the next Math exam" } 85 | } 86 | }; 87 | 88 | const AppWithAStore = withStore(App); 89 | 90 | expect( 91 | , 92 | "when mounted", 93 | "to have class", 94 | "Board" 95 | ); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/assets/bin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/assets/checkmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/assets/fonts/AvenirNext/AvenirNexBoldCn.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/src/assets/fonts/AvenirNext/AvenirNexBoldCn.otf -------------------------------------------------------------------------------- /src/assets/fonts/AvenirNext/AvenirNextBold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/src/assets/fonts/AvenirNext/AvenirNextBold.otf -------------------------------------------------------------------------------- /src/assets/fonts/AvenirNext/AvenirNextDemi.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/src/assets/fonts/AvenirNext/AvenirNextDemi.otf -------------------------------------------------------------------------------- /src/assets/fonts/AvenirNext/AvenirNextDemiCn.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/src/assets/fonts/AvenirNext/AvenirNextDemiCn.otf -------------------------------------------------------------------------------- /src/assets/fonts/AvenirNext/AvenirNextIt.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/src/assets/fonts/AvenirNext/AvenirNextIt.otf -------------------------------------------------------------------------------- /src/assets/fonts/AvenirNext/AvenirNextRegular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/moubi/flow-task/9a530eb50d90fa3bfb3877ea80e1cbdb96a79211/src/assets/fonts/AvenirNext/AvenirNextRegular.otf -------------------------------------------------------------------------------- /src/assets/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/Board/Board.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; 5 | 6 | import { moveTask } from "../../store/actions"; 7 | 8 | import Column from "../Column/Column"; 9 | import Task from "../Task/Task"; 10 | import Swipeable from "swipeable-react"; 11 | 12 | import "./Board.scss"; 13 | 14 | export let VIEWPORT_WIDTH = 0; 15 | 16 | const stopImmediatePropagation = e => { 17 | e.stopImmediatePropagation(); 18 | }; 19 | 20 | const getColumnIndexAtPosition = scrollX => 21 | Math.floor(Math.abs(scrollX) / VIEWPORT_WIDTH); 22 | 23 | const getColumnTransitionStyle = columnIndex => ({ 24 | left: columnIndex * -VIEWPORT_WIDTH + "px", 25 | transition: "left 0.3s cubic-bezier(0.075, 0.82, 0.165, 1)" 26 | }); 27 | 28 | export class Board extends Component { 29 | constructor(props) { 30 | super(props); 31 | 32 | this.el = null; 33 | this.state = { 34 | swipeStyle: { left: 0 } 35 | }; 36 | 37 | this.handleDragEnd = this.handleDragEnd.bind(this); 38 | this.handleSwipeLeft = this.handleSwipeLeft.bind(this); 39 | this.handleSwipeRight = this.handleSwipeRight.bind(this); 40 | this.handleOnBeforeDragCapture = this.handleOnBeforeDragCapture.bind(this); 41 | // This is needed in order to disable dragging with keyboard 42 | // It doesn't work if placed in componentWillMount 43 | window.addEventListener("keydown", stopImmediatePropagation, true); 44 | } 45 | 46 | componentDidMount() { 47 | const columnsLength = Object.keys(this.props.columns).length; 48 | VIEWPORT_WIDTH = this.el.offsetWidth / columnsLength; 49 | } 50 | 51 | componentWillUnmount() { 52 | window.removeEventListener("keydown", stopImmediatePropagation, true); 53 | } 54 | 55 | handleDragEnd({ source, destination }) { 56 | if (destination) { 57 | const { columns, moveTask } = this.props; 58 | const draggedTaskId = columns[source.droppableId].tasks[source.index]; 59 | const destinationColumnId = columns[destination.droppableId].id; 60 | 61 | moveTask(draggedTaskId, destinationColumnId, destination.index); 62 | } 63 | 64 | this.setState({ 65 | swipeStyle: { 66 | position: "fixed", 67 | left: `${-1 * window.scrollX}px` 68 | } 69 | }); 70 | } 71 | 72 | handleSwipeLeft() { 73 | const { swipeStyle } = this.state; 74 | const left = parseInt(swipeStyle.left); 75 | let columnIndexInView = getColumnIndexAtPosition(left); 76 | 77 | if (columnIndexInView < 2) { 78 | this.setState({ 79 | swipeStyle: { 80 | ...swipeStyle, 81 | ...getColumnTransitionStyle(columnIndexInView + 1) 82 | } 83 | }); 84 | } 85 | } 86 | 87 | handleSwipeRight() { 88 | const { swipeStyle } = this.state; 89 | const left = Math.abs(parseInt(swipeStyle.left)); 90 | let columnIndexInView = getColumnIndexAtPosition(left); 91 | const isInTheMiddleOfTwoColumns = !Number.isInteger(left / VIEWPORT_WIDTH); 92 | 93 | if (isInTheMiddleOfTwoColumns) { 94 | // If we are in the middle of two columns 95 | // set the view to the one on the right 96 | columnIndexInView = columnIndexInView + 1; 97 | } 98 | 99 | if (columnIndexInView > 0) { 100 | this.setState({ 101 | swipeStyle: { 102 | ...swipeStyle, 103 | ...getColumnTransitionStyle(columnIndexInView - 1) 104 | } 105 | }); 106 | } 107 | } 108 | 109 | handleOnBeforeDragCapture() { 110 | const { swipeStyle } = this.state; 111 | this.setState({ 112 | swipeStyle: { 113 | ...swipeStyle, 114 | position: "initial" 115 | } 116 | }); 117 | // This is needed if we have swiped to other columns previously 118 | // It prevents jumping to the first column when drag starts 119 | // For some reason (-1 * parseInt(swipeStyle.left)) may equal -0 120 | window.scroll(parseInt(-1 * parseInt(swipeStyle.left)), 0); 121 | } 122 | 123 | render() { 124 | const { columns, tasks } = this.props; 125 | const { swipeStyle } = this.state; 126 | const hasTasks = Object.keys(tasks).length > 0; 127 | 128 | return ( 129 | 134 | {innerRef => ( 135 |
{ 138 | this.el = el; 139 | innerRef(el); 140 | }} 141 | style={{ ...swipeStyle }} 142 | > 143 | 147 | {Object.values(columns).map(column => ( 148 | 153 | {(provided, snapshot) => ( 154 | 162 | {hasTasks && 163 | column.tasks.map( 164 | (taskId, taskIndex) => 165 | tasks[taskId] && ( 166 | 172 | {(provided, snapshot) => ( 173 | // TODO: Think about removing this wrapper 174 | // at some point and use directly 175 |
180 | 186 |
187 | )} 188 |
189 | ) 190 | )} 191 | {provided.placeholder} 192 |
193 | )} 194 |
195 | ))} 196 |
197 |
198 | )} 199 |
200 | ); 201 | } 202 | } 203 | 204 | Board.propTypes = { 205 | columns: PropTypes.object.isRequired, 206 | tasks: PropTypes.object.isRequired, 207 | moveTask: PropTypes.func.isRequired 208 | }; 209 | 210 | export default connect(null, { 211 | moveTask 212 | })(Board); 213 | -------------------------------------------------------------------------------- /src/components/Board/Board.scss: -------------------------------------------------------------------------------- 1 | .Board { 2 | // The surface color is #121212 or hsl(0, 0%, 7%) 3 | $surface-color: hsl(0, 0%, 7%); 4 | 5 | // Fix to disable horizontal scroll 6 | position: fixed; 7 | display: flex; 8 | width: 300%; // 100% * number of columns 9 | height: 100%; 10 | align-items: stretch; 11 | background-color: $surface-color; 12 | overflow: hidden; 13 | left: 0; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Board/Board.test.js: -------------------------------------------------------------------------------- 1 | import expect, { 2 | Mounter, 3 | withStore, 4 | Ignore, 5 | getInstanceWithStore 6 | } from "../../testUtils/unexpected-react"; 7 | import React from "react"; 8 | import sinon from "sinon"; 9 | 10 | import { Board as BoardUnconnected, VIEWPORT_WIDTH } from "./Board"; 11 | 12 | const Board = withStore(BoardUnconnected); 13 | let props; 14 | 15 | // Return the hardcoded width of the board. 16 | // It is set to 960 - 3 columns * 320 (iPhone SE screen size) 17 | // https://github.com/jsdom/jsdom/issues/135#issuecomment-68191941 18 | Object.defineProperties(window.HTMLElement.prototype, { 19 | offsetWidth: { 20 | get: () => 960 21 | } 22 | }); 23 | // Disable react-beautiful-dnd warnings since they 24 | // do not impract the test results 25 | window["__react-beautiful-dnd-disable-dev-warnings"] = true; 26 | 27 | describe("Board", () => { 28 | beforeEach(() => { 29 | props = { 30 | columns: { 31 | "0": { 32 | id: "0", 33 | name: "To do", 34 | tasks: ["1", "2"] 35 | }, 36 | "1": { 37 | id: "1", 38 | name: "Doing", 39 | tasks: ["3"] 40 | }, 41 | "2": { 42 | id: "2", 43 | name: "Done", 44 | tasks: [] 45 | } 46 | }, 47 | tasks: {}, 48 | moveTask: sinon.stub().named("moveTask") 49 | }; 50 | }); 51 | 52 | it("should render default", () => { 53 | return expect( 54 | , 55 | "when mounted", 56 | "to exhaustively satisfy", 57 |
58 |
59 | 60 | 61 |
62 |
63 | 64 | 65 |
66 |
67 | 68 | 69 |
70 |
71 | ); 72 | }); 73 | 74 | it("should render with tasks", () => { 75 | props.columns = { 76 | ...props.columns, 77 | "0": { 78 | ...props.columns["0"], 79 | tasks: ["1", "2"] 80 | } 81 | }; 82 | props.tasks = { 83 | "1": { text: "Buy some cakes" }, 84 | "2": { text: "Visit parents" } 85 | }; 86 | 87 | return expect( 88 | , 89 | "when mounted", 90 | "queried for first", 91 | ".Column .Column-body", 92 | "to exhaustively satisfy", 93 |
98 |
106 |
107 | 108 | 109 |
110 |
111 |
119 |
120 | 121 | 122 |
123 |
124 |
125 | ); 126 | }); 127 | 128 | it("should move a task within a column", () => { 129 | props.columns = { 130 | ...props.columns, 131 | "0": { 132 | ...props.columns["0"], 133 | tasks: ["1", "2"] 134 | } 135 | }; 136 | props.tasks = { 137 | "1": { text: "Buy some cakes" }, 138 | "2": { text: "Visit parents" } 139 | }; 140 | 141 | let instance = null; 142 | const { subject } = getInstanceWithStore( 143 | { 146 | instance = el; 147 | }} 148 | /> 149 | ); 150 | 151 | instance.handleDragEnd({ 152 | source: { droppableId: "0", index: 0 }, 153 | destination: { droppableId: "0", index: 1 } 154 | }); 155 | 156 | return expect(subject, "to have attributes", { 157 | style: { 158 | position: "fixed", 159 | left: "0px" 160 | } 161 | }).then(() => 162 | expect(props.moveTask, "to have a call exhaustively satisfying", [ 163 | "1", 164 | "0", 165 | 1 166 | ]) 167 | ); 168 | }); 169 | 170 | it("should NOT move a task if drop is unsuccessful", () => { 171 | props.columns = { 172 | ...props.columns, 173 | "0": { 174 | ...props.columns["0"], 175 | tasks: ["1", "2"] 176 | } 177 | }; 178 | props.tasks = { 179 | "1": { text: "Buy some cakes" }, 180 | "2": { text: "Visit parents" } 181 | }; 182 | 183 | let instance = null; 184 | getInstanceWithStore( 185 | { 188 | instance = el; 189 | }} 190 | /> 191 | ); 192 | 193 | instance.handleDragEnd({ 194 | source: { droppableId: "0", index: 0 }, 195 | destination: null 196 | }); 197 | 198 | return expect(props.moveTask, "was not called"); 199 | }); 200 | 201 | it("should preset draggable styling before drag starts", () => { 202 | window.scroll = sinon.stub().named("scroll"); 203 | 204 | let instance = null; 205 | const { subject } = getInstanceWithStore( 206 | { 209 | instance = el; 210 | }} 211 | /> 212 | ); 213 | 214 | instance.handleOnBeforeDragCapture(); 215 | 216 | return expect(subject, "to have attributes", { 217 | style: { left: "0px", position: "initial" } 218 | }).then(() => 219 | expect(window.scroll, "to have a call exhaustively satisfying", [0, 0]) 220 | ); 221 | }); 222 | 223 | it("should set the viewport width", () => { 224 | getInstanceWithStore( 225 | 226 | 227 | 228 | ); 229 | 230 | return expect(VIEWPORT_WIDTH, "to be", 320); 231 | }); 232 | 233 | describe("with swiping", () => { 234 | it("should swipe to the second column", () => { 235 | let instance = null; 236 | const { subject } = getInstanceWithStore( 237 | 238 | { 241 | instance = el; 242 | }} 243 | /> 244 | 245 | ); 246 | 247 | instance.handleSwipeLeft(); 248 | 249 | return expect( 250 | subject, 251 | "queried for first", 252 | ".Board", 253 | "to have attributes", 254 | { 255 | style: { 256 | left: "-320px", 257 | transition: "left 0.3s cubic-bezier(0.075, 0.82, 0.165, 1)" 258 | } 259 | } 260 | ); 261 | }); 262 | 263 | it("should NOT swipe further right when viewing last column", () => { 264 | let instance = null; 265 | const { subject } = getInstanceWithStore( 266 | 267 | { 270 | instance = el; 271 | }} 272 | /> 273 | 274 | ); 275 | 276 | instance.handleSwipeLeft(); 277 | instance.handleSwipeLeft(); 278 | instance.handleSwipeLeft(); 279 | 280 | return expect( 281 | subject, 282 | "queried for first", 283 | ".Board", 284 | "to have attributes", 285 | { 286 | style: { 287 | left: "-640px", 288 | transition: "left 0.3s cubic-bezier(0.075, 0.82, 0.165, 1)" 289 | } 290 | } 291 | ); 292 | }); 293 | 294 | it("should NOT swipe further left when viewing first column", () => { 295 | let instance = null; 296 | const { subject } = getInstanceWithStore( 297 | 298 | { 301 | instance = el; 302 | }} 303 | /> 304 | 305 | ); 306 | 307 | instance.handleSwipeRight(); 308 | 309 | return expect( 310 | subject, 311 | "queried for first", 312 | ".Board", 313 | "to have attributes", 314 | { 315 | style: { 316 | left: "0px" 317 | } 318 | } 319 | ); 320 | }); 321 | 322 | it("should swipe from third to the second column", () => { 323 | let instance = null; 324 | const { subject } = getInstanceWithStore( 325 | 326 | { 329 | instance = el; 330 | }} 331 | /> 332 | 333 | ); 334 | 335 | instance.handleSwipeLeft(); 336 | instance.handleSwipeLeft(); 337 | instance.handleSwipeRight(); 338 | 339 | return expect( 340 | subject, 341 | "queried for first", 342 | ".Board", 343 | "to have attributes", 344 | { 345 | style: { 346 | left: "-320px", 347 | transition: "left 0.3s cubic-bezier(0.075, 0.82, 0.165, 1)" 348 | } 349 | } 350 | ); 351 | }); 352 | 353 | it("should swipe to first column beeing between the first and second one", () => { 354 | props.tasks = { 355 | "1": { text: "Buy some cakes" }, 356 | "2": { text: "Visit parents" } 357 | }; 358 | props.columns = { 359 | ...props.columns, 360 | "0": { 361 | ...props.columns["0"], 362 | tasks: ["1"] 363 | }, 364 | "1": { 365 | ...props.columns["1"], 366 | tasks: ["2"] 367 | } 368 | }; 369 | let instance = null; 370 | const { subject } = getInstanceWithStore( 371 | 372 | { 375 | instance = el; 376 | }} 377 | /> 378 | 379 | ); 380 | 381 | Object.defineProperty(window, "scrollX", { value: -117, writable: true }); 382 | 383 | instance.handleDragEnd({ 384 | source: { droppableId: "0", index: 0 }, 385 | destination: { droppableId: "1", index: 0 } 386 | }); 387 | 388 | instance.handleSwipeRight(); 389 | 390 | return expect( 391 | subject, 392 | "queried for first", 393 | ".Board", 394 | "to have attributes", 395 | { 396 | style: { 397 | left: "0px", 398 | transition: "left 0.3s cubic-bezier(0.075, 0.82, 0.165, 1)" 399 | } 400 | } 401 | ); 402 | }); 403 | 404 | it("should swipe to second column beeing between the first and second one", () => { 405 | props.tasks = { 406 | "1": { text: "Buy some cakes" }, 407 | "2": { text: "Visit parents" } 408 | }; 409 | props.columns = { 410 | ...props.columns, 411 | "0": { 412 | ...props.columns["0"], 413 | tasks: ["1"] 414 | }, 415 | "1": { 416 | ...props.columns["1"], 417 | tasks: ["2"] 418 | } 419 | }; 420 | let instance = null; 421 | const { subject } = getInstanceWithStore( 422 | 423 | { 426 | instance = el; 427 | }} 428 | /> 429 | 430 | ); 431 | 432 | Object.defineProperty(window, "scrollX", { value: -117, writable: true }); 433 | 434 | instance.handleDragEnd({ 435 | source: { droppableId: "0", index: 0 }, 436 | destination: { droppableId: "1", index: 0 } 437 | }); 438 | 439 | instance.handleSwipeLeft(); 440 | 441 | return expect( 442 | subject, 443 | "queried for first", 444 | ".Board", 445 | "to have attributes", 446 | { 447 | style: { 448 | left: "-320px", 449 | transition: "left 0.3s cubic-bezier(0.075, 0.82, 0.165, 1)" 450 | } 451 | } 452 | ); 453 | }); 454 | }); 455 | }); 456 | -------------------------------------------------------------------------------- /src/components/Column/Column.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import { createTaskInColumn } from "../../store/actions"; 5 | 6 | import "./Column.scss"; 7 | 8 | // TODO: Use selector instead 9 | const FIRST_COLUMN_NAME = "To do"; 10 | 11 | export class Column extends Component { 12 | constructor(props) { 13 | super(props); 14 | 15 | this.el = null; 16 | this.handleCreateTask = this.handleCreateTask.bind(this); 17 | } 18 | 19 | handleCreateTask() { 20 | this.el.scrollTop = 0; 21 | this.props.createTaskInColumn(); 22 | } 23 | 24 | render() { 25 | const { id, name, count, children, innerRef, droppableProps } = this.props; 26 | const isFirstColumn = name === FIRST_COLUMN_NAME; 27 | 28 | return ( 29 |
30 |
31 |

32 | {name} ({count}) 33 |

34 | {isFirstColumn && ( 35 | 36 | )} 37 |
38 |
{ 41 | this.el = el; 42 | innerRef(el); 43 | }} 44 | {...droppableProps} 45 | > 46 | {children} 47 |
48 |
49 | ); 50 | } 51 | } 52 | 53 | Column.propTypes = { 54 | id: PropTypes.string.isRequired, 55 | name: PropTypes.string.isRequired, 56 | count: PropTypes.number.isRequired, 57 | droppableProps: PropTypes.object.isRequired, 58 | innerRef: PropTypes.func.isRequired, 59 | createTaskInColumn: PropTypes.func.isRequired, 60 | children: PropTypes.node 61 | }; 62 | 63 | export default connect(null, { 64 | createTaskInColumn 65 | })(Column); 66 | -------------------------------------------------------------------------------- /src/components/Column/Column.scss: -------------------------------------------------------------------------------- 1 | .Column { 2 | $surface-color: hsl(0, 0%, 7%); 3 | $elevation-12-color: hsl(0, 0%, 19%); // 12% elevation 4 | // TODO: use filter: saturate(-400%); for working 5 | // with accent colors 6 | 7 | flex-grow: 1; 8 | flex-basis: 0; 9 | flex-shrink: 0; 10 | box-sizing: border-box; 11 | padding: 0 2px; 12 | 13 | &-body { 14 | // TODO: find a better way to determine height (flex?) 15 | height: calc(100% - 48px); 16 | overflow-y: auto; 17 | box-sizing: border-box; 18 | } 19 | 20 | header { 21 | position: relative; 22 | display: flex; 23 | align-items: center; 24 | justify-content: space-between; 25 | height: 36px; 26 | padding: 5px 10px; 27 | background-clip: padding-box; 28 | border-bottom: 2px solid transparent; 29 | background-color: $elevation-12-color; 30 | 31 | h2 { 32 | display: inline-block; 33 | font-family: AvenirNextDemi; 34 | font-size: 15px; 35 | font-weight: normal; 36 | text-transform: uppercase; 37 | color: #fff; 38 | } 39 | 40 | .Column-plus { 41 | position: absolute; 42 | top: 0; 43 | right: 10px; 44 | display: inline-block; 45 | width: 50px; 46 | height: 50px; 47 | background: url("../../assets/plus.svg"); 48 | background-repeat: no-repeat; 49 | background-size: 36px 36px; 50 | background-position: 50% 50%; 51 | background-color: #baffb8; // 200 of the Primary color #99ff99 52 | border-radius: 50%; 53 | cursor: pointer; 54 | z-index: 1; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Column/Column.test.js: -------------------------------------------------------------------------------- 1 | import expect, { 2 | simulate, 3 | getInstance 4 | } from "../../testUtils/unexpected-react"; 5 | import React from "react"; 6 | import sinon from "sinon"; 7 | 8 | import { Column } from "./Column"; 9 | 10 | let props; 11 | 12 | describe("Column", () => { 13 | beforeEach(() => { 14 | props = { 15 | id: "1", 16 | name: "Doing", 17 | count: 0, 18 | droppableProps: {}, 19 | innerRef: sinon.stub().named("innerRef"), 20 | createTaskInColumn: sinon.stub().named("createTaskInColumn"), 21 | children: null 22 | }; 23 | }); 24 | 25 | it("should render default", () => { 26 | return expect( 27 | , 28 | "when mounted", 29 | "to exhaustively satisfy", 30 |
31 |
32 |

33 | {"Doing"} ({"0"}) 34 |

35 |
36 |
37 |
38 | ); 39 | }); 40 | 41 | it("should render first column", () => { 42 | props.name = "To do"; 43 | 44 | return expect( 45 | , 46 | "when mounted", 47 | "to contain elements matching", 48 | "[class=Column-plus]" 49 | ); 50 | }); 51 | 52 | it("should render with additional styling from droppableProps", () => { 53 | props.droppableProps = { 54 | style: { width: "100px" } 55 | }; 56 | 57 | return expect( 58 | , 59 | "when mounted", 60 | "queried for first", 61 | ".Column-body", 62 | "to have attributes", 63 | { 64 | style: "width: 100px" 65 | } 66 | ); 67 | }); 68 | 69 | it("should render with children", () => { 70 | props.children =
I am a task
; 71 | 72 | return expect( 73 | , 74 | "when mounted", 75 | "queried for first", 76 | ".Column-body", 77 | "to contain", 78 | "
I am a task
" 79 | ); 80 | }); 81 | 82 | it("should create task in column", () => { 83 | props.name = "To do"; 84 | const { subject, instance } = getInstance(); 85 | 86 | simulate(subject, { 87 | type: "touchEnd", 88 | target: ".Column-plus" 89 | }); 90 | return expect(props.createTaskInColumn, "was called").then(() => 91 | expect(instance.el.scrollTop, "to be", 0) 92 | ); 93 | }); 94 | 95 | it("should set the ref", () => { 96 | getInstance(); 97 | 98 | return expect(props.innerRef, "was called"); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./Loader.scss"; 3 | 4 | const Loader = () =>
Loading...
; 5 | 6 | export default Loader; 7 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.scss: -------------------------------------------------------------------------------- 1 | .Loader { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 100%; 6 | font-size: 20px; 7 | color: #fff; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../testUtils/unexpected-react"; 2 | import React from "react"; 3 | 4 | import Loader from "./Loader"; 5 | 6 | describe("Loader", () => { 7 | it("should render default", () => { 8 | return expect( 9 | , 10 | "when mounted", 11 | "to exhaustively satisfy", 12 |
Loading...
13 | ); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Task/Task.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import classNames from "classnames"; 5 | import debounce from "lodash/debounce"; 6 | 7 | import { 8 | updateTask, 9 | deleteTaskAndUpdateColumn, 10 | completeTask 11 | } from "../../store/actions"; 12 | 13 | import Swipeable from "swipeable-react"; 14 | 15 | import "./Task.scss"; 16 | 17 | const TRANSITION_DURATION = 600; 18 | 19 | export class Task extends Component { 20 | constructor(props) { 21 | super(props); 22 | 23 | this.el = null; 24 | this.state = { 25 | // TODO: may need to cover the case when props.text 26 | // changes and need to update the state 27 | text: props.text, 28 | isOptionsMenuShown: false, 29 | forDeletion: false, 30 | forCompletion: false 31 | }; 32 | 33 | this.handleTap = this.handleTap.bind(this); 34 | this.handleTextChange = this.handleTextChange.bind(this); 35 | this.handleDelete = this.handleDelete.bind(this); 36 | this.handleComplete = this.handleComplete.bind(this); 37 | this.handleSwipeLeft = this.handleSwipeLeft.bind(this); 38 | this.handleSwipeRight = this.handleSwipeRight.bind(this); 39 | this.updateTask = debounce(props.updateTask, 500); 40 | this.completeTask = debounce(props.completeTask, TRANSITION_DURATION); 41 | this.deleteTaskAndUpdateColumn = debounce(props.deleteTaskAndUpdateColumn, TRANSITION_DURATION); 42 | } 43 | 44 | shouldComponentUpdate({ isDragging }, { isOptionsMenuShown }) { 45 | if ( 46 | isOptionsMenuShown !== this.state.isOptionsMenuShown || 47 | isDragging !== this.props.isDragging 48 | ) { 49 | return true; 50 | } 51 | return false; 52 | } 53 | 54 | handleSwipeLeft() { 55 | this.setState({ isOptionsMenuShown: true }); 56 | } 57 | 58 | handleSwipeRight() { 59 | this.setState({ isOptionsMenuShown: false }); 60 | } 61 | 62 | handleTap() { 63 | if (!this.props.isDragging && this.el) { 64 | this.el.focus(); 65 | // Move cursor to the end 66 | document.execCommand("selectAll", false, null); 67 | document.getSelection().collapseToEnd(); 68 | } 69 | } 70 | 71 | handleTextChange(e) { 72 | const text = e.target.innerText; 73 | 74 | this.setState({ text }, () => { 75 | this.updateTask(this.props.id, { text }); 76 | }); 77 | } 78 | 79 | handleDelete() { 80 | this.deleteTaskAndUpdateColumn(this.props.id); 81 | this.setState({ forDeletion: true }); 82 | this.handleSwipeRight(); 83 | } 84 | 85 | handleComplete() { 86 | this.completeTask(this.props.id); 87 | this.setState({ forCompletion: true }); 88 | this.handleSwipeRight(); 89 | } 90 | 91 | render() { 92 | const { id, isDragging } = this.props; 93 | const { text, isOptionsMenuShown, forDeletion, forCompletion } = this.state; 94 | 95 | return ( 96 |
106 | 111 | {innerRef => ( 112 |
{ 115 | this.el = el; 116 | innerRef(el); 117 | }} 118 | contentEditable 119 | onInput={this.handleTextChange} 120 | onTouchEnd={this.handleTap} 121 | suppressContentEditableWarning 122 | > 123 | {text} 124 |
125 | )} 126 |
127 |
128 | 129 | complete 130 | 131 | 132 | delete 133 | 134 |
135 |
136 | ); 137 | } 138 | } 139 | 140 | Task.propTypes = { 141 | id: PropTypes.string.isRequired, 142 | text: PropTypes.string, 143 | isDragging: PropTypes.bool.isRequired, 144 | completeTask: PropTypes.func.isRequired, 145 | updateTask: PropTypes.func.isRequired, 146 | deleteTaskAndUpdateColumn: PropTypes.func.isRequired 147 | }; 148 | 149 | export default connect(null, { 150 | updateTask, 151 | completeTask, 152 | deleteTaskAndUpdateColumn 153 | })(Task); 154 | -------------------------------------------------------------------------------- /src/components/Task/Task.scss: -------------------------------------------------------------------------------- 1 | .Task { 2 | // The surface color is #121212 or hsl(0, 0%, 7%) 3 | // 5% elevation on top of that is added 4 | $surface-color: hsl(0, 0%, 7%); 5 | $elevation-5-color: hsl(0, 0%, 12%); 6 | $elevation-11-color: hsl(0, 0%, 18%); 7 | 8 | display: flex; 9 | position: relative; 10 | height: 70px; 11 | background-color: $elevation-5-color; 12 | border-bottom: 2px solid transparent; 13 | background-clip: padding-box; 14 | // transition-duration is set via javascript 15 | transition-property: height, background-color, border-bottom-width; 16 | transition-timing-function: cubic-bezier(0.19, 1, 0.22, 1); 17 | overflow: hidden; 18 | 19 | &-text { 20 | width: 100%; 21 | margin: 15px 15px 7px 20px; 22 | font-size: 16px; 23 | line-height: 24px; 24 | color: #fff; 25 | outline: none; 26 | // iOS Safari fix to make overflow: hidden 27 | // work with transform 28 | clip-path: content-box; 29 | 30 | transform: translateX(0); 31 | transition: transform 0.2s ease-in; 32 | user-select: text; 33 | overflow: hidden; 34 | } 35 | 36 | &--dragging { 37 | background-color: $elevation-11-color; 38 | user-select: none; 39 | } 40 | 41 | &--delete, 42 | &--complete { 43 | height: 0px; 44 | border-bottom-width: 0; 45 | } 46 | 47 | &--delete { 48 | background-color: #cf6679; 49 | } 50 | 51 | &--complete { 52 | background-color: #baffb8; 53 | } 54 | 55 | &-options { 56 | position: absolute; 57 | top: 0; 58 | left: 0; 59 | display: flex; 60 | height: 100%; 61 | width: 100%; 62 | transform: translateX(100%); 63 | transition: transform 0.2s ease-in; 64 | 65 | &-delete, 66 | &-complete { 67 | display: inline-flex; 68 | justify-content: center; 69 | align-items: center; 70 | width: 80px; 71 | height: 100%; 72 | padding-top: 37px; 73 | font-family: AvenirNextDemi; 74 | font-size: 11px; 75 | text-transform: uppercase; 76 | color: $surface-color; 77 | background-repeat: no-repeat; 78 | background-position: center 30%; 79 | background-size: 22px 22px; 80 | box-sizing: border-box; 81 | cursor: pointer; 82 | } 83 | 84 | &-delete { 85 | background-color: #cf6679; 86 | background-image: url("../../assets/bin.svg"); 87 | } 88 | 89 | &-complete { 90 | background-position: center 24%; 91 | background-size: 35px 35px; 92 | background-color: #baffb8; // 200 of the Primary color #99ff99 93 | background-image: url("../../assets/checkmark.svg"); 94 | } 95 | } 96 | 97 | &--isOptionsMenuShown { 98 | .Task-options { 99 | transform: translateX(calc(100% - 160px)); 100 | transition: transform 0.2s ease-out; 101 | } 102 | 103 | .Task-text { 104 | transform: translateX(-160px); 105 | transition: transform 0.2s ease-out; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/components/Task/Task.test.js: -------------------------------------------------------------------------------- 1 | import expect, { 2 | simulate, 3 | getInstance, 4 | PropUpdater 5 | } from "../../testUtils/unexpected-react"; 6 | import React from "react"; 7 | import sinon from "sinon"; 8 | 9 | import { Task } from "./Task"; 10 | 11 | // Fake reseting cursor position related document methods 12 | document.execCommand = null; 13 | document.getSelection = null; 14 | let collapseToEnd; 15 | 16 | let props; 17 | 18 | describe("Task", () => { 19 | beforeEach(() => { 20 | props = { 21 | id: "1", 22 | text: "", 23 | isDragging: false, 24 | completeTask: sinon.stub().named("completeTask"), 25 | updateTask: sinon.stub().named("updateTask"), 26 | deleteTaskAndUpdateColumn: sinon.stub().named("deleteTaskAndUpdateColumn") 27 | }; 28 | 29 | // Fake reseting cursor position related document methods 30 | document.execCommand = sinon.stub().named("execCommand"); 31 | collapseToEnd = sinon.stub().named("collapseToEnd"); 32 | document.getSelection = function() { 33 | this.collapseToEnd = collapseToEnd; 34 | return this; 35 | }; 36 | }); 37 | 38 | it("should render default", () => { 39 | return expect( 40 | , 41 | "when mounted", 42 | "to exhaustively satisfy", 43 |
44 |
45 |
46 | complete 47 | delete 48 |
49 |
50 | ); 51 | }); 52 | 53 | it("should render with text", () => { 54 | props.text = "Visit parents"; 55 | 56 | return expect( 57 | , 58 | "when mounted", 59 | "queried for first", 60 | ".Task-text", 61 | "to have text", 62 | "Visit parents" 63 | ); 64 | }); 65 | 66 | it("should render in dragging mode", () => { 67 | props.isDragging = true; 68 | 69 | return expect( 70 | , 71 | "when mounted", 72 | "to have class", 73 | "Task--dragging" 74 | ); 75 | }); 76 | 77 | it("should place the cursor at the text end when focusing", () => { 78 | props.text = "Visit parents"; 79 | 80 | const { subject } = getInstance(); 81 | 82 | simulate(subject, { 83 | type: "touchEnd", 84 | target: ".Task-text" 85 | }); 86 | 87 | return expect( 88 | document.execCommand, 89 | "to have a call exhaustively satisfying", 90 | ["selectAll", false, null] 91 | ).then(() => expect(collapseToEnd, "was called")); 92 | }); 93 | 94 | it("should NOT focus while dragging", () => { 95 | props.text = "Visit parents"; 96 | props.isDragging = true; 97 | 98 | const { subject } = getInstance(); 99 | 100 | simulate(subject, { 101 | type: "touchEnd", 102 | target: ".Task-text" 103 | }); 104 | 105 | return expect(document.execCommand, "was not called").then(() => 106 | expect(collapseToEnd, "was not called") 107 | ); 108 | }); 109 | 110 | it("should edit task's text", () => { 111 | props.text = "Visit parents"; 112 | const clock = sinon.useFakeTimers(); 113 | 114 | const updatedProps = { 115 | ...props, 116 | text: "Visit my father", 117 | // Need key update to force rerender of the component 118 | // shouldComponentUpdate does not currently react on text change 119 | key: 1 120 | }; 121 | 122 | const { applyPropsUpdate, subject } = getInstance( 123 | 124 | 125 | 126 | ); 127 | 128 | simulate(subject, { 129 | type: "input", 130 | target: ".Task-text", 131 | data: { 132 | target: { 133 | innerText: "Visit my father" 134 | } 135 | } 136 | }); 137 | 138 | applyPropsUpdate(); 139 | 140 | return expect( 141 | subject, 142 | "queried for first", 143 | ".Task-text", 144 | "to have text", 145 | "Visit my father" 146 | ).then(() => { 147 | // Handling debaunce 148 | clock.tick(500); 149 | 150 | return expect( 151 | props.updateTask, 152 | "to have a call exhaustively satisfying", 153 | ["1", { text: "Visit my father" }] 154 | ); 155 | }); 156 | }); 157 | 158 | it("should open options menu", () => { 159 | const { subject, instance } = getInstance(); 160 | 161 | // TODO: find a way to do that with simulate() 162 | instance.handleSwipeLeft(); 163 | 164 | return expect(subject, "to have class", "Task--isOptionsMenuShown"); 165 | }); 166 | 167 | it("should close options menu", () => { 168 | const { subject, instance } = getInstance(); 169 | 170 | // TODO: find a way to do that with simulate() 171 | instance.handleSwipeLeft(); 172 | instance.handleSwipeRight(); 173 | 174 | return expect(subject, "to satisfy", { 175 | attributes: { 176 | class: expect.it("not to contain", "Task--isOptionsMenuShown") 177 | } 178 | }); 179 | }); 180 | 181 | it("should delete a task", () => { 182 | const { subject, instance } = getInstance(); 183 | const clock = sinon.useFakeTimers(); 184 | 185 | // TODO: find a way to do that with simulate() 186 | instance.handleSwipeLeft(); 187 | 188 | simulate(subject, { 189 | type: "click", 190 | target: ".Task-options-delete" 191 | }); 192 | 193 | // Transition delay 194 | clock.tick(600); 195 | 196 | return expect( 197 | props.deleteTaskAndUpdateColumn, 198 | "to have a call exhaustively satisfying", 199 | ["1"] 200 | ).then(() => 201 | expect(subject, "to satisfy", { 202 | attributes: { 203 | class: "Task Task--delete" 204 | } 205 | }) 206 | ); 207 | }); 208 | 209 | it("should complete a task", () => { 210 | const { subject, instance } = getInstance(); 211 | const clock = sinon.useFakeTimers(); 212 | 213 | // TODO: find a way to do that with simulate() 214 | instance.handleSwipeLeft(); 215 | 216 | simulate(subject, { 217 | type: "click", 218 | target: ".Task-options-complete" 219 | }); 220 | 221 | // Transition delay 222 | clock.tick(600); 223 | 224 | return expect( 225 | props.completeTask, 226 | "to have a call exhaustively satisfying", 227 | ["1"] 228 | ).then(() => 229 | expect(subject, "to satisfy", { 230 | attributes: { 231 | class: "Task Task--complete" 232 | } 233 | }) 234 | ); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Provider } from "react-redux"; 4 | import store from "./store"; 5 | import { bootstrap } from "./store/actions"; 6 | import App from "./App"; 7 | 8 | import "./index.scss"; 9 | 10 | window.flow_task = store; 11 | 12 | store.dispatch(bootstrap()).catch(() => { 13 | // ignore 14 | }); 15 | 16 | ReactDOM.render( 17 | 18 | 19 | , 20 | document.getElementById("root") 21 | ); 22 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: AvenirNextRegular; 3 | src: url("./assets/fonts/AvenirNext/AvenirNextRegular.otf") format("opentype"); 4 | } 5 | 6 | @font-face { 7 | font-family: AvenirNextBold; 8 | src: url("./assets/fonts/AvenirNext/AvenirNextBold.otf") format("opentype"); 9 | } 10 | 11 | @font-face { 12 | font-family: AvenirNextDemi; 13 | src: url("./assets/fonts/AvenirNext/AvenirNextDemi.otf") format("opentype"); 14 | } 15 | 16 | body { 17 | margin: 0; 18 | padding: 0; 19 | font-family: AvenirNextRegular, sans-serif; 20 | user-select: none; 21 | } 22 | 23 | html, body { 24 | height: 100%; 25 | } 26 | 27 | #root { 28 | $surface-color: hsl(0, 0%, 7%); 29 | height: 100%; 30 | width: 100%; 31 | // The surface color is #121212 or hsl(0, 0%, 7%) 32 | background-color: $surface-color; 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/Api.js: -------------------------------------------------------------------------------- 1 | const appName = "FlowTask"; 2 | const columns = { 3 | kl6w18uqrli: { 4 | id: "kl6w18uqrli", 5 | name: "To do", 6 | tasks: [ 7 | "zy1bx7fyfrb", 8 | "xmtia6ohms0", 9 | "rzg2osmn5ba", 10 | "y47dprqg32y", 11 | "mb8suubnax1", 12 | "cdc454qe5fd", 13 | "q29oswkdvzk" 14 | ] 15 | }, 16 | emrjor03vl9: { 17 | id: "emrjor03vl9", 18 | name: "Doing", 19 | tasks: ["jxkvc7ysxva", "zkvdswly9d8"] 20 | }, 21 | selp4hn9uoj: { 22 | id: "selp4hn9uoj", 23 | name: "Done", 24 | tasks: ["qxkream0i7o", "m1t8j2bf3q7", "k9s6xw4wprt"] 25 | } 26 | }; 27 | 28 | const tasks = { 29 | zy1bx7fyfrb: { 30 | id: "zy1bx7fyfrb", 31 | text: "Buy some swedish cakes", 32 | lastModifiedDate: 1582900781820 33 | }, 34 | xmtia6ohms0: { 35 | id: "xmtia6ohms0", 36 | text: "Meeting with Steven about his new business idea", 37 | lastModifiedDate: 1582900781821 38 | }, 39 | rzg2osmn5ba: { 40 | id: "rzg2osmn5ba", 41 | text: "Rewrite Swipeable plugin with React Hooks", 42 | lastModifiedDate: 1582900781822 43 | }, 44 | y47dprqg32y: { 45 | id: "y47dprqg32y", 46 | text: "Try this new pancackes recepy that everyone is talking about", 47 | lastModifiedDate: 1582900781823 48 | }, 49 | mb8suubnax1: { 50 | id: "mb8suubnax1", 51 | text: "Doctor appointment at 8:30", 52 | lastModifiedDate: 15829007818324 53 | }, 54 | cdc454qe5fd: { 55 | id: "cdc454qe5fd", 56 | text: "Check how is your old friend Bob doing", 57 | lastModifiedDate: 1582900781825 58 | }, 59 | q29oswkdvzk: { 60 | id: "q29oswkdvzk", 61 | text: "Go to the bank and create a new $$ account with Mastercard", 62 | lastModifiedDate: 1582900781826 63 | }, 64 | jxkvc7ysxva: { 65 | id: "jxkvc7ysxva", 66 | text: "Look for a good iPhone offer online", 67 | lastModifiedDate: 1582900781827 68 | }, 69 | zkvdswly9d8: { 70 | id: "zkvdswly9d8", 71 | text: "Throw away this old printer that served well, though", 72 | lastModifiedDate: 1582900781828 73 | }, 74 | qxkream0i7o: { 75 | id: "qxkream0i7o", 76 | text: "Buy new shoes", 77 | lastModifiedDate: 1582900781829 78 | }, 79 | m1t8j2bf3q7: { 80 | id: "m1t8j2bf3q7", 81 | text: "Prepare for the PMP exam next week", 82 | lastModifiedDate: 1582900781830 83 | }, 84 | k9s6xw4wprt: { 85 | id: "k9s6xw4wprt", 86 | text: "Be at home on Thursday when the delivery from Amazon is expected", 87 | lastModifiedDate: 1582900781831 88 | } 89 | }; 90 | 91 | const setDefaultDataIfNotPresent = () => { 92 | if (!window.localStorage.getItem(appName)) { 93 | window.localStorage.setItem(appName, JSON.stringify({ columns, tasks })); 94 | } 95 | }; 96 | setDefaultDataIfNotPresent(); 97 | 98 | const getColumnsData = () => { 99 | setDefaultDataIfNotPresent(); 100 | return JSON.parse(window.localStorage.getItem(appName)).columns; 101 | }; 102 | const setColumnsData = columns => { 103 | const data = JSON.parse(window.localStorage.getItem(appName)); 104 | data.columns = columns; 105 | window.localStorage.setItem(appName, JSON.stringify(data)); 106 | }; 107 | 108 | const getTasksData = () => { 109 | setDefaultDataIfNotPresent(); 110 | return JSON.parse(window.localStorage.getItem(appName)).tasks; 111 | }; 112 | const setTasksData = tasks => { 113 | const data = JSON.parse(window.localStorage.getItem(appName)); 114 | data.tasks = tasks; 115 | window.localStorage.setItem(appName, JSON.stringify(data)); 116 | }; 117 | 118 | export default { 119 | loadColumns: () => { 120 | return Promise.resolve(getColumnsData()); 121 | }, 122 | loadTasks: () => { 123 | return Promise.resolve(getTasksData()); 124 | }, 125 | deleteTask: id => { 126 | const tasks = getTasksData(); 127 | delete tasks[id]; 128 | setTasksData(tasks); 129 | 130 | return Promise.resolve(tasks); 131 | }, 132 | updateTask: (id, data) => { 133 | const tasks = getTasksData(); 134 | 135 | if (tasks[id]) { 136 | tasks[id] = data; 137 | } 138 | setTasksData(tasks); 139 | 140 | return Promise.resolve(tasks); 141 | }, 142 | createTask: data => { 143 | const tasks = getTasksData(); 144 | if (data.id) { 145 | tasks[data.id] = data; 146 | setTasksData(tasks); 147 | } 148 | 149 | return Promise.resolve(tasks[data.id]); 150 | }, 151 | updateColumn: (id, data) => { 152 | const columns = getColumnsData(); 153 | 154 | if (columns[id]) { 155 | columns[id] = data; 156 | } 157 | setColumnsData(columns); 158 | 159 | return Promise.resolve(columns); 160 | } 161 | }; 162 | -------------------------------------------------------------------------------- /src/lib/Api.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../testUtils/unexpected-react"; 2 | 3 | import Api from "./Api"; 4 | 5 | describe("Api", () => { 6 | const api = Api; 7 | 8 | it("should load all columns", async () => { 9 | const payload = await api.loadColumns(); 10 | 11 | // Default columns data in Api.js 12 | expect(payload, "to exhaustively satisfy", { 13 | kl6w18uqrli: { 14 | id: "kl6w18uqrli", 15 | name: "To do", 16 | tasks: [ 17 | "zy1bx7fyfrb", 18 | "xmtia6ohms0", 19 | "rzg2osmn5ba", 20 | "y47dprqg32y", 21 | "mb8suubnax1", 22 | "cdc454qe5fd", 23 | "q29oswkdvzk" 24 | ] 25 | }, 26 | emrjor03vl9: { 27 | id: "emrjor03vl9", 28 | name: "Doing", 29 | tasks: ["jxkvc7ysxva", "zkvdswly9d8"] 30 | }, 31 | selp4hn9uoj: { 32 | id: "selp4hn9uoj", 33 | name: "Done", 34 | tasks: ["qxkream0i7o", "m1t8j2bf3q7", "k9s6xw4wprt"] 35 | } 36 | }); 37 | }); 38 | 39 | it("should load all tasks", async () => { 40 | const payload = await api.loadTasks(); 41 | 42 | // Default tasks data in Api.js 43 | expect(payload, "to exhaustively satisfy", { 44 | zy1bx7fyfrb: { 45 | id: "zy1bx7fyfrb", 46 | text: "Buy some swedish cakes", 47 | lastModifiedDate: 1582900781820 48 | }, 49 | xmtia6ohms0: { 50 | id: "xmtia6ohms0", 51 | text: "Meeting with Steven about his new business idea", 52 | lastModifiedDate: 1582900781821 53 | }, 54 | rzg2osmn5ba: { 55 | id: "rzg2osmn5ba", 56 | text: "Rewrite Swipeable plugin with React Hooks", 57 | lastModifiedDate: 1582900781822 58 | }, 59 | y47dprqg32y: { 60 | id: "y47dprqg32y", 61 | text: "Try this new pancackes recepy that everyone is talking about", 62 | lastModifiedDate: 1582900781823 63 | }, 64 | mb8suubnax1: { 65 | id: "mb8suubnax1", 66 | text: "Doctor appointment at 8:30", 67 | lastModifiedDate: 15829007818324 68 | }, 69 | cdc454qe5fd: { 70 | id: "cdc454qe5fd", 71 | text: "Check how is your old friend Bob doing", 72 | lastModifiedDate: 1582900781825 73 | }, 74 | q29oswkdvzk: { 75 | id: "q29oswkdvzk", 76 | text: "Go to the bank and create a new $$ account with Mastercard", 77 | lastModifiedDate: 1582900781826 78 | }, 79 | jxkvc7ysxva: { 80 | id: "jxkvc7ysxva", 81 | text: "Look for a good iPhone offer online", 82 | lastModifiedDate: 1582900781827 83 | }, 84 | zkvdswly9d8: { 85 | id: "zkvdswly9d8", 86 | text: "Throw away this old printer that served well, though", 87 | lastModifiedDate: 1582900781828 88 | }, 89 | qxkream0i7o: { 90 | id: "qxkream0i7o", 91 | text: "Buy new shoes", 92 | lastModifiedDate: 1582900781829 93 | }, 94 | m1t8j2bf3q7: { 95 | id: "m1t8j2bf3q7", 96 | text: "Prepare for the PMP exam next week", 97 | lastModifiedDate: 1582900781830 98 | }, 99 | k9s6xw4wprt: { 100 | id: "k9s6xw4wprt", 101 | text: 102 | "Be at home on Thursday when the delivery from Amazon is expected", 103 | lastModifiedDate: 1582900781831 104 | } 105 | }); 106 | }); 107 | 108 | it("should delete a task", async () => { 109 | const payload = await api.deleteTask("zy1bx7fyfrb"); 110 | 111 | expect(payload, "to not have key", "zy1bx7fyfrb"); 112 | }); 113 | 114 | it("should update a task", async () => { 115 | // Initial task before the update: 116 | // "xmtia6ohms0": { 117 | // id: "xmtia6ohms0" 118 | // text: "Meeting with Steven about his new business idea", 119 | // lastModifiedDate: 1582900781821 120 | // } 121 | const payload = await api.updateTask("xmtia6ohms0", { 122 | id: "xmtia6ohms0", 123 | text: "Task with new text", 124 | lastModifiedDate: 1582900781999 125 | }); 126 | 127 | expect(payload, "to have a value exhaustively satisfying", { 128 | id: "xmtia6ohms0", 129 | text: "Task with new text", 130 | lastModifiedDate: 1582900781999 131 | }); 132 | }); 133 | 134 | it("should create a task", async () => { 135 | const payload = await api.createTask({ 136 | id: "2", 137 | text: "This is a new task", 138 | lastModifiedDate: 1582900781444 139 | }); 140 | 141 | expect(payload, "to exhaustively satisfy", { 142 | id: "2", 143 | text: "This is a new task", 144 | lastModifiedDate: 1582900781444 145 | }); 146 | }); 147 | 148 | it("should update a column", async () => { 149 | const payload = await api.updateColumn("kl6w18uqrli", { 150 | id: "kl6w18uqrli", 151 | name: "To do", 152 | tasks: ["zy1bx7fyfrb"] 153 | }); 154 | 155 | expect(payload, "to have a value exhaustively satisfying", { 156 | id: "kl6w18uqrli", 157 | name: "To do", 158 | tasks: ["zy1bx7fyfrb"] 159 | }); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | export { 2 | loadTasks, 3 | updateTask, 4 | deleteTaskAndUpdateColumn, 5 | createTask 6 | } from "./tasks"; 7 | export { updateColumn, loadColumns } from "./columns"; 8 | export { completeTask } from "./actions/completeTask"; 9 | export { createTaskInColumn } from "./actions/createTaskInColumn"; 10 | export { moveTask } from "./actions/moveTask"; 11 | export { bootstrap } from "./actions/bootstrap"; 12 | -------------------------------------------------------------------------------- /src/store/actions/bootstrap.js: -------------------------------------------------------------------------------- 1 | import { loadColumns, loadTasks } from "../actions"; 2 | 3 | export const createBootstrapAction = ({ loadColumns, loadTasks }) => () => { 4 | return (dispatch, getState, api) => { 5 | // This set up assumes having more actions in the future here 6 | return Promise.all([dispatch(loadColumns()), dispatch(loadTasks())]); 7 | }; 8 | }; 9 | 10 | export const bootstrap = createBootstrapAction({ 11 | loadColumns, 12 | loadTasks 13 | }); 14 | -------------------------------------------------------------------------------- /src/store/actions/bootstrap.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { createBootstrapAction } from "./bootstrap"; 5 | 6 | describe("bootstrap", () => { 7 | let dispatch; 8 | let bootstrap; 9 | let loadColumnsStub; 10 | let loadTasksStub; 11 | 12 | beforeEach(() => { 13 | dispatch = sinon 14 | .stub() 15 | .named("dispatch") 16 | .resolves(); 17 | 18 | loadColumnsStub = sinon 19 | .stub() 20 | .named("loadColumns") 21 | .returns({ type: "@columns/LOAD_COLUMNS_REQUEST" }); 22 | 23 | loadTasksStub = sinon 24 | .stub() 25 | .named("loadTasks") 26 | .returns({ type: "@tasks/LOAD_TASKS_REQUEST" }); 27 | 28 | bootstrap = createBootstrapAction({ 29 | loadColumns: loadColumnsStub, 30 | loadTasks: loadTasksStub 31 | }); 32 | }); 33 | 34 | it("should return a Promise from the thunk", () => { 35 | return expect(bootstrap()(dispatch), "to be a", Promise); 36 | }); 37 | 38 | it("should dispatch loadColumns, loadTasks in sequence", () => { 39 | bootstrap()(dispatch); 40 | 41 | return expect(dispatch, "to have calls satisfying", [ 42 | [loadColumnsStub()], 43 | [loadTasksStub()] 44 | ]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/store/actions/completeTask.js: -------------------------------------------------------------------------------- 1 | import { updateColumn } from "../actions"; 2 | import { getColumnByTaskId, getDoneColumn } from "../selectors"; 3 | 4 | export const createCompleteTaskAction = updateColumnAction => taskId => ( 5 | dispatch, 6 | getState, 7 | api 8 | ) => { 9 | const taskColumn = getColumnByTaskId(getState(), taskId); 10 | const doneColumn = getDoneColumn(getState()); 11 | 12 | if (taskColumn && doneColumn) { 13 | taskColumn.tasks.splice(taskColumn.tasks.indexOf(taskId), 1); 14 | doneColumn.tasks.push(taskId); 15 | 16 | return dispatch(updateColumnAction(taskColumn.id, taskColumn)).then(() => 17 | dispatch(updateColumnAction(doneColumn.id, doneColumn)) 18 | ); 19 | } 20 | return false; 21 | }; 22 | 23 | export const completeTask = createCompleteTaskAction(updateColumn); 24 | -------------------------------------------------------------------------------- /src/store/actions/completeTask.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { createCompleteTaskAction } from "./completeTask"; 5 | 6 | describe("completeTask", () => { 7 | let dispatch, getState; 8 | 9 | beforeEach(() => { 10 | dispatch = sinon 11 | .stub() 12 | .named("dispatch") 13 | // Emulate thunk 14 | .callsFake(v => { 15 | return typeof v === "function" ? v(dispatch, getState) : v; 16 | }); 17 | getState = sinon.stub().named("getState"); 18 | }); 19 | 20 | it("should not update parent column", async () => { 21 | getState.returns({ 22 | columns: {}, 23 | tasks: {} 24 | }); 25 | 26 | const mockUpdateColumnAction = () => Promise.resolve(true); 27 | const deleteTask = createCompleteTaskAction(mockUpdateColumnAction); 28 | const wasSuccessful = await deleteTask("TaskId")(dispatch, getState); 29 | 30 | return expect(wasSuccessful, "to be false"); 31 | }); 32 | 33 | it("should remove task from its parent column", async () => { 34 | getState.returns({ 35 | columns: { 36 | "1": { 37 | id: "1", 38 | name: "To Do", 39 | tasks: ["TaskId"] 40 | }, 41 | "2": { 42 | id: "2", 43 | name: "Doing", 44 | tasks: [] 45 | }, 46 | "3": { 47 | id: "3", 48 | name: "Done", 49 | tasks: [] 50 | } 51 | }, 52 | tasks: { 53 | TaskId: { 54 | id: "TaskId", 55 | text: "Task 1", 56 | lastModifiedData: 1234 57 | } 58 | } 59 | }); 60 | 61 | const mockUpdateColumnAction = sinon 62 | .stub() 63 | .named("mockUpdateColumnAction") 64 | .resolves(true); 65 | const deleteTask = createCompleteTaskAction(mockUpdateColumnAction); 66 | await deleteTask("TaskId")(dispatch, getState); 67 | 68 | return expect( 69 | mockUpdateColumnAction, 70 | "to have a call exhaustively satisfying", 71 | [ 72 | "1", 73 | { 74 | id: "1", 75 | name: "To Do", 76 | tasks: [] 77 | } 78 | ] 79 | ); 80 | }); 81 | 82 | it("should add task to Done column", async () => { 83 | getState.returns({ 84 | columns: { 85 | "1": { 86 | id: "1", 87 | name: "To Do", 88 | tasks: ["TaskId"] 89 | }, 90 | "2": { 91 | id: "2", 92 | name: "Doing", 93 | tasks: [] 94 | }, 95 | "3": { 96 | id: "3", 97 | name: "Done", 98 | tasks: [] 99 | } 100 | }, 101 | tasks: { 102 | TaskId: { 103 | id: "TaskId", 104 | text: "Task 1", 105 | lastModifiedData: 1234 106 | } 107 | } 108 | }); 109 | 110 | const mockUpdateColumnAction = sinon 111 | .stub() 112 | .named("mockUpdateColumnAction") 113 | .resolves(true); 114 | const deleteTask = createCompleteTaskAction(mockUpdateColumnAction); 115 | await deleteTask("TaskId")(dispatch, getState); 116 | 117 | return expect( 118 | mockUpdateColumnAction, 119 | "to have a call exhaustively satisfying", 120 | [ 121 | "3", 122 | { 123 | id: "3", 124 | name: "Done", 125 | tasks: ["TaskId"] 126 | } 127 | ] 128 | ); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/store/actions/createTaskInColumn.js: -------------------------------------------------------------------------------- 1 | import { updateColumn, createTask } from "../actions"; 2 | import { getToDoColumn } from "../selectors"; 3 | 4 | export const createCreateTaskInColumnAction = ( 5 | updateColumnAction, 6 | createTaskAction 7 | ) => () => (dispatch, getState, api) => { 8 | const todoColumn = getToDoColumn(getState()); 9 | 10 | if (todoColumn) { 11 | return dispatch(createTaskAction()).then(taskId => { 12 | todoColumn.tasks = [taskId].concat(todoColumn.tasks); 13 | return dispatch(updateColumnAction(todoColumn.id, todoColumn)); 14 | }); 15 | } 16 | return false; 17 | }; 18 | 19 | export const createTaskInColumn = createCreateTaskInColumnAction( 20 | updateColumn, 21 | createTask 22 | ); 23 | -------------------------------------------------------------------------------- /src/store/actions/createTaskInColumn.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { createCreateTaskInColumnAction } from "./createTaskInColumn"; 5 | 6 | describe("createTaskInColumn", () => { 7 | let dispatch, getState; 8 | 9 | beforeEach(() => { 10 | dispatch = sinon 11 | .stub() 12 | .named("dispatch") 13 | // Emulate thunk 14 | .callsFake(v => { 15 | return typeof v === "function" ? v(dispatch, getState) : v; 16 | }); 17 | getState = sinon.stub().named("getState"); 18 | }); 19 | 20 | it("should not create task if To Do column doesn't exist", async () => { 21 | getState.returns({ 22 | columns: {}, 23 | tasks: {} 24 | }); 25 | 26 | const mockUpdateColumnAction = () => Promise.resolve(true); 27 | const mockCreateTaskAction = () => Promise.resolve(true); 28 | const createTaskInColumn = createCreateTaskInColumnAction( 29 | mockUpdateColumnAction, 30 | mockCreateTaskAction 31 | ); 32 | const wasSuccessful = await createTaskInColumn()(dispatch, getState); 33 | 34 | return expect(wasSuccessful, "to be false"); 35 | }); 36 | 37 | it("should create a task in the To Do column", async () => { 38 | getState.returns({ 39 | columns: { 40 | "1": { 41 | id: "1", 42 | name: "To Do", 43 | tasks: [] 44 | }, 45 | "2": { 46 | id: "2", 47 | name: "Doing", 48 | tasks: [] 49 | }, 50 | "3": { 51 | id: "3", 52 | name: "Done", 53 | tasks: [] 54 | } 55 | } 56 | }); 57 | 58 | const mockCreateTaskAction = sinon 59 | .stub() 60 | .named("mockCreateTaskAction") 61 | .resolves(true); 62 | const mockUpdateColumnAction = () => Promise.resolve(true); 63 | const createTaskInColumn = createCreateTaskInColumnAction( 64 | mockUpdateColumnAction, 65 | mockCreateTaskAction 66 | ); 67 | await createTaskInColumn()(dispatch, getState); 68 | 69 | return expect(mockCreateTaskAction, "was called"); 70 | }); 71 | 72 | it("should create a task in the To Do column", async () => { 73 | getState.returns({ 74 | columns: { 75 | "1": { 76 | id: "1", 77 | name: "To Do", 78 | tasks: [] 79 | }, 80 | "2": { 81 | id: "2", 82 | name: "Doing", 83 | tasks: [] 84 | }, 85 | "3": { 86 | id: "3", 87 | name: "Done", 88 | tasks: [] 89 | } 90 | } 91 | }); 92 | 93 | const mockCreateTaskAction = () => Promise.resolve("NewTaskId"); 94 | const mockUpdateColumnAction = sinon 95 | .stub() 96 | .named("mockCreateTaskAction") 97 | .resolves(true); 98 | const createTaskInColumn = createCreateTaskInColumnAction( 99 | mockUpdateColumnAction, 100 | mockCreateTaskAction 101 | ); 102 | await createTaskInColumn()(dispatch, getState); 103 | 104 | return expect( 105 | mockUpdateColumnAction, 106 | "to have a call exhaustively satisfying", 107 | [ 108 | "1", 109 | { 110 | id: "1", 111 | name: "To Do", 112 | tasks: ["NewTaskId"] 113 | } 114 | ] 115 | ); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/store/actions/moveTask.js: -------------------------------------------------------------------------------- 1 | import { updateColumn } from "../actions"; 2 | import { getColumnByTaskId, getColumn } from "../selectors"; 3 | 4 | export const createMoveTaskAction = updateColumnAction => ( 5 | taskId, 6 | toColumnId, 7 | atIndex 8 | ) => (dispatch, getState, api) => { 9 | const fromColumn = getColumnByTaskId(getState(), taskId); 10 | const toColumn = getColumn(getState(), toColumnId); 11 | 12 | if (fromColumn && toColumn) { 13 | fromColumn.tasks.splice(fromColumn.tasks.indexOf(taskId), 1); 14 | toColumn.tasks.splice(atIndex, 0, taskId); 15 | 16 | return dispatch(updateColumnAction(fromColumn.id, fromColumn)).then(() => 17 | dispatch(updateColumnAction(toColumn.id, toColumn)) 18 | ); 19 | } 20 | return false; 21 | }; 22 | 23 | export const moveTask = createMoveTaskAction(updateColumn); 24 | -------------------------------------------------------------------------------- /src/store/actions/moveTask.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { createMoveTaskAction } from "./moveTask"; 5 | 6 | describe("moveTask", () => { 7 | let dispatch, getState; 8 | 9 | beforeEach(() => { 10 | dispatch = sinon 11 | .stub() 12 | .named("dispatch") 13 | // Emulate thunk 14 | .callsFake(v => { 15 | return typeof v === "function" ? v(dispatch, getState) : v; 16 | }); 17 | getState = sinon.stub().named("getState"); 18 | }); 19 | 20 | it("should not update parent column", async () => { 21 | getState.returns({ 22 | columns: {}, 23 | tasks: {} 24 | }); 25 | 26 | const mockUpdateColumnAction = () => Promise.resolve(true); 27 | const moveTask = createMoveTaskAction(mockUpdateColumnAction); 28 | const wasSuccessful = await moveTask("TaskId", "2", 0)(dispatch, getState); 29 | 30 | return expect(wasSuccessful, "to be false"); 31 | }); 32 | 33 | it("should remove task from its parent column", async () => { 34 | getState.returns({ 35 | columns: { 36 | "1": { 37 | id: "1", 38 | name: "To Do", 39 | tasks: ["TaskId"] 40 | }, 41 | "2": { 42 | id: "2", 43 | name: "Doing", 44 | tasks: [] 45 | }, 46 | "3": { 47 | id: "3", 48 | name: "Done", 49 | tasks: [] 50 | } 51 | }, 52 | tasks: { 53 | TaskId: { 54 | id: "TaskId", 55 | text: "Task 1", 56 | lastModifiedData: 1234 57 | } 58 | } 59 | }); 60 | 61 | const mockUpdateColumnAction = sinon 62 | .stub() 63 | .named("mockUpdateColumnAction") 64 | .resolves(true); 65 | const moveTask = createMoveTaskAction(mockUpdateColumnAction); 66 | await moveTask("TaskId", "2", 0)(dispatch, getState); 67 | 68 | return expect( 69 | mockUpdateColumnAction, 70 | "to have a call exhaustively satisfying", 71 | [ 72 | "1", 73 | { 74 | id: "1", 75 | name: "To Do", 76 | tasks: [] 77 | } 78 | ] 79 | ); 80 | }); 81 | 82 | it("should add task to the second column", async () => { 83 | getState.returns({ 84 | columns: { 85 | "1": { 86 | id: "1", 87 | name: "To Do", 88 | tasks: ["TaskId"] 89 | }, 90 | "2": { 91 | id: "2", 92 | name: "Doing", 93 | tasks: [] 94 | }, 95 | "3": { 96 | id: "3", 97 | name: "Done", 98 | tasks: [] 99 | } 100 | }, 101 | tasks: { 102 | TaskId: { 103 | id: "TaskId", 104 | text: "Task 1", 105 | lastModifiedData: 1234 106 | } 107 | } 108 | }); 109 | 110 | const mockUpdateColumnAction = sinon 111 | .stub() 112 | .named("mockUpdateColumnAction") 113 | .resolves(true); 114 | const moveTask = createMoveTaskAction(mockUpdateColumnAction); 115 | await moveTask("TaskId", "2", 0)(dispatch, getState); 116 | 117 | return expect( 118 | mockUpdateColumnAction, 119 | "to have a call exhaustively satisfying", 120 | [ 121 | "2", 122 | { 123 | id: "2", 124 | name: "Doing", 125 | tasks: ["TaskId"] 126 | } 127 | ] 128 | ); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/store/columns/actions/loadColumns.js: -------------------------------------------------------------------------------- 1 | import { createAction, createErrorAction } from "../../utils"; 2 | 3 | import { 4 | LOAD_COLUMNS_REQUEST, 5 | LOAD_COLUMNS_SUCCESS, 6 | LOAD_COLUMNS_FAILURE 7 | } from "../constants"; 8 | 9 | const loadColumnsRequest = createAction(LOAD_COLUMNS_REQUEST); 10 | const loadColumnsSuccess = createAction(LOAD_COLUMNS_SUCCESS); 11 | const loadColumnsFailure = createErrorAction(LOAD_COLUMNS_FAILURE); 12 | 13 | export const loadColumns = () => (dispatch, getState, api) => { 14 | dispatch(loadColumnsRequest()); 15 | 16 | return api.loadColumns().then( 17 | data => { 18 | dispatch(loadColumnsSuccess(data)); 19 | return true; 20 | }, 21 | error => { 22 | dispatch(loadColumnsFailure(error)); 23 | return false; 24 | } 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/store/columns/actions/loadColumns.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { 5 | LOAD_COLUMNS_REQUEST, 6 | LOAD_COLUMNS_SUCCESS, 7 | LOAD_COLUMNS_FAILURE 8 | } from "../constants"; 9 | 10 | import { loadColumns } from "./loadColumns"; 11 | 12 | describe("loadColumns", () => { 13 | let dispatch, api; 14 | 15 | beforeEach(() => { 16 | api = { 17 | loadColumns: sinon.stub().named("loadColumns") 18 | }; 19 | dispatch = sinon.stub().named("dispatch"); 20 | }); 21 | 22 | it(`should dispatch ${LOAD_COLUMNS_REQUEST}`, async () => { 23 | api.loadColumns.returns(Promise.resolve({})); 24 | await loadColumns()(dispatch, null, api); 25 | 26 | expect(dispatch, "to have a call exhaustively satisfying", [ 27 | { 28 | type: LOAD_COLUMNS_REQUEST, 29 | payload: undefined 30 | } 31 | ]); 32 | }); 33 | 34 | it(`should dispatch ${LOAD_COLUMNS_SUCCESS}`, async () => { 35 | api.loadColumns.returns( 36 | Promise.resolve({ 37 | "1": { 38 | id: "1", 39 | text: "Task 1", 40 | lastModifiedDate: 1234 41 | }, 42 | "2": { 43 | id: "2", 44 | text: "Task 2", 45 | lastModifiedDate: 3245 46 | } 47 | }) 48 | ); 49 | const wasSuccessful = await loadColumns()(dispatch, null, api); 50 | 51 | expect(wasSuccessful, "to be true").then( 52 | expect(dispatch, "to have a call exhaustively satisfying", [ 53 | { 54 | type: LOAD_COLUMNS_SUCCESS, 55 | payload: { 56 | "1": { 57 | id: "1", 58 | text: "Task 1", 59 | lastModifiedDate: 1234 60 | }, 61 | "2": { 62 | id: "2", 63 | text: "Task 2", 64 | lastModifiedDate: 3245 65 | } 66 | } 67 | } 68 | ]) 69 | ); 70 | }); 71 | 72 | it(`should dispatch ${LOAD_COLUMNS_FAILURE}`, async () => { 73 | const error = new Error("Error message"); 74 | api.loadColumns.returns(Promise.reject(error)); 75 | 76 | const wasSuccessful = await loadColumns()(dispatch, null, api); 77 | 78 | expect(wasSuccessful, "to be false").then( 79 | expect(dispatch, "to have a call satisfying", [ 80 | { 81 | type: LOAD_COLUMNS_FAILURE, 82 | payload: undefined 83 | } 84 | ]) 85 | ); 86 | }); 87 | 88 | it("should call the api with the correct parameter", async () => { 89 | api.loadColumns.returns(Promise.resolve({})); 90 | 91 | await loadColumns()(dispatch, null, api); 92 | 93 | expect(api.loadColumns, "to have a call exhaustively satisfying", []); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/store/columns/actions/updateColumn.js: -------------------------------------------------------------------------------- 1 | import { createAction, createErrorAction } from "../../utils"; 2 | 3 | import { 4 | UPDATE_COLUMN_REQUEST, 5 | UPDATE_COLUMN_SUCCESS, 6 | UPDATE_COLUMN_FAILURE 7 | } from "../constants"; 8 | 9 | const updateColumnRequest = createAction(UPDATE_COLUMN_REQUEST); 10 | const updateColumnSuccess = createAction(UPDATE_COLUMN_SUCCESS); 11 | const updateColumnFailure = createErrorAction(UPDATE_COLUMN_FAILURE); 12 | 13 | export const updateColumn = (id, data) => (dispatch, getState, api) => { 14 | dispatch(updateColumnRequest()); 15 | 16 | return api.updateColumn(id, data).then( 17 | () => { 18 | dispatch(updateColumnSuccess(data)); 19 | return true; 20 | }, 21 | error => { 22 | dispatch(updateColumnFailure(error)); 23 | return false; 24 | } 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/store/columns/actions/updateColumn.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { 5 | UPDATE_COLUMN_REQUEST, 6 | UPDATE_COLUMN_SUCCESS, 7 | UPDATE_COLUMN_FAILURE 8 | } from "../constants"; 9 | 10 | import { updateColumn } from "./updateColumn"; 11 | 12 | describe("updateColumn", () => { 13 | let dispatch, api; 14 | 15 | beforeEach(() => { 16 | api = { 17 | updateColumn: sinon.stub().named("updateColumn") 18 | }; 19 | dispatch = sinon.stub().named("dispatch"); 20 | }); 21 | 22 | it(`should dispatch ${UPDATE_COLUMN_REQUEST}`, async () => { 23 | api.updateColumn.returns(Promise.resolve({})); 24 | await updateColumn("1", { id: "1", name: "To Do", tasks: [] })( 25 | dispatch, 26 | null, 27 | api 28 | ); 29 | 30 | expect(dispatch, "to have a call exhaustively satisfying", [ 31 | { 32 | type: UPDATE_COLUMN_REQUEST, 33 | payload: undefined 34 | } 35 | ]); 36 | }); 37 | 38 | it(`should dispatch ${UPDATE_COLUMN_SUCCESS}`, async () => { 39 | api.updateColumn.returns(Promise.resolve({})); 40 | const wasSuccessful = await updateColumn("1", { 41 | id: "1", 42 | name: "To Do", 43 | tasks: [] 44 | })(dispatch, null, api); 45 | 46 | expect(wasSuccessful, "to be true").then( 47 | expect(dispatch, "to have a call exhaustively satisfying", [ 48 | { 49 | type: UPDATE_COLUMN_SUCCESS, 50 | payload: { 51 | id: "1", 52 | name: "To Do", 53 | tasks: [] 54 | } 55 | } 56 | ]) 57 | ); 58 | }); 59 | 60 | it(`should dispatch ${UPDATE_COLUMN_FAILURE}`, async () => { 61 | const error = new Error("Error message"); 62 | api.updateColumn.returns(Promise.reject(error)); 63 | 64 | const wasSuccessful = await updateColumn("1", { 65 | id: "1", 66 | name: "To Do", 67 | tasks: [] 68 | })(dispatch, null, api); 69 | 70 | expect(wasSuccessful, "to be false").then( 71 | expect(dispatch, "to have a call satisfying", [ 72 | { 73 | type: UPDATE_COLUMN_FAILURE, 74 | payload: undefined 75 | } 76 | ]) 77 | ); 78 | }); 79 | 80 | it("should call the api with the correct parameter", async () => { 81 | api.updateColumn.returns(Promise.resolve({})); 82 | 83 | await updateColumn("1", { id: "1", name: "To Do", tasks: [] })( 84 | dispatch, 85 | null, 86 | api 87 | ); 88 | 89 | expect(api.updateColumn, "to have a call exhaustively satisfying", [ 90 | "1", 91 | { 92 | id: "1", 93 | name: "To Do", 94 | tasks: [] 95 | } 96 | ]); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/store/columns/constants.js: -------------------------------------------------------------------------------- 1 | export const LOAD_COLUMNS_REQUEST = "@columns/LOAD_COLUMNS_REQUEST"; 2 | export const LOAD_COLUMNS_SUCCESS = "@columns/LOAD_COLUMNS_SUCCESS"; 3 | export const LOAD_COLUMNS_FAILURE = "@columns/LOAD_COLUMNS_FAILURE"; 4 | 5 | export const UPDATE_COLUMN_REQUEST = "@columns/UPDATE_COLUMN_REQUEST"; 6 | export const UPDATE_COLUMN_SUCCESS = "@columns/UPDATE_COLUMN_SUCCESS"; 7 | export const UPDATE_COLUMN_FAILURE = "@columns/UPDATE_COLUMN_FAILURE"; 8 | -------------------------------------------------------------------------------- /src/store/columns/index.js: -------------------------------------------------------------------------------- 1 | import { qualifySelector } from "../utils"; 2 | import { LOAD_COLUMNS_SUCCESS, UPDATE_COLUMN_SUCCESS } from "./constants"; 3 | 4 | const name = "columns"; 5 | const initialState = {}; 6 | 7 | export const columnsReducer = (state = initialState, action) => { 8 | if (action.type === LOAD_COLUMNS_SUCCESS) { 9 | return { 10 | ...state, 11 | ...action.payload 12 | }; 13 | } else if (action.type === UPDATE_COLUMN_SUCCESS) { 14 | const stateCopy = { ...state }; 15 | stateCopy[action.payload.id] = { 16 | ...stateCopy[action.payload.id], 17 | ...action.payload 18 | }; 19 | return stateCopy; 20 | } 21 | return state; 22 | }; 23 | 24 | export default { [name]: columnsReducer }; 25 | 26 | // Selectors 27 | export const getColumns = qualifySelector(name, state => state || {}); 28 | 29 | export const getColumnByTaskId = (state, taskId) => { 30 | const columns = Object.values(getColumns(state)); 31 | const column = columns.find(column => column.tasks.includes(taskId)); 32 | 33 | return column || null; 34 | }; 35 | 36 | export const getDoneColumn = state => { 37 | const columns = Object.values(getColumns(state)); 38 | return columns[columns.length - 1] || null; 39 | }; 40 | 41 | export const getToDoColumn = state => { 42 | const columns = Object.values(getColumns(state)); 43 | return columns[0] || null; 44 | }; 45 | 46 | export const getColumn = (state, id) => { 47 | const columns = getColumns(state); 48 | return columns[id] || null; 49 | }; 50 | 51 | // Actions 52 | export { loadColumns } from "./actions/loadColumns"; 53 | export { updateColumn } from "./actions/updateColumn"; 54 | -------------------------------------------------------------------------------- /src/store/columns/index.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../testUtils/unexpected-react"; 2 | import { createStore, combineReducers } from "redux"; 3 | 4 | import columnsReducer, { 5 | getColumns, 6 | getColumnByTaskId, 7 | getDoneColumn, 8 | getToDoColumn, 9 | getColumn 10 | } from "./"; 11 | 12 | import { LOAD_COLUMNS_SUCCESS, UPDATE_COLUMN_SUCCESS } from "./constants"; 13 | 14 | function createFakeStore(intialState) { 15 | return createStore(combineReducers(columnsReducer), intialState); 16 | } 17 | 18 | let store; 19 | let dispatch; 20 | 21 | describe("columnsReducer", () => { 22 | it("should initialize store with default value", () => { 23 | store = createFakeStore(); 24 | 25 | expect(store.getState().columns, "to equal", {}); 26 | }); 27 | 28 | it(`should handle action ${LOAD_COLUMNS_SUCCESS}`, () => { 29 | store = createFakeStore(); 30 | dispatch = store.dispatch; 31 | 32 | dispatch({ 33 | type: LOAD_COLUMNS_SUCCESS, 34 | payload: { 35 | "1": { 36 | id: "1", 37 | name: "To Do", 38 | tasks: [] 39 | }, 40 | "2": { 41 | id: "2", 42 | name: "Doing", 43 | tasks: [] 44 | }, 45 | "3": { 46 | id: "3", 47 | name: "Done", 48 | tasks: [] 49 | } 50 | } 51 | }); 52 | 53 | expect(store.getState(), "to equal", { 54 | columns: { 55 | "1": { 56 | id: "1", 57 | name: "To Do", 58 | tasks: [] 59 | }, 60 | "2": { 61 | id: "2", 62 | name: "Doing", 63 | tasks: [] 64 | }, 65 | "3": { 66 | id: "3", 67 | name: "Done", 68 | tasks: [] 69 | } 70 | } 71 | }); 72 | }); 73 | 74 | it(`should handle action ${UPDATE_COLUMN_SUCCESS}`, () => { 75 | store = createFakeStore({ 76 | columns: { 77 | "1": { 78 | id: "1", 79 | name: "To Do", 80 | tasks: [] 81 | }, 82 | "2": { 83 | id: "2", 84 | name: "Doing", 85 | tasks: [] 86 | }, 87 | "3": { 88 | id: "3", 89 | name: "Done", 90 | tasks: [] 91 | } 92 | } 93 | }); 94 | dispatch = store.dispatch; 95 | 96 | dispatch({ 97 | type: UPDATE_COLUMN_SUCCESS, 98 | payload: { 99 | id: "1", 100 | name: "To Do", 101 | tasks: ["1", "2"] 102 | } 103 | }); 104 | 105 | expect(store.getState(), "to equal", { 106 | columns: { 107 | "1": { 108 | id: "1", 109 | name: "To Do", 110 | tasks: ["1", "2"] 111 | }, 112 | "2": { 113 | id: "2", 114 | name: "Doing", 115 | tasks: [] 116 | }, 117 | "3": { 118 | id: "3", 119 | name: "Done", 120 | tasks: [] 121 | } 122 | } 123 | }); 124 | }); 125 | 126 | it("should return all columns", () => { 127 | const store = createFakeStore({ 128 | columns: { 129 | "1": { 130 | id: "1", 131 | name: "To Do", 132 | tasks: [] 133 | }, 134 | "2": { 135 | id: "2", 136 | name: "Doing", 137 | tasks: [] 138 | }, 139 | "3": { 140 | id: "3", 141 | name: "Done", 142 | tasks: [] 143 | } 144 | } 145 | }); 146 | 147 | expect(getColumns(store.getState()), "to equal", { 148 | "1": { 149 | id: "1", 150 | name: "To Do", 151 | tasks: [] 152 | }, 153 | "2": { 154 | id: "2", 155 | name: "Doing", 156 | tasks: [] 157 | }, 158 | "3": { 159 | id: "3", 160 | name: "Done", 161 | tasks: [] 162 | } 163 | }); 164 | }); 165 | 166 | it("should return column by task id", () => { 167 | const store = createFakeStore({ 168 | columns: { 169 | "1": { 170 | id: "1", 171 | name: "To Do", 172 | tasks: [] 173 | }, 174 | "2": { 175 | id: "2", 176 | name: "Doing", 177 | tasks: ["1"] 178 | }, 179 | "3": { 180 | id: "3", 181 | name: "Done", 182 | tasks: [] 183 | } 184 | } 185 | }); 186 | 187 | expect(getColumnByTaskId(store.getState(), "1"), "to equal", { 188 | id: "2", 189 | name: "Doing", 190 | tasks: ["1"] 191 | }); 192 | }); 193 | 194 | it("should return column by id", () => { 195 | const store = createFakeStore({ 196 | columns: { 197 | "1": { 198 | id: "1", 199 | name: "To Do", 200 | tasks: [] 201 | }, 202 | "2": { 203 | id: "2", 204 | name: "Doing", 205 | tasks: [] 206 | }, 207 | "3": { 208 | id: "3", 209 | name: "Done", 210 | tasks: [] 211 | } 212 | } 213 | }); 214 | 215 | expect(getColumn(store.getState(), "1"), "to equal", { 216 | id: "1", 217 | name: "To Do", 218 | tasks: [] 219 | }); 220 | }); 221 | 222 | it("should return the To Do column", () => { 223 | const store = createFakeStore({ 224 | columns: { 225 | "1": { 226 | id: "1", 227 | name: "To Do", 228 | tasks: [] 229 | }, 230 | "2": { 231 | id: "2", 232 | name: "Doing", 233 | tasks: [] 234 | }, 235 | "3": { 236 | id: "3", 237 | name: "Done", 238 | tasks: [] 239 | } 240 | } 241 | }); 242 | 243 | expect(getToDoColumn(store.getState()), "to equal", { 244 | id: "1", 245 | name: "To Do", 246 | tasks: [] 247 | }); 248 | }); 249 | 250 | it("should return the Done column", () => { 251 | const store = createFakeStore({ 252 | columns: { 253 | "1": { 254 | id: "1", 255 | name: "To Do", 256 | tasks: [] 257 | }, 258 | "2": { 259 | id: "2", 260 | name: "Doing", 261 | tasks: [] 262 | }, 263 | "3": { 264 | id: "3", 265 | name: "Done", 266 | tasks: [] 267 | } 268 | } 269 | }); 270 | 271 | expect(getDoneColumn(store.getState()), "to equal", { 272 | id: "3", 273 | name: "Done", 274 | tasks: [] 275 | }); 276 | }); 277 | }); 278 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers, applyMiddleware } from "redux"; 2 | import thunk from "redux-thunk"; 3 | import columns from "./columns"; 4 | import tasks from "./tasks"; 5 | import api from "../lib/Api"; 6 | 7 | const middlewares = [thunk.withExtraArgument(api)]; 8 | const rootReducer = combineReducers({ ...columns, ...tasks }); 9 | 10 | export default createStore(rootReducer, applyMiddleware(...middlewares)); 11 | -------------------------------------------------------------------------------- /src/store/selectors.js: -------------------------------------------------------------------------------- 1 | export { 2 | getColumn, 3 | getColumns, 4 | getColumnByTaskId, 5 | getDoneColumn, 6 | getToDoColumn 7 | } from "./columns"; 8 | export { getTasks, isFetching } from "./tasks"; 9 | -------------------------------------------------------------------------------- /src/store/tasks/actions/createTask.js: -------------------------------------------------------------------------------- 1 | import { createAction, createErrorAction } from "../../utils"; 2 | import uid from "uid"; 3 | 4 | import { 5 | CREATE_TASK_REQUEST, 6 | CREATE_TASK_SUCCESS, 7 | CREATE_TASK_FAILURE 8 | } from "../constants"; 9 | 10 | const createTaskRequest = createAction(CREATE_TASK_REQUEST); 11 | const createTaskSuccess = createAction(CREATE_TASK_SUCCESS); 12 | const createTaskFailure = createErrorAction(CREATE_TASK_FAILURE); 13 | 14 | export const createTask = () => (dispatch, getState, api) => { 15 | dispatch(createTaskRequest()); 16 | // custom string with length of 11 17 | const id = uid(); 18 | const newTask = { 19 | id, 20 | text: "", 21 | lastModifiedDate: Date.now() 22 | }; 23 | 24 | return api.createTask(newTask).then( 25 | data => { 26 | dispatch(createTaskSuccess(data)); 27 | return id; 28 | }, 29 | error => { 30 | dispatch(createTaskFailure(error)); 31 | return false; 32 | } 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/store/tasks/actions/createTask.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { 5 | CREATE_TASK_REQUEST, 6 | CREATE_TASK_SUCCESS, 7 | CREATE_TASK_FAILURE 8 | } from "../constants"; 9 | 10 | import { createTask } from "./createTask"; 11 | 12 | describe("createTask", () => { 13 | let dispatch, api; 14 | 15 | beforeEach(() => { 16 | api = { 17 | createTask: sinon.stub().named("createTask") 18 | }; 19 | dispatch = sinon.stub().named("dispatch"); 20 | }); 21 | 22 | it(`should dispatch ${CREATE_TASK_REQUEST}`, async () => { 23 | api.createTask.returns(Promise.resolve({})); 24 | await createTask()(dispatch, null, api); 25 | 26 | expect(dispatch, "to have a call exhaustively satisfying", [ 27 | { 28 | type: CREATE_TASK_REQUEST, 29 | payload: undefined 30 | } 31 | ]); 32 | }); 33 | 34 | it(`should dispatch ${CREATE_TASK_SUCCESS}`, async () => { 35 | api.createTask.returns( 36 | Promise.resolve({ 37 | id: "1", 38 | text: "", 39 | lastModifiedDate: 1234 40 | }) 41 | ); 42 | const wasSuccessful = await createTask()(dispatch, null, api); 43 | 44 | expect(wasSuccessful, "to be a string").then( 45 | expect(dispatch, "to have a call exhaustively satisfying", [ 46 | { 47 | type: CREATE_TASK_SUCCESS, 48 | payload: { 49 | id: "1", 50 | text: "", 51 | lastModifiedDate: 1234 52 | } 53 | } 54 | ]) 55 | ); 56 | }); 57 | 58 | it(`should dispatch ${CREATE_TASK_FAILURE}`, async () => { 59 | const error = new Error("Error message"); 60 | api.createTask.returns(Promise.reject(error)); 61 | 62 | const wasSuccessful = await createTask()(dispatch, null, api); 63 | 64 | expect(wasSuccessful, "to be false").then( 65 | expect(dispatch, "to have a call satisfying", [ 66 | { 67 | type: CREATE_TASK_FAILURE, 68 | payload: undefined 69 | } 70 | ]) 71 | ); 72 | }); 73 | 74 | it("should call the api with the correct parameter", async () => { 75 | api.createTask.returns(Promise.resolve({})); 76 | 77 | await createTask()(dispatch, null, api); 78 | 79 | expect(api.createTask, "to have a call exhaustively satisfying", [ 80 | { 81 | id: expect.it("to be a string"), 82 | text: "", 83 | lastModifiedDate: expect.it("to be a number") 84 | } 85 | ]); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/store/tasks/actions/deleteTaskAndUpdateColumn.js: -------------------------------------------------------------------------------- 1 | import { createAction, createErrorAction } from "../../utils"; 2 | import { updateColumn } from "../../columns/actions/updateColumn"; 3 | import { getColumnByTaskId } from "../../selectors"; 4 | 5 | import { 6 | DELETE_TASK_REQUEST, 7 | DELETE_TASK_SUCCESS, 8 | DELETE_TASK_FAILURE 9 | } from "../constants"; 10 | 11 | const deleteTaskRequest = createAction(DELETE_TASK_REQUEST); 12 | const deleteTaskSuccess = createAction(DELETE_TASK_SUCCESS); 13 | const deleteTaskFailure = createErrorAction(DELETE_TASK_FAILURE); 14 | 15 | export const getColumnWithRemovedTask = (state, taskId) => { 16 | const columnData = getColumnByTaskId(state, taskId); 17 | if (columnData) { 18 | columnData.tasks.splice(columnData.tasks.indexOf(taskId), 1); 19 | } 20 | return columnData; 21 | }; 22 | 23 | export const createDeleteTaskAction = updateColumnAction => id => ( 24 | dispatch, 25 | getState, 26 | api 27 | ) => { 28 | dispatch(deleteTaskRequest()); 29 | 30 | return api.deleteTask(id).then( 31 | data => { 32 | dispatch(deleteTaskSuccess([id])); 33 | const columnData = getColumnWithRemovedTask(getState(), id); 34 | 35 | if (columnData) { 36 | return dispatch(updateColumnAction(columnData.id, columnData)).then( 37 | wasSuccessful => { 38 | if (wasSuccessful) { 39 | return true; 40 | } 41 | 42 | return false; 43 | } 44 | ); 45 | } 46 | return false; 47 | }, 48 | error => { 49 | dispatch(deleteTaskFailure(error)); 50 | return false; 51 | } 52 | ); 53 | }; 54 | 55 | export const deleteTaskAndUpdateColumn = createDeleteTaskAction(updateColumn); 56 | -------------------------------------------------------------------------------- /src/store/tasks/actions/deleteTaskAndUpdateColumn.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { 5 | DELETE_TASK_REQUEST, 6 | DELETE_TASK_SUCCESS, 7 | DELETE_TASK_FAILURE 8 | } from "../constants"; 9 | 10 | import { 11 | createDeleteTaskAction, 12 | getColumnWithRemovedTask 13 | } from "./deleteTaskAndUpdateColumn"; 14 | 15 | describe("deleteTaskAndUpdateColumn", () => { 16 | let dispatch, getState, api; 17 | 18 | beforeEach(() => { 19 | api = { 20 | deleteTask: sinon.stub().named("deleteTask") 21 | }; 22 | dispatch = sinon 23 | .stub() 24 | .named("dispatch") 25 | // Emulate thunk 26 | .callsFake(v => { 27 | return typeof v === "function" ? v(dispatch, getState) : v; 28 | }); 29 | getState = sinon.stub().named("getState"); 30 | }); 31 | 32 | it(`should dispatch ${DELETE_TASK_REQUEST} and ${DELETE_TASK_SUCCESS}`, async () => { 33 | getState.returns({ 34 | columns: { 35 | "1": { 36 | id: "1", 37 | name: "To Do", 38 | tasks: ["TaskId"] 39 | }, 40 | "2": { 41 | id: "2", 42 | name: "Doing", 43 | tasks: [] 44 | }, 45 | "3": { 46 | id: "3", 47 | name: "Done", 48 | tasks: [] 49 | } 50 | } 51 | }); 52 | api.deleteTask.returns(Promise.resolve({})); 53 | const mockUpdateColumnAction = () => Promise.resolve(true); 54 | const deleteTask = createDeleteTaskAction(mockUpdateColumnAction); 55 | 56 | const wasSuccessful = await deleteTask("TaskId")(dispatch, getState, api); 57 | 58 | return expect(wasSuccessful, "to be true").then( 59 | expect(dispatch, "to have calls satisfying", [ 60 | [{ type: DELETE_TASK_REQUEST }], 61 | [ 62 | { 63 | type: DELETE_TASK_SUCCESS, 64 | payload: ["TaskId"] 65 | } 66 | ], 67 | [expect.it("to be fulfilled with", true)] 68 | ]) 69 | ); 70 | }); 71 | 72 | it(`should dispatch ${DELETE_TASK_FAILURE}`, async () => { 73 | getState.returns({ 74 | columns: { 75 | "1": { 76 | id: "1", 77 | name: "To Do", 78 | tasks: ["TaskId"] 79 | }, 80 | "2": { 81 | id: "2", 82 | name: "Doing", 83 | tasks: [] 84 | }, 85 | "3": { 86 | id: "3", 87 | name: "Done", 88 | tasks: [] 89 | } 90 | } 91 | }); 92 | 93 | const error = new Error("Custom Error"); 94 | api.deleteTask.returns(Promise.reject(error)); 95 | 96 | const mockUpdateColumnAction = () => Promise.resolve(true); 97 | const deleteTask = createDeleteTaskAction(mockUpdateColumnAction); 98 | 99 | const wasSuccessful = await deleteTask("TaskId")(dispatch, getState, api); 100 | 101 | return expect(wasSuccessful, "to be false").then( 102 | expect(dispatch, "to have a call satisfying", [ 103 | { 104 | type: DELETE_TASK_FAILURE, 105 | error 106 | } 107 | ]) 108 | ); 109 | }); 110 | 111 | it("should return false when updating column fails", async () => { 112 | getState.returns({ 113 | columns: { 114 | "1": { 115 | id: "1", 116 | name: "To Do", 117 | tasks: ["TaskId"] 118 | }, 119 | "2": { 120 | id: "2", 121 | name: "Doing", 122 | tasks: [] 123 | }, 124 | "3": { 125 | id: "3", 126 | name: "Done", 127 | tasks: [] 128 | } 129 | } 130 | }); 131 | 132 | api.deleteTask.returns(Promise.resolve({})); 133 | const mockUpdateColumnAction = () => Promise.resolve(false); 134 | const deleteTask = createDeleteTaskAction(mockUpdateColumnAction); 135 | 136 | const wasSuccessful = await deleteTask("TaskId")(dispatch, getState, api); 137 | 138 | expect(wasSuccessful, "to be false"); 139 | }); 140 | 141 | it("should call the api with the correct parameter", async () => { 142 | getState.returns({ 143 | columns: { 144 | "1": { 145 | id: "1", 146 | name: "To Do", 147 | tasks: ["TaskId"] 148 | }, 149 | "2": { 150 | id: "2", 151 | name: "Doing", 152 | tasks: [] 153 | }, 154 | "3": { 155 | id: "3", 156 | name: "Done", 157 | tasks: [] 158 | } 159 | } 160 | }); 161 | 162 | api.deleteTask.returns(Promise.resolve({})); 163 | const mockUpdateColumnAction = () => Promise.resolve(false); 164 | const deleteTask = createDeleteTaskAction(mockUpdateColumnAction); 165 | 166 | await deleteTask("TaskId")(dispatch, getState, api); 167 | 168 | expect(api.deleteTask, "to have a call exhaustively satisfying", [ 169 | "TaskId" 170 | ]); 171 | }); 172 | 173 | it("should get column data for task to be removed", () => { 174 | getState.returns({ 175 | columns: { 176 | "1": { 177 | id: "1", 178 | name: "To Do", 179 | tasks: ["TaskId"] 180 | }, 181 | "2": { 182 | id: "2", 183 | name: "Doing", 184 | tasks: [] 185 | }, 186 | "3": { 187 | id: "3", 188 | name: "Done", 189 | tasks: [] 190 | } 191 | } 192 | }); 193 | 194 | const columnDataWithRemovedTask = getColumnWithRemovedTask( 195 | getState(), 196 | "TaskId" 197 | ); 198 | 199 | expect(columnDataWithRemovedTask, "to equal", { 200 | id: "1", 201 | name: "To Do", 202 | tasks: [] 203 | }); 204 | }); 205 | 206 | it("should return null if no column for removed task", () => { 207 | getState.returns({ 208 | columns: { 209 | "1": { 210 | id: "1", 211 | name: "To Do", 212 | tasks: ["TaskId"] 213 | }, 214 | "2": { 215 | id: "2", 216 | name: "Doing", 217 | tasks: [] 218 | }, 219 | "3": { 220 | id: "3", 221 | name: "Done", 222 | tasks: [] 223 | } 224 | } 225 | }); 226 | 227 | const columnDataWithRemovedTask = getColumnWithRemovedTask( 228 | getState(), 229 | "WrongId" 230 | ); 231 | 232 | expect(columnDataWithRemovedTask, "to be null"); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /src/store/tasks/actions/loadTasks.js: -------------------------------------------------------------------------------- 1 | import { createAction, createErrorAction } from "../../utils"; 2 | 3 | import { 4 | LOAD_TASKS_REQUEST, 5 | LOAD_TASKS_SUCCESS, 6 | LOAD_TASKS_FAILURE 7 | } from "../constants"; 8 | 9 | const loadTasksRequest = createAction(LOAD_TASKS_REQUEST); 10 | const loadTasksSuccess = createAction(LOAD_TASKS_SUCCESS); 11 | const loadTasksFailure = createErrorAction(LOAD_TASKS_FAILURE); 12 | 13 | export const loadTasks = () => (dispatch, getState, api) => { 14 | dispatch(loadTasksRequest()); 15 | 16 | return api.loadTasks().then( 17 | data => { 18 | dispatch(loadTasksSuccess(data)); 19 | return true; 20 | }, 21 | error => { 22 | dispatch(loadTasksFailure(error)); 23 | return false; 24 | } 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/store/tasks/actions/loadTasks.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { 5 | LOAD_TASKS_REQUEST, 6 | LOAD_TASKS_SUCCESS, 7 | LOAD_TASKS_FAILURE 8 | } from "../constants"; 9 | 10 | import { loadTasks } from "./loadTasks"; 11 | 12 | describe("loadTasks", () => { 13 | let dispatch, api; 14 | 15 | beforeEach(() => { 16 | api = { 17 | loadTasks: sinon.stub().named("loadTasks") 18 | }; 19 | dispatch = sinon.stub().named("dispatch"); 20 | }); 21 | 22 | it(`should dispatch ${LOAD_TASKS_REQUEST}`, async () => { 23 | api.loadTasks.returns(Promise.resolve({})); 24 | await loadTasks()(dispatch, null, api); 25 | 26 | expect(dispatch, "to have a call exhaustively satisfying", [ 27 | { 28 | type: LOAD_TASKS_REQUEST, 29 | payload: undefined 30 | } 31 | ]); 32 | }); 33 | 34 | it(`should dispatch ${LOAD_TASKS_SUCCESS}`, async () => { 35 | api.loadTasks.returns( 36 | Promise.resolve({ 37 | "1": { 38 | id: "1", 39 | text: "Task 1", 40 | lastModifiedDate: 1234 41 | }, 42 | "2": { 43 | id: "2", 44 | text: "Task 2", 45 | lastModifiedDate: 3245 46 | } 47 | }) 48 | ); 49 | const wasSuccessful = await loadTasks()(dispatch, null, api); 50 | 51 | expect(wasSuccessful, "to be true").then( 52 | expect(dispatch, "to have a call exhaustively satisfying", [ 53 | { 54 | type: LOAD_TASKS_SUCCESS, 55 | payload: { 56 | "1": { 57 | id: "1", 58 | text: "Task 1", 59 | lastModifiedDate: 1234 60 | }, 61 | "2": { 62 | id: "2", 63 | text: "Task 2", 64 | lastModifiedDate: 3245 65 | } 66 | } 67 | } 68 | ]) 69 | ); 70 | }); 71 | 72 | it(`should dispatch ${LOAD_TASKS_FAILURE}`, async () => { 73 | const error = new Error("Error message"); 74 | api.loadTasks.returns(Promise.reject(error)); 75 | 76 | const wasSuccessful = await loadTasks()(dispatch, null, api); 77 | 78 | expect(wasSuccessful, "to be false").then( 79 | expect(dispatch, "to have a call satisfying", [ 80 | { 81 | type: LOAD_TASKS_FAILURE, 82 | payload: undefined 83 | } 84 | ]) 85 | ); 86 | }); 87 | 88 | it("should call the api with the correct parameter", async () => { 89 | api.loadTasks.returns(Promise.resolve({})); 90 | 91 | await loadTasks()(dispatch, null, api); 92 | 93 | expect(api.loadTasks, "to have a call exhaustively satisfying", []); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/store/tasks/actions/updateTask.js: -------------------------------------------------------------------------------- 1 | import { createAction, createErrorAction } from "../../utils"; 2 | 3 | import { 4 | UPDATE_TASK_REQUEST, 5 | UPDATE_TASK_SUCCESS, 6 | UPDATE_TASK_FAILURE 7 | } from "../constants"; 8 | 9 | const updateTaskRequest = createAction(UPDATE_TASK_REQUEST); 10 | const updateTaskSuccess = createAction(UPDATE_TASK_SUCCESS); 11 | const updateTaskFailure = createErrorAction(UPDATE_TASK_FAILURE); 12 | 13 | export const updateTask = (id, data) => (dispatch, getState, api) => { 14 | dispatch(updateTaskRequest()); 15 | data = { 16 | id, 17 | ...data, 18 | lastModifiedDate: Date.now() 19 | }; 20 | 21 | return api.updateTask(id, data).then( 22 | () => { 23 | dispatch(updateTaskSuccess(data)); 24 | return true; 25 | }, 26 | error => { 27 | dispatch(updateTaskFailure(error)); 28 | return false; 29 | } 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/store/tasks/actions/updateTask.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../../testUtils/unexpected-react"; 2 | import sinon from "sinon"; 3 | 4 | import { 5 | UPDATE_TASK_REQUEST, 6 | UPDATE_TASK_SUCCESS, 7 | UPDATE_TASK_FAILURE 8 | } from "../constants"; 9 | 10 | import { updateTask } from "./updateTask"; 11 | 12 | describe("updateTask", () => { 13 | let dispatch, api; 14 | 15 | beforeEach(() => { 16 | api = { 17 | updateTask: sinon.stub().named("updateTask") 18 | }; 19 | dispatch = sinon.stub().named("dispatch"); 20 | }); 21 | 22 | it(`should dispatch ${UPDATE_TASK_REQUEST}`, async () => { 23 | api.updateTask.returns(Promise.resolve({})); 24 | await updateTask("1", { text: "Task 1" })(dispatch, null, api); 25 | 26 | expect(dispatch, "to have a call exhaustively satisfying", [ 27 | { 28 | type: UPDATE_TASK_REQUEST, 29 | payload: undefined 30 | } 31 | ]); 32 | }); 33 | 34 | it(`should dispatch ${UPDATE_TASK_SUCCESS}`, async () => { 35 | api.updateTask.returns(Promise.resolve({})); 36 | const wasSuccessful = await updateTask("1", { text: "Task 1" })( 37 | dispatch, 38 | null, 39 | api 40 | ); 41 | 42 | expect(wasSuccessful, "to be true").then( 43 | expect(dispatch, "to have a call exhaustively satisfying", [ 44 | { 45 | type: UPDATE_TASK_SUCCESS, 46 | payload: { 47 | id: "1", 48 | text: "Task 1", 49 | lastModifiedDate: expect.it("to be a number") 50 | } 51 | } 52 | ]) 53 | ); 54 | }); 55 | 56 | it(`should dispatch ${UPDATE_TASK_FAILURE}`, async () => { 57 | const error = new Error("Error message"); 58 | api.updateTask.returns(Promise.reject(error)); 59 | 60 | const wasSuccessful = await updateTask("1", { text: "Task 1" })( 61 | dispatch, 62 | null, 63 | api 64 | ); 65 | 66 | expect(wasSuccessful, "to be false").then( 67 | expect(dispatch, "to have a call satisfying", [ 68 | { 69 | type: UPDATE_TASK_FAILURE, 70 | payload: undefined 71 | } 72 | ]) 73 | ); 74 | }); 75 | 76 | it("should call the api with the correct parameter", async () => { 77 | api.updateTask.returns(Promise.resolve({})); 78 | 79 | await updateTask("1", { text: "Task 1" })(dispatch, null, api); 80 | 81 | expect(api.updateTask, "to have a call exhaustively satisfying", [ 82 | "1", 83 | { 84 | id: "1", 85 | text: "Task 1", 86 | lastModifiedDate: expect.it("to be a number") 87 | } 88 | ]); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/store/tasks/constants.js: -------------------------------------------------------------------------------- 1 | export const LOAD_TASKS_REQUEST = "@tasks/LOAD_TASKS_REQUEST"; 2 | export const LOAD_TASKS_SUCCESS = "@tasks/LOAD_TASKS_SUCCESS"; 3 | export const LOAD_TASKS_FAILURE = "@tasks/LOAD_TASKS_FAILURE"; 4 | 5 | export const UPDATE_TASK_REQUEST = "@tasks/UPDATE_TASK_REQUEST"; 6 | export const UPDATE_TASK_SUCCESS = "@tasks/UPDATE_TASK_SUCCESS"; 7 | export const UPDATE_TASK_FAILURE = "@tasks/UPDATE_TASK_FAILURE"; 8 | 9 | export const DELETE_TASK_REQUEST = "@tasks/DELETE_TASK_REQUEST"; 10 | export const DELETE_TASK_SUCCESS = "@tasks/DELETE_TASK_SUCCESS"; 11 | export const DELETE_TASK_FAILURE = "@tasks/DELETE_TASK_FAILURE"; 12 | 13 | export const CREATE_TASK_REQUEST = "@tasks/CREATE_TASK_REQUEST"; 14 | export const CREATE_TASK_SUCCESS = "@tasks/CREATE_TASK_SUCCESS"; 15 | export const CREATE_TASK_FAILURE = "@tasks/CREATE_TASK_FAILURE"; 16 | -------------------------------------------------------------------------------- /src/store/tasks/index.js: -------------------------------------------------------------------------------- 1 | import { qualifySelector } from "../utils"; 2 | import { 3 | LOAD_TASKS_REQUEST, 4 | LOAD_TASKS_SUCCESS, 5 | LOAD_TASKS_FAILURE, 6 | UPDATE_TASK_SUCCESS, 7 | DELETE_TASK_SUCCESS, 8 | CREATE_TASK_SUCCESS 9 | } from "./constants"; 10 | 11 | const name = "tasks"; 12 | const initialState = { 13 | fetching: false, 14 | data: {} 15 | }; 16 | 17 | export const tasksReducer = (state = initialState, action) => { 18 | if (action.type === LOAD_TASKS_REQUEST) { 19 | return { 20 | fetching: true, 21 | data: { 22 | ...state.data 23 | } 24 | } 25 | } else if (action.type === LOAD_TASKS_FAILURE) { 26 | return { 27 | fetching: false, 28 | data: { 29 | ...state.data 30 | } 31 | } 32 | } else if (action.type === LOAD_TASKS_SUCCESS) { 33 | return { 34 | fetching: false, 35 | data: { 36 | ...state.data, 37 | ...action.payload 38 | } 39 | }; 40 | } else if (action.type === CREATE_TASK_SUCCESS) { 41 | return { 42 | ...state, 43 | data: { 44 | ...state.data, 45 | [action.payload.id]: action.payload 46 | } 47 | }; 48 | } else if (action.type === UPDATE_TASK_SUCCESS) { 49 | return { 50 | ...state, 51 | data: { 52 | ...state.data, 53 | [action.payload.id]: { 54 | ...state.data[action.payload.id], 55 | ...action.payload 56 | } 57 | } 58 | }; 59 | } else if (action.type === DELETE_TASK_SUCCESS) { 60 | const stateCopy = { ...state.data }; 61 | 62 | action.payload.forEach(taskId => { 63 | delete stateCopy[taskId]; 64 | }); 65 | return { 66 | ...state, 67 | data: { 68 | ...stateCopy 69 | } 70 | }; 71 | } 72 | return state; 73 | }; 74 | 75 | export default { [name]: tasksReducer }; 76 | 77 | // Selectors 78 | export const getTasks = qualifySelector(name, state => state.data || {}); 79 | export const isFetching = qualifySelector(name, state => state.fetching); 80 | 81 | // Actions 82 | export { loadTasks } from "./actions/loadTasks"; 83 | export { updateTask } from "./actions/updateTask"; 84 | export { deleteTaskAndUpdateColumn } from "./actions/deleteTaskAndUpdateColumn"; 85 | export { createTask } from "./actions/createTask"; 86 | -------------------------------------------------------------------------------- /src/store/tasks/index.test.js: -------------------------------------------------------------------------------- 1 | import expect from "../../testUtils/unexpected-react"; 2 | import { createStore, combineReducers } from "redux"; 3 | 4 | import tasksReducer, { getTasks, isFetching } from "./"; 5 | 6 | import { 7 | LOAD_TASKS_REQUEST, 8 | LOAD_TASKS_SUCCESS, 9 | LOAD_TASKS_FAILURE, 10 | UPDATE_TASK_SUCCESS, 11 | DELETE_TASK_SUCCESS, 12 | CREATE_TASK_SUCCESS 13 | } from "./constants"; 14 | 15 | function createFakeStore(intialState) { 16 | return createStore(combineReducers(tasksReducer), intialState); 17 | } 18 | 19 | let store; 20 | let dispatch; 21 | 22 | describe("tasksReducer", () => { 23 | it("should initialize store with default value", () => { 24 | store = createFakeStore(); 25 | 26 | expect(store.getState().tasks, "to equal", { 27 | fetching: false, 28 | data: {} 29 | }); 30 | }); 31 | 32 | it(`should handle action ${LOAD_TASKS_SUCCESS}`, () => { 33 | store = createFakeStore(); 34 | dispatch = store.dispatch; 35 | 36 | dispatch({ 37 | type: LOAD_TASKS_SUCCESS, 38 | payload: { 39 | "1": { 40 | id: "1", 41 | text: "Task 1", 42 | lastModifiedDate: 3424 43 | }, 44 | "2": { 45 | id: "2", 46 | text: "Task 2", 47 | lastModifiedDate: 8498 48 | } 49 | } 50 | }); 51 | 52 | expect(store.getState(), "to equal", { 53 | tasks: { 54 | fetching: false, 55 | data: { 56 | "1": { 57 | id: "1", 58 | text: "Task 1", 59 | lastModifiedDate: 3424 60 | }, 61 | "2": { 62 | id: "2", 63 | text: "Task 2", 64 | lastModifiedDate: 8498 65 | } 66 | } 67 | } 68 | }); 69 | }); 70 | 71 | it(`should handle action ${UPDATE_TASK_SUCCESS}`, () => { 72 | store = createFakeStore({ 73 | tasks: { 74 | fetching: false, 75 | data: { 76 | "1": { 77 | id: "1", 78 | text: "Task 1", 79 | lastModifiedDate: 3424 80 | }, 81 | "2": { 82 | id: "2", 83 | text: "Task 2", 84 | lastModifiedDate: 8498 85 | } 86 | } 87 | } 88 | }); 89 | dispatch = store.dispatch; 90 | 91 | dispatch({ 92 | type: UPDATE_TASK_SUCCESS, 93 | payload: { 94 | id: "1", 95 | text: "Task with updated text", 96 | lastModifiedDate: 9009 97 | } 98 | }); 99 | 100 | expect(store.getState().tasks, "to equal", { 101 | fetching: false, 102 | data: { 103 | "1": { 104 | id: "1", 105 | text: "Task with updated text", 106 | lastModifiedDate: 9009 107 | }, 108 | "2": { 109 | id: "2", 110 | text: "Task 2", 111 | lastModifiedDate: 8498 112 | } 113 | } 114 | }); 115 | }); 116 | 117 | it(`should handle action ${CREATE_TASK_SUCCESS}`, () => { 118 | store = createFakeStore({ 119 | tasks: { 120 | fetching: false, 121 | data: { 122 | "1": { 123 | id: "1", 124 | text: "Task 1", 125 | lastModifiedDate: 3424 126 | } 127 | } 128 | } 129 | }); 130 | dispatch = store.dispatch; 131 | 132 | dispatch({ 133 | type: CREATE_TASK_SUCCESS, 134 | payload: { 135 | id: "2", 136 | text: "Task 2", 137 | lastModifiedDate: 8498 138 | } 139 | }); 140 | 141 | expect(store.getState().tasks, "to equal", { 142 | fetching: false, 143 | data: { 144 | "1": { 145 | id: "1", 146 | text: "Task 1", 147 | lastModifiedDate: 3424 148 | }, 149 | "2": { 150 | id: "2", 151 | text: "Task 2", 152 | lastModifiedDate: 8498 153 | } 154 | } 155 | }); 156 | }); 157 | 158 | it(`should handle action ${DELETE_TASK_SUCCESS}`, () => { 159 | store = createFakeStore({ 160 | tasks: { 161 | fetching: false, 162 | data: { 163 | "1": { 164 | id: "1", 165 | text: "Task 1", 166 | lastModifiedDate: 3424 167 | }, 168 | "2": { 169 | id: "2", 170 | text: "Task 2", 171 | lastModifiedDate: 8498 172 | } 173 | } 174 | } 175 | }); 176 | dispatch = store.dispatch; 177 | 178 | dispatch({ 179 | type: DELETE_TASK_SUCCESS, 180 | payload: ["1"] 181 | }); 182 | 183 | expect(store.getState().tasks, "to equal", { 184 | fetching: false, 185 | data: { 186 | "2": { 187 | id: "2", 188 | text: "Task 2", 189 | lastModifiedDate: 8498 190 | } 191 | } 192 | }); 193 | }); 194 | 195 | it("should return all tasks", () => { 196 | const store = createFakeStore({ 197 | tasks: { 198 | fetching: false, 199 | data: { 200 | "1": { 201 | id: "1", 202 | text: "Task 1", 203 | lastModifiedDate: 3424 204 | }, 205 | "2": { 206 | id: "2", 207 | text: "Task 2", 208 | lastModifiedDate: 8498 209 | } 210 | } 211 | } 212 | }); 213 | 214 | expect(getTasks(store.getState()), "to equal", { 215 | "1": { 216 | id: "1", 217 | text: "Task 1", 218 | lastModifiedDate: 3424 219 | }, 220 | "2": { 221 | id: "2", 222 | text: "Task 2", 223 | lastModifiedDate: 8498 224 | } 225 | }); 226 | }); 227 | 228 | it("should return fetching state false by default", () => { 229 | const store = createFakeStore(); 230 | 231 | expect(isFetching(store.getState()), "to be false"); 232 | }); 233 | 234 | it(`should return fetching state true on ${LOAD_TASKS_REQUEST}`, () => { 235 | const store = createFakeStore(); 236 | dispatch = store.dispatch; 237 | 238 | dispatch({ type: LOAD_TASKS_REQUEST }); 239 | 240 | expect(isFetching(store.getState()), "to be true"); 241 | }); 242 | 243 | it(`should return fetching state false on ${LOAD_TASKS_SUCCESS}`, () => { 244 | const store = createFakeStore(); 245 | dispatch = store.dispatch; 246 | 247 | dispatch({ type: LOAD_TASKS_REQUEST }); 248 | dispatch({ 249 | type: LOAD_TASKS_SUCCESS, 250 | payload: { 251 | "1": { 252 | id: "1", 253 | text: "Task 1", 254 | lastModifiedDate: 3424 255 | }, 256 | "2": { 257 | id: "2", 258 | text: "Task 2", 259 | lastModifiedDate: 8498 260 | } 261 | } 262 | }); 263 | 264 | expect(isFetching(store.getState()), "to be false"); 265 | }); 266 | 267 | it(`should return fetching state false on ${LOAD_TASKS_FAILURE}`, () => { 268 | const store = createFakeStore(); 269 | dispatch = store.dispatch; 270 | 271 | dispatch({ type: LOAD_TASKS_REQUEST }); 272 | dispatch({ type: LOAD_TASKS_FAILURE }); 273 | 274 | expect(isFetching(store.getState()), "to be false"); 275 | }); 276 | }); 277 | -------------------------------------------------------------------------------- /src/store/utils.js: -------------------------------------------------------------------------------- 1 | export const qualifySelector = (name, selector) => (state, ...args) => { 2 | return selector(state[name], ...args); 3 | }; 4 | 5 | export const createAction = (type, payloadFactory = x => x) => (...args) => ({ 6 | type, 7 | payload: payloadFactory(...args) 8 | }); 9 | 10 | export const createErrorAction = (type, options) => (error, payload) => ({ 11 | type, 12 | error, 13 | errorAction: true, 14 | ...options, 15 | payload: payload || {} 16 | }); 17 | -------------------------------------------------------------------------------- /src/testUtils/FakeReduxProvider.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { createStore, applyMiddleware } from "redux"; 3 | import { Provider as ReduxProvider } from "react-redux"; 4 | import thunk from "redux-thunk"; 5 | import PropTypes from "prop-types"; 6 | 7 | class FakeReduxProvider extends Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | const fakeReducer = props.reducer || (s => s); 12 | 13 | const thunkMiddleware = props.mockApi 14 | ? thunk.withExtraArgument(props.mockApi) 15 | : thunk; 16 | this.store = createStore( 17 | fakeReducer, 18 | props.initialState, 19 | applyMiddleware(thunkMiddleware) 20 | ); 21 | } 22 | 23 | render() { 24 | return ( 25 | {this.props.children} 26 | ); 27 | } 28 | } 29 | 30 | FakeReduxProvider.propTypes = { 31 | children: PropTypes.node, 32 | initialState: PropTypes.object, 33 | mockApi: PropTypes.object, 34 | reducer: PropTypes.func 35 | }; 36 | 37 | export default FakeReduxProvider; 38 | -------------------------------------------------------------------------------- /src/testUtils/unexpected-react.js: -------------------------------------------------------------------------------- 1 | import unexpected from "unexpected"; 2 | import unexpectedDom from "unexpected-dom"; 3 | import unexpectedReaction from "unexpected-reaction"; 4 | import ReactDom from "react-dom"; 5 | import React, { Component } from "react"; 6 | import unexpectedSinon from "unexpected-sinon"; 7 | import PropTypes from "prop-types"; 8 | import { simulate } from "react-dom-testing"; 9 | 10 | import FakeReduxProvider from "./FakeReduxProvider"; 11 | 12 | const expect = unexpected 13 | .clone() 14 | .use(unexpectedDom) 15 | .use(unexpectedReaction) 16 | .use(unexpectedSinon); 17 | 18 | export class Mounter extends Component { 19 | render() { 20 | return
{this.props.children}
; 21 | } 22 | } 23 | 24 | Mounter.propTypes = { 25 | children: PropTypes.node 26 | }; 27 | 28 | export function getInstance(reactElement, tagName = "div") { 29 | const div = document.createElement(tagName); 30 | const element = ReactDom.render(reactElement, div); 31 | 32 | const result = { 33 | instance: element, 34 | subject: div.firstChild 35 | }; 36 | 37 | if (reactElement.type === PropUpdater) { 38 | result.applyPropsUpdate = () => 39 | simulate(result.subject.firstChild, { type: "click" }); 40 | } 41 | 42 | return result; 43 | } 44 | 45 | export function withStore(Component, initialState, mockApi, reducer) { 46 | return props => ( 47 | 52 | 53 | 54 | ); 55 | } 56 | 57 | export function getInstanceWithStore( 58 | reactElement, 59 | initialState, 60 | mockApi, 61 | reducer 62 | ) { 63 | return getInstance( 64 | 69 | {reactElement} 70 | 71 | ); 72 | } 73 | 74 | export class PropUpdater extends Component { 75 | constructor(props) { 76 | super(props); 77 | 78 | this.state = { 79 | isClicked: false 80 | }; 81 | } 82 | 83 | render() { 84 | const { children, propsUpdate } = this.props; 85 | const { isClicked } = this.state; 86 | 87 | let child; 88 | if (isClicked) { 89 | child = React.cloneElement(children, propsUpdate); 90 | } else { 91 | child = children; 92 | } 93 | 94 | return ( 95 |
this.setState({ isClicked: true })}>{child}
96 | ); 97 | } 98 | } 99 | 100 | PropUpdater.propTypes = { 101 | children: PropTypes.element.isRequired, 102 | propsUpdate: PropTypes.object.isRequired 103 | }; 104 | 105 | export { Ignore, simulate } from "react-dom-testing"; 106 | export default expect; 107 | --------------------------------------------------------------------------------