From 06355849fde0e09c7e0acb4fc5d5d6ed1c934329 Mon Sep 17 00:00:00 2001 From: Viggie1 Date: Wed, 3 Jul 2024 13:25:51 -0400 Subject: [PATCH] Implements auto save in the module editor --- .../components/visual-editor.test.js | 22 +++++ .../oboeditor/components/code-editor.js | 1 + .../oboeditor/components/editor-app.js | 96 ++++++++++++++++++- .../oboeditor/components/visual-editor.js | 51 ++++++++-- .../oboeditor/components/visual-editor.scss | 15 +++ 5 files changed, 174 insertions(+), 11 deletions(-) diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/visual-editor.test.js b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/visual-editor.test.js index 7bd38f546e..eb93861fb9 100644 --- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/visual-editor.test.js +++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/visual-editor.test.js @@ -433,10 +433,14 @@ describe('VisualEditor', () => { "addObjective": [Function], "contentRect": null, "editable": false, + "elapsed": 0, + "intervalId": 108, + "lastSaved": null, "objectives": Array [], "removeObjective": [Function], "saveState": "saveSuccessful", "showPlaceholders": true, + "unsavedChanges": false, "updateObjective": [Function], "value": Array [ Object { @@ -458,10 +462,14 @@ describe('VisualEditor', () => { "addObjective": [Function], "contentRect": null, "editable": true, + "elapsed": 0, + "intervalId": 114, + "lastSaved": null, "objectives": Array [], "removeObjective": [Function], "saveState": "", "showPlaceholders": true, + "unsavedChanges": false, "updateObjective": [Function], "value": Array [ Object { @@ -681,6 +689,20 @@ describe('VisualEditor', () => { expect(plugins).toMatchSnapshot() }) + test('draft saves to local storage every 10 seconds', () => { + jest.useFakeTimers(); + + const saveModuleToLocalStorage = jest.spyOn(VisualEditor.prototype, 'saveModuleToLocalStorage'); + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(setInterval).toHaveBeenLastCalledWith(expect(saveModuleToLocalStorage), 10000); + }) + + test('lastSaved displays in toolbar after save', () => { + const component = mount() + + }) + test('exportToJSON returns expected json for assessment node', () => { const spy = jest.spyOn(Common.Registry, 'getItemForType') spy.mockReturnValueOnce({ diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/code-editor.js b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/code-editor.js index 5b743ac27a..ba6f0910e7 100644 --- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/code-editor.js +++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/code-editor.js @@ -99,6 +99,7 @@ class CodeEditor extends React.Component { location.reload() } + //TARGET saveAndGetTitleFromCode() { // Update the title in the File Toolbar const title = EditorUtil.getTitleFromString(this.state.code, this.props.mode) diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/editor-app.js b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/editor-app.js index 21892d8cdc..006ac2379b 100644 --- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/editor-app.js +++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/editor-app.js @@ -12,6 +12,7 @@ import React from 'react' import enableWindowCloseDispatcher from '../../common/util/close-window-dispatcher' import ObojoboIdleTimer from '../../common/components/obojobo-idle-timer' import SimpleDialog from '../../common/components/modal/simple-dialog' +import { isEqual } from 'underscore' const ModalContainer = Common.components.ModalContainer const ModalUtil = Common.util.ModalUtil @@ -28,6 +29,7 @@ class EditorApp extends React.Component { // store the current version id for locking // not stored on state because no effect on render this.contentId = null + const unsavedChanges = localStorage.getItem("localStorageJSON") this.state = { model: null, @@ -40,7 +42,9 @@ class EditorApp extends React.Component { mode: VISUAL_MODE, code: null, requestStatus: null, - requestError: null + requestError: null, + unsavedChanges: unsavedChanges ? true : null, + overwrite: false } // caluclate edit lock settings @@ -85,17 +89,49 @@ class EditorApp extends React.Component { this.onWindowReturnFromInactive = this.onWindowReturnFromInactive.bind(this) this.onWindowInactive = this.onWindowInactive.bind(this) this.renewLockInterval = null + this.saveToLocalStorage = this.saveToLocalStorage.bind(this) + this.overwriteChanges = this.overwriteChanges.bind(this) + this.cancelOverwrite = this.cancelOverwrite.bind(this) + } + + // all the stuff you need to do to save the current json string to localStorage + saveToLocalStorage(currentDraftJSON) { + // write the current draft JSON into local storage + // compare json here + if(!isEqual(currentDraftJSON, this.state.draft)) { + localStorage.setItem('localStorageJSON', JSON.stringify(currentDraftJSON)) + this.setState({ unsavedChanges: true }) + this.setState({ firstLoad: false }) + } else { + localStorage.removeItem('localStorageJSON') + } + } saveDraft(draftId, draftSrc, xmlOrJSON = 'json') { const mode = xmlOrJSON === 'xml' ? 'text/plain' : 'application/json' + // remove local storage + if(localStorage.getItem("localStorageJSON")) { + localStorage.removeItem("localStorageJSON"); + } return EditorAPI.postDraft(draftId, draftSrc, mode) .then(({ contentId, result }) => { if (result.status !== 'ok') { throw Error(result.value.message) } + + contentId = this.contentId + + if(xmlOrJSON !== 'xml') { + this.setState({ + draft: { + ...JSON.parse(draftSrc), + contentId + } + }) + + } - this.contentId = contentId // keep new contentId for edit locks return true }) .catch(e => { @@ -104,11 +140,61 @@ class EditorApp extends React.Component { }) } + overwriteChanges() { + try { + const localStorageJSON = localStorage.getItem('localStorageJSON') + const parsedLocalStorageJSON = JSON.parse(localStorageJSON) + + this.setState({ + unsavedChanges: false, + }, () => { + localStorage.removeItem('localStorageJSON'); + this.saveDraft(parsedLocalStorageJSON.draftId, localStorageJSON, 'json').then(() => { + this.reloadDraft(this.state.draftId, this.state.mode); + }).then(() => { + window.alert('Page needs to refresh in order7 for overwritten changes to show'); //eslint-disable-line no-alert + window.removeEventListener('beforeunload', this.checkIfSaved) + window.location.reload(); + }) + }) + + }catch(e) { + throw new Error(e) + } + } + + cancelOverwrite() { + localStorage.removeItem('localStorageJSON'); + ModalUtil.hide(); + } + getVisualEditorState(draftId, draftModel) { OboModel.clearAll() const json = JSON.parse(draftModel) const obomodel = OboModel.create(json) + + // compare and show modal here + // define functions instead of using anonymous/arrow functions + const localStorageJSON = localStorage.getItem('localStorageJSON') + if (this.state.unsavedChanges) { + this.setState({ overwrite: true }) + if(!isEqual(localStorageJSON, json) ) { + ModalUtil.show( + + It looks like you did not save changes before closing the window. +

+ Would you like to restore your unsaved changes? +
+ ) + } + } + EditorStore.init( obomodel, json.content.start, @@ -209,6 +295,7 @@ class EditorApp extends React.Component { return EditorAPI.getFullDraft(draftId, mode === VISUAL_MODE ? 'json' : mode) .then(({ contentId, body }) => { this.contentId = contentId + switch (mode) { case XML_MODE: return body @@ -223,7 +310,7 @@ class EditorApp extends React.Component { error.type = json.value.type throw error } - // stringify and format the draft data + return JSON.stringify(json.value, null, 4) } } @@ -355,6 +442,7 @@ class EditorApp extends React.Component { switchMode={this.switchMode} insertableItems={Common.Registry.insertableItems} saveDraft={this.saveDraft} + saveToLocalStorage={this.saveToLocalStorage} /> ) } @@ -371,6 +459,8 @@ class EditorApp extends React.Component { switchMode={this.switchMode} insertableItems={Common.Registry.insertableItems} saveDraft={this.saveDraft} + saveToLocalStorage={this.saveToLocalStorage} + unsavedChanges={this.unsavedChanges} readOnly={ // Prevents editing a draft that's a revision, // even if the url was visited manually diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/visual-editor.js b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/visual-editor.js index d5daaca4e2..99af5c5471 100644 --- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/visual-editor.js +++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/visual-editor.js @@ -73,13 +73,17 @@ class VisualEditor extends React.Component { this.state = { value: json, saveState: 'saveSuccessful', + lastSaved: null, + elapsed: 0, + unsavedChanges: false, editable: json && json.length >= 1 && !json[0].text, showPlaceholders: true, contentRect: null, objectives: this.props.model?.objectives ?? [], addObjective: this.addObjective, removeObjective: this.removeObjective, - updateObjective: this.updateObjective + updateObjective: this.updateObjective, + intervalId: null, } this.pageEditorContainerRef = React.createRef() @@ -102,6 +106,8 @@ class VisualEditor extends React.Component { this.setEditorFocus = this.setEditorFocus.bind(this) this.onClick = this.onClick.bind(this) this.hasInvalidFields = this.hasInvalidFields.bind(this) + this.formatJSON = this.formatJSON.bind(this) + this.saveModuleToLocalStorage = this.saveModuleToLocalStorage.bind(this) this.editor = this.withPlugins(withHistory(withReact(createEditor()))) this.editor.toggleEditable = this.toggleEditable @@ -181,6 +187,12 @@ class VisualEditor extends React.Component { return Array.from(items.values()) } + updateElapsed() { + if (this.state.lastSaved === null) return {...this.state.lastSaved} + const duration = Math.floor((Date.now() - this.state.lastSaved) / (60 * 1000)) + this.setState({...this.state, elapsed: duration}) + } + componentDidMount() { Dispatcher.on('modal:show', () => { this.toggleEditable(false) @@ -193,6 +205,13 @@ class VisualEditor extends React.Component { // Setup global keydown to listen to all global keys window.addEventListener('keydown', this.onKeyDownGlobal) + const intervalId = setInterval(() => { + this.saveModuleToLocalStorage() + this.updateElapsed() + }, + 10000) + this.setState({ ...this.state, intervalId }) + // Set keyboard focus to the editor Transforms.select(this.editor, Editor.start(this.editor, [])) this.setEditorFocus() @@ -219,6 +238,7 @@ class VisualEditor extends React.Component { window.removeEventListener('beforeunload', this.checkIfSaved) window.removeEventListener('keydown', this.onKeyDownGlobal) if (this.resizeObserver) this.resizeObserver.disconnect() + clearInterval(this.state.intervalId) } checkIfSaved(event) { @@ -232,11 +252,13 @@ class VisualEditor extends React.Component { return undefined } - if (this.state.saveState !== 'saveSuccessful') { + // have to change value before save state is unsuccessful or do it in the already existing if + if (this.state.saveState !== 'saveSuccessful') { event.returnValue = true return true // Returning true will cause browser to ask user to confirm leaving page } + //eslint-disable-next-line return undefined } @@ -407,7 +429,7 @@ class VisualEditor extends React.Component { return false } - saveModule(draftId) { + formatJSON() { if (this.props.readOnly) { return } @@ -449,15 +471,20 @@ class VisualEditor extends React.Component { contentJSON.content = child.get('content') break } - json.children.push(contentJSON) }) + + return json + } + + saveModule(draftId) { + const json = this.formatJSON() this.setState({ saveState: 'saving' }) return this.props.saveDraft(draftId, JSON.stringify(json)).then(isSaved => { if (isSaved) { if (this.state.saveState === 'saving') { - this.setState({ saveState: 'saveSuccessful' }) + this.setState({...this.state, saveState: 'saveSuccessful', lastSaved: Date.now()}) } } else { this.setState({ saveState: 'saveFailed' }) @@ -465,6 +492,11 @@ class VisualEditor extends React.Component { }) } + saveModuleToLocalStorage() { + const json = this.formatJSON() + this.props.saveToLocalStorage(json) + } + exportToJSON(page, value) { if (page === null) return @@ -498,13 +530,13 @@ class VisualEditor extends React.Component { this.exportToJSON(this.props.page, this.state.value) } - importFromJSON() { + importFromJSON(existingJSON = null) { if (!this.props.page) { // if page is empty, exit return [{ text: 'No content available, create a page to start editing' }] } - const json = this.props.page.toJSON() + const json = existingJSON ?? this.props.page.toJSON() if (json.type === ASSESSMENT_NODE) { return [this.assessment.oboToSlate(this.props.page)] @@ -634,12 +666,14 @@ class VisualEditor extends React.Component { } } + render() { const className = 'editor--page-editor ' + isOrNot(this.state.showPlaceholders, 'show-placeholders') + isOrNot(this.props.readOnly, 'read-only') - + + //check document engine for confirmation dialog/modal return (
@@ -650,6 +684,7 @@ class VisualEditor extends React.Component { + {this.state.lastSaved ? {this.state.elapsed < 1 ? 'Last saved < 1m ago' : `Last saved ${this.state.elapsed}m ago`} : <>}