diff --git a/app/components/Auth.js b/app/components/Auth.js index 8952f87c..2a6b33ac 100644 --- a/app/components/Auth.js +++ b/app/components/Auth.js @@ -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, }), }; diff --git a/app/components/Avatar/Avatar.js b/app/components/Avatar/Avatar.js index c0566bee..ee3db84b 100644 --- a/app/components/Avatar/Avatar.js +++ b/app/components/Avatar/Avatar.js @@ -23,11 +23,13 @@ class Avatar extends React.Component { }; render() { + const { src, ...rest } = this.props; + return ( ); } diff --git a/app/components/DocumentHistory/DocumentHistory.js b/app/components/DocumentHistory/DocumentHistory.js new file mode 100644 index 00000000..26f2dce1 --- /dev/null +++ b/app/components/DocumentHistory/DocumentHistory.js @@ -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 { + @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 we’re 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 ( + + {showLoading ? ( + + + + ) : ( + + {this.revisions.map((revision, index) => ( + + ))} + + )} + {this.allowLoadMore && ( + + )} + + ); + } +} + +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)); diff --git a/app/components/DocumentHistory/components/DiffSummary.js b/app/components/DocumentHistory/components/DiffSummary.js new file mode 100644 index 00000000..68e2bcd8 --- /dev/null +++ b/app/components/DocumentHistory/components/DiffSummary.js @@ -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 ( + + {hasChanges && ( + + + + + )} + {hasChanges ? summary.join(', ') : 'No changes'} + + ); +} + +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; +`; diff --git a/app/components/DocumentHistory/components/Revision.js b/app/components/DocumentHistory/components/Revision.js new file mode 100644 index 00000000..7a36a1ae --- /dev/null +++ b/app/components/DocumentHistory/components/Revision.js @@ -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 ( + + + {' '} + {revision.createdBy.name} + + + + + + {showMenu && ( + } + /> + )} + + ); + } +} + +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); diff --git a/app/components/DocumentHistory/index.js b/app/components/DocumentHistory/index.js new file mode 100644 index 00000000..e533e31a --- /dev/null +++ b/app/components/DocumentHistory/index.js @@ -0,0 +1,3 @@ +// @flow +import DocumentHistory from './DocumentHistory'; +export default DocumentHistory; diff --git a/app/components/ErrorBoundary.js b/app/components/ErrorBoundary.js index 81bdbecf..e7f5b334 100644 --- a/app/components/ErrorBoundary.js +++ b/app/components/ErrorBoundary.js @@ -77,6 +77,7 @@ const Pre = styled.pre` padding: 16px; border-radius: 4px; font-size: 12px; + white-space: pre-wrap; `; export default ErrorBoundary; diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index cc2889a8..ba4de3aa 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -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 { 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 { )}
+ + Document history + *, + onClose: () => *, + history: Object, + document: Document, + revision: Revision, + className?: string, + ui: UiStore, +}; + +class RevisionMenu extends React.Component { + 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 ( + } + onOpen={onOpen} + onClose={onClose} + className={className} + > + + Restore version + +
+ + Copy link + +
+ ); + } +} + +export default withRouter(inject('ui')(RevisionMenu)); diff --git a/app/models/Document.js b/app/models/Document.js index d45601cc..3f611737 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -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; diff --git a/app/routes.js b/app/routes.js index 5450806d..b9ea0584 100644 --- a/app/routes.js +++ b/app/routes.js @@ -56,8 +56,12 @@ export default function Routes() { - - + + diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index 2a2c7f11..321b24e0 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -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 { @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 { 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 { 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 { 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 { 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 { 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 { return ( - - {isMoving && } + + } + /> { { onCancel={this.onDiscard} onShowToast={this.onShowToast} readOnly={!this.isEditing} - toc + toc={!revision} /> + {isHistory && ( + + )} ); } @@ -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) +); diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index 238c96b1..37e82d4c 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -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, }; diff --git a/app/stores/CacheStore.js b/app/stores/CacheStore.js deleted file mode 100644 index 96a3dd21..00000000 --- a/app/stores/CacheStore.js +++ /dev/null @@ -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; diff --git a/app/stores/RevisionsStore.js b/app/stores/RevisionsStore.js new file mode 100644 index 00000000..09955ff1 --- /dev/null +++ b/app/stores/RevisionsStore.js @@ -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 = 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; diff --git a/app/stores/index.js b/app/stores/index.js index 7065b456..634c4b10 100644 --- a/app/stores/index.js +++ b/app/stores/index.js @@ -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(), }; diff --git a/app/types/index.js b/app/types/index.js index a461d793..165827cd 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -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', diff --git a/app/utils/routeHelpers.js b/app/utils/routeHelpers.js index 741137ef..0cf02e63 100644 --- a/app/utils/routeHelpers.js +++ b/app/utils/routeHelpers.js @@ -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`; diff --git a/package.json b/package.json index 54f449f5..c5ea3b14 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api/__snapshots__/documents.test.js.snap b/server/api/__snapshots__/documents.test.js.snap index b2dbf607..2c0ac520 100644 --- a/server/api/__snapshots__/documents.test.js.snap +++ b/server/api/__snapshots__/documents.test.js.snap @@ -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", diff --git a/server/api/documents.js b/server/api/documents.js index cccd902d..832b16ce 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -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; diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 67e0efa1..6c8355ff 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -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(); diff --git a/server/models/Document.js b/server/models/Document.js index 8a1cd294..62cb5ae7 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -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; diff --git a/server/models/Revision.js b/server/models/Revision.js index f5dcc846..c138cfec 100644 --- a/server/models/Revision.js +++ b/server/models/Revision.js @@ -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; diff --git a/server/pages/Api.js b/server/pages/Api.js index 0d168b00..32434dcb 100644 --- a/server/pages/Api.js +++ b/server/pages/Api.js @@ -494,6 +494,28 @@ export default function Pricing() { + + + Restores a document to a previous revision by creating a new + revision with the contents of the given revisionId. + + + + + + + Pins a document to the collection home. The pinned document is @@ -576,6 +598,21 @@ export default function Pricing() { + + Return a specific revision of a document. + + + + + + - + + + diff --git a/server/policies/document.js b/server/policies/document.js index cbe3be79..c0a68165 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -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 +); diff --git a/server/presenters/revision.js b/server/presenters/revision.js index b03eda18..87d6b445 100644 --- a/server/presenters/revision.js +++ b/server/presenters/revision.js @@ -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)), }; } diff --git a/yarn.lock b/yarn.lock index 948712e4..b2f6a074 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"