Project synching, for #790 (#1039)

* add isSaving to project reducer, move actions to functions, start work to get comprehensive frontend/backend syncing working

* handle making changes while saving project, handle saving from another window

* add change to handle saving new sketch, and adding new changes while saving
This commit is contained in:
Cassie Tarakajian 2019-04-17 14:08:33 -04:00 committed by GitHub
parent 94eb6f1ac9
commit 7d1901649f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 120 additions and 58 deletions

View File

@ -117,3 +117,6 @@ export const HIDE_HELP_MODAL = 'HIDE_HELP_MODAL';
export const HIDE_RUNTIME_ERROR_WARNING = 'HIDE_RUNTIME_ERROR_WARNING'; export const HIDE_RUNTIME_ERROR_WARNING = 'HIDE_RUNTIME_ERROR_WARNING';
export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING'; export const SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING';
export const SET_ASSETS = 'SET_ASSETS'; export const SET_ASSETS = 'SET_ASSETS';
export const START_SAVING_PROJECT = 'START_SAVING_PROJECT';
export const END_SAVING_PROJECT = 'END_SAVING_PROJECT';

View File

@ -2,7 +2,7 @@ import { browserHistory } from 'react-router';
import axios from 'axios'; import axios from 'axios';
import objectID from 'bson-objectid'; import objectID from 'bson-objectid';
import each from 'async/each'; import each from 'async/each';
import { isEqual, pick } from 'lodash'; import isEqual from 'lodash/isEqual';
import * as ActionTypes from '../../../constants'; import * as ActionTypes from '../../../constants';
import { showToast, setToastText } from './toast'; import { showToast, setToastText } from './toast';
import { import {
@ -32,6 +32,22 @@ export function setProjectName(name) {
}; };
} }
export function projectSaveFail(error) {
return {
type: ActionTypes.PROJECT_SAVE_FAIL,
error
};
}
export function setNewProject(project) {
return {
type: ActionTypes.NEW_PROJECT,
project,
owner: project.user,
files: project.files
};
}
export function getProject(id) { export function getProject(id) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(justOpenedProject()); dispatch(justOpenedProject());
@ -66,37 +82,71 @@ export function clearPersistedState() {
}; };
} }
export function startSavingProject() {
return {
type: ActionTypes.START_SAVING_PROJECT
};
}
export function endSavingProject() {
return {
type: ActionTypes.END_SAVING_PROJECT
};
}
export function projectSaveSuccess() {
return {
type: ActionTypes.PROJECT_SAVE_SUCCESS
};
}
// want a function that will check for changes on the front end
function getSynchedProject(currentState, responseProject) {
let hasChanges = false;
const synchedProject = Object.assign({}, responseProject);
const currentFiles = currentState.files.map(({ name, children, content }) => ({ name, children, content }));
const responseFiles = responseProject.files.map(({ name, children, content }) => ({ name, children, content }));
if (!isEqual(currentFiles, responseFiles)) {
synchedProject.files = currentState.files;
hasChanges = true;
}
if (currentState.project.name !== responseProject.name) {
synchedProject.name = currentState.project.name;
hasChanges = true;
}
return {
synchedProject,
hasChanges
};
}
export function saveProject(selectedFile = null, autosave = false) { export function saveProject(selectedFile = null, autosave = false) {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState(); const state = getState();
if (state.project.isSaving) {
return Promise.resolve();
}
dispatch(startSavingProject());
if (state.user.id && state.project.owner && state.project.owner.id !== state.user.id) { if (state.user.id && state.project.owner && state.project.owner.id !== state.user.id) {
return Promise.reject(); return Promise.reject();
} }
const formParams = Object.assign({}, state.project); const formParams = Object.assign({}, state.project);
formParams.files = [...state.files]; formParams.files = [...state.files];
if (selectedFile) { if (selectedFile) {
console.log('selected file being updated');
const fileToUpdate = formParams.files.find(file => file.id === selectedFile.id); const fileToUpdate = formParams.files.find(file => file.id === selectedFile.id);
fileToUpdate.content = selectedFile.content; fileToUpdate.content = selectedFile.content;
} }
if (state.project.id) { if (state.project.id) {
return axios.put(`${ROOT_URL}/projects/${state.project.id}`, formParams, { withCredentials: true }) return axios.put(`${ROOT_URL}/projects/${state.project.id}`, formParams, { withCredentials: true })
.then((response) => { .then((response) => {
const currentState = getState(); dispatch(endSavingProject());
const savedProject = Object.assign({}, response.data); dispatch(setUnsavedChanges(false));
if (!isEqual( const { hasChanges, synchedProject } = getSynchedProject(getState(), response.data);
pick(currentState.files, ['name', 'children', 'content']), if (hasChanges) {
pick(response.data.files, ['name', 'children', 'content'])
)) {
savedProject.files = currentState.files;
dispatch(setUnsavedChanges(true)); dispatch(setUnsavedChanges(true));
} else {
dispatch(setUnsavedChanges(false));
} }
dispatch(setProject(savedProject)); dispatch(setProject(synchedProject));
dispatch({ dispatch(projectSaveSuccess());
type: ActionTypes.PROJECT_SAVE_SUCCESS
});
if (!autosave) { if (!autosave) {
if (state.ide.justOpenedProject && state.preferences.autosave) { if (state.ide.justOpenedProject && state.preferences.autosave) {
dispatch(showToast(5500)); dispatch(showToast(5500));
@ -110,30 +160,32 @@ export function saveProject(selectedFile = null, autosave = false) {
} }
}) })
.catch((response) => { .catch((response) => {
dispatch(endSavingProject());
if (response.status === 403) { if (response.status === 403) {
dispatch(showErrorModal('staleSession')); dispatch(showErrorModal('staleSession'));
} else if (response.status === 409) { } else if (response.status === 409) {
dispatch(showErrorModal('staleProject')); dispatch(showErrorModal('staleProject'));
} else { } else {
dispatch({ dispatch(projectSaveFail(response.data));
type: ActionTypes.PROJECT_SAVE_FAIL,
error: response.data
});
} }
}); });
} }
return axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true }) return axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true })
.then((response) => { .then((response) => {
dispatch(setUnsavedChanges(false)); dispatch(endSavingProject());
dispatch(setProject(response.data)); const { hasChanges, synchedProject } = getSynchedProject(getState(), response.data);
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`); if (hasChanges) {
dispatch({ dispatch(setNewProject(synchedProject));
type: ActionTypes.NEW_PROJECT, dispatch(setUnsavedChanges(false));
project: response.data, browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
owner: response.data.user, dispatch(setUnsavedChanges(true));
files: response.data.files } else {
}); dispatch(setNewProject(synchedProject));
dispatch(setUnsavedChanges(false));
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
}
dispatch(projectSaveSuccess());
if (!autosave) { if (!autosave) {
if (state.preferences.autosave) { if (state.preferences.autosave) {
dispatch(showToast(5500)); dispatch(showToast(5500));
@ -147,13 +199,11 @@ export function saveProject(selectedFile = null, autosave = false) {
} }
}) })
.catch((response) => { .catch((response) => {
dispatch(endSavingProject());
if (response.status === 403) { if (response.status === 403) {
dispatch(showErrorModal('staleSession')); dispatch(showErrorModal('staleSession'));
} else { } else {
dispatch({ dispatch(projectSaveFail(response.data));
type: ActionTypes.PROJECT_SAVE_FAIL,
error: response.data
});
} }
}); });
}; };
@ -166,22 +216,28 @@ export function autosaveProject() {
} }
export function createProject() { export function createProject() {
return (dispatch) => { return (dispatch, getState) => {
const state = getState();
if (state.project.isSaving) {
Promise.resolve();
return;
}
dispatch(startSavingProject());
axios.post(`${ROOT_URL}/projects`, {}, { withCredentials: true }) axios.post(`${ROOT_URL}/projects`, {}, { withCredentials: true })
.then((response) => { .then((response) => {
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`); dispatch(endSavingProject());
dispatch({
type: ActionTypes.NEW_PROJECT,
project: response.data,
owner: response.data.user,
files: response.data.files
});
dispatch(setUnsavedChanges(false)); dispatch(setUnsavedChanges(false));
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
const { hasChanges, synchedProject } = getSynchedProject(getState(), response.data);
if (hasChanges) {
dispatch(setUnsavedChanges(true));
}
dispatch(setNewProject(synchedProject));
}) })
.catch(response => dispatch({ .catch((response) => {
type: ActionTypes.PROJECT_SAVE_FAIL, dispatch(endSavingProject());
error: response.data dispatch(projectSaveFail(response.data));
})); });
}; };
} }
@ -251,12 +307,7 @@ export function cloneProject() {
axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true }) axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true })
.then((response) => { .then((response) => {
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`); browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
dispatch({ dispatch(setNewProject(response.data));
type: ActionTypes.NEW_PROJECT,
project: response.data,
owner: response.data.user,
files: response.data.files
});
}) })
.catch(response => dispatch({ .catch(response => dispatch({
type: ActionTypes.PROJECT_SAVE_FAIL, type: ActionTypes.PROJECT_SAVE_FAIL,

View File

@ -26,7 +26,7 @@ class ErrorModal extends React.Component {
staleProject() { staleProject() {
return ( return (
<p> <p>
The project you have attempted to save is out of date. Please refresh the page. The project you have attempted to save has been saved from another window. Please refresh the page to see the latest version.
</p> </p>
); );
} }

View File

@ -12,7 +12,8 @@ const initialState = () => {
const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1); const generatedName = generatedString.charAt(0).toUpperCase() + generatedString.slice(1);
return { return {
name: generatedName, name: generatedName,
updatedAt: '' updatedAt: '',
isSaving: false
}; };
}; };
@ -28,14 +29,16 @@ const project = (state, action) => {
id: action.project.id, id: action.project.id,
name: action.project.name, name: action.project.name,
updatedAt: action.project.updatedAt, updatedAt: action.project.updatedAt,
owner: action.owner owner: action.owner,
isSaving: false
}; };
case ActionTypes.SET_PROJECT: case ActionTypes.SET_PROJECT:
return { return {
id: action.project.id, id: action.project.id,
name: action.project.name, name: action.project.name,
updatedAt: action.project.updatedAt, updatedAt: action.project.updatedAt,
owner: action.owner owner: action.owner,
isSaving: false
}; };
case ActionTypes.RESET_PROJECT: case ActionTypes.RESET_PROJECT:
return initialState(); return initialState();
@ -45,6 +48,10 @@ const project = (state, action) => {
return Object.assign({}, state, { isEditingName: false }); return Object.assign({}, state, { isEditingName: false });
case ActionTypes.SET_PROJECT_SAVED_TIME: case ActionTypes.SET_PROJECT_SAVED_TIME:
return Object.assign({}, state, { updatedAt: action.value }); return Object.assign({}, state, { updatedAt: action.value });
case ActionTypes.START_SAVING_PROJECT:
return Object.assign({}, state, { isSaving: true });
case ActionTypes.START_STOP_PROJECT:
return Object.assign({}, state, { isSaving: false });
default: default:
return state; return state;
} }

View File

@ -3,6 +3,7 @@ import format from 'date-fns/format';
import isUrl from 'is-url'; import isUrl from 'is-url';
import jsdom, { serializeDocument } from 'jsdom'; import jsdom, { serializeDocument } from 'jsdom';
import isBefore from 'date-fns/is_before'; import isBefore from 'date-fns/is_before';
import isAfter from 'date-fns/is_after';
import request from 'request'; import request from 'request';
import slugify from 'slugify'; import slugify from 'slugify';
import Project from '../models/project'; import Project from '../models/project';
@ -43,10 +44,10 @@ export function updateProject(req, res) {
res.status(403).send({ success: false, message: 'Session does not match owner of project.' }); res.status(403).send({ success: false, message: 'Session does not match owner of project.' });
return; return;
} }
// if (req.body.updatedAt && moment(req.body.updatedAt) < moment(project.updatedAt)) { if (req.body.updatedAt && isAfter(new Date(project.updatedAt), req.body.updatedAt)) {
// res.status(409).send({ success: false, message: 'Attempted to save stale version of project.' }); res.status(409).send({ success: false, message: 'Attempted to save stale version of project.' });
// return; return;
// } }
Project.findByIdAndUpdate( Project.findByIdAndUpdate(
req.params.project_id, req.params.project_id,
{ {