Websocket Support (#937)
* Atom / RSS meta link * Spike * Feeling good about this spike now * Remove document.collection * Remove koa.ctx from all presenters to make them portable outside requests * Remove full serialized model from events Move events.add to controllers for now, will eventually be in commands * collections.create event parentDocument -> parentDocumentId * Fix up deprecated tests * Fixed: Doc creation * documents.move * Handle collection deleted * 💚 * Authorize room join requests * Move starred data structure Account for documents with no context on sockets * Add socket.io-redis * Add WEBSOCKETS_ENABLED env variable to disable websockets entirely for self hosted New installations will default to true, existing installations to false * 💚 No need for promise response here * Reload notice
This commit is contained in:
parent
4a571a088e
commit
07a941a65d
|
@ -13,6 +13,7 @@ URL=http://localhost:3000
|
||||||
DEPLOYMENT=self
|
DEPLOYMENT=self
|
||||||
ENABLE_UPDATES=true
|
ENABLE_UPDATES=true
|
||||||
SUBDOMAINS_ENABLED=false
|
SUBDOMAINS_ENABLED=false
|
||||||
|
WEBSOCKETS_ENABLED=true
|
||||||
DEBUG=sql,cache,presenters,events
|
DEBUG=sql,cache,presenters,events
|
||||||
|
|
||||||
# Third party signin credentials (at least one is required)
|
# Third party signin credentials (at least one is required)
|
||||||
|
|
280
app.json
280
app.json
|
@ -1,143 +1,147 @@
|
||||||
{
|
{
|
||||||
"name": "Outline",
|
"name": "Outline",
|
||||||
"description": "Open source wiki and knowledge base for growing teams",
|
"description": "Open source wiki and knowledge base for growing teams",
|
||||||
"website": "https://www.getoutline.com/",
|
"website": "https://www.getoutline.com/",
|
||||||
"repository": "https://github.com/outline/outline",
|
"repository": "https://github.com/outline/outline",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"wiki",
|
"wiki",
|
||||||
"team",
|
"team",
|
||||||
"node",
|
"node",
|
||||||
"markdown",
|
"markdown",
|
||||||
"slack"
|
"slack"
|
||||||
],
|
],
|
||||||
"success_url": "/",
|
"success_url": "/",
|
||||||
"formation": {
|
"formation": {
|
||||||
"web": {
|
"web": {
|
||||||
"quantity": 1,
|
"quantity": 1,
|
||||||
"size": "Hobby"
|
"size": "Hobby"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"image": "heroku/node",
|
||||||
|
"addons": [
|
||||||
|
{
|
||||||
|
"plan": "heroku-redis"
|
||||||
},
|
},
|
||||||
"image": "heroku/node",
|
{
|
||||||
"addons": [
|
"plan": "heroku-postgresql"
|
||||||
{
|
}
|
||||||
"plan": "heroku-redis"
|
],
|
||||||
},
|
"scripts": {
|
||||||
{
|
"postdeploy": "yarn sequelize db:migrate"
|
||||||
"plan": "heroku-postgresql"
|
},
|
||||||
}
|
"env": {
|
||||||
],
|
"SECRET_KEY": {
|
||||||
"scripts": {
|
"description": "A secret key",
|
||||||
"postdeploy": "yarn sequelize db:migrate"
|
"generator": "secret",
|
||||||
|
"required": true
|
||||||
},
|
},
|
||||||
"env": {
|
"DEPLOYMENT": {
|
||||||
"SECRET_KEY": {
|
"description": "Should be 'self' for self hosted installations, turns off things like pricing pages",
|
||||||
"description": "A secret key",
|
"value": "self",
|
||||||
"generator": "secret",
|
"required": true
|
||||||
"required": true
|
},
|
||||||
},
|
"ENABLE_UPDATES": {
|
||||||
"DEPLOYMENT": {
|
"value": "true",
|
||||||
"description": "Should be 'self' for self hosted installations, turns off things like pricing pages",
|
"required": true
|
||||||
"value": "self",
|
},
|
||||||
"required": true
|
"SUBDOMAINS_ENABLED": {
|
||||||
},
|
"value": "false",
|
||||||
"ENABLE_UPDATES": {
|
"required": true,
|
||||||
"value": "true",
|
"description": "Allows each team to have a different subdomain. Not recommend when self hosting"
|
||||||
"required": true
|
},
|
||||||
},
|
"WEBSOCKETS_ENABLED": {
|
||||||
"SUBDOMAINS_ENABLED": {
|
"value": "true",
|
||||||
"value": "false",
|
"required": true,
|
||||||
"required": true,
|
"description": "Allow realtime data to be pushed to clients over websockets"
|
||||||
"description": "Allows each team to have a different subdomain. Not recommend when self hosting"
|
},
|
||||||
},
|
"URL": {
|
||||||
"URL": {
|
"description": "https://{your app name}.herokuapp.com",
|
||||||
"description": "https://{your app name}.herokuapp.com",
|
"required": true
|
||||||
"required": true
|
},
|
||||||
},
|
"GOOGLE_CLIENT_ID": {
|
||||||
"GOOGLE_CLIENT_ID": {
|
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
|
||||||
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"GOOGLE_CLIENT_SECRET": {
|
||||||
"GOOGLE_CLIENT_SECRET": {
|
"description": "",
|
||||||
"description": "",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"GOOGLE_ALLOWED_DOMAINS": {
|
||||||
"GOOGLE_ALLOWED_DOMAINS": {
|
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
|
||||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SLACK_KEY": {
|
||||||
"SLACK_KEY": {
|
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
|
||||||
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SLACK_SECRET": {
|
||||||
"SLACK_SECRET": {
|
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
|
||||||
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SLACK_VERIFICATION_TOKEN": {
|
||||||
"SLACK_VERIFICATION_TOKEN": {
|
"description": "Your Slack verification token - PLxk6OlXXXXXVj3YYYY",
|
||||||
"description": "Your Slack verification token - PLxk6OlXXXXXVj3YYYY",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SLACK_APP_ID": {
|
||||||
"SLACK_APP_ID": {
|
"description": "A0XXXXXXXXX",
|
||||||
"description": "A0XXXXXXXXX",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"AWS_ACCESS_KEY_ID": {
|
||||||
"AWS_ACCESS_KEY_ID": {
|
"description": "Needed to save file uploads. Optional for dev / testing.",
|
||||||
"description": "Needed to save file uploads. Optional for dev / testing.",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"AWS_SECRET_ACCESS_KEY": {
|
||||||
"AWS_SECRET_ACCESS_KEY": {
|
"description": "",
|
||||||
"description": "",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"AWS_S3_UPLOAD_BUCKET_NAME": {
|
||||||
"AWS_S3_UPLOAD_BUCKET_NAME": {
|
"description": "yourbucket.example.com",
|
||||||
"description": "yourbucket.example.com",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"AWS_S3_UPLOAD_BUCKET_URL": {
|
||||||
"AWS_S3_UPLOAD_BUCKET_URL": {
|
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
||||||
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"AWS_S3_UPLOAD_MAX_SIZE": {
|
||||||
"AWS_S3_UPLOAD_MAX_SIZE": {
|
"description": "Maximum file upload size in bytes",
|
||||||
"description": "Maximum file upload size in bytes",
|
"value": "26214400",
|
||||||
"value": "26214400",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SMTP_HOST": {
|
||||||
"SMTP_HOST": {
|
"description": "smtp.example.com (optional)",
|
||||||
"description": "smtp.example.com (optional)",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SMTP_PORT": {
|
||||||
"SMTP_PORT": {
|
"description": "1234 (optional)",
|
||||||
"description": "1234 (optional)",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SMTP_USERNAME": {
|
||||||
"SMTP_USERNAME": {
|
"description": "me@example.com (optional)",
|
||||||
"description": "me@example.com (optional)",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SMTP_PASSWORD": {
|
||||||
"SMTP_PASSWORD": {
|
"description": "(optional)",
|
||||||
"description": "(optional)",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SMTP_FROM_EMAIL": {
|
||||||
"SMTP_FROM_EMAIL": {
|
"description": "wiki@example.com (optional)",
|
||||||
"description": "wiki@example.com (optional)",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"SMTP_REPLY_EMAIL": {
|
||||||
"SMTP_REPLY_EMAIL": {
|
"description": "wikireply@example.com (optional)",
|
||||||
"description": "wikireply@example.com (optional)",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"GOOGLE_ANALYTICS_ID": {
|
||||||
"GOOGLE_ANALYTICS_ID": {
|
"description": "UA-xxxx (optional)",
|
||||||
"description": "UA-xxxx (optional)",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"BUGSNAG_KEY": {
|
||||||
"BUGSNAG_KEY": {
|
"description": "An API key for bugsnag if you wish to collect error reporting (optional)",
|
||||||
"description": "An API key for bugsnag if you wish to collect error reporting (optional)",
|
"required": false
|
||||||
"required": false
|
},
|
||||||
},
|
"GITHUB_ACCESS_TOKEN": {
|
||||||
"GITHUB_ACCESS_TOKEN": {
|
"description": "An API token for GitHub, optional for self hosted (optional)",
|
||||||
"description": "An API token for GitHub, optional for self hosted (optional)",
|
"required": false
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -18,7 +18,6 @@ type Props = {
|
||||||
showCollection?: boolean,
|
showCollection?: boolean,
|
||||||
showPublished?: boolean,
|
showPublished?: boolean,
|
||||||
showPin?: boolean,
|
showPin?: boolean,
|
||||||
link?: boolean,
|
|
||||||
ref?: *,
|
ref?: *,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -141,7 +140,6 @@ class DocumentPreview extends React.Component<Props> {
|
||||||
showPin,
|
showPin,
|
||||||
highlight,
|
highlight,
|
||||||
context,
|
context,
|
||||||
link,
|
|
||||||
...rest
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
@ -151,15 +149,10 @@ class DocumentPreview extends React.Component<Props> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentLink
|
<DocumentLink
|
||||||
as={link === false ? 'div' : undefined}
|
to={{
|
||||||
to={
|
pathname: document.url,
|
||||||
link === false
|
state: { title: document.title },
|
||||||
? undefined
|
}}
|
||||||
: {
|
|
||||||
pathname: document.url,
|
|
||||||
state: { title: document.title },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<Heading>
|
<Heading>
|
||||||
|
@ -167,7 +160,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||||
{!document.isDraft &&
|
{!document.isDraft &&
|
||||||
!document.isArchived && (
|
!document.isArchived && (
|
||||||
<Actions>
|
<Actions>
|
||||||
{document.starred ? (
|
{document.isStarred ? (
|
||||||
<StyledStar onClick={this.unstar} solid />
|
<StyledStar onClick={this.unstar} solid />
|
||||||
) : (
|
) : (
|
||||||
<StyledStar onClick={this.star} />
|
<StyledStar onClick={this.star} />
|
||||||
|
@ -185,7 +178,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||||
)}
|
)}
|
||||||
<PublishingInfo
|
<PublishingInfo
|
||||||
document={document}
|
document={document}
|
||||||
collection={showCollection ? document.collection : undefined}
|
showCollection={showCollection}
|
||||||
showPublished={showPublished}
|
showPublished={showPublished}
|
||||||
/>
|
/>
|
||||||
</DocumentLink>
|
</DocumentLink>
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { inject } from 'mobx-react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import Collection from 'models/Collection';
|
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import Time from 'shared/components/Time';
|
import Time from 'shared/components/Time';
|
||||||
import Breadcrumb from 'shared/components/Breadcrumb';
|
import Breadcrumb from 'shared/components/Breadcrumb';
|
||||||
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
|
||||||
const Container = styled(Flex)`
|
const Container = styled(Flex)`
|
||||||
color: ${props => props.theme.textTertiary};
|
color: ${props => props.theme.textTertiary};
|
||||||
|
@ -21,13 +22,19 @@ const Modified = styled.span`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
collection?: Collection,
|
collections: CollectionsStore,
|
||||||
|
showCollection?: boolean,
|
||||||
showPublished?: boolean,
|
showPublished?: boolean,
|
||||||
document: Document,
|
document: Document,
|
||||||
views?: number,
|
views?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
function PublishingInfo({ collection, showPublished, document }: Props) {
|
function PublishingInfo({
|
||||||
|
collections,
|
||||||
|
showPublished,
|
||||||
|
showCollection,
|
||||||
|
document,
|
||||||
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
modifiedSinceViewed,
|
modifiedSinceViewed,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
@ -37,6 +44,7 @@ function PublishingInfo({ collection, showPublished, document }: Props) {
|
||||||
deletedAt,
|
deletedAt,
|
||||||
isDraft,
|
isDraft,
|
||||||
} = document;
|
} = document;
|
||||||
|
|
||||||
const neverUpdated = publishedAt === updatedAt;
|
const neverUpdated = publishedAt === updatedAt;
|
||||||
let content;
|
let content;
|
||||||
|
|
||||||
|
@ -72,20 +80,23 @@ function PublishingInfo({ collection, showPublished, document }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collection = collections.get(document.collectionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container align="center">
|
<Container align="center">
|
||||||
{updatedBy.name}
|
{updatedBy.name}
|
||||||
{content}
|
{content}
|
||||||
{collection && (
|
{showCollection &&
|
||||||
<span>
|
collection && (
|
||||||
in
|
<span>
|
||||||
<strong>
|
in
|
||||||
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
|
<strong>
|
||||||
</strong>
|
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
|
||||||
</span>
|
</strong>
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PublishingInfo;
|
export default inject('collections')(PublishingInfo);
|
||||||
|
|
|
@ -52,7 +52,7 @@ class DropToImport extends React.Component<Props> {
|
||||||
if (documentId && !collectionId) {
|
if (documentId && !collectionId) {
|
||||||
const document = await this.props.documents.fetch(documentId);
|
const document = await this.props.documents.fetch(documentId);
|
||||||
invariant(document, 'Document not available');
|
invariant(document, 'Document not available');
|
||||||
collectionId = document.collection.id;
|
collectionId = document.collectionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
|
|
@ -55,7 +55,7 @@ class Editor extends React.Component<Props> {
|
||||||
};
|
};
|
||||||
|
|
||||||
onShowToast = (message: string) => {
|
onShowToast = (message: string) => {
|
||||||
this.props.ui.showToast(message, 'success');
|
this.props.ui.showToast(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
getLinkComponent = node => {
|
getLinkComponent = node => {
|
||||||
|
|
|
@ -65,6 +65,7 @@ class CollectionLink extends React.Component<Props> {
|
||||||
<DocumentLink
|
<DocumentLink
|
||||||
key={document.id}
|
key={document.id}
|
||||||
document={document}
|
document={document}
|
||||||
|
collection={collection}
|
||||||
activeDocument={activeDocument}
|
activeDocument={activeDocument}
|
||||||
prefetchDocument={prefetchDocument}
|
prefetchDocument={prefetchDocument}
|
||||||
depth={1.5}
|
depth={1.5}
|
||||||
|
|
|
@ -34,10 +34,10 @@ class Collections extends React.Component<Props> {
|
||||||
|
|
||||||
@keydown('n')
|
@keydown('n')
|
||||||
goToNewDocument() {
|
goToNewDocument() {
|
||||||
const activeCollection = this.props.collections.active;
|
const { activeCollectionId } = this.props.ui;
|
||||||
if (!activeCollection) return;
|
if (!activeCollectionId) return;
|
||||||
|
|
||||||
this.props.history.push(newDocumentUrl(activeCollection));
|
this.props.history.push(newDocumentUrl(activeCollectionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -5,11 +5,13 @@ import styled from 'styled-components';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import SidebarLink from './SidebarLink';
|
import SidebarLink from './SidebarLink';
|
||||||
import DropToImport from 'components/DropToImport';
|
import DropToImport from 'components/DropToImport';
|
||||||
|
import Collection from 'models/Collection';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import { type NavigationNode } from 'types';
|
import { type NavigationNode } from 'types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
document: NavigationNode,
|
document: NavigationNode,
|
||||||
|
collection?: Collection,
|
||||||
activeDocument: ?Document,
|
activeDocument: ?Document,
|
||||||
activeDocumentRef?: (?HTMLElement) => *,
|
activeDocumentRef?: (?HTMLElement) => *,
|
||||||
prefetchDocument: (documentId: string) => Promise<void>,
|
prefetchDocument: (documentId: string) => Promise<void>,
|
||||||
|
@ -29,6 +31,7 @@ class DocumentLink extends React.Component<Props> {
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
document,
|
document,
|
||||||
|
collection,
|
||||||
activeDocument,
|
activeDocument,
|
||||||
activeDocumentRef,
|
activeDocumentRef,
|
||||||
prefetchDocument,
|
prefetchDocument,
|
||||||
|
@ -39,7 +42,9 @@ class DocumentLink extends React.Component<Props> {
|
||||||
activeDocument && activeDocument.id === document.id;
|
activeDocument && activeDocument.id === document.id;
|
||||||
const showChildren = !!(
|
const showChildren = !!(
|
||||||
activeDocument &&
|
activeDocument &&
|
||||||
(activeDocument.pathToDocument
|
collection &&
|
||||||
|
(collection
|
||||||
|
.pathToDocument(activeDocument)
|
||||||
.map(entry => entry.id)
|
.map(entry => entry.id)
|
||||||
.includes(document.id) ||
|
.includes(document.id) ||
|
||||||
isActiveDocument)
|
isActiveDocument)
|
||||||
|
@ -69,6 +74,7 @@ class DocumentLink extends React.Component<Props> {
|
||||||
{document.children.map(childDocument => (
|
{document.children.map(childDocument => (
|
||||||
<DocumentLink
|
<DocumentLink
|
||||||
key={childDocument.id}
|
key={childDocument.id}
|
||||||
|
collection={collection}
|
||||||
document={childDocument}
|
document={childDocument}
|
||||||
activeDocument={activeDocument}
|
activeDocument={activeDocument}
|
||||||
prefetchDocument={prefetchDocument}
|
prefetchDocument={prefetchDocument}
|
||||||
|
|
|
@ -0,0 +1,96 @@
|
||||||
|
// @flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import { inject } from 'mobx-react';
|
||||||
|
import io from 'socket.io-client';
|
||||||
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
import AuthStore from 'stores/AuthStore';
|
||||||
|
import UiStore from 'stores/UiStore';
|
||||||
|
|
||||||
|
const SocketContext = React.createContext();
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.Node,
|
||||||
|
documents: DocumentsStore,
|
||||||
|
collections: CollectionsStore,
|
||||||
|
auth: AuthStore,
|
||||||
|
ui: UiStore,
|
||||||
|
};
|
||||||
|
|
||||||
|
class SocketProvider extends React.Component<Props> {
|
||||||
|
socket;
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (!process.env.WEBSOCKETS_ENABLED) return;
|
||||||
|
|
||||||
|
this.socket = io(window.location.origin, {
|
||||||
|
path: '/realtime',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { auth, ui, documents, collections } = this.props;
|
||||||
|
if (!auth.token) return;
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
this.socket.emit('authentication', {
|
||||||
|
token: auth.token,
|
||||||
|
});
|
||||||
|
this.socket.on('unauthorized', err => {
|
||||||
|
ui.showToast(err.message);
|
||||||
|
});
|
||||||
|
this.socket.on('entities', event => {
|
||||||
|
if (event.documents) {
|
||||||
|
event.documents.forEach(doc => {
|
||||||
|
documents.add(doc);
|
||||||
|
|
||||||
|
// TODO: Move this to the document scene once data loading
|
||||||
|
// has been refactored to be friendlier there.
|
||||||
|
if (
|
||||||
|
auth.user &&
|
||||||
|
doc.id === ui.activeDocumentId &&
|
||||||
|
doc.updatedBy.id !== auth.user.id
|
||||||
|
) {
|
||||||
|
ui.showToast(`Document updated by ${doc.updatedBy.name}`, {
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
action: {
|
||||||
|
text: 'Refresh',
|
||||||
|
onClick: () => window.location.reload(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (event.collections) {
|
||||||
|
event.collections.forEach(collections.add);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.socket.on('documents.star', event => {
|
||||||
|
documents.starredIds.set(event.documentId, true);
|
||||||
|
});
|
||||||
|
this.socket.on('documents.unstar', event => {
|
||||||
|
documents.starredIds.set(event.documentId, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// received a message from the API server that we should request
|
||||||
|
// to join a specific room. Forward that to the ws server.
|
||||||
|
this.socket.on('join', event => {
|
||||||
|
this.socket.emit('join', event);
|
||||||
|
});
|
||||||
|
|
||||||
|
// received a message from the API server that we should request
|
||||||
|
// to leave a specific room. Forward that to the ws server.
|
||||||
|
this.socket.on('leave', event => {
|
||||||
|
this.socket.emit('leave', event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider value={this.socket}>
|
||||||
|
{this.props.children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('auth', 'ui', 'documents', 'collections')(SocketProvider);
|
|
@ -21,7 +21,7 @@ class Toast extends React.Component<Props> {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.timeout = setTimeout(
|
this.timeout = setTimeout(
|
||||||
this.props.onRequestClose,
|
this.props.onRequestClose,
|
||||||
this.props.closeAfterMs
|
this.props.toast.timeout || this.props.closeAfterMs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,6 +31,7 @@ class Toast extends React.Component<Props> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { toast, onRequestClose } = this.props;
|
const { toast, onRequestClose } = this.props;
|
||||||
|
const { action } = toast;
|
||||||
const message =
|
const message =
|
||||||
typeof toast.message === 'string'
|
typeof toast.message === 'string'
|
||||||
? toast.message
|
? toast.message
|
||||||
|
@ -38,20 +39,43 @@ class Toast extends React.Component<Props> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<Container onClick={onRequestClose} type={toast.type}>
|
<Container
|
||||||
|
onClick={action ? undefined : onRequestClose}
|
||||||
|
type={toast.type || 'success'}
|
||||||
|
>
|
||||||
<Message>{message}</Message>
|
<Message>{message}</Message>
|
||||||
|
{action && (
|
||||||
|
<Action type={toast.type || 'success'} onClick={action.onClick}>
|
||||||
|
{action.text}
|
||||||
|
</Action>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Action = styled.span`
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
height: 100%;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${props => props.theme.white};
|
||||||
|
background: ${props => darken(0.05, props.theme[props.type])};
|
||||||
|
border-top-right-radius: 5px;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: ${props => darken(0.1, props.theme[props.type])};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
animation: ${fadeAndScaleIn} 100ms ease;
|
animation: ${fadeAndScaleIn} 100ms ease;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
padding: 10px 12px;
|
|
||||||
color: ${props => props.theme.white};
|
color: ${props => props.theme.white};
|
||||||
background: ${props => props.theme[props.type]};
|
background: ${props => props.theme[props.type]};
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
@ -64,7 +88,8 @@ const Container = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Message = styled.div`
|
const Message = styled.div`
|
||||||
padding-left: 5px;
|
display: inline-block;
|
||||||
|
padding: 10px 12px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Toast;
|
export default Toast;
|
||||||
|
|
|
@ -3,7 +3,6 @@ import * as React from 'react';
|
||||||
import { render } from 'react-dom';
|
import { render } from 'react-dom';
|
||||||
import { Provider } from 'mobx-react';
|
import { Provider } from 'mobx-react';
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
|
|
||||||
import stores from 'stores';
|
import stores from 'stores';
|
||||||
import 'shared/styles/prism.css';
|
import 'shared/styles/prism.css';
|
||||||
|
|
||||||
|
@ -13,6 +12,10 @@ import Toasts from 'components/Toasts';
|
||||||
import Theme from 'components/Theme';
|
import Theme from 'components/Theme';
|
||||||
import Routes from './routes';
|
import Routes from './routes';
|
||||||
|
|
||||||
|
// socket.on('connect', function(){});
|
||||||
|
// socket.on('event', function(data){});
|
||||||
|
// socket.on('disconnect', function(){});
|
||||||
|
|
||||||
let DevTools;
|
let DevTools;
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
DevTools = require('mobx-react-devtools').default; // eslint-disable-line global-require
|
DevTools = require('mobx-react-devtools').default; // eslint-disable-line global-require
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { MoreIcon } from 'outline-icons';
|
||||||
import Modal from 'components/Modal';
|
import Modal from 'components/Modal';
|
||||||
import CollectionPermissions from 'scenes/CollectionPermissions';
|
import CollectionPermissions from 'scenes/CollectionPermissions';
|
||||||
|
|
||||||
|
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||||
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
||||||
import importFile from 'utils/importFile';
|
import importFile from 'utils/importFile';
|
||||||
import Collection from 'models/Collection';
|
import Collection from 'models/Collection';
|
||||||
|
@ -34,7 +35,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||||
onNewDocument = (ev: SyntheticEvent<*>) => {
|
onNewDocument = (ev: SyntheticEvent<*>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const { collection } = this.props;
|
const { collection } = this.props;
|
||||||
this.props.history.push(`${collection.url}/new`);
|
this.props.history.push(newDocumentUrl(collection.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
onImportDocument = (ev: SyntheticEvent<*>) => {
|
onImportDocument = (ev: SyntheticEvent<*>) => {
|
||||||
|
|
|
@ -8,7 +8,12 @@ import { MoreIcon } from 'outline-icons';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import AuthStore from 'stores/AuthStore';
|
import AuthStore from 'stores/AuthStore';
|
||||||
import { documentMoveUrl, documentHistoryUrl } from 'utils/routeHelpers';
|
import CollectionStore from 'stores/CollectionsStore';
|
||||||
|
import {
|
||||||
|
documentMoveUrl,
|
||||||
|
documentHistoryUrl,
|
||||||
|
newDocumentUrl,
|
||||||
|
} from 'utils/routeHelpers';
|
||||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -16,6 +21,7 @@ type Props = {
|
||||||
auth: AuthStore,
|
auth: AuthStore,
|
||||||
label?: React.Node,
|
label?: React.Node,
|
||||||
document: Document,
|
document: Document,
|
||||||
|
collections: CollectionStore,
|
||||||
className: string,
|
className: string,
|
||||||
showPrint?: boolean,
|
showPrint?: boolean,
|
||||||
showToggleEmbeds?: boolean,
|
showToggleEmbeds?: boolean,
|
||||||
|
@ -32,9 +38,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||||
|
|
||||||
handleNewChild = (ev: SyntheticEvent<*>) => {
|
handleNewChild = (ev: SyntheticEvent<*>) => {
|
||||||
const { document } = this.props;
|
const { document } = this.props;
|
||||||
this.redirectTo = `${document.collection.url}/new?parentDocument=${
|
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
|
||||||
document.id
|
|
||||||
}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDelete = (ev: SyntheticEvent<*>) => {
|
handleDelete = (ev: SyntheticEvent<*>) => {
|
||||||
|
@ -128,7 +132,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||||
Pin to collection
|
Pin to collection
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
{document.starred ? (
|
{document.isStarred ? (
|
||||||
<DropdownMenuItem onClick={this.handleUnstar}>
|
<DropdownMenuItem onClick={this.handleUnstar}>
|
||||||
Unstar
|
Unstar
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
@ -183,4 +187,4 @@ class DocumentMenu extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default inject('ui', 'auth')(DocumentMenu);
|
export default inject('ui', 'auth', 'collections')(DocumentMenu);
|
||||||
|
|
|
@ -2,16 +2,18 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
import { observer } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import { MoreIcon } from 'outline-icons';
|
import { MoreIcon } from 'outline-icons';
|
||||||
|
|
||||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label?: React.Node,
|
label?: React.Node,
|
||||||
document: Document,
|
document: Document,
|
||||||
|
collections: CollectionsStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
|
@ -23,27 +25,27 @@ class NewChildDocumentMenu extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNewDocument = () => {
|
handleNewDocument = () => {
|
||||||
this.redirectTo = newDocumentUrl(this.props.document.collection);
|
const { document } = this.props;
|
||||||
|
this.redirectTo = newDocumentUrl(document.collectionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleNewChild = () => {
|
handleNewChild = () => {
|
||||||
const { document } = this.props;
|
const { document } = this.props;
|
||||||
this.redirectTo = `${document.collection.url}/new?parentDocument=${
|
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
|
||||||
document.id
|
|
||||||
}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||||
|
|
||||||
const { label, document, ...rest } = this.props;
|
const { label, document, collections, ...rest } = this.props;
|
||||||
const { collection } = document;
|
const collection = collections.get(document.collectionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu label={label || <MoreIcon />} {...rest}>
|
<DropdownMenu label={label || <MoreIcon />} {...rest}>
|
||||||
<DropdownMenuItem onClick={this.handleNewDocument}>
|
<DropdownMenuItem onClick={this.handleNewDocument}>
|
||||||
<span>
|
<span>
|
||||||
New document in <strong>{collection.name}</strong>
|
New document in{' '}
|
||||||
|
<strong>{collection ? collection.name : 'collection'}</strong>
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={this.handleNewChild}>
|
<DropdownMenuItem onClick={this.handleNewChild}>
|
||||||
|
@ -54,4 +56,4 @@ class NewChildDocumentMenu extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NewChildDocumentMenu;
|
export default inject('collections')(NewChildDocumentMenu);
|
||||||
|
|
|
@ -22,15 +22,15 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||||
this.redirectTo = undefined;
|
this.redirectTo = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNewDocument = collection => {
|
handleNewDocument = (collectionId: string) => {
|
||||||
this.redirectTo = newDocumentUrl(collection);
|
this.redirectTo = newDocumentUrl(collectionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
onOpen = () => {
|
onOpen = () => {
|
||||||
const { collections } = this.props;
|
const { collections } = this.props;
|
||||||
|
|
||||||
if (collections.orderedData.length === 1) {
|
if (collections.orderedData.length === 1) {
|
||||||
this.handleNewDocument(collections.orderedData[0]);
|
this.handleNewDocument(collections.orderedData[0].id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ class NewDocumentMenu extends React.Component<Props> {
|
||||||
{collections.orderedData.map(collection => (
|
{collections.orderedData.map(collection => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={collection.id}
|
key={collection.id}
|
||||||
onClick={() => this.handleNewDocument(collection)}
|
onClick={() => this.handleNewDocument(collection.id)}
|
||||||
>
|
>
|
||||||
{collection.private ? (
|
{collection.private ? (
|
||||||
<PrivateCollectionIcon color={collection.color} />
|
<PrivateCollectionIcon color={collection.color} />
|
||||||
|
|
|
@ -26,12 +26,12 @@ class RevisionMenu extends React.Component<Props> {
|
||||||
handleRestore = async (ev: SyntheticEvent<*>) => {
|
handleRestore = async (ev: SyntheticEvent<*>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
await this.props.document.restore(this.props.revision);
|
await this.props.document.restore(this.props.revision);
|
||||||
this.props.ui.showToast('Document restored', 'success');
|
this.props.ui.showToast('Document restored');
|
||||||
this.props.history.push(this.props.document.url);
|
this.props.history.push(this.props.document.url);
|
||||||
};
|
};
|
||||||
|
|
||||||
handleCopy = () => {
|
handleCopy = () => {
|
||||||
this.props.ui.showToast('Link copied', 'success');
|
this.props.ui.showToast('Link copied');
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -36,11 +36,11 @@ class ShareMenu extends React.Component<Props> {
|
||||||
handleRevoke = (ev: SyntheticEvent<*>) => {
|
handleRevoke = (ev: SyntheticEvent<*>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.props.shares.revoke(this.props.share);
|
this.props.shares.revoke(this.props.share);
|
||||||
this.props.ui.showToast('Share link revoked', 'success');
|
this.props.ui.showToast('Share link revoked');
|
||||||
};
|
};
|
||||||
|
|
||||||
handleCopy = () => {
|
handleCopy = () => {
|
||||||
this.props.ui.showToast('Share link copied', 'success');
|
this.props.ui.showToast('Share link copied');
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -22,6 +22,7 @@ export default class Collection extends BaseModel {
|
||||||
documents: NavigationNode[];
|
documents: NavigationNode[];
|
||||||
createdAt: ?string;
|
createdAt: ?string;
|
||||||
updatedAt: ?string;
|
updatedAt: ?string;
|
||||||
|
deletedAt: ?string;
|
||||||
url: string;
|
url: string;
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
|
@ -101,6 +102,27 @@ export default class Collection extends BaseModel {
|
||||||
travelDocuments(this.documents);
|
travelDocuments(this.documents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pathToDocument(document: Document) {
|
||||||
|
let path;
|
||||||
|
const traveler = (nodes, previousPath) => {
|
||||||
|
nodes.forEach(childNode => {
|
||||||
|
const newPath = [...previousPath, childNode];
|
||||||
|
if (childNode.id === document.id) {
|
||||||
|
path = newPath;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return traveler(childNode.children, newPath);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.documents) {
|
||||||
|
traveler(this.documents, []);
|
||||||
|
if (path) return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
toJS = () => {
|
toJS = () => {
|
||||||
return pick(this, ['id', 'name', 'color', 'description', 'private']);
|
return pick(this, ['id', 'name', 'color', 'description', 'private']);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,16 +1,12 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { action, set, computed } from 'mobx';
|
import { action, set, computed } from 'mobx';
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
|
|
||||||
import { client } from 'utils/ApiClient';
|
import { client } from 'utils/ApiClient';
|
||||||
import parseTitle from 'shared/utils/parseTitle';
|
import parseTitle from 'shared/utils/parseTitle';
|
||||||
import unescape from 'shared/utils/unescape';
|
import unescape from 'shared/utils/unescape';
|
||||||
|
|
||||||
import type { NavigationNode } from 'types';
|
|
||||||
import BaseModel from 'models/BaseModel';
|
import BaseModel from 'models/BaseModel';
|
||||||
import Revision from 'models/Revision';
|
import Revision from 'models/Revision';
|
||||||
import User from 'models/User';
|
import User from 'models/User';
|
||||||
import Collection from 'models/Collection';
|
|
||||||
|
|
||||||
type SaveOptions = { publish?: boolean, done?: boolean, autosave?: boolean };
|
type SaveOptions = { publish?: boolean, done?: boolean, autosave?: boolean };
|
||||||
|
|
||||||
|
@ -20,7 +16,6 @@ export default class Document extends BaseModel {
|
||||||
store: *;
|
store: *;
|
||||||
|
|
||||||
collaborators: User[];
|
collaborators: User[];
|
||||||
collection: Collection;
|
|
||||||
collectionId: string;
|
collectionId: string;
|
||||||
lastViewedAt: ?string;
|
lastViewedAt: ?string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
@ -29,12 +24,11 @@ export default class Document extends BaseModel {
|
||||||
updatedBy: User;
|
updatedBy: User;
|
||||||
id: string;
|
id: string;
|
||||||
team: string;
|
team: string;
|
||||||
starred: boolean;
|
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
title: string;
|
title: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
parentDocument: ?string;
|
parentDocumentId: ?string;
|
||||||
publishedAt: ?string;
|
publishedAt: ?string;
|
||||||
archivedAt: string;
|
archivedAt: string;
|
||||||
deletedAt: ?string;
|
deletedAt: ?string;
|
||||||
|
@ -59,25 +53,8 @@ export default class Document extends BaseModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get pathToDocument(): NavigationNode[] {
|
get isStarred(): boolean {
|
||||||
let path;
|
return this.store.starredIds.get(this.id);
|
||||||
const traveler = (nodes, previousPath) => {
|
|
||||||
nodes.forEach(childNode => {
|
|
||||||
const newPath = [...previousPath, childNode];
|
|
||||||
if (childNode.id === this.id) {
|
|
||||||
path = newPath;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return traveler(childNode.children, newPath);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.collection && this.collection.documents) {
|
|
||||||
traveler(this.collection.documents, []);
|
|
||||||
if (path) return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
|
@ -106,13 +83,6 @@ export default class Document extends BaseModel {
|
||||||
return !this.isEmpty && !this.isSaving;
|
return !this.isEmpty && !this.isSaving;
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
|
||||||
get parentDocumentId(): ?string {
|
|
||||||
return this.pathToDocument.length > 1
|
|
||||||
? this.pathToDocument[this.pathToDocument.length - 2].id
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
@action
|
||||||
share = async () => {
|
share = async () => {
|
||||||
const res = await client.post('/shares.create', { documentId: this.id });
|
const res = await client.post('/shares.create', { documentId: this.id });
|
||||||
|
@ -158,25 +128,13 @@ export default class Document extends BaseModel {
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
star = async () => {
|
star = () => {
|
||||||
this.starred = true;
|
return this.store.star(this);
|
||||||
try {
|
|
||||||
await this.store.star(this);
|
|
||||||
} catch (err) {
|
|
||||||
this.starred = false;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
unstar = async () => {
|
unstar = async () => {
|
||||||
this.starred = false;
|
return this.store.unstar(this);
|
||||||
try {
|
|
||||||
await this.store.unstar(this);
|
|
||||||
} catch (err) {
|
|
||||||
this.starred = true;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
@ -202,31 +160,25 @@ export default class Document extends BaseModel {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
const data = {
|
return this.store.create({
|
||||||
parentDocument: undefined,
|
parentDocumentId: this.parentDocumentId,
|
||||||
collection: this.collection.id,
|
collectionId: this.collectionId,
|
||||||
title: this.title,
|
title: this.title,
|
||||||
text: this.text,
|
text: this.text,
|
||||||
...options,
|
...options,
|
||||||
};
|
|
||||||
if (this.parentDocument) {
|
|
||||||
data.parentDocument = this.parentDocument;
|
|
||||||
}
|
|
||||||
const document = await this.store.create(data);
|
|
||||||
return document;
|
|
||||||
} else {
|
|
||||||
const document = await this.store.update({
|
|
||||||
id: this.id,
|
|
||||||
title: this.title,
|
|
||||||
text: this.text,
|
|
||||||
lastRevision: this.revision,
|
|
||||||
...options,
|
|
||||||
});
|
});
|
||||||
return document;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.store.update({
|
||||||
|
id: this.id,
|
||||||
|
title: this.title,
|
||||||
|
text: this.text,
|
||||||
|
lastRevision: this.revision,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (wasDraft && options.publish) {
|
if (wasDraft && options.publish) {
|
||||||
this.store.rootStore.collections.fetch(this.collection.id, {
|
this.store.rootStore.collections.fetch(this.collectionId, {
|
||||||
force: true,
|
force: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
119
app/routes.js
119
app/routes.js
|
@ -23,6 +23,7 @@ import Export from 'scenes/Settings/Export';
|
||||||
import Error404 from 'scenes/Error404';
|
import Error404 from 'scenes/Error404';
|
||||||
|
|
||||||
import Layout from 'components/Layout';
|
import Layout from 'components/Layout';
|
||||||
|
import SocketProvider from 'components/SocketProvider';
|
||||||
import Authenticated from 'components/Authenticated';
|
import Authenticated from 'components/Authenticated';
|
||||||
import RouteSidebarHidden from 'components/RouteSidebarHidden';
|
import RouteSidebarHidden from 'components/RouteSidebarHidden';
|
||||||
import { matchDocumentSlug as slug } from 'utils/routeHelpers';
|
import { matchDocumentSlug as slug } from 'utils/routeHelpers';
|
||||||
|
@ -39,62 +40,68 @@ export default function Routes() {
|
||||||
<Route exact path="/" component={Home} />
|
<Route exact path="/" component={Home} />
|
||||||
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
||||||
<Authenticated>
|
<Authenticated>
|
||||||
<Layout>
|
<SocketProvider>
|
||||||
<Switch>
|
<Layout>
|
||||||
<Route path="/dashboard/:tab" component={Dashboard} />
|
<Switch>
|
||||||
<Route path="/dashboard" component={Dashboard} />
|
<Route path="/dashboard/:tab" component={Dashboard} />
|
||||||
<Route exact path="/starred" component={Starred} />
|
<Route path="/dashboard" component={Dashboard} />
|
||||||
<Route exact path="/starred/:sort" component={Starred} />
|
<Route exact path="/starred" component={Starred} />
|
||||||
<Route exact path="/drafts" component={Drafts} />
|
<Route exact path="/starred/:sort" component={Starred} />
|
||||||
<Route exact path="/archive" component={Archive} />
|
<Route exact path="/drafts" component={Drafts} />
|
||||||
<Route exact path="/settings" component={Settings} />
|
<Route exact path="/archive" component={Archive} />
|
||||||
<Route exact path="/settings/details" component={Details} />
|
<Route exact path="/settings" component={Settings} />
|
||||||
<Route exact path="/settings/security" component={Security} />
|
<Route exact path="/settings/details" component={Details} />
|
||||||
<Route exact path="/settings/people" component={People} />
|
<Route exact path="/settings/security" component={Security} />
|
||||||
<Route exact path="/settings/people/:filter" component={People} />
|
<Route exact path="/settings/people" component={People} />
|
||||||
<Route exact path="/settings/shares" component={Shares} />
|
<Route exact path="/settings/people/:filter" component={People} />
|
||||||
<Route exact path="/settings/tokens" component={Tokens} />
|
<Route exact path="/settings/shares" component={Shares} />
|
||||||
<Route
|
<Route exact path="/settings/tokens" component={Tokens} />
|
||||||
exact
|
<Route
|
||||||
path="/settings/notifications"
|
exact
|
||||||
component={Notifications}
|
path="/settings/notifications"
|
||||||
/>
|
component={Notifications}
|
||||||
<Route
|
/>
|
||||||
exact
|
<Route
|
||||||
path="/settings/integrations/slack"
|
exact
|
||||||
component={Slack}
|
path="/settings/integrations/slack"
|
||||||
/>
|
component={Slack}
|
||||||
<Route
|
/>
|
||||||
exact
|
<Route
|
||||||
path="/settings/integrations/zapier"
|
exact
|
||||||
component={Zapier}
|
path="/settings/integrations/zapier"
|
||||||
/>
|
component={Zapier}
|
||||||
<Route exact path="/settings/export" component={Export} />
|
/>
|
||||||
<RouteSidebarHidden
|
<Route exact path="/settings/export" component={Export} />
|
||||||
exact
|
<RouteSidebarHidden
|
||||||
path="/collections/:id/new"
|
exact
|
||||||
component={NewDocument}
|
path="/collections/:id/new"
|
||||||
/>
|
component={NewDocument}
|
||||||
<Route exact path="/collections/:id/:tab" component={Collection} />
|
/>
|
||||||
<Route exact path="/collections/:id" component={Collection} />
|
<Route
|
||||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
exact
|
||||||
<Route
|
path="/collections/:id/:tab"
|
||||||
exact
|
component={Collection}
|
||||||
path={`/doc/${slug}/history/:revisionId?`}
|
/>
|
||||||
component={KeyedDocument}
|
<Route exact path="/collections/:id" component={Collection} />
|
||||||
/>
|
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||||
<RouteSidebarHidden
|
<Route
|
||||||
exact
|
exact
|
||||||
path={`/doc/${slug}/edit`}
|
path={`/doc/${slug}/history/:revisionId?`}
|
||||||
component={KeyedDocument}
|
component={KeyedDocument}
|
||||||
/>
|
/>
|
||||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
<RouteSidebarHidden
|
||||||
<Route exact path="/search" component={Search} />
|
exact
|
||||||
<Route exact path="/search/:query" component={Search} />
|
path={`/doc/${slug}/edit`}
|
||||||
<Route path="/404" component={Error404} />
|
component={KeyedDocument}
|
||||||
<Route component={NotFound} />
|
/>
|
||||||
</Switch>
|
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||||
</Layout>
|
<Route exact path="/search" component={Search} />
|
||||||
|
<Route exact path="/search/:query" component={Search} />
|
||||||
|
<Route path="/404" component={Error404} />
|
||||||
|
<Route component={NotFound} />
|
||||||
|
</Switch>
|
||||||
|
</Layout>
|
||||||
|
</SocketProvider>
|
||||||
</Authenticated>
|
</Authenticated>
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|
|
@ -86,7 +86,7 @@ class CollectionScene extends React.Component<Props> {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
if (this.collection) {
|
if (this.collection) {
|
||||||
this.redirectTo = `${this.collection.url}/new`;
|
this.redirectTo = newDocumentUrl(this.collection.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -139,7 +139,7 @@ class CollectionScene extends React.Component<Props> {
|
||||||
documents yet.<br />Get started by creating a new one!
|
documents yet.<br />Get started by creating a new one!
|
||||||
</HelpText>
|
</HelpText>
|
||||||
<Wrapper>
|
<Wrapper>
|
||||||
<Link to={newDocumentUrl(collection)}>
|
<Link to={newDocumentUrl(collection.id)}>
|
||||||
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
|
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
|
||||||
Create a document
|
Create a document
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -27,7 +27,7 @@ class CollectionExport extends React.Component<Props> {
|
||||||
await this.props.collection.export();
|
await this.props.collection.export();
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
|
||||||
this.props.ui.showToast('Export in progress…', 'success');
|
this.props.ui.showToast('Export in progress…');
|
||||||
this.props.onSubmit();
|
this.props.onSubmit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -101,7 +101,7 @@ class DocumentScene extends React.Component<Props> {
|
||||||
goToMove(ev) {
|
goToMove(ev) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
|
||||||
if (this.document && !this.document.isArchived && this.document.isDraft) {
|
if (this.document && !this.document.isArchived && !this.document.isDraft) {
|
||||||
this.props.history.push(documentMoveUrl(this.document));
|
this.props.history.push(documentMoveUrl(this.document));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -137,9 +137,9 @@ class DocumentScene extends React.Component<Props> {
|
||||||
if (props.newDocument) {
|
if (props.newDocument) {
|
||||||
this.document = new Document(
|
this.document = new Document(
|
||||||
{
|
{
|
||||||
collection: { id: props.match.params.id },
|
collectionId: props.match.params.id,
|
||||||
parentDocument: new URLSearchParams(props.location.search).get(
|
parentDocumentId: new URLSearchParams(props.location.search).get(
|
||||||
'parentDocument'
|
'parentDocumentId'
|
||||||
),
|
),
|
||||||
title: '',
|
title: '',
|
||||||
text: '',
|
text: '',
|
||||||
|
|
|
@ -65,7 +65,7 @@ class DocumentMove extends React.Component<Props> {
|
||||||
|
|
||||||
// Exclude root from search results if document is already at the root
|
// Exclude root from search results if document is already at the root
|
||||||
if (!document.parentDocumentId) {
|
if (!document.parentDocumentId) {
|
||||||
results = results.filter(result => result.id !== document.collection.id);
|
results = results.filter(result => result.id !== document.collectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude document if on the path to result, or the same result
|
// Exclude document if on the path to result, or the same result
|
||||||
|
|
|
@ -9,6 +9,7 @@ import HelpText from 'components/HelpText';
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
|
import { collectionUrl } from 'utils/routeHelpers';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
history: Object,
|
history: Object,
|
||||||
|
@ -25,12 +26,13 @@ class DocumentDelete extends React.Component<Props> {
|
||||||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this.isDeleting = true;
|
this.isDeleting = true;
|
||||||
const { collection } = this.props.document;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.props.document.delete();
|
await this.props.document.delete();
|
||||||
if (this.props.ui.activeDocumentId === this.props.document.id) {
|
if (this.props.ui.activeDocumentId === this.props.document.id) {
|
||||||
this.props.history.push(collection.url);
|
this.props.history.push(
|
||||||
|
collectionUrl(this.props.document.collectionId)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this.props.onSubmit();
|
this.props.onSubmit();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -49,7 +49,7 @@ class Details extends React.Component<Props> {
|
||||||
avatarUrl: this.avatarUrl,
|
avatarUrl: this.avatarUrl,
|
||||||
subdomain: this.subdomain,
|
subdomain: this.subdomain,
|
||||||
});
|
});
|
||||||
this.props.ui.showToast('Settings saved', 'success');
|
this.props.ui.showToast('Settings saved');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.props.ui.showToast(err.message);
|
this.props.ui.showToast(err.message);
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,7 +29,7 @@ class Export extends React.Component<Props> {
|
||||||
try {
|
try {
|
||||||
await this.props.collections.export();
|
await this.props.collections.export();
|
||||||
this.isExporting = true;
|
this.isExporting = true;
|
||||||
this.props.ui.showToast('Export in progress…', 'success');
|
this.props.ui.showToast('Export in progress…');
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,7 @@ class Profile extends React.Component<Props> {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
avatarUrl: this.avatarUrl,
|
avatarUrl: this.avatarUrl,
|
||||||
});
|
});
|
||||||
this.props.ui.showToast('Profile saved', 'success');
|
this.props.ui.showToast('Profile saved');
|
||||||
};
|
};
|
||||||
|
|
||||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||||
|
@ -58,7 +58,7 @@ class Profile extends React.Component<Props> {
|
||||||
await this.props.auth.updateUser({
|
await this.props.auth.updateUser({
|
||||||
avatarUrl: this.avatarUrl,
|
avatarUrl: this.avatarUrl,
|
||||||
});
|
});
|
||||||
this.props.ui.showToast('Profile picture updated', 'success');
|
this.props.ui.showToast('Profile picture updated');
|
||||||
};
|
};
|
||||||
|
|
||||||
handleAvatarError = (error: ?string) => {
|
handleAvatarError = (error: ?string) => {
|
||||||
|
|
|
@ -160,7 +160,6 @@ export default class BaseStore<T: BaseModel> {
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get orderedData(): T[] {
|
get orderedData(): T[] {
|
||||||
// $FlowIssue
|
|
||||||
return orderBy(Array.from(this.data.values()), 'createdAt', 'desc');
|
return orderBy(Array.from(this.data.values()), 'createdAt', 'desc');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { computed, runInAction } from 'mobx';
|
import { computed, runInAction } from 'mobx';
|
||||||
import { concat, last } from 'lodash';
|
import { concat, filter, last } from 'lodash';
|
||||||
import { client } from 'utils/ApiClient';
|
import { client } from 'utils/ApiClient';
|
||||||
|
|
||||||
import BaseStore from './BaseStore';
|
import BaseStore from './BaseStore';
|
||||||
import RootStore from './RootStore';
|
import RootStore from './RootStore';
|
||||||
import Collection from '../models/Collection';
|
import Collection from 'models/Collection';
|
||||||
import naturalSort from 'shared/utils/naturalSort';
|
import naturalSort from 'shared/utils/naturalSort';
|
||||||
|
|
||||||
export type DocumentPathItem = {
|
export type DocumentPathItem = {
|
||||||
|
@ -34,7 +34,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get orderedData(): Collection[] {
|
get orderedData(): Collection[] {
|
||||||
return naturalSort(Array.from(this.data.values()), 'name');
|
return filter(
|
||||||
|
naturalSort(Array.from(this.data.values()), 'name'),
|
||||||
|
d => !d.deletedAt
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
|
|
|
@ -14,6 +14,7 @@ import type { FetchOptions, PaginationParams, SearchResult } from 'types';
|
||||||
export default class DocumentsStore extends BaseStore<Document> {
|
export default class DocumentsStore extends BaseStore<Document> {
|
||||||
@observable recentlyViewedIds: string[] = [];
|
@observable recentlyViewedIds: string[] = [];
|
||||||
@observable searchCache: Map<string, SearchResult[]> = new Map();
|
@observable searchCache: Map<string, SearchResult[]> = new Map();
|
||||||
|
@observable starredIds: Map<string, boolean> = new Map();
|
||||||
|
|
||||||
constructor(rootStore: RootStore) {
|
constructor(rootStore: RootStore) {
|
||||||
super(rootStore, Document);
|
super(rootStore, Document);
|
||||||
|
@ -95,7 +96,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get starred(): Document[] {
|
get starred(): Document[] {
|
||||||
return filter(this.all, d => d.starred);
|
return filter(this.all, d => d.isStarred);
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
|
@ -314,8 +315,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
duplicate = async (document: Document): * => {
|
duplicate = async (document: Document): * => {
|
||||||
const res = await client.post('/documents.create', {
|
const res = await client.post('/documents.create', {
|
||||||
publish: true,
|
publish: true,
|
||||||
parentDocument: document.parentDocumentId,
|
parentDocumentId: document.parentDocumentId,
|
||||||
collection: document.collection.id,
|
collection: document.collectionId,
|
||||||
title: `${document.title} (duplicate)`,
|
title: `${document.title} (duplicate)`,
|
||||||
text: document.text,
|
text: document.text,
|
||||||
});
|
});
|
||||||
|
@ -327,6 +328,20 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
return this.add(res.data);
|
return this.add(res.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_add = this.add;
|
||||||
|
|
||||||
|
@action
|
||||||
|
add = (item: Object) => {
|
||||||
|
const document = this._add(item);
|
||||||
|
|
||||||
|
if (item.starred !== undefined) {
|
||||||
|
this.starredIds.set(document.id, item.starred);
|
||||||
|
}
|
||||||
|
|
||||||
|
return document;
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
async update(params: *) {
|
async update(params: *) {
|
||||||
const document = await super.update(params);
|
const document = await super.update(params);
|
||||||
|
|
||||||
|
@ -337,6 +352,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
async delete(document: Document) {
|
async delete(document: Document) {
|
||||||
await super.delete(document);
|
await super.delete(document);
|
||||||
|
|
||||||
|
@ -385,12 +401,24 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||||
return client.post('/documents.unpin', { id: document.id });
|
return client.post('/documents.unpin', { id: document.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
star = (document: Document) => {
|
star = async (document: Document) => {
|
||||||
return client.post('/documents.star', { id: document.id });
|
this.starredIds.set(document.id, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return client.post('/documents.star', { id: document.id });
|
||||||
|
} catch (err) {
|
||||||
|
this.starredIds.set(document.id, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
unstar = (document: Document) => {
|
unstar = (document: Document) => {
|
||||||
return client.post('/documents.unstar', { id: document.id });
|
this.starredIds.set(document.id, false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return client.post('/documents.unstar', { id: document.id });
|
||||||
|
} catch (err) {
|
||||||
|
this.starredIds.set(document.id, false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
getByUrl = (url: string = ''): ?Document => {
|
getByUrl = (url: string = ''): ?Document => {
|
||||||
|
|
|
@ -94,13 +94,20 @@ class UiStore {
|
||||||
@action
|
@action
|
||||||
showToast = (
|
showToast = (
|
||||||
message: string,
|
message: string,
|
||||||
type?: 'warning' | 'error' | 'info' | 'success' = 'success'
|
options?: {
|
||||||
|
type?: 'warning' | 'error' | 'info' | 'success',
|
||||||
|
timeout?: number,
|
||||||
|
action?: {
|
||||||
|
text: string,
|
||||||
|
onClick: () => void,
|
||||||
|
},
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
|
|
||||||
const id = v4();
|
const id = v4();
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
this.toasts.set(id, { message, type, createdAt, id });
|
this.toasts.set(id, { message, createdAt, id, ...options });
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -111,7 +118,6 @@ class UiStore {
|
||||||
|
|
||||||
@computed
|
@computed
|
||||||
get orderedToasts(): Toast[] {
|
get orderedToasts(): Toast[] {
|
||||||
// $FlowIssue
|
|
||||||
return orderBy(Array.from(this.toasts.values()), 'createdAt', 'desc');
|
return orderBy(Array.from(this.toasts.values()), 'createdAt', 'desc');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,11 @@ export type Toast = {
|
||||||
createdAt: string,
|
createdAt: string,
|
||||||
message: string,
|
message: string,
|
||||||
type: 'warning' | 'error' | 'info' | 'success',
|
type: 'warning' | 'error' | 'info' | 'success',
|
||||||
|
timeout?: number,
|
||||||
|
action?: {
|
||||||
|
text: string,
|
||||||
|
onClick: () => void,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FetchOptions = {
|
export type FetchOptions = {
|
||||||
|
|
|
@ -19,15 +19,15 @@ const importFile = async ({
|
||||||
|
|
||||||
reader.onload = async ev => {
|
reader.onload = async ev => {
|
||||||
const text = ev.target.result;
|
const text = ev.target.result;
|
||||||
let data = {
|
|
||||||
parentDocument: undefined,
|
|
||||||
collection: { id: collectionId },
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (documentId) data.parentDocument = documentId;
|
let document = new Document(
|
||||||
|
{
|
||||||
let document = new Document(data, documents);
|
parentDocumentId: documentId,
|
||||||
|
collectionId,
|
||||||
|
text,
|
||||||
|
},
|
||||||
|
documents
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
document = await document.save({ publish: true });
|
document = await document.save({ publish: true });
|
||||||
resolve(document);
|
resolve(document);
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import Collection from 'models/Collection';
|
|
||||||
|
|
||||||
export function homeUrl(): string {
|
export function homeUrl(): string {
|
||||||
return '/dashboard';
|
return '/dashboard';
|
||||||
|
@ -24,14 +23,6 @@ export function documentUrl(doc: Document): string {
|
||||||
return doc.url;
|
return doc.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function documentNewUrl(doc: Document): string {
|
|
||||||
const newUrl = `${doc.collection.url || ''}/new`;
|
|
||||||
if (doc.parentDocumentId) {
|
|
||||||
return `${newUrl}?parentDocument=${doc.parentDocumentId}`;
|
|
||||||
}
|
|
||||||
return newUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function documentEditUrl(doc: Document): string {
|
export function documentEditUrl(doc: Document): string {
|
||||||
return `${doc.url}/edit`;
|
return `${doc.url}/edit`;
|
||||||
}
|
}
|
||||||
|
@ -60,8 +51,17 @@ export function updateDocumentUrl(oldUrl: string, newUrl: string): string {
|
||||||
return newUrl;
|
return newUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newDocumentUrl(collection: Collection): string {
|
export function newDocumentUrl(
|
||||||
return `${collection.url || ''}/new`;
|
collectionId: string,
|
||||||
|
parentDocumentId?: string
|
||||||
|
): string {
|
||||||
|
let route = `/collections/${collectionId}/new`;
|
||||||
|
|
||||||
|
if (parentDocumentId) {
|
||||||
|
route += `?parentDocumentId=${parentDocumentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchUrl(query?: string): string {
|
export function searchUrl(query?: string): string {
|
||||||
|
|
|
@ -0,0 +1,988 @@
|
||||||
|
// flow-typed signature: 27b6ff5cf910473843da0caf82e362fe
|
||||||
|
// flow-typed version: a3709d51ed/jest_v22.x.x/flow_>=v0.39.x
|
||||||
|
|
||||||
|
type JestMockFn<TArguments: $ReadOnlyArray<*>, TReturn> = {
|
||||||
|
(...args: TArguments): TReturn,
|
||||||
|
/**
|
||||||
|
* An object for introspecting mock calls
|
||||||
|
*/
|
||||||
|
mock: {
|
||||||
|
/**
|
||||||
|
* An array that represents all calls that have been made into this mock
|
||||||
|
* function. Each call is represented by an array of arguments that were
|
||||||
|
* passed during the call.
|
||||||
|
*/
|
||||||
|
calls: Array<TArguments>,
|
||||||
|
/**
|
||||||
|
* An array that contains all the object instances that have been
|
||||||
|
* instantiated from this mock function.
|
||||||
|
*/
|
||||||
|
instances: Array<TReturn>
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Resets all information stored in the mockFn.mock.calls and
|
||||||
|
* mockFn.mock.instances arrays. Often this is useful when you want to clean
|
||||||
|
* up a mock's usage data between two assertions.
|
||||||
|
*/
|
||||||
|
mockClear(): void,
|
||||||
|
/**
|
||||||
|
* Resets all information stored in the mock. This is useful when you want to
|
||||||
|
* completely restore a mock back to its initial state.
|
||||||
|
*/
|
||||||
|
mockReset(): void,
|
||||||
|
/**
|
||||||
|
* Removes the mock and restores the initial implementation. This is useful
|
||||||
|
* when you want to mock functions in certain test cases and restore the
|
||||||
|
* original implementation in others. Beware that mockFn.mockRestore only
|
||||||
|
* works when mock was created with jest.spyOn. Thus you have to take care of
|
||||||
|
* restoration yourself when manually assigning jest.fn().
|
||||||
|
*/
|
||||||
|
mockRestore(): void,
|
||||||
|
/**
|
||||||
|
* Accepts a function that should be used as the implementation of the mock.
|
||||||
|
* The mock itself will still record all calls that go into and instances
|
||||||
|
* that come from itself -- the only difference is that the implementation
|
||||||
|
* will also be executed when the mock is called.
|
||||||
|
*/
|
||||||
|
mockImplementation(
|
||||||
|
fn: (...args: TArguments) => TReturn
|
||||||
|
): JestMockFn<TArguments, TReturn>,
|
||||||
|
/**
|
||||||
|
* Accepts a function that will be used as an implementation of the mock for
|
||||||
|
* one call to the mocked function. Can be chained so that multiple function
|
||||||
|
* calls produce different results.
|
||||||
|
*/
|
||||||
|
mockImplementationOnce(
|
||||||
|
fn: (...args: TArguments) => TReturn
|
||||||
|
): JestMockFn<TArguments, TReturn>,
|
||||||
|
/**
|
||||||
|
* Accepts a string to use in test result output in place of "jest.fn()" to
|
||||||
|
* indicate which mock function is being referenced.
|
||||||
|
*/
|
||||||
|
mockName(name: string): JestMockFn<TArguments, TReturn>,
|
||||||
|
/**
|
||||||
|
* Just a simple sugar function for returning `this`
|
||||||
|
*/
|
||||||
|
mockReturnThis(): void,
|
||||||
|
/**
|
||||||
|
* Deprecated: use jest.fn(() => value) instead
|
||||||
|
*/
|
||||||
|
mockReturnValue(value: TReturn): JestMockFn<TArguments, TReturn>,
|
||||||
|
/**
|
||||||
|
* Sugar for only returning a value once inside your mock
|
||||||
|
*/
|
||||||
|
mockReturnValueOnce(value: TReturn): JestMockFn<TArguments, TReturn>
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestAsymmetricEqualityType = {
|
||||||
|
/**
|
||||||
|
* A custom Jasmine equality tester
|
||||||
|
*/
|
||||||
|
asymmetricMatch(value: mixed): boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestCallsType = {
|
||||||
|
allArgs(): mixed,
|
||||||
|
all(): mixed,
|
||||||
|
any(): boolean,
|
||||||
|
count(): number,
|
||||||
|
first(): mixed,
|
||||||
|
mostRecent(): mixed,
|
||||||
|
reset(): void
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestClockType = {
|
||||||
|
install(): void,
|
||||||
|
mockDate(date: Date): void,
|
||||||
|
tick(milliseconds?: number): void,
|
||||||
|
uninstall(): void
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestMatcherResult = {
|
||||||
|
message?: string | (() => string),
|
||||||
|
pass: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestMatcher = (actual: any, expected: any) => JestMatcherResult;
|
||||||
|
|
||||||
|
type JestPromiseType = {
|
||||||
|
/**
|
||||||
|
* Use rejects to unwrap the reason of a rejected promise so any other
|
||||||
|
* matcher can be chained. If the promise is fulfilled the assertion fails.
|
||||||
|
*/
|
||||||
|
rejects: JestExpectType,
|
||||||
|
/**
|
||||||
|
* Use resolves to unwrap the value of a fulfilled promise so any other
|
||||||
|
* matcher can be chained. If the promise is rejected the assertion fails.
|
||||||
|
*/
|
||||||
|
resolves: JestExpectType
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jest allows functions and classes to be used as test names in test() and
|
||||||
|
* describe()
|
||||||
|
*/
|
||||||
|
type JestTestName = string | Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin: jest-enzyme
|
||||||
|
*/
|
||||||
|
type EnzymeMatchersType = {
|
||||||
|
toBeChecked(): void,
|
||||||
|
toBeDisabled(): void,
|
||||||
|
toBeEmpty(): void,
|
||||||
|
toBeEmptyRender(): void,
|
||||||
|
toBePresent(): void,
|
||||||
|
toContainReact(element: React$Element<any>): void,
|
||||||
|
toExist(): void,
|
||||||
|
toHaveClassName(className: string): void,
|
||||||
|
toHaveHTML(html: string): void,
|
||||||
|
toHaveProp: ((propKey: string, propValue?: any) => void) & ((props: Object) => void),
|
||||||
|
toHaveRef(refName: string): void,
|
||||||
|
toHaveState: ((stateKey: string, stateValue?: any) => void) & ((state: Object) => void),
|
||||||
|
toHaveStyle: ((styleKey: string, styleValue?: any) => void) & ((style: Object) => void),
|
||||||
|
toHaveTagName(tagName: string): void,
|
||||||
|
toHaveText(text: string): void,
|
||||||
|
toIncludeText(text: string): void,
|
||||||
|
toHaveValue(value: any): void,
|
||||||
|
toMatchElement(element: React$Element<any>): void,
|
||||||
|
toMatchSelector(selector: string): void
|
||||||
|
};
|
||||||
|
|
||||||
|
// DOM testing library extensions https://github.com/kentcdodds/dom-testing-library#custom-jest-matchers
|
||||||
|
type DomTestingLibraryType = {
|
||||||
|
toBeInTheDOM(): void,
|
||||||
|
toHaveTextContent(content: string): void,
|
||||||
|
toHaveAttribute(name: string, expectedValue?: string): void
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jest JQuery Matchers: https://github.com/unindented/custom-jquery-matchers
|
||||||
|
type JestJQueryMatchersType = {
|
||||||
|
toExist(): void,
|
||||||
|
toHaveLength(len: number): void,
|
||||||
|
toHaveId(id: string): void,
|
||||||
|
toHaveClass(className: string): void,
|
||||||
|
toHaveTag(tag: string): void,
|
||||||
|
toHaveAttr(key: string, val?: any): void,
|
||||||
|
toHaveProp(key: string, val?: any): void,
|
||||||
|
toHaveText(text: string | RegExp): void,
|
||||||
|
toHaveData(key: string, val?: any): void,
|
||||||
|
toHaveValue(val: any): void,
|
||||||
|
toHaveCss(css: {[key: string]: any}): void,
|
||||||
|
toBeChecked(): void,
|
||||||
|
toBeDisabled(): void,
|
||||||
|
toBeEmpty(): void,
|
||||||
|
toBeHidden(): void,
|
||||||
|
toBeSelected(): void,
|
||||||
|
toBeVisible(): void,
|
||||||
|
toBeFocused(): void,
|
||||||
|
toBeInDom(): void,
|
||||||
|
toBeMatchedBy(sel: string): void,
|
||||||
|
toHaveDescendant(sel: string): void,
|
||||||
|
toHaveDescendantWithText(sel: string, text: string | RegExp): void
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Jest Extended Matchers: https://github.com/jest-community/jest-extended
|
||||||
|
type JestExtendedMatchersType = {
|
||||||
|
/**
|
||||||
|
* Note: Currently unimplemented
|
||||||
|
* Passing assertion
|
||||||
|
*
|
||||||
|
* @param {String} message
|
||||||
|
*/
|
||||||
|
// pass(message: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: Currently unimplemented
|
||||||
|
* Failing assertion
|
||||||
|
*
|
||||||
|
* @param {String} message
|
||||||
|
*/
|
||||||
|
// fail(message: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use .toBeEmpty when checking if a String '', Array [] or Object {} is empty.
|
||||||
|
*/
|
||||||
|
toBeEmpty(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use .toBeOneOf when checking if a value is a member of a given Array.
|
||||||
|
* @param {Array.<*>} members
|
||||||
|
*/
|
||||||
|
toBeOneOf(members: any[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeNil` when checking a value is `null` or `undefined`.
|
||||||
|
*/
|
||||||
|
toBeNil(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toSatisfy` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean`.
|
||||||
|
* @param {Function} predicate
|
||||||
|
*/
|
||||||
|
toSatisfy(predicate: (n: any) => boolean): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeArray` when checking if a value is an `Array`.
|
||||||
|
*/
|
||||||
|
toBeArray(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeArrayOfSize` when checking if a value is an `Array` of size x.
|
||||||
|
* @param {Number} x
|
||||||
|
*/
|
||||||
|
toBeArrayOfSize(x: number): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toIncludeAllMembers` when checking if an `Array` contains all of the same members of a given set.
|
||||||
|
* @param {Array.<*>} members
|
||||||
|
*/
|
||||||
|
toIncludeAllMembers(members: any[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toIncludeAnyMembers` when checking if an `Array` contains any of the members of a given set.
|
||||||
|
* @param {Array.<*>} members
|
||||||
|
*/
|
||||||
|
toIncludeAnyMembers(members: any[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toSatisfyAll` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean` for all values in an array.
|
||||||
|
* @param {Function} predicate
|
||||||
|
*/
|
||||||
|
toSatisfyAll(predicate: (n: any) => boolean): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeBoolean` when checking if a value is a `Boolean`.
|
||||||
|
*/
|
||||||
|
toBeBoolean(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeTrue` when checking a value is equal (===) to `true`.
|
||||||
|
*/
|
||||||
|
toBeTrue(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeFalse` when checking a value is equal (===) to `false`.
|
||||||
|
*/
|
||||||
|
toBeFalse(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use .toBeDate when checking if a value is a Date.
|
||||||
|
*/
|
||||||
|
toBeDate(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeFunction` when checking if a value is a `Function`.
|
||||||
|
*/
|
||||||
|
toBeFunction(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toHaveBeenCalledBefore` when checking if a `Mock` was called before another `Mock`.
|
||||||
|
*
|
||||||
|
* Note: Required Jest version >22
|
||||||
|
* Note: Your mock functions will have to be asynchronous to cause the timestamps inside of Jest to occur in a differentJS event loop, otherwise the mock timestamps will all be the same
|
||||||
|
*
|
||||||
|
* @param {Mock} mock
|
||||||
|
*/
|
||||||
|
toHaveBeenCalledBefore(mock: JestMockFn<any, any>): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeNumber` when checking if a value is a `Number`.
|
||||||
|
*/
|
||||||
|
toBeNumber(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeNaN` when checking a value is `NaN`.
|
||||||
|
*/
|
||||||
|
toBeNaN(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeFinite` when checking if a value is a `Number`, not `NaN` or `Infinity`.
|
||||||
|
*/
|
||||||
|
toBeFinite(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBePositive` when checking if a value is a positive `Number`.
|
||||||
|
*/
|
||||||
|
toBePositive(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeNegative` when checking if a value is a negative `Number`.
|
||||||
|
*/
|
||||||
|
toBeNegative(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeEven` when checking if a value is an even `Number`.
|
||||||
|
*/
|
||||||
|
toBeEven(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeOdd` when checking if a value is an odd `Number`.
|
||||||
|
*/
|
||||||
|
toBeOdd(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeWithin` when checking if a number is in between the given bounds of: start (inclusive) and end (exclusive).
|
||||||
|
*
|
||||||
|
* @param {Number} start
|
||||||
|
* @param {Number} end
|
||||||
|
*/
|
||||||
|
toBeWithin(start: number, end: number): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeObject` when checking if a value is an `Object`.
|
||||||
|
*/
|
||||||
|
toBeObject(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainKey` when checking if an object contains the provided key.
|
||||||
|
*
|
||||||
|
* @param {String} key
|
||||||
|
*/
|
||||||
|
toContainKey(key: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainKeys` when checking if an object has all of the provided keys.
|
||||||
|
*
|
||||||
|
* @param {Array.<String>} keys
|
||||||
|
*/
|
||||||
|
toContainKeys(keys: string[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainAllKeys` when checking if an object only contains all of the provided keys.
|
||||||
|
*
|
||||||
|
* @param {Array.<String>} keys
|
||||||
|
*/
|
||||||
|
toContainAllKeys(keys: string[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainAnyKeys` when checking if an object contains at least one of the provided keys.
|
||||||
|
*
|
||||||
|
* @param {Array.<String>} keys
|
||||||
|
*/
|
||||||
|
toContainAnyKeys(keys: string[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainValue` when checking if an object contains the provided value.
|
||||||
|
*
|
||||||
|
* @param {*} value
|
||||||
|
*/
|
||||||
|
toContainValue(value: any): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainValues` when checking if an object contains all of the provided values.
|
||||||
|
*
|
||||||
|
* @param {Array.<*>} values
|
||||||
|
*/
|
||||||
|
toContainValues(values: any[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainAllValues` when checking if an object only contains all of the provided values.
|
||||||
|
*
|
||||||
|
* @param {Array.<*>} values
|
||||||
|
*/
|
||||||
|
toContainAllValues(values: any[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainAnyValues` when checking if an object contains at least one of the provided values.
|
||||||
|
*
|
||||||
|
* @param {Array.<*>} values
|
||||||
|
*/
|
||||||
|
toContainAnyValues(values: any[]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainEntry` when checking if an object contains the provided entry.
|
||||||
|
*
|
||||||
|
* @param {Array.<String, String>} entry
|
||||||
|
*/
|
||||||
|
toContainEntry(entry: [string, string]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainEntries` when checking if an object contains all of the provided entries.
|
||||||
|
*
|
||||||
|
* @param {Array.<Array.<String, String>>} entries
|
||||||
|
*/
|
||||||
|
toContainEntries(entries: [string, string][]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainAllEntries` when checking if an object only contains all of the provided entries.
|
||||||
|
*
|
||||||
|
* @param {Array.<Array.<String, String>>} entries
|
||||||
|
*/
|
||||||
|
toContainAllEntries(entries: [string, string][]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toContainAnyEntries` when checking if an object contains at least one of the provided entries.
|
||||||
|
*
|
||||||
|
* @param {Array.<Array.<String, String>>} entries
|
||||||
|
*/
|
||||||
|
toContainAnyEntries(entries: [string, string][]): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeExtensible` when checking if an object is extensible.
|
||||||
|
*/
|
||||||
|
toBeExtensible(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeFrozen` when checking if an object is frozen.
|
||||||
|
*/
|
||||||
|
toBeFrozen(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeSealed` when checking if an object is sealed.
|
||||||
|
*/
|
||||||
|
toBeSealed(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toBeString` when checking if a value is a `String`.
|
||||||
|
*/
|
||||||
|
toBeString(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toEqualCaseInsensitive` when checking if a string is equal (===) to another ignoring the casing of both strings.
|
||||||
|
*
|
||||||
|
* @param {String} string
|
||||||
|
*/
|
||||||
|
toEqualCaseInsensitive(string: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toStartWith` when checking if a `String` starts with a given `String` prefix.
|
||||||
|
*
|
||||||
|
* @param {String} prefix
|
||||||
|
*/
|
||||||
|
toStartWith(prefix: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toEndWith` when checking if a `String` ends with a given `String` suffix.
|
||||||
|
*
|
||||||
|
* @param {String} suffix
|
||||||
|
*/
|
||||||
|
toEndWith(suffix: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toInclude` when checking if a `String` includes the given `String` substring.
|
||||||
|
*
|
||||||
|
* @param {String} substring
|
||||||
|
*/
|
||||||
|
toInclude(substring: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toIncludeRepeated` when checking if a `String` includes the given `String` substring the correct number of times.
|
||||||
|
*
|
||||||
|
* @param {String} substring
|
||||||
|
* @param {Number} times
|
||||||
|
*/
|
||||||
|
toIncludeRepeated(substring: string, times: number): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `.toIncludeMultiple` when checking if a `String` includes all of the given substrings.
|
||||||
|
*
|
||||||
|
* @param {Array.<String>} substring
|
||||||
|
*/
|
||||||
|
toIncludeMultiple(substring: string[]): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestExpectType = {
|
||||||
|
not: JestExpectType & EnzymeMatchersType & DomTestingLibraryType & JestJQueryMatchersType & JestExtendedMatchersType,
|
||||||
|
/**
|
||||||
|
* If you have a mock function, you can use .lastCalledWith to test what
|
||||||
|
* arguments it was last called with.
|
||||||
|
*/
|
||||||
|
lastCalledWith(...args: Array<any>): void,
|
||||||
|
/**
|
||||||
|
* toBe just checks that a value is what you expect. It uses === to check
|
||||||
|
* strict equality.
|
||||||
|
*/
|
||||||
|
toBe(value: any): void,
|
||||||
|
/**
|
||||||
|
* Use .toHaveBeenCalled to ensure that a mock function got called.
|
||||||
|
*/
|
||||||
|
toBeCalled(): void,
|
||||||
|
/**
|
||||||
|
* Use .toBeCalledWith to ensure that a mock function was called with
|
||||||
|
* specific arguments.
|
||||||
|
*/
|
||||||
|
toBeCalledWith(...args: Array<any>): void,
|
||||||
|
/**
|
||||||
|
* Using exact equality with floating point numbers is a bad idea. Rounding
|
||||||
|
* means that intuitive things fail.
|
||||||
|
*/
|
||||||
|
toBeCloseTo(num: number, delta: any): void,
|
||||||
|
/**
|
||||||
|
* Use .toBeDefined to check that a variable is not undefined.
|
||||||
|
*/
|
||||||
|
toBeDefined(): void,
|
||||||
|
/**
|
||||||
|
* Use .toBeFalsy when you don't care what a value is, you just want to
|
||||||
|
* ensure a value is false in a boolean context.
|
||||||
|
*/
|
||||||
|
toBeFalsy(): void,
|
||||||
|
/**
|
||||||
|
* To compare floating point numbers, you can use toBeGreaterThan.
|
||||||
|
*/
|
||||||
|
toBeGreaterThan(number: number): void,
|
||||||
|
/**
|
||||||
|
* To compare floating point numbers, you can use toBeGreaterThanOrEqual.
|
||||||
|
*/
|
||||||
|
toBeGreaterThanOrEqual(number: number): void,
|
||||||
|
/**
|
||||||
|
* To compare floating point numbers, you can use toBeLessThan.
|
||||||
|
*/
|
||||||
|
toBeLessThan(number: number): void,
|
||||||
|
/**
|
||||||
|
* To compare floating point numbers, you can use toBeLessThanOrEqual.
|
||||||
|
*/
|
||||||
|
toBeLessThanOrEqual(number: number): void,
|
||||||
|
/**
|
||||||
|
* Use .toBeInstanceOf(Class) to check that an object is an instance of a
|
||||||
|
* class.
|
||||||
|
*/
|
||||||
|
toBeInstanceOf(cls: Class<*>): void,
|
||||||
|
/**
|
||||||
|
* .toBeNull() is the same as .toBe(null) but the error messages are a bit
|
||||||
|
* nicer.
|
||||||
|
*/
|
||||||
|
toBeNull(): void,
|
||||||
|
/**
|
||||||
|
* Use .toBeTruthy when you don't care what a value is, you just want to
|
||||||
|
* ensure a value is true in a boolean context.
|
||||||
|
*/
|
||||||
|
toBeTruthy(): void,
|
||||||
|
/**
|
||||||
|
* Use .toBeUndefined to check that a variable is undefined.
|
||||||
|
*/
|
||||||
|
toBeUndefined(): void,
|
||||||
|
/**
|
||||||
|
* Use .toContain when you want to check that an item is in a list. For
|
||||||
|
* testing the items in the list, this uses ===, a strict equality check.
|
||||||
|
*/
|
||||||
|
toContain(item: any): void,
|
||||||
|
/**
|
||||||
|
* Use .toContainEqual when you want to check that an item is in a list. For
|
||||||
|
* testing the items in the list, this matcher recursively checks the
|
||||||
|
* equality of all fields, rather than checking for object identity.
|
||||||
|
*/
|
||||||
|
toContainEqual(item: any): void,
|
||||||
|
/**
|
||||||
|
* Use .toEqual when you want to check that two objects have the same value.
|
||||||
|
* This matcher recursively checks the equality of all fields, rather than
|
||||||
|
* checking for object identity.
|
||||||
|
*/
|
||||||
|
toEqual(value: any): void,
|
||||||
|
/**
|
||||||
|
* Use .toHaveBeenCalled to ensure that a mock function got called.
|
||||||
|
*/
|
||||||
|
toHaveBeenCalled(): void,
|
||||||
|
/**
|
||||||
|
* Use .toHaveBeenCalledTimes to ensure that a mock function got called exact
|
||||||
|
* number of times.
|
||||||
|
*/
|
||||||
|
toHaveBeenCalledTimes(number: number): void,
|
||||||
|
/**
|
||||||
|
* Use .toHaveBeenCalledWith to ensure that a mock function was called with
|
||||||
|
* specific arguments.
|
||||||
|
*/
|
||||||
|
toHaveBeenCalledWith(...args: Array<any>): void,
|
||||||
|
/**
|
||||||
|
* Use .toHaveBeenLastCalledWith to ensure that a mock function was last called
|
||||||
|
* with specific arguments.
|
||||||
|
*/
|
||||||
|
toHaveBeenLastCalledWith(...args: Array<any>): void,
|
||||||
|
/**
|
||||||
|
* Check that an object has a .length property and it is set to a certain
|
||||||
|
* numeric value.
|
||||||
|
*/
|
||||||
|
toHaveLength(number: number): void,
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
toHaveProperty(propPath: string | Array<string>, value?: any): void,
|
||||||
|
/**
|
||||||
|
* Use .toMatch to check that a string matches a regular expression or string.
|
||||||
|
*/
|
||||||
|
toMatch(regexpOrString: RegExp | string): void,
|
||||||
|
/**
|
||||||
|
* Use .toMatchObject to check that a javascript object matches a subset of the properties of an object.
|
||||||
|
*/
|
||||||
|
toMatchObject(object: Object | Array<Object>): void,
|
||||||
|
/**
|
||||||
|
* This ensures that a React component matches the most recent snapshot.
|
||||||
|
*/
|
||||||
|
toMatchSnapshot(name?: string): void,
|
||||||
|
/**
|
||||||
|
* Use .toThrow to test that a function throws when it is called.
|
||||||
|
* If you want to test that a specific error gets thrown, you can provide an
|
||||||
|
* argument to toThrow. The argument can be a string for the error message,
|
||||||
|
* a class for the error, or a regex that should match the error.
|
||||||
|
*
|
||||||
|
* Alias: .toThrowError
|
||||||
|
*/
|
||||||
|
toThrow(message?: string | Error | Class<Error> | RegExp): void,
|
||||||
|
toThrowError(message?: string | Error | Class<Error> | RegExp): void,
|
||||||
|
/**
|
||||||
|
* Use .toThrowErrorMatchingSnapshot to test that a function throws a error
|
||||||
|
* matching the most recent snapshot when it is called.
|
||||||
|
*/
|
||||||
|
toThrowErrorMatchingSnapshot(): void
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestObjectType = {
|
||||||
|
/**
|
||||||
|
* Disables automatic mocking in the module loader.
|
||||||
|
*
|
||||||
|
* After this method is called, all `require()`s will return the real
|
||||||
|
* versions of each module (rather than a mocked version).
|
||||||
|
*/
|
||||||
|
disableAutomock(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* An un-hoisted version of disableAutomock
|
||||||
|
*/
|
||||||
|
autoMockOff(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Enables automatic mocking in the module loader.
|
||||||
|
*/
|
||||||
|
enableAutomock(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* An un-hoisted version of enableAutomock
|
||||||
|
*/
|
||||||
|
autoMockOn(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Clears the mock.calls and mock.instances properties of all mocks.
|
||||||
|
* Equivalent to calling .mockClear() on every mocked function.
|
||||||
|
*/
|
||||||
|
clearAllMocks(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Resets the state of all mocks. Equivalent to calling .mockReset() on every
|
||||||
|
* mocked function.
|
||||||
|
*/
|
||||||
|
resetAllMocks(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Restores all mocks back to their original value.
|
||||||
|
*/
|
||||||
|
restoreAllMocks(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Removes any pending timers from the timer system.
|
||||||
|
*/
|
||||||
|
clearAllTimers(): void,
|
||||||
|
/**
|
||||||
|
* The same as `mock` but not moved to the top of the expectation by
|
||||||
|
* babel-jest.
|
||||||
|
*/
|
||||||
|
doMock(moduleName: string, moduleFactory?: any): JestObjectType,
|
||||||
|
/**
|
||||||
|
* The same as `unmock` but not moved to the top of the expectation by
|
||||||
|
* babel-jest.
|
||||||
|
*/
|
||||||
|
dontMock(moduleName: string): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Returns a new, unused mock function. Optionally takes a mock
|
||||||
|
* implementation.
|
||||||
|
*/
|
||||||
|
fn<TArguments: $ReadOnlyArray<*>, TReturn>(
|
||||||
|
implementation?: (...args: TArguments) => TReturn
|
||||||
|
): JestMockFn<TArguments, TReturn>,
|
||||||
|
/**
|
||||||
|
* Determines if the given function is a mocked function.
|
||||||
|
*/
|
||||||
|
isMockFunction(fn: Function): boolean,
|
||||||
|
/**
|
||||||
|
* Given the name of a module, use the automatic mocking system to generate a
|
||||||
|
* mocked version of the module for you.
|
||||||
|
*/
|
||||||
|
genMockFromModule(moduleName: string): any,
|
||||||
|
/**
|
||||||
|
* Mocks a module with an auto-mocked version when it is being required.
|
||||||
|
*
|
||||||
|
* The second argument can be used to specify an explicit module factory that
|
||||||
|
* is being run instead of using Jest's automocking feature.
|
||||||
|
*
|
||||||
|
* The third argument can be used to create virtual mocks -- mocks of modules
|
||||||
|
* that don't exist anywhere in the system.
|
||||||
|
*/
|
||||||
|
mock(
|
||||||
|
moduleName: string,
|
||||||
|
moduleFactory?: any,
|
||||||
|
options?: Object
|
||||||
|
): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Returns the actual module instead of a mock, bypassing all checks on
|
||||||
|
* whether the module should receive a mock implementation or not.
|
||||||
|
*/
|
||||||
|
requireActual(moduleName: string): any,
|
||||||
|
/**
|
||||||
|
* Returns a mock module instead of the actual module, bypassing all checks
|
||||||
|
* on whether the module should be required normally or not.
|
||||||
|
*/
|
||||||
|
requireMock(moduleName: string): any,
|
||||||
|
/**
|
||||||
|
* Resets the module registry - the cache of all required modules. This is
|
||||||
|
* useful to isolate modules where local state might conflict between tests.
|
||||||
|
*/
|
||||||
|
resetModules(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Exhausts the micro-task queue (usually interfaced in node via
|
||||||
|
* process.nextTick).
|
||||||
|
*/
|
||||||
|
runAllTicks(): void,
|
||||||
|
/**
|
||||||
|
* Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(),
|
||||||
|
* setInterval(), and setImmediate()).
|
||||||
|
*/
|
||||||
|
runAllTimers(): void,
|
||||||
|
/**
|
||||||
|
* Exhausts all tasks queued by setImmediate().
|
||||||
|
*/
|
||||||
|
runAllImmediates(): void,
|
||||||
|
/**
|
||||||
|
* Executes only the macro task queue (i.e. all tasks queued by setTimeout()
|
||||||
|
* or setInterval() and setImmediate()).
|
||||||
|
*/
|
||||||
|
advanceTimersByTime(msToRun: number): void,
|
||||||
|
/**
|
||||||
|
* Executes only the macro task queue (i.e. all tasks queued by setTimeout()
|
||||||
|
* or setInterval() and setImmediate()).
|
||||||
|
*
|
||||||
|
* Renamed to `advanceTimersByTime`.
|
||||||
|
*/
|
||||||
|
runTimersToTime(msToRun: number): void,
|
||||||
|
/**
|
||||||
|
* Executes only the macro-tasks that are currently pending (i.e., only the
|
||||||
|
* tasks that have been queued by setTimeout() or setInterval() up to this
|
||||||
|
* point)
|
||||||
|
*/
|
||||||
|
runOnlyPendingTimers(): void,
|
||||||
|
/**
|
||||||
|
* Explicitly supplies the mock object that the module system should return
|
||||||
|
* for the specified module. Note: It is recommended to use jest.mock()
|
||||||
|
* instead.
|
||||||
|
*/
|
||||||
|
setMock(moduleName: string, moduleExports: any): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Indicates that the module system should never return a mocked version of
|
||||||
|
* the specified module from require() (e.g. that it should always return the
|
||||||
|
* real module).
|
||||||
|
*/
|
||||||
|
unmock(moduleName: string): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Instructs Jest to use fake versions of the standard timer functions
|
||||||
|
* (setTimeout, setInterval, clearTimeout, clearInterval, nextTick,
|
||||||
|
* setImmediate and clearImmediate).
|
||||||
|
*/
|
||||||
|
useFakeTimers(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Instructs Jest to use the real versions of the standard timer functions.
|
||||||
|
*/
|
||||||
|
useRealTimers(): JestObjectType,
|
||||||
|
/**
|
||||||
|
* Creates a mock function similar to jest.fn but also tracks calls to
|
||||||
|
* object[methodName].
|
||||||
|
*/
|
||||||
|
spyOn(object: Object, methodName: string, accessType?: "get" | "set"): JestMockFn<any, any>,
|
||||||
|
/**
|
||||||
|
* Set the default timeout interval for tests and before/after hooks in milliseconds.
|
||||||
|
* Note: The default timeout interval is 5 seconds if this method is not called.
|
||||||
|
*/
|
||||||
|
setTimeout(timeout: number): JestObjectType
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestSpyType = {
|
||||||
|
calls: JestCallsType
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Runs this function after every test inside this context */
|
||||||
|
declare function afterEach(
|
||||||
|
fn: (done: () => void) => ?Promise<mixed>,
|
||||||
|
timeout?: number
|
||||||
|
): void;
|
||||||
|
/** Runs this function before every test inside this context */
|
||||||
|
declare function beforeEach(
|
||||||
|
fn: (done: () => void) => ?Promise<mixed>,
|
||||||
|
timeout?: number
|
||||||
|
): void;
|
||||||
|
/** Runs this function after all tests have finished inside this context */
|
||||||
|
declare function afterAll(
|
||||||
|
fn: (done: () => void) => ?Promise<mixed>,
|
||||||
|
timeout?: number
|
||||||
|
): void;
|
||||||
|
/** Runs this function before any tests have started inside this context */
|
||||||
|
declare function beforeAll(
|
||||||
|
fn: (done: () => void) => ?Promise<mixed>,
|
||||||
|
timeout?: number
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/** A context for grouping tests together */
|
||||||
|
declare var describe: {
|
||||||
|
/**
|
||||||
|
* Creates a block that groups together several related tests in one "test suite"
|
||||||
|
*/
|
||||||
|
(name: JestTestName, fn: () => void): void,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only run this describe block
|
||||||
|
*/
|
||||||
|
only(name: JestTestName, fn: () => void): void,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip running this describe block
|
||||||
|
*/
|
||||||
|
skip(name: JestTestName, fn: () => void): void
|
||||||
|
};
|
||||||
|
|
||||||
|
/** An individual test unit */
|
||||||
|
declare var it: {
|
||||||
|
/**
|
||||||
|
* An individual test unit
|
||||||
|
*
|
||||||
|
* @param {JestTestName} Name of Test
|
||||||
|
* @param {Function} Test
|
||||||
|
* @param {number} Timeout for the test, in milliseconds.
|
||||||
|
*/
|
||||||
|
(
|
||||||
|
name: JestTestName,
|
||||||
|
fn?: (done: () => void) => ?Promise<mixed>,
|
||||||
|
timeout?: number
|
||||||
|
): void,
|
||||||
|
/**
|
||||||
|
* Only run this test
|
||||||
|
*
|
||||||
|
* @param {JestTestName} Name of Test
|
||||||
|
* @param {Function} Test
|
||||||
|
* @param {number} Timeout for the test, in milliseconds.
|
||||||
|
*/
|
||||||
|
only(
|
||||||
|
name: JestTestName,
|
||||||
|
fn?: (done: () => void) => ?Promise<mixed>,
|
||||||
|
timeout?: number
|
||||||
|
): void,
|
||||||
|
/**
|
||||||
|
* Skip running this test
|
||||||
|
*
|
||||||
|
* @param {JestTestName} Name of Test
|
||||||
|
* @param {Function} Test
|
||||||
|
* @param {number} Timeout for the test, in milliseconds.
|
||||||
|
*/
|
||||||
|
skip(
|
||||||
|
name: JestTestName,
|
||||||
|
fn?: (done: () => void) => ?Promise<mixed>,
|
||||||
|
timeout?: number
|
||||||
|
): void,
|
||||||
|
/**
|
||||||
|
* Run the test concurrently
|
||||||
|
*
|
||||||
|
* @param {JestTestName} Name of Test
|
||||||
|
* @param {Function} Test
|
||||||
|
* @param {number} Timeout for the test, in milliseconds.
|
||||||
|
*/
|
||||||
|
concurrent(
|
||||||
|
name: JestTestName,
|
||||||
|
fn?: (done: () => void) => ?Promise<mixed>,
|
||||||
|
timeout?: number
|
||||||
|
): void
|
||||||
|
};
|
||||||
|
declare function fit(
|
||||||
|
name: JestTestName,
|
||||||
|
fn: (done: () => void) => ?Promise<mixed>,
|
||||||
|
timeout?: number
|
||||||
|
): void;
|
||||||
|
/** An individual test unit */
|
||||||
|
declare var test: typeof it;
|
||||||
|
/** A disabled group of tests */
|
||||||
|
declare var xdescribe: typeof describe;
|
||||||
|
/** A focused group of tests */
|
||||||
|
declare var fdescribe: typeof describe;
|
||||||
|
/** A disabled individual test */
|
||||||
|
declare var xit: typeof it;
|
||||||
|
/** A disabled individual test */
|
||||||
|
declare var xtest: typeof it;
|
||||||
|
|
||||||
|
type JestPrettyFormatColors = {
|
||||||
|
comment: { close: string, open: string },
|
||||||
|
content: { close: string, open: string },
|
||||||
|
prop: { close: string, open: string },
|
||||||
|
tag: { close: string, open: string },
|
||||||
|
value: { close: string, open: string },
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestPrettyFormatIndent = string => string;
|
||||||
|
type JestPrettyFormatRefs = Array<any>;
|
||||||
|
type JestPrettyFormatPrint = any => string;
|
||||||
|
type JestPrettyFormatStringOrNull = string | null;
|
||||||
|
|
||||||
|
type JestPrettyFormatOptions = {|
|
||||||
|
callToJSON: boolean,
|
||||||
|
edgeSpacing: string,
|
||||||
|
escapeRegex: boolean,
|
||||||
|
highlight: boolean,
|
||||||
|
indent: number,
|
||||||
|
maxDepth: number,
|
||||||
|
min: boolean,
|
||||||
|
plugins: JestPrettyFormatPlugins,
|
||||||
|
printFunctionName: boolean,
|
||||||
|
spacing: string,
|
||||||
|
theme: {|
|
||||||
|
comment: string,
|
||||||
|
content: string,
|
||||||
|
prop: string,
|
||||||
|
tag: string,
|
||||||
|
value: string,
|
||||||
|
|},
|
||||||
|
|};
|
||||||
|
|
||||||
|
type JestPrettyFormatPlugin = {
|
||||||
|
print: (
|
||||||
|
val: any,
|
||||||
|
serialize: JestPrettyFormatPrint,
|
||||||
|
indent: JestPrettyFormatIndent,
|
||||||
|
opts: JestPrettyFormatOptions,
|
||||||
|
colors: JestPrettyFormatColors,
|
||||||
|
) => string,
|
||||||
|
test: any => boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
type JestPrettyFormatPlugins = Array<JestPrettyFormatPlugin>;
|
||||||
|
|
||||||
|
/** The expect function is used every time you want to test a value */
|
||||||
|
declare var expect: {
|
||||||
|
/** The object that you want to make assertions against */
|
||||||
|
(value: any): JestExpectType & JestPromiseType & EnzymeMatchersType & DomTestingLibraryType & JestJQueryMatchersType & JestExtendedMatchersType,
|
||||||
|
/** Add additional Jasmine matchers to Jest's roster */
|
||||||
|
extend(matchers: { [name: string]: JestMatcher }): void,
|
||||||
|
/** Add a module that formats application-specific data structures. */
|
||||||
|
addSnapshotSerializer(pluginModule: JestPrettyFormatPlugin): void,
|
||||||
|
assertions(expectedAssertions: number): void,
|
||||||
|
hasAssertions(): void,
|
||||||
|
any(value: mixed): JestAsymmetricEqualityType,
|
||||||
|
anything(): any,
|
||||||
|
arrayContaining(value: Array<mixed>): Array<mixed>,
|
||||||
|
objectContaining(value: Object): Object,
|
||||||
|
/** Matches any received string that contains the exact expected string. */
|
||||||
|
stringContaining(value: string): string,
|
||||||
|
stringMatching(value: string | RegExp): string
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO handle return type
|
||||||
|
// http://jasmine.github.io/2.4/introduction.html#section-Spies
|
||||||
|
declare function spyOn(value: mixed, method: string): Object;
|
||||||
|
|
||||||
|
/** Holds all functions related to manipulating test runner */
|
||||||
|
declare var jest: JestObjectType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global Jasmine object, this is generally not exposed as the public API,
|
||||||
|
* using features inside here could break in later versions of Jest.
|
||||||
|
*/
|
||||||
|
declare var jasmine: {
|
||||||
|
DEFAULT_TIMEOUT_INTERVAL: number,
|
||||||
|
any(value: mixed): JestAsymmetricEqualityType,
|
||||||
|
anything(): any,
|
||||||
|
arrayContaining(value: Array<mixed>): Array<mixed>,
|
||||||
|
clock(): JestClockType,
|
||||||
|
createSpy(name: string): JestSpyType,
|
||||||
|
createSpyObj(
|
||||||
|
baseName: string,
|
||||||
|
methodNames: Array<string>
|
||||||
|
): { [methodName: string]: JestSpyType },
|
||||||
|
objectContaining(value: Object): Object,
|
||||||
|
stringMatching(value: string): string
|
||||||
|
};
|
15
index.js
15
index.js
|
@ -1,3 +1,4 @@
|
||||||
|
// @flow
|
||||||
require('./init');
|
require('./init');
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
@ -20,18 +21,8 @@ if (
|
||||||
console.error(
|
console.error(
|
||||||
'Please set SECRET_KEY env variable with output of `openssl rand -hex 32`'
|
'Please set SECRET_KEY env variable with output of `openssl rand -hex 32`'
|
||||||
);
|
);
|
||||||
|
// $FlowFixMe
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = require('./server').default;
|
require('./server');
|
||||||
const http = require('http');
|
|
||||||
|
|
||||||
const server = http.createServer(app.callback());
|
|
||||||
server.listen(process.env.PORT || '3000');
|
|
||||||
server.on('error', err => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
server.on('listening', () => {
|
|
||||||
const address = server.address();
|
|
||||||
console.log(`\n> Listening on http://localhost:${address.port}\n`);
|
|
||||||
});
|
|
||||||
|
|
|
@ -49,7 +49,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "8.11"
|
"node": ">= 8.11"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -148,6 +148,9 @@
|
||||||
"sequelize-cli": "^5.4.0",
|
"sequelize-cli": "^5.4.0",
|
||||||
"sequelize-encrypted": "0.1.0",
|
"sequelize-encrypted": "0.1.0",
|
||||||
"slug": "^1.0.0",
|
"slug": "^1.0.0",
|
||||||
|
"socket.io": "^2.2.0",
|
||||||
|
"socketio-auth": "^0.1.1",
|
||||||
|
"socket.io-redis": "^5.2.0",
|
||||||
"string-replace-to-array": "^1.0.3",
|
"string-replace-to-array": "^1.0.3",
|
||||||
"style-loader": "^0.18.2",
|
"style-loader": "^0.18.2",
|
||||||
"styled-components": "^4.2.0",
|
"styled-components": "^4.2.0",
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
// @flow
|
||||||
|
export default {
|
||||||
|
add: () => {},
|
||||||
|
};
|
|
@ -23,7 +23,7 @@ router.post('apiKeys.create', auth(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentApiKey(ctx, key),
|
data: presentApiKey(key),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -38,11 +38,9 @@ router.post('apiKeys.list', auth(), pagination(), async ctx => {
|
||||||
limit: ctx.state.pagination.limit,
|
limit: ctx.state.pagination.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = keys.map(key => presentApiKey(ctx, key));
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
data,
|
data: keys.map(presentApiKey),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -12,8 +12,8 @@ router.post('auth.info', auth(), async ctx => {
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: {
|
data: {
|
||||||
user: await presentUser(ctx, user, { includeDetails: true }),
|
user: presentUser(user, { includeDetails: true }),
|
||||||
team: await presentTeam(ctx, team),
|
team: presentTeam(team),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { Collection, CollectionUser, Team, User } from '../models';
|
||||||
import { ValidationError, InvalidRequestError } from '../errors';
|
import { ValidationError, InvalidRequestError } from '../errors';
|
||||||
import { exportCollection, exportCollections } from '../logistics';
|
import { exportCollection, exportCollections } from '../logistics';
|
||||||
import policy from '../policies';
|
import policy from '../policies';
|
||||||
|
import events from '../events';
|
||||||
|
|
||||||
const { authorize } = policy;
|
const { authorize } = policy;
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
@ -32,8 +33,15 @@ router.post('collections.create', auth(), async ctx => {
|
||||||
private: isPrivate,
|
private: isPrivate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'collections.create',
|
||||||
|
modelId: collection.id,
|
||||||
|
teamId: collection.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentCollection(ctx, collection),
|
data: await presentCollection(collection),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -45,7 +53,7 @@ router.post('collections.info', auth(), async ctx => {
|
||||||
authorize(ctx.state.user, 'read', collection);
|
authorize(ctx.state.user, 'read', collection);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentCollection(ctx, collection),
|
data: await presentCollection(collection),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -71,6 +79,14 @@ router.post('collections.add_user', auth(), async ctx => {
|
||||||
createdById: ctx.state.user.id,
|
createdById: ctx.state.user.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'collections.add_user',
|
||||||
|
modelId: userId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
teamId: collection.teamId,
|
||||||
|
actorId: ctx.state.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
@ -93,6 +109,14 @@ router.post('collections.remove_user', auth(), async ctx => {
|
||||||
|
|
||||||
await collection.removeUser(user);
|
await collection.removeUser(user);
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'collections.remove_user',
|
||||||
|
modelId: userId,
|
||||||
|
collectionId: collection.id,
|
||||||
|
teamId: collection.teamId,
|
||||||
|
actorId: ctx.state.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
@ -107,12 +131,8 @@ router.post('collections.users', auth(), async ctx => {
|
||||||
|
|
||||||
const users = await collection.getUsers();
|
const users = await collection.getUsers();
|
||||||
|
|
||||||
const data = await Promise.all(
|
|
||||||
users.map(async user => await presentUser(ctx, user))
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data,
|
data: users.map(presentUser),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -176,8 +196,15 @@ router.post('collections.update', auth(), async ctx => {
|
||||||
collection.private = isPrivate;
|
collection.private = isPrivate;
|
||||||
await collection.save();
|
await collection.save();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'collections.update',
|
||||||
|
modelId: collection.id,
|
||||||
|
teamId: collection.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentCollection(ctx, collection),
|
data: presentCollection(collection),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -196,9 +223,7 @@ router.post('collections.list', auth(), pagination(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(
|
||||||
collections.map(
|
collections.map(async collection => await presentCollection(collection))
|
||||||
async collection => await presentCollection(ctx, collection)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -209,16 +234,24 @@ router.post('collections.list', auth(), pagination(), async ctx => {
|
||||||
|
|
||||||
router.post('collections.delete', auth(), async ctx => {
|
router.post('collections.delete', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
|
const user = ctx.state.user;
|
||||||
ctx.assertUuid(id, 'id is required');
|
ctx.assertUuid(id, 'id is required');
|
||||||
|
|
||||||
const collection = await Collection.findById(id);
|
const collection = await Collection.findById(id);
|
||||||
authorize(ctx.state.user, 'delete', collection);
|
authorize(user, 'delete', collection);
|
||||||
|
|
||||||
const total = await Collection.count();
|
const total = await Collection.count();
|
||||||
if (total === 1) throw new ValidationError('Cannot delete last collection');
|
if (total === 1) throw new ValidationError('Cannot delete last collection');
|
||||||
|
|
||||||
await collection.destroy();
|
await collection.destroy();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'collections.delete',
|
||||||
|
modelId: collection.id,
|
||||||
|
teamId: collection.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '..';
|
import app from '../app';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildUser, buildCollection } from '../test/factories';
|
import { buildUser, buildCollection } from '../test/factories';
|
||||||
import { Collection } from '../models';
|
import { Collection } from '../models';
|
||||||
|
|
|
@ -60,7 +60,7 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(
|
||||||
documents.map(document => presentDocument(ctx, document))
|
documents.map(document => presentDocument(document))
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -96,7 +96,7 @@ router.post('documents.pinned', auth(), pagination(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(
|
||||||
documents.map(document => presentDocument(ctx, document))
|
documents.map(document => presentDocument(document))
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -128,7 +128,7 @@ router.post('documents.archived', auth(), pagination(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(
|
||||||
documents.map(document => presentDocument(ctx, document))
|
documents.map(document => presentDocument(document))
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -169,7 +169,7 @@ router.post('documents.viewed', auth(), pagination(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(
|
||||||
views.map(view => presentDocument(ctx, view.document))
|
views.map(view => presentDocument(view.document))
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -212,7 +212,7 @@ router.post('documents.starred', auth(), pagination(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(
|
||||||
stars.map(star => presentDocument(ctx, star.document))
|
stars.map(star => presentDocument(star.document))
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -241,7 +241,7 @@ router.post('documents.drafts', auth(), pagination(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(
|
||||||
documents.map(document => presentDocument(ctx, document))
|
documents.map(document => presentDocument(document))
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -284,7 +284,7 @@ router.post('documents.info', auth({ required: false }), async ctx => {
|
||||||
const isPublic = cannot(user, 'read', document);
|
const isPublic = cannot(user, 'read', document);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document, { isPublic }),
|
data: await presentDocument(document, { isPublic }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -305,7 +305,7 @@ router.post('documents.revision', auth(), async ctx => {
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
data: presentRevision(ctx, revision),
|
data: presentRevision(revision),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -324,9 +324,7 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
|
||||||
limit: ctx.state.pagination.limit,
|
limit: ctx.state.pagination.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(revisions.map(presentRevision));
|
||||||
revisions.map((revision, index) => presentRevision(ctx, revision))
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
|
@ -347,8 +345,15 @@ router.post('documents.restore', auth(), async ctx => {
|
||||||
// restore a previously archived document
|
// restore a previously archived document
|
||||||
await document.unarchive(user.id);
|
await document.unarchive(user.id);
|
||||||
|
|
||||||
// restore a document to a specific revision
|
events.add({
|
||||||
|
name: 'documents.unarchive',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
} else if (revisionId) {
|
} else if (revisionId) {
|
||||||
|
// restore a document to a specific revision
|
||||||
authorize(user, 'update', document);
|
authorize(user, 'update', document);
|
||||||
|
|
||||||
const revision = await Revision.findById(revisionId);
|
const revision = await Revision.findById(revisionId);
|
||||||
|
@ -357,12 +362,20 @@ router.post('documents.restore', auth(), async ctx => {
|
||||||
document.text = revision.text;
|
document.text = revision.text;
|
||||||
document.title = revision.title;
|
document.title = revision.title;
|
||||||
await document.save();
|
await document.save();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.restore',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
ctx.assertPresent(revisionId, 'revisionId is required');
|
ctx.assertPresent(revisionId, 'revisionId is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document),
|
data: await presentDocument(document),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -380,7 +393,7 @@ router.post('documents.search', auth(), pagination(), async ctx => {
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(
|
||||||
results.map(async result => {
|
results.map(async result => {
|
||||||
const document = await presentDocument(ctx, result.document);
|
const document = await presentDocument(result.document);
|
||||||
return { ...result, document };
|
return { ...result, document };
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -402,8 +415,16 @@ router.post('documents.pin', auth(), async ctx => {
|
||||||
document.pinnedById = user.id;
|
document.pinnedById = user.id;
|
||||||
await document.save();
|
await document.save();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.pin',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document),
|
data: await presentDocument(document),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -418,8 +439,16 @@ router.post('documents.unpin', auth(), async ctx => {
|
||||||
document.pinnedById = null;
|
document.pinnedById = null;
|
||||||
await document.save();
|
await document.save();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.unpin',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document),
|
data: await presentDocument(document),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -434,6 +463,14 @@ router.post('documents.star', auth(), async ctx => {
|
||||||
await Star.findOrCreate({
|
await Star.findOrCreate({
|
||||||
where: { documentId: document.id, userId: user.id },
|
where: { documentId: document.id, userId: user.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.star',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('documents.unstar', auth(), async ctx => {
|
router.post('documents.unstar', auth(), async ctx => {
|
||||||
|
@ -447,16 +484,32 @@ router.post('documents.unstar', auth(), async ctx => {
|
||||||
await Star.destroy({
|
await Star.destroy({
|
||||||
where: { documentId: document.id, userId: user.id },
|
where: { documentId: document.id, userId: user.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.unstar',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('documents.create', auth(), async ctx => {
|
router.post('documents.create', auth(), async ctx => {
|
||||||
const { title, text, publish, parentDocument, index } = ctx.body;
|
const {
|
||||||
const collectionId = ctx.body.collection;
|
title,
|
||||||
ctx.assertUuid(collectionId, 'collection must be an uuid');
|
text,
|
||||||
|
publish,
|
||||||
|
collectionId,
|
||||||
|
parentDocumentId,
|
||||||
|
index,
|
||||||
|
} = ctx.body;
|
||||||
|
ctx.assertUuid(collectionId, 'collectionId must be an uuid');
|
||||||
ctx.assertPresent(title, 'title is required');
|
ctx.assertPresent(title, 'title is required');
|
||||||
ctx.assertPresent(text, 'text is required');
|
ctx.assertPresent(text, 'text is required');
|
||||||
if (parentDocument)
|
if (parentDocumentId) {
|
||||||
ctx.assertUuid(parentDocument, 'parentDocument must be an uuid');
|
ctx.assertUuid(parentDocumentId, 'parentDocumentId must be an uuid');
|
||||||
|
}
|
||||||
|
|
||||||
if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)');
|
if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)');
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
|
@ -470,19 +523,19 @@ router.post('documents.create', auth(), async ctx => {
|
||||||
});
|
});
|
||||||
authorize(user, 'publish', collection);
|
authorize(user, 'publish', collection);
|
||||||
|
|
||||||
let parentDocumentObj = {};
|
let parentDocument;
|
||||||
if (parentDocument && collection.type === 'atlas') {
|
if (parentDocumentId && collection.type === 'atlas') {
|
||||||
parentDocumentObj = await Document.findOne({
|
parentDocument = await Document.findOne({
|
||||||
where: {
|
where: {
|
||||||
id: parentDocument,
|
id: parentDocumentId,
|
||||||
collectionId: collection.id,
|
collectionId: collection.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
authorize(user, 'read', parentDocumentObj);
|
authorize(user, 'read', parentDocument);
|
||||||
}
|
}
|
||||||
|
|
||||||
let document = await Document.create({
|
let document = await Document.create({
|
||||||
parentDocumentId: parentDocumentObj.id,
|
parentDocumentId,
|
||||||
collectionId: collection.id,
|
collectionId: collection.id,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
@ -492,8 +545,24 @@ router.post('documents.create', auth(), async ctx => {
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.create',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
if (publish) {
|
if (publish) {
|
||||||
await document.publish();
|
await document.publish();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.publish',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// reload to get all of the data needed to present (user, collection etc)
|
// reload to get all of the data needed to present (user, collection etc)
|
||||||
|
@ -504,12 +573,12 @@ router.post('documents.create', auth(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document),
|
data: await presentDocument(document),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('documents.update', auth(), async ctx => {
|
router.post('documents.update', auth(), async ctx => {
|
||||||
const { id, title, text, publish, autosave, done, lastRevision } = ctx.body;
|
const { id, title, text, publish, autosave, lastRevision } = ctx.body;
|
||||||
ctx.assertPresent(id, 'id is required');
|
ctx.assertPresent(id, 'id is required');
|
||||||
ctx.assertPresent(title || text, 'title or text is required');
|
ctx.assertPresent(title || text, 'title or text is required');
|
||||||
|
|
||||||
|
@ -529,16 +598,28 @@ router.post('documents.update', auth(), async ctx => {
|
||||||
|
|
||||||
if (publish) {
|
if (publish) {
|
||||||
await document.publish();
|
await document.publish();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.publish',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await document.save({ autosave });
|
await document.save({ autosave });
|
||||||
|
|
||||||
if (document.publishedAt && done) {
|
events.add({
|
||||||
events.add({ name: 'documents.update', model: document });
|
name: 'documents.update',
|
||||||
}
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document),
|
data: await presentDocument(document),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -587,10 +668,10 @@ router.post('documents.move', auth(), async ctx => {
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: {
|
data: {
|
||||||
documents: await Promise.all(
|
documents: await Promise.all(
|
||||||
documents.map(document => presentDocument(ctx, document))
|
documents.map(document => presentDocument(document))
|
||||||
),
|
),
|
||||||
collections: await Promise.all(
|
collections: await Promise.all(
|
||||||
collections.map(collection => presentCollection(ctx, collection))
|
collections.map(collection => presentCollection(collection))
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -606,8 +687,16 @@ router.post('documents.archive', auth(), async ctx => {
|
||||||
|
|
||||||
await document.archive(user.id);
|
await document.archive(user.id);
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.archive',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: await presentDocument(ctx, document),
|
data: await presentDocument(document),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -621,6 +710,14 @@ router.post('documents.delete', auth(), async ctx => {
|
||||||
|
|
||||||
await document.delete();
|
await document.delete();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.delete',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionId: document.collectionId,
|
||||||
|
teamId: document.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '..';
|
import app from '../app';
|
||||||
import { Document, View, Star, Revision } from '../models';
|
import { Document, View, Star, Revision } from '../models';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import {
|
import {
|
||||||
|
@ -79,7 +79,6 @@ describe('#documents.info', async () => {
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.id).toEqual(document.id);
|
expect(body.data.id).toEqual(document.id);
|
||||||
expect(body.data.collection).toEqual(undefined);
|
|
||||||
expect(body.data.createdBy).toEqual(undefined);
|
expect(body.data.createdBy).toEqual(undefined);
|
||||||
expect(body.data.updatedBy).toEqual(undefined);
|
expect(body.data.updatedBy).toEqual(undefined);
|
||||||
});
|
});
|
||||||
|
@ -113,7 +112,7 @@ describe('#documents.info', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return document from shareId with token', async () => {
|
it('should return document from shareId with token', async () => {
|
||||||
const { user, document, collection } = await seed();
|
const { user, document } = await seed();
|
||||||
const share = await buildShare({
|
const share = await buildShare({
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
teamId: document.teamId,
|
teamId: document.teamId,
|
||||||
|
@ -126,7 +125,6 @@ describe('#documents.info', async () => {
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.id).toEqual(document.id);
|
expect(body.data.id).toEqual(document.id);
|
||||||
expect(body.data.collection.id).toEqual(collection.id);
|
|
||||||
expect(body.data.createdBy.id).toEqual(user.id);
|
expect(body.data.createdBy.id).toEqual(user.id);
|
||||||
expect(body.data.updatedBy.id).toEqual(user.id);
|
expect(body.data.updatedBy.id).toEqual(user.id);
|
||||||
});
|
});
|
||||||
|
@ -892,7 +890,7 @@ describe('#documents.create', async () => {
|
||||||
const res = await server.post('/api/documents.create', {
|
const res = await server.post('/api/documents.create', {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
collection: collection.id,
|
collectionId: collection.id,
|
||||||
title: 'new document',
|
title: 'new document',
|
||||||
text: 'hello',
|
text: 'hello',
|
||||||
publish: true,
|
publish: true,
|
||||||
|
@ -910,7 +908,7 @@ describe('#documents.create', async () => {
|
||||||
const res = await server.post('/api/documents.create', {
|
const res = await server.post('/api/documents.create', {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
collection: collection.id,
|
collectionId: collection.id,
|
||||||
title: ' ',
|
title: ' ',
|
||||||
text: ' ',
|
text: ' ',
|
||||||
},
|
},
|
||||||
|
@ -926,7 +924,7 @@ describe('#documents.create', async () => {
|
||||||
const res = await server.post('/api/documents.create', {
|
const res = await server.post('/api/documents.create', {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
collection: collection.id,
|
collectionId: collection.id,
|
||||||
title:
|
title:
|
||||||
'This is a really long title that is not acceptable to Outline because it is so ridiculously long that we need to have a limit somewhere',
|
'This is a really long title that is not acceptable to Outline because it is so ridiculously long that we need to have a limit somewhere',
|
||||||
text: ' ',
|
text: ' ',
|
||||||
|
@ -940,10 +938,10 @@ describe('#documents.create', async () => {
|
||||||
const res = await server.post('/api/documents.create', {
|
const res = await server.post('/api/documents.create', {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
collection: collection.id,
|
collectionId: collection.id,
|
||||||
|
parentDocumentId: document.id,
|
||||||
title: 'new document',
|
title: 'new document',
|
||||||
text: 'hello',
|
text: 'hello',
|
||||||
parentDocument: document.id,
|
|
||||||
publish: true,
|
publish: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -951,8 +949,6 @@ describe('#documents.create', async () => {
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.title).toBe('new document');
|
expect(body.data.title).toBe('new document');
|
||||||
expect(body.data.collection.documents.length).toBe(2);
|
|
||||||
expect(body.data.collection.documents[0].children[0].id).toBe(body.data.id);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should error with invalid parentDocument', async () => {
|
it('should error with invalid parentDocument', async () => {
|
||||||
|
@ -960,10 +956,10 @@ describe('#documents.create', async () => {
|
||||||
const res = await server.post('/api/documents.create', {
|
const res = await server.post('/api/documents.create', {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
collection: collection.id,
|
collectionId: collection.id,
|
||||||
|
parentDocumentId: 'd7a4eb73-fac1-4028-af45-d7e34d54db8e',
|
||||||
title: 'new document',
|
title: 'new document',
|
||||||
text: 'hello',
|
text: 'hello',
|
||||||
parentDocument: 'd7a4eb73-fac1-4028-af45-d7e34d54db8e',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
@ -977,17 +973,16 @@ describe('#documents.create', async () => {
|
||||||
const res = await server.post('/api/documents.create', {
|
const res = await server.post('/api/documents.create', {
|
||||||
body: {
|
body: {
|
||||||
token: user.getJwtToken(),
|
token: user.getJwtToken(),
|
||||||
collection: collection.id,
|
collectionId: collection.id,
|
||||||
|
parentDocumentId: document.id,
|
||||||
title: 'new document',
|
title: 'new document',
|
||||||
text: 'hello',
|
text: 'hello',
|
||||||
parentDocument: document.id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.title).toBe('new document');
|
expect(body.data.title).toBe('new document');
|
||||||
expect(body.data.collection.documents.length).toBe(2);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1009,7 +1004,6 @@ describe('#documents.update', async () => {
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.title).toBe('Updated title');
|
expect(body.data.title).toBe('Updated title');
|
||||||
expect(body.data.text).toBe('Updated text');
|
expect(body.data.text).toBe('Updated text');
|
||||||
expect(body.data.collection.documents[0].title).toBe('Updated title');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not edit archived document', async () => {
|
it('should not edit archived document', async () => {
|
||||||
|
@ -1070,7 +1064,6 @@ describe('#documents.update', async () => {
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.title).toBe('Untitled document');
|
expect(body.data.title).toBe('Untitled document');
|
||||||
expect(body.data.text).toBe('# Untitled document');
|
expect(body.data.text).toBe('# Untitled document');
|
||||||
expect(body.data.collection.documents[0].title).toBe('Untitled document');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail if document lastRevision does not match', async () => {
|
it('should fail if document lastRevision does not match', async () => {
|
||||||
|
@ -1121,9 +1114,6 @@ describe('#documents.update', async () => {
|
||||||
|
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.data.title).toBe('Updated title');
|
expect(body.data.title).toBe('Updated title');
|
||||||
expect(body.data.collection.documents[0].children[1].title).toBe(
|
|
||||||
'Updated title'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '..';
|
import app from '../app';
|
||||||
import { Authentication } from '../models';
|
import { Authentication } from '../models';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildDocument, buildUser } from '../test/factories';
|
import { buildDocument, buildUser } from '../test/factories';
|
||||||
|
|
|
@ -5,6 +5,7 @@ import pagination from './middlewares/pagination';
|
||||||
import auth from '../middlewares/authentication';
|
import auth from '../middlewares/authentication';
|
||||||
import { presentIntegration } from '../presenters';
|
import { presentIntegration } from '../presenters';
|
||||||
import policy from '../policies';
|
import policy from '../policies';
|
||||||
|
import events from '../events';
|
||||||
|
|
||||||
const { authorize } = policy;
|
const { authorize } = policy;
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
@ -21,9 +22,7 @@ router.post('integrations.list', auth(), pagination(), async ctx => {
|
||||||
limit: ctx.state.pagination.limit,
|
limit: ctx.state.pagination.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(
|
const data = await Promise.all(integrations.map(presentIntegration));
|
||||||
integrations.map(integration => presentIntegration(ctx, integration))
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
|
@ -35,11 +34,19 @@ router.post('integrations.delete', auth(), async ctx => {
|
||||||
const { id } = ctx.body;
|
const { id } = ctx.body;
|
||||||
ctx.assertUuid(id, 'id is required');
|
ctx.assertUuid(id, 'id is required');
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
const integration = await Integration.findById(id);
|
const integration = await Integration.findById(id);
|
||||||
authorize(ctx.state.user, 'delete', integration);
|
authorize(user, 'delete', integration);
|
||||||
|
|
||||||
await integration.destroy();
|
await integration.destroy();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'integrations.delete',
|
||||||
|
modelId: integration.id,
|
||||||
|
teamId: integration.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,7 +25,7 @@ router.post('notificationSettings.create', auth(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentNotificationSetting(ctx, setting),
|
data: presentNotificationSetting(setting),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -38,7 +38,7 @@ router.post('notificationSettings.list', auth(), async ctx => {
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: settings.map(setting => presentNotificationSetting(ctx, setting)),
|
data: settings.map(presentNotificationSetting),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -48,10 +48,8 @@ router.post('shares.list', auth(), pagination(), async ctx => {
|
||||||
limit: ctx.state.pagination.limit,
|
limit: ctx.state.pagination.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await Promise.all(shares.map(share => presentShare(ctx, share)));
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data,
|
data: shares.map(presentShare),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -78,7 +76,7 @@ router.post('shares.create', auth(), async ctx => {
|
||||||
share.document = document;
|
share.document = document;
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentShare(ctx, share),
|
data: presentShare(share),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '..';
|
import app from '../app';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildUser, buildShare } from '../test/factories';
|
import { buildUser, buildShare } from '../test/factories';
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,9 @@ router.post('team.update', auth(), async ctx => {
|
||||||
}
|
}
|
||||||
await team.save();
|
await team.save();
|
||||||
|
|
||||||
ctx.body = { data: await presentTeam(ctx, team) };
|
ctx.body = {
|
||||||
|
data: presentTeam(team),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '..';
|
import app from '../app';
|
||||||
|
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
|
|
||||||
|
|
|
@ -27,13 +27,13 @@ router.post('users.list', auth(), pagination(), async ctx => {
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
pagination: ctx.state.pagination,
|
pagination: ctx.state.pagination,
|
||||||
data: users.map(listUser =>
|
data: users.map(listUser =>
|
||||||
presentUser(ctx, listUser, { includeDetails: user.isAdmin })
|
presentUser(listUser, { includeDetails: user.isAdmin })
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('users.info', auth(), async ctx => {
|
router.post('users.info', auth(), async ctx => {
|
||||||
ctx.body = { data: await presentUser(ctx, ctx.state.user) };
|
ctx.body = { data: await presentUser(ctx.state.user) };
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('users.update', auth(), async ctx => {
|
router.post('users.update', auth(), async ctx => {
|
||||||
|
@ -48,7 +48,7 @@ router.post('users.update', auth(), async ctx => {
|
||||||
|
|
||||||
await user.save();
|
await user.save();
|
||||||
|
|
||||||
ctx.body = { data: await presentUser(ctx, user, { includeDetails: true }) };
|
ctx.body = { data: await presentUser(user, { includeDetails: true }) };
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('users.s3Upload', auth(), async ctx => {
|
router.post('users.s3Upload', auth(), async ctx => {
|
||||||
|
@ -112,7 +112,7 @@ router.post('users.promote', auth(), async ctx => {
|
||||||
await team.addAdmin(user);
|
await team.addAdmin(user);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(ctx, user, { includeDetails: true }),
|
data: presentUser(user, { includeDetails: true }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -132,7 +132,7 @@ router.post('users.demote', auth(), async ctx => {
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(ctx, user, { includeDetails: true }),
|
data: presentUser(user, { includeDetails: true }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -158,7 +158,7 @@ router.post('users.suspend', auth(), async ctx => {
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(ctx, user, { includeDetails: true }),
|
data: presentUser(user, { includeDetails: true }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -181,7 +181,7 @@ router.post('users.activate', auth(), async ctx => {
|
||||||
await team.activateUser(user, admin);
|
await team.activateUser(user, admin);
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data: presentUser(ctx, user, { includeDetails: true }),
|
data: presentUser(user, { includeDetails: true }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '..';
|
import app from '../app';
|
||||||
|
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildUser } from '../test/factories';
|
import { buildUser } from '../test/factories';
|
||||||
|
|
|
@ -27,10 +27,8 @@ router.post('views.list', auth(), async ctx => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = views.map(view => presentView(ctx, view));
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
data,
|
data: views.map(presentView),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '..';
|
import app from '../app';
|
||||||
import { View } from '../models';
|
import { View } from '../models';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildUser } from '../test/factories';
|
import { buildUser } from '../test/factories';
|
||||||
|
|
|
@ -0,0 +1,128 @@
|
||||||
|
// @flow
|
||||||
|
import compress from 'koa-compress';
|
||||||
|
import { contentSecurityPolicy } from 'koa-helmet';
|
||||||
|
import logger from 'koa-logger';
|
||||||
|
import mount from 'koa-mount';
|
||||||
|
import enforceHttps from 'koa-sslify';
|
||||||
|
import Koa from 'koa';
|
||||||
|
import bugsnag from 'bugsnag';
|
||||||
|
import onerror from 'koa-onerror';
|
||||||
|
import updates from './utils/updates';
|
||||||
|
|
||||||
|
import auth from './auth';
|
||||||
|
import api from './api';
|
||||||
|
import emails from './emails';
|
||||||
|
import routes from './routes';
|
||||||
|
|
||||||
|
const app = new Koa();
|
||||||
|
|
||||||
|
app.use(compress());
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
/* eslint-disable global-require */
|
||||||
|
const convert = require('koa-convert');
|
||||||
|
const webpack = require('webpack');
|
||||||
|
const devMiddleware = require('koa-webpack-dev-middleware');
|
||||||
|
const hotMiddleware = require('koa-webpack-hot-middleware');
|
||||||
|
const config = require('../webpack.config.dev');
|
||||||
|
const compile = webpack(config);
|
||||||
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
convert(
|
||||||
|
devMiddleware(compile, {
|
||||||
|
// display no info to console (only warnings and errors)
|
||||||
|
noInfo: true,
|
||||||
|
|
||||||
|
// display nothing to the console
|
||||||
|
quiet: false,
|
||||||
|
|
||||||
|
// switch into lazy mode
|
||||||
|
// that means no watching, but recompilation on every request
|
||||||
|
lazy: false,
|
||||||
|
|
||||||
|
// // watch options (only lazy: false)
|
||||||
|
// watchOptions: {
|
||||||
|
// aggregateTimeout: 300,
|
||||||
|
// poll: true
|
||||||
|
// },
|
||||||
|
|
||||||
|
// public path to bind the middleware to
|
||||||
|
// use the same as in webpack
|
||||||
|
publicPath: config.output.publicPath,
|
||||||
|
|
||||||
|
// options for formatting the statistics
|
||||||
|
stats: {
|
||||||
|
colors: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
convert(
|
||||||
|
hotMiddleware(compile, {
|
||||||
|
log: console.log, // eslint-disable-line
|
||||||
|
path: '/__webpack_hmr',
|
||||||
|
heartbeat: 10 * 1000,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
app.use(logger());
|
||||||
|
|
||||||
|
app.use(mount('/emails', emails));
|
||||||
|
} else if (process.env.NODE_ENV === 'production') {
|
||||||
|
// Force HTTPS on all pages
|
||||||
|
app.use(
|
||||||
|
enforceHttps({
|
||||||
|
trustProtoHeader: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// trust header fields set by our proxy. eg X-Forwarded-For
|
||||||
|
app.proxy = true;
|
||||||
|
|
||||||
|
// catch errors in one place, automatically set status and response headers
|
||||||
|
onerror(app);
|
||||||
|
|
||||||
|
if (process.env.BUGSNAG_KEY) {
|
||||||
|
bugsnag.register(process.env.BUGSNAG_KEY, {
|
||||||
|
filters: ['authorization'],
|
||||||
|
});
|
||||||
|
app.on('error', (error, ctx) => {
|
||||||
|
// we don't need to report every time a request stops to the bug tracker
|
||||||
|
if (error.code === 'EPIPE' || error.code === 'ECONNRESET') {
|
||||||
|
console.warn('Connection error', { error });
|
||||||
|
} else {
|
||||||
|
bugsnag.koaHandler(error, ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(mount('/auth', auth));
|
||||||
|
app.use(mount('/api', api));
|
||||||
|
app.use(mount(routes));
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
contentSecurityPolicy({
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production updates and anonymous analytics.
|
||||||
|
*
|
||||||
|
* Set ENABLE_UPDATES=false to disable them for your installation
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
process.env.ENABLE_UPDATES !== 'false' &&
|
||||||
|
process.env.NODE_ENV === 'production'
|
||||||
|
) {
|
||||||
|
updates();
|
||||||
|
setInterval(updates, 24 * 3600 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app;
|
|
@ -1,6 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { Document, Collection } from '../models';
|
import { Document, Collection } from '../models';
|
||||||
import { sequelize } from '../sequelize';
|
import { sequelize } from '../sequelize';
|
||||||
|
import events from '../events';
|
||||||
|
|
||||||
export default async function documentMover({
|
export default async function documentMover({
|
||||||
document,
|
document,
|
||||||
|
@ -67,10 +68,17 @@ export default async function documentMover({
|
||||||
}
|
}
|
||||||
|
|
||||||
await document.save({ transaction });
|
await document.save({ transaction });
|
||||||
document.collection = newCollection;
|
|
||||||
result.documents.push(document);
|
result.documents.push(document);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
|
|
||||||
|
events.add({
|
||||||
|
name: 'documents.move',
|
||||||
|
modelId: document.id,
|
||||||
|
collectionIds: result.collections.map(c => c.id),
|
||||||
|
documentIds: result.documents.map(d => d.id),
|
||||||
|
teamId: document.teamId,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
|
|
|
@ -1,24 +1,74 @@
|
||||||
// @flow
|
// @flow
|
||||||
import Queue from 'bull';
|
import Queue from 'bull';
|
||||||
import services from './services';
|
import services from './services';
|
||||||
import { Collection, Document, Integration } from './models';
|
|
||||||
|
|
||||||
type DocumentEvent = {
|
type UserEvent = {
|
||||||
name: 'documents.create' | 'documents.update' | 'documents.publish',
|
name: | 'users.create' // eslint-disable-line
|
||||||
model: Document,
|
| 'users.update'
|
||||||
|
| 'users.suspend'
|
||||||
|
| 'users.activate'
|
||||||
|
| 'users.delete',
|
||||||
|
modelId: string,
|
||||||
|
teamId: string,
|
||||||
|
actorId: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
type CollectionEvent = {
|
type DocumentEvent =
|
||||||
name: 'collections.create' | 'collections.update',
|
| {
|
||||||
model: Collection,
|
name: | 'documents.create' // eslint-disable-line
|
||||||
};
|
| 'documents.publish'
|
||||||
|
| 'documents.update'
|
||||||
|
| 'documents.delete'
|
||||||
|
| 'documents.pin'
|
||||||
|
| 'documents.unpin'
|
||||||
|
| 'documents.archive'
|
||||||
|
| 'documents.unarchive'
|
||||||
|
| 'documents.restore'
|
||||||
|
| 'documents.star'
|
||||||
|
| 'documents.unstar',
|
||||||
|
modelId: string,
|
||||||
|
collectionId: string,
|
||||||
|
teamId: string,
|
||||||
|
actorId: string,
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'documents.move',
|
||||||
|
modelId: string,
|
||||||
|
collectionIds: string[],
|
||||||
|
documentIds: string[],
|
||||||
|
teamId: string,
|
||||||
|
actorId: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
type CollectionEvent =
|
||||||
|
| {
|
||||||
|
name: | 'collections.create' // eslint-disable-line
|
||||||
|
| 'collections.update'
|
||||||
|
| 'collections.delete',
|
||||||
|
modelId: string,
|
||||||
|
teamId: string,
|
||||||
|
actorId: string,
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'collections.add_user' | 'collections.remove_user',
|
||||||
|
modelId: string,
|
||||||
|
collectionId: string,
|
||||||
|
teamId: string,
|
||||||
|
actorId: string,
|
||||||
|
};
|
||||||
|
|
||||||
type IntegrationEvent = {
|
type IntegrationEvent = {
|
||||||
name: 'integrations.create' | 'integrations.update',
|
name: 'integrations.create' | 'integrations.update' | 'collections.delete',
|
||||||
model: Integration,
|
modelId: string,
|
||||||
|
teamId: string,
|
||||||
|
actorId: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Event = DocumentEvent | CollectionEvent | IntegrationEvent;
|
export type Event =
|
||||||
|
| UserEvent
|
||||||
|
| DocumentEvent
|
||||||
|
| CollectionEvent
|
||||||
|
| IntegrationEvent;
|
||||||
|
|
||||||
const globalEventsQueue = new Queue('global events', process.env.REDIS_URL);
|
const globalEventsQueue = new Queue('global events', process.env.REDIS_URL);
|
||||||
const serviceEventsQueue = new Queue('service events', process.env.REDIS_URL);
|
const serviceEventsQueue = new Queue('service events', process.env.REDIS_URL);
|
||||||
|
|
183
server/index.js
183
server/index.js
|
@ -1,128 +1,81 @@
|
||||||
// @flow
|
// @flow
|
||||||
import compress from 'koa-compress';
|
import http from 'http';
|
||||||
import { contentSecurityPolicy } from 'koa-helmet';
|
import IO from 'socket.io';
|
||||||
import logger from 'koa-logger';
|
import SocketAuth from 'socketio-auth';
|
||||||
import mount from 'koa-mount';
|
import socketRedisAdapter from 'socket.io-redis';
|
||||||
import enforceHttps from 'koa-sslify';
|
import { getUserForJWT } from './utils/jwt';
|
||||||
import Koa from 'koa';
|
import { Collection } from './models';
|
||||||
import bugsnag from 'bugsnag';
|
import app from './app';
|
||||||
import onerror from 'koa-onerror';
|
import policy from './policies';
|
||||||
import updates from './utils/updates';
|
|
||||||
|
|
||||||
import auth from './auth';
|
const server = http.createServer(app.callback());
|
||||||
import api from './api';
|
let io;
|
||||||
import emails from './emails';
|
|
||||||
import routes from './routes';
|
|
||||||
|
|
||||||
const app = new Koa();
|
if (process.env.WEBSOCKETS_ENABLED === 'true') {
|
||||||
|
const { can } = policy;
|
||||||
|
|
||||||
app.use(compress());
|
io = IO(server, {
|
||||||
|
path: '/realtime',
|
||||||
|
serveClient: false,
|
||||||
|
cookie: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
io.adapter(socketRedisAdapter(process.env.REDIS_URL));
|
||||||
/* eslint-disable global-require */
|
|
||||||
const convert = require('koa-convert');
|
|
||||||
const webpack = require('webpack');
|
|
||||||
const devMiddleware = require('koa-webpack-dev-middleware');
|
|
||||||
const hotMiddleware = require('koa-webpack-hot-middleware');
|
|
||||||
const config = require('../webpack.config.dev');
|
|
||||||
const compile = webpack(config);
|
|
||||||
/* eslint-enable global-require */
|
|
||||||
|
|
||||||
app.use(
|
SocketAuth(io, {
|
||||||
convert(
|
authenticate: async (socket, data, callback) => {
|
||||||
devMiddleware(compile, {
|
const { token } = data;
|
||||||
// display no info to console (only warnings and errors)
|
|
||||||
noInfo: true,
|
|
||||||
|
|
||||||
// display nothing to the console
|
try {
|
||||||
quiet: false,
|
const user = await getUserForJWT(token);
|
||||||
|
socket.client.user = user;
|
||||||
|
|
||||||
// switch into lazy mode
|
return callback(null, true);
|
||||||
// that means no watching, but recompilation on every request
|
} catch (err) {
|
||||||
lazy: false,
|
return callback(err);
|
||||||
|
|
||||||
// // watch options (only lazy: false)
|
|
||||||
// watchOptions: {
|
|
||||||
// aggregateTimeout: 300,
|
|
||||||
// poll: true
|
|
||||||
// },
|
|
||||||
|
|
||||||
// public path to bind the middleware to
|
|
||||||
// use the same as in webpack
|
|
||||||
publicPath: config.output.publicPath,
|
|
||||||
|
|
||||||
// options for formatting the statistics
|
|
||||||
stats: {
|
|
||||||
colors: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
app.use(
|
|
||||||
convert(
|
|
||||||
hotMiddleware(compile, {
|
|
||||||
log: console.log, // eslint-disable-line
|
|
||||||
path: '/__webpack_hmr',
|
|
||||||
heartbeat: 10 * 1000,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
app.use(logger());
|
|
||||||
|
|
||||||
app.use(mount('/emails', emails));
|
|
||||||
} else if (process.env.NODE_ENV === 'production') {
|
|
||||||
// Force HTTPS on all pages
|
|
||||||
app.use(
|
|
||||||
enforceHttps({
|
|
||||||
trustProtoHeader: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// trust header fields set by our proxy. eg X-Forwarded-For
|
|
||||||
app.proxy = true;
|
|
||||||
|
|
||||||
// catch errors in one place, automatically set status and response headers
|
|
||||||
onerror(app);
|
|
||||||
|
|
||||||
if (process.env.BUGSNAG_KEY) {
|
|
||||||
bugsnag.register(process.env.BUGSNAG_KEY, {
|
|
||||||
filters: ['authorization'],
|
|
||||||
});
|
|
||||||
app.on('error', (error, ctx) => {
|
|
||||||
// we don't need to report every time a request stops to the bug tracker
|
|
||||||
if (error.code === 'EPIPE' || error.code === 'ECONNRESET') {
|
|
||||||
console.warn('Connection error', { error });
|
|
||||||
} else {
|
|
||||||
bugsnag.koaHandler(error, ctx);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(mount('/auth', auth));
|
|
||||||
app.use(mount('/api', api));
|
|
||||||
app.use(mount(routes));
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
contentSecurityPolicy({
|
|
||||||
directives: {
|
|
||||||
defaultSrc: ["'self'"],
|
|
||||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
||||||
},
|
},
|
||||||
})
|
postAuthenticate: async (socket, data) => {
|
||||||
);
|
const { user } = socket.client;
|
||||||
|
// join the rooms associated with the current team
|
||||||
|
// and user so we can send authenticated events
|
||||||
|
socket.join(user.teamId);
|
||||||
|
socket.join(user.id);
|
||||||
|
|
||||||
/**
|
// join rooms associated with collections this user
|
||||||
* Production updates and anonymous analytics.
|
// has access to on connection. New collection subscriptions
|
||||||
*
|
// are managed from the client as needed
|
||||||
* Set ENABLE_UPDATES=false to disable them for your installation
|
const collectionIds = await user.collectionIds();
|
||||||
*/
|
collectionIds.forEach(collectionId => socket.join(collectionId));
|
||||||
if (
|
|
||||||
process.env.ENABLE_UPDATES !== 'false' &&
|
// allow the client to request to join rooms based on
|
||||||
process.env.NODE_ENV === 'production'
|
// new collections being created.
|
||||||
) {
|
socket.on('join', async event => {
|
||||||
updates();
|
const collection = await Collection.findById(event.roomId);
|
||||||
setInterval(updates, 24 * 3600 * 1000);
|
|
||||||
|
if (can(user, 'read', collection)) {
|
||||||
|
socket.join(event.roomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('leave', event => {
|
||||||
|
socket.leave(event.roomId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default app;
|
server.on('error', err => {
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('listening', () => {
|
||||||
|
const address = server.address();
|
||||||
|
console.log(`\n> Listening on http://localhost:${address.port}\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(process.env.PORT || '3000');
|
||||||
|
|
||||||
|
export const socketio = io;
|
||||||
|
|
||||||
|
export default server;
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import JWT from 'jsonwebtoken';
|
import JWT from 'jsonwebtoken';
|
||||||
import { type Context } from 'koa';
|
import { type Context } from 'koa';
|
||||||
import { User, ApiKey } from '../models';
|
import { User, ApiKey } from '../models';
|
||||||
|
import { getUserForJWT } from '../utils/jwt';
|
||||||
import { AuthenticationError, UserSuspendedError } from '../errors';
|
import { AuthenticationError, UserSuspendedError } from '../errors';
|
||||||
import addMonths from 'date-fns/add_months';
|
import addMonths from 'date-fns/add_months';
|
||||||
import addMinutes from 'date-fns/add_minutes';
|
import addMinutes from 'date-fns/add_minutes';
|
||||||
|
@ -60,23 +61,7 @@ export default function auth(options?: { required?: boolean } = {}) {
|
||||||
if (!user) throw new AuthenticationError('Invalid API key');
|
if (!user) throw new AuthenticationError('Invalid API key');
|
||||||
} else {
|
} else {
|
||||||
// JWT
|
// JWT
|
||||||
// Get user without verifying payload signature
|
user = await getUserForJWT(token);
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JWT.decode(token);
|
|
||||||
} catch (e) {
|
|
||||||
throw new AuthenticationError('Unable to decode JWT token');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload) throw new AuthenticationError('Invalid token');
|
|
||||||
|
|
||||||
user = await User.findById(payload.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
JWT.verify(token, user.jwtSecret);
|
|
||||||
} catch (e) {
|
|
||||||
throw new AuthenticationError('Invalid token');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isSuspended) {
|
if (user.isSuspended) {
|
||||||
|
|
|
@ -4,7 +4,6 @@ import slug from 'slug';
|
||||||
import randomstring from 'randomstring';
|
import randomstring from 'randomstring';
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
import { asyncLock } from '../redis';
|
import { asyncLock } from '../redis';
|
||||||
import events from '../events';
|
|
||||||
import Document from './Document';
|
import Document from './Document';
|
||||||
import CollectionUser from './CollectionUser';
|
import CollectionUser from './CollectionUser';
|
||||||
import { welcomeMessage } from '../utils/onboarding';
|
import { welcomeMessage } from '../utils/onboarding';
|
||||||
|
@ -119,18 +118,6 @@ Collection.addHook('afterDestroy', async (model: Collection) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Collection.addHook('afterCreate', (model: Collection) =>
|
|
||||||
events.add({ name: 'collections.create', model })
|
|
||||||
);
|
|
||||||
|
|
||||||
Collection.addHook('afterDestroy', (model: Collection) =>
|
|
||||||
events.add({ name: 'collections.delete', model })
|
|
||||||
);
|
|
||||||
|
|
||||||
Collection.addHook('afterUpdate', (model: Collection) =>
|
|
||||||
events.add({ name: 'collections.update', model })
|
|
||||||
);
|
|
||||||
|
|
||||||
Collection.addHook('afterCreate', (model: Collection, options) => {
|
Collection.addHook('afterCreate', (model: Collection, options) => {
|
||||||
if (model.private) {
|
if (model.private) {
|
||||||
return CollectionUser.findOrCreate({
|
return CollectionUser.findOrCreate({
|
||||||
|
|
|
@ -10,7 +10,6 @@ import removeMarkdown from '@tommoor/remove-markdown';
|
||||||
import isUUID from 'validator/lib/isUUID';
|
import isUUID from 'validator/lib/isUUID';
|
||||||
import { Collection, User } from '../models';
|
import { Collection, User } from '../models';
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
import events from '../events';
|
|
||||||
import parseTitle from '../../shared/utils/parseTitle';
|
import parseTitle from '../../shared/utils/parseTitle';
|
||||||
import unescape from '../../shared/utils/unescape';
|
import unescape from '../../shared/utils/unescape';
|
||||||
import Revision from './Revision';
|
import Revision from './Revision';
|
||||||
|
@ -289,14 +288,9 @@ Document.addHook('afterCreate', async model => {
|
||||||
await collection.addDocumentToStructure(model);
|
await collection.addDocumentToStructure(model);
|
||||||
model.collection = collection;
|
model.collection = collection;
|
||||||
|
|
||||||
events.add({ name: 'documents.create', model });
|
|
||||||
return model;
|
return model;
|
||||||
});
|
});
|
||||||
|
|
||||||
Document.addHook('afterDestroy', model =>
|
|
||||||
events.add({ name: 'documents.delete', model })
|
|
||||||
);
|
|
||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
|
|
||||||
// Note: This method marks the document and it's children as deleted
|
// Note: This method marks the document and it's children as deleted
|
||||||
|
@ -353,7 +347,6 @@ Document.prototype.publish = async function() {
|
||||||
await this.save();
|
await this.save();
|
||||||
this.collection = collection;
|
this.collection = collection;
|
||||||
|
|
||||||
events.add({ name: 'documents.publish', model: this });
|
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -367,7 +360,6 @@ Document.prototype.archive = async function(userId) {
|
||||||
|
|
||||||
await this.archiveWithChildren(userId);
|
await this.archiveWithChildren(userId);
|
||||||
|
|
||||||
events.add({ name: 'documents.archive', model: this });
|
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -397,7 +389,6 @@ Document.prototype.unarchive = async function(userId) {
|
||||||
this.lastModifiedById = userId;
|
this.lastModifiedById = userId;
|
||||||
await this.save();
|
await this.save();
|
||||||
|
|
||||||
events.add({ name: 'documents.unarchive', model: this });
|
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -417,7 +408,6 @@ Document.prototype.delete = function(options) {
|
||||||
|
|
||||||
await this.destroy({ transaction, ...options });
|
await this.destroy({ transaction, ...options });
|
||||||
|
|
||||||
events.add({ name: 'documents.delete', model: this });
|
|
||||||
return this;
|
return this;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
import events from '../events';
|
|
||||||
|
|
||||||
const Integration = sequelize.define('integration', {
|
const Integration = sequelize.define('integration', {
|
||||||
id: {
|
id: {
|
||||||
|
@ -33,16 +32,4 @@ Integration.associate = models => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
Integration.addHook('afterCreate', async model => {
|
|
||||||
events.add({ name: 'integrations.create', model });
|
|
||||||
});
|
|
||||||
|
|
||||||
Integration.addHook('afterUpdate', model =>
|
|
||||||
events.add({ name: 'integrations.update', model })
|
|
||||||
);
|
|
||||||
|
|
||||||
Integration.addHook('afterDestroy', model =>
|
|
||||||
events.add({ name: 'integrations.delete', model })
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Integration;
|
export default Integration;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
import { groupBy, map } from 'lodash';
|
import { groupBy, map } from 'lodash';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
@ -25,6 +26,14 @@ function Changelog({ releases }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid>
|
<Grid>
|
||||||
|
<Helmet>
|
||||||
|
<link
|
||||||
|
rel="alternate"
|
||||||
|
type="application/atom+xml"
|
||||||
|
title="Release Notes"
|
||||||
|
href="https://github.com/outline/outline/releases.atom"
|
||||||
|
/>
|
||||||
|
</Helmet>
|
||||||
<PageTitle title="Changelog" />
|
<PageTitle title="Changelog" />
|
||||||
<Header background="#00ADFF">
|
<Header background="#00ADFF">
|
||||||
<h1>Changelog</h1>
|
<h1>Changelog</h1>
|
||||||
|
|
|
@ -265,11 +265,11 @@ export default function Pricing() {
|
||||||
This method allows you to publish a new document under an existing
|
This method allows you to publish a new document under an existing
|
||||||
collection. By default a document is set to the parent collection
|
collection. By default a document is set to the parent collection
|
||||||
root. If you want to create a subdocument, you can pass{' '}
|
root. If you want to create a subdocument, you can pass{' '}
|
||||||
<Code>parentDocument</Code> to set parent document.
|
<Code>parentDocumentId</Code> to set parent document.
|
||||||
</Description>
|
</Description>
|
||||||
<Arguments>
|
<Arguments>
|
||||||
<Argument
|
<Argument
|
||||||
id="collection"
|
id="collectionId"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
<Code>ID</Code> of the collection to which the document is
|
<Code>ID</Code> of the collection to which the document is
|
||||||
|
@ -289,7 +289,7 @@ export default function Pricing() {
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Argument
|
<Argument
|
||||||
id="parentDocument"
|
id="parentDocumentId"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
<Code>ID</Code> of the parent document within the collection
|
<Code>ID</Code> of the parent document within the collection
|
||||||
|
|
|
@ -18,8 +18,9 @@ allow(
|
||||||
if (
|
if (
|
||||||
collection.private &&
|
collection.private &&
|
||||||
!map(collection.users, u => u.id).includes(user.id)
|
!map(collection.users, u => u.id).includes(user.id)
|
||||||
)
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -28,8 +29,12 @@ allow(
|
||||||
allow(User, 'delete', Collection, (user, collection) => {
|
allow(User, 'delete', Collection, (user, collection) => {
|
||||||
if (!collection || user.teamId !== collection.teamId) return false;
|
if (!collection || user.teamId !== collection.teamId) return false;
|
||||||
|
|
||||||
if (collection.private && !map(collection.users, u => u.id).includes(user.id))
|
if (
|
||||||
|
collection.private &&
|
||||||
|
!map(collection.users, u => u.id).includes(user.id)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (user.isAdmin) return true;
|
if (user.isAdmin) return true;
|
||||||
if (user.id === collection.creatorId) return true;
|
if (user.id === collection.creatorId) return true;
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { type Context } from 'koa';
|
|
||||||
import { ApiKey } from '../models';
|
import { ApiKey } from '../models';
|
||||||
|
|
||||||
function present(ctx: Context, key: ApiKey) {
|
export default function present(key: ApiKey) {
|
||||||
return {
|
return {
|
||||||
id: key.id,
|
id: key.id,
|
||||||
name: key.name,
|
name: key.name,
|
||||||
secret: key.secret,
|
secret: key.secret,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -18,9 +18,7 @@ const sortDocuments = (documents: Document[]): Document[] => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
async function present(ctx: Object, collection: Collection) {
|
export default function present(collection: Collection) {
|
||||||
ctx.cache.set(collection.id, collection);
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
id: collection.id,
|
id: collection.id,
|
||||||
url: collection.url,
|
url: collection.url,
|
||||||
|
@ -31,6 +29,7 @@ async function present(ctx: Object, collection: Collection) {
|
||||||
private: collection.private,
|
private: collection.private,
|
||||||
createdAt: collection.createdAt,
|
createdAt: collection.createdAt,
|
||||||
updatedAt: collection.updatedAt,
|
updatedAt: collection.updatedAt,
|
||||||
|
deletedAt: collection.deletedAt,
|
||||||
documents: undefined,
|
documents: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -41,5 +40,3 @@ async function present(ctx: Object, collection: Collection) {
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -2,18 +2,16 @@
|
||||||
import { takeRight } from 'lodash';
|
import { takeRight } from 'lodash';
|
||||||
import { User, Document } from '../models';
|
import { User, Document } from '../models';
|
||||||
import presentUser from './user';
|
import presentUser from './user';
|
||||||
import presentCollection from './collection';
|
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
isPublic?: boolean,
|
isPublic?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function present(ctx: Object, document: Document, options: ?Options) {
|
export default async function present(document: Document, options: ?Options) {
|
||||||
options = {
|
options = {
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
...options,
|
...options,
|
||||||
};
|
};
|
||||||
ctx.cache.set(document.id, document);
|
|
||||||
|
|
||||||
// For empty document content, return the title
|
// For empty document content, return the title
|
||||||
if (!document.text.trim()) {
|
if (!document.text.trim()) {
|
||||||
|
@ -36,32 +34,27 @@ async function present(ctx: Object, document: Document, options: ?Options) {
|
||||||
deletedAt: document.deletedAt,
|
deletedAt: document.deletedAt,
|
||||||
team: document.teamId,
|
team: document.teamId,
|
||||||
collaborators: [],
|
collaborators: [],
|
||||||
starred: !!(document.starred && document.starred.length),
|
starred: document.starred ? !!document.starred.length : undefined,
|
||||||
revision: document.revisionCount,
|
revision: document.revisionCount,
|
||||||
pinned: undefined,
|
pinned: undefined,
|
||||||
collectionId: undefined,
|
collectionId: undefined,
|
||||||
collection: undefined,
|
parentDocumentId: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!options.isPublic) {
|
if (!options.isPublic) {
|
||||||
data.pinned = !!document.pinnedById;
|
data.pinned = !!document.pinnedById;
|
||||||
data.collectionId = document.collectionId;
|
data.collectionId = document.collectionId;
|
||||||
data.createdBy = presentUser(ctx, document.createdBy);
|
data.parentDocumentId = document.parentDocumentId;
|
||||||
data.updatedBy = presentUser(ctx, document.updatedBy);
|
data.createdBy = presentUser(document.createdBy);
|
||||||
|
data.updatedBy = presentUser(document.updatedBy);
|
||||||
|
|
||||||
if (document.collection) {
|
// TODO: This could be further optimized
|
||||||
data.collection = await presentCollection(ctx, document.collection);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This could be further optimized by using ctx.cache
|
|
||||||
data.collaborators = await User.findAll({
|
data.collaborators = await User.findAll({
|
||||||
where: {
|
where: {
|
||||||
id: takeRight(document.collaboratorIds, 10) || [],
|
id: takeRight(document.collaboratorIds, 10) || [],
|
||||||
},
|
},
|
||||||
}).map(user => presentUser(ctx, user));
|
}).map(presentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { Integration } from '../models';
|
import { Integration } from '../models';
|
||||||
|
|
||||||
function present(ctx: Object, integration: Integration) {
|
export default function present(integration: Integration) {
|
||||||
return {
|
return {
|
||||||
id: integration.id,
|
id: integration.id,
|
||||||
type: integration.type,
|
type: integration.type,
|
||||||
|
@ -16,5 +16,3 @@ function present(ctx: Object, integration: Integration) {
|
||||||
updatedAt: integration.updatedAt,
|
updatedAt: integration.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
// @flow
|
// @flow
|
||||||
import type { Context } from 'koa';
|
|
||||||
import { NotificationSetting } from '../models';
|
import { NotificationSetting } from '../models';
|
||||||
|
|
||||||
function present(ctx: Context, setting: NotificationSetting) {
|
export default function present(setting: NotificationSetting) {
|
||||||
return {
|
return {
|
||||||
id: setting.id,
|
id: setting.id,
|
||||||
event: setting.event,
|
event: setting.event,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -2,15 +2,13 @@
|
||||||
import { Revision } from '../models';
|
import { Revision } from '../models';
|
||||||
import presentUser from './user';
|
import presentUser from './user';
|
||||||
|
|
||||||
function present(ctx: Object, revision: Revision) {
|
export default function present(revision: Revision) {
|
||||||
return {
|
return {
|
||||||
id: revision.id,
|
id: revision.id,
|
||||||
documentId: revision.documentId,
|
documentId: revision.documentId,
|
||||||
title: revision.title,
|
title: revision.title,
|
||||||
text: revision.text,
|
text: revision.text,
|
||||||
createdAt: revision.createdAt,
|
createdAt: revision.createdAt,
|
||||||
createdBy: presentUser(ctx, revision.user),
|
createdBy: presentUser(revision.user),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -2,16 +2,14 @@
|
||||||
import { Share } from '../models';
|
import { Share } from '../models';
|
||||||
import { presentUser } from '.';
|
import { presentUser } from '.';
|
||||||
|
|
||||||
function present(ctx: Object, share: Share) {
|
export default function present(share: Share) {
|
||||||
return {
|
return {
|
||||||
id: share.id,
|
id: share.id,
|
||||||
documentTitle: share.document.title,
|
documentTitle: share.document.title,
|
||||||
documentUrl: share.document.url,
|
documentUrl: share.document.url,
|
||||||
url: `${process.env.URL}/share/${share.id}`,
|
url: `${process.env.URL}/share/${share.id}`,
|
||||||
createdBy: presentUser(ctx, share.user),
|
createdBy: presentUser(share.user),
|
||||||
createdAt: share.createdAt,
|
createdAt: share.createdAt,
|
||||||
updatedAt: share.updatedAt,
|
updatedAt: share.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ type Action = {
|
||||||
value: string,
|
value: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
function present(
|
export default function present(
|
||||||
document: Document,
|
document: Document,
|
||||||
team: Team,
|
team: Team,
|
||||||
context?: string,
|
context?: string,
|
||||||
|
@ -31,5 +31,3 @@ function present(
|
||||||
actions,
|
actions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -1,9 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { Team } from '../models';
|
import { Team } from '../models';
|
||||||
|
|
||||||
function present(ctx: Object, team: Team) {
|
export default function present(team: Team) {
|
||||||
ctx.cache.set(team.id, team);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
|
@ -16,5 +14,3 @@ function present(ctx: Object, team: Team) {
|
||||||
url: team.url,
|
url: team.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -14,11 +14,7 @@ type UserPresentation = {
|
||||||
isSuspended: boolean,
|
isSuspended: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default (
|
export default (user: User, options: Options = {}): ?UserPresentation => {
|
||||||
ctx: Object,
|
|
||||||
user: User,
|
|
||||||
options: Options = {}
|
|
||||||
): ?UserPresentation => {
|
|
||||||
const userData = {};
|
const userData = {};
|
||||||
userData.id = user.id;
|
userData.id = user.id;
|
||||||
userData.createdAt = user.createdAt;
|
userData.createdAt = user.createdAt;
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import presentUser from './user';
|
import presentUser from './user';
|
||||||
import ctx from '../../__mocks__/ctx';
|
|
||||||
|
|
||||||
it('presents a user', async () => {
|
it('presents a user', async () => {
|
||||||
const user = await presentUser(ctx, {
|
const user = await presentUser({
|
||||||
id: '123',
|
id: '123',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
|
@ -16,7 +15,7 @@ it('presents a user', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('presents a user without slack data', async () => {
|
it('presents a user without slack data', async () => {
|
||||||
const user = await presentUser(ctx, {
|
const user = await presentUser({
|
||||||
id: '123',
|
id: '123',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
|
|
|
@ -2,15 +2,13 @@
|
||||||
import { View } from '../models';
|
import { View } from '../models';
|
||||||
import { presentUser } from '../presenters';
|
import { presentUser } from '../presenters';
|
||||||
|
|
||||||
function present(ctx: Object, view: View) {
|
export default function present(view: View) {
|
||||||
return {
|
return {
|
||||||
id: view.id,
|
id: view.id,
|
||||||
documentId: view.documentId,
|
documentId: view.documentId,
|
||||||
count: view.count,
|
count: view.count,
|
||||||
firstViewedAt: view.createdAt,
|
firstViewedAt: view.createdAt,
|
||||||
lastViewedAt: view.updatedAt,
|
lastViewedAt: view.updatedAt,
|
||||||
user: presentUser(ctx, view.user),
|
user: presentUser(view.user),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default present;
|
|
||||||
|
|
|
@ -146,11 +146,12 @@ router.get('/', async ctx => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Other
|
|
||||||
router.get('/robots.txt', ctx => (ctx.body = robotsResponse(ctx)));
|
router.get('/robots.txt', ctx => (ctx.body = robotsResponse(ctx)));
|
||||||
|
|
||||||
// catch all for react app
|
// catch all for react app
|
||||||
router.get('*', async ctx => {
|
router.get('*', async (ctx, next) => {
|
||||||
|
if (ctx.request.path === '/realtime/') return next();
|
||||||
|
|
||||||
await renderapp(ctx);
|
await renderapp(ctx);
|
||||||
if (!ctx.status) ctx.throw(new NotFoundError());
|
if (!ctx.status) ctx.throw(new NotFoundError());
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '.';
|
import app from './app';
|
||||||
import { flushdb } from './test/support';
|
import { flushdb } from './test/support';
|
||||||
|
|
||||||
const server = new TestServer(app.callback());
|
const server = new TestServer(app.callback());
|
||||||
|
|
|
@ -17,7 +17,7 @@ export default class Notifications {
|
||||||
}
|
}
|
||||||
|
|
||||||
async documentUpdated(event: Event) {
|
async documentUpdated(event: Event) {
|
||||||
const document = await Document.findById(event.model.id);
|
const document = await Document.findById(event.modelId);
|
||||||
if (!document) return;
|
if (!document) return;
|
||||||
|
|
||||||
const { collection } = document;
|
const { collection } = document;
|
||||||
|
@ -67,7 +67,7 @@ export default class Notifications {
|
||||||
}
|
}
|
||||||
|
|
||||||
async collectionCreated(event: Event) {
|
async collectionCreated(event: Event) {
|
||||||
const collection = await Collection.findById(event.model.id, {
|
const collection = await Collection.findById(event.modelId, {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: User,
|
model: User,
|
||||||
|
|
|
@ -18,7 +18,7 @@ export default class Slack {
|
||||||
async integrationCreated(event: Event) {
|
async integrationCreated(event: Event) {
|
||||||
const integration = await Integration.findOne({
|
const integration = await Integration.findOne({
|
||||||
where: {
|
where: {
|
||||||
id: event.model.id,
|
id: event.modelId,
|
||||||
service: 'slack',
|
service: 'slack',
|
||||||
type: 'post',
|
type: 'post',
|
||||||
},
|
},
|
||||||
|
@ -57,9 +57,12 @@ export default class Slack {
|
||||||
}
|
}
|
||||||
|
|
||||||
async documentUpdated(event: Event) {
|
async documentUpdated(event: Event) {
|
||||||
const document = await Document.findById(event.model.id);
|
const document = await Document.findById(event.modelId);
|
||||||
if (!document) return;
|
if (!document) return;
|
||||||
|
|
||||||
|
// never send information on draft documents
|
||||||
|
if (!document.publishedAt) return;
|
||||||
|
|
||||||
const integration = await Integration.findOne({
|
const integration = await Integration.findOne({
|
||||||
where: {
|
where: {
|
||||||
teamId: document.teamId,
|
teamId: document.teamId,
|
||||||
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
// @flow
|
||||||
|
import type { Event } from '../events';
|
||||||
|
import { Document, Collection } from '../models';
|
||||||
|
import { presentDocument, presentCollection } from '../presenters';
|
||||||
|
import { socketio } from '../';
|
||||||
|
|
||||||
|
export default class Websockets {
|
||||||
|
async on(event: Event) {
|
||||||
|
if (process.env.WEBSOCKETS_ENABLED !== 'true' || !socketio) return;
|
||||||
|
|
||||||
|
switch (event.name) {
|
||||||
|
case 'documents.publish':
|
||||||
|
case 'documents.restore':
|
||||||
|
case 'documents.archive':
|
||||||
|
case 'documents.unarchive':
|
||||||
|
case 'documents.pin':
|
||||||
|
case 'documents.unpin':
|
||||||
|
case 'documents.update':
|
||||||
|
case 'documents.delete': {
|
||||||
|
const document = await Document.findById(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return socketio.to(document.collectionId).emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
documents: [await presentDocument(document)],
|
||||||
|
collections: [await presentCollection(document.collection)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'documents.create': {
|
||||||
|
const document = await Document.findById(event.modelId);
|
||||||
|
|
||||||
|
return socketio.to(event.actorId).emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
documents: [await presentDocument(document)],
|
||||||
|
collections: [await presentCollection(document.collection)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'documents.star':
|
||||||
|
case 'documents.unstar': {
|
||||||
|
return socketio.to(event.actorId).emit(event.name, {
|
||||||
|
documentId: event.modelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'documents.move': {
|
||||||
|
const documents = await Document.findAll({
|
||||||
|
where: {
|
||||||
|
id: event.documentIds,
|
||||||
|
},
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
const collections = await Collection.findAll({
|
||||||
|
where: {
|
||||||
|
id: event.collectionIds,
|
||||||
|
},
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
documents.forEach(async document => {
|
||||||
|
socketio.to(document.collectionId).emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
documents: [await presentDocument(document)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
collections.forEach(async collection => {
|
||||||
|
socketio.to(collection.id).emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
collections: [await presentCollection(collection)],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'collections.create': {
|
||||||
|
const collection = await Collection.findById(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
socketio
|
||||||
|
.to(collection.private ? collection.id : collection.teamId)
|
||||||
|
.emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
collections: [await presentCollection(collection)],
|
||||||
|
});
|
||||||
|
return socketio
|
||||||
|
.to(collection.private ? collection.id : collection.teamId)
|
||||||
|
.emit('join', {
|
||||||
|
event: event.name,
|
||||||
|
roomId: collection.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'collections.update':
|
||||||
|
case 'collections.delete': {
|
||||||
|
const collection = await Collection.findById(event.modelId, {
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return socketio.to(collection.id).emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
collections: [await presentCollection(collection)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
case 'collections.add_user':
|
||||||
|
return socketio.to(event.modelId).emit('join', {
|
||||||
|
event: event.name,
|
||||||
|
roomId: event.collectionId,
|
||||||
|
});
|
||||||
|
case 'collections.remove_user':
|
||||||
|
return socketio.to(event.modelId).emit('leave', {
|
||||||
|
event: event.name,
|
||||||
|
roomId: event.collectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
/* global jest */
|
||||||
require('dotenv').config({ silent: true });
|
require('dotenv').config({ silent: true });
|
||||||
|
|
||||||
// test environment variables
|
// test environment variables
|
||||||
|
@ -26,3 +27,7 @@ function runMigrations() {
|
||||||
}
|
}
|
||||||
|
|
||||||
runMigrations();
|
runMigrations();
|
||||||
|
|
||||||
|
// This is needed for the relative manual mock to be picked up
|
||||||
|
// $FlowFixMe
|
||||||
|
jest.mock('../events');
|
||||||
|
|
|
@ -75,10 +75,12 @@ const seed = async () => {
|
||||||
text: '# Much guidance',
|
text: '# Much guidance',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await collection.reload();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
admin,
|
admin,
|
||||||
collection: document.collection,
|
collection,
|
||||||
document,
|
document,
|
||||||
team,
|
team,
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
// @flow
|
||||||
|
import JWT from 'jsonwebtoken';
|
||||||
|
import { AuthenticationError } from '../errors';
|
||||||
|
import { User } from '../models';
|
||||||
|
|
||||||
|
export async function getUserForJWT(token: string) {
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JWT.decode(token);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AuthenticationError('Unable to decode JWT token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload) throw new AuthenticationError('Invalid token');
|
||||||
|
|
||||||
|
const user = await User.findById(payload.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
JWT.verify(token, user.jwtSecret);
|
||||||
|
} catch (err) {
|
||||||
|
throw new AuthenticationError('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
|
@ -24,7 +24,7 @@ Outline features an [API](https://www.getoutline.com/developers) for programatic
|
||||||
const newDocument = {
|
const newDocument = {
|
||||||
title: 'Getting started with codebase',
|
title: 'Getting started with codebase',
|
||||||
text: 'All the information needed in Markdown',
|
text: 'All the information needed in Markdown',
|
||||||
collection: '${collectionId}',
|
collectionId: '${collectionId}',
|
||||||
token: 'API_KEY', // Replace with a value from https://www.getoutline.com/settings/tokens
|
token: 'API_KEY', // Replace with a value from https://www.getoutline.com/settings/tokens
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -18,11 +18,10 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||||
const path = document.pathToDocument.slice(0, -1);
|
const collection = collections.get(document.collectionId);
|
||||||
if (!document.collection) return null;
|
if (!collection) return null;
|
||||||
|
|
||||||
const collection =
|
const path = collection.pathToDocument(document).slice(0, -1);
|
||||||
collections.data.get(document.collection.id) || document.collection;
|
|
||||||
|
|
||||||
if (onlyText === true) {
|
if (onlyText === true) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -17,7 +17,8 @@ const definePlugin = new webpack.DefinePlugin({
|
||||||
'process.env': {
|
'process.env': {
|
||||||
URL: JSON.stringify(process.env.URL),
|
URL: JSON.stringify(process.env.URL),
|
||||||
SLACK_KEY: JSON.stringify(process.env.SLACK_KEY),
|
SLACK_KEY: JSON.stringify(process.env.SLACK_KEY),
|
||||||
SUBDOMAINS_ENABLED: JSON.stringify(process.env.SUBDOMAINS_ENABLED === 'true')
|
SUBDOMAINS_ENABLED: JSON.stringify(process.env.SUBDOMAINS_ENABLED === 'true'),
|
||||||
|
WEBSOCKETS_ENABLED: JSON.stringify(process.env.WEBSOCKETS_ENABLED === 'true')
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,7 @@ productionWebpackConfig.plugins = [
|
||||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||||
'process.env.GOOGLE_ANALYTICS_ID': JSON.stringify(process.env.GOOGLE_ANALYTICS_ID),
|
'process.env.GOOGLE_ANALYTICS_ID': JSON.stringify(process.env.GOOGLE_ANALYTICS_ID),
|
||||||
'process.env.SUBDOMAINS_ENABLED': JSON.stringify(process.env.SUBDOMAINS_ENABLED === 'true'),
|
'process.env.SUBDOMAINS_ENABLED': JSON.stringify(process.env.SUBDOMAINS_ENABLED === 'true'),
|
||||||
|
'process.env.WEBSOCKETS_ENABLED': JSON.stringify(process.env.WEBSOCKETS_ENABLED === 'true'),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
208
yarn.lock
208
yarn.lock
|
@ -175,7 +175,7 @@ abort-controller@^2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim "^5.0.0"
|
event-target-shim "^5.0.0"
|
||||||
|
|
||||||
accepts@^1.3.5:
|
accepts@^1.3.5, accepts@~1.3.4:
|
||||||
version "1.3.5"
|
version "1.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
|
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -215,6 +215,10 @@ acorn@^6.0.1, acorn@^6.0.7:
|
||||||
version "6.1.1"
|
version "6.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f"
|
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f"
|
||||||
|
|
||||||
|
after@0.8.2:
|
||||||
|
version "0.8.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
|
||||||
|
|
||||||
agent-base@4, agent-base@^4.1.0:
|
agent-base@4, agent-base@^4.1.0:
|
||||||
version "4.2.1"
|
version "4.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"
|
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"
|
||||||
|
@ -388,6 +392,10 @@ array-unique@^0.3.2:
|
||||||
version "0.3.2"
|
version "0.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
|
resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
|
||||||
|
|
||||||
|
arraybuffer.slice@~0.0.7:
|
||||||
|
version "0.0.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
|
||||||
|
|
||||||
arrify@^1.0.1:
|
arrify@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
|
resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
|
||||||
|
@ -1192,6 +1200,10 @@ babylon@^6.18.0:
|
||||||
version "6.18.0"
|
version "6.18.0"
|
||||||
resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
|
resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
|
||||||
|
|
||||||
|
backo2@1.0.2:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
|
||||||
|
|
||||||
bail@^1.0.0:
|
bail@^1.0.0:
|
||||||
version "1.0.3"
|
version "1.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.3.tgz#63cfb9ddbac829b02a3128cd53224be78e6c21a3"
|
resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.3.tgz#63cfb9ddbac829b02a3128cd53224be78e6c21a3"
|
||||||
|
@ -1204,10 +1216,18 @@ balanced-match@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
|
||||||
|
|
||||||
|
base64-arraybuffer@0.1.5:
|
||||||
|
version "0.1.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
|
||||||
|
|
||||||
base64-js@^1.0.2:
|
base64-js@^1.0.2:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"
|
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"
|
||||||
|
|
||||||
|
base64id@1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
|
||||||
|
|
||||||
base@^0.11.1:
|
base@^0.11.1:
|
||||||
version "0.11.2"
|
version "0.11.2"
|
||||||
resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
|
resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
|
||||||
|
@ -1230,6 +1250,12 @@ before-after-hook@^1.1.0:
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.3.2.tgz#7bfbf844ad670aa7a96b5a4e4e15bd74b08ed66b"
|
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.3.2.tgz#7bfbf844ad670aa7a96b5a4e4e15bd74b08ed66b"
|
||||||
|
|
||||||
|
better-assert@~1.0.0:
|
||||||
|
version "1.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
|
||||||
|
dependencies:
|
||||||
|
callsite "1.0.0"
|
||||||
|
|
||||||
big-integer@^1.6.17:
|
big-integer@^1.6.17:
|
||||||
version "1.6.42"
|
version "1.6.42"
|
||||||
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.42.tgz#91623ae5ceeff9a47416c56c9440a66f12f534f1"
|
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.42.tgz#91623ae5ceeff9a47416c56c9440a66f12f534f1"
|
||||||
|
@ -1253,6 +1279,10 @@ binary@~0.3.0:
|
||||||
buffers "~0.1.1"
|
buffers "~0.1.1"
|
||||||
chainsaw "~0.1.0"
|
chainsaw "~0.1.0"
|
||||||
|
|
||||||
|
blob@0.0.5:
|
||||||
|
version "0.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
|
||||||
|
|
||||||
bluebird@^3.3.5, bluebird@^3.4.6, bluebird@^3.5.1, bluebird@^3.5.3:
|
bluebird@^3.3.5, bluebird@^3.4.6, bluebird@^3.5.1, bluebird@^3.5.3:
|
||||||
version "3.5.3"
|
version "3.5.3"
|
||||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
|
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
|
||||||
|
@ -1565,6 +1595,10 @@ cache-content-type@^1.0.0:
|
||||||
mime-types "^2.1.18"
|
mime-types "^2.1.18"
|
||||||
ylru "^1.2.0"
|
ylru "^1.2.0"
|
||||||
|
|
||||||
|
callsite@1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
|
||||||
|
|
||||||
callsites@^2.0.0:
|
callsites@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50"
|
resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50"
|
||||||
|
@ -1986,10 +2020,18 @@ commondir@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
|
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
|
||||||
|
|
||||||
component-emitter@^1.2.1:
|
component-bind@1.0.0:
|
||||||
|
version "1.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
|
||||||
|
|
||||||
|
component-emitter@1.2.1, component-emitter@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
|
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
|
||||||
|
|
||||||
|
component-inherit@0.0.3:
|
||||||
|
version "0.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
|
||||||
|
|
||||||
compressible@^2.0.0:
|
compressible@^2.0.0:
|
||||||
version "2.0.16"
|
version "2.0.16"
|
||||||
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.16.tgz#a49bf9858f3821b64ce1be0296afc7380466a77f"
|
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.16.tgz#a49bf9858f3821b64ce1be0296afc7380466a77f"
|
||||||
|
@ -2084,6 +2126,10 @@ convert-source-map@^1.4.0, convert-source-map@^1.5.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "~5.1.1"
|
safe-buffer "~5.1.1"
|
||||||
|
|
||||||
|
cookie@0.3.1:
|
||||||
|
version "0.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
|
||||||
|
|
||||||
cookies@~0.7.1:
|
cookies@~0.7.1:
|
||||||
version "0.7.3"
|
version "0.7.3"
|
||||||
resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.7.3.tgz#7912ce21fbf2e8c2da70cf1c3f351aecf59dadfa"
|
resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.7.3.tgz#7912ce21fbf2e8c2da70cf1c3f351aecf59dadfa"
|
||||||
|
@ -2369,7 +2415,7 @@ date-now@^0.1.4:
|
||||||
version "0.1.4"
|
version "0.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
|
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
|
||||||
|
|
||||||
debug@*, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
|
debug@*, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@~4.1.0:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -2381,7 +2427,7 @@ debug@3.1.0, debug@~3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms "2.0.0"
|
ms "2.0.0"
|
||||||
|
|
||||||
debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.1, debug@^2.6.3, debug@^2.6.8, debug@^2.6.9:
|
debug@^2.1.2, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.1, debug@^2.6.3, debug@^2.6.8, debug@^2.6.9, debug@~2.6.8:
|
||||||
version "2.6.9"
|
version "2.6.9"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -2724,6 +2770,43 @@ ends-with@^0.2.0:
|
||||||
version "0.2.0"
|
version "0.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
|
resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
|
||||||
|
|
||||||
|
engine.io-client@~3.3.1:
|
||||||
|
version "3.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.2.tgz#04e068798d75beda14375a264bb3d742d7bc33aa"
|
||||||
|
dependencies:
|
||||||
|
component-emitter "1.2.1"
|
||||||
|
component-inherit "0.0.3"
|
||||||
|
debug "~3.1.0"
|
||||||
|
engine.io-parser "~2.1.1"
|
||||||
|
has-cors "1.1.0"
|
||||||
|
indexof "0.0.1"
|
||||||
|
parseqs "0.0.5"
|
||||||
|
parseuri "0.0.5"
|
||||||
|
ws "~6.1.0"
|
||||||
|
xmlhttprequest-ssl "~1.5.4"
|
||||||
|
yeast "0.1.2"
|
||||||
|
|
||||||
|
engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
|
||||||
|
version "2.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
|
||||||
|
dependencies:
|
||||||
|
after "0.8.2"
|
||||||
|
arraybuffer.slice "~0.0.7"
|
||||||
|
base64-arraybuffer "0.1.5"
|
||||||
|
blob "0.0.5"
|
||||||
|
has-binary2 "~1.0.2"
|
||||||
|
|
||||||
|
engine.io@~3.3.1:
|
||||||
|
version "3.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.3.2.tgz#18cbc8b6f36e9461c5c0f81df2b830de16058a59"
|
||||||
|
dependencies:
|
||||||
|
accepts "~1.3.4"
|
||||||
|
base64id "1.0.0"
|
||||||
|
cookie "0.3.1"
|
||||||
|
debug "~3.1.0"
|
||||||
|
engine.io-parser "~2.1.0"
|
||||||
|
ws "~6.1.0"
|
||||||
|
|
||||||
enhanced-resolve@^3.4.0:
|
enhanced-resolve@^3.4.0:
|
||||||
version "3.4.1"
|
version "3.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e"
|
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e"
|
||||||
|
@ -3809,6 +3892,16 @@ has-ansi@^2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex "^2.0.0"
|
ansi-regex "^2.0.0"
|
||||||
|
|
||||||
|
has-binary2@~1.0.2:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
|
||||||
|
dependencies:
|
||||||
|
isarray "2.0.1"
|
||||||
|
|
||||||
|
has-cors@1.1.0:
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
|
||||||
|
|
||||||
has-flag@^1.0.0:
|
has-flag@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
|
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
|
||||||
|
@ -4629,6 +4722,10 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
|
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
|
||||||
|
|
||||||
|
isarray@2.0.1:
|
||||||
|
version "2.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
|
||||||
|
|
||||||
isexe@^2.0.0:
|
isexe@^2.0.0:
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||||
|
@ -6141,6 +6238,10 @@ normalize-url@^1.4.0:
|
||||||
query-string "^4.1.0"
|
query-string "^4.1.0"
|
||||||
sort-keys "^1.0.0"
|
sort-keys "^1.0.0"
|
||||||
|
|
||||||
|
notepack.io@~2.1.2:
|
||||||
|
version "2.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/notepack.io/-/notepack.io-2.1.3.tgz#cc904045c751b1a27b2dcfd838d81d0bf3ced923"
|
||||||
|
|
||||||
npm-bundled@^1.0.1:
|
npm-bundled@^1.0.1:
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
|
resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
|
||||||
|
@ -6197,6 +6298,10 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||||
|
|
||||||
|
object-component@0.0.3:
|
||||||
|
version "0.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
|
||||||
|
|
||||||
object-copy@^0.1.0:
|
object-copy@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
|
resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
|
||||||
|
@ -6498,6 +6603,18 @@ parse5@4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
|
resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
|
||||||
|
|
||||||
|
parseqs@0.0.5:
|
||||||
|
version "0.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
|
||||||
|
dependencies:
|
||||||
|
better-assert "~1.0.0"
|
||||||
|
|
||||||
|
parseuri@0.0.5:
|
||||||
|
version "0.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
|
||||||
|
dependencies:
|
||||||
|
better-assert "~1.0.0"
|
||||||
|
|
||||||
parseurl@^1.3.2:
|
parseurl@^1.3.2:
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
|
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
|
||||||
|
@ -7469,7 +7586,7 @@ redis-parser@^3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
redis-errors "^1.0.0"
|
redis-errors "^1.0.0"
|
||||||
|
|
||||||
redis@^2.6.2:
|
redis@^2.6.2, redis@~2.8.0:
|
||||||
version "2.8.0"
|
version "2.8.0"
|
||||||
resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02"
|
resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -8208,6 +8325,65 @@ snapdragon@^0.8.1:
|
||||||
source-map-resolve "^0.5.0"
|
source-map-resolve "^0.5.0"
|
||||||
use "^3.1.0"
|
use "^3.1.0"
|
||||||
|
|
||||||
|
socket.io-adapter@~1.1.0:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"
|
||||||
|
|
||||||
|
socket.io-client@2.2.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7"
|
||||||
|
dependencies:
|
||||||
|
backo2 "1.0.2"
|
||||||
|
base64-arraybuffer "0.1.5"
|
||||||
|
component-bind "1.0.0"
|
||||||
|
component-emitter "1.2.1"
|
||||||
|
debug "~3.1.0"
|
||||||
|
engine.io-client "~3.3.1"
|
||||||
|
has-binary2 "~1.0.2"
|
||||||
|
has-cors "1.1.0"
|
||||||
|
indexof "0.0.1"
|
||||||
|
object-component "0.0.3"
|
||||||
|
parseqs "0.0.5"
|
||||||
|
parseuri "0.0.5"
|
||||||
|
socket.io-parser "~3.3.0"
|
||||||
|
to-array "0.1.4"
|
||||||
|
|
||||||
|
socket.io-parser@~3.3.0:
|
||||||
|
version "3.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
|
||||||
|
dependencies:
|
||||||
|
component-emitter "1.2.1"
|
||||||
|
debug "~3.1.0"
|
||||||
|
isarray "2.0.1"
|
||||||
|
|
||||||
|
socket.io-redis@^5.2.0:
|
||||||
|
version "5.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/socket.io-redis/-/socket.io-redis-5.2.0.tgz#8fe2ad9445fc50886fb70abc759d67403d5899df"
|
||||||
|
dependencies:
|
||||||
|
debug "~2.6.8"
|
||||||
|
notepack.io "~2.1.2"
|
||||||
|
redis "~2.8.0"
|
||||||
|
socket.io-adapter "~1.1.0"
|
||||||
|
uid2 "0.0.3"
|
||||||
|
|
||||||
|
socket.io@^2.2.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.2.0.tgz#f0f633161ef6712c972b307598ecd08c9b1b4d5b"
|
||||||
|
dependencies:
|
||||||
|
debug "~4.1.0"
|
||||||
|
engine.io "~3.3.1"
|
||||||
|
has-binary2 "~1.0.2"
|
||||||
|
socket.io-adapter "~1.1.0"
|
||||||
|
socket.io-client "2.2.0"
|
||||||
|
socket.io-parser "~3.3.0"
|
||||||
|
|
||||||
|
socketio-auth@^0.1.1:
|
||||||
|
version "0.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/socketio-auth/-/socketio-auth-0.1.1.tgz#03f1fdd9d9b5e10f0a0ea9502abadbc580015d71"
|
||||||
|
dependencies:
|
||||||
|
debug "^2.1.3"
|
||||||
|
lodash "^4.17.5"
|
||||||
|
|
||||||
sort-keys@^1.0.0:
|
sort-keys@^1.0.0:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
|
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
|
||||||
|
@ -8739,6 +8915,10 @@ tmpl@1.0.x:
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
|
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
|
||||||
|
|
||||||
|
to-array@0.1.4:
|
||||||
|
version "0.1.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
|
||||||
|
|
||||||
to-arraybuffer@^1.0.0:
|
to-arraybuffer@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
|
resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
|
||||||
|
@ -8947,6 +9127,10 @@ uglifyjs-webpack-plugin@^0.4.6:
|
||||||
uglify-js "^2.8.29"
|
uglify-js "^2.8.29"
|
||||||
webpack-sources "^1.0.1"
|
webpack-sources "^1.0.1"
|
||||||
|
|
||||||
|
uid2@0.0.3:
|
||||||
|
version "0.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82"
|
||||||
|
|
||||||
umzug@^2.1.0:
|
umzug@^2.1.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/umzug/-/umzug-2.2.0.tgz#6160bdc1817e4a63a625946775063c638623e62e"
|
resolved "https://registry.yarnpkg.com/umzug/-/umzug-2.2.0.tgz#6160bdc1817e4a63a625946775063c638623e62e"
|
||||||
|
@ -9514,6 +9698,12 @@ ws@^5.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
async-limiter "~1.0.0"
|
async-limiter "~1.0.0"
|
||||||
|
|
||||||
|
ws@~6.1.0:
|
||||||
|
version "6.1.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
|
||||||
|
dependencies:
|
||||||
|
async-limiter "~1.0.0"
|
||||||
|
|
||||||
x-is-string@^0.1.0:
|
x-is-string@^0.1.0:
|
||||||
version "0.1.0"
|
version "0.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82"
|
resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82"
|
||||||
|
@ -9545,6 +9735,10 @@ xmlbuilder@~9.0.1:
|
||||||
version "9.0.7"
|
version "9.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
|
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@~1.5.4:
|
||||||
|
version "1.5.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
|
||||||
|
|
||||||
xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
|
xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
|
||||||
version "4.0.1"
|
version "4.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
|
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
|
||||||
|
@ -9671,6 +9865,10 @@ yargs@~3.10.0:
|
||||||
decamelize "^1.0.0"
|
decamelize "^1.0.0"
|
||||||
window-size "0.1.0"
|
window-size "0.1.0"
|
||||||
|
|
||||||
|
yeast@0.1.2:
|
||||||
|
version "0.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
|
||||||
|
|
||||||
ylru@^1.2.0:
|
ylru@^1.2.0:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
|
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
|
||||||
|
|
Reference in New Issue