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
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`;