├── .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 | [](https://circleci.com/gh/moubi/flow-task) [](https://lgtm.com/projects/g/moubi/flow-task/context:javascript) [](LICENSE)
14 |
15 |

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 |
14 |
--------------------------------------------------------------------------------
/src/assets/checkmark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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 |
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 |
--------------------------------------------------------------------------------