update, lint, prettify everything

put backend sources into src
setup circleci
don't use react-loadable because it seems unnecessary
This commit is contained in:
2021-03-13 19:58:06 +03:00
parent f19a55790c
commit afd1f98254
98 changed files with 13582 additions and 10832 deletions

73
.circleci/config.yml Normal file
View File

@@ -0,0 +1,73 @@
version: 2
jobs:
test-backend:
docker:
- image: circleci/node:14
working_directory: ~/todolist
steps:
- checkout
- restore_cache:
keys:
- backend-dependencies-{{ checksum "package.json" }}
- run:
name: install backend deps
command: npm i
- save_cache:
paths:
- node_modules
key: backend-dependencies-{{ checksum "package.json" }}
- run:
name: test backend
command: npm test
test-frontend:
docker:
- image: circleci/node:14
working_directory: ~/todolist/client
steps:
- checkout:
- restore_cache:
keys:
- backend-dependencies-{{ checksum "package.json" }}
- run:
name: install backend deps
command: npm i
- save_cache:
paths:
- node_modules
key: backend-dependencies-{{ checksum "package.json" }}
- restore_cache:
keys:
- frontend-dependencies-{{ checksum "package.json" }}
- run:
name: install frontend deps
command: cd client && npm i
- save_cache:
paths:
- client/node_modules
key: frontend-dependencies-{{ checksum "package.json" }}
- run:
name: test client
command: cd client && npm test
workflows:
version: 2
test:
jobs:
- test-backend
- test-frontend

View File

@@ -1,2 +1,5 @@
client/build/* client/build/*
client/node_modules/* client/node_modules/*
*.css
*.scss
**package-lock.json

View File

@@ -1,39 +1,11 @@
{ {
"root": true,
"extends": [ "extends": [
"eslint:recommended", "eslint:recommended",
"plugin:node/recommended", "plugin:node/recommended",
"plugin:jest/recommended" "plugin:jest/recommended"
], ],
"rules": { "rules": {},
"no-unused-expressions": [
"error",
{
"allowTernary": true
}
],
"no-console": "warn",
"no-underscore-dangle": [
"warn",
{
"allow": ["_id"]
}
],
"func-names": "off",
"node/no-unsupported-features/es-syntax": [
"error",
{
"version": ">=10.0.0",
"ignores": []
}
],
"node/no-unsupported-features/es-builtins": [
"error",
{
"version": ">=10.0.0",
"ignores": []
}
]
},
"parserOptions": { "parserOptions": {
"sourceType": "module" "sourceType": "module"
}, },

View File

@@ -1,19 +0,0 @@
image: node:10
stages:
- test-backend
cache:
paths:
- node_modules/
- client/node_modules
test-backend:
stage: test-backend
services:
- mongo:4
script:
- npm i npm@latest -g
- npm i
- npm test

View File

@@ -1,4 +1,5 @@
{ {
"singleQuote": true, "trailingComma": "all",
"trailingComma": "all" "tabWidth": 4,
"endOfLine": "auto"
} }

11
.vscode/settings.json vendored
View File

@@ -1,6 +1,11 @@
{ {
"editor.tabSize": 2, "eslint.workingDirectories": [
"prettier.eslintIntegration": true, ".",
"./client"
],
"search.exclude": {
"**/package-lock.json": true
},
"editor.insertSpaces": true, "editor.insertSpaces": true,
"jest.pathToJest": "npm test --" "editor.tabSize": 4
} }

119
app.js
View File

@@ -1,119 +0,0 @@
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const morgan = require('morgan');
const cors = require('cors');
const path = require('path');
const hsts = require('hsts');
const compression = require('compression');
const { redirectToHTTPS } = require('express-http-to-https');
const db = require('./config/db');
const config = require('./config');
require('./models/TodoList');
require('./models/User');
require('./models/Todo');
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(cors());
app.use(compression());
process.env.NODE_ENV === 'production'
? app.use(morgan('combined'))
: app.use(morgan('dev'));
if (process.env.NODE_ENV === 'production' && process.env.HSTS === 'true') {
app.use(redirectToHTTPS([/localhost:(\d{4})/]));
app.use(
hsts({
maxAge: 31536000,
includeSubDomains: true,
}),
);
}
const passport = require('./config/passport');
app.use(passport.initialize());
// Addresses, starting with /__, are not cached by service worker
// https://github.com/facebook/create-react-app/issues/2237
app.use('/__/users', require('./routes/users'));
const auth = require('./routes/auth');
app.use('/__/lists', auth.required, require('./routes/lists'));
app.use('/__/todos', auth.required, require('./routes/todos'));
if (
process.env.NODE_ENV === 'production' ||
process.env.NODE_ENV === 'development'
) {
app.use(express.static(path.join(__dirname, 'client/build')));
app.use('*', express.static(path.join(__dirname, 'client/build/index.html')));
}
// 404 route
app.use((req, res) => {
res.status(404);
if (req.accepts('html')) {
res.send('404');
return;
}
if (req.accepts('json')) {
res.send({ error: 'Not found' });
return;
}
res.type('txt').send('not found');
});
// handle errors
app.use((error, req, res, next) => {
if (error.status) {
res.status(error.status);
} else {
switch (error.name) {
case 'ValidationError':
case 'MissingPasswordError':
case 'BadRequest':
case 'BadRequestError':
res.status(400);
break;
case 'AuthenticationError':
case 'UnauthorizedError':
res.status(401);
break;
case 'NotFound':
res.status(404);
break;
default:
res.status(500);
}
}
res.json({ success: false, error });
if (
process.env.NODE_ENV === 'production' ||
process.env.NODE_ENV === 'test'
) {
console.error(error);
}
next(error);
});
let server;
if (process.env.NODE_ENV !== 'test') {
db.connect();
server = app.listen(config.app.port, () => {
console.log(`Listening on port ${config.app.port}`);
console.log('Started!');
});
} else {
server = app;
}
module.exports = server;

View File

@@ -1,3 +0,0 @@
module.exports = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};

View File

@@ -1,25 +1,22 @@
{ {
"extends": [ "root": true,
"eslint:recommended", "extends": [
"plugin:jest/recommended", "eslint:recommended",
"plugin:react/recommended" "plugin:jest/recommended",
], "plugin:react/recommended"
"rules": {
"react/jsx-filename-extension": [
1,
{
"extensions": [".js", ".jsx"]
}
], ],
"linebreak-style": "off", "rules": {
"react/forbid-prop-types": "off", "react/display-name": "warn"
"node/no-unsupported-features/es-syntax": ["off"], },
"node/no-unsupported-features/es-builtins": ["off"], "parserOptions": {
"node/no-unsupported-features/node-builtins": ["off"], "ecmaVersion": 2018,
"react/display-name": ["warn"], "ecmaFeatures": {
"no-console": "warn" "jsx": true
}, },
"env": { "sourceType": "module"
"browser": true },
} "env": {
"browser": true,
"node": true
}
} }

