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:
parent
7973bfeca2
commit
d0bee23432
@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
138
app/components/DocumentHistory/DocumentHistory.js
Normal file
138
app/components/DocumentHistory/DocumentHistory.js
Normal 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 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 (
|
||||
<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));
|
58
app/components/DocumentHistory/components/DiffSummary.js
Normal file
58
app/components/DocumentHistory/components/DiffSummary.js
Normal 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;
|
||||
`;
|
80
app/components/DocumentHistory/components/Revision.js
Normal file
80
app/components/DocumentHistory/components/Revision.js
Normal 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);
|
3
app/components/DocumentHistory/index.js
Normal file
3
app/components/DocumentHistory/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
// @flow
|
||||
import DocumentHistory from './DocumentHistory';
|
||||
export default DocumentHistory;
|
@ -77,6 +77,7 @@ const Pre = styled.pre`
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
@ -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
63
app/menus/RevisionMenu.js
Normal 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));
|
@ -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;
|
||||
|
@ -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} />
|
||||
|
@ -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)
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
96
app/stores/RevisionsStore.js
Normal file
96
app/stores/RevisionsStore.js
Normal 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;
|
@ -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(),
|
||||
};
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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`;
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
25
yarn.lock
25
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"
|
||||
|
Reference in New Issue
Block a user