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

11247
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

@@ -11,9 +11,9 @@ import {
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 };
@@ -38,7 +38,7 @@ export function stopEditList() {
} }
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,
@@ -54,9 +54,9 @@ export function addList(name) {
body: JSON.stringify({ name, id }), body: JSON.stringify({ name, 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,
@@ -80,9 +80,9 @@ export function removeList() {
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', method: "DELETE",
}, },
rollback: { rollback: {
type: INVALIDATE_LISTS, type: INVALIDATE_LISTS,
@@ -108,9 +108,9 @@ export function editList(name) {
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', method: "PATCH",
}, },
rollback: { rollback: {
type: INVALIDATE_LISTS, type: INVALIDATE_LISTS,
@@ -135,7 +135,7 @@ function normalizeTodos(lists) {
} }
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: {
@@ -151,7 +151,7 @@ export function fetchLists() {
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;
}, {}); }, {});

View File

@@ -6,12 +6,12 @@ import {
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: {
@@ -44,9 +44,9 @@ export function addTodo(text) {
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,
@@ -59,7 +59,7 @@ export function addTodo(text) {
} }
export function removeTodo(id) { export function removeTodo(id) {
return async dispatch => { return async (dispatch) => {
dispatch({ dispatch({
type: REMOVE_TODO, type: REMOVE_TODO,
id, id,
@@ -69,9 +69,9 @@ export function removeTodo(id) {
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', method: "DELETE",
}, },
rollback: { rollback: {
type: INVALIDATE_LISTS, type: INVALIDATE_LISTS,
@@ -97,9 +97,9 @@ export function toggleTodo(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', method: "PATCH",
}, },
rollback: { rollback: {
type: INVALIDATE_LISTS, type: INVALIDATE_LISTS,
@@ -111,7 +111,7 @@ export function toggleTodo(id) {
} }
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,
@@ -123,9 +123,9 @@ export function editTodo(id, text) {
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', method: "PATCH",
}, },
rollback: { rollback: {
type: INVALIDATE_LISTS, type: INVALIDATE_LISTS,

View File

@@ -11,10 +11,10 @@ import {
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 };
@@ -33,14 +33,14 @@ function validateUser() {
} }
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) {
@@ -56,14 +56,14 @@ export function loadUser() {
} }
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) {
@@ -77,14 +77,14 @@ export function login(user) {
} }
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) {
@@ -106,14 +106,14 @@ function signupFail(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) {
@@ -126,7 +126,6 @@ export function signup(user) {
}; };
} }
function startEdit(user) { function startEdit(user) {
return { type: EDIT_START, user }; return { type: EDIT_START, user };
} }
@@ -140,15 +139,15 @@ function editFail(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) {
@@ -160,19 +159,18 @@ export function edit(user) {
} }
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 };
} }
@@ -182,7 +180,7 @@ export function reset() {
} }
export function logout() { export function logout() {
return async dispatch => { return async (dispatch) => {
dispatch({ type: LOGOUT }); dispatch({ type: LOGOUT });
}; };
} }

View File

@@ -1,4 +1,4 @@
export const API_ROOT = '/__'; export const API_ROOT = "/__";
let token = null; let token = null;
@@ -15,7 +15,7 @@ export function mongoObjectId() {
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,4 +1,4 @@
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

@@ -64,7 +64,7 @@
#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;
@@ -215,7 +215,7 @@ textarea.todo--input {
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;
} }

View File

@@ -1,59 +1,24 @@
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() {
@@ -67,10 +32,10 @@ export default class App extends React.PureComponent {
<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>

View File

@@ -1,8 +1,8 @@
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 {
@@ -16,7 +16,4 @@ function mapDispatchToProps(dispatch) {
}; };
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(App);
mapStateToProps,
mapDispatchToProps,
)(App);

View File

@@ -1,9 +1,9 @@
@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;
} }

View File

@@ -1,6 +1,6 @@
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 (

View File

@@ -1,7 +1,7 @@
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 }) {
@@ -18,8 +18,5 @@ export default function OnlyUnauth(WrappedComponent) {
}; };
} }
return connect( return connect(mapStateToProps, null)(Component);
mapStateToProps,
null,
)(Component);
} }

