Version History (#768)

* Stash. Super rough progress

* Stash

* 'h' how toggles history panel
Add documents.restore endpoint

* Add tests for documents.restore endpoint

* Document restore endpoint

* Tiding, RevisionMenu, remove scroll dep

* Add history menu item

* Paginate loading

* Fixed: Error boundary styling
Select first revision faster

* Diff summary, styling

* Add history loading placeholder
Fix move modal not opening

* Fixes: Refreshing page on specific revision

* documentation for document.revision

* Better handle versions with no text changes (will no longer be created)
This commit is contained in:
Tom Moor 2018-09-29 21:24:07 -07:00 committed by GitHub
parent 7973bfeca2
commit d0bee23432
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 794 additions and 85 deletions

View File

@ -7,7 +7,6 @@ import ApiKeysStore from 'stores/ApiKeysStore';
import UsersStore from 'stores/UsersStore';
import CollectionsStore from 'stores/CollectionsStore';
import IntegrationsStore from 'stores/IntegrationsStore';
import CacheStore from 'stores/CacheStore';
import LoadingIndicator from 'components/LoadingIndicator';
type Props = {
@ -29,7 +28,6 @@ const Auth = observer(({ auth, children }: Props) => {
// will get overridden on route change
if (!authenticatedStores) {
// Stores for authenticated user
const cache = new CacheStore(user.id);
authenticatedStores = {
integrations: new IntegrationsStore({
ui: stores.ui,
@ -39,7 +37,6 @@ const Auth = observer(({ auth, children }: Props) => {
collections: new CollectionsStore({
ui: stores.ui,
teamId: team.id,
cache,
}),
};

View File

@ -23,11 +23,13 @@ class Avatar extends React.Component<Props> {
};
render() {
const { src, ...rest } = this.props;
return (
<CircleImg
size={this.props.size}
onError={this.handleError}
src={this.error ? placeholder : this.props.src}
src={this.error ? placeholder : src}
{...rest}
/>
);
}

View File

@ -0,0 +1,138 @@
// @flow
import * as React from 'react';
import { withRouter } from 'react-router-dom';
import { observable, action } from 'mobx';
import { observer, inject } from 'mobx-react';
import styled from 'styled-components';
import Waypoint from 'react-waypoint';
import ArrowKeyNavigation from 'boundless-arrow-key-navigation';
import { DEFAULT_PAGINATION_LIMIT } from 'stores/DocumentsStore';
import Document from 'models/Document';
import RevisionsStore from 'stores/RevisionsStore';
import Flex from 'shared/components/Flex';
import { ListPlaceholder } from 'components/LoadingPlaceholder';
import Revision from './components/Revision';
import { documentHistoryUrl } from 'utils/routeHelpers';
type Props = {
match: Object,
document: Document,
revisions: RevisionsStore,
revision?: Object,
history: Object,
};
@observer
class DocumentHistory extends React.Component<Props> {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
async componentDidMount() {
this.selectFirstRevision();
await this.loadMoreResults();
this.selectFirstRevision();
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.revisions.fetchPage({
limit,
offset: this.offset,
id: this.props.document.id,
});
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
selectFirstRevision = () => {
const revisions = this.revisions;
if (revisions.length && !this.props.revision) {
this.props.history.replace(
documentHistoryUrl(this.props.document, this.revisions[0].id)
);
}
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
get revisions() {
return this.props.revisions.getDocumentRevisions(this.props.document.id);
}
render() {
const showLoading = !this.isLoaded && this.isFetching;
const maxChanges = this.revisions.reduce((acc, change) => {
if (acc < change.diff.added + change.diff.removed) {
return change.diff.added + change.diff.removed;
}
return acc;
}, 0);
return (
<Wrapper column>
{showLoading ? (
<Loading>
<ListPlaceholder count={5} />
</Loading>
) : (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.revisions.map((revision, index) => (
<Revision
key={revision.id}
revision={revision}
document={this.props.document}
maxChanges={maxChanges}
showMenu={index !== 0}
/>
))}
</ArrowKeyNavigation>
)}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</Wrapper>
);
}
}
const Loading = styled.div`
margin: 0 16px;
`;
const Wrapper = styled(Flex)`
position: fixed;
top: 0;
right: 0;
bottom: 0;
min-width: ${props => props.theme.sidebarWidth};
border-left: 1px solid ${props => props.theme.slateLight};
overflow: scroll;
overscroll-behavior: none;
`;
export default withRouter(inject('revisions')(DocumentHistory));

View File

@ -0,0 +1,58 @@
// @flow
import * as React from 'react';
import styled from 'styled-components';
import Flex from 'shared/components/Flex';
type Props = {
added: number,
removed: number,
max: number,
color?: string,
width: number,
};
export default function DiffSummary({
added,
removed,
max,
color,
width = 180,
}: Props) {
const summary = [];
if (added) summary.push(`+${added}`);
if (removed) summary.push(`-${removed}`);
const hasChanges = !!summary.length;
return (
<Flex align="center">
{hasChanges && (
<Diff>
<Bar color={color} style={{ width: `${added / max * width}px` }} />
<Bar color={color} style={{ width: `${removed / max * width}px` }} />
</Diff>
)}
<Summary>{hasChanges ? summary.join(', ') : 'No changes'}</Summary>
</Flex>
);
}
const Summary = styled.div`
display: inline-block;
font-size: 10px;
opacity: 0.5;
flex-grow: 100;
text-transform: uppercase;
`;
const Diff = styled(Flex)`
height: 6px;
margin-right: 2px;
`;
const Bar = styled.div`
display: inline-block;
background: ${props => props.color || props.theme.text};
height: 100%;
opacity: 0.3;
margin-right: 1px;
`;

View File

@ -0,0 +1,80 @@
// @flow
import * as React from 'react';
import { NavLink } from 'react-router-dom';
import styled, { withTheme } from 'styled-components';
import format from 'date-fns/format';
import { MoreIcon } from 'outline-icons';
import Flex from 'shared/components/Flex';
import Time from 'shared/components/Time';
import Avatar from 'components/Avatar';
import RevisionMenu from 'menus/RevisionMenu';
import DiffSummary from './DiffSummary';
import { documentHistoryUrl } from 'utils/routeHelpers';
class Revision extends React.Component<*> {
render() {
const { revision, document, maxChanges, showMenu, theme } = this.props;
return (
<StyledNavLink
to={documentHistoryUrl(document, revision.id)}
activeStyle={{ background: theme.primary, color: theme.white }}
>
<Author>
<StyledAvatar src={revision.createdBy.avatarUrl} />{' '}
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt}>
{format(revision.createdAt, 'MMMM Do, YYYY h:mm a')}
</Time>
</Meta>
<DiffSummary {...revision.diff} max={maxChanges} />
{showMenu && (
<StyledRevisionMenu
document={document}
revision={revision}
label={<MoreIcon color={theme.white} />}
/>
)}
</StyledNavLink>
);
}
}
const StyledAvatar = styled(Avatar)`
border-color: transparent;
margin-right: 4px;
`;
const StyledRevisionMenu = styled(RevisionMenu)`
position: absolute;
right: 16px;
top: 16px;
`;
const StyledNavLink = styled(NavLink)`
color: ${props => props.theme.text};
display: block;
padding: 16px;
font-size: 15px;
position: relative;
height: 100px;
`;
const Author = styled(Flex)`
font-weight: 500;
padding: 0;
margin: 0;
`;
const Meta = styled.p`
font-size: 14px;
opacity: 0.75;
margin: 0 0 2px;
padding: 0;
`;
export default withTheme(Revision);

View File

@ -0,0 +1,3 @@
// @flow
import DocumentHistory from './DocumentHistory';
export default DocumentHistory;

View File

@ -77,6 +77,7 @@ const Pre = styled.pre`
padding: 16px;
border-radius: 4px;
font-size: 12px;
white-space: pre-wrap;
`;
export default ErrorBoundary;

View File

@ -7,7 +7,7 @@ import { MoreIcon } from 'outline-icons';
import Document from 'models/Document';
import UiStore from 'stores/UiStore';
import AuthStore from 'stores/AuthStore';
import { documentMoveUrl } from 'utils/routeHelpers';
import { documentMoveUrl, documentHistoryUrl } from 'utils/routeHelpers';
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
type Props = {
@ -34,6 +34,10 @@ class DocumentMenu extends React.Component<Props> {
this.props.ui.setActiveModal('document-delete', { document });
};
handleDocumentHistory = () => {
this.props.history.push(documentHistoryUrl(this.props.document));
};
handleMove = (ev: SyntheticEvent<*>) => {
this.props.history.push(documentMoveUrl(this.props.document));
};
@ -103,6 +107,9 @@ class DocumentMenu extends React.Component<Props> {
</DropdownMenuItem>
)}
<hr />
<DropdownMenuItem onClick={this.handleDocumentHistory}>
Document history
</DropdownMenuItem>
<DropdownMenuItem
onClick={this.handleNewChild}
title="Create a new child document for the current document"

63
app/menus/RevisionMenu.js Normal file
View File

@ -0,0 +1,63 @@
// @flow
import * as React from 'react';
import { withRouter } from 'react-router-dom';
import { inject } from 'mobx-react';
import { MoreIcon } from 'outline-icons';
import CopyToClipboard from 'components/CopyToClipboard';
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
import { documentHistoryUrl } from 'utils/routeHelpers';
import { Revision } from 'types';
import Document from 'models/Document';
import UiStore from 'stores/UiStore';
type Props = {
label?: React.Node,
onOpen?: () => *,
onClose: () => *,
history: Object,
document: Document,
revision: Revision,
className?: string,
ui: UiStore,
};
class RevisionMenu extends React.Component<Props> {
handleRestore = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
await this.props.document.restore(this.props.revision);
this.props.ui.showToast('Document restored', 'success');
this.props.history.push(this.props.document.url);
};
handleCopy = () => {
this.props.ui.showToast('Link copied', 'success');
};
render() {
const { label, className, onOpen, onClose } = this.props;
const url = `${process.env.URL}${documentHistoryUrl(
this.props.document,
this.props.revision.id
)}`;
return (
<DropdownMenu
label={label || <MoreIcon />}
onOpen={onOpen}
onClose={onClose}
className={className}
>
<DropdownMenuItem onClick={this.handleRestore}>
Restore version
</DropdownMenuItem>
<hr />
<CopyToClipboard text={url} onCopy={this.handleCopy}>
<DropdownMenuItem>Copy link</DropdownMenuItem>
</CopyToClipboard>
</DropdownMenu>
);
}
}
export default withRouter(inject('ui')(RevisionMenu));

View File

@ -7,7 +7,7 @@ import stores from 'stores';
import parseTitle from '../../shared/utils/parseTitle';
import unescape from '../../shared/utils/unescape';
import type { NavigationNode, User } from 'types';
import type { NavigationNode, Revision, User } from 'types';
import BaseModel from './BaseModel';
import Collection from './Collection';
@ -110,6 +110,22 @@ class Document extends BaseModel {
}
};
@action
restore = async (revision: Revision) => {
try {
const res = await client.post('/documents.restore', {
id: this.id,
revisionId: revision.id,
});
runInAction('Document#save', () => {
invariant(res && res.data, 'Data should be available');
this.updateData(res.data);
});
} catch (e) {
this.ui.showToast('Document failed to restore');
}
};
@action
pin = async () => {
this.pinned = true;

View File

@ -56,8 +56,12 @@ export default function Routes() {
<Route exact path="/settings/export" component={Export} />
<Route exact path="/collections/:id" component={Collection} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route exact path={`/doc/${slug}`} component={Document} />
<Route exact path={`/doc/${slug}/move`} component={Document} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={Document}
/>
<Route path={`/doc/${slug}`} component={Document} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:query" component={Search} />
<Route path="/404" component={Error404} />

View File

@ -5,7 +5,7 @@ import styled from 'styled-components';
import breakpoint from 'styled-components-breakpoint';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react';
import { withRouter, Prompt } from 'react-router-dom';
import { withRouter, Prompt, Route } from 'react-router-dom';
import type { Location } from 'react-router-dom';
import keydown from 'react-keydown';
import Flex from 'shared/components/Flex';
@ -13,21 +13,20 @@ import {
collectionUrl,
updateDocumentUrl,
documentMoveUrl,
documentHistoryUrl,
documentEditUrl,
matchDocumentEdit,
matchDocumentMove,
} from 'utils/routeHelpers';
import { uploadFile } from 'utils/uploadFile';
import { emojiToUrl } from 'utils/emoji';
import isInternalUrl from 'utils/isInternalUrl';
import type { Revision } from 'types';
import Document from 'models/Document';
import Header from './components/Header';
import DocumentMove from './components/DocumentMove';
import UiStore from 'stores/UiStore';
import AuthStore from 'stores/AuthStore';
import DocumentsStore from 'stores/DocumentsStore';
import ErrorBoundary from 'components/ErrorBoundary';
import DocumentHistory from 'components/DocumentHistory';
import LoadingPlaceholder from 'components/LoadingPlaceholder';
import LoadingIndicator from 'components/LoadingIndicator';
import CenteredContent from 'components/CenteredContent';
@ -36,6 +35,11 @@ import Search from 'scenes/Search';
import Error404 from 'scenes/Error404';
import ErrorOffline from 'scenes/ErrorOffline';
import UiStore from 'stores/UiStore';
import AuthStore from 'stores/AuthStore';
import DocumentsStore from 'stores/DocumentsStore';
import RevisionsStore from 'stores/RevisionsStore';
const AUTOSAVE_DELAY = 3000;
const IS_DIRTY_DELAY = 500;
const MARK_AS_VIEWED_AFTER = 3000;
@ -53,6 +57,7 @@ type Props = {
history: Object,
location: Location,
documents: DocumentsStore,
revisions: RevisionsStore,
newDocument?: boolean,
auth: AuthStore,
ui: UiStore,
@ -65,6 +70,7 @@ class DocumentScene extends React.Component<Props> {
@observable editorComponent;
@observable document: ?Document;
@observable revision: ?Revision;
@observable newDocument: ?Document;
@observable isUploading = false;
@observable isSaving = false;
@ -81,7 +87,8 @@ class DocumentScene extends React.Component<Props> {
componentWillReceiveProps(nextProps) {
if (
nextProps.match.params.documentSlug !==
this.props.match.params.documentSlug
this.props.match.params.documentSlug ||
this.props.match.params.revisionId !== nextProps.match.params.revisionId
) {
this.notFound = false;
clearTimeout(this.viewTimeout);
@ -100,6 +107,18 @@ class DocumentScene extends React.Component<Props> {
if (this.document) this.props.history.push(documentMoveUrl(this.document));
}
@keydown('h')
goToHistory(ev) {
ev.preventDefault();
if (!this.document) return;
if (this.revision) {
this.props.history.push(this.document.url);
} else {
this.props.history.push(documentHistoryUrl(this.document));
}
}
loadDocument = async props => {
if (props.newDocument) {
this.document = new Document({
@ -111,11 +130,22 @@ class DocumentScene extends React.Component<Props> {
text: '',
});
} else {
const { shareId } = props.match.params;
const { shareId, revisionId } = props.match.params;
this.document = await this.props.documents.fetch(
props.match.params.documentSlug,
{ shareId }
);
if (revisionId) {
this.revision = await this.props.revisions.fetch(
props.match.params.documentSlug,
revisionId
);
} else {
this.revision = undefined;
}
this.isDirty = false;
const document = this.document;
@ -128,10 +158,12 @@ class DocumentScene extends React.Component<Props> {
this.viewTimeout = setTimeout(document.view, MARK_AS_VIEWED_AFTER);
}
// Update url to match the current one
this.props.history.replace(
updateDocumentUrl(props.match.url, document.url)
);
if (!this.revision) {
// Update url to match the current one
this.props.history.replace(
updateDocumentUrl(props.match.url, document.url)
);
}
}
} else {
// Render 404 with search
@ -275,9 +307,10 @@ class DocumentScene extends React.Component<Props> {
render() {
const { location, match } = this.props;
const Editor = this.editorComponent;
const isMoving = match.path === matchDocumentMove;
const document = this.document;
const revision = this.revision;
const isShare = match.params.shareId;
const isHistory = match.url.match(/history/);
if (this.notFound) {
return navigator.onLine ? (
@ -304,8 +337,17 @@ class DocumentScene extends React.Component<Props> {
return (
<ErrorBoundary>
<Container key={document.id} isShare={isShare} column auto>
{isMoving && <DocumentMove document={document} />}
<Container
key={revision ? revision.id : document.id}
sidebar={isHistory}
isShare={isShare}
column
auto
>
<Route
path={`${match.url}/move`}
component={() => <DocumentMove document={document} />}
/>
<PageTitle
title={document.title.replace(document.emoji, '')}
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
@ -336,7 +378,7 @@ class DocumentScene extends React.Component<Props> {
<Editor
titlePlaceholder="Start with a title…"
bodyPlaceholder="…the rest is your canvas"
defaultValue={document.text}
defaultValue={revision ? revision.text : document.text}
pretitle={document.emoji}
uploadImage={this.onUploadImage}
onImageUploadStart={this.onImageUploadStart}
@ -348,11 +390,14 @@ class DocumentScene extends React.Component<Props> {
onCancel={this.onDiscard}
onShowToast={this.onShowToast}
readOnly={!this.isEditing}
toc
toc={!revision}
/>
</MaxWidth>
</Container>
</Container>
{isHistory && (
<DocumentHistory revision={revision} document={document} />
)}
</ErrorBoundary>
);
}
@ -375,10 +420,13 @@ const MaxWidth = styled(Flex)`
const Container = styled(Flex)`
position: relative;
margin-top: ${props => (props.isShare ? '50px' : '0')};
margin-right: ${props => (props.sidebar ? props.theme.sidebarWidth : 0)};
`;
const LoadingState = styled(LoadingPlaceholder)`
margin: 40px 0;
`;
export default withRouter(inject('ui', 'auth', 'documents')(DocumentScene));
export default withRouter(
inject('ui', 'auth', 'documents', 'revisions')(DocumentScene)
);

View File

@ -6,8 +6,8 @@ import RichMarkdownEditor, { Placeholder, schema } from 'rich-markdown-editor';
import ClickablePadding from 'components/ClickablePadding';
type Props = {
titlePlaceholder: string,
bodyPlaceholder: string,
titlePlaceholder?: string,
bodyPlaceholder?: string,
defaultValue?: string,
readOnly: boolean,
};

View File

@ -1,29 +0,0 @@
// @flow
import localForage from 'localforage';
class CacheStore {
key: string;
version: number = 2;
cacheKey = (key: string): string => {
return `CACHE_${this.key}_${this.version}_${key}`;
};
getItem = (key: string): any => {
return localForage.getItem(this.cacheKey(key));
};
setItem = (key: string, value: any): any => {
return localForage.setItem(this.cacheKey(key), value);
};
removeItem = (key: string) => {
return localForage.removeItem(this.cacheKey(key));
};
constructor(cacheKey: string) {
this.key = cacheKey;
}
}
export default CacheStore;

View File

@ -0,0 +1,96 @@
// @flow
import { observable, computed, action, runInAction, ObservableMap } from 'mobx';
import { client } from 'utils/ApiClient';
import { orderBy, filter } from 'lodash';
import invariant from 'invariant';
import BaseStore from './BaseStore';
import UiStore from './UiStore';
import type { Revision, PaginationParams } from 'types';
class RevisionsStore extends BaseStore {
@observable data: Map<string, Revision> = new ObservableMap([]);
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
ui: UiStore;
@computed
get orderedData(): Revision[] {
return orderBy(this.data.values(), 'createdAt', 'desc');
}
getDocumentRevisions(documentId: string): Revision[] {
return filter(this.orderedData, { documentId });
}
@action
fetch = async (documentId: string, id: string): Promise<*> => {
this.isFetching = true;
try {
const rev = this.getById(id);
if (rev) return rev;
const res = await client.post('/documents.revision', {
id: documentId,
revisionId: id,
});
invariant(res && res.data, 'Revision not available');
const { data } = res;
runInAction('RevisionsStore#fetch', () => {
this.data.set(data.id, data);
this.isLoaded = true;
});
return data;
} catch (e) {
this.ui.showToast('Failed to load document revision');
} finally {
this.isFetching = false;
}
};
@action
fetchPage = async (options: ?PaginationParams): Promise<*> => {
this.isFetching = true;
try {
const res = await client.post('/documents.revisions', options);
invariant(res && res.data, 'Document revisions not available');
const { data } = res;
runInAction('RevisionsStore#fetchPage', () => {
data.forEach(revision => {
this.data.set(revision.id, revision);
});
this.isLoaded = true;
});
return data;
} catch (e) {
this.ui.showToast('Failed to load document revisions');
} finally {
this.isFetching = false;
}
};
@action
add = (data: Revision): void => {
this.data.set(data.id, data);
};
@action
remove = (id: string): void => {
this.data.delete(id);
};
getById = (id: string): ?Revision => {
return this.data.get(id);
};
constructor(options: { ui: UiStore }) {
super();
this.ui = options.ui;
}
}
export default RevisionsStore;

View File

@ -2,6 +2,7 @@
import AuthStore from './AuthStore';
import UiStore from './UiStore';
import DocumentsStore from './DocumentsStore';
import RevisionsStore from './RevisionsStore';
import SharesStore from './SharesStore';
const ui = new UiStore();
@ -10,6 +11,7 @@ const stores = {
auth: new AuthStore(),
ui,
documents: new DocumentsStore({ ui }),
revisions: new RevisionsStore({ ui }),
shares: new SharesStore(),
};

View File

@ -1,4 +1,5 @@
// @flow
export type User = {
avatarUrl: string,
id: string,
@ -10,6 +11,19 @@ export type User = {
createdAt: string,
};
export type Revision = {
id: string,
documentId: string,
title: string,
text: string,
createdAt: string,
createdBy: User,
diff: {
added: number,
removed: number,
},
};
export type Toast = {
message: string,
type: 'warning' | 'error' | 'info' | 'success',

View File

@ -38,6 +38,12 @@ export function documentMoveUrl(doc: Document): string {
return `${doc.url}/move`;
}
export function documentHistoryUrl(doc: Document, revisionId?: string): string {
let base = `${doc.url}/history`;
if (revisionId) base += `/${revisionId}`;
return base;
}
/**
* Replace full url's document part with the new one in case
* the document slug has been updated
@ -69,4 +75,3 @@ export const matchDocumentSlug =
':documentSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})';
export const matchDocumentEdit = `/doc/${matchDocumentSlug}/edit`;
export const matchDocumentMove = `/doc/${matchDocumentSlug}/move`;

View File

@ -92,6 +92,7 @@
"css-loader": "^0.28.7",
"date-fns": "1.29.0",
"debug": "2.6.9",
"diff": "3.5.0",
"dotenv": "^4.0.0",
"emoji-regex": "^6.5.1",
"exports-loader": "^0.6.4",
@ -144,9 +145,9 @@
"query-string": "^4.3.4",
"randomstring": "1.1.5",
"raw-loader": "^0.5.1",
"react": "^16.2.0",
"react": "^16.4.0",
"react-avatar-editor": "^10.3.0",
"react-dom": "^16.1.0",
"react-dom": "^16.4.0",
"react-dropzone": "4.2.1",
"react-helmet": "^5.2.0",
"react-keydown": "^1.7.3",

View File

@ -35,6 +35,15 @@ Object {
}
`;
exports[`#documents.restore should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#documents.search should require authentication 1`] = `
Object {
"error": "authentication_required",

View File

@ -195,6 +195,26 @@ router.post('documents.info', auth({ required: false }), async ctx => {
};
});
router.post('documents.revision', auth(), async ctx => {
let { id, revisionId } = ctx.body;
ctx.assertPresent(id, 'id is required');
ctx.assertPresent(revisionId, 'revisionId is required');
const document = await Document.findById(id);
authorize(ctx.state.user, 'read', document);
const revision = await Revision.findOne({
where: {
id: revisionId,
documentId: document.id,
},
});
ctx.body = {
pagination: ctx.state.pagination,
data: presentRevision(ctx, revision),
};
});
router.post('documents.revisions', auth(), pagination(), async ctx => {
let { id, sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
@ -211,7 +231,9 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
revisions.map(revision => presentRevision(ctx, revision))
revisions.map((revision, index) =>
presentRevision(ctx, revision, revisions[index + 1])
)
);
ctx.body = {
@ -220,6 +242,27 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
};
});
router.post('documents.restore', auth(), async ctx => {
const { id, revisionId } = ctx.body;
ctx.assertPresent(id, 'id is required');
ctx.assertPresent(revisionId, 'revisionId is required');
const user = ctx.state.user;
const document = await Document.findById(id);
authorize(user, 'update', document);
const revision = await Revision.findById(revisionId);
authorize(document, 'restore', revision);
document.text = revision.text;
document.title = revision.title;
await document.save();
ctx.body = {
data: await presentDocument(ctx, document),
};
});
router.post('documents.search', auth(), pagination(), async ctx => {
const { query } = ctx.body;
const { offset, limit } = ctx.state.pagination;

View File

@ -418,6 +418,63 @@ describe('#documents.pin', async () => {
});
});
describe('#documents.restore', async () => {
it('should restore the document to a previous version', async () => {
const { user, document } = await seed();
const revision = await Revision.findOne({
where: { documentId: document.id },
});
const previousText = revision.text;
const revisionId = revision.id;
// update the document contents
document.text = 'UPDATED';
await document.save();
const res = await server.post('/api/documents.restore', {
body: { token: user.getJwtToken(), id: document.id, revisionId },
});
const body = await res.json();
expect(body.data.text).toEqual(previousText);
});
it('should not allow restoring a revision in another document', async () => {
const { user, document } = await seed();
const anotherDoc = await buildDocument();
const revision = await Revision.findOne({
where: { documentId: anotherDoc.id },
});
const revisionId = revision.id;
const res = await server.post('/api/documents.restore', {
body: { token: user.getJwtToken(), id: document.id, revisionId },
});
expect(res.status).toEqual(403);
});
it('should require authentication', async () => {
const res = await server.post('/api/documents.restore');
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should require authorization', async () => {
const { document } = await seed();
const revision = await Revision.findOne({
where: { documentId: document.id },
});
const revisionId = revision.id;
const user = await buildUser();
const res = await server.post('/api/documents.restore', {
body: { token: user.getJwtToken(), id: document.id, revisionId },
});
expect(res.status).toEqual(403);
});
});
describe('#documents.unpin', async () => {
it('should unpin the document', async () => {
const { user, document } = await seed();

View File

@ -28,8 +28,12 @@ const slugify = text =>
});
const createRevision = (doc, options = {}) => {
// we don't create revisions for autosaves
if (options.autosave) return;
// we don't create revisions if identical to previous
if (doc.text === doc.previous('text')) return;
return Revision.create({
title: doc.title,
text: doc.text,
@ -54,20 +58,9 @@ const beforeSave = async doc => {
doc.text = doc.text.replace(/^.*$/m, `# ${DEFAULT_TITLE}`);
}
// calculate collaborators
let ids = [];
if (doc.id) {
ids = await Revision.findAll({
attributes: [[DataTypes.literal('DISTINCT "userId"'), 'userId']],
where: {
documentId: doc.id,
},
}).map(rev => rev.userId);
}
// add the current user as revision hasn't been generated yet
ids.push(doc.lastModifiedById);
doc.collaboratorIds = uniq(ids);
// add the current user as a collaborator on this doc
if (!doc.collaboratorIds) doc.collaboratorIds = [];
doc.collaboratorIds = uniq(doc.collaboratorIds.concat(doc.lastModifiedById));
// increment revision
doc.revisionCount += 1;

View File

@ -28,4 +28,22 @@ const Revision = sequelize.define('revision', {
},
});
Revision.associate = models => {
Revision.belongsTo(models.Document, {
as: 'document',
foreignKey: 'documentId',
});
Revision.belongsTo(models.User, {
as: 'user',
foreignKey: 'userId',
});
Revision.addScope(
'defaultScope',
{
include: [{ model: models.User, as: 'user', paranoid: false }],
},
{ override: true }
);
};
export default Revision;

View File

@ -494,6 +494,28 @@ export default function Pricing() {
</Arguments>
</Method>
<Method
method="documents.restore"
label="Restore a previous revision"
>
<Description>
Restores a document to a previous revision by creating a new
revision with the contents of the given revisionId.
</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
<Argument
id="revisionId"
description="Revision ID to restore to"
required
/>
</Arguments>
</Method>
<Method method="documents.pin" label="Pin a document">
<Description>
Pins a document to the collection home. The pinned document is
@ -576,6 +598,21 @@ export default function Pricing() {
<Arguments pagination />
</Method>
<Method
method="documents.revision"
label="Get revision for a document"
>
<Description>Return a specific revision of a document.</Description>
<Arguments>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
<Argument id="revisionId" description="Revision ID" required />
</Arguments>
</Method>
<Method
method="documents.revisions"
label="Get revisions for a document"
@ -584,7 +621,13 @@ export default function Pricing() {
Return revisions for a document. Upon each edit, a new revision is
stored.
</Description>
<Arguments pagination />
<Arguments pagination>
<Argument
id="id"
description="Document ID or URI identifier"
required
/>
</Arguments>
</Method>
<Method method="team.users" label="List team's users">

View File

@ -1,6 +1,6 @@
// @flow
import policy from './policy';
import { Document, User } from '../models';
import { Document, Revision, User } from '../models';
const { allow } = policy;
@ -12,3 +12,10 @@ allow(
Document,
(user, document) => user.teamId === document.teamId
);
allow(
Document,
'restore',
Revision,
(document, revision) => document.id === revision.documentId
);

View File

@ -1,13 +1,33 @@
// @flow
import * as JSDiff from 'diff';
import { Revision } from '../models';
import presentUser from './user';
function counts(changes) {
return changes.reduce(
(acc, change) => {
if (change.added) acc.added += change.value.length;
if (change.removed) acc.removed += change.value.length;
return acc;
},
{
added: 0,
removed: 0,
}
);
}
function present(ctx: Object, revision: Revision, previous?: Revision) {
const prev = previous ? previous.text : '';
function present(ctx: Object, revision: Revision) {
return {
id: revision.id,
documentId: revision.documentId,
title: revision.title,
text: revision.text,
createdAt: revision.createdAt,
updatedAt: revision.updatedAt,
createdBy: presentUser(ctx, revision.user),
diff: counts(JSDiff.diffChars(prev, revision.text)),
};
}

View File

@ -2680,6 +2680,10 @@ detect-newline@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2"
diff@3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
diff@^3.2.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-3.3.0.tgz#056695150d7aa93237ca7e378ac3b1682b7963b9"
@ -8212,18 +8216,18 @@ react-deep-force-update@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.1.1.tgz#bcd31478027b64b3339f108921ab520b4313dc2c"
react-dom@^16.1.0:
version "16.1.0"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-16.1.0.tgz#ab6fd2a285096f388aeba51919a573d06c9bdde4"
react-dom@^16.2.0:
version "16.3.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.3.2.tgz#cb90f107e09536d683d84ed5d4888e9640e0e4df"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.0"
react-dom@^16.2.0:
version "16.3.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.3.2.tgz#cb90f107e09536d683d84ed5d4888e9640e0e4df"
react-dom@^16.4.0:
version "16.4.2"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.4.2.tgz#4afed569689f2c561d2b8da0b819669c38a0bda4"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.1.0"
@ -8387,6 +8391,15 @@ react@^16.2.0:
object-assign "^4.1.1"
prop-types "^15.6.0"
react@^16.4.0:
version "16.4.2"
resolved "https://registry.yarnpkg.com/react/-/react-16.4.2.tgz#2cd90154e3a9d9dd8da2991149fdca3c260e129f"
dependencies:
fbjs "^0.8.16"
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.0"
read-all-stream@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa"