Merge pull request #644 from outline/autosave

Autosave Documents
This commit is contained in:
Tom Moor
2018-05-12 10:41:52 -07:00
committed by GitHub
10 changed files with 122 additions and 42 deletions

View File

@@ -2,7 +2,7 @@
import * as React from 'react'; import * as React from 'react';
import { observable, action } from 'mobx'; import { observable, action } from 'mobx';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { NavLink } from 'react-router-dom'; import { withRouter, NavLink } from 'react-router-dom';
import { CollapsedIcon } from 'outline-icons'; import { CollapsedIcon } from 'outline-icons';
import { color, fontWeight } from 'shared/styles/constants'; import { color, fontWeight } from 'shared/styles/constants';
import styled from 'styled-components'; import styled from 'styled-components';
@@ -59,6 +59,7 @@ type Props = {
active?: boolean, active?: boolean,
}; };
@withRouter
@observer @observer
class SidebarLink extends React.Component<Props> { class SidebarLink extends React.Component<Props> {
@observable expanded: boolean = false; @observable expanded: boolean = false;

View File

@@ -3,6 +3,7 @@ import { extendObservable, action, computed, runInAction } from 'mobx';
import invariant from 'invariant'; import invariant from 'invariant';
import BaseModel from 'models/BaseModel'; import BaseModel from 'models/BaseModel';
import Document from 'models/Document';
import { client } from 'utils/ApiClient'; import { client } from 'utils/ApiClient';
import stores from 'stores'; import stores from 'stores';
import ErrorsStore from 'stores/ErrorsStore'; import ErrorsStore from 'stores/ErrorsStore';
@@ -20,7 +21,7 @@ class Collection extends BaseModel {
name: string; name: string;
color: string; color: string;
type: 'atlas' | 'journal'; type: 'atlas' | 'journal';
documents: Array<NavigationNode>; documents: NavigationNode[];
updatedAt: string; updatedAt: string;
url: string; url: string;
@@ -51,6 +52,21 @@ class Collection extends BaseModel {
return results; return results;
} }
@action
updateDocument(document: Document) {
const travelDocuments = (documentList, path) =>
documentList.forEach(d => {
if (d.id === document.id) {
d.title = document.title;
d.url = document.url;
} else {
travelDocuments(d.children);
}
});
travelDocuments(this.documents);
}
/* Actions */ /* Actions */
@action @action
@@ -123,7 +139,7 @@ class Collection extends BaseModel {
extendObservable(this, data); extendObservable(this, data);
} }
constructor(collection: Object = {}) { constructor(collection: $Shape<Collection>) {
super(); super();
this.updateData(collection); this.updateData(collection);
@@ -133,13 +149,16 @@ class Collection extends BaseModel {
if (data.collectionId === this.id) this.fetch(); if (data.collectionId === this.id) this.fetch();
}); });
this.on( this.on(
'collections.update', 'documents.update',
(data: { id: string, collection: Collection }) => { (data: { collectionId: string, document: Document }) => {
// FIXME: calling this.updateData won't update the if (data.collectionId === this.id) {
// UI. Some mobx issue this.updateDocument(data.document);
if (data.id === this.id) this.fetch(); }
} }
); );
this.on('documents.publish', (data: { collectionId: string }) => {
if (data.collectionId === this.id) this.fetch();
});
this.on('documents.move', (data: { collectionId: string }) => { this.on('documents.move', (data: { collectionId: string }) => {
if (data.collectionId === this.id) this.fetch(); if (data.collectionId === this.id) this.fetch();
}); });

View File

