add offline support

This commit is contained in:
2018-06-26 19:44:27 +03:00
parent 3791d4a5d7
commit 4c0da92a1a
10 changed files with 165 additions and 100 deletions

View File

@@ -1,5 +0,0 @@
{
"editor.tabSize": 2,
"editor.insertSpaces": true,
"prettier.eslintIntegration": true
}

View File

@@ -1,4 +1,4 @@
import { API_ROOT, getToken } from './util';
import { API_ROOT, getToken, mongoObjectId } from './util';
import { RECIEVE_TODOS } from './todos';
export const ADD_LIST = 'ADD_LIST';
@@ -20,17 +20,9 @@ function requestLists() {
function recieveLists(lists) {
return { type: RECIEVE_LISTS, lists };
}
function invalidateLists() {
return { type: INVALIDATE_LISTS };
}
function validateLists() {
return { type: VALIDATE_LISTS };
}
export function changeList(list) {
return { type: CHANGE_LIST, list };
}
export function startCreateList() {
return { type: START_CREATE_LIST };
}
@@ -46,44 +38,57 @@ export function stopEditList() {
export function addList(name) {
return async dispatch => {
dispatch(invalidateLists());
const response = await fetch(`${API_ROOT}/lists`, {
body: JSON.stringify({ name }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
const id = mongoObjectId();
dispatch({
type: ADD_LIST,
list: {
name,
id,
todos: [],
},
meta: {
offline: {
effect: {
url: `${API_ROOT}/lists`,
body: JSON.stringify({ name, id }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'POST',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
method: 'POST',
});
const json = await response.json();
const list = json.data;
dispatch({ type: ADD_LIST, list });
dispatch(changeList(list.id));
dispatch(validateLists());
};
}
export function removeList() {
return async (dispatch, getState) => {
let state = getState();
const state = getState();
const { list } = state.lists;
dispatch(invalidateLists());
const response = await fetch(`${API_ROOT}/lists/${list}`, {
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
dispatch({
type: REMOVE_LIST,
list,
meta: {
offline: {
effect: {
url: `${API_ROOT}/lists/${list}`,
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'DELETE',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
method: 'DELETE',
});
const json = await response.json();
if (json.success) {
dispatch({ type: REMOVE_LIST, list });
state = getState();
const lists = Object.values(state.lists.lists);
const newList = lists.length ? lists[lists.length - 1].id : '';
dispatch(changeList(newList));
}
dispatch(validateLists());
};
}
@@ -91,20 +96,27 @@ export function editList(name) {
return async (dispatch, getState) => {
const state = getState();
const { list } = state.lists;
dispatch(invalidateLists());
const response = await fetch(`${API_ROOT}/lists/${list}`, {
body: JSON.stringify({ name }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
dispatch({
type: EDIT_LIST_NAME,
list,
name,
meta: {
offline: {
effect: {
url: `${API_ROOT}/lists/${list}`,
body: JSON.stringify({ name }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'PATCH',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
method: 'PATCH',
});
const json = await response.json();
if (json.success) {
dispatch({ type: EDIT_LIST_NAME, list, name });
}
dispatch(validateLists());
};
}
@@ -131,29 +143,19 @@ export function fetchLists() {
});
const json = await response.json();
const lists = json.data;
const listsObj = lists.reduce((obj, list) => {
const listsObj = lists.reduce((obj, curList) => {
const newObj = { ...obj };
newObj[list.id] = {
newObj[curList.id] = {
dirty: true,
fetching: false,
editing: false,
...list,
todos: list.todos.map(todo => todo.id),
...curList,
todos: curList.todos.map(todo => todo.id),
};
return newObj;
}, {});
dispatch({ type: RECIEVE_TODOS, todos: normalizeTodos(lists) });
dispatch(recieveLists(listsObj));
if (lists.length !== 0) {
dispatch(changeList(listsObj[Object.keys(listsObj)[0]].id));
}
};
}
export function loadLists() {
return async dispatch => {
dispatch(requestLists());
dispatch(fetchLists());
};
}

View File

@@ -1,5 +1,5 @@
import { API_ROOT, getToken } from './util';
import { fetchLists, INVALIDATE_LISTS } from './lists';
import { API_ROOT, getToken, mongoObjectId } from './util';
import { INVALIDATE_LISTS } from './lists';
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
@@ -21,13 +21,6 @@ export function setVisibilityFilter(filter) {
return { type: SET_VISIBILITY_FILTER, filter };
}
function invalidateTodos() {
return { type: INVALIDATE_TODOS };
}
function validateTodos() {
return { type: VALIDATE_TODOS };
}
export function fetchTodos() {
return async dispatch => {
dispatch({ type: REQUEST_TODOS });
@@ -46,24 +39,32 @@ export function addTodo(text) {
return async (dispatch, getState) => {
const state = getState();
const { list } = state.lists;
const id = mongoObjectId();
if (list) {
dispatch(invalidateTodos());
const response = await fetch(`${API_ROOT}/lists/${list}/todos`, {
body: JSON.stringify({ text }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
dispatch({
type: ADD_TODO,
todo: {
text,
id,
completed: false,
},
meta: {
offline: {
effect: {
url: `${API_ROOT}/lists/${list}/todos`,
body: JSON.stringify({ text, id }),
headers: {
Authorization: `Bearer ${getToken()}`,
'content-type': 'application/json',
},
method: 'POST',
},
rollback: {
type: INVALIDATE_LISTS,
},
},
},
method: 'POST',
});
const json = await response.json();
const todo = json.data;
if (json.success) {
dispatch({ type: ADD_TODO, todo });
} else {
dispatch(fetchLists());
}
dispatch(validateTodos());
}
};
}

View File

@@ -1,5 +1,5 @@
import { API_ROOT, getToken, setToken } from './util';
import { loadLists } from './lists';
import { fetchLists } from './lists';
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAIL = 'LOGIN_FAIL';
@@ -40,7 +40,7 @@ export function loadUser() {
const json = await response.json();
if (json.success) {
dispatch(loginSuccess(json.data));
dispatch(loadLists());
dispatch(fetchLists());
} else {
dispatch(loginFail(json.error));
}
@@ -64,7 +64,7 @@ export function login(user) {
if (json.success) {
setToken(json.data.jwt);
dispatch(loginSuccess(json.data));
dispatch(loadLists());
dispatch(fetchLists());
} else {
dispatch(loginFail(json.error));
}
@@ -93,7 +93,7 @@ export function signup(user) {
if (json.success) {
setToken(json.data.jwt);
dispatch(signupSuccess(json.data));
dispatch(loadLists());
dispatch(fetchLists());
} else {
dispatch(signupFail(json.error));
}

View File

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

View File

@@ -40,14 +40,23 @@ export default function lists(
};
case CHANGE_LIST:
return { ...state, list: action.list };
case RECIEVE_LISTS:
case RECIEVE_LISTS: {
const newLists = Object.values(action.lists);
let { list } = state;
if (newLists.length !== 0) {
if (!newLists.some(curList => curList.id === list)) {
list = newLists[0].id;
}
}
return {
...state,
dirty: false,
loaded: true,
fetching: false,
lists: action.lists,
list,
};
}
case START_CREATE_LIST:
return {
...state,
@@ -63,13 +72,16 @@ export default function lists(
...state,
creating: false,
lists: { ...state.lists, [action.list.id]: action.list },
list: action.list.id,
};
case REMOVE_LIST: {
const newLists = { ...state.lists };
delete newLists[action.list];
const listsObjs = Object.values(newLists);
const list = listsObjs.length ? listsObjs[listsObjs.length - 1].id : '';
return {
...state,
list: null,
list,
lists: newLists,
};
}

View File

@@ -24,7 +24,8 @@ router.post(
'/',
asyncHelper(async (req, res) => {
const { name } = req.body;
const newList = new TodoList({ name, user: req.user.id });
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() });
}),

View File

@@ -26,7 +26,8 @@ router.post(
asyncHelper(async (req, res) => {
const { listId } = res.locals || req.body;
const { text } = req.body;
const todo = new Todo({ text, list: listId, user: req.user.id });
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() });
}),

View File

@@ -71,6 +71,25 @@ describe('test lists', () => {
const freshUser = await User.findById(user.id).exec();
expect(freshUser.lists).toContain(response.body.data.id);
});
test('should create list with custom id', async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post('/api/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).toContain(response.body.data.id);
});
test('should not create list without authentication', async () => {
await request(server)
.post('/api/lists')

View File

@@ -84,6 +84,28 @@ describe('test todos', () => {
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos).toContain(response.body.data.id);
});
test('should create todo with custom id', async () => {
const id = mongoose.Types.ObjectId();
const response = await request(server)
.post(`/api/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).toContain(response.body.data.id);
const freshList = await TodoList.findById(list.id).exec();
expect(freshList.todos).toContain(response.body.data.id);
});
test('should not create todo without authentication', async () => {
await request(server)
.post(`/api/lists/${list._id}/todos`)