Skip to content

Commit 9b00652

Browse files
authored
Merge pull request #858 from ajgreenb/save-editor-state
Add a grace period during which a closed workspace can be reloaded
2 parents 65db795 + 82808c0 commit 9b00652

11 files changed

Lines changed: 83 additions & 13 deletions

File tree

locales/en/translation.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@
5151
},
5252
"workspace": {
5353
"confirmations": {
54-
"unload-unsaved": "If you leave the page, you will lose your work. To save your work, you must log in using your GitHub account.",
5554
"anonymous-gist-export": "Are you sure you want to export this gist anonymously? If you sign in before exporting, the gist will be added to your GitHub account."
5655
},
5756
"loading": "Loading…",

src/actions/clients.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@ export const repoExportError = createAction('REPO_EXPORT_ERROR');
1818
export const repoExportDisplayed = createAction('REPO_EXPORT_DISPLAYED');
1919
export const repoExportNotDisplayed =
2020
createAction('REPO_EXPORT_NOT_DISPLAYED');
21+
export const projectRestoredFromLastSession =
22+
createAction('PROJECT_RESTORED_FROM_LAST_SESSION');

src/clients/localStorage.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
const PROJECT_STORAGE_KEY = 'last-closed-session-project-state';
2+
const GRACE_PERIOD = 5 * 60 * 1000;
3+
4+
export function dehydrateProject(project) {
5+
localStorage.setItem(PROJECT_STORAGE_KEY, JSON.stringify({
6+
dehydratedAt: Date.now(),
7+
project,
8+
}));
9+
}
10+
11+
export function rehydrateProject() {
12+
const dehydrated = localStorage.getItem(PROJECT_STORAGE_KEY);
13+
localStorage.removeItem(PROJECT_STORAGE_KEY);
14+
if (dehydrated) {
15+
const rehydrated = JSON.parse(dehydrated);
16+
if (Date.now() - rehydrated.dehydratedAt <= GRACE_PERIOD) {
17+
return rehydrated.project;
18+
}
19+
}
20+
return null;
21+
}

src/components/Workspace.jsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
onSignedOut,
1717
startSessionHeartbeat,
1818
} from '../clients/firebase';
19+
import {dehydrateProject, rehydrateProject} from '../clients/localStorage';
1920

2021
import {
2122
updateProjectSource,
@@ -53,7 +54,6 @@ function mapStateToProps(state) {
5354
isUserTyping: state.getIn(['ui', 'editors', 'typing']),
5455
editorsFlex: state.getIn(['ui', 'workspace', 'columnFlex']).toJS(),
5556
rowsFlex: state.getIn(['ui', 'workspace', 'rowFlex']).toJS(),
56-
currentUser: state.get('user').toJS(),
5757
ui: state.get('ui').toJS(),
5858
};
5959
}
@@ -63,7 +63,7 @@ class Workspace extends React.Component {
6363
super();
6464
bindAll(
6565
this,
66-
'_confirmUnload',
66+
'_handleUnload',
6767
'_handleComponentUnhide',
6868
'_handleComponentHide',
6969
'_handleDividerDrag',
@@ -94,30 +94,30 @@ class Workspace extends React.Component {
9494
}
9595
isExperimental = Object.keys(query).includes('experimental');
9696
}
97+
const rehydratedProject = rehydrateProject();
9798
history.replaceState({}, '', location.pathname);
9899
this.props.dispatch(applicationLoaded({
99100
snapshotKey,
100101
gistId,
101102
isExperimental,
103+
rehydratedProject,
102104
}));
103105
this._listenForAuthChange();
104106
startSessionHeartbeat();
105107
}
106108

107109
componentDidMount() {
108-
addEventListener('beforeunload', this._confirmUnload);
110+
addEventListener('beforeunload', this._handleUnload);
109111
}
110112

111113
componentWillUnmount() {
112-
removeEventListener('beforeunload', this._confirmUnload);
114+
removeEventListener('beforeunload', this._handleUnload);
113115
}
114116