@@ -11,6 +11,8 @@ import type { User } from 'types';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
import Collection from './Collection'; import Collection from './Collection';
type SaveOptions = { publish?: boolean, done?: boolean, autosave?: boolean };
class Document extends BaseModel { class Document extends BaseModel {
isSaving: boolean = false; isSaving: boolean = false;
hasPendingChanges: boolean = false; hasPendingChanges: boolean = false;
@@ -168,8 +170,10 @@ class Document extends BaseModel {
}; };
@action @action
save = async (publish: boolean = false, done: boolean = false) => { save = async (options: SaveOptions) => {
if (this.isSaving) return this; if (this.isSaving) return this;
const wasDraft = !this.publishedAt;
this.isSaving = true; this.isSaving = true;
try { try {
@@ -180,8 +184,7 @@ class Document extends BaseModel {
title: this.title, title: this.title,
text: this.text, text: this.text,
lastRevision: this.revision, lastRevision: this.revision,
publish, ...options,
done,
}); });
} else { } else {
const data = { const data = {
@@ -189,8 +192,7 @@ class Document extends BaseModel {
collection: this.collection.id, collection: this.collection.id,
title: this.title, title: this.title,
text: this.text, text: this.text,
publish, ...options,
done,
}; };
if (this.parentDocument) { if (this.parentDocument) {
data.parentDocument = this.parentDocument; data.parentDocument = this.parentDocument;
@@ -204,12 +206,19 @@ class Document extends BaseModel {
this.hasPendingChanges = false; this.hasPendingChanges = false;
}); });
this.emit('collections.update', { this.emit('documents.update', {
id: this.collection.id, document: this,
collection: this.collection, collectionId: this.collection.id,
}); });
if (wasDraft && this.publishedAt) {
this.emit('documents.publish', {
id: this.id,
collectionId: this.collection.id,
});
}
} catch (e) { } catch (e) {
this.errors.add('Document failed saving'); this.errors.add('Document failed to save');
} finally { } finally {
this.isSaving = false; this.isSaving = false;
} }

View File