11283
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,28 +3,29 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@material-ui/core": "^4.11.0", "@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.9.1", "@material-ui/icons": "^4.11.2",
"@redux-offline/redux-offline": "^2.6.0", "@redux-offline/redux-offline": "^2.6.0",
"http-proxy-middleware": "^1.0.6", "http-proxy-middleware": "^1.0.6",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^16.13.1", "react": "^17.0.1",
"react-dom": "^16.13.1", "react-dom": "^17.0.1",
"react-loadable": "^5.5.0", "react-redux": "^7.2.2",
"react-redux": "^7.2.1",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-router-redux": "^4.0.8", "react-router-redux": "^4.0.8",
"react-scripts": "3.4.3", "react-scripts": "4.0.3",
"react-spring": "^5.0.0", "react-spring": "^8.0.27",
"redux": "^4.0.5", "redux": "^4.0.5",
"redux-form": "^8.3.6", "redux-form": "^8.3.7",
"redux-thunk": "^2.3.0" "redux-thunk": "^2.3.0"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"test": "react-scripts test --env=jsdom", "test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"lint": "eslint ./src/** --ext .js,.jsx,.ts,.tsx",
"lint-fix": "eslint ./src/** --ext .js,.jsx,.ts,.tsx --fix"
}, },
"browserslist": [ "browserslist": [
">0.2%", ">0.2%",

View File

@@ -1,42 +1,42 @@
export const ADD_LIST = 'ADD_LIST'; export const ADD_LIST = "ADD_LIST";
export const REMOVE_LIST = 'REMOVE_LIST'; export const REMOVE_LIST = "REMOVE_LIST";
export const EDIT_LIST_NAME = 'EDIT_LIST_NAME'; export const EDIT_LIST_NAME = "EDIT_LIST_NAME";
export const RECIEVE_LISTS = 'RECIEVE_LISTS'; export const RECIEVE_LISTS = "RECIEVE_LISTS";
export const REQUEST_LISTS = 'REQUEST_LISTS'; export const REQUEST_LISTS = "REQUEST_LISTS";
export const INVALIDATE_LISTS = 'INVALIDATE_LISTS'; export const INVALIDATE_LISTS = "INVALIDATE_LISTS";
export const VALIDATE_LISTS = 'VALIDATE_LISTS'; export const VALIDATE_LISTS = "VALIDATE_LISTS";
export const CHANGE_LIST = 'CHANGE_LIST'; export const CHANGE_LIST = "CHANGE_LIST";
export const START_CREATE_LIST = 'START_CREATE_LIST'; export const START_CREATE_LIST = "START_CREATE_LIST";
export const START_EDIT_LIST = 'START_EDIT_LIST'; export const START_EDIT_LIST = "START_EDIT_LIST";
export const STOP_CREATE_LIST = 'STOP_CREATE_LIST'; export const STOP_CREATE_LIST = "STOP_CREATE_LIST";
export const STOP_EDIT_LIST = 'STOP_EDIT_LIST'; export const STOP_EDIT_LIST = "STOP_EDIT_LIST";
export const ADD_TODO = 'ADD_TODO'; export const ADD_TODO = "ADD_TODO";
export const REMOVE_TODO = 'REMOVE_TODO'; export const REMOVE_TODO = "REMOVE_TODO";
export const TOGGLE_TODO = 'TOGGLE_TODO'; export const TOGGLE_TODO = "TOGGLE_TODO";
export const EDIT_TODO = 'EDIT_TODO'; export const EDIT_TODO = "EDIT_TODO";
export const SET_VISIBILITY_FILTER = 'SET_VISIBILITY_FILTER'; export const SET_VISIBILITY_FILTER = "SET_VISIBILITY_FILTER";
export const RECIEVE_TODOS = 'RECIEVE_TODOS'; export const RECIEVE_TODOS = "RECIEVE_TODOS";
export const REQUEST_TODOS = 'REQUEST_TODOS'; export const REQUEST_TODOS = "REQUEST_TODOS";
export const INVALIDATE_TODOS = 'INVALIDATE_TODOS'; export const INVALIDATE_TODOS = "INVALIDATE_TODOS";
export const VALIDATE_TODOS = 'VALIDATE_TODOS'; export const VALIDATE_TODOS = "VALIDATE_TODOS";
export const VisibilityFilters = { export const VisibilityFilters = {
SHOW_ALL: 'SHOW_ALL', SHOW_ALL: "SHOW_ALL",
SHOW_COMPLETED: 'SHOW_COMPLETED', SHOW_COMPLETED: "SHOW_COMPLETED",
SHOW_ACTIVE: 'SHOW_ACTIVE', SHOW_ACTIVE: "SHOW_ACTIVE",
}; };
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; export const LOGIN_SUCCESS = "LOGIN_SUCCESS";
export const LOGIN_FAIL = 'LOGIN_FAIL'; export const LOGIN_FAIL = "LOGIN_FAIL";
export const SIGNUP_SUCCESS = 'SIGNUP_SUCCESS'; export const SIGNUP_SUCCESS = "SIGNUP_SUCCESS";
export const SIGNUP_FAIL = 'SIGNUP_FAIL'; export const SIGNUP_FAIL = "SIGNUP_FAIL";
export const LOGOUT = 'LOGOUT'; export const LOGOUT = "LOGOUT";
export const START_LOGIN = 'INVALIDATE_USER'; export const START_LOGIN = "INVALIDATE_USER";
export const REQUEST_USER = 'REQUEST_USER'; export const REQUEST_USER = "REQUEST_USER";
export const VALIDATE_USER = 'VALIDATE_USER'; export const VALIDATE_USER = "VALIDATE_USER";
export const RESET_USER = 'RESET_USER'; export const RESET_USER = "RESET_USER";
export const EDIT_START = 'EDIT_START'; export const EDIT_START = "EDIT_START";
export const EDIT_SUCCESS = 'EDIT_SUCCESS'; export const EDIT_SUCCESS = "EDIT_SUCCESS";
export const EDIT_FAIL = 'EDIT_FAIL'; export const EDIT_FAIL = "EDIT_FAIL";
export const RESET_EDIT = 'RESET_EDIT'; export const RESET_EDIT = "RESET_EDIT";

View File

@@ -1,162 +1,162 @@
import { import {
REQUEST_LISTS, REQUEST_LISTS,
RECIEVE_LISTS, RECIEVE_LISTS,
CHANGE_LIST, CHANGE_LIST,
START_CREATE_LIST, START_CREATE_LIST,
START_EDIT_LIST, START_EDIT_LIST,
STOP_CREATE_LIST, STOP_CREATE_LIST,
STOP_EDIT_LIST, STOP_EDIT_LIST,
ADD_LIST, ADD_LIST,
INVALIDATE_LISTS, INVALIDATE_LISTS,
REMOVE_LIST, REMOVE_LIST,
EDIT_LIST_NAME, EDIT_LIST_NAME,
RECIEVE_TODOS, RECIEVE_TODOS,
} from './defs'; } from "./defs";
import { API_ROOT, getToken, mongoObjectId } from './util'; import { API_ROOT, getToken, mongoObjectId } from "./util";
function requestLists() { function requestLists() {
return { type: REQUEST_LISTS }; return { type: REQUEST_LISTS };
} }
function recieveLists(lists) { function recieveLists(lists) {
return { type: RECIEVE_LISTS, lists }; return { type: RECIEVE_LISTS, lists };
} }
export function changeList(list) { export function changeList(list) {
return { type: CHANGE_LIST, list }; return { type: CHANGE_LIST, list };
} }
export function startCreateList() { export function startCreateList() {
return { type: START_CREATE_LIST }; return { type: START_CREATE_LIST };
} }
export function startEditList() { export function startEditList() {
return { type: START_EDIT_LIST }; return { type: START_EDIT_LIST };
} }
export function stopCreateList() { export function stopCreateList() {
return { type: STOP_CREATE_LIST }; return { type: STOP_CREATE_LIST };
} }
export function stopEditList() { export function stopEditList() {
return { type: STOP_EDIT_LIST }; return { type: STOP_EDIT_LIST };
} }
export function addList(name) { export function addList(name) {
return async dispatch => { return async (dispatch) => {
const id = mongoObjectId(); const id = mongoObjectId();
dispatch({ dispatch({
type: ADD_LIST, type: ADD_LIST,
list: { list: {
name, name,
id, id,
todos: [], todos: [],
},
meta: {
offline: {
effect: {
url: `${API_ROOT}/lists`,
body: JSON.stringify({ name, id }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
}, },
method: 'POST', meta: {
}, offline: {
rollback: { effect: {
type: INVALIDATE_LISTS, url: `${API_ROOT}/lists`,
}, body: JSON.stringify({ name, id }),
}, headers: {
}, Authorization: `Bearer ${getToken()}`,
}); "content-type": "application/json",
}; },
method: "POST",
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
} }
export function removeList() { export function removeList() {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const { list } = state.lists; const { list } = state.lists;
dispatch({ dispatch({
type: REMOVE_LIST, type: REMOVE_LIST,
list, list,
meta: { meta: {
offline: { offline: {
effect: { effect: {
url: `${API_ROOT}/lists/${list}`, url: `${API_ROOT}/lists/${list}`,
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json', "content-type": "application/json",
},
method: "DELETE",
},
rollback: {
type: INVALIDATE_LISTS,
},
},
}, },
method: 'DELETE', });
}, };
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
} }
export function editList(name) { export function editList(name) {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const { list } = state.lists; const { list } = state.lists;
dispatch({ dispatch({
type: EDIT_LIST_NAME, type: EDIT_LIST_NAME,
list, list,
name, name,
meta: { meta: {
offline: { offline: {
effect: { effect: {
url: `${API_ROOT}/lists/${list}`, url: `${API_ROOT}/lists/${list}`,
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json', "content-type": "application/json",
},
method: "PATCH",
},
rollback: {
type: INVALIDATE_LISTS,
},
},
}, },
method: 'PATCH', });
}, };
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
} }
function normalizeTodos(lists) { function normalizeTodos(lists) {
return lists.reduce((todos, list) => { return lists.reduce((todos, list) => {
const listTodosObj = list.todos.reduce( const listTodosObj = list.todos.reduce(
(listTodos, todo) => ({ (listTodos, todo) => ({
...listTodos, ...listTodos,
[todo.id]: { ...todo }, [todo.id]: { ...todo },
}), }),
{}, {},
); );
return { ...todos, ...listTodosObj }; return { ...todos, ...listTodosObj };
}, {}); }, {});
} }
export function fetchLists() { export function fetchLists() {
return async dispatch => { return async (dispatch) => {
dispatch(requestLists()); dispatch(requestLists());
const response = await fetch(`${API_ROOT}/lists`, { const response = await fetch(`${API_ROOT}/lists`, {
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
}, },
}); });
const json = await response.json(); const json = await response.json();
const lists = json.data; const lists = json.data;
const listsObj = lists.reduce((obj, curList) => { const listsObj = lists.reduce((obj, curList) => {
const newObj = { ...obj }; const newObj = { ...obj };
newObj[curList.id] = { newObj[curList.id] = {
dirty: true, dirty: true,
fetching: false, fetching: false,
editing: false, editing: false,
...curList, ...curList,
todos: curList.todos.map(todo => todo.id), todos: curList.todos.map((todo) => todo.id),
}; };
return newObj; return newObj;
}, {}); }, {});
dispatch({ type: RECIEVE_TODOS, todos: normalizeTodos(lists) }); dispatch({ type: RECIEVE_TODOS, todos: normalizeTodos(lists) });
dispatch(recieveLists(listsObj)); dispatch(recieveLists(listsObj));
}; };
} }

View File

@@ -1,137 +1,137 @@
import { import {
REQUEST_TODOS, REQUEST_TODOS,
RECIEVE_TODOS, RECIEVE_TODOS,
ADD_TODO, ADD_TODO,
REMOVE_TODO, REMOVE_TODO,
TOGGLE_TODO, TOGGLE_TODO,
EDIT_TODO, EDIT_TODO,
INVALIDATE_LISTS, INVALIDATE_LISTS,
} from './defs'; } from "./defs";
import { API_ROOT, getToken, mongoObjectId } from './util'; import { API_ROOT, getToken, mongoObjectId } from "./util";
export function fetchTodos() { export function fetchTodos() {
return async dispatch => { return async (dispatch) => {
dispatch({ type: REQUEST_TODOS }); dispatch({ type: REQUEST_TODOS });
const response = await fetch(`${API_ROOT}/todos`, { const response = await fetch(`${API_ROOT}/todos`, {
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
}, },
}); });
const json = await response.json(); const json = await response.json();
const todos = json.data; const todos = json.data;
dispatch({ type: RECIEVE_TODOS, todos }); dispatch({ type: RECIEVE_TODOS, todos });
}; };
} }
export function addTodo(text) { export function addTodo(text) {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const { list } = state.lists; const { list } = state.lists;
const id = mongoObjectId(); const id = mongoObjectId();
if (list) { if (list) {
dispatch({ dispatch({
type: ADD_TODO, type: ADD_TODO,
todo: { todo: {
text, text,
id, id,
completed: false, completed: false,
}, },
meta: { meta: {
offline: { offline: {
effect: { effect: {
url: `${API_ROOT}/lists/${list}/todos`, url: `${API_ROOT}/lists/${list}/todos`,
body: JSON.stringify({ text, id }), body: JSON.stringify({ text, id }),
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json', "content-type": "application/json",
}, },
method: 'POST', method: "POST",
}, },
rollback: { rollback: {
type: INVALIDATE_LISTS, type: INVALIDATE_LISTS,
}, },
}, },
}, },
}); });
} }
}; };
} }
export function removeTodo(id) { export function removeTodo(id) {
return async dispatch => { return async (dispatch) => {
dispatch({ dispatch({
type: REMOVE_TODO, type: REMOVE_TODO,
id, id,
meta: { meta: {
offline: { offline: {
effect: { effect: {
url: `${API_ROOT}/todos/${id}`, url: `${API_ROOT}/todos/${id}`,
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json', "content-type": "application/json",
},
method: "DELETE",
},
rollback: {
type: INVALIDATE_LISTS,
},
},
}, },
method: 'DELETE', });
}, };
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
} }
export function toggleTodo(id) { export function toggleTodo(id) {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
const todoObj = state.todos.todos[id]; const todoObj = state.todos.todos[id];
const completed = !todoObj.completed; const completed = !todoObj.completed;
dispatch({ dispatch({
type: TOGGLE_TODO, type: TOGGLE_TODO,
id, id,
meta: { meta: {
offline: { offline: {
effect: { effect: {
url: `${API_ROOT}/todos/${id}`, url: `${API_ROOT}/todos/${id}`,
body: JSON.stringify({ completed }), body: JSON.stringify({ completed }),
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json', "content-type": "application/json",
},
method: "PATCH",
},
rollback: {
type: INVALIDATE_LISTS,
},
},
}, },
method: 'PATCH', });
}, };
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
} }
export function editTodo(id, text) { export function editTodo(id, text) {
return async dispatch => { return async (dispatch) => {
dispatch({ dispatch({
type: EDIT_TODO, type: EDIT_TODO,
id, id,
text, text,
meta: { meta: {
offline: { offline: {
effect: { effect: {
url: `${API_ROOT}/todos/${id}`, url: `${API_ROOT}/todos/${id}`,
body: JSON.stringify({ text }), body: JSON.stringify({ text }),
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json', "content-type": "application/json",
},
method: "PATCH",
},
rollback: {
type: INVALIDATE_LISTS,
},
},
}, },
method: 'PATCH', });
}, };
rollback: {
type: INVALIDATE_LISTS,
},
},
},
});
};
} }

View File

@@ -1,188 +1,186 @@
import { import {
START_LOGIN, START_LOGIN,
LOGIN_SUCCESS, LOGIN_SUCCESS,
LOGIN_FAIL, LOGIN_FAIL,
VALIDATE_USER, VALIDATE_USER,
SIGNUP_SUCCESS, SIGNUP_SUCCESS,
SIGNUP_FAIL, SIGNUP_FAIL,
RESET_USER, RESET_USER,
LOGOUT, LOGOUT,
EDIT_START, EDIT_START,
EDIT_SUCCESS, EDIT_SUCCESS,
EDIT_FAIL, EDIT_FAIL,
RESET_EDIT, RESET_EDIT,
} from './defs'; } from "./defs";
import { API_ROOT, getToken, setToken } from './util'; import { API_ROOT, getToken, setToken } from "./util";
import { fetchLists } from './lists'; import { fetchLists } from "./lists";
function startLogin() { function startLogin() {
return { type: START_LOGIN }; return { type: START_LOGIN };
} }
function loginSuccess(user) { function loginSuccess(user) {
return { type: LOGIN_SUCCESS, user }; return { type: LOGIN_SUCCESS, user };
} }
function loginFail(error) { function loginFail(error) {
return { type: LOGIN_FAIL, error }; return { type: LOGIN_FAIL, error };
} }
function validateUser() { function validateUser() {
return { type: VALIDATE_USER }; return { type: VALIDATE_USER };
} }
export function loadUser() { export function loadUser() {
return async dispatch => { return async (dispatch) => {
if (getToken()) { if (getToken()) {
const response = await fetch(`${API_ROOT}/users/user`, { const response = await fetch(`${API_ROOT}/users/user`, {
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json', "content-type": "application/json",
}, },
method: 'GET', method: "GET",
}); });
const json = await response.json(); const json = await response.json();
if (json.success) { if (json.success) {
dispatch(loginSuccess(json.data)); dispatch(loginSuccess(json.data));
dispatch(fetchLists()); dispatch(fetchLists());
} else { } else {
dispatch(loginFail(json.error)); dispatch(loginFail(json.error));
} }
} else { } else {
dispatch(validateUser()); dispatch(validateUser());
} }
}; };
} }
export function login(user) { export function login(user) {
return async dispatch => { return async (dispatch) => {
dispatch(startLogin()); dispatch(startLogin());
const response = await fetch(`${API_ROOT}/users/login`, { const response = await fetch(`${API_ROOT}/users/login`, {
body: JSON.stringify(user), body: JSON.stringify(user),
headers: { headers: {
'content-type': 'application/json', "content-type": "application/json",
}, },
method: 'POST', method: "POST",
}); });
const json = await response.json(); const json = await response.json();
if (json.success) { if (json.success) {
setToken(json.data.jwt); setToken(json.data.jwt);
dispatch(loginSuccess(json.data)); dispatch(loginSuccess(json.data));
dispatch(fetchLists()); dispatch(fetchLists());
} else { } else {
dispatch(loginFail(json.error)); dispatch(loginFail(json.error));
} }
}; };
} }
export function loginJWT(jwt) { export function loginJWT(jwt) {
return async dispatch => { return async (dispatch) => {
dispatch(startLogin()); dispatch(startLogin());
const response = await fetch(`${API_ROOT}/users/user`, { const response = await fetch(`${API_ROOT}/users/user`, {
headers: { headers: {
'content-type': 'application/json', "content-type": "application/json",
Authorization: `Bearer ${jwt}`, Authorization: `Bearer ${jwt}`,
}, },
method: 'GET', method: "GET",
}); });
const json = await response.json(); const json = await response.json();
if (json.success) { if (json.success) {
setToken(jwt); setToken(jwt);
dispatch(loginSuccess(json.data)); dispatch(loginSuccess(json.data));
dispatch(fetchLists()); dispatch(fetchLists());
} else { } else {
dispatch(loginFail(json.error)); dispatch(loginFail(json.error));
} }
}; };
} }
function signupSuccess(user) { function signupSuccess(user) {
return { type: SIGNUP_SUCCESS, user }; return { type: SIGNUP_SUCCESS, user };
} }
function signupFail(error) { function signupFail(error) {
return { type: SIGNUP_FAIL, error }; return { type: SIGNUP_FAIL, error };
} }
export function signup(user) { export function signup(user) {
return async dispatch => { return async (dispatch) => {
dispatch(startLogin()); dispatch(startLogin());
const response = await fetch(`${API_ROOT}/users`, { const response = await fetch(`${API_ROOT}/users`, {
body: JSON.stringify(user), body: JSON.stringify(user),
headers: { headers: {
'content-type': 'application/json', "content-type": "application/json",
}, },
method: 'POST', method: "POST",
}); });
const json = await response.json(); const json = await response.json();
if (json.success) { if (json.success) {
setToken(json.data.jwt); setToken(json.data.jwt);
dispatch(signupSuccess(json.data)); dispatch(signupSuccess(json.data));
dispatch(fetchLists()); dispatch(fetchLists());
} else { } else {
dispatch(signupFail(json.error)); dispatch(signupFail(json.error));
} }
}; };
} }
function startEdit(user) { function startEdit(user) {
return { type: EDIT_START, user }; return { type: EDIT_START, user };
} }
function editSuccess(user) { function editSuccess(user) {
return { type: EDIT_SUCCESS, user }; return { type: EDIT_SUCCESS, user };
} }
function editFail(error) { function editFail(error) {
return { type: EDIT_FAIL, error }; return { type: EDIT_FAIL, error };
} }
export function edit(user) { export function edit(user) {
return async dispatch => { return async (dispatch) => {
dispatch(startEdit()); dispatch(startEdit());
const response = await fetch(`${API_ROOT}/users/user`, { const response = await fetch(`${API_ROOT}/users/user`, {
body: JSON.stringify(user), body: JSON.stringify(user),
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json', "content-type": "application/json",
}, },
method: 'PATCH', method: "PATCH",
}); });
const json = await response.json(); const json = await response.json();
if (json.success) { if (json.success) {
dispatch(editSuccess(json.data)); dispatch(editSuccess(json.data));
} else { } else {
dispatch(editFail(json.error)); dispatch(editFail(json.error));
} }
}; };
} }
export function deleteUser() { export function deleteUser() {
return async dispatch => { return async (dispatch) => {
await fetch(`${API_ROOT}/users/user`, { await fetch(`${API_ROOT}/users/user`, {
headers: { headers: {
Authorization: `Bearer ${getToken()}`, Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json', "content-type": "application/json",
}, },
method: 'DELETE', method: "DELETE",
}); });
dispatch(reset()); dispatch(reset());
}; };
} }
export function resetEdit() { export function resetEdit() {
return { type: RESET_EDIT }; return { type: RESET_EDIT };
} }
export function reset() { export function reset() {
return { type: RESET_USER }; return { type: RESET_USER };
} }
export function logout() { export function logout() {
return async dispatch => { return async (dispatch) => {
dispatch({ type: LOGOUT }); dispatch({ type: LOGOUT });
}; };
} }

View File

@@ -1,23 +1,23 @@
export const API_ROOT = '/__'; export const API_ROOT = "/__";
let token = null; let token = null;
export function setToken(_token) { export function setToken(_token) {
token = _token; token = _token;
} }
export function getToken() { export function getToken() {
return token; return token;
} }
export function mongoObjectId() { export function mongoObjectId() {
// eslint-disable-next-line // eslint-disable-next-line
const timestamp = ((new Date().getTime() / 1000) | 0).toString(16); const timestamp = ((new Date().getTime() / 1000) | 0).toString(16);
return ( return (
timestamp + timestamp +
'xxxxxxxxxxxxxxxx' "xxxxxxxxxxxxxxxx"
// eslint-disable-next-line // eslint-disable-next-line
.replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16)) .replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16))
.toLowerCase() .toLowerCase()
); );
} }

View File

@@ -1,5 +1,5 @@
import { SET_VISIBILITY_FILTER } from './defs'; import { SET_VISIBILITY_FILTER } from "./defs";
export default function setVisibilityFilter(filter) { export default function setVisibilityFilter(filter) {
return { type: SET_VISIBILITY_FILTER, filter }; return { type: SET_VISIBILITY_FILTER, filter };
} }

View File

@@ -1,246 +1,246 @@
#lists-header { #lists-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
background-color: #fbfbfb; background-color: #fbfbfb;
} }
#lists { #lists {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-grow: 1; flex-grow: 1;
width: 80%; width: 80%;
margin-right: 1rem; margin-right: 1rem;
height: 100px; height: 100px;
} }
.loading { .loading {
align-self: center; align-self: center;
margin-left: 2rem; margin-left: 2rem;
} }
#listactions { #listactions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
margin: 0.2rem 0.1rem; margin: 0.2rem 0.1rem;
margin-left: 0.5rem; margin-left: 0.5rem;
} }
#listactions button { #listactions button {
color: #555555; color: #555555;
background: none; background: none;
border: none; border: none;
margin: 0.1rem 0.3rem; margin: 0.1rem 0.3rem;
padding: 0.3rem 0.7em; padding: 0.3rem 0.7em;
} }
#listactions .backbutton { #listactions .backbutton {
margin: 1rem 0; margin: 1rem 0;
} }
#filters { #filters {
display: flex; display: flex;
justify-content: center; justify-content: center;
margin: 0; margin: 0;
align-self: center; align-self: center;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
position: sticky; position: sticky;
z-index: 5; z-index: 5;
} }
#filters button { #filters button {
min-width: 3rem; min-width: 3rem;
} }
#inputs { #inputs {
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 7px rgba(0, 0, 0, 0.1);
display: flex; display: flex;
height: 2.5rem; height: 2.5rem;
position: relative; position: relative;
z-index: 10; z-index: 10;
} }
#input { #input {
color: black; color: black;
background: white; background: white;
font-family: 'Roboto'; font-family: "Roboto";
box-sizing: border-box; box-sizing: border-box;
font-size: 1rem; font-size: 1rem;
flex-grow: 1; flex-grow: 1;
margin: 0; margin: 0;
padding: 0; padding: 0;
padding-left: 1rem; padding-left: 1rem;
height: 100%; height: 100%;
line-height: 100%; line-height: 100%;
border: none; border: none;
} }
#input::placeholder { #input::placeholder {
opacity: 0.35; opacity: 0.35;
} }
#input:focus::placeholder { #input:focus::placeholder {
opacity: 0; opacity: 0;
} }
#add { #add {
color: black; color: black;
flex-grow: 0; flex-grow: 0;
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
max-width: 2rem; max-width: 2rem;
background-color: white; background-color: white;
border: none; border: none;
} }
li:first-child .delete, li:first-child .delete,
li:first-child .edit, li:first-child .edit,
li:first-child .save, li:first-child .save,
li:first-child .todo { li:first-child .todo {
border-top: none; border-top: none;
} }
.done { .done {
color: rgba(0, 0, 0, 0.6); color: rgba(0, 0, 0, 0.6);
text-decoration: line-through; text-decoration: line-through;
} }
li button { li button {
color: black; color: black;
outline: none; outline: none;
text-align: left; text-align: left;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
text-align: center; text-align: center;
border: none; border: none;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
background: none; background: none;
width: 2rem; width: 2rem;
font-size: 1rem; font-size: 1rem;
transition: 0.17s ease-in-out; transition: 0.17s ease-in-out;
overflow: hidden; overflow: hidden;
box-shadow: inset 3px 0 6px -3px rgba(0, 0, 0, 0.3); box-shadow: inset 3px 0 6px -3px rgba(0, 0, 0, 0.3);
} }
/* make it usable on smartphones */ /* make it usable on smartphones */
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
#header { #header {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
background-color: white; background-color: white;
z-index: 5; z-index: 5;
width: 100%; width: 100%;
} }
#inputs { #inputs {
position: fixed; position: fixed;
top: 8rem; top: 8rem;
left: 0; left: 0;
background-color: white; background-color: white;
z-index: 10; z-index: 10;
width: 100%; width: 100%;
height: 65px; height: 65px;
max-height: 65px !important; max-height: 65px !important;
} }
#container { #container {
margin-top: 12rem; margin-top: 12rem;
border-top-left-radius: 0; border-top-left-radius: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
margin-bottom: 3rem; margin-bottom: 3rem;
} }
#filters { #filters {
position: fixed; position: fixed;
left: 0; left: 0;
bottom: 0; bottom: 0;
width: 100%; width: 100%;
max-height: 3rem !important; max-height: 3rem !important;
opacity: 1 !important; opacity: 1 !important;
background-color: white; background-color: white;
} }
#filters button { #filters button {
height: 3rem !important; height: 3rem !important;
padding: 0 1.5rem !important; padding: 0 1.5rem !important;
} }
li button { li button {
padding: 0 1.5rem !important; padding: 0 1.5rem !important;
} }
} }
li button.todo { li button.todo {
padding: 0.5rem; padding: 0.5rem;
box-shadow: none; box-shadow: none;
} }
ul { ul {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
li { li {
height: 60px; height: 60px;
width: 100%; width: 100%;
font-weight: 500; font-weight: 500;
overflow: hidden; overflow: hidden;
font-size: 1rem; font-size: 1rem;
display: flex; display: flex;
} }
.todo { .todo {
text-align: left; text-align: left;
box-sizing: border-box; box-sizing: border-box;
word-wrap: break-word; word-wrap: break-word;
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.5rem;
padding-left: 1rem; padding-left: 1rem;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
font-weight: 400; font-weight: 400;
flex-grow: 2; flex-grow: 2;
flex-shrink: 1; flex-shrink: 1;
transition: 0.1s ease-in-out; transition: 0.1s ease-in-out;
font-family: Roboto; font-family: Roboto;
} }
textarea.todo--input { textarea.todo--input {
background: #fafafa; background: #fafafa;
color: black; color: black;
resize: none; resize: none;
border: none; border: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
word-wrap: break-word; word-wrap: break-word;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 100%; max-height: 100%;
font-family: 'Roboto'; font-family: "Roboto";
font-size: 1rem; font-size: 1rem;
transition: 0.1s ease-in-out; transition: 0.1s ease-in-out;
} }
.disabled { .disabled {
width: 2rem; width: 2rem;
background-color: #fafafa; background-color: #fafafa;
color: #555555; color: #555555;
box-shadow: none; box-shadow: none;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
.filter { .filter {
margin: 0.1rem; margin: 0.1rem;
color: #555555; color: #555555;
border: none; border: none;
background: none; background: none;
transition: 0.1s ease-in-out; transition: 0.1s ease-in-out;
font-size: 1rem; font-size: 1rem;
font-weight: 400; font-weight: 400;
font-family: Roboto; font-family: Roboto;
} }
.filter--active { .filter--active {
font-weight: 500; font-weight: 500;
color: black; color: black;
} }

View File

@@ -1,83 +1,48 @@
import * as React from 'react'; import * as React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { BrowserRouter as Router, Route } from 'react-router-dom'; import { BrowserRouter as Router, Route } from "react-router-dom";
import CssBaseline from '@material-ui/core/CssBaseline'; import CssBaseline from "@material-ui/core/CssBaseline";
import Loadable from 'react-loadable';
import Protected from './Protected'; import Protected from "./Protected";
import OnlyUnauth from './OnlyUnauth'; import OnlyUnauth from "./OnlyUnauth";
import './Container.css'; import "./Container.css";
import './App.css'; import "./App.css";
import TodosView from "./todolist/TodosView";
import LoginForm from "./user/LoginForm";
import SignupForm from "./user/SignupForm";
import EditForm from "./user/EditForm";
function Loading(props) { const ProtectedTodosView = Protected(TodosView);
if (props.error) {
return (
<div>
Error! <button onClick={props.retry}>Retry</button>
</div>
);
} else if (props.pastDelay) {
return <div>Loading...</div>;
} else {
return null;
}
}
const LoadableTodosView = Protected( const ProtectedLoginForm = OnlyUnauth(LoginForm);
Loadable({
loader: () => import('./todolist/TodosView'),
loading: () => Loading,
delay: 1000,
}),
);
const LoadableLoginForm = OnlyUnauth( const ProtectedSignupForm = OnlyUnauth(SignupForm);
Loadable({
loader: () => import('./user/LoginForm'),
loading: () => Loading,
delay: 1000,
}),
);
const LoadableSignupForm = OnlyUnauth( const ProtectedEditView = Protected(EditForm);
Loadable({
loader: () => import('./user/SignupForm'),
loading: () => Loading,
delay: 1000,
}),
);
const LoadableEditView = Protected(
Loadable({
loader: () => import('./user/EditForm'),
loading: () => Loading,
delay: 1000,
}),
);
export default class App extends React.PureComponent { export default class App extends React.PureComponent {
componentDidMount() { componentDidMount() {
const { loadUser } = this.props; const { loadUser } = this.props;
loadUser(); loadUser();
} }
render() { render() {
return ( return (
<React.Fragment> <React.Fragment>
<CssBaseline /> <CssBaseline />
<Router> <Router>
<div id="container"> <div id="container">
<Route exact path="/" component={LoadableTodosView} /> <Route exact path="/" component={ProtectedTodosView} />
<Route path="/login" component={LoadableLoginForm} /> <Route path="/login" component={ProtectedLoginForm} />
<Route path="/signup" component={LoadableSignupForm} /> <Route path="/signup" component={ProtectedSignupForm} />
<Route path="/edit" component={LoadableEditView} /> <Route path="/edit" component={ProtectedEditView} />
</div> </div>
</Router> </Router>
</React.Fragment> </React.Fragment>
); );
} }
} }
App.propTypes = { App.propTypes = {
loadUser: PropTypes.func.isRequired, loadUser: PropTypes.func.isRequired,
}; };

View File

@@ -1,22 +1,19 @@
import { connect } from 'react-redux'; import { connect } from "react-redux";
import App from './App'; import App from "./App";
import { loadUser } from '../actions/user'; import { loadUser } from "../actions/user";
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
user: state.user, user: state.user,
}; };
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
loadUser: () => dispatch(loadUser()), loadUser: () => dispatch(loadUser()),
}; };
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(App);
mapStateToProps,
mapDispatchToProps,
)(App);

View File

@@ -1,60 +1,60 @@
@import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500|Material+Icons'); @import url("https://fonts.googleapis.com/css?family=Roboto:300,400,500|Material+Icons");
body { body {
background: white; background: white;
color: black; color: black;
font-family: 'Roboto'; font-family: "Roboto";
user-select: none; user-select: none;
} }
#root { #root {
margin-top: 5rem; margin-top: 5rem;
} }
#container { #container {
overflow: hidden; overflow: hidden;
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0;
max-width: 25rem; max-width: 25rem;
border: 1px solid #dddddd; border: 1px solid #dddddd;
border-radius: 7px; border-radius: 7px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
} }
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
#root { #root {
margin: 0; margin: 0;
} }
#container { #container {
max-width: 100%; max-width: 100%;
} }
} }
#user-header { #user-header {
display: flex; display: flex;
height: 2rem; height: 2rem;
position: relative; position: relative;
z-index: 20; z-index: 20;
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 7px rgba(0, 0, 0, 0.1);
justify-content: flex-end; justify-content: flex-end;
align-content: center; align-content: center;
} }
#user-header button { #user-header button {
box-sizing: border-box; box-sizing: border-box;
margin-right: 0.2rem; margin-right: 0.2rem;
height: 100%; height: 100%;
flex-grow: 0; flex-grow: 0;
flex-shrink: 1; flex-shrink: 1;
color: #555555; color: #555555;
border: none; border: none;
background: none; background: none;
transition: 0.1s ease-in-out; transition: 0.1s ease-in-out;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 300; font-weight: 300;
font-family: Roboto; font-family: Roboto;
} }
#user-header button:hover { #user-header button:hover {
color: #555555; color: #555555;
} }

