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 SHOW_RUNTIME_ERROR_WARNING = 'SHOW_RUNTIME_ERROR_WARNING';
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 objectID from 'bson-objectid';
import each from 'async/each';
import { isEqual, pick } from 'lodash';
import isEqual from 'lodash/isEqual';
import * as ActionTypes from '../../../constants';
import { showToast, setToastText } from './toast';
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) {
return (dispatch, getState) => {
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) {
return (dispatch, 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) {
return Promise.reject();
}
const formParams = Object.assign({}, state.project);
formParams.files = [...state.files];
if (selectedFile) {
console.log('selected file being updated');
const fileToUpdate = formParams.files.find(file => file.id === selectedFile.id);
fileToUpdate.content = selectedFile.content;
}
if (state.project.id) {
return axios.put(`${ROOT_URL}/projects/${state.project.id}`, formParams, { withCredentials: true })
.then((response) => {
const currentState = getState();
const savedProject = Object.assign({}, response.data);
if (!isEqual(
pick(currentState.files, ['name', 'children', 'content']),
pick(response.data.files, ['name', 'children', 'content'])
)) {
savedProject.files = currentState.files;
dispatch(endSavingProject());
dispatch(setUnsavedChanges(false));
const { hasChanges, synchedProject } = getSynchedProject(getState(), response.data);
if (hasChanges) {
dispatch(setUnsavedChanges(true));
} else {
dispatch(setUnsavedChanges(false));
}
dispatch(setProject(savedProject));
dispatch({
type: ActionTypes.PROJECT_SAVE_SUCCESS
});
dispatch(setProject(synchedProject));
dispatch(projectSaveSuccess());
if (!autosave) {
if (state.ide.justOpenedProject && state.preferences.autosave) {
dispatch(showToast(5500));
@ -110,30 +160,32 @@ export function saveProject(selectedFile = null, autosave = false) {
}
})
.catch((response) => {
dispatch(endSavingProject());
if (response.status === 403) {
dispatch(showErrorModal('staleSession'));
} else if (response.status === 409) {
dispatch(showErrorModal('staleProject'));
} else {
dispatch({
type: ActionTypes.PROJECT_SAVE_FAIL,
error: response.data
});
dispatch(projectSaveFail(response.data));
}
});
}
return axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true })
.then((response) => {
dispatch(setUnsavedChanges(false));
dispatch(setProject(response.data));
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
dispatch({
type: ActionTypes.NEW_PROJECT,
project: response.data,
owner: response.data.user,
files: response.data.files
});
dispatch(endSavingProject());
const { hasChanges, synchedProject } = getSynchedProject(getState(), response.data);
if (hasChanges) {
dispatch(setNewProject(synchedProject));
dispatch(setUnsavedChanges(false));
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
dispatch(setUnsavedChanges(true));
} else {
dispatch(setNewProject(synchedProject));
dispatch(setUnsavedChanges(false));
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
}
dispatch(projectSaveSuccess());
if (!autosave) {
if (state.preferences.autosave) {
dispatch(showToast(5500));
@ -147,13 +199,11 @@ export function saveProject(selectedFile = null, autosave = false) {
}
})
.catch((response) => {
dispatch(endSavingProject());
if (response.status === 403) {
dispatch(showErrorModal('staleSession'));
} else {
dispatch({
type: ActionTypes.PROJECT_SAVE_FAIL,
error: response.data
});
dispatch(projectSaveFail(response.data));
}
});
};
@ -166,22 +216,28 @@ export function autosaveProject() {
}
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 })
.then((response) => {
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
dispatch({
type: ActionTypes.NEW_PROJECT,
project: response.data,
owner: response.data.user,
files: response.data.files
});
dispatch(endSavingProject());
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({
type: ActionTypes.PROJECT_SAVE_FAIL,
error: response.data
}));
.catch((response) => {
dispatch(endSavingProject());
dispatch(projectSaveFail(response.data));
});
};
}
@ -251,12 +307,7 @@ export function cloneProject() {
axios.post(`${ROOT_URL}/projects`, formParams, { withCredentials: true })
.then((response) => {
browserHistory.push(`/${response.data.user.username}/sketches/${response.data.id}`);
dispatch({
type: ActionTypes.NEW_PROJECT,
project: response.data,
owner: response.data.user,
files: response.data.files
});
dispatch(setNewProject(response.data));
})
.catch(response => dispatch({
type: ActionTypes.PROJECT_SAVE_FAIL,

View File

@ -26,7 +26,7 @@ class ErrorModal extends React.Component {
staleProject() {
return (
<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>
);
}

View File

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

View File

@ -3,6 +3,7 @@ import format from 'date-fns/format';
import isUrl from 'is-url';
import jsdom, { serializeDocument } from 'jsdom';
import isBefore from 'date-fns/is_before';
import isAfter from 'date-fns/is_after';
import request from 'request';
import slugify from 'slugify';
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.' });
return;
}
// if (req.body.updatedAt && moment(req.body.updatedAt) < moment(project.updatedAt)) {
// res.status(409).send({ success: false, message: 'Attempted to save stale version of project.' });
// return;
// }
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.' });
return;
}
Project.findByIdAndUpdate(
req.params.project_id,
{