@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user