View File

@@ -1,12 +1,12 @@
import React from 'react'; import React from "react";
import UserHeader from './user/UserHeader'; import UserHeader from "./user/UserHeader";
import Lists from './lists/Lists'; import Lists from "./lists/Lists";
export default function Header() { export default function Header() {
return ( return (
<div id="header"> <div id="header">
<UserHeader /> <UserHeader />
<Lists /> <Lists />
</div> </div>
); );
} }

View File

@@ -1,25 +1,22 @@
import * as React from 'react'; import * as React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { Redirect } from 'react-router-dom'; import { Redirect } from "react-router-dom";
import { connect } from 'react-redux'; import { connect } from "react-redux";
export default function OnlyUnauth(WrappedComponent) { export default function OnlyUnauth(WrappedComponent) {
function Component({ loggedIn }) { function Component({ loggedIn }) {
return loggedIn ? <Redirect to="/" /> : <WrappedComponent />; return loggedIn ? <Redirect to="/" /> : <WrappedComponent />;
} }
Component.propTypes = { Component.propTypes = {
loggedIn: PropTypes.bool.isRequired, loggedIn: PropTypes.bool.isRequired,
};
function mapStateToProps(state) {
return {
loggedIn: state.user.user !== undefined && state.user.user !== null,
}; };
}
return connect( function mapStateToProps(state) {
mapStateToProps, return {
null, loggedIn: state.user.user !== undefined && state.user.user !== null,
)(Component); };
}
return connect(mapStateToProps, null)(Component);
} }

View File

@@ -1,25 +1,22 @@
import * as React from 'react'; import * as React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { Redirect } from 'react-router-dom'; import { Redirect } from "react-router-dom";
import { connect } from 'react-redux'; import { connect } from "react-redux";
export default function Protected(WrappedComponent) { export default function Protected(WrappedComponent) {
function Component({ loggedIn }) { function Component({ loggedIn }) {
return loggedIn ? <WrappedComponent /> : <Redirect to="/login" />; return loggedIn ? <WrappedComponent /> : <Redirect to="/login" />;
} }
Component.propTypes = { Component.propTypes = {
loggedIn: PropTypes.bool.isRequired, loggedIn: PropTypes.bool.isRequired,
};
function mapStateToProps(state) {
return {
loggedIn: state.user.user !== undefined && state.user.user !== null,
}; };
}
return connect( function mapStateToProps(state) {
mapStateToProps, return {
null, loggedIn: state.user.user !== undefined && state.user.user !== null,
)(Component); };
}
return connect(mapStateToProps, null)(Component);
} }

View File

@@ -1,51 +1,48 @@
import { connect } from 'react-redux'; import { connect } from "react-redux";
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { ButtonBase } from '@material-ui/core'; import { ButtonBase } from "@material-ui/core";
import setVisibilityFilter from '../../actions/visibilityFilter'; import setVisibilityFilter from "../../actions/visibilityFilter";
function Link({ active, onClick, children }) { function Link({ active, onClick, children }) {
const classes = ['filter']; const classes = ["filter"];
if (active) { if (active) {
classes.push('filter--active'); classes.push("filter--active");
} }
return ( return (
<ButtonBase <ButtonBase
style={{ style={{
padding: '0 1rem', padding: "0 1rem",
color: active ? 'black' : '#444444', color: active ? "black" : "#444444",
height: '2rem', height: "2rem",
}} }}
className={classes.join(' ')} className={classes.join(" ")}
onClick={e => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onClick(); onClick();
}} }}
> >
{children} {children}
</ButtonBase> </ButtonBase>
); );
} }
Link.propTypes = { Link.propTypes = {
active: PropTypes.bool.isRequired, active: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
}; };
function mapStateToProps(state, ownProps) { function mapStateToProps(state, ownProps) {
return { return {
active: ownProps.filter === state.visibilityFilter, active: ownProps.filter === state.visibilityFilter,
}; };
} }
function mapDispatchToProps(dispatch, ownProps) { function mapDispatchToProps(dispatch, ownProps) {
return { return {
onClick: () => dispatch(setVisibilityFilter(ownProps.filter)), onClick: () => dispatch(setVisibilityFilter(ownProps.filter)),
}; };
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(Link);
mapStateToProps,
mapDispatchToProps,
)(Link);

View File

@@ -1,17 +1,19 @@
import React from 'react'; import React from "react";
import FilterLink from './FilterLink'; import FilterLink from "./FilterLink";
import { VisibilityFilters } from '../../actions/defs'; import { VisibilityFilters } from "../../actions/defs";
function Filters(styles) { function Filters(styles) {
return ( return (
<div style={styles} id="filters"> <div style={styles} id="filters">
<FilterLink filter={VisibilityFilters.SHOW_ALL}>all</FilterLink> <FilterLink filter={VisibilityFilters.SHOW_ALL}>all</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_ACTIVE}>active</FilterLink> <FilterLink filter={VisibilityFilters.SHOW_ACTIVE}>
<FilterLink filter={VisibilityFilters.SHOW_COMPLETED}> active
completed </FilterLink>
</FilterLink> <FilterLink filter={VisibilityFilters.SHOW_COMPLETED}>
</div> completed
); </FilterLink>
</div>
);
} }
export default Filters; export default Filters;

View File

@@ -1,117 +1,117 @@
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from "@material-ui/icons/Delete";
import AddIcon from '@material-ui/icons/Add'; import AddIcon from "@material-ui/icons/Add";
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from "@material-ui/icons/Edit";
import BackButton from '@material-ui/icons/ArrowBack'; import BackButton from "@material-ui/icons/ArrowBack";
import { IconButton } from '@material-ui/core'; import { IconButton } from "@material-ui/core";
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { Transition, config } from 'react-spring'; import { Transition, config } from "react-spring";
const button = { const button = {
width: 30, width: 30,
height: 30, height: 30,
padding: 0, padding: 0,
}; };
const icon = { const icon = {
fontSize: 24, fontSize: 24,
}; };
export default function ListActions({ export default function ListActions({
startCreateList, startCreateList,
removeList, removeList,
startEditList, startEditList,
stopCreateList, stopCreateList,
stopEditList, stopEditList,
creating, creating,
editing, editing,
list, list,
}) { }) {
function back() { function back() {
if (editing) { if (editing) {
stopEditList(); stopEditList();
}
if (creating) {
stopCreateList();
}
} }
if (creating) { const actions = [];
stopCreateList(); if (!creating && !editing) {
actions.push((styles) => (
<IconButton
key="create"
style={{ ...button, ...styles }}
onClick={() => startCreateList()}
>
<AddIcon style={icon} />
</IconButton>
));
} }
} if (list && !creating && !editing) {
const actions = []; actions.push((styles) => (
if (!creating && !editing) { <IconButton
actions.push(styles => ( key="remove"
<IconButton style={{ ...button, ...styles }}
key="create" onClick={() => removeList()}
style={{ ...button, ...styles }} >
onClick={() => startCreateList()} <DeleteIcon style={icon} />
> </IconButton>
<AddIcon style={icon} /> ));
</IconButton> }
)); if (list && !creating && !editing) {
} actions.push((styles) => (
if (list && !creating && !editing) { <IconButton
actions.push(styles => ( key="edit"
<IconButton style={{ ...button, ...styles }}
key="remove" onClick={() => startEditList()}
style={{ ...button, ...styles }} >
onClick={() => removeList()} <EditIcon style={icon} />
> </IconButton>
<DeleteIcon style={icon} /> ));
</IconButton> }
)); if (creating || editing) {
} actions.push((styles) => (
if (list && !creating && !editing) { <IconButton
actions.push(styles => ( key="back"
<IconButton style={{ ...button, ...styles }}
key="edit" className="backbutton"
style={{ ...button, ...styles }} onClick={() => back()}
onClick={() => startEditList()} >
> <BackButton style={icon} />
<EditIcon style={icon} /> </IconButton>
</IconButton> ));
)); }
} return (
if (creating || editing) { <div id="listactions">
actions.push(styles => ( <Transition
<IconButton config={{
key="back" ...config.stiff,
style={{ ...button, ...styles }} overshootClamping: true,
className="backbutton" restSpeedThreshold: 0.5,
onClick={() => back()} restDisplacementThreshold: 0.5,
> }}
<BackButton style={icon} /> keys={actions.map((action) => action({}).key)}
</IconButton> from={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
)); enter={{ opacity: 1, height: 30, margin: 0, padding: 0 }}
} leave={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
return ( >
<div id="listactions"> {actions}
<Transition </Transition>
config={{ </div>
...config.stiff, );
overshootClamping: true,
restSpeedThreshold: 0.5,
restDisplacementThreshold: 0.5,
}}
keys={actions.map(action => action({}).key)}
from={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
enter={{ opacity: 1, height: 30, margin: 0, padding: 0 }}
leave={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
>
{actions}
</Transition>
</div>
);
} }
ListActions.defaultProps = { ListActions.defaultProps = {
list: '', list: "",
}; };
ListActions.propTypes = { ListActions.propTypes = {
startCreateList: PropTypes.func.isRequired, startCreateList: PropTypes.func.isRequired,
removeList: PropTypes.func.isRequired, removeList: PropTypes.func.isRequired,
startEditList: PropTypes.func.isRequired, startEditList: PropTypes.func.isRequired,
creating: PropTypes.bool.isRequired, creating: PropTypes.bool.isRequired,
editing: PropTypes.bool.isRequired, editing: PropTypes.bool.isRequired,
list: PropTypes.string, list: PropTypes.string,
stopCreateList: PropTypes.func.isRequired, stopCreateList: PropTypes.func.isRequired,
stopEditList: PropTypes.func.isRequired, stopEditList: PropTypes.func.isRequired,
}; };

View File

@@ -1,31 +1,28 @@
import { connect } from 'react-redux'; import { connect } from "react-redux";
import ListActions from './ListActions'; import ListActions from "./ListActions";
import { import {
startCreateList, startCreateList,
startEditList, startEditList,
removeList, removeList,
stopCreateList, stopCreateList,
stopEditList, stopEditList,
} from '../../actions/lists'; } from "../../actions/lists";
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
list: state.lists.list, list: state.lists.list,
creating: state.lists.creating, creating: state.lists.creating,
editing: state.lists.editing, editing: state.lists.editing,
}; };
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
startCreateList: () => dispatch(startCreateList()), startCreateList: () => dispatch(startCreateList()),
startEditList: () => dispatch(startEditList()), startEditList: () => dispatch(startEditList()),
stopCreateList: () => dispatch(stopCreateList()), stopCreateList: () => dispatch(stopCreateList()),
stopEditList: () => dispatch(stopEditList()), stopEditList: () => dispatch(stopEditList()),
removeList: () => dispatch(removeList()), removeList: () => dispatch(removeList()),
}; };
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(ListActions);
mapStateToProps,
mapDispatchToProps,
)(ListActions);

View File

@@ -1,43 +1,43 @@
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { IconButton } from '@material-ui/core'; import { IconButton } from "@material-ui/core";
const button = { const button = {
width: 36, width: 36,
height: 36, height: 36,
}; };
export default function ListInput({ onClick, children, defaultValue }) { export default function ListInput({ onClick, children, defaultValue }) {
let input; let input;
return ( return (
<div id="listselector" className="list--input"> <div id="listselector" className="list--input">
<input <input
ref={node => { ref={(node) => {
input = node; input = node;
}} }}
defaultValue={defaultValue} defaultValue={defaultValue}
style={{ height: 40 }} style={{ height: 40 }}
id="input" id="input"
type="text" type="text"
onKeyPress={e => { onKeyPress={(e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
onClick(input.value); onClick(input.value);
} }
}} }}
/> />
<IconButton <IconButton
style={button} style={button}
type="submit" type="submit"
onClick={() => input.value.trim() && onClick(input.value)} onClick={() => input.value.trim() && onClick(input.value)}
> >
{children} {children}
</IconButton> </IconButton>
</div> </div>
); );
} }
ListInput.propTypes = { ListInput.propTypes = {
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
children: PropTypes.element.isRequired, children: PropTypes.element.isRequired,
defaultValue: PropTypes.string, defaultValue: PropTypes.string,
}; };

View File

@@ -1,34 +1,33 @@
import { connect } from 'react-redux'; import { connect } from "react-redux";
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import ListActionsContainer from './ListActionsContainer'; import ListActionsContainer from "./ListActionsContainer";
import SelectorContainer from './SelectorContainer'; import SelectorContainer from "./SelectorContainer";
function Lists({ userLoaded, listsLoaded }) { function Lists({ userLoaded, listsLoaded }) {
return ( return (
<div id="lists-header"> <div id="lists-header">
{userLoaded && {userLoaded && listsLoaded && (
listsLoaded && ( <div id="lists">
<div id="lists"> <ListActionsContainer />
<ListActionsContainer /> <SelectorContainer />
<SelectorContainer /> </div>
</div> )}
)} </div>
</div> );
);
} }
Lists.propTypes = { Lists.propTypes = {
userLoaded: PropTypes.bool.isRequired, userLoaded: PropTypes.bool.isRequired,
listsLoaded: PropTypes.bool.isRequired, listsLoaded: PropTypes.bool.isRequired,
}; };
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
userLoaded: state.user.loaded, userLoaded: state.user.loaded,
listsLoaded: state.lists.loaded, listsLoaded: state.lists.loaded,
}; };
} }
export default connect(mapStateToProps)(Lists); export default connect(mapStateToProps)(Lists);

View File

@@ -1,53 +1,53 @@
#listselector { #listselector {
display: flex; display: flex;
margin-left: 1rem; margin-left: 1rem;
overflow: hidden; overflow: hidden;
align-self: center; align-self: center;
background-color: #fbfbfb; background-color: #fbfbfb;
flex-grow: 1; flex-grow: 1;
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
#listselector input { #listselector input {
padding: 0; padding: 0;
color: black; color: black;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 400; font-weight: 400;
outline: none; outline: none;
border: none; border: none;
background-color: #fbfbfb; background-color: #fbfbfb;
border-bottom: 1px solid #888888; border-bottom: 1px solid #888888;
width: 80%; width: 80%;
} }
#listselector button { #listselector button {
align-self: center; align-self: center;
width: 20%; width: 20%;
font-size: 0.9rem; font-size: 0.9rem;
color: #1b881b; color: #1b881b;
background: none; background: none;
border: none; border: none;
margin: 0.1rem 0.3rem; margin: 0.1rem 0.3rem;
padding: 0.3rem 0.7em; padding: 0.3rem 0.7em;
} }
#listselector select { #listselector select {
max-width: 100%; max-width: 100%;
color: black; color: black;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 400; font-weight: 400;
outline: none; outline: none;
border: none; border: none;
background-color: #fbfbfb; background-color: #fbfbfb;
} }
#listselector select option { #listselector select option {
max-width: 100%; max-width: 100%;
color: black; color: black;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 400; font-weight: 400;
outline: none; outline: none;
border: none; border: none;
background-color: #fbfbfb; background-color: #fbfbfb;
} }

View File

@@ -1,69 +1,69 @@
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { Select, MenuItem } from '@material-ui/core'; import { Select, MenuItem } from "@material-ui/core";
import AddIcon from '@material-ui/icons/Add'; import AddIcon from "@material-ui/icons/Add";
import CheckIcon from '@material-ui/icons/Check'; import CheckIcon from "@material-ui/icons/Check";
import ListInput from './ListInput'; import ListInput from "./ListInput";
import './Selector.css'; import "./Selector.css";
const icon = { const icon = {
fontSize: 24, fontSize: 24,
}; };
export default function Selector({ export default function Selector({
lists, lists,
list, list,
onChange, onChange,
editing, editing,
creating, creating,
addList, addList,
editList, editList,
}) { }) {
if (creating) { if (creating) {
return ( return (
<ListInput onClick={addList}> <ListInput onClick={addList}>
<AddIcon style={icon} /> <AddIcon style={icon} />
</ListInput> </ListInput>
); );
} }
if (editing) { if (editing) {
return ( return (
<ListInput onClick={editList} defaultValue={lists.lists[list].name}> <ListInput onClick={editList} defaultValue={lists.lists[list].name}>
<CheckIcon style={icon} /> <CheckIcon style={icon} />
</ListInput> </ListInput>
); );
} }
if (list) { if (list) {
return ( return (
<div id="listselector"> <div id="listselector">
<Select <Select
style={{ fontSize: '1.5rem', width: '100%', height: 40 }} style={{ fontSize: "1.5rem", width: "100%", height: 40 }}
value={list} value={list}
onChange={e => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
> >
{Object.values(lists.lists).map(elem => ( {Object.values(lists.lists).map((elem) => (
<MenuItem key={elem.id} value={elem.id}> <MenuItem key={elem.id} value={elem.id}>
{elem.name} {elem.name}
</MenuItem> </MenuItem>
))} ))}
</Select> </Select>
</div> </div>
); );
} }
return null; return null;
} }
Selector.defaultProps = { Selector.defaultProps = {
list: '', list: "",
}; };
Selector.propTypes = { Selector.propTypes = {
list: PropTypes.string, list: PropTypes.string,
editing: PropTypes.bool.isRequired, editing: PropTypes.bool.isRequired,
creating: PropTypes.bool.isRequired, creating: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
editList: PropTypes.func.isRequired, editList: PropTypes.func.isRequired,
addList: PropTypes.func.isRequired, addList: PropTypes.func.isRequired,
lists: PropTypes.object.isRequired, lists: PropTypes.object.isRequired,
}; };

View File

@@ -1,25 +1,22 @@
import { connect } from 'react-redux'; import { connect } from "react-redux";
import Selector from './Selector'; import Selector from "./Selector";
import { changeList, addList, editList } from '../../actions/lists'; import { changeList, addList, editList } from "../../actions/lists";
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
lists: state.lists, lists: state.lists,
list: state.lists.list, list: state.lists.list,
editing: state.lists.editing, editing: state.lists.editing,
creating: state.lists.creating, creating: state.lists.creating,
}; };
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
onChange: list => dispatch(changeList(list)), onChange: (list) => dispatch(changeList(list)),
addList: name => dispatch(addList(name)), addList: (name) => dispatch(addList(name)),
editList: name => dispatch(editList(name)), editList: (name) => dispatch(editList(name)),
}; };
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(Selector);
mapStateToProps,
mapDispatchToProps,
)(Selector);

View File