View File

@@ -1,7 +1,7 @@
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 }) {
@@ -18,8 +18,5 @@ export default function Protected(WrappedComponent) {
}; };
} }
return connect( return connect(mapStateToProps, null)(Component);
mapStateToProps,
null,
)(Component);
} }

View File

@@ -1,23 +1,23 @@
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();
}} }}
@@ -45,7 +45,4 @@ function mapDispatchToProps(dispatch, ownProps) {
}; };
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(Link);
mapStateToProps,
mapDispatchToProps,
)(Link);

View File

@@ -1,12 +1,14 @@
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}>
active
</FilterLink>
<FilterLink filter={VisibilityFilters.SHOW_COMPLETED}> <FilterLink filter={VisibilityFilters.SHOW_COMPLETED}>
completed completed
</FilterLink> </FilterLink>

View File

@@ -1,11 +1,11 @@
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,
@@ -37,7 +37,7 @@ export default function ListActions({
} }
const actions = []; const actions = [];
if (!creating && !editing) { if (!creating && !editing) {
actions.push(styles => ( actions.push((styles) => (
<IconButton <IconButton
key="create" key="create"
style={{ ...button, ...styles }} style={{ ...button, ...styles }}
@@ -48,7 +48,7 @@ export default function ListActions({
)); ));
} }
if (list && !creating && !editing) { if (list && !creating && !editing) {
actions.push(styles => ( actions.push((styles) => (
<IconButton <IconButton
key="remove" key="remove"
style={{ ...button, ...styles }} style={{ ...button, ...styles }}
@@ -59,7 +59,7 @@ export default function ListActions({
)); ));
} }
if (list && !creating && !editing) { if (list && !creating && !editing) {
actions.push(styles => ( actions.push((styles) => (
<IconButton <IconButton
key="edit" key="edit"
style={{ ...button, ...styles }} style={{ ...button, ...styles }}
@@ -70,7 +70,7 @@ export default function ListActions({
)); ));
} }
if (creating || editing) { if (creating || editing) {
actions.push(styles => ( actions.push((styles) => (
<IconButton <IconButton
key="back" key="back"
style={{ ...button, ...styles }} style={{ ...button, ...styles }}
@@ -90,7 +90,7 @@ export default function ListActions({
restSpeedThreshold: 0.5, restSpeedThreshold: 0.5,
restDisplacementThreshold: 0.5, restDisplacementThreshold: 0.5,
}} }}
keys={actions.map(action => action({}).key)} keys={actions.map((action) => action({}).key)}
from={{ opacity: 0, height: 0, margin: 0, padding: 0 }} from={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
enter={{ opacity: 1, height: 30, margin: 0, padding: 0 }} enter={{ opacity: 1, height: 30, margin: 0, padding: 0 }}
leave={{ opacity: 0, height: 0, margin: 0, padding: 0 }} leave={{ opacity: 0, height: 0, margin: 0, padding: 0 }}
@@ -102,7 +102,7 @@ export default function ListActions({
} }
ListActions.defaultProps = { ListActions.defaultProps = {
list: '', list: "",
}; };
ListActions.propTypes = { ListActions.propTypes = {

View File

@@ -1,12 +1,12 @@
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 {
@@ -25,7 +25,4 @@ function mapDispatchToProps(dispatch) {
}; };
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(ListActions);
mapStateToProps,
mapDispatchToProps,
)(ListActions);

View File

@@ -1,6 +1,6 @@
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,
@@ -12,15 +12,15 @@ export default function ListInput({ onClick, children, defaultValue }) {
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);
} }
}} }}

View File

@@ -1,15 +1,14 @@
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 />

View File

@@ -1,11 +1,11 @@
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,
@@ -38,11 +38,11 @@ export default function Selector({
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>
@@ -55,7 +55,7 @@ export default function Selector({
} }
Selector.defaultProps = { Selector.defaultProps = {
list: '', list: "",
}; };
Selector.propTypes = { Selector.propTypes = {

View File

@@ -1,6 +1,6 @@
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 {
@@ -13,13 +13,10 @@ function mapStateToProps(state) {
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,18 +1,18 @@
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 {
@@ -63,13 +63,13 @@ class Todo extends React.Component {
} }
render() { render() {
const deleteClasses = ['delete']; const deleteClasses = ["delete"];
const editClasses = ['edit']; const editClasses = ["edit"];
const { hover, editing } = this.state; const { hover, editing } = this.state;
const { todo, removeTodo, toggleTodo, style } = this.props; const { todo, removeTodo, toggleTodo, style } = this.props;
if (!hover) { if (!hover) {
deleteClasses.push('disabled'); deleteClasses.push("disabled");
editClasses.push('disabled'); editClasses.push("disabled");
} }
let input; let input;
@@ -79,7 +79,7 @@ class Todo extends React.Component {
<textarea <textarea
className="todo--input" className="todo--input"
defaultValue={todo.text} defaultValue={todo.text}
ref={node => { ref={(node) => {
input = node; input = node;
}} }}
/> />
@@ -87,10 +87,10 @@ class Todo extends React.Component {
) : ( ) : (
<ButtonBase <ButtonBase
style={{ style={{
justifyContent: 'left', justifyContent: "left",
paddingLeft: '1rem', paddingLeft: "1rem",
textDecoration: todo.completed ? 'line-through' : 'none', textDecoration: todo.completed ? "line-through" : "none",
color: todo.completed ? '#888888' : 'black', color: todo.completed ? "#888888" : "black",
}} }}
className="todo" className="todo"
onClick={() => { onClick={() => {
@@ -104,7 +104,7 @@ class Todo extends React.Component {
? [ ? [
<ButtonBase <ButtonBase
key="save" key="save"
style={{ backgroundColor: 'lightgreen' }} style={{ backgroundColor: "lightgreen" }}
className="save" className="save"
onClick={() => this.stopEdit(input.value)} onClick={() => this.stopEdit(input.value)}
> >
@@ -114,16 +114,22 @@ class Todo extends React.Component {
: [ : [
<ButtonBase <ButtonBase
key="remove" key="remove"
style={hover ? { backgroundColor: 'pink' } : disabledAction} style={
className={deleteClasses.join(' ')} hover ? { backgroundColor: "pink" } : disabledAction
}
className={deleteClasses.join(" ")}
onClick={removeTodo} onClick={removeTodo}
> >
<DeleteIcon style={icon} /> <DeleteIcon style={icon} />
</ButtonBase>, </ButtonBase>,
<ButtonBase <ButtonBase
key="edit" key="edit"
style={hover ? { backgroundColor: 'lightcyan' } : disabledAction} style={
className={editClasses.join(' ')} hover
? { backgroundColor: "lightcyan" }
: disabledAction
}
className={editClasses.join(" ")}
onClick={this.startEdit} onClick={this.startEdit}
> >
<EditIcon style={icon} /> <EditIcon style={icon} />
@@ -133,7 +139,7 @@ class Todo extends React.Component {
<animated.li <animated.li
style={{ style={{
...style, ...style,
borderTop: '1px solid #f0f0f0', borderTop: "1px solid #f0f0f0",
}} }}
onMouseOver={this.onMouseOver} onMouseOver={this.onMouseOver}
onFocus={this.onMouseOver} onFocus={this.onMouseOver}

View File

@@ -1,8 +1,8 @@
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,
@@ -21,35 +21,35 @@ export default function TodosContainer({
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>

View File

@@ -1,15 +1,15 @@
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,
) )
@@ -20,15 +20,12 @@ function mapStateToProps(state) {
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,14 +1,14 @@
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() {
@@ -21,7 +21,7 @@ class Todos extends React.PureComponent {
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

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
.filter((todo) => todo)
.filter((todo) => !todo.completed);
case VisibilityFilters.SHOW_COMPLETED: case VisibilityFilters.SHOW_COMPLETED:
return todos.filter(todo => todo).filter(todo => todo.completed); return todos
.filter((todo) => todo)
.filter((todo) => todo.completed);
default: default:
return todos.filter(todo => todo); return todos.filter((todo) => todo);
} }
} }

View File

@@ -1,38 +1,42 @@
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
style={{ borderRadius: 0 }}
id="add"
onClick={() => submit()}
>
<AddIcon /> <AddIcon />
</Button> </Button>
</div> </div>
@@ -50,11 +54,8 @@ function mapStateToProps(state, 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,21 +1,21 @@
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;
} }
@@ -30,19 +30,19 @@ function EditForm({
}) { }) {
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
@@ -70,7 +70,9 @@ function EditForm({
type="password" type="password"
/> />
<div id="buttons"> <div id="buttons">
<Button onClick={() => deleteUser()}>Delete your account</Button> <Button onClick={() => deleteUser()}>
Delete your account
</Button>
<Button <Button
id="submitbutton" id="submitbutton"
variant="raised" variant="raised"
@@ -111,18 +113,11 @@ function mapDispatchToProps(dispatch) {
} }
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,17 +1,17 @@
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);
}} }}

View File

@@ -1,6 +1,6 @@
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,
@@ -16,7 +16,7 @@ export default function InputField({
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>

View File

@@ -1,23 +1,23 @@
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);
} }
} }
@@ -29,13 +29,13 @@ class LoginForm extends React.PureComponent {
<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
@@ -64,7 +64,7 @@ class LoginForm extends React.PureComponent {
id="googlebutton" id="googlebutton"
variant="raised" variant="raised"
onClick={() => { onClick={() => {
window.location = '/__/users/login/google/'; window.location = "/__/users/login/google/";
}} }}
> >
Google Google
@@ -105,21 +105,14 @@ function mapDispatchToProps(dispatch) {
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,19 +1,19 @@
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();
}} }}
@@ -34,7 +34,4 @@ function mapDispatchToProps(dispatch) {
}; };
} }
export default connect( export default connect(null, mapDispatchToProps)(Link);
null,
mapDispatchToProps,
)(Link);

View File

@@ -1,21 +1,21 @@
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;
} }
@@ -26,13 +26,13 @@ function SignupForm({ handleSubmit, onSignup, user, history, resetUser }) {
<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
@@ -101,18 +101,11 @@ function mapDispatchToProps(dispatch) {
} }
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,17 +1,17 @@
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.name === "ValidationError") {
if (user.errors.message.split(' ').includes('unique.')) { if (user.errors.message.split(" ").includes("unique.")) {
errors.push( errors.push(
<div key="exists" className="error"> <div key="exists" className="error">
User already exists User already exists

View File

@@ -1,12 +1,12 @@
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,16 +1,16 @@
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;
@@ -23,7 +23,7 @@ const persistCallback = () => {
<Provider store={store}> <Provider store={store}>
<AppContainer /> <AppContainer />
</Provider>, </Provider>,
document.getElementById('root'), document.getElementById("root"),
); );
}; };

View File

@@ -1,9 +1,9 @@
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;

View File

@@ -1,10 +1,10 @@
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,

View File

@@ -14,7 +14,7 @@ import {
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 = {
@@ -45,7 +45,7 @@ export default function lists(
const newLists = Object.values(action.lists); const newLists = Object.values(action.lists);
let { list } = state; let { list } = state;
if (newLists.length !== 0) { if (newLists.length !== 0) {
if (!newLists.some(curList => curList.id === list)) { if (!newLists.some((curList) => curList.id === list)) {
list = newLists[0].id || null; list = newLists[0].id || null;
} }
} else { } else {
@@ -81,7 +81,9 @@ export default function lists(
const newLists = { ...state.lists }; const newLists = { ...state.lists };
delete newLists[action.list]; delete newLists[action.list];
const listsObjs = Object.values(newLists); const listsObjs = Object.values(newLists);
const list = listsObjs.length ? listsObjs[listsObjs.length - 1].id : null; const list = listsObjs.length
? listsObjs[listsObjs.length - 1].id
: null;
return { return {
...state, ...state,
list, list,
@@ -121,7 +123,7 @@ export default function lists(
[state.list]: { [state.list]: {
...state.lists[state.list], ...state.lists[state.list],
todos: state.lists[state.list].todos.filter( todos: state.lists[state.list].todos.filter(
todo => todo !== action.id, (todo) => todo !== action.id,
), ),
}, },
}, },
@@ -134,7 +136,10 @@ export default function lists(
...state.lists, ...state.lists,
[state.list]: { [state.list]: {
...state.lists[state.list], ...state.lists[state.list],
todos: [action.todo.id, ...state.lists[state.list].todos], todos: [
action.todo.id,
...state.lists[state.list].todos,
],
}, },
}, },
}; };

View File

@@ -9,7 +9,7 @@ import {
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 = {
@@ -53,7 +53,10 @@ export default function todos(
...state, ...state,
todos: { todos: {
...state.todos, ...state.todos,
[action.id]: { ...state.todos[action.id], text: action.text }, [action.id]: {
...state.todos[action.id],
text: action.text,
},
}, },
}; };
case REQUEST_TODOS: case REQUEST_TODOS:
@@ -71,7 +74,7 @@ export default function todos(
} }
case REMOVE_LIST: { case REMOVE_LIST: {
const newTodos = { ...state.todos }; const newTodos = { ...state.todos };
Object.keys(newTodos).forEach(todoId => { Object.keys(newTodos).forEach((todoId) => {
if (newTodos[todoId].list === action.list) { if (newTodos[todoId].list === action.list) {
delete newTodos[todoId]; delete newTodos[todoId];
} }

View File

@@ -10,7 +10,7 @@ import {
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 = {

View File

@@ -1,4 +1,4 @@
import { VisibilityFilters, SET_VISIBILITY_FILTER } from '../actions/defs'; import { VisibilityFilters, SET_VISIBILITY_FILTER } from "../actions/defs";
const { SHOW_ALL } = VisibilityFilters; const { SHOW_ALL } = VisibilityFilters;

View File

@@ -9,9 +9,9 @@
// 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}$/,
@@ -21,44 +21,47 @@ const isLocalhost = Boolean(
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(
"New content is available; please refresh.",
);
} else { } else {
// At this point, everything has been precached. // At this point, everything has been precached.
// It's the perfect time to display a // It's the perfect time to display a
// "Content is cached for offline use." message. // "Content is cached for offline use." message.
console.log('Content is cached for offline use.'); console.log("Content is cached for offline use.");
} }
} }
}; };
}; };
}) })
.catch(error => { .catch((error) => {
console.error('Error during service worker registration:', 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. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => { registration.unregister().then(() => {
window.location.reload(); window.location.reload();
}); });
@@ -70,13 +73,13 @@ function checkValidServiceWorker(swUrl) {
}) })
.catch(() => { .catch(() => {
console.log( console.log(
'No internet connection found. App is running in offline mode.', "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) {
@@ -86,7 +89,7 @@ export default function register() {
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) {
@@ -97,8 +100,8 @@ export default function register() {
// 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 { } else {
@@ -110,8 +113,8 @@ export default function register() {
} }
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);

5581
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 };