115-
_confirmUnload(event) {
116-
const {currentUser, currentProject} = this.props;
117-
if (!currentUser.authenticated) {
118-
if (!isNull(currentProject) && !isPristineProject(currentProject)) {
119-
event.returnValue = t('workspace.confirmations.unload-unsaved');
120-
}
117+
_handleUnload() {
118+
const {currentProject} = this.props;
119+
if (!isNull(currentProject) && !isPristineProject(currentProject)) {
120+
dehydrateProject(currentProject);
121121
}
122122
}
123123

@@ -303,7 +303,6 @@ class Workspace extends React.Component {
303303

304304
Workspace.propTypes = {
305305
currentProject: PropTypes.object,
306-
currentUser: PropTypes.object.isRequired,
307306
dispatch: PropTypes.func.isRequired,
308307
editorsFlex: PropTypes.array.isRequired,
309308
errors: PropTypes.object.isRequired,

src/reducers/currentProject.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ function currentProject(stateIn, action) {
1919
return state.set('projectKey', action.payload.projectKey);
2020
case 'GIST_IMPORTED':
2121
return state.set('projectKey', action.payload.projectKey);
22+
case 'PROJECT_RESTORED_FROM_LAST_SESSION':
23+
return state.set('projectKey', action.payload.projectKey);
2224
default:
2325
return state;
2426
}

src/reducers/projects.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,9 @@ export default function reduceProjects(stateIn, action) {
127127
action.payload.gistData,
128128
);
129129

130+
case 'PROJECT_RESTORED_FROM_LAST_SESSION':
131+
return addProject(state, action.payload);
132+
130133
case 'TOGGLE_LIBRARY':
131134
return state.updateIn(
132135
[action.payload.projectKey, 'enabledLibraries'],

src/sagas/errors.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ export default function* () {
6363
takeEvery('CHANGE_CURRENT_PROJECT', validateCurrentProject, tasks),
6464
takeEvery('GIST_IMPORTED', validateCurrentProject, tasks),
6565
takeEvery('SNAPSHOT_IMPORTED', validateCurrentProject, tasks),
66+
takeEvery(
67+
'PROJECT_RESTORED_FROM_LAST_SESSION',
68+
validateCurrentProject,
69+
tasks,
70+
),
6671
takeEvery('UPDATE_PROJECT_SOURCE', updateProjectSource, tasks),
6772
takeEvery('TOGGLE_LIBRARY', toggleLibrary, tasks),
6873
]);

src/sagas/projects.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
snapshotImported,
2222
snapshotImportError,
2323
snapshotNotFound,
24+
projectRestoredFromLastSession,
2425
} from '../actions/clients';
2526
import {saveCurrentProject} from '../util/projectUtils';
2627
import {loadGistFromId} from '../clients/github';
@@ -32,6 +33,10 @@ export function* applicationLoaded(action) {
3233
yield call(importGist, action);
3334
} else if (isString(action.payload.snapshotKey)) {
3435
yield call(importSnapshot, action);
36+
} else if (action.payload.rehydratedProject) {
37+
yield put(
38+
projectRestoredFromLastSession(action.payload.rehydratedProject),
39+
);
3540
} else {
3641
yield call(createProject);
3742
}

test/unit/reducers/currentProject.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '../../../src/actions/projects';
1111
import {
1212
snapshotImported,
13+
projectRestoredFromLastSession,
1314
} from '../../../src/actions/clients';
1415
import {gistData} from '../../helpers/factory';
1516

@@ -39,6 +40,13 @@ test('snapshotImported', reducerTest(
3940
Immutable.fromJS({projectKey}),
4041
));
4142

43+
test('projectRestoredFromLastSession', reducerTest(
44+
reducer,
45+
initialState,
46+
partial(projectRestoredFromLastSession, {projectKey}),
47+
Immutable.fromJS({projectKey}),
48+
));
49+
4250
test('gistImported', reducerTest(
4351
reducer,
4452
initialState,

test/unit/reducers/projects.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '../../../src/actions/projects';
2323
import {
2424
snapshotImported,
25+
projectRestoredFromLastSession,
2526
} from '../../../src/actions/clients';
2627
import {
2728
focusLine,
@@ -103,6 +104,21 @@ tap(project(), importedProject =>
103104
)),
104105
);
105106

107+
tap(project(), rehydratedProject =>
108+
test('projectRestoredFromLastSession', reducerTest(
109+
reducer,
110+
states.initial,
111+
partial(
112+
projectRestoredFromLastSession,
113+
rehydratedProject,
114+
),
115+
states.initial.set(
116+
rehydratedProject.projectKey,
117+
Project.fromJS(rehydratedProject),
118+
),
119+
)),
120+
);
121+
106122
test('gistImported', (t) => {
107123
t.test('HTML and CSS, no JSON', reducerTest(
108124
reducer,

0 commit comments

Comments
 (0)