@@ -1,162 +1,168 @@
import * as React from 'react'; import * as React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { animated } from 'react-spring'; import { animated } from "react-spring";
import { ButtonBase } from '@material-ui/core'; import { ButtonBase } from "@material-ui/core";
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from "@material-ui/icons/Delete";
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from "@material-ui/icons/Edit";
import CheckIcon from '@material-ui/icons/Check'; import CheckIcon from "@material-ui/icons/Check";
const icon = { const icon = {
fontSize: 24, fontSize: 24,
padding: 0, padding: 0,
}; };
const disabledAction = { const disabledAction = {
backgroundColor: '#fafafa', backgroundColor: "#fafafa",
color: '#dddddd', color: "#dddddd",
}; };
class Todo extends React.Component { class Todo extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
hover: false, hover: false,
}; };
this.onMouseOver = this.onMouseOver.bind(this); this.onMouseOver = this.onMouseOver.bind(this);
this.onMouseOut = this.onMouseOut.bind(this); this.onMouseOut = this.onMouseOut.bind(this);
this.startEdit = this.startEdit.bind(this); this.startEdit = this.startEdit.bind(this);
this.stopEdit = this.stopEdit.bind(this); this.stopEdit = this.stopEdit.bind(this);
}
onMouseOver() {
const { state } = this;
this.setState({
...state,
hover: true,
});
}
onMouseOut() {
const { state } = this;
this.setState({
...state,
hover: false,
});
}
startEdit() {
const { state } = this;
this.setState({
...state,
editing: true,
});
}
stopEdit(value) {
const { editTodo } = this.props;
editTodo(value);
const { state } = this;
this.setState({
...state,
editing: false,
hover: false,
});
}
render() {
const deleteClasses = ['delete'];
const editClasses = ['edit'];
const { hover, editing } = this.state;
const { todo, removeTodo, toggleTodo, style } = this.props;
if (!hover) {
deleteClasses.push('disabled');
editClasses.push('disabled');
} }
let input; onMouseOver() {
const { state } = this;
this.setState({
...state,
hover: true,
});
}
const text = editing ? ( onMouseOut() {
<div className="todo"> const { state } = this;
<textarea this.setState({
className="todo--input" ...state,
defaultValue={todo.text} hover: false,
ref={node => { });
input = node; }
}}
/> startEdit() {
</div> const { state } = this;
) : ( this.setState({
<ButtonBase ...state,
style={{ editing: true,
justifyContent: 'left', });
paddingLeft: '1rem', }
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#888888' : 'black', stopEdit(value) {
}} const { editTodo } = this.props;
className="todo" editTodo(value);
onClick={() => { const { state } = this;
toggleTodo(); this.setState({
}} ...state,
> editing: false,
{todo.text} hover: false,
</ButtonBase> });
); }
const ButtonBases = editing
? [ render() {
<ButtonBase const deleteClasses = ["delete"];
key="save" const editClasses = ["edit"];
style={{ backgroundColor: 'lightgreen' }} const { hover, editing } = this.state;
className="save" const { todo, removeTodo, toggleTodo, style } = this.props;
onClick={() => this.stopEdit(input.value)} if (!hover) {
> deleteClasses.push("disabled");
<CheckIcon style={icon} /> editClasses.push("disabled");
</ButtonBase>, }
]
: [ let input;
<ButtonBase
key="remove" const text = editing ? (
style={hover ? { backgroundColor: 'pink' } : disabledAction} <div className="todo">
className={deleteClasses.join(' ')} <textarea
onClick={removeTodo} className="todo--input"
> defaultValue={todo.text}
<DeleteIcon style={icon} /> ref={(node) => {
</ButtonBase>, input = node;
<ButtonBase }}
key="edit" />
style={hover ? { backgroundColor: 'lightcyan' } : disabledAction} </div>
className={editClasses.join(' ')} ) : (
onClick={this.startEdit} <ButtonBase
> style={{
<EditIcon style={icon} /> justifyContent: "left",
</ButtonBase>, paddingLeft: "1rem",
]; textDecoration: todo.completed ? "line-through" : "none",
return ( color: todo.completed ? "#888888" : "black",
<animated.li }}
style={{ className="todo"
...style, onClick={() => {
borderTop: '1px solid #f0f0f0', toggleTodo();
}} }}
onMouseOver={this.onMouseOver} >
onFocus={this.onMouseOver} {todo.text}
onMouseOut={this.onMouseOut} </ButtonBase>
onBlur={this.onMouseOut} );
> const ButtonBases = editing
{text} ? [
{ButtonBases} <ButtonBase
</animated.li> key="save"
); style={{ backgroundColor: "lightgreen" }}
} className="save"
onClick={() => this.stopEdit(input.value)}
>
<CheckIcon style={icon} />
</ButtonBase>,
]
: [
<ButtonBase
key="remove"
style={
hover ? { backgroundColor: "pink" } : disabledAction
}
className={deleteClasses.join(" ")}
onClick={removeTodo}
>
<DeleteIcon style={icon} />
</ButtonBase>,
<ButtonBase
key="edit"
style={
hover
? { backgroundColor: "lightcyan" }
: disabledAction
}
className={editClasses.join(" ")}
onClick={this.startEdit}
>
<EditIcon style={icon} />
</ButtonBase>,
];
return (
<animated.li
style={{
...style,
borderTop: "1px solid #f0f0f0",
}}
onMouseOver={this.onMouseOver}
onFocus={this.onMouseOver}
onMouseOut={this.onMouseOut}
onBlur={this.onMouseOut}
>
{text}
{ButtonBases}
</animated.li>
);
}
} }
Todo.propTypes = { Todo.propTypes = {
todo: PropTypes.shape({ todo: PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired, text: PropTypes.string.isRequired,
completed: PropTypes.bool.isRequired, completed: PropTypes.bool.isRequired,
}).isRequired, }).isRequired,
removeTodo: PropTypes.func.isRequired, removeTodo: PropTypes.func.isRequired,
toggleTodo: PropTypes.func.isRequired, toggleTodo: PropTypes.func.isRequired,
editTodo: PropTypes.func.isRequired, editTodo: PropTypes.func.isRequired,
style: PropTypes.shape({ height: PropTypes.object.isRequired }).isRequired, style: PropTypes.shape({ height: PropTypes.object.isRequired }).isRequired,
}; };
export default Todo; export default Todo;

View File

@@ -1,71 +1,71 @@
import * as React from 'react'; import * as React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { Transition, config } from 'react-spring'; import { Transition, config } from "react-spring";
import Todo from './Todo'; import Todo from "./Todo";
export default function TodosContainer({ export default function TodosContainer({
todos, todos,
toggleTodo, toggleTodo,
removeTodo, removeTodo,
editTodo, editTodo,
}) { }) {
return ( return (
<ul id="list"> <ul id="list">
<Transition <Transition
native native
config={{ config={{
...config.default, ...config.default,
overshootClamping: true, overshootClamping: true,
restSpeedThreshold: 1, restSpeedThreshold: 1,
restDisplacementThreshold: 1, restDisplacementThreshold: 1,
}} }}
items={todos} items={todos}
keys={todo => todo.id} keys={(todo) => todo.id}
from={{ from={{
height: 0, height: 0,
borderColor: '#f0f0f0', borderColor: "#f0f0f0",
opacity: 0.7, opacity: 0.7,
}} }}
enter={{ enter={{
height: 60, height: 60,
borderColor: '#f0f0f0', borderColor: "#f0f0f0",
opacity: 1, opacity: 1,
}} }}
leave={{ leave={{
height: 0, height: 0,
borderColor: '#ffffff', borderColor: "#ffffff",
borderWidth: 0, borderWidth: 0,
opacity: 0.3, opacity: 0.3,
padding: 0, padding: 0,
margin: 0, margin: 0,
pointerEvents: 'none', pointerEvents: "none",
}} }}
> >
{todos.map(todo => styles => ( {todos.map((todo) => (styles) => (
<Todo <Todo
key={todo.id} key={todo.id}
todo={todo} todo={todo}
style={styles} style={styles}
toggleTodo={() => toggleTodo(todo.id)} toggleTodo={() => toggleTodo(todo.id)}
removeTodo={() => removeTodo(todo.id)} removeTodo={() => removeTodo(todo.id)}
editTodo={text => editTodo(todo.id, text)} editTodo={(text) => editTodo(todo.id, text)}
/> />
))} ))}
</Transition> </Transition>
</ul> </ul>
); );
} }
TodosContainer.propTypes = { TodosContainer.propTypes = {
todos: PropTypes.arrayOf( todos: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired, text: PropTypes.string.isRequired,
completed: PropTypes.bool.isRequired, completed: PropTypes.bool.isRequired,
}), }),
).isRequired, ).isRequired,
removeTodo: PropTypes.func.isRequired, removeTodo: PropTypes.func.isRequired,
toggleTodo: PropTypes.func.isRequired, toggleTodo: PropTypes.func.isRequired,
editTodo: PropTypes.func.isRequired, editTodo: PropTypes.func.isRequired,
}; };

View File

@@ -1,34 +1,31 @@
import { connect } from 'react-redux'; import { connect } from "react-redux";
import TodoList from './TodoList'; import TodoList from "./TodoList";
import { toggleTodo, removeTodo, editTodo } from '../../actions/todos'; import { toggleTodo, removeTodo, editTodo } from "../../actions/todos";
import getVisibleTodos from './getVisibleTodos'; import getVisibleTodos from "./getVisibleTodos";
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
todos: state.lists.list todos: state.lists.list
? getVisibleTodos( ? getVisibleTodos(
state.lists.lists[state.lists.list].todos.map( state.lists.lists[state.lists.list].todos.map(
id => state.todos.todos[id], (id) => state.todos.todos[id],
), ),
state.visibilityFilter, state.visibilityFilter,
) )
: [], : [],
dirty: state.todos.dirty, dirty: state.todos.dirty,
}; };
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
toggleTodo: id => dispatch(toggleTodo(id)), toggleTodo: (id) => dispatch(toggleTodo(id)),
removeTodo: id => dispatch(removeTodo(id)), removeTodo: (id) => dispatch(removeTodo(id)),
editTodo: (id, text) => dispatch(editTodo(id, text)), editTodo: (id, text) => dispatch(editTodo(id, text)),
}; };
} }
const TodosContainer = connect( const TodosContainer = connect(mapStateToProps, mapDispatchToProps)(TodoList);
mapStateToProps,
mapDispatchToProps,
)(TodoList);
export default TodosContainer; export default TodosContainer;

View File

@@ -1,52 +1,52 @@
import { connect } from 'react-redux'; import { connect } from "react-redux";
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { Transition } from 'react-spring'; import { Transition } from "react-spring";
import withRouter from 'react-router-dom/withRouter'; import withRouter from "react-router-dom/withRouter";
import Input from '../todos/Input'; import Input from "../todos/Input";
import TodoListContainer from './TodoListContainer'; import TodoListContainer from "./TodoListContainer";
import Header from '../Header'; import Header from "../Header";
import Filters from '../filters/Filters'; import Filters from "../filters/Filters";
class Todos extends React.PureComponent { class Todos extends React.PureComponent {
render() { render() {
const { list } = this.props; const { list } = this.props;
return ( return (
<div id="todos"> <div id="todos">
<Header /> <Header />
<Transition <Transition
from={{ opacity: 0, maxHeight: 0 }} from={{ opacity: 0, maxHeight: 0 }}
enter={{ opacity: 1, maxHeight: 38 }} enter={{ opacity: 1, maxHeight: 38 }}
leave={{ opacity: 0, maxHeight: 0 }} leave={{ opacity: 0, maxHeight: 0 }}
> >
{list && (styles => <Input styles={styles} />)} {list && ((styles) => <Input styles={styles} />)}
</Transition> </Transition>
<TodoListContainer /> <TodoListContainer />
<Transition <Transition
from={{ opacity: 0, maxHeight: 0 }} from={{ opacity: 0, maxHeight: 0 }}
enter={{ opacity: 1, maxHeight: 32 }} enter={{ opacity: 1, maxHeight: 32 }}
leave={{ opacity: 0, maxHeight: 0 }} leave={{ opacity: 0, maxHeight: 0 }}
> >
{list && Filters} {list && Filters}
</Transition> </Transition>
</div> </div>
); );
} }
} }
Todos.propTypes = { Todos.propTypes = {
list: PropTypes.bool.isRequired, list: PropTypes.bool.isRequired,
user: PropTypes.any.isRequired, user: PropTypes.any.isRequired,
history: PropTypes.any.isRequired, history: PropTypes.any.isRequired,
}; };
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
list: Boolean(state.lists.list), list: Boolean(state.lists.list),
user: state.user, user: state.user,
}; };
} }
export default withRouter(connect(mapStateToProps)(Todos)); export default withRouter(connect(mapStateToProps)(Todos));

View File

@@ -1,14 +1,18 @@
import { VisibilityFilters } from '../../actions/defs'; import { VisibilityFilters } from "../../actions/defs";
export default function getVisibleTodos(todos, filter) { export default function getVisibleTodos(todos, filter) {
switch (filter) { switch (filter) {
case VisibilityFilters.SHOW_ALL: case VisibilityFilters.SHOW_ALL:
return todos.filter(todo => todo); return todos.filter((todo) => todo);
case VisibilityFilters.SHOW_ACTIVE: case VisibilityFilters.SHOW_ACTIVE:
return todos.filter(todo => todo).filter(todo => !todo.completed); return todos
case VisibilityFilters.SHOW_COMPLETED: .filter((todo) => todo)
return todos.filter(todo => todo).filter(todo => todo.completed); .filter((todo) => !todo.completed);
default: case VisibilityFilters.SHOW_COMPLETED:
return todos.filter(todo => todo); return todos
} .filter((todo) => todo)
.filter((todo) => todo.completed);
default:
return todos.filter((todo) => todo);
}
} }

View File

@@ -1,60 +1,61 @@
import { connect } from 'react-redux'; import { connect } from "react-redux";
import * as React from 'react'; import * as React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { Button } from '@material-ui/core'; import { Button } from "@material-ui/core";
import AddIcon from '@material-ui/icons/Add'; import AddIcon from "@material-ui/icons/Add";
import { addTodo } from '../../actions/todos'; import { addTodo } from "../../actions/todos";
function Input({ onClick, styles }) { function Input({ onClick, styles }) {
let input; let input;
function submit() { function submit() {
if (input.value.trim() !== '') { if (input.value.trim() !== "") {
onClick(input.value); onClick(input.value);
}
input.value = "";
} }
input.value = '';
}
return ( return (
<div style={styles} id="inputs"> <div style={styles} id="inputs">
<input <input
aria-label="todo text" aria-label="todo text"
ref={node => { ref={(node) => {
input = node; input = node;
}} }}
id="input" id="input"
type="text" type="text"
placeholder="Add something!" placeholder="Add something!"
onKeyPress={e => { onKeyPress={(e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
submit(); submit();
} }
}} }}
/> />
<Button style={{ borderRadius: 0 }} id="add" onClick={() => submit()}> <Button
<AddIcon /> style={{ borderRadius: 0 }}
</Button> id="add"
</div> onClick={() => submit()}
); >
<AddIcon />
</Button>
</div>
);
} }
Input.propTypes = { Input.propTypes = {
styles: PropTypes.any.isRequired, styles: PropTypes.any.isRequired,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
}; };
function mapStateToProps(state, ownProps) { function mapStateToProps(state, ownProps) {
return { ...ownProps }; return { ...ownProps };
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
onClick: text => dispatch(addTodo(text)), onClick: (text) => dispatch(addTodo(text)),
}; };
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(Input);
mapStateToProps,
mapDispatchToProps,
)(Input);

View File

@@ -1,128 +1,123 @@
import React from 'react'; import React from "react";
import { Field, reduxForm } from 'redux-form'; import { Field, reduxForm } from "redux-form";
import { connect } from 'react-redux'; import { connect } from "react-redux";
import { withRouter } from 'react-router-dom'; import { withRouter } from "react-router-dom";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { ButtonBase, Button } from '@material-ui/core'; import { ButtonBase, Button } from "@material-ui/core";
import InputField from './InputField'; import InputField from "./InputField";
import UserErrors from './UserErrors'; import UserErrors from "./UserErrors";
import './Form.css'; import "./Form.css";
import { edit, resetEdit, deleteUser } from '../../actions/user'; import { edit, resetEdit, deleteUser } from "../../actions/user";
function validate(values) { function validate(values) {
const errors = {}; const errors = {};
if (values.password !== values.passwordRepeat) { if (values.password !== values.passwordRepeat) {
errors.passwordRepeat = 'Passwords should match'; errors.passwordRepeat = "Passwords should match";
} }
return errors; return errors;
} }
function EditForm({ function EditForm({
handleSubmit, handleSubmit,
onSubmit, onSubmit,
deleteUser, deleteUser,
user, user,
history, history,
reset, reset,
}) { }) {
if (user.user && user.editSuccess) { if (user.user && user.editSuccess) {
reset(); reset();
history.push('/'); history.push("/");
} }
return ( return (
<React.Fragment> <React.Fragment>
<div id="user-header"> <div id="user-header">
<ButtonBase <ButtonBase
style={{ style={{
marginLeft: '0', marginLeft: "0",
marginRight: 'auto', marginRight: "auto",
padding: '0 0.5rem', padding: "0 0.5rem",
}} }}
onClick={() => { onClick={() => {
history.push('/'); history.push("/");
}} }}
> >
todos todos
</ButtonBase> </ButtonBase>
</div> </div>
<div id="form"> <div id="form">
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<UserErrors user={user} /> <UserErrors user={user} />
<Field <Field
label="username" label="username"
name="username" name="username"
component={InputField} component={InputField}
type="text" type="text"
/> />
<Field <Field
label="password" label="password"
name="password" name="password"
component={InputField} component={InputField}
type="password" type="password"
/> />
<Field <Field
label="repeat pasword" label="repeat pasword"
name="passwordRepeat" name="passwordRepeat"
component={InputField} component={InputField}
type="password" type="password"
/> />
<div id="buttons"> <div id="buttons">
<Button onClick={() => deleteUser()}>Delete your account</Button> <Button onClick={() => deleteUser()}>
<Button Delete your account
id="submitbutton" </Button>
variant="raised" <Button
color="primary" id="submitbutton"
type="submit" variant="raised"
> color="primary"
Save type="submit"
</Button> >
</div> Save
</form> </Button>
</div> </div>
</React.Fragment> </form>
); </div>
</React.Fragment>
);
} }
EditForm.propTypes = { EditForm.propTypes = {
handleSubmit: PropTypes.func.isRequired, handleSubmit: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired,
user: PropTypes.object.isRequired, user: PropTypes.object.isRequired,
history: PropTypes.any.isRequired, history: PropTypes.any.isRequired,
reset: PropTypes.func.isRequired, reset: PropTypes.func.isRequired,
deleteUser: PropTypes.func.isRequired, deleteUser: PropTypes.func.isRequired,
}; };
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
user: state.user, user: state.user,
}; };
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
reset: () => dispatch(resetEdit()), reset: () => dispatch(resetEdit()),
deleteUser: () => dispatch(deleteUser()), deleteUser: () => dispatch(deleteUser()),
onSubmit: ({ username, password }) => onSubmit: ({ username, password }) =>
dispatch(edit({ username, password })), dispatch(edit({ username, password })),
}; };
} }
export default reduxForm({ export default reduxForm({
form: 'editForm', form: "editForm",
initialValues: { initialValues: {
username: '', username: "",
password: '', password: "",
passwordRepeat: '', passwordRepeat: "",
}, },
validate, validate,
})( })(withRouter(connect(mapStateToProps, mapDispatchToProps)(EditForm)));
withRouter(
connect(
mapStateToProps,
mapDispatchToProps,
)(EditForm),
),
);

View File

@@ -1,37 +1,37 @@
#form { #form {
margin: 2rem; margin: 2rem;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
form { form {
max-width: 80%; max-width: 80%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 20rem; width: 20rem;
} }
.error { .error {
margin: 1rem; margin: 1rem;
color: red; color: red;
} }
#googlebutton { #googlebutton {
margin: auto; margin: auto;
margin-left: 0rem; margin-left: 0rem;
} }
#submitbutton { #submitbutton {
margin: auto; margin: auto;
margin-right: 0rem; margin-right: 0rem;
} }
#buttons { #buttons {
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
} }
#buttons button { #buttons button {
margin: 0 0.5rem; margin: 0 0.5rem;
} }

View File

@@ -1,30 +1,30 @@
import React from 'react'; import React from "react";
import { withRouter } from 'react-router-dom'; import { withRouter } from "react-router-dom";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { ButtonBase } from '@material-ui/core'; import { ButtonBase } from "@material-ui/core";
function Link({ history, to, text }) { function Link({ history, to, text }) {
return ( return (
<ButtonBase <ButtonBase
style={{ style={{
marginLeft: '0', marginLeft: "0",
marginRight: 'auto', marginRight: "auto",
padding: '0 1rem', padding: "0 1rem",
}} }}
onClick={e => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
history.push(to); history.push(to);
}} }}
> >
{text} {text}
</ButtonBase> </ButtonBase>
); );
} }
Link.propTypes = { Link.propTypes = {
history: PropTypes.any, history: PropTypes.any,
to: PropTypes.string.isRequired, to: PropTypes.string.isRequired,
text: PropTypes.string.isRequired, text: PropTypes.string.isRequired,
}; };
export default withRouter(Link); export default withRouter(Link);

View File

@@ -1,35 +1,35 @@
import React from 'react'; import React from "react";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { TextField } from '@material-ui/core'; import { TextField } from "@material-ui/core";
export default function InputField({ export default function InputField({
required, required,
input, input,
label, label,
meta: { touched, error }, meta: { touched, error },
type, type,
}) { }) {
return ( return (
<React.Fragment> <React.Fragment>
<TextField <TextField
label={label} label={label}
required={required} required={required}
{...input} {...input}
type={type} type={type}
style={{ marginBottom: '1rem' }} style={{ marginBottom: "1rem" }}
/> />
{touched && error && <span className="error">{error}</span>} {touched && error && <span className="error">{error}</span>}
</React.Fragment> </React.Fragment>
); );
} }
InputField.propTypes = { InputField.propTypes = {
required: PropTypes.bool.isRequired, required: PropTypes.bool.isRequired,
input: PropTypes.any.isRequired, input: PropTypes.any.isRequired,
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
meta: PropTypes.shape({ meta: PropTypes.shape({
touched: PropTypes.bool, touched: PropTypes.bool,
error: PropTypes.string, error: PropTypes.string,
}).isRequired, }).isRequired,
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
}; };