@@ -1,6 +1,7 @@
// @flow // @flow
import * as React from 'react'; import * as React from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
import debounce from 'lodash/debounce';
import styled from 'styled-components'; import styled from 'styled-components';
import breakpoint from 'styled-components-breakpoint'; import breakpoint from 'styled-components-breakpoint';
import { observable } from 'mobx'; import { observable } from 'mobx';
@@ -32,6 +33,7 @@ import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle'; import PageTitle from 'components/PageTitle';
import Search from 'scenes/Search'; import Search from 'scenes/Search';
const AUTOSAVE_INTERVAL = 3000;
const DISCARD_CHANGES = ` const DISCARD_CHANGES = `
You have unsaved changes. You have unsaved changes.
Are you sure you want to discard them? Are you sure you want to discard them?
@@ -154,20 +156,20 @@ class DocumentScene extends React.Component<Props> {
handleCloseMoveModal = () => (this.moveModalOpen = false); handleCloseMoveModal = () => (this.moveModalOpen = false);
handleOpenMoveModal = () => (this.moveModalOpen = true); handleOpenMoveModal = () => (this.moveModalOpen = true);
onSave = async (options: { redirect?: boolean, publish?: boolean } = {}) => { onSave = async (
const { redirect, publish } = options; options: { done?: boolean, publish?: boolean, autosave?: boolean } = {}
) => {
let document = this.document; let document = this.document;
if (!document || !document.allowSave) return; if (!document || !document.allowSave) return;
this.editCache = null; this.editCache = null;
this.isSaving = true; this.isSaving = true;
this.isPublishing = !!publish; this.isPublishing = !!options.publish;
document = await document.save(publish, redirect); document = await document.save(options);
this.isSaving = false; this.isSaving = false;
this.isPublishing = false; this.isPublishing = false;
if (redirect) { if (options.done) {
this.props.history.push(document.url); this.props.history.push(document.url);
this.props.ui.setActiveDocument(document); this.props.ui.setActiveDocument(document);
} else if (this.props.newDocument) { } else if (this.props.newDocument) {
@@ -176,6 +178,10 @@ class DocumentScene extends React.Component<Props> {
} }
}; };
autosave = debounce(async () => {
this.onSave({ done: false, autosave: true });
}, AUTOSAVE_INTERVAL);
onImageUploadStart = () => { onImageUploadStart = () => {
this.isLoading = true; this.isLoading = true;
}; };
@@ -189,6 +195,7 @@ class DocumentScene extends React.Component<Props> {
if (!document) return; if (!document) return;
if (document.text.trim() === text.trim()) return; if (document.text.trim() === text.trim()) return;
document.updateData({ text }, true); document.updateData({ text }, true);
this.autosave();
}; };
onDiscard = () => { onDiscard = () => {

View File

@@ -20,8 +20,9 @@ type Props = {
savingIsDisabled: boolean, savingIsDisabled: boolean,
onDiscard: () => *, onDiscard: () => *,
onSave: ({ onSave: ({
redirect?: boolean, done?: boolean,
publish?: boolean, publish?: boolean,
autosave?: boolean,
}) => *, }) => *,
history: Object, history: Object,
}; };
@@ -36,11 +37,11 @@ class DocumentActions extends React.Component<Props> {
}; };
handleSave = () => { handleSave = () => {
this.props.onSave({ redirect: true }); this.props.onSave({ done: true });
}; };
handlePublish = () => { handlePublish = () => {
this.props.onSave({ redirect: true, publish: true }); this.props.onSave({ done: true, publish: true });
}; };
render() { render() {

View File

@@ -29,7 +29,7 @@ const importFile = async ({
if (documentId) data.parentDocument = documentId; if (documentId) data.parentDocument = documentId;
let document = new Document(data); let document = new Document(data);
document = await document.save(true); document = await document.save({ publish: true });
documents.add(document); documents.add(document);
resolve(document); resolve(document);
}; };

View File

@@ -334,7 +334,7 @@ router.post('documents.create', auth(), async ctx => {
}); });
router.post('documents.update', auth(), async ctx => { router.post('documents.update', auth(), async ctx => {
const { id, title, text, publish, done, lastRevision } = ctx.body; const { id, title, text, publish, autosave, done, lastRevision } = ctx.body;
ctx.assertPresent(id, 'id is required'); ctx.assertPresent(id, 'id is required');
ctx.assertPresent(title || text, 'title or text is required'); ctx.assertPresent(title || text, 'title or text is required');
@@ -355,7 +355,7 @@ router.post('documents.update', auth(), async ctx => {
if (publish) { if (publish) {
await document.publish(); await document.publish();
} else { } else {
await document.save(); await document.save({ autosave });
if (document.publishedAt && done) { if (document.publishedAt && done) {
events.add({ name: 'documents.update', model: document }); events.add({ name: 'documents.update', model: document });

View File

@@ -1,7 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */ /* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server'; import TestServer from 'fetch-test-server';
import app from '..'; import app from '..';
import { Document, View, Star } from '../models'; import { Document, View, Star, Revision } from '../models';
import { flushdb, seed } from '../test/support'; import { flushdb, seed } from '../test/support';
import { buildUser } from '../test/factories'; import { buildUser } from '../test/factories';
@@ -484,6 +484,31 @@ describe('#documents.update', async () => {
expect(body.data.collection.documents[0].title).toBe('Updated title'); expect(body.data.collection.documents[0].title).toBe('Updated title');
}); });
it('should not create new version when autosave=true', async () => {
const { user, document } = await seed();
const res = await server.post('/api/documents.update', {
body: {
token: user.getJwtToken(),
id: document.id,
title: 'Updated title',
text: 'Updated text',
lastRevision: document.revision,
autosave: true,
},
});
const prevRevisionRecords = await Revision.count();
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toBe('Updated title');
expect(body.data.text).toBe('Updated text');
const revisionRecords = await Revision.count();
expect(revisionRecords).toBe(prevRevisionRecords);
});
it('should fallback to a default title', async () => { it('should fallback to a default title', async () => {
const { user, document } = await seed(); const { user, document } = await seed();

View File

@@ -25,8 +25,9 @@ const slugify = text =>
remove: /[.]/g, remove: /[.]/g,
}); });
const createRevision = doc => { const createRevision = (doc, options = {}) => {
// Create revision of the current (latest) if (options.autosave) return;
return Revision.create({ return Revision.create({
title: doc.title, title: doc.title,
text: doc.text, text: doc.text,
@@ -204,15 +205,14 @@ Document.searchForUser = async (
LIMIT :limit OFFSET :offset; LIMIT :limit OFFSET :offset;
`; `;
const results = await sequelize const results = await sequelize.query(sql, {
.query(sql, { replacements: {
replacements: { query,
query, limit,
limit, offset,
offset, },
}, model: Document,
model: Document, });
})
const ids = results.map(document => document.id); const ids = results.map(document => document.id);
// Second query to get views for the data // Second query to get views for the data

View File

@@ -425,7 +425,25 @@ export default function Pricing() {
id="publish" id="publish"
description={ description={
<span> <span>
Pass <code>true</code> to publish a draft Pass <code>true</code> to publish a draft.
</span>
}
/>
<Argument
id="autosave"
description={
<span>
Pass <code>true</code> to signify an autosave. This skips
creating a revision.
</span>
}
/>
<Argument
id="done"
description={
<span>
Pass <code>true</code> to signify the end of an editing
session. This will trigger documents.update hooks.
</span> </span>
} }
/> />