Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP Auto Save #2135

Open
wants to merge 1 commit into
base: dev/34-bismuth
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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(<VisualEditor {...props}/>)

})

test('exportToJSON returns expected json for assessment node', () => {
const spy = jest.spyOn(Common.Registry, 'getItemForType')
spy.mockReturnValueOnce({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 => {
Expand All @@ -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(
<SimpleDialog
yesOrNo={true}
title={"Restore Unsaved Changes?"}
onConfirm={this.overwriteChanges}
onCancel={this.props.onCancel}
>
It looks like you did not save changes before closing the window.
<br/><br/>
Would you like to restore your unsaved changes?
</SimpleDialog>
)
}
}

EditorStore.init(
obomodel,
json.content.start,
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -355,6 +442,7 @@ class EditorApp extends React.Component {
switchMode={this.switchMode}
insertableItems={Common.Registry.insertableItems}
saveDraft={this.saveDraft}
saveToLocalStorage={this.saveToLocalStorage}
/>
)
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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) {
Expand All @@ -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
}
Expand Down Expand Up @@ -407,7 +429,7 @@ class VisualEditor extends React.Component {
return false
}

saveModule(draftId) {
formatJSON() {
if (this.props.readOnly) {
return
}
Expand Down Expand Up @@ -449,22 +471,32 @@ 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' })
}
})
}

saveModuleToLocalStorage() {
const json = this.formatJSON()
this.props.saveToLocalStorage(json)
}

exportToJSON(page, value) {
if (page === null) return

Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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 (
<div className={className} ref={this.pageEditorContainerRef}>
<Slate editor={this.editor} value={this.state.value} onChange={this.onChange}>
Expand All @@ -650,6 +684,7 @@ class VisualEditor extends React.Component {
<Button className="skip-nav" onClick={this.setEditorFocus}>
Skip to Editor
</Button>
{this.state.lastSaved ? <span className='lastSaved'>{this.state.elapsed < 1 ? 'Last saved < 1m ago' : `Last saved ${this.state.elapsed}m ago`}</span> : <></>}
<FileToolbarViewer
title={this.props.model.title}
draftId={this.props.draftId}
Expand Down
Loading
Loading