View File

@@ -1,125 +1,118 @@
import React from 'react'; import React from "react";
import { Field, reduxForm } from 'redux-form'; import { Field, reduxForm } from "redux-form";
import { connect } from 'react-redux'; import { connect } from "react-redux";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { ButtonBase, Button } from '@material-ui/core'; import { ButtonBase, Button } from "@material-ui/core";
import { withRouter } from 'react-router'; import { withRouter } from "react-router";
import InputField from './InputField'; import InputField from "./InputField";
import UserErrors from './UserErrors'; import UserErrors from "./UserErrors";
import './Form.css'; import "./Form.css";
import { login, reset, loginJWT } from '../../actions/user'; import { login, reset, loginJWT } from "../../actions/user";
class LoginForm extends React.PureComponent { class LoginForm extends React.PureComponent {
componentDidMount() { componentDidMount() {
const { setJWT } = this.props; const { setJWT } = this.props;
const params = new URLSearchParams(new URL(window.location).search); const params = new URLSearchParams(new URL(window.location).search);
if (params.has('jwt')) { if (params.has("jwt")) {
const jwt = params.get('jwt'); const jwt = params.get("jwt");
setJWT(jwt); setJWT(jwt);
}
} }
}
render() { render() {
const { resetUser, history, handleSubmit, user, onLogin } = this.props; const { resetUser, history, handleSubmit, user, onLogin } = this.props;
return ( return (
<React.Fragment> <React.Fragment>
<div id="user-header"> <div id="user-header">
<ButtonBase <ButtonBase
style={{ style={{
marginRight: '1rem', marginRight: "1rem",
padding: '0 0.5rem', padding: "0 0.5rem",
borderRadius: '7px', borderRadius: "7px",
}} }}
onClick={() => { onClick={() => {
resetUser(); resetUser();
history.push('/signup'); history.push("/signup");
}} }}
> >
signup signup
</ButtonBase> </ButtonBase>
</div> </div>
<div id="form"> <div id="form">
<form onSubmit={handleSubmit(onLogin)}> <form onSubmit={handleSubmit(onLogin)}>
<UserErrors user={user} /> <UserErrors user={user} />
<Field <Field
label="username" label="username"
name="username" name="username"
required required
component={InputField} component={InputField}
type="text" type="text"
/> />
<Field <Field
label="password" label="password"
name="password" name="password"
required required
component={InputField} component={InputField}
type="password" type="password"
/> />
<div id="buttons"> <div id="buttons">
<Button <Button
id="googlebutton" id="googlebutton"
variant="raised" variant="raised"
onClick={() => { onClick={() => {
window.location = '/__/users/login/google/'; window.location = "/__/users/login/google/";
}} }}
> >
Google Google
</Button> </Button>
<Button <Button
id="submitbutton" id="submitbutton"
variant="raised" variant="raised"
color="primary" color="primary"
type="submit" type="submit"
> >
Login Login
</Button> </Button>
</div> </div>
</form> </form>
</div> </div>
</React.Fragment> </React.Fragment>
); );
} }
} }
LoginForm.propTypes = { LoginForm.propTypes = {
handleSubmit: PropTypes.func.isRequired, handleSubmit: PropTypes.func.isRequired,
onLogin: PropTypes.func.isRequired, onLogin: PropTypes.func.isRequired,
user: PropTypes.object.isRequired, user: PropTypes.object.isRequired,
history: PropTypes.any.isRequired, history: PropTypes.any.isRequired,
resetUser: PropTypes.func.isRequired, resetUser: PropTypes.func.isRequired,
setJWT: PropTypes.func.isRequired, setJWT: PropTypes.func.isRequired,
}; };
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
user: state.user, user: state.user,
}; };
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
resetUser: () => dispatch(reset()), resetUser: () => dispatch(reset()),
onLogin: ({ username, password }) => onLogin: ({ username, password }) =>
dispatch(login({ username, password })), dispatch(login({ username, password })),
setJWT: jwt => dispatch(loginJWT(jwt)), setJWT: (jwt) => dispatch(loginJWT(jwt)),
}; };
} }
export default reduxForm({ export default reduxForm({
form: 'loginForm', form: "loginForm",
initialValues: { initialValues: {
username: '', username: "",
password: '', password: "",
}, },
})( })(withRouter(connect(mapStateToProps, mapDispatchToProps)(LoginForm)));
withRouter(
connect(
mapStateToProps,
mapDispatchToProps,
)(LoginForm)
),
);

View File

@@ -1,40 +1,37 @@
import React from 'react'; import React from "react";
import { connect } from 'react-redux'; import { connect } from "react-redux";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { ButtonBase } from '@material-ui/core'; import { ButtonBase } from "@material-ui/core";
import { logout } from '../../actions/user'; import { logout } from "../../actions/user";
function Link({ onClick, children }) { function Link({ onClick, children }) {
return ( return (
<ButtonBase <ButtonBase
style={{ style={{
marginLeft: 'auto', marginLeft: "auto",
marginRight: 0, marginRight: 0,
padding: '0 1rem', padding: "0 1rem",
}} }}
onClick={e => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
onClick(); onClick();
}} }}
> >
{children} {children}
</ButtonBase> </ButtonBase>
); );
} }
Link.propTypes = { Link.propTypes = {
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
}; };
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
onClick: () => dispatch(logout()), onClick: () => dispatch(logout()),
}; };
} }
export default connect( export default connect(null, mapDispatchToProps)(Link);
null,
mapDispatchToProps,
)(Link);

View File

@@ -1,118 +1,111 @@
import React from 'react'; import React from "react";
import { Field, reduxForm } from 'redux-form'; import { Field, reduxForm } from "redux-form";
import { connect } from 'react-redux'; import { connect } from "react-redux";
import PropTypes from 'prop-types'; import PropTypes from "prop-types";
import { ButtonBase, Button } from '@material-ui/core'; import { ButtonBase, Button } from "@material-ui/core";
import { withRouter } from 'react-router'; import { withRouter } from "react-router";
import InputField from './InputField'; import InputField from "./InputField";
import UserErrors from './UserErrors'; import UserErrors from "./UserErrors";
import './Form.css'; import "./Form.css";
import { signup, reset } from '../../actions/user'; import { signup, reset } from "../../actions/user";
function validate(values) { function validate(values) {
const errors = {}; const errors = {};
if (values.password !== values.passwordRepeat) { if (values.password !== values.passwordRepeat) {
errors.passwordRepeat = 'Passwords should match'; errors.passwordRepeat = "Passwords should match";
} }
return errors; return errors;
} }
function SignupForm({ handleSubmit, onSignup, user, history, resetUser }) { function SignupForm({ handleSubmit, onSignup, user, history, resetUser }) {
return ( return (
<React.Fragment> <React.Fragment>
<div id="user-header"> <div id="user-header">
<ButtonBase <ButtonBase
style={{ style={{
marginRight: '1rem', marginRight: "1rem",
padding: '0 0.5rem', padding: "0 0.5rem",
borderRadius: '7px', borderRadius: "7px",
}} }}
onClick={() => { onClick={() => {
resetUser(); resetUser();
history.push('/login'); history.push("/login");
}} }}
> >
login login
</ButtonBase> </ButtonBase>
</div> </div>
<div id="form"> <div id="form">
<form onSubmit={handleSubmit(onSignup)}> <form onSubmit={handleSubmit(onSignup)}>
<UserErrors user={user} /> <UserErrors user={user} />
<Field <Field
label="username" label="username"
name="username" name="username"
required required
component={InputField} component={InputField}
type="text" type="text"
/> />
<Field <Field
label="password" label="password"
name="password" name="password"
required required
component={InputField} component={InputField}
type="password" type="password"
/> />
<Field <Field
label="repeat pasword" label="repeat pasword"
name="passwordRepeat" name="passwordRepeat"
required required
component={InputField} component={InputField}
type="password" type="password"
/> />
<div id="buttons"> <div id="buttons">
<Button <Button
id="submitbutton" id="submitbutton"
variant="raised" variant="raised"
color="primary" color="primary"
type="submit" type="submit"
> >
Signup Signup
</Button> </Button>
</div> </div>
</form> </form>
</div> </div>
</React.Fragment> </React.Fragment>
); );
} }
SignupForm.propTypes = { SignupForm.propTypes = {
handleSubmit: PropTypes.func.isRequired, handleSubmit: PropTypes.func.isRequired,
onSignup: PropTypes.func.isRequired, onSignup: PropTypes.func.isRequired,
user: PropTypes.object.isRequired, user: PropTypes.object.isRequired,
history: PropTypes.any.isRequired, history: PropTypes.any.isRequired,
resetUser: PropTypes.func.isRequired, resetUser: PropTypes.func.isRequired,
}; };
function mapStateToProps(state) { function mapStateToProps(state) {
return { return {
user: state.user, user: state.user,
}; };
} }
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
resetUser: () => dispatch(reset()), resetUser: () => dispatch(reset()),
onSignup: ({ username, password }) => onSignup: ({ username, password }) =>
dispatch(signup({ username, password })), dispatch(signup({ username, password })),
}; };
} }
export default reduxForm({ export default reduxForm({
form: 'signupForm', form: "signupForm",
initialValues: { initialValues: {
username: '', username: "",
password: '', password: "",
passwordRepeat: '', passwordRepeat: "",
}, },
validate, validate,
})( })(withRouter(connect(mapStateToProps, mapDispatchToProps)(SignupForm)));
withRouter(
connect(
mapStateToProps,
mapDispatchToProps,
)(SignupForm)
),
);

View File

@@ -1,32 +1,32 @@
import React from 'react'; import React from "react";
function UserErrors({ user }) { function UserErrors({ user }) {
const errors = []; const errors = [];
if (user.errors) { if (user.errors) {
if (user.errors.name === 'AuthenticationError') { if (user.errors.name === "AuthenticationError") {
errors.push( errors.push(
<div key="wrongauth" className="error"> <div key="wrongauth" className="error">
Wrong username or password Wrong username or password
</div>, </div>,
); );
}
if (user.errors.name === "ValidationError") {
if (user.errors.message.split(" ").includes("unique.")) {
errors.push(
<div key="exists" className="error">
User already exists
</div>,
);
} else {
errors.push(
<div key="invalid" className="error">
Validation error
</div>,
);
}
}
} }
if (user.errors.name === 'ValidationError') { return errors || null;
if (user.errors.message.split(' ').includes('unique.')) {
errors.push(
<div key="exists" className="error">
User already exists
</div>,
);
} else {
errors.push(
<div key="invalid" className="error">
Validation error
</div>,
);
}
}
}
return errors || null;
} }
export default UserErrors; export default UserErrors;

View File

@@ -1,13 +1,13 @@
import React from 'react'; import React from "react";
import LogoutLink from './LogoutLink'; import LogoutLink from "./LogoutLink";
import HeaderLink from './HeaderLink'; import HeaderLink from "./HeaderLink";
export default function UserHeader() { export default function UserHeader() {
return ( return (
<div id="user-header"> <div id="user-header">
<HeaderLink to="/edit" text="account"/> <HeaderLink to="/edit" text="account" />
<LogoutLink>logout</LogoutLink> <LogoutLink>logout</LogoutLink>
</div> </div>
); );
} }

View File

@@ -1,38 +1,38 @@
import React from 'react'; import React from "react";
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom";
import thunk from 'redux-thunk'; import thunk from "redux-thunk";
import { Provider } from 'react-redux'; import { Provider } from "react-redux";
import { applyMiddleware, createStore, compose } from 'redux'; import { applyMiddleware, createStore, compose } from "redux";
import { offline } from '@redux-offline/redux-offline'; import { offline } from "@redux-offline/redux-offline";
import offlineConfig from '@redux-offline/redux-offline/lib/defaults'; import offlineConfig from "@redux-offline/redux-offline/lib/defaults";
import AppContainer from './components/AppContainer'; import AppContainer from "./components/AppContainer";
import registerServiceWorker from './registerServiceWorker'; import registerServiceWorker from "./registerServiceWorker";
import todoApp from './reducers'; import todoApp from "./reducers";
import { setToken } from './actions/util'; import { setToken } from "./actions/util";
import keepSynced from './middleware/keepSynced'; import keepSynced from "./middleware/keepSynced";
let store; let store;
const persistCallback = () => { const persistCallback = () => {
const state = store.getState(); const state = store.getState();
if (state.user.user) { if (state.user.user) {
setToken(state.user.user.jwt); setToken(state.user.user.jwt);
} }
ReactDOM.render( ReactDOM.render(
<Provider store={store}> <Provider store={store}>
<AppContainer /> <AppContainer />
</Provider>, </Provider>,
document.getElementById('root'), document.getElementById("root"),
); );
}; };
store = createStore( store = createStore(
todoApp, todoApp,
compose( compose(
offline({ ...offlineConfig, persistCallback }), offline({ ...offlineConfig, persistCallback }),
applyMiddleware(thunk, keepSynced), applyMiddleware(thunk, keepSynced),
), ),
); );
registerServiceWorker(); registerServiceWorker();

View File

@@ -1,20 +1,20 @@
import { REQUEST_LISTS, INVALIDATE_LISTS } from '../actions/defs'; import { REQUEST_LISTS, INVALIDATE_LISTS } from "../actions/defs";
import { fetchLists } from '../actions/lists'; import { fetchLists } from "../actions/lists";
export default store => next => action => { export default (store) => (next) => (action) => {
next(action); next(action);
if (action.type !== REQUEST_LISTS && typeof action !== 'function') { if (action.type !== REQUEST_LISTS && typeof action !== "function") {
const state = store.getState(); const state = store.getState();
if (state.user.user) { if (state.user.user) {
const dirtyLists = state.lists.dirty || false; const dirtyLists = state.lists.dirty || false;
const dirtyTodos = state.todos.dirty || false; const dirtyTodos = state.todos.dirty || false;
const fetchingLists = state.lists.fetching || false; const fetchingLists = state.lists.fetching || false;
if ( if (
((dirtyLists || dirtyTodos) && !fetchingLists) || ((dirtyLists || dirtyTodos) && !fetchingLists) ||
action.type === INVALIDATE_LISTS action.type === INVALIDATE_LISTS
) { ) {
store.dispatch(fetchLists()); store.dispatch(fetchLists());
} }
}
} }
}
}; };

View File

@@ -1,17 +1,17 @@
import { combineReducers } from 'redux'; import { combineReducers } from "redux";
import { reducer as formReducer } from 'redux-form'; import { reducer as formReducer } from "redux-form";
import lists from './lists'; import lists from "./lists";
import visibilityFilter from './visibilityFilter'; import visibilityFilter from "./visibilityFilter";
import user from './user'; import user from "./user";
import todos from './todos'; import todos from "./todos";
const todoApp = combineReducers({ const todoApp = combineReducers({
lists, lists,
todos, todos,
visibilityFilter, visibilityFilter,
form: formReducer, form: formReducer,
user, user,
}); });
export default todoApp; export default todoApp;

View File

