From 7d1901649f9849c294cba06c29109dc9a1ced05f Mon Sep 17 00:00:00 2001 From: Cassie Tarakajian Date: Wed, 17 Apr 2019 14:08:33 -0400 Subject: [PATCH] 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 --- client/constants.js | 3 + client/modules/IDE/actions/project.js | 151 +++++++++++++------ client/modules/IDE/components/ErrorModal.jsx | 2 +- client/modules/IDE/reducers/project.js | 13 +- server/controllers/project.controller.js | 9 +- 5 files changed, 120 insertions(+), 58 deletions(-) diff --git a/client/constants.js b/client/constants.js index 66bceff7..38fc467b 100644 --- a/client/constants.js +++ b/client/constants.js @@ -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'; diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index df930712..b04da7a5 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -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, diff --git a/client/modules/IDE/components/ErrorModal.jsx b/client/modules/IDE/components/ErrorModal.jsx index 6f264658..04ea3598 100644 --- a/client/modules/IDE/components/ErrorModal.jsx +++ b/client/modules/IDE/components/ErrorModal.jsx @@ -26,7 +26,7 @@ class ErrorModal extends React.Component { staleProject() { return (

- 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.

); } diff --git a/client/modules/IDE/reducers/project.js b/client/modules/IDE/reducers/project.js index 5371280a..6109474a 100644 --- a/client/modules/IDE/reducers/project.js +++ b/client/modules/IDE/reducers/project.js @@ -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; } diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index ef9e0ef9..41b240a0 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -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, {