@@ -1,36 +1,23 @@
import { import {
CHANGE_LIST, CHANGE_LIST,
INVALIDATE_LISTS, INVALIDATE_LISTS,
VALIDATE_LISTS, VALIDATE_LISTS,
REQUEST_LISTS, REQUEST_LISTS,
RECIEVE_LISTS, RECIEVE_LISTS,
ADD_LIST, ADD_LIST,
REMOVE_LIST, REMOVE_LIST,
EDIT_LIST_NAME, EDIT_LIST_NAME,
START_CREATE_LIST, START_CREATE_LIST,
START_EDIT_LIST, START_EDIT_LIST,
STOP_CREATE_LIST, STOP_CREATE_LIST,
STOP_EDIT_LIST, STOP_EDIT_LIST,
REMOVE_TODO, REMOVE_TODO,
ADD_TODO, ADD_TODO,
LOGOUT, LOGOUT,
} from '../actions/defs'; } from "../actions/defs";
export default function lists( export default function lists(
state = { state = {
dirty: true,
fetching: false,
lists: null,
loaded: false,
creating: false,
list: null,
editing: false,
},
action,
) {
switch (action.type) {
case LOGOUT:
return {
dirty: true, dirty: true,
fetching: false, fetching: false,
lists: null, lists: null,
@@ -38,123 +25,141 @@ export default function lists(
creating: false, creating: false,
list: null, list: null,
editing: false, editing: false,
}; },
case CHANGE_LIST: action,
return { ...state, list: action.list }; ) {
case RECIEVE_LISTS: { switch (action.type) {
const newLists = Object.values(action.lists); case LOGOUT:
let { list } = state; return {
if (newLists.length !== 0) { dirty: true,
if (!newLists.some(curList => curList.id === list)) { fetching: false,
list = newLists[0].id || null; lists: null,
loaded: false,
creating: false,
list: null,
editing: false,
};
case CHANGE_LIST:
return { ...state, list: action.list };
case RECIEVE_LISTS: {
const newLists = Object.values(action.lists);
let { list } = state;
if (newLists.length !== 0) {
if (!newLists.some((curList) => curList.id === list)) {
list = newLists[0].id || null;
}
} else {
list = null;
}
return {
...state,
dirty: false,
loaded: true,
fetching: false,
lists: action.lists,
list,
};
} }
} else { case START_CREATE_LIST:
list = null; return {
} ...state,
return { creating: true,
...state, };
dirty: false, case STOP_CREATE_LIST:
loaded: true, return {
fetching: false, ...state,
lists: action.lists, creating: false,
list, };
}; case ADD_LIST:
return {
...state,
creating: false,
lists: { ...state.lists, [action.list.id]: action.list },
list: action.list.id,
};
case REMOVE_LIST: {
const newLists = { ...state.lists };
delete newLists[action.list];
const listsObjs = Object.values(newLists);
const list = listsObjs.length
? listsObjs[listsObjs.length - 1].id
: null;
return {
...state,
list,
lists: newLists,
};
}
case START_EDIT_LIST: {
return {
...state,
editing: true,
};
}
case STOP_EDIT_LIST: {
return {
...state,
editing: false,
};
}
case EDIT_LIST_NAME: {
return {
...state,
editing: false,
lists: {
...state.lists,
[action.list]: {
...state.lists[action.list],
name: action.name,
},
},
};
}
case REMOVE_TODO: {
return {
...state,
lists: {
...state.lists,
[state.list]: {
...state.lists[state.list],
todos: state.lists[state.list].todos.filter(
(todo) => todo !== action.id,
),
},
},
};
}
case ADD_TODO: {
return {
...state,
lists: {
...state.lists,
[state.list]: {
...state.lists[state.list],
todos: [
action.todo.id,
...state.lists[state.list].todos,
],
},
},
};
}
case INVALIDATE_LISTS:
return {
...state,
dirty: true,
};
case VALIDATE_LISTS:
return {
...state,
dirty: false,
};
case REQUEST_LISTS:
return {
...state,
fetching: true,
};
default:
return state;
} }
case START_CREATE_LIST:
return {
...state,
creating: true,
};
case STOP_CREATE_LIST:
return {
...state,
creating: false,
};
case ADD_LIST:
return {
...state,
creating: false,
lists: { ...state.lists, [action.list.id]: action.list },
list: action.list.id,
};
case REMOVE_LIST: {
const newLists = { ...state.lists };
delete newLists[action.list];
const listsObjs = Object.values(newLists);
const list = listsObjs.length ? listsObjs[listsObjs.length - 1].id : null;
return {
...state,
list,
lists: newLists,
};
}
case START_EDIT_LIST: {
return {
...state,
editing: true,
};
}
case STOP_EDIT_LIST: {
return {
...state,
editing: false,
};
}
case EDIT_LIST_NAME: {
return {
...state,
editing: false,
lists: {
...state.lists,
[action.list]: {
...state.lists[action.list],
name: action.name,
},
},
};
}
case REMOVE_TODO: {
return {
...state,
lists: {
...state.lists,
[state.list]: {
...state.lists[state.list],
todos: state.lists[state.list].todos.filter(
todo => todo !== action.id,
),
},
},
};
}
case ADD_TODO: {
return {
...state,
lists: {
...state.lists,
[state.list]: {
...state.lists[state.list],
todos: [action.todo.id, ...state.lists[state.list].todos],
},
},
};
}
case INVALIDATE_LISTS:
return {
...state,
dirty: true,
};
case VALIDATE_LISTS:
return {
...state,
dirty: false,
};
case REQUEST_LISTS:
return {
...state,
fetching: true,
};
default:
return state;
}
} }

View File

@@ -1,99 +1,102 @@
import { import {
ADD_TODO, ADD_TODO,
REMOVE_TODO, REMOVE_TODO,
TOGGLE_TODO, TOGGLE_TODO,
RECIEVE_TODOS, RECIEVE_TODOS,
REQUEST_TODOS, REQUEST_TODOS,
INVALIDATE_TODOS, INVALIDATE_TODOS,
VALIDATE_TODOS, VALIDATE_TODOS,
EDIT_TODO, EDIT_TODO,
REMOVE_LIST, REMOVE_LIST,
LOGOUT, LOGOUT,
} from '../actions/defs'; } from "../actions/defs";
export default function todos( export default function todos(
state = { state = {
dirty: true,
fetching: false,
todos: null,
},
action,
) {
switch (action.type) {
case LOGOUT:
return {
dirty: true, dirty: true,
fetching: false, fetching: false,
todos: null, todos: null,
}; },
case RECIEVE_TODOS: action,
return { ) {
...state, switch (action.type) {
dirty: false, case LOGOUT:
fetching: false, return {
todos: action.todos, dirty: true,
}; fetching: false,
case ADD_TODO: todos: null,
return { };
...state, case RECIEVE_TODOS:
todos: { [action.todo.id]: action.todo, ...state.todos }, return {
}; ...state,
case INVALIDATE_TODOS: dirty: false,
return { fetching: false,
...state, todos: action.todos,
dirty: true, };
}; case ADD_TODO:
case VALIDATE_TODOS: return {
return { ...state,
...state, todos: { [action.todo.id]: action.todo, ...state.todos },
dirty: false, };
}; case INVALIDATE_TODOS:
case EDIT_TODO: return {
return { ...state,
...state, dirty: true,
todos: { };
...state.todos, case VALIDATE_TODOS:
[action.id]: { ...state.todos[action.id], text: action.text }, return {
}, ...state,
}; dirty: false,
case REQUEST_TODOS: };
return { case EDIT_TODO:
...state, return {
fetching: true, ...state,
}; todos: {
case REMOVE_TODO: { ...state.todos,
const newTodos = { ...state.todos }; [action.id]: {
delete newTodos[action.id]; ...state.todos[action.id],
return { text: action.text,
...state, },
todos: newTodos, },
}; };
} case REQUEST_TODOS:
case REMOVE_LIST: { return {
const newTodos = { ...state.todos }; ...state,
Object.keys(newTodos).forEach(todoId => { fetching: true,
if (newTodos[todoId].list === action.list) { };
delete newTodos[todoId]; case REMOVE_TODO: {
const newTodos = { ...state.todos };
delete newTodos[action.id];
return {
...state,
todos: newTodos,
};
} }
}); case REMOVE_LIST: {
return { const newTodos = { ...state.todos };
...state, Object.keys(newTodos).forEach((todoId) => {
todos: newTodos, if (newTodos[todoId].list === action.list) {
}; delete newTodos[todoId];
}
});
return {
...state,
todos: newTodos,
};
}
case TOGGLE_TODO: {
return {
...state,
todos: {
...state.todos,
[action.id]: {
...state.todos[action.id],
completed: !state.todos[action.id].completed,
},
},
};
}
default:
return state;
} }
case TOGGLE_TODO: {
return {
...state,
todos: {
...state.todos,
[action.id]: {
...state.todos[action.id],
completed: !state.todos[action.id].completed,
},
},
};
}
default:
return state;
}
} }

View File

@@ -1,90 +1,90 @@
import { import {
LOGIN_SUCCESS, LOGIN_SUCCESS,
LOGIN_FAIL, LOGIN_FAIL,
START_LOGIN, START_LOGIN,
LOGOUT, LOGOUT,
SIGNUP_FAIL, SIGNUP_FAIL,
SIGNUP_SUCCESS, SIGNUP_SUCCESS,
VALIDATE_USER, VALIDATE_USER,
RESET_USER, RESET_USER,
EDIT_SUCCESS, EDIT_SUCCESS,
EDIT_FAIL, EDIT_FAIL,
RESET_EDIT, RESET_EDIT,
} from '../actions/defs'; } from "../actions/defs";
export default function user( export default function user(
state = { state = {
dirty: true, dirty: true,
fetching: false, fetching: false,
user: null, user: null,
loaded: false, loaded: false,
errors: null, errors: null,
}, },
action, action,
) { ) {
switch (action.type) { switch (action.type) {
case VALIDATE_USER: case VALIDATE_USER:
return { return {
...state, ...state,
dirty: false, dirty: false,
}; };
case START_LOGIN: case START_LOGIN:
return { return {
...state, ...state,
fetching: true, fetching: true,
}; };
case SIGNUP_SUCCESS: case SIGNUP_SUCCESS:
case LOGIN_SUCCESS: case LOGIN_SUCCESS:
return { return {
...state, ...state,
user: action.user, user: action.user,
errors: null, errors: null,
dirty: false, dirty: false,
loaded: true, loaded: true,
fetching: false, fetching: false,
}; };
case EDIT_SUCCESS: case EDIT_SUCCESS:
return { return {
...state, ...state,
user: action.user, user: action.user,
editSuccess: true, editSuccess: true,
}; };
case SIGNUP_FAIL: case SIGNUP_FAIL:
case LOGIN_FAIL: case LOGIN_FAIL:
return { return {
...state, ...state,
user: null, user: null,
errors: action.error, errors: action.error,
dirty: false, dirty: false,
fetching: false, fetching: false,
loaded: false, loaded: false,
}; };
case EDIT_FAIL: case EDIT_FAIL:
return { return {
...state, ...state,
errors: action.error, errors: action.error,
editSuccess: false, editSuccess: false,
}; };
case RESET_EDIT: case RESET_EDIT:
return { return {
...state, ...state,
editSuccess: null, editSuccess: null,
}; };
case RESET_USER: case RESET_USER:
return { return {
...state, ...state,
fetching: false, fetching: false,
loaded: false, loaded: false,
user: null, user: null,
errors: null, errors: null,
}; };
case LOGOUT: case LOGOUT:
return { return {
...state, ...state,
loaded: false, loaded: false,
user: null, user: null,
}; };
default: default:
return state; return state;
} }
} }

View File

@@ -1,12 +1,12 @@
import { VisibilityFilters, SET_VISIBILITY_FILTER } from '../actions/defs'; import { VisibilityFilters, SET_VISIBILITY_FILTER } from "../actions/defs";
const { SHOW_ALL } = VisibilityFilters; const { SHOW_ALL } = VisibilityFilters;
export default function visibilityFilter(state = SHOW_ALL, action) { export default function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) { switch (action.type) {
case SET_VISIBILITY_FILTER: case SET_VISIBILITY_FILTER:
return action.filter; return action.filter;
default: default:
return state; return state;
} }
} }

View File

@@ -9,110 +9,113 @@
// This link also includes instructions on opting out of this behavior. // This link also includes instructions on opting out of this behavior.
const isLocalhost = Boolean( const isLocalhost = Boolean(
window.location.hostname === 'localhost' || window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address. // [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' || window.location.hostname === "[::1]" ||
// 127.0.0.1/8 is considered localhost for IPv4. // 127.0.0.1/8 is considered localhost for IPv4.
window.location.hostname.match( window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
), ),
); );
function registerValidSW(swUrl) { function registerValidSW(swUrl) {
navigator.serviceWorker navigator.serviceWorker
.register(swUrl) .register(swUrl)
.then(registration => { .then((registration) => {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing; const installingWorker = registration.installing;
installingWorker.onstatechange = () => { installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') { if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) { if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and // At this point, the old content will have been purged and
// the fresh content will have been added to the cache. // the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is // It's the perfect time to display a "New content is
// available; please refresh." message in your web app. // available; please refresh." message in your web app.
console.log('New content is available; please refresh.'); console.log(
} else { "New content is available; please refresh.",
// At this point, everything has been precached. );
// It's the perfect time to display a } else {
// "Content is cached for offline use." message. // At this point, everything has been precached.
console.log('Content is cached for offline use.'); // It's the perfect time to display a
} // "Content is cached for offline use." message.
} console.log("Content is cached for offline use.");
}; }
}; }
}) };
.catch(error => { };
console.error('Error during service worker registration:', error); })
}); .catch((error) => {
console.error("Error during service worker registration:", error);
});
} }
function checkValidServiceWorker(swUrl) { function checkValidServiceWorker(swUrl) {
// Check if the service worker can be found. If it can't reload the page. // Check if the service worker can be found. If it can't reload the page.
fetch(swUrl) fetch(swUrl)
.then(response => { .then((response) => {
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
if ( if (
response.status === 404 || response.status === 404 ||
response.headers.get('content-type').indexOf('javascript') === -1 response.headers.get("content-type").indexOf("javascript") ===
) { -1
// No service worker found. Probably a different app. Reload the page. ) {
navigator.serviceWorker.ready.then(registration => { // No service worker found. Probably a different app. Reload the page.
registration.unregister().then(() => { navigator.serviceWorker.ready.then((registration) => {
window.location.reload(); registration.unregister().then(() => {
}); window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
"No internet connection found. App is running in offline mode.",
);
}); });
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.',
);
});
} }
export default function register() { export default function register() {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location); const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
if (publicUrl.origin !== window.location.origin) { if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin // Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to // from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
return; return;
} }
window.addEventListener('load', () => { window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) { if (isLocalhost) {
// This is running on localhost. Lets check if a service worker still exists or not. // This is running on localhost. Lets check if a service worker still exists or not.
checkValidServiceWorker(swUrl); checkValidServiceWorker(swUrl);
// Add some additional logging to localhost, pointing developers to the // Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation. // service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => { navigator.serviceWorker.ready.then(() => {
console.log( console.log(
'This web app is being served cache-first by a service ' + "This web app is being served cache-first by a service " +
'worker. To learn more, visit https://goo.gl/SC7cgQ', "worker. To learn more, visit https://goo.gl/SC7cgQ",
); );
});
} else {
// Is not local host. Just register service worker
registerValidSW(swUrl);
}
}); });
} else { }
// Is not local host. Just register service worker
registerValidSW(swUrl);
}
});
}
} }
export function unregister() { export function unregister() {
if ('serviceWorker' in navigator) { if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister(); registration.unregister();
}); });
} }
} }

View File

@@ -1,5 +1,5 @@
const { createProxyMiddleware } = require('http-proxy-middleware'); const { createProxyMiddleware } = require("http-proxy-middleware");
module.exports = function(app) { module.exports = function (app) {
app.use(createProxyMiddleware('/__', { target: 'http://localhost:4000/' })); app.use(createProxyMiddleware("/__", { target: "http://localhost:4000/" }));
}; };

View File

@@ -1,18 +0,0 @@
const mongoose = require('mongoose');
const config = require('./');
async function connect() {
await mongoose.connect(config.db.uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
}
async function disconnect() {
await mongoose.disconnect();
}
module.exports = {
connect,
disconnect,
};

View File

@@ -1,39 +0,0 @@
const env = process.env.NODE_ENV;
const production = {
app: {
port: process.env.PORT || 4000,
},
db: {
uri:
process.env.DB_URI ||
process.env.MONGODB_URI ||
'mongodb://localhost/todolist',
},
googleOAuth: {
googleEnabled:
process.env.GOOGLE_ENABLED ? process.env.GOOGLE_ENABLED.toUpperCase() === 'TRUE' : false,
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
googleCallback: `${process.env.HOST}/__/users/login/google/callback`,
},
secret: process.env.SECRET,
};
const development = {
...production,
secret: process.env.SECRET || 'devsecret',
};
const test = {
...production,
secret: process.env.SECRET || 'testsecret',
};
const config = {
production,
development,
test,
};
module.exports = config[env] || config.production;

View File

@@ -1,31 +0,0 @@
const passport = require('passport');
const mongoose = require('mongoose');
const GoogleStrategy = require('passport-google-oauth').OAuth2Strategy;
const {
googleClientId,
googleClientSecret,
googleCallback,
googleEnabled,
} = require('./').googleOAuth;
const User = mongoose.model('User');
passport.use(User.createStrategy());
if (googleEnabled) {
passport.use(
new GoogleStrategy(
{
clientID: googleClientId,
clientSecret: googleClientSecret,
callbackURL: googleCallback,
},
(accessToken, refreshToken, profile, done) => {
User.findOrCreate({ googleId: profile.id }, (err, user) =>
done(err, user),
);
},
),
);
}
module.exports = passport;

View File

@@ -1,21 +0,0 @@
class NotFoundError extends Error {
constructor(text, ...args) {
super(...args);
Error.captureStackTrace(this, NotFoundError);
this.name = 'NotFound';
this.text = text;
this.status = 404;
}
}
class BadRequestError extends Error {
constructor(text, ...args) {
super(...args);
Error.captureStackTrace(this, NotFoundError);
this.name = 'BadRequest';
this.text = text;
this.status = 400;
}
}
module.exports = { NotFoundError, BadRequestError };

0
git
View File

View File

@@ -1,50 +0,0 @@
const mongoose = require('mongoose');
const { Schema } = mongoose;
const TodoSchema = Schema({
text: {
type: String,
required: true,
minLength: 1,
maxLength: 300,
trim: true,
},
list: { type: Schema.Types.ObjectId, ref: 'TodoList', required: true },
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
completed: { type: Boolean, default: false },
});
TodoSchema.pre('save', async function() {
if (this.isNew) {
const user = await this.model('User').findById(this.user);
user.todos.push(this._id);
await user.save();
const list = await this.model('TodoList').findById(this.list);
list.todos.push(this._id);
await list.save();
}
});
TodoSchema.pre('remove', async function() {
const user = await this.model('User').findById(this.user);
user.todos.splice(user.todos.indexOf(this._id), 1);
await user.save();
const list = await this.model('TodoList').findById(this.list);
list.todos.splice(list.todos.indexOf(this._id), 1);
await list.save();
});
TodoSchema.methods.toJson = function() {
return {
id: this._id.toString(),
text: this.text,
list: this.list.toString(),
user: this.user.toString(),
completed: this.completed,
};
};
mongoose.model('Todo', TodoSchema);

View File

@@ -1,56 +0,0 @@
const mongoose = require('mongoose');
const { Schema } = mongoose;
const TodoListSchema = Schema({
name: {
type: String,
required: true,
minLength: 1,
maxLength: 100,
trim: true,
},
todos: [{ type: Schema.Types.ObjectId, ref: 'Todo' }],
user: { type: Schema.Types.ObjectId, ref: 'User', required: true },
});
TodoListSchema.pre('save', async function() {
if (this.isNew) {
const user = await this.model('User').findById(this.user);
user.lists.push(this._id);
await user.save();
}
});
TodoListSchema.pre('remove', async function() {
const user = await this.model('User').findById(this.user);
user.lists.splice(user.lists.indexOf(this._id), 1);
// removing todos in parallel can cause VersionError
// so we remove todos from user
const todos = await this.model('Todo')
.find({ list: this._id })
.exec();
const ids = todos.map(todo => todo._id);
user.todos = user.todos.filter(todo => ids.includes(todo._id));
await user.save();
// and remove them from db
await this.model('Todo')
.find({ list: this._id })
.remove()
.exec();
});
TodoListSchema.methods.toJson = function() {
const todos = this.populated('todos')
? this.todos.map(todo => todo.toJson())
: this.todos;
return {
id: this._id.toString(),
user: this.user.toString(),
name: this.name,
todos,
};
};
mongoose.model('TodoList', TodoListSchema);

View File

@@ -1,76 +0,0 @@
const mongoose = require('mongoose');
const passportLocalMongoose = require('passport-local-mongoose');
const jwt = require('jsonwebtoken');
const uniqueValidator = require('mongoose-unique-validator');
const findOrCreate = require('mongoose-findorcreate');
const { BadRequestError } = require('../errors');
const { secret } = require('../config');
const { Schema } = mongoose;
const UserSchema = Schema({
username: {
type: String,
unique: true,
validate: /^\S*$/,
minLength: 3,
maxLength: 50,
trim: true,
sparse: true,
},
googleId: {
type: String,
unique: true,
sparse: true,
},
lists: [{ type: Schema.Types.ObjectId, ref: 'TodoList' }],
todos: [{ type: Schema.Types.ObjectId, ref: 'Todo' }],
});
UserSchema.plugin(passportLocalMongoose, {
limitAttempts: true,
maxAttempts: 20,
});
UserSchema.plugin(uniqueValidator);
UserSchema.plugin(findOrCreate);
UserSchema.pre('validate', async function() {
if (!this.username && !this.googleId) {
throw new BadRequestError('username is required');
}
});
UserSchema.pre('remove', async function() {
await this.model('TodoList')
.find({ user: this._id })
.remove()
.exec();
await this.model('Todo')
.find({ user: this._id })
.remove()
.exec();
});
UserSchema.methods.generateJwt = function() {
return jwt.sign({ id: this._id, username: this.username }, secret, {
expiresIn: '120d',
});
};
UserSchema.methods.toJson = function() {
return {
id: this._id,
username: this.username,
};
};
UserSchema.methods.toAuthJson = function() {
return {
id: this._id,
username: this.username,
jwt: this.generateJwt(),
};
};
mongoose.model('User', UserSchema);

5637
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,12 +5,22 @@
"description": "", "description": "",
"main": "app.js", "main": "app.js",
"scripts": { "scripts": {
"start": "node ./app.js", "start": "node ./src/app.js",
"dev": "npx concurrently npm:client npm:server -c 'blue,green'", "dev": "npx concurrently npm:client npm:server -c 'blue,green'",
"client": "cd client && npm start", "client": "cd client && npm start",
"server": "npx cross-env NODE_ENV=development npx nodemon --inspect ./app.js", "server": "npx cross-env NODE_ENV=development npx nodemon --inspect ./src/app.js",
"test": "npx cross-env NODE_ENV=test jest --runInBand", "test": "npx cross-env NODE_ENV=test jest --runInBand",
"heroku-postbuild": "cd client && npm install && npm run build" "test-frontend": "cd client && npm test",
"test-all": "npm test && npm run test-frontend",
"heroku-postbuild": "cd client && npm install && npm run build",
"lint": "eslint ./src/** --ext .js,.jsx,.ts,.tsx",
"lint-fix": "eslint ./src/** --ext .js,.jsx,.ts,.tsx --fix",
"lint-frontend": "cd client && npm run lint",
"lint-frontend-fix": "cd client && npm run lint-fix",
"lint-all": "npm run lint && npm run lint-frontend",
"lint-all-fix": "npm run lint-fix && npm run lint-frontend-fix",
"prettier-check": "prettier ./src/ ./client/src/ --check",
"prettify": "prettier ./src/ ./client/src/ --write"
}, },
"cacheDirectories": [ "cacheDirectories": [
"client/node_modules", "client/node_modules",
@@ -30,36 +40,36 @@
"express-jwt": "^6.0.0", "express-jwt": "^6.0.0",
"hsts": "^2.2.0", "hsts": "^2.2.0",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"mongoose": "^5.10.9", "mongoose": "^5.12.0",
"mongoose-findorcreate": "^3.0.0", "mongoose-findorcreate": "^3.0.0",
"mongoose-unique-validator": "^2.0.3", "mongoose-unique-validator": "^2.0.3",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"passport": "^0.4.1", "passport": "^0.4.1",
"passport-google-oauth": "^2.0.0", "passport-google-oauth": "^2.0.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"passport-local-mongoose": "^6.0.1" "passport-local-mongoose": "^6.1.0"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^5.3.0", "concurrently": "^6.0.0",
"cross-env": "^7.0.2", "cross-env": "^7.0.3",
"eslint": "^6.0.0", "eslint": "^7.22.0",
"eslint-config-airbnb-base": "^14.2.0", "eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^6.12.0", "eslint-config-prettier": "^8.1.0",
"eslint-plugin-import": "^2.22.1", "eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^23.20.0", "eslint-plugin-jest": "^24.2.1",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^3.1.4", "eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.21.4", "eslint-plugin-react": "^7.22.0",
"jest": "^24.0.0", "jest": "26.6.0",
"mongodb-memory-server": "^6.9.2", "mongodb-memory-server": "^6.9.6",
"nodemon": "^2.0.5", "nodemon": "^2.0.7",
"prettier-eslint": "^11.0.0", "prettier-eslint": "^12.0.0",
"supertest": "^5.0.0" "supertest": "^6.1.3"
}, },
"jest": { "jest": {
"testEnvironment": "node", "testEnvironment": "node",
"roots": [ "roots": [
"<rootDir>/tests/" "<rootDir>/src/tests/"
] ]
} }
} }

View File

@@ -1,7 +0,0 @@
const jwt = require('express-jwt');
const { secret } = require('../config');
module.exports = {
required: jwt({ secret, algorithms: ['HS256'] }),
optional: jwt({ secret, credentialsRequired: false, algorithms: ['HS256'] }),
};

View File

@@ -1,23 +0,0 @@
const express = require('express');
const passport = require('passport');
const router = express.Router();
const asyncHelper = require('../asyncHelper');
router.get(
'/google',
passport.authenticate('google', {
scope: ['https://www.googleapis.com/auth/plus.login'],
}),
);
router.get(
'/google/callback',
passport.authenticate('google', { session: false, failWithError: true }),
asyncHelper(async (req, res) => {
res.redirect(`/login?jwt=${req.user.generateJwt()}`);
}),
);
module.exports = router;

View File

@@ -1,74 +0,0 @@
const express = require('express');
const mongoose = require('mongoose');
const router = express.Router();
const TodoList = mongoose.model('TodoList');
const asyncHelper = require('../asyncHelper');
const { NotFoundError } = require('../errors');
// index
router.get(
'/',
asyncHelper(async (req, res) => {
const lists = await TodoList.find({ user: req.user.id })
.populate('todos')
.exec();
res.json({ success: true, data: lists.map(list => { list.todos.reverse(); return list.toJson() }) });
}),
);
// create
router.post(
'/',
asyncHelper(async (req, res) => {
const { name } = req.body;
const { id } = req.body || mongoose.Types.ObjectId();
const newList = new TodoList({ name, user: req.user.id, _id: id });
await newList.save();
res.json({ success: true, data: newList.toJson() });
}),
);
// delete
router.delete(
'/:listId',
asyncHelper(async (req, res) => {
const { listId } = req.params;
const list = await TodoList.findOne({
_id: listId,
user: req.user.id,
}).exec();
await list.remove();
res.json({ success: true });
}),
);
// update
router.patch(
'/:listId',
asyncHelper(async (req, res) => {
const { listId } = req.params;
const { name } = req.body;
const list = await TodoList.findOne({ _id: listId, user: req.user.id });
if (!list) {
throw new NotFoundError("can't find list");
}
if (name !== undefined) {
list.name = name;
}
await list.save();
res.json({ success: true, data: list.toJson() });
}),
);
router.use(
'/:listId/todos',
(req, res, next) => {
res.locals.listId = req.params.listId;
next();
},
require('./todos'),
);
module.exports = router;

View File

@@ -1,71 +0,0 @@
const express = require('express');
const mongoose = require('mongoose');
const router = express.Router();
const Todo = mongoose.model('Todo');
const asyncHelper = require('../asyncHelper');
const { NotFoundError } = require('../errors');
// index
router.get(
'/',
asyncHelper(async (req, res) => {
const { listId } = res.locals || req.body;
const todos = listId
? await Todo.find({ list: listId, user: req.user.id }).exec()
: await Todo.find({ user: req.user.id }).exec();
res.json({ success: true, data: todos.reverse().map(todo => todo.toJson()) });
}),
);
// create
router.post(
'/',
asyncHelper(async (req, res) => {
const { listId } = res.locals || req.body;
const { text } = req.body;
const { id } = req.body || mongoose.Types.ObjectId();
const todo = new Todo({ text, list: listId, user: req.user.id, _id: id });
await todo.save();
res.json({ success: true, data: todo.toJson() });
}),
);
// update
router.patch(
'/:todoId',
asyncHelper(async (req, res) => {
const { todoId } = req.params;
const { text, completed } = req.body;
const todo = await Todo.findOne({ _id: todoId, user: req.user.id });
if (!todo) {
throw new NotFoundError("can't find todo");
}
if (text !== undefined) {
todo.text = text;
}
if (completed !== undefined) {
todo.completed = completed;
}
await todo.save();
res.json({ success: true, data: todo.toJson() });
}),
);
// delete
router.delete(
'/:todoId',
asyncHelper(async (req, res) => {
const { todoId } = req.params;
const todo = await Todo.findOne({ _id: todoId, user: req.user.id }).exec();
if (!todo) {
throw new NotFoundError(`can't find todo with id ${todoId}`);
}
await todo.remove();
res.json({ success: true });
}),
);
module.exports = router;

View File

@@ -1,93 +0,0 @@
const express = require('express');
const mongoose = require('mongoose');
const passport = require('passport');
const User = mongoose.model('User');
const router = express.Router();
const asyncHelper = require('../asyncHelper');
const auth = require('./auth');
const { googleEnabled } = require('../config').googleOAuth;
const googleOAuth = require('./google');
const { NotFoundError } = require('../errors');
router.get(
'/user',
auth.required,
asyncHelper(async (req, res) => {
const { id } = req.user;
const user = await User.findById(id).exec();
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.post(
'/',
asyncHelper(async (req, res) => {
const { username, password } = req.body;
const user = new User({ username });
await user.setPassword(password);
await user.save();
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.patch(
'/user',
auth.required,
asyncHelper(async (req, res) => {
const { username, password, google } = req.body;
const patch = {};
if (username !== undefined && username != '') {
patch.username = username;
}
if (google === null) {
patch.googleId = null;
}
let user;
if (patch !== {}) {
user = await User.findOneAndUpdate(
{ _id: req.user.id },
{ $set: patch },
{ runValidators: true, context: 'query', new: true },
).exec();
} else {
user = await User.findById(req.user.id);
}
if (!user) {
throw new NotFoundError(
`can't find user with username ${req.user.username}`,
);
}
if (password !== undefined) {
await user.setPassword(password);
await user.save();
}
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.delete(
'/user',
auth.required,
asyncHelper(async (req, res) => {
const user = await User.findById(req.user.id).exec();
await user.remove();
res.json({ success: true });
}),
);
if (googleEnabled) {
router.use('/login', googleOAuth);
}
router.post(
'/login',
passport.authenticate('local', { session: false, failWithError: true }),
asyncHelper(async (req, res) => {
res.json({ success: true, data: req.user.toAuthJson() });
}),
);
module.exports = router;

122
src/app.js Normal file
View File

@@ -0,0 +1,122 @@
require("dotenv").config();
const express = require("express");
const bodyParser = require("body-parser");
const morgan = require("morgan");
const cors = require("cors");
const path = require("path");
const hsts = require("hsts");
const compression = require("compression");
const { redirectToHTTPS } = require("express-http-to-https");
const db = require("./config/db");
const config = require("./config");
require("./models/TodoList");
require("./models/User");
require("./models/Todo");
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(cors());
app.use(compression());
process.env.NODE_ENV === "production"
? app.use(morgan("combined"))
: app.use(morgan("dev"));
if (process.env.NODE_ENV === "production" && process.env.HSTS === "true") {
app.use(redirectToHTTPS([/localhost:(\d{4})/]));
app.use(
hsts({
maxAge: 31536000,
includeSubDomains: true,
}),
);
}
const passport = require("./config/passport");
app.use(passport.initialize());
// Addresses, starting with /__, are not cached by service worker
// https://github.com/facebook/create-react-app/issues/2237
app.use("/__/users", require("./routes/users"));
const auth = require("./routes/auth");
app.use("/__/lists", auth.required, require("./routes/lists"));
app.use("/__/todos", auth.required, require("./routes/todos"));
if (
process.env.NODE_ENV === "production" ||
process.env.NODE_ENV === "development"
) {
app.use(express.static(path.join(__dirname, "client/build")));
app.use(
"*",
express.static(path.join(__dirname, "client/build/index.html")),
);
}
// 404 route
app.use((req, res) => {
res.status(404);
if (req.accepts("html")) {
res.send("404");
return;
}
if (req.accepts("json")) {
res.send({ error: "Not found" });
return;
}
res.type("txt").send("not found");
});
// handle errors
app.use((error, req, res, next) => {
if (error.status) {
res.status(error.status);
} else {
switch (error.name) {
case "ValidationError":
case "MissingPasswordError":
case "BadRequest":
case "BadRequestError":
res.status(400);
break;
case "AuthenticationError":
case "UnauthorizedError":
res.status(401);
break;
case "NotFound":
res.status(404);
break;
default:
res.status(500);
}
}
res.json({ success: false, error });
if (
process.env.NODE_ENV === "production" ||
process.env.NODE_ENV === "test"
) {
console.error(error);
}
next(error);
});
let server;
if (process.env.NODE_ENV !== "test") {
db.connect();
server = app.listen(config.app.port, () => {
console.log(`Listening on port ${config.app.port}`);
console.log("Started!");
});
} else {
server = app;
}
module.exports = server;

3
src/asyncHelper.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};

18
src/config/db.js Normal file
View File

@@ -0,0 +1,18 @@
const mongoose = require("mongoose");
const config = require("./");
async function connect() {
await mongoose.connect(config.db.uri, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
}
async function disconnect() {
await mongoose.disconnect();
}
module.exports = {
connect,
disconnect,
};

40
src/config/index.js Normal file
View File

@@ -0,0 +1,40 @@
const env = process.env.NODE_ENV;
const production = {
app: {
port: process.env.PORT || 4000,
},
db: {
uri:
process.env.DB_URI ||
process.env.MONGODB_URI ||
"mongodb://localhost/todolist",
},
googleOAuth: {
googleEnabled: process.env.GOOGLE_ENABLED
? process.env.GOOGLE_ENABLED.toUpperCase() === "TRUE"
: false,
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
googleCallback: `${process.env.HOST}/__/users/login/google/callback`,
},
secret: process.env.SECRET,
};
const development = {
...production,
secret: process.env.SECRET || "devsecret",
};
const test = {
...production,
secret: process.env.SECRET || "testsecret",
};
const config = {
production,
development,
test,
};
module.exports = config[env] || config.production;

31
src/config/passport.js Normal file
View File

@@ -0,0 +1,31 @@
const passport = require("passport");
const mongoose = require("mongoose");
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy;
const {
googleClientId,
googleClientSecret,
googleCallback,
googleEnabled,
} = require(".").googleOAuth;
const User = mongoose.model("User");
passport.use(User.createStrategy());
if (googleEnabled) {
passport.use(
new GoogleStrategy(
{
clientID: googleClientId,
clientSecret: googleClientSecret,
callbackURL: googleCallback,
},
(accessToken, refreshToken, profile, done) => {
User.findOrCreate({ googleId: profile.id }, (err, user) =>
done(err, user),
);
},
),
);
}
module.exports = passport;

21
src/errors/index.js Normal file
View File

@@ -0,0 +1,21 @@
class NotFoundError extends Error {
constructor(text, ...args) {
super(...args);
Error.captureStackTrace(this, NotFoundError);
this.name = "NotFound";
this.text = text;
this.status = 404;
}
}
class BadRequestError extends Error {
constructor(text, ...args) {
super(...args);
Error.captureStackTrace(this, NotFoundError);
this.name = "BadRequest";
this.text = text;
this.status = 400;
}
}
module.exports = { NotFoundError, BadRequestError };

50
src/models/Todo.js Normal file
View File

@@ -0,0 +1,50 @@
const mongoose = require("mongoose");
const { Schema } = mongoose;
const TodoSchema = Schema({
text: {
type: String,
required: true,
minLength: 1,
maxLength: 300,
trim: true,
},
list: { type: Schema.Types.ObjectId, ref: "TodoList", required: true },
user: { type: Schema.Types.ObjectId, ref: "User", required: true },
completed: { type: Boolean, default: false },
});
TodoSchema.pre("save", async function () {
if (this.isNew) {
const user = await this.model("User").findById(this.user);
user.todos.push(this._id);
await user.save();
const list = await this.model("TodoList").findById(this.list);
list.todos.push(this._id);
await list.save();
}
});
TodoSchema.pre("remove", async function () {
const user = await this.model("User").findById(this.user);
user.todos.splice(user.todos.indexOf(this._id), 1);
await user.save();
const list = await this.model("TodoList").findById(this.list);
list.todos.splice(list.todos.indexOf(this._id), 1);
await list.save();
});
TodoSchema.methods.toJson = function () {
return {
id: this._id.toString(),
text: this.text,
list: this.list.toString(),
user: this.user.toString(),
completed: this.completed,
};
};
mongoose.model("Todo", TodoSchema);

51
src/models/TodoList.js Normal file
View File

@@ -0,0 +1,51 @@
const mongoose = require("mongoose");
const { Schema } = mongoose;
const TodoListSchema = Schema({
name: {
type: String,
required: true,
minLength: 1,
maxLength: 100,
trim: true,
},
todos: [{ type: Schema.Types.ObjectId, ref: "Todo" }],
user: { type: Schema.Types.ObjectId, ref: "User", required: true },
});
TodoListSchema.pre("save", async function () {
if (this.isNew) {
const user = await this.model("User").findById(this.user);
user.lists.push(this._id);
await user.save();
}
});
TodoListSchema.pre("remove", async function () {
const user = await this.model("User").findById(this.user);
user.lists.splice(user.lists.indexOf(this._id), 1);
// removing todos in parallel can cause VersionError
// so we remove todos from user
const todos = await this.model("Todo").find({ list: this._id }).exec();
const ids = todos.map((todo) => todo._id);
user.todos = user.todos.filter((todo) => ids.includes(todo._id));
await user.save();
// and remove them from db
await this.model("Todo").find({ list: this._id }).remove().exec();
});
TodoListSchema.methods.toJson = function () {
const todos = this.populated("todos")
? this.todos.map((todo) => todo.toJson())
: this.todos;
return {
id: this._id.toString(),
user: this.user.toString(),
name: this.name,
todos,
};
};
mongoose.model("TodoList", TodoListSchema);

70
src/models/User.js Normal file
View File

@@ -0,0 +1,70 @@
const mongoose = require("mongoose");
const passportLocalMongoose = require("passport-local-mongoose");
const jwt = require("jsonwebtoken");
const uniqueValidator = require("mongoose-unique-validator");
const findOrCreate = require("mongoose-findorcreate");
const { BadRequestError } = require("../errors");
const { secret } = require("../config");
const { Schema } = mongoose;
const UserSchema = Schema({
username: {
type: String,
unique: true,
validate: /^\S*$/,
minLength: 3,
maxLength: 50,
trim: true,
sparse: true,
},
googleId: {
type: String,
unique: true,
sparse: true,
},
lists: [{ type: Schema.Types.ObjectId, ref: "TodoList" }],
todos: [{ type: Schema.Types.ObjectId, ref: "Todo" }],
});
UserSchema.plugin(passportLocalMongoose, {
limitAttempts: true,
maxAttempts: 20,
});
UserSchema.plugin(uniqueValidator);
UserSchema.plugin(findOrCreate);
UserSchema.pre("validate", async function () {
if (!this.username && !this.googleId) {
throw new BadRequestError("username is required");
}
});
UserSchema.pre("remove", async function () {
await this.model("TodoList").find({ user: this._id }).remove().exec();
await this.model("Todo").find({ user: this._id }).remove().exec();
});
UserSchema.methods.generateJwt = function () {
return jwt.sign({ id: this._id, username: this.username }, secret, {
expiresIn: "120d",
});
};
UserSchema.methods.toJson = function () {
return {
id: this._id,
username: this.username,
};
};
UserSchema.methods.toAuthJson = function () {
return {
id: this._id,
username: this.username,
jwt: this.generateJwt(),
};
};
mongoose.model("User", UserSchema);

11
src/routes/auth.js Normal file
View File

@@ -0,0 +1,11 @@
const jwt = require("express-jwt");
const { secret } = require("../config");
module.exports = {
required: jwt({ secret, algorithms: ["HS256"] }),
optional: jwt({
secret,
credentialsRequired: false,
algorithms: ["HS256"],
}),
};

23
src/routes/google.js Normal file
View File

@@ -0,0 +1,23 @@
const express = require("express");
const passport = require("passport");
const router = express.Router();
const asyncHelper = require("../asyncHelper");
router.get(
"/google",
passport.authenticate("google", {
scope: ["https://www.googleapis.com/auth/plus.login"],
}),
);
router.get(
"/google/callback",
passport.authenticate("google", { session: false, failWithError: true }),
asyncHelper(async (req, res) => {
res.redirect(`/login?jwt=${req.user.generateJwt()}`);
}),
);
module.exports = router;

80
src/routes/lists.js Normal file
View File

@@ -0,0 +1,80 @@
const express = require("express");
const mongoose = require("mongoose");
const router = express.Router();
const TodoList = mongoose.model("TodoList");
const asyncHelper = require("../asyncHelper");
const { NotFoundError } = require("../errors");
// index
router.get(
"/",
asyncHelper(async (req, res) => {
const lists = await TodoList.find({ user: req.user.id })
.populate("todos")
.exec();
res.json({
success: true,
data: lists.map((list) => {
list.todos.reverse();
return list.toJson();
}),
});
}),
);
// create
router.post(
"/",
asyncHelper(async (req, res) => {
const { name } = req.body;
const { id } = req.body || mongoose.Types.ObjectId();
const newList = new TodoList({ name, user: req.user.id, _id: id });
await newList.save();
res.json({ success: true, data: newList.toJson() });
}),
);
// delete
router.delete(
"/:listId",
asyncHelper(async (req, res) => {
const { listId } = req.params;
const list = await TodoList.findOne({
_id: listId,
user: req.user.id,
}).exec();
await list.remove();
res.json({ success: true });
}),
);
// update
router.patch(
"/:listId",
asyncHelper(async (req, res) => {
const { listId } = req.params;
const { name } = req.body;
const list = await TodoList.findOne({ _id: listId, user: req.user.id });
if (!list) {
throw new NotFoundError("can't find list");
}
if (name !== undefined) {
list.name = name;
}
await list.save();
res.json({ success: true, data: list.toJson() });
}),
);
router.use(
"/:listId/todos",
(req, res, next) => {
res.locals.listId = req.params.listId;
next();
},
require("./todos"),
);
module.exports = router;

82
src/routes/todos.js Normal file
View File

@@ -0,0 +1,82 @@
const express = require("express");
const mongoose = require("mongoose");
const router = express.Router();
const Todo = mongoose.model("Todo");
const asyncHelper = require("../asyncHelper");
const { NotFoundError } = require("../errors");
// index
router.get(
"/",
asyncHelper(async (req, res) => {
const { listId } = res.locals || req.body;
const todos = listId
? await Todo.find({ list: listId, user: req.user.id }).exec()
: await Todo.find({ user: req.user.id }).exec();
res.json({
success: true,
data: todos.reverse().map((todo) => todo.toJson()),
});
}),
);
// create
router.post(
"/",
asyncHelper(async (req, res) => {
const { listId } = res.locals || req.body;
const { text } = req.body;
const { id } = req.body || mongoose.Types.ObjectId();
const todo = new Todo({
text,
list: listId,
user: req.user.id,
_id: id,
});
await todo.save();
res.json({ success: true, data: todo.toJson() });
}),
);
// update
router.patch(
"/:todoId",
asyncHelper(async (req, res) => {
const { todoId } = req.params;
const { text, completed } = req.body;
const todo = await Todo.findOne({ _id: todoId, user: req.user.id });
if (!todo) {
throw new NotFoundError("can't find todo");
}
if (text !== undefined) {
todo.text = text;
}
if (completed !== undefined) {
todo.completed = completed;
}
await todo.save();
res.json({ success: true, data: todo.toJson() });
}),
);
// delete
router.delete(
"/:todoId",
asyncHelper(async (req, res) => {
const { todoId } = req.params;
const todo = await Todo.findOne({
_id: todoId,
user: req.user.id,
}).exec();
if (!todo) {
throw new NotFoundError(`can't find todo with id ${todoId}`);
}
await todo.remove();
res.json({ success: true });
}),
);
module.exports = router;

93
src/routes/users.js Normal file
View File

@@ -0,0 +1,93 @@
const express = require("express");
const mongoose = require("mongoose");
const passport = require("passport");
const User = mongoose.model("User");
const router = express.Router();
const asyncHelper = require("../asyncHelper");
const auth = require("./auth");
const { googleEnabled } = require("../config").googleOAuth;
const googleOAuth = require("./google");
const { NotFoundError } = require("../errors");
router.get(
"/user",
auth.required,
asyncHelper(async (req, res) => {
const { id } = req.user;
const user = await User.findById(id).exec();
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.post(
"/",
asyncHelper(async (req, res) => {
const { username, password } = req.body;
const user = new User({ username });
await user.setPassword(password);
await user.save();
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.patch(
"/user",
auth.required,
asyncHelper(async (req, res) => {
const { username, password, google } = req.body;
const patch = {};
if (username !== undefined && username != "") {
patch.username = username;
}
if (google === null) {
patch.googleId = null;
}
let user;
if (patch !== {}) {
user = await User.findOneAndUpdate(
{ _id: req.user.id },
{ $set: patch },
{ runValidators: true, context: "query", new: true },
).exec();
} else {
user = await User.findById(req.user.id);
}
if (!user) {
throw new NotFoundError(
`can't find user with username ${req.user.username}`,
);
}
if (password !== undefined) {
await user.setPassword(password);
await user.save();
}
res.json({ success: true, data: user.toAuthJson() });
}),
);
router.delete(
"/user",
auth.required,
asyncHelper(async (req, res) => {
const user = await User.findById(req.user.id).exec();
await user.remove();
res.json({ success: true });
}),
);
if (googleEnabled) {
router.use("/login", googleOAuth);
}
router.post(
"/login",
passport.authenticate("local", { session: false, failWithError: true }),
asyncHelper(async (req, res) => {
res.json({ success: true, data: req.user.toAuthJson() });
}),
);
module.exports = router;

5
src/tests/.eslintrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"rules": {
"node/no-unpublished-require": "off"
}
}

View File

@@ -0,0 +1,165 @@
const request = require("supertest");
const mongoose = require("mongoose");
require("../../models/Todo");
require("../../models/TodoList");
require("../../models/User");
const Todo = mongoose.model("Todo");
const TodoList = mongoose.model("TodoList");
const User = mongoose.model("User");
jest.setTimeout(60000);
const MongoDBMemoryServer = require("mongodb-memory-server").default;
const server = require("../../app.js");
const { seed, clean, mongodbMemoryServerConfig } = require("./utils");
let user;
let token;
let list;
let todo;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ user, token, list, todo } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe("test lists", () => {
test("should index lists", async () => {
const response = await request(server)
.get("/__/lists")
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].name).toEqual("List1");
});
test("should not index lists without authentication", async () => {
await request(server)
.get("/__/lists")
.set("Accept", "application/json")
.expect(401);
});
test("should create list", async () => {
const response = await request(server)
.post("/__/lists")
.send({
name: "List2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: "List2" })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map((l) => String(l))).toContain(
response.body.data.id,
);
});
test("should create list with custom id", async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post("/__/lists")
.send({
name: "List2",
id,
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: "List2", _id: id })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map((l) => String(l))).toContain(
response.body.data.id,
);
});
test("should not create list without authentication", async () => {
await request(server)
.post("/__/lists")
.send({
name: "List2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
});
test("should update list", async () => {
const response = await request(server)
.patch(`/__/lists/${list._id}`)
.send({
name: "List2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: "List2" })).toBeTruthy();
});
test("should not update list without authentication", async () => {
await request(server)
.patch(`/__/lists/${list._id}`)
.send({
name: "List2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(await TodoList.findOne({ name: "List2" })).toBeFalsy();
});
test("should remove list", async () => {
const response = await request(server)
.delete(`/__/lists/${list._id}`)
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: "List1" }).exec()).toBeFalsy();
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeFalsy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists).not.toContain(list._id);
expect(freshUser.todos).not.toContain(todo._id);
});
test("should not remove list without authentication", async () => {
await request(server)
.delete(`/__/lists/${list._id}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(await TodoList.findOne({ name: "List1" }).exec()).toBeTruthy();
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map((l) => String(l))).toContain(
String(list._id),
);
expect(freshUser.todos.map((t) => String(t))).toContain(
String(todo._id),
);
});
});

View File

@@ -0,0 +1,30 @@
const server = require("../../app.js");
const request = require("supertest");
describe("test not found", () => {
test("respond not found with json", async () => {
const response = await request(server)
.get("/")
.set("Accept", "application/json")
.expect(404)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body).toEqual({ error: "Not found" });
});
test("respond not found with html", async () => {
const response = await request(server)
.get("/")
.set("Accept", "text/html")
.expect(404)
.expect("Content-Type", "text/html; charset=utf-8");
expect(response.text).toEqual("404");
});
test("respond not found with plain text", async () => {
const response = await request(server)
.get("/")
.set("Accept", "text/plain")
.expect(404)
.expect("Content-Type", "text/plain; charset=utf-8");
expect(response.text).toEqual("not found");
});
});

View File

@@ -0,0 +1,181 @@
const request = require("supertest");
const mongoose = require("mongoose");
require("../../models/Todo");
require("../../models/TodoList");
require("../../models/User");
const Todo = mongoose.model("Todo");
const TodoList = mongoose.model("TodoList");
const User = mongoose.model("User");
jest.setTimeout(60000);
const MongoDBMemoryServer = require("mongodb-memory-server").default;
const server = require("../../app.js");
const { seed, clean, mongodbMemoryServerConfig } = require("./utils");
let user;
let token;
let list;
let todo;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ user, token, list, todo } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe("test todos", () => {
test("should index todos", async () => {
const response = await request(server)
.get(`/__/lists/${list._id}/todos`)
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].text).toEqual("Todo1");
});
test("should index all todos", async () => {
const response = await request(server)
.get(`/__/todos`)
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].text).toEqual("Todo1");
});
test("should not index todos without authentication", async () => {
await request(server)
.get(`/__/lists/${list._id}/todos`)
.set("Accept", "application/json")
.expect(401);
});
test("should create todo", async () => {
const response = await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: "Todo2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(
await Todo.findOne({ text: "Todo2", list: list._id }),
).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos.map((t) => String(t))).toContain(
response.body.data.id,
);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos.map((t) => String(t))).toContain(
response.body.data.id,
);
});
test("should create todo with custom id", async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: "Todo2",
id,
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(
await Todo.findOne({ text: "Todo2", list: list._id, _id: id }),
).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos.map((t) => String(t))).toContain(
response.body.data.id,
);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos.map((t) => String(t))).toContain(
response.body.data.id,
);
});
test("should not create todo without authentication", async () => {
await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: "Todo1",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
});
test("should update todo", async () => {
const response = await request(server)
.patch(`/__/lists/${list._id}/todos/${todo._id}`)
.send({
text: "Todo2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: "Todo2" })).toBeTruthy();
expect(await Todo.findOne({ text: "Todo1" })).toBeFalsy();
});
test("should not update todo without authentication", async () => {
await request(server)
.patch(`/__/lists/${list._id}/todos/${todo._id}`)
.send({
text: "Todo2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(await Todo.findOne({ text: "Todo1" })).toBeTruthy();
expect(await Todo.findOne({ text: "Todo2" })).toBeFalsy();
});
test("should remove todo", async () => {
const response = await request(server)
.delete(`/__/lists/${list._id}/todos/${todo._id}`)
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeFalsy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos).not.toContain(todo.id);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos).not.toContain(todo.id);
});
test("should not remove todo without authentication", async () => {
await request(server)
.delete(`/__/lists/${list._id}/todos/${todo._id}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeTruthy();
});
});

View File

@@ -0,0 +1,189 @@
const request = require("supertest");
const mongoose = require("mongoose");
const jwt = require("jsonwebtoken");
require("../../models/Todo");
require("../../models/TodoList");
require("../../models/User");
const Todo = mongoose.model("Todo");
const TodoList = mongoose.model("TodoList");
const User = mongoose.model("User");
jest.setTimeout(60000);
const MongoDBMemoryServer = require("mongodb-memory-server").default;
const server = require("../../app.js");
const { seed, clean, mongodbMemoryServerConfig } = require("./utils");
const { secret } = require("../../config");
let token;
let user;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ token, user } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe("test users", () => {
test("should get user", async () => {
const response = await request(server)
.get("/__/users/user")
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(response.body.data.id).toBe(user._id.toString());
expect(response.body.data.username).toBe(user.username);
});
test("should create user", async () => {
const response = await request(server)
.post("/__/users")
.send({
username: "User2",
password: "password2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual("User2");
const userAuth = await User.authenticate()("User2", "password2");
expect(userAuth.user).toBeTruthy();
});
test("should not create user with no username", async () => {
const response = await request(server)
.post("/__/users")
.send({
username: "",
password: "password2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(400)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeFalsy();
});
test("should not create user with no password", async () => {
const response = await request(server)
.post("/__/users")
.send({
username: "User",
password: "",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(400)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeFalsy();
});
test("should login user", async () => {
const response = await request(server)
.post("/__/users/login")
.send({
username: "User1",
password: "password1",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual("User1");
});
test("should not login user with no name", async () => {
await request(server)
.post("/__/users/login")
.send({
username: "",
password: "notpassword",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(400);
});
test("should not login user with wrong password", async () => {
await request(server)
.post("/__/users/login")
.send({
username: "User",
password: "notpassword",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
});
test("should update user", async () => {
const response = await request(server)
.patch("/__/users/user")
.send({
username: "User2",
password: "password2",
})
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual("User2");
const userAuth = await User.authenticate()("User2", "password2");
expect(userAuth.user).toBeTruthy();
});
test("should not update user without authentication", async () => {
const response = await request(server)
.patch("/__/users/user")
.send({
username: "User2",
password: "password2",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(response.body.success).toBeFalsy();
});
test("should not delete user without authentication", async () => {
const response = await request(server)
.delete("/__/users/user")
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(401);
expect(response.body.success).toBeFalsy();
expect(await User.findOne({ username: "User1" }).exec()).toBeTruthy();
expect(await TodoList.findOne({ name: "List1" }).exec()).toBeTruthy();
expect(await Todo.findOne({ text: "Todo1" })).toBeTruthy();
});
test("should delete user", async () => {
const response = await request(server)
.delete("/__/users/user")
.set("Authorization", `Bearer ${token}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.expect(200)
.expect("Content-Type", "application/json; charset=utf-8");
expect(response.body.success).toBeTruthy();
expect(await User.findOne({ username: "User1" }).exec()).toBeFalsy();
expect(await TodoList.findOne({ name: "List1" }).exec()).toBeFalsy();
expect(await Todo.findOne({ text: "Todo1" }).exec()).toBeFalsy();
});
});

View File

@@ -0,0 +1,46 @@
const mongoose = require("mongoose");
require("../../models/Todo");
require("../../models/TodoList");
require("../../models/User");
const User = mongoose.model("User");
const Todo = mongoose.model("Todo");
const TodoList = mongoose.model("TodoList");
async function seed() {
const user = new User({ username: "User1" });
await user.setPassword("password1");
await user.save();
const token = user.generateJwt();
const list = new TodoList({ name: "List1", user: user._id });
const todo = new Todo({ text: "Todo1", list: list._id, user: user._id });
await list.save();
await todo.save();
return {
user,
token,
list,
todo,
};
}
async function clean() {
await TodoList.remove({}).exec();
await Todo.remove({}).exec();
await User.remove({}).exec();
}
const mongodbMemoryServerConfig = {
binary: {
version: "4.0.14",
},
instance: {
args: ["--enableMajorityReadConcern=false"],
},
};
module.exports = { seed, clean, mongodbMemoryServerConfig };

View File

@@ -1,5 +0,0 @@
{
"rules": {
"node/no-unpublished-require": "off"
}
}

View File

@@ -1,157 +0,0 @@
const request = require('supertest');
const mongoose = require('mongoose');
require('../../models/Todo');
require('../../models/TodoList');
require('../../models/User');
const Todo = mongoose.model('Todo');
const TodoList = mongoose.model('TodoList');
const User = mongoose.model('User');
jest.setTimeout(60000);
const MongoDBMemoryServer = require('mongodb-memory-server').default;
const server = require('../../app.js');
const { seed, clean, mongodbMemoryServerConfig } = require('./utils');
let user;
let token;
let list;
let todo;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ user, token, list, todo } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe('test lists', () => {
test('should index lists', async () => {
const response = await request(server)
.get('/__/lists')
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].name).toEqual('List1');
});
test('should not index lists without authentication', async () => {
await request(server)
.get('/__/lists')
.set('Accept', 'application/json')
.expect(401);
});
test('should create list', async () => {
const response = await request(server)
.post('/__/lists')
.send({
name: 'List2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: 'List2' })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map(l => String(l))).toContain(response.body.data.id);
});
test('should create list with custom id', async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post('/__/lists')
.send({
name: 'List2',
id,
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: 'List2', _id: id })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map(l => String(l))).toContain(response.body.data.id);
});
test('should not create list without authentication', async () => {
await request(server)
.post('/__/lists')
.send({
name: 'List2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
});
test('should update list', async () => {
const response = await request(server)
.patch(`/__/lists/${list._id}`)
.send({
name: 'List2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: 'List2' })).toBeTruthy();
});
test('should not update list without authentication', async () => {
await request(server)
.patch(`/__/lists/${list._id}`)
.send({
name: 'List2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(await TodoList.findOne({ name: 'List2' })).toBeFalsy();
});
test('should remove list', async () => {
const response = await request(server)
.delete(`/__/lists/${list._id}`)
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await TodoList.findOne({ name: 'List1' }).exec()).toBeFalsy();
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeFalsy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists).not.toContain(list._id);
expect(freshUser.todos).not.toContain(todo._id);
});
test('should not remove list without authentication', async () => {
await request(server)
.delete(`/__/lists/${list._id}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(await TodoList.findOne({ name: 'List1' }).exec()).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists.map(l => String(l))).toContain(String(list._id));
expect(freshUser.todos.map(t => String(t))).toContain(String(todo._id));
});
});

View File

@@ -1,30 +0,0 @@
const server = require('../../app.js');
const request = require('supertest');
describe('test not found', () => {
test('respond not found with json', async () => {
const response = await request(server)
.get('/')
.set('Accept', 'application/json')
.expect(404)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body).toEqual({ error: 'Not found' });
});
test('respond not found with html', async () => {
const response = await request(server)
.get('/')
.set('Accept', 'text/html')
.expect(404)
.expect('Content-Type', 'text/html; charset=utf-8');
expect(response.text).toEqual('404');
});
test('respond not found with plain text', async () => {
const response = await request(server)
.get('/')
.set('Accept', 'text/plain')
.expect(404)
.expect('Content-Type', 'text/plain; charset=utf-8');
expect(response.text).toEqual('not found');
});
});

View File

@@ -1,171 +0,0 @@
const request = require('supertest');
const mongoose = require('mongoose');
require('../../models/Todo');
require('../../models/TodoList');
require('../../models/User');
const Todo = mongoose.model('Todo');
const TodoList = mongoose.model('TodoList');
const User = mongoose.model('User');
jest.setTimeout(60000);
const MongoDBMemoryServer = require('mongodb-memory-server').default;
const server = require('../../app.js');
const { seed, clean, mongodbMemoryServerConfig } = require('./utils');
let user;
let token;
let list;
let todo;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ user, token, list, todo } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe('test todos', () => {
test('should index todos', async () => {
const response = await request(server)
.get(`/__/lists/${list._id}/todos`)
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].text).toEqual('Todo1');
});
test('should index all todos', async () => {
const response = await request(server)
.get(`/__/todos`)
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(response.body.data[0].text).toEqual('Todo1');
});
test('should not index todos without authentication', async () => {
await request(server)
.get(`/__/lists/${list._id}/todos`)
.set('Accept', 'application/json')
.expect(401);
});
test('should create todo', async () => {
const response = await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: 'Todo2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo2', list: list._id })).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos.map(t => String(t))).toContain(response.body.data.id);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos.map(t => String(t))).toContain(response.body.data.id);
});
test('should create todo with custom id', async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: 'Todo2',
id,
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(
await Todo.findOne({ text: 'Todo2', list: list._id, _id: id }),
).toBeTruthy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos.map(t => String(t))).toContain(response.body.data.id);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos.map(t => String(t))).toContain(response.body.data.id);
});
test('should not create todo without authentication', async () => {
await request(server)
.post(`/__/lists/${list._id}/todos`)
.send({
text: 'Todo1',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
});
test('should update todo', async () => {
const response = await request(server)
.patch(`/__/lists/${list._id}/todos/${todo._id}`)
.send({
text: 'Todo2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo2' })).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo1' })).toBeFalsy();
});
test('should not update todo without authentication', async () => {
await request(server)
.patch(`/__/lists/${list._id}/todos/${todo._id}`)
.send({
text: 'Todo2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(await Todo.findOne({ text: 'Todo1' })).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo2' })).toBeFalsy();
});
test('should remove todo', async () => {
const response = await request(server)
.delete(`/__/lists/${list._id}/todos/${todo._id}`)
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeFalsy();
const freshUser = await User.findById(user.id).exec();
expect(freshUser.todos).not.toContain(todo.id);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos).not.toContain(todo.id);
});
test('should not remove todo without authentication', async () => {
await request(server)
.delete(`/__/lists/${list._id}/todos/${todo._id}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeTruthy();
});
});

View File

@@ -1,189 +0,0 @@
const request = require('supertest');
const mongoose = require('mongoose');
const jwt = require('jsonwebtoken');
require('../../models/Todo');
require('../../models/TodoList');
require('../../models/User');
const Todo = mongoose.model('Todo');
const TodoList = mongoose.model('TodoList');
const User = mongoose.model('User');
jest.setTimeout(60000);
const MongoDBMemoryServer = require('mongodb-memory-server').default;
const server = require('../../app.js');
const { seed, clean, mongodbMemoryServerConfig } = require('./utils');
const { secret } = require('../../config');
let token;
let user;
let mongoServer;
beforeAll(async () => {
mongoServer = new MongoDBMemoryServer(mongodbMemoryServerConfig);
const mongoUri = await mongoServer.getUri();
await mongoose.connect(mongoUri);
});
beforeEach(async () => {
({ token, user } = await seed());
});
afterEach(async () => {
await clean();
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
describe('test users', () => {
test('should get user', async () => {
const response = await request(server)
.get('/__/users/user')
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(response.body.data.id).toBe(user._id.toString());
expect(response.body.data.username).toBe(user.username);
});
test('should create user', async () => {
const response = await request(server)
.post('/__/users')
.send({
username: 'User2',
password: 'password2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual('User2');
const userAuth = await User.authenticate()('User2', 'password2');
expect(userAuth.user).toBeTruthy();
});
test('should not create user with no username', async () => {
const response = await request(server)
.post('/__/users')
.send({
username: '',
password: 'password2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(400)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeFalsy();
});
test('should not create user with no password', async () => {
const response = await request(server)
.post('/__/users')
.send({
username: 'User',
password: '',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(400)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeFalsy();
});
test('should login user', async () => {
const response = await request(server)
.post('/__/users/login')
.send({
username: 'User1',
password: 'password1',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual('User1');
});
test('should not login user with no name', async () => {
await request(server)
.post('/__/users/login')
.send({
username: '',
password: 'notpassword',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(400);
});
test('should not login user with wrong password', async () => {
await request(server)
.post('/__/users/login')
.send({
username: 'User',
password: 'notpassword',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
});
test('should update user', async () => {
const response = await request(server)
.patch('/__/users/user')
.send({
username: 'User2',
password: 'password2',
})
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
const tokenDecoded = jwt.verify(response.body.data.jwt, secret);
expect(tokenDecoded.username).toEqual('User2');
const userAuth = await User.authenticate()('User2', 'password2');
expect(userAuth.user).toBeTruthy();
});
test('should not update user without authentication', async () => {
const response = await request(server)
.patch('/__/users/user')
.send({
username: 'User2',
password: 'password2',
})
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(response.body.success).toBeFalsy();
});
test('should not delete user without authentication', async () => {
const response = await request(server)
.delete('/__/users/user')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(401);
expect(response.body.success).toBeFalsy();
expect(await User.findOne({ username: 'User1' }).exec()).toBeTruthy();
expect(await TodoList.findOne({ name: 'List1' }).exec()).toBeTruthy();
expect(await Todo.findOne({ text: 'Todo1' })).toBeTruthy();
});
test('should delete user', async () => {
const response = await request(server)
.delete('/__/users/user')
.set('Authorization', `Bearer ${token}`)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', 'application/json; charset=utf-8');
expect(response.body.success).toBeTruthy();
expect(await User.findOne({ username: 'User1' }).exec()).toBeFalsy();
expect(await TodoList.findOne({ name: 'List1' }).exec()).toBeFalsy();
expect(await Todo.findOne({ text: 'Todo1' }).exec()).toBeFalsy();
});
});

View File

@@ -1,46 +0,0 @@
const mongoose = require('mongoose');
require('../../models/Todo');
require('../../models/TodoList');
require('../../models/User');
const User = mongoose.model('User');
const Todo = mongoose.model('Todo');
const TodoList = mongoose.model('TodoList');
async function seed() {
const user = new User({ username: 'User1' });
await user.setPassword('password1');
await user.save();
const token = user.generateJwt();
const list = new TodoList({ name: 'List1', user: user._id });
const todo = new Todo({ text: 'Todo1', list: list._id, user: user._id });
await list.save();
await todo.save();
return {
user,
token,
list,
todo,
};
}
async function clean() {
await TodoList.remove({}).exec();
await Todo.remove({}).exec();
await User.remove({}).exec();
}
const mongodbMemoryServerConfig = {
binary: {
version: 'latest',
},
instance: {
args: ['--enableMajorityReadConcern=false'],
},
};
module.exports = { seed, clean, mongodbMemoryServerConfig };