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
|
||||
ENABLE_UPDATES=true
|
||||
SUBDOMAINS_ENABLED=false
|
||||
WEBSOCKETS_ENABLED=true
|
||||
DEBUG=sql,cache,presenters,events
|
||||
|
||||
# Third party signin credentials (at least one is required)
|
||||
|
|
280
app.json
280
app.json
|
@ -1,143 +1,147 @@
|
|||
{
|
||||
"name": "Outline",
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
"quantity": 1,
|
||||
"size": "Hobby"
|
||||
}
|
||||
"name": "Outline",
|
||||
"description": "Open source wiki and knowledge base for growing teams",
|
||||
"website": "https://www.getoutline.com/",
|
||||
"repository": "https://github.com/outline/outline",
|
||||
"keywords": [
|
||||
"wiki",
|
||||
"team",
|
||||
"node",
|
||||
"markdown",
|
||||
"slack"
|
||||
],
|
||||
"success_url": "/",
|
||||
"formation": {
|
||||
"web": {
|
||||
"quantity": 1,
|
||||
"size": "Hobby"
|
||||
}
|
||||
},
|
||||
"image": "heroku/node",
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-redis"
|
||||
},
|
||||
"image": "heroku/node",
|
||||
"addons": [
|
||||
{
|
||||
"plan": "heroku-redis"
|
||||
},
|
||||
{
|
||||
"plan": "heroku-postgresql"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
{
|
||||
"plan": "heroku-postgresql"
|
||||
}
|
||||
],
|
||||
"scripts": {
|
||||
"postdeploy": "yarn sequelize db:migrate"
|
||||
},
|
||||
"env": {
|
||||
"SECRET_KEY": {
|
||||
"description": "A secret key",
|
||||
"generator": "secret",
|
||||
"required": true
|
||||
},
|
||||
"env": {
|
||||
"SECRET_KEY": {
|
||||
"description": "A secret key",
|
||||
"generator": "secret",
|
||||
"required": true
|
||||
},
|
||||
"DEPLOYMENT": {
|
||||
"description": "Should be 'self' for self hosted installations, turns off things like pricing pages",
|
||||
"value": "self",
|
||||
"required": true
|
||||
},
|
||||
"ENABLE_UPDATES": {
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"SUBDOMAINS_ENABLED": {
|
||||
"value": "false",
|
||||
"required": true,
|
||||
"description": "Allows each team to have a different subdomain. Not recommend when self hosting"
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com",
|
||||
"required": true
|
||||
},
|
||||
"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.",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_CLIENT_SECRET": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
|
||||
"required": false
|
||||
},
|
||||
"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.",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_SECRET": {
|
||||
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_VERIFICATION_TOKEN": {
|
||||
"description": "Your Slack verification token - PLxk6OlXXXXXVj3YYYY",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_APP_ID": {
|
||||
"description": "A0XXXXXXXXX",
|
||||
"required": false
|
||||
},
|
||||
"AWS_ACCESS_KEY_ID": {
|
||||
"description": "Needed to save file uploads. Optional for dev / testing.",
|
||||
"required": false
|
||||
},
|
||||
"AWS_SECRET_ACCESS_KEY": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_BUCKET_NAME": {
|
||||
"description": "yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_BUCKET_URL": {
|
||||
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_MAX_SIZE": {
|
||||
"description": "Maximum file upload size in bytes",
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_HOST": {
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PORT": {
|
||||
"description": "1234 (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_USERNAME": {
|
||||
"description": "me@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PASSWORD": {
|
||||
"description": "(optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_FROM_EMAIL": {
|
||||
"description": "wiki@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_REPLY_EMAIL": {
|
||||
"description": "wikireply@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "UA-xxxx (optional)",
|
||||
"required": false
|
||||
},
|
||||
"BUGSNAG_KEY": {
|
||||
"description": "An API key for bugsnag if you wish to collect error reporting (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GITHUB_ACCESS_TOKEN": {
|
||||
"description": "An API token for GitHub, optional for self hosted (optional)",
|
||||
"required": false
|
||||
}
|
||||
|
||||
"DEPLOYMENT": {
|
||||
"description": "Should be 'self' for self hosted installations, turns off things like pricing pages",
|
||||
"value": "self",
|
||||
"required": true
|
||||
},
|
||||
"ENABLE_UPDATES": {
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"SUBDOMAINS_ENABLED": {
|
||||
"value": "false",
|
||||
"required": true,
|
||||
"description": "Allows each team to have a different subdomain. Not recommend when self hosting"
|
||||
},
|
||||
"WEBSOCKETS_ENABLED": {
|
||||
"value": "true",
|
||||
"required": true,
|
||||
"description": "Allow realtime data to be pushed to clients over websockets"
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com",
|
||||
"required": true
|
||||
},
|
||||
"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.",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_CLIENT_SECRET": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
|
||||
"required": false
|
||||
},
|
||||
"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.",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_SECRET": {
|
||||
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_VERIFICATION_TOKEN": {
|
||||
"description": "Your Slack verification token - PLxk6OlXXXXXVj3YYYY",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_APP_ID": {
|
||||
"description": "A0XXXXXXXXX",
|
||||
"required": false
|
||||
},
|
||||
"AWS_ACCESS_KEY_ID": {
|
||||
"description": "Needed to save file uploads. Optional for dev / testing.",
|
||||
"required": false
|
||||
},
|
||||
"AWS_SECRET_ACCESS_KEY": {
|
||||
"description": "",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_BUCKET_NAME": {
|
||||
"description": "yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_BUCKET_URL": {
|
||||
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_UPLOAD_MAX_SIZE": {
|
||||
"description": "Maximum file upload size in bytes",
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_HOST": {
|
||||
"description": "smtp.example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PORT": {
|
||||
"description": "1234 (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_USERNAME": {
|
||||
"description": "me@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_PASSWORD": {
|
||||
"description": "(optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_FROM_EMAIL": {
|
||||
"description": "wiki@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_REPLY_EMAIL": {
|
||||
"description": "wikireply@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "UA-xxxx (optional)",
|
||||
"required": false
|
||||
},
|
||||
"BUGSNAG_KEY": {
|
||||
"description": "An API key for bugsnag if you wish to collect error reporting (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GITHUB_ACCESS_TOKEN": {
|
||||
"description": "An API token for GitHub, optional for self hosted (optional)",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@ type Props = {
|
|||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
link?: boolean,
|
||||
ref?: *,
|
||||
};
|
||||
|
||||
|
@ -141,7 +140,6 @@ class DocumentPreview extends React.Component<Props> {
|
|||
showPin,
|
||||
highlight,
|
||||
context,
|
||||
link,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
|
@ -151,15 +149,10 @@ class DocumentPreview extends React.Component<Props> {
|
|||
|
||||
return (
|
||||
<DocumentLink
|
||||
as={link === false ? 'div' : undefined}
|
||||
to={
|
||||
link === false
|
||||
? undefined
|
||||
: {
|
||||
pathname: document.url,
|
||||
state: { title: document.title },
|
||||
}
|
||||
}
|
||||
to={{
|
||||
pathname: document.url,
|
||||
state: { title: document.title },
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Heading>
|
||||
|
@ -167,7 +160,7 @@ class DocumentPreview extends React.Component<Props> {
|
|||
{!document.isDraft &&
|
||||
!document.isArchived && (
|
||||
<Actions>
|
||||
{document.starred ? (
|
||||
{document.isStarred ? (
|
||||
<StyledStar onClick={this.unstar} solid />
|
||||
) : (
|
||||
<StyledStar onClick={this.star} />
|
||||
|
@ -185,7 +178,7 @@ class DocumentPreview extends React.Component<Props> {
|
|||
)}
|
||||
<PublishingInfo
|
||||
document={document}
|
||||
collection={showCollection ? document.collection : undefined}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
/>
|
||||
</DocumentLink>
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import { inject } from 'mobx-react';
|
||||
import styled from 'styled-components';
|
||||
import Collection from 'models/Collection';
|
||||
import Document from 'models/Document';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import Time from 'shared/components/Time';
|
||||
import Breadcrumb from 'shared/components/Breadcrumb';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
|
||||
const Container = styled(Flex)`
|
||||
color: ${props => props.theme.textTertiary};
|
||||
|
@ -21,13 +22,19 @@ const Modified = styled.span`
|
|||
`;
|
||||
|
||||
type Props = {
|
||||
collection?: Collection,
|
||||
collections: CollectionsStore,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
document: Document,
|
||||
views?: number,
|
||||
};
|
||||
|
||||
function PublishingInfo({ collection, showPublished, document }: Props) {
|
||||
function PublishingInfo({
|
||||
collections,
|
||||
showPublished,
|
||||
showCollection,
|
||||
document,
|
||||
}: Props) {
|
||||
const {
|
||||
modifiedSinceViewed,
|
||||
updatedAt,
|
||||
|
@ -37,6 +44,7 @@ function PublishingInfo({ collection, showPublished, document }: Props) {
|
|||
deletedAt,
|
||||
isDraft,
|
||||
} = document;
|
||||
|
||||
const neverUpdated = publishedAt === updatedAt;
|
||||
let content;
|
||||
|
||||
|
@ -72,20 +80,23 @@ function PublishingInfo({ collection, showPublished, document }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
<Container align="center">
|
||||
{updatedBy.name}
|
||||
{content}
|
||||
{collection && (
|
||||
<span>
|
||||
in
|
||||
<strong>
|
||||
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showCollection &&
|
||||
collection && (
|
||||
<span>
|
||||
in
|
||||
<strong>
|
||||
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default PublishingInfo;
|
||||
export default inject('collections')(PublishingInfo);
|
||||
|
|
|
@ -52,7 +52,7 @@ class DropToImport extends React.Component<Props> {
|
|||
if (documentId && !collectionId) {
|
||||
const document = await this.props.documents.fetch(documentId);
|
||||
invariant(document, 'Document not available');
|
||||
collectionId = document.collection.id;
|
||||
collectionId = document.collectionId;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
|
|
|
@ -55,7 +55,7 @@ class Editor extends React.Component<Props> {
|
|||
};
|
||||
|
||||
onShowToast = (message: string) => {
|
||||
this.props.ui.showToast(message, 'success');
|
||||
this.props.ui.showToast(message);
|
||||
};
|
||||
|
||||
getLinkComponent = node => {
|
||||
|
|
|
@ -65,6 +65,7 @@ class CollectionLink extends React.Component<Props> {
|
|||
<DocumentLink
|
||||
key={document.id}
|
||||
document={document}
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={1.5}
|
||||
|
|
|
@ -34,10 +34,10 @@ class Collections extends React.Component<Props> {
|
|||
|
||||
@keydown('n')
|
||||
goToNewDocument() {
|
||||
const activeCollection = this.props.collections.active;
|
||||
if (!activeCollection) return;
|
||||
const { activeCollectionId } = this.props.ui;
|
||||
if (!activeCollectionId) return;
|
||||
|
||||
this.props.history.push(newDocumentUrl(activeCollection));
|
||||
this.props.history.push(newDocumentUrl(activeCollectionId));
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -5,11 +5,13 @@ import styled from 'styled-components';
|
|||
import Document from 'models/Document';
|
||||
import SidebarLink from './SidebarLink';
|
||||
import DropToImport from 'components/DropToImport';
|
||||
import Collection from 'models/Collection';
|
||||
import Flex from 'shared/components/Flex';
|
||||
import { type NavigationNode } from 'types';
|
||||
|
||||
type Props = {
|
||||
document: NavigationNode,
|
||||
collection?: Collection,
|
||||
activeDocument: ?Document,
|
||||
activeDocumentRef?: (?HTMLElement) => *,
|
||||
prefetchDocument: (documentId: string) => Promise<void>,
|
||||
|
@ -29,6 +31,7 @@ class DocumentLink extends React.Component<Props> {
|
|||
render() {
|
||||
const {
|
||||
document,
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentRef,
|
||||
prefetchDocument,
|
||||
|
@ -39,7 +42,9 @@ class DocumentLink extends React.Component<Props> {
|
|||
activeDocument && activeDocument.id === document.id;
|
||||
const showChildren = !!(
|
||||
activeDocument &&
|
||||
(activeDocument.pathToDocument
|
||||
collection &&
|
||||
(collection
|
||||
.pathToDocument(activeDocument)
|
||||
.map(entry => entry.id)
|
||||
.includes(document.id) ||
|
||||
isActiveDocument)
|
||||
|
@ -69,6 +74,7 @@ class DocumentLink extends React.Component<Props> {
|
|||
{document.children.map(childDocument => (
|
||||
<DocumentLink
|
||||
key={childDocument.id}
|
||||
collection={collection}
|
||||
document={childDocument}
|
||||
activeDocument={activeDocument}
|
||||
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() {
|
||||
this.timeout = setTimeout(
|
||||
this.props.onRequestClose,
|
||||
this.props.closeAfterMs
|
||||
this.props.toast.timeout || this.props.closeAfterMs
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -31,6 +31,7 @@ class Toast extends React.Component<Props> {
|
|||
|
||||
render() {
|
||||
const { toast, onRequestClose } = this.props;
|
||||
const { action } = toast;
|
||||
const message =
|
||||
typeof toast.message === 'string'
|
||||
? toast.message
|
||||
|
@ -38,20 +39,43 @@ class Toast extends React.Component<Props> {
|
|||
|
||||
return (
|
||||
<li>
|
||||
<Container onClick={onRequestClose} type={toast.type}>
|
||||
<Container
|
||||
onClick={action ? undefined : onRequestClose}
|
||||
type={toast.type || 'success'}
|
||||
>
|
||||
<Message>{message}</Message>
|
||||
{action && (
|
||||
<Action type={toast.type || 'success'} onClick={action.onClick}>
|
||||
{action.text}
|
||||
</Action>
|
||||
)}
|
||||
</Container>
|
||||
</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`
|
||||
display: inline-block;
|
||||
align-items: center;
|
||||
animation: ${fadeAndScaleIn} 100ms ease;
|
||||
margin: 8px 0;
|
||||
padding: 10px 12px;
|
||||
color: ${props => props.theme.white};
|
||||
background: ${props => props.theme[props.type]};
|
||||
font-size: 15px;
|
||||
|
@ -64,7 +88,8 @@ const Container = styled.div`
|
|||
`;
|
||||
|
||||
const Message = styled.div`
|
||||
padding-left: 5px;
|
||||
display: inline-block;
|
||||
padding: 10px 12px;
|
||||
`;
|
||||
|
||||
export default Toast;
|
||||
|
|
|
@ -3,7 +3,6 @@ import * as React from 'react';
|
|||
import { render } from 'react-dom';
|
||||
import { Provider } from 'mobx-react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
|
||||
import stores from 'stores';
|
||||
import 'shared/styles/prism.css';
|
||||
|
||||
|
@ -13,6 +12,10 @@ import Toasts from 'components/Toasts';
|
|||
import Theme from 'components/Theme';
|
||||
import Routes from './routes';
|
||||
|
||||
// socket.on('connect', function(){});
|
||||
// socket.on('event', function(data){});
|
||||
// socket.on('disconnect', function(){});
|
||||
|
||||
let DevTools;
|
||||
if (__DEV__) {
|
||||
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 CollectionPermissions from 'scenes/CollectionPermissions';
|
||||
|
||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||
import getDataTransferFiles from 'utils/getDataTransferFiles';
|
||||
import importFile from 'utils/importFile';
|
||||
import Collection from 'models/Collection';
|
||||
|
@ -34,7 +35,7 @@ class CollectionMenu extends React.Component<Props> {
|
|||
onNewDocument = (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
const { collection } = this.props;
|
||||
this.props.history.push(`${collection.url}/new`);
|
||||
this.props.history.push(newDocumentUrl(collection.id));
|
||||
};
|
||||
|
||||
onImportDocument = (ev: SyntheticEvent<*>) => {
|
||||
|
|
|
@ -8,7 +8,12 @@ import { MoreIcon } from 'outline-icons';
|
|||
import Document from 'models/Document';
|
||||
import UiStore from 'stores/UiStore';
|
||||
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';
|
||||
|
||||
type Props = {
|
||||
|
@ -16,6 +21,7 @@ type Props = {
|
|||
auth: AuthStore,
|
||||
label?: React.Node,
|
||||
document: Document,
|
||||
collections: CollectionStore,
|
||||
className: string,
|
||||
showPrint?: boolean,
|
||||
showToggleEmbeds?: boolean,
|
||||
|
@ -32,9 +38,7 @@ class DocumentMenu extends React.Component<Props> {
|
|||
|
||||
handleNewChild = (ev: SyntheticEvent<*>) => {
|
||||
const { document } = this.props;
|
||||
this.redirectTo = `${document.collection.url}/new?parentDocument=${
|
||||
document.id
|
||||
}`;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
|
||||
};
|
||||
|
||||
handleDelete = (ev: SyntheticEvent<*>) => {
|
||||
|
@ -128,7 +132,7 @@ class DocumentMenu extends React.Component<Props> {
|
|||
Pin to collection
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{document.starred ? (
|
||||
{document.isStarred ? (
|
||||
<DropdownMenuItem onClick={this.handleUnstar}>
|
||||
Unstar
|
||||
</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 { Redirect } from 'react-router-dom';
|
||||
import { observable } from 'mobx';
|
||||
import { observer } from 'mobx-react';
|
||||
import { observer, inject } from 'mobx-react';
|
||||
import { MoreIcon } from 'outline-icons';
|
||||
|
||||
import { newDocumentUrl } from 'utils/routeHelpers';
|
||||
import Document from 'models/Document';
|
||||
import CollectionsStore from 'stores/CollectionsStore';
|
||||
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
|
||||
|
||||
type Props = {
|
||||
label?: React.Node,
|
||||
document: Document,
|
||||
collections: CollectionsStore,
|
||||
};
|
||||
|
||||
@observer
|
||||
|
@ -23,27 +25,27 @@ class NewChildDocumentMenu extends React.Component<Props> {
|
|||
}
|
||||
|
||||
handleNewDocument = () => {
|
||||
this.redirectTo = newDocumentUrl(this.props.document.collection);
|
||||
const { document } = this.props;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId);
|
||||
};
|
||||
|
||||
handleNewChild = () => {
|
||||
const { document } = this.props;
|
||||
this.redirectTo = `${document.collection.url}/new?parentDocument=${
|
||||
document.id
|
||||
}`;
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
const { label, document, ...rest } = this.props;
|
||||
const { collection } = document;
|
||||
const { label, document, collections, ...rest } = this.props;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
<DropdownMenu label={label || <MoreIcon />} {...rest}>
|
||||
<DropdownMenuItem onClick={this.handleNewDocument}>
|
||||
<span>
|
||||
New document in <strong>{collection.name}</strong>
|
||||
New document in{' '}
|
||||
<strong>{collection ? collection.name : 'collection'}</strong>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<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;
|
||||
}
|
||||
|
||||
handleNewDocument = collection => {
|
||||
this.redirectTo = newDocumentUrl(collection);
|
||||
handleNewDocument = (collectionId: string) => {
|
||||
this.redirectTo = newDocumentUrl(collectionId);
|
||||
};
|
||||
|
||||
onOpen = () => {
|
||||
const { collections } = this.props;
|
||||
|
||||
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 => (
|
||||
<DropdownMenuItem
|
||||
key={collection.id}
|
||||
onClick={() => this.handleNewDocument(collection)}
|
||||
onClick={() => this.handleNewDocument(collection.id)}
|
||||
>
|
||||
{collection.private ? (
|
||||
<PrivateCollectionIcon color={collection.color} />
|
||||
|
|
|
@ -26,12 +26,12 @@ class RevisionMenu extends React.Component<Props> {
|
|||
handleRestore = async (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
await this.props.document.restore(this.props.revision);
|
||||
this.props.ui.showToast('Document restored', 'success');
|
||||
this.props.ui.showToast('Document restored');
|
||||
this.props.history.push(this.props.document.url);
|
||||
};
|
||||
|
||||
handleCopy = () => {
|
||||
this.props.ui.showToast('Link copied', 'success');
|
||||
this.props.ui.showToast('Link copied');
|
||||
};
|
||||
|
||||
render() {
|
||||
|
|
|
@ -36,11 +36,11 @@ class ShareMenu extends React.Component<Props> {
|
|||
handleRevoke = (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
this.props.shares.revoke(this.props.share);
|
||||
this.props.ui.showToast('Share link revoked', 'success');
|
||||
this.props.ui.showToast('Share link revoked');
|
||||
};
|
||||
|
||||
handleCopy = () => {
|
||||
this.props.ui.showToast('Share link copied', 'success');
|
||||
this.props.ui.showToast('Share link copied');
|
||||
};
|
||||
|
||||
render() {
|
||||
|
|
|
@ -22,6 +22,7 @@ export default class Collection extends BaseModel {
|
|||
documents: NavigationNode[];
|
||||
createdAt: ?string;
|
||||
updatedAt: ?string;
|
||||
deletedAt: ?string;
|
||||
url: string;
|
||||
|
||||
@computed
|
||||
|
@ -101,6 +102,27 @@ export default class Collection extends BaseModel {
|
|||
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 = () => {
|
||||
return pick(this, ['id', 'name', 'color', 'description', 'private']);
|
||||
};
|
||||
|
|
|
@ -1,16 +1,12 @@
|
|||
// @flow
|
||||
import { action, set, computed } from 'mobx';
|
||||
import invariant from 'invariant';
|
||||
|
||||
import { client } from 'utils/ApiClient';
|
||||
import parseTitle from 'shared/utils/parseTitle';
|
||||
import unescape from 'shared/utils/unescape';
|
||||
|
||||
import type { NavigationNode } from 'types';
|
||||
import BaseModel from 'models/BaseModel';
|
||||
import Revision from 'models/Revision';
|
||||
import User from 'models/User';
|
||||
import Collection from 'models/Collection';
|
||||
|
||||
type SaveOptions = { publish?: boolean, done?: boolean, autosave?: boolean };
|
||||
|
||||
|
@ -20,7 +16,6 @@ export default class Document extends BaseModel {
|
|||
store: *;
|
||||
|
||||
collaborators: User[];
|
||||
collection: Collection;
|
||||
collectionId: string;
|
||||
lastViewedAt: ?string;
|
||||
createdAt: string;
|
||||
|
@ -29,12 +24,11 @@ export default class Document extends BaseModel {
|
|||
updatedBy: User;
|
||||
id: string;
|
||||
team: string;
|
||||
starred: boolean;
|
||||
pinned: boolean;
|
||||
text: string;
|
||||
title: string;
|
||||
emoji: string;
|
||||
parentDocument: ?string;
|
||||
parentDocumentId: ?string;
|
||||
publishedAt: ?string;
|
||||
archivedAt: string;
|
||||
deletedAt: ?string;
|
||||
|
@ -59,25 +53,8 @@ export default class Document extends BaseModel {
|
|||
}
|
||||
|
||||
@computed
|
||||
get pathToDocument(): NavigationNode[] {
|
||||
let path;
|
||||
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 [];
|
||||
get isStarred(): boolean {
|
||||
return this.store.starredIds.get(this.id);
|
||||
}
|
||||
|
||||
@computed
|
||||
|
@ -106,13 +83,6 @@ export default class Document extends BaseModel {
|
|||
return !this.isEmpty && !this.isSaving;
|
||||
}
|
||||
|
||||
@computed
|
||||
get parentDocumentId(): ?string {
|
||||
return this.pathToDocument.length > 1
|
||||
? this.pathToDocument[this.pathToDocument.length - 2].id
|
||||
: null;
|
||||
}
|
||||
|
||||
@action
|
||||
share = async () => {
|
||||
const res = await client.post('/shares.create', { documentId: this.id });
|
||||
|
@ -158,25 +128,13 @@ export default class Document extends BaseModel {
|
|||
};
|
||||
|
||||
@action
|
||||
star = async () => {
|
||||
this.starred = true;
|
||||
try {
|
||||
await this.store.star(this);
|
||||
} catch (err) {
|
||||
this.starred = false;
|
||||
throw err;
|
||||
}
|
||||
star = () => {
|
||||
return this.store.star(this);
|
||||
};
|
||||
|
||||
@action
|
||||
unstar = async () => {
|
||||
this.starred = false;
|
||||
try {
|
||||
await this.store.unstar(this);
|
||||
} catch (err) {
|
||||
this.starred = true;
|
||||
throw err;
|
||||
}
|
||||
return this.store.unstar(this);
|
||||
};
|
||||
|
||||
@action
|
||||
|
@ -202,31 +160,25 @@ export default class Document extends BaseModel {
|
|||
|
||||
try {
|
||||
if (isCreating) {
|
||||
const data = {
|
||||
parentDocument: undefined,
|
||||
collection: this.collection.id,
|
||||
return this.store.create({
|
||||
parentDocumentId: this.parentDocumentId,
|
||||
collectionId: this.collectionId,
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
...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 {
|
||||
if (wasDraft && options.publish) {
|
||||
this.store.rootStore.collections.fetch(this.collection.id, {
|
||||
this.store.rootStore.collections.fetch(this.collectionId, {
|
||||
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 Layout from 'components/Layout';
|
||||
import SocketProvider from 'components/SocketProvider';
|
||||
import Authenticated from 'components/Authenticated';
|
||||
import RouteSidebarHidden from 'components/RouteSidebarHidden';
|
||||
import { matchDocumentSlug as slug } from 'utils/routeHelpers';
|
||||
|
@ -39,62 +40,68 @@ export default function Routes() {
|
|||
<Route exact path="/" component={Home} />
|
||||
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
||||
<Authenticated>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Route path="/dashboard/:tab" component={Dashboard} />
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/settings/details" component={Details} />
|
||||
<Route exact path="/settings/security" component={Security} />
|
||||
<Route exact path="/settings/people" component={People} />
|
||||
<Route exact path="/settings/people/:filter" component={People} />
|
||||
<Route exact path="/settings/shares" component={Shares} />
|
||||
<Route exact path="/settings/tokens" component={Tokens} />
|
||||
<Route
|
||||
exact
|
||||
path="/settings/notifications"
|
||||
component={Notifications}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/settings/integrations/slack"
|
||||
component={Slack}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/settings/integrations/zapier"
|
||||
component={Zapier}
|
||||
/>
|
||||
<Route exact path="/settings/export" component={Export} />
|
||||
<RouteSidebarHidden
|
||||
exact
|
||||
path="/collections/:id/new"
|
||||
component={NewDocument}
|
||||
/>
|
||||
<Route exact path="/collections/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collections/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<RouteSidebarHidden
|
||||
exact
|
||||
path={`/doc/${slug}/edit`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:query" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<SocketProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Route path="/dashboard/:tab" component={Dashboard} />
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/settings/details" component={Details} />
|
||||
<Route exact path="/settings/security" component={Security} />
|
||||
<Route exact path="/settings/people" component={People} />
|
||||
<Route exact path="/settings/people/:filter" component={People} />
|
||||
<Route exact path="/settings/shares" component={Shares} />
|
||||
<Route exact path="/settings/tokens" component={Tokens} />
|
||||
<Route
|
||||
exact
|
||||
path="/settings/notifications"
|
||||
component={Notifications}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/settings/integrations/slack"
|
||||
component={Slack}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/settings/integrations/zapier"
|
||||
component={Zapier}
|
||||
/>
|
||||
<Route exact path="/settings/export" component={Export} />
|
||||
<RouteSidebarHidden
|
||||
exact
|
||||
path="/collections/:id/new"
|
||||
component={NewDocument}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/collections/:id/:tab"
|
||||
component={Collection}
|
||||
/>
|
||||
<Route exact path="/collections/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<RouteSidebarHidden
|
||||
exact
|
||||
path={`/doc/${slug}/edit`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<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>
|
||||
</Switch>
|
||||
);
|
||||
|
|
|
@ -86,7 +86,7 @@ class CollectionScene extends React.Component<Props> {
|
|||
ev.preventDefault();
|
||||
|
||||
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!
|
||||
</HelpText>
|
||||
<Wrapper>
|
||||
<Link to={newDocumentUrl(collection)}>
|
||||
<Link to={newDocumentUrl(collection.id)}>
|
||||
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
|
||||
Create a document
|
||||
</Button>
|
||||
|
|
|
@ -27,7 +27,7 @@ class CollectionExport extends React.Component<Props> {
|
|||
await this.props.collection.export();
|
||||
this.isLoading = false;
|
||||
|
||||
this.props.ui.showToast('Export in progress…', 'success');
|
||||
this.props.ui.showToast('Export in progress…');
|
||||
this.props.onSubmit();
|
||||
};
|
||||
|
||||
|
|
|
@ -101,7 +101,7 @@ class DocumentScene extends React.Component<Props> {
|
|||
goToMove(ev) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
@ -137,9 +137,9 @@ class DocumentScene extends React.Component<Props> {
|
|||
if (props.newDocument) {
|
||||
this.document = new Document(
|
||||
{
|
||||
collection: { id: props.match.params.id },
|
||||
parentDocument: new URLSearchParams(props.location.search).get(
|
||||
'parentDocument'
|
||||
collectionId: props.match.params.id,
|
||||
parentDocumentId: new URLSearchParams(props.location.search).get(
|
||||
'parentDocumentId'
|
||||
),
|
||||
title: '',
|
||||
text: '',
|
||||
|
|
|
@ -65,7 +65,7 @@ class DocumentMove extends React.Component<Props> {
|
|||
|
||||
// Exclude root from search results if document is already at the root
|
||||
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
|
||||
|
|
|
@ -9,6 +9,7 @@ import HelpText from 'components/HelpText';
|
|||
import Document from 'models/Document';
|
||||
import DocumentsStore from 'stores/DocumentsStore';
|
||||
import UiStore from 'stores/UiStore';
|
||||
import { collectionUrl } from 'utils/routeHelpers';
|
||||
|
||||
type Props = {
|
||||
history: Object,
|
||||
|
@ -25,12 +26,13 @@ class DocumentDelete extends React.Component<Props> {
|
|||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
this.isDeleting = true;
|
||||
const { collection } = this.props.document;
|
||||
|
||||
try {
|
||||
await this.props.document.delete();
|
||||
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();
|
||||
} catch (err) {
|
||||
|
|
|
@ -49,7 +49,7 @@ class Details extends React.Component<Props> {
|
|||
avatarUrl: this.avatarUrl,
|
||||
subdomain: this.subdomain,
|
||||
});
|
||||
this.props.ui.showToast('Settings saved', 'success');
|
||||
this.props.ui.showToast('Settings saved');
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ class Export extends React.Component<Props> {
|
|||
try {
|
||||
await this.props.collections.export();
|
||||
this.isExporting = true;
|
||||
this.props.ui.showToast('Export in progress…', 'success');
|
||||
this.props.ui.showToast('Export in progress…');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
|
|
@ -45,7 +45,7 @@ class Profile extends React.Component<Props> {
|
|||
name: this.name,
|
||||
avatarUrl: this.avatarUrl,
|
||||
});
|
||||
this.props.ui.showToast('Profile saved', 'success');
|
||||
this.props.ui.showToast('Profile saved');
|
||||
};
|
||||
|
||||
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||
|
@ -58,7 +58,7 @@ class Profile extends React.Component<Props> {
|
|||
await this.props.auth.updateUser({
|
||||
avatarUrl: this.avatarUrl,
|
||||
});
|
||||
this.props.ui.showToast('Profile picture updated', 'success');
|
||||
this.props.ui.showToast('Profile picture updated');
|
||||
};
|
||||
|
||||
handleAvatarError = (error: ?string) => {
|
||||
|
|
|
@ -160,7 +160,6 @@ export default class BaseStore<T: BaseModel> {
|
|||
|
||||
@computed
|
||||
get orderedData(): T[] {
|
||||
// $FlowIssue
|
||||
return orderBy(Array.from(this.data.values()), 'createdAt', 'desc');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
// @flow
|
||||
import { computed, runInAction } from 'mobx';
|
||||
import { concat, last } from 'lodash';
|
||||
import { concat, filter, last } from 'lodash';
|
||||
import { client } from 'utils/ApiClient';
|
||||
|
||||
import BaseStore from './BaseStore';
|
||||
import RootStore from './RootStore';
|
||||
import Collection from '../models/Collection';
|
||||
import Collection from 'models/Collection';
|
||||
import naturalSort from 'shared/utils/naturalSort';
|
||||
|
||||
export type DocumentPathItem = {
|
||||
|
@ -34,7 +34,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
|||
|
||||
@computed
|
||||
get orderedData(): Collection[] {
|
||||
return naturalSort(Array.from(this.data.values()), 'name');
|
||||
return filter(
|
||||
naturalSort(Array.from(this.data.values()), 'name'),
|
||||
d => !d.deletedAt
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
|
|
|
@ -14,6 +14,7 @@ import type { FetchOptions, PaginationParams, SearchResult } from 'types';
|
|||
export default class DocumentsStore extends BaseStore<Document> {
|
||||
@observable recentlyViewedIds: string[] = [];
|
||||
@observable searchCache: Map<string, SearchResult[]> = new Map();
|
||||
@observable starredIds: Map<string, boolean> = new Map();
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Document);
|
||||
|
@ -95,7 +96,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||
|
||||
@computed
|
||||
get starred(): Document[] {
|
||||
return filter(this.all, d => d.starred);
|
||||
return filter(this.all, d => d.isStarred);
|
||||
}
|
||||
|
||||
@computed
|
||||
|
@ -314,8 +315,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||
duplicate = async (document: Document): * => {
|
||||
const res = await client.post('/documents.create', {
|
||||
publish: true,
|
||||
parentDocument: document.parentDocumentId,
|
||||
collection: document.collection.id,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
collection: document.collectionId,
|
||||
title: `${document.title} (duplicate)`,
|
||||
text: document.text,
|
||||
});
|
||||
|
@ -327,6 +328,20 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||
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: *) {
|
||||
const document = await super.update(params);
|
||||
|
||||
|
@ -337,6 +352,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||
return document;
|
||||
}
|
||||
|
||||
@action
|
||||
async delete(document: Document) {
|
||||
await super.delete(document);
|
||||
|
||||
|
@ -385,12 +401,24 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||
return client.post('/documents.unpin', { id: document.id });
|
||||
};
|
||||
|
||||
star = (document: Document) => {
|
||||
return client.post('/documents.star', { id: document.id });
|
||||
star = async (document: Document) => {
|
||||
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) => {
|
||||
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 => {
|
||||
|
|
|
@ -94,13 +94,20 @@ class UiStore {
|
|||
@action
|
||||
showToast = (
|
||||
message: string,
|
||||
type?: 'warning' | 'error' | 'info' | 'success' = 'success'
|
||||
options?: {
|
||||
type?: 'warning' | 'error' | 'info' | 'success',
|
||||
timeout?: number,
|
||||
action?: {
|
||||
text: string,
|
||||
onClick: () => void,
|
||||
},
|
||||
}
|
||||
) => {
|
||||
if (!message) return;
|
||||
|
||||
const id = v4();
|
||||
const createdAt = new Date().toISOString();
|
||||
this.toasts.set(id, { message, type, createdAt, id });
|
||||
this.toasts.set(id, { message, createdAt, id, ...options });
|
||||
return id;
|
||||
};
|
||||
|
||||
|
@ -111,7 +118,6 @@ class UiStore {
|
|||
|
||||
@computed
|
||||
get orderedToasts(): Toast[] {
|
||||
// $FlowIssue
|
||||
return orderBy(Array.from(this.toasts.values()), 'createdAt', 'desc');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,11 @@ export type Toast = {
|
|||
createdAt: string,
|
||||
message: string,
|
||||
type: 'warning' | 'error' | 'info' | 'success',
|
||||
timeout?: number,
|
||||
action?: {
|
||||
text: string,
|
||||
onClick: () => void,
|
||||
},
|
||||
};
|
||||
|
||||
export type FetchOptions = {
|
||||
|
|
|
@ -19,15 +19,15 @@ const importFile = async ({
|
|||
|
||||
reader.onload = async ev => {
|
||||
const text = ev.target.result;
|
||||
let data = {
|
||||
parentDocument: undefined,
|
||||
collection: { id: collectionId },
|
||||
text,
|
||||
};
|
||||
|
||||
if (documentId) data.parentDocument = documentId;
|
||||
|
||||
let document = new Document(data, documents);
|
||||
let document = new Document(
|
||||
{
|
||||
parentDocumentId: documentId,
|
||||
collectionId,
|
||||
text,
|
||||
},
|
||||
documents
|
||||
);
|
||||
try {
|
||||
document = await document.save({ publish: true });
|
||||
resolve(document);
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
// @flow
|
||||
import Document from 'models/Document';
|
||||
import Collection from 'models/Collection';
|
||||
|
||||
export function homeUrl(): string {
|
||||
return '/dashboard';
|
||||
|
@ -24,14 +23,6 @@ export function documentUrl(doc: Document): string {
|
|||
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 {
|
||||
return `${doc.url}/edit`;
|
||||
}
|
||||
|
@ -60,8 +51,17 @@ export function updateDocumentUrl(oldUrl: string, newUrl: string): string {
|
|||
return newUrl;
|
||||
}
|
||||
|
||||
export function newDocumentUrl(collection: Collection): string {
|
||||
return `${collection.url || ''}/new`;
|
||||
export function newDocumentUrl(
|
||||
collectionId: string,
|
||||
parentDocumentId?: string
|
||||
): string {
|
||||
let route = `/collections/${collectionId}/new`;
|
||||
|
||||
if (parentDocumentId) {
|
||||
route += `?parentDocumentId=${parentDocumentId}`;
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
|
@ -20,18 +21,8 @@ if (
|
|||
console.error(
|
||||
'Please set SECRET_KEY env variable with output of `openssl rand -hex 32`'
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = require('./server').default;
|
||||
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`);
|
||||
});
|
||||
require('./server');
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": "8.11"
|
||||
"node": ">= 8.11"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -148,6 +148,9 @@
|
|||
"sequelize-cli": "^5.4.0",
|
||||
"sequelize-encrypted": "0.1.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",
|
||||
"style-loader": "^0.18.2",
|
||||
"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 = {
|
||||
data: presentApiKey(ctx, key),
|
||||
data: presentApiKey(key),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -38,11 +38,9 @@ router.post('apiKeys.list', auth(), pagination(), async ctx => {
|
|||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
const data = keys.map(key => presentApiKey(ctx, key));
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data,
|
||||
data: keys.map(presentApiKey),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -12,8 +12,8 @@ router.post('auth.info', auth(), async ctx => {
|
|||
|
||||
ctx.body = {
|
||||
data: {
|
||||
user: await presentUser(ctx, user, { includeDetails: true }),
|
||||
team: await presentTeam(ctx, team),
|
||||
user: presentUser(user, { includeDetails: true }),
|
||||
team: presentTeam(team),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@ import { Collection, CollectionUser, Team, User } from '../models';
|
|||
import { ValidationError, InvalidRequestError } from '../errors';
|
||||
import { exportCollection, exportCollections } from '../logistics';
|
||||
import policy from '../policies';
|
||||
import events from '../events';
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
@ -32,8 +33,15 @@ router.post('collections.create', auth(), async ctx => {
|
|||
private: isPrivate,
|
||||
});
|
||||
|
||||
events.add({
|
||||
name: 'collections.create',
|
||||
modelId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
events.add({
|
||||
name: 'collections.add_user',
|
||||
modelId: userId,
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: ctx.state.user.id,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
|
@ -93,6 +109,14 @@ router.post('collections.remove_user', auth(), async ctx => {
|
|||
|
||||
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 = {
|
||||
success: true,
|
||||
};
|
||||
|
@ -107,12 +131,8 @@ router.post('collections.users', auth(), async ctx => {
|
|||
|
||||
const users = await collection.getUsers();
|
||||
|
||||
const data = await Promise.all(
|
||||
users.map(async user => await presentUser(ctx, user))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data,
|
||||
data: users.map(presentUser),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -176,8 +196,15 @@ router.post('collections.update', auth(), async ctx => {
|
|||
collection.private = isPrivate;
|
||||
await collection.save();
|
||||
|
||||
events.add({
|
||||
name: 'collections.update',
|
||||
modelId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
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(
|
||||
collections.map(
|
||||
async collection => await presentCollection(ctx, collection)
|
||||
)
|
||||
collections.map(async collection => await presentCollection(collection))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
|
@ -209,16 +234,24 @@ router.post('collections.list', auth(), pagination(), async ctx => {
|
|||
|
||||
router.post('collections.delete', auth(), async ctx => {
|
||||
const { id } = ctx.body;
|
||||
const user = ctx.state.user;
|
||||
ctx.assertUuid(id, 'id is required');
|
||||
|
||||
const collection = await Collection.findById(id);
|
||||
authorize(ctx.state.user, 'delete', collection);
|
||||
authorize(user, 'delete', collection);
|
||||
|
||||
const total = await Collection.count();
|
||||
if (total === 1) throw new ValidationError('Cannot delete last collection');
|
||||
|
||||
await collection.destroy();
|
||||
|
||||
events.add({
|
||||
name: 'collections.delete',
|
||||
modelId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import app from '..';
|
||||
import app from '../app';
|
||||
import { flushdb, seed } from '../test/support';
|
||||
import { buildUser, buildCollection } from '../test/factories';
|
||||
import { Collection } from '../models';
|
||||
|
|
|
@ -60,7 +60,7 @@ router.post('documents.list', auth(), pagination(), async ctx => {
|
|||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
documents.map(document => presentDocument(ctx, document))
|
||||
documents.map(document => presentDocument(document))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
|
@ -96,7 +96,7 @@ router.post('documents.pinned', auth(), pagination(), async ctx => {
|
|||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
documents.map(document => presentDocument(ctx, document))
|
||||
documents.map(document => presentDocument(document))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
|
@ -128,7 +128,7 @@ router.post('documents.archived', auth(), pagination(), async ctx => {
|
|||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
documents.map(document => presentDocument(ctx, document))
|
||||
documents.map(document => presentDocument(document))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
|
@ -169,7 +169,7 @@ router.post('documents.viewed', auth(), pagination(), async ctx => {
|
|||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
views.map(view => presentDocument(ctx, view.document))
|
||||
views.map(view => presentDocument(view.document))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
|
@ -212,7 +212,7 @@ router.post('documents.starred', auth(), pagination(), async ctx => {
|
|||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
stars.map(star => presentDocument(ctx, star.document))
|
||||
stars.map(star => presentDocument(star.document))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
|
@ -241,7 +241,7 @@ router.post('documents.drafts', auth(), pagination(), async ctx => {
|
|||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
documents.map(document => presentDocument(ctx, document))
|
||||
documents.map(document => presentDocument(document))
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
|
@ -284,7 +284,7 @@ router.post('documents.info', auth({ required: false }), async ctx => {
|
|||
const isPublic = cannot(user, 'read', document);
|
||||
|
||||
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 = {
|
||||
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,
|
||||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
revisions.map((revision, index) => presentRevision(ctx, revision))
|
||||
);
|
||||
const data = await Promise.all(revisions.map(presentRevision));
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
|
@ -347,8 +345,15 @@ router.post('documents.restore', auth(), async ctx => {
|
|||
// restore a previously archived document
|
||||
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) {
|
||||
// restore a document to a specific revision
|
||||
authorize(user, 'update', document);
|
||||
|
||||
const revision = await Revision.findById(revisionId);
|
||||
|
@ -357,12 +362,20 @@ router.post('documents.restore', auth(), async ctx => {
|
|||
document.text = revision.text;
|
||||
document.title = revision.title;
|
||||
await document.save();
|
||||
|
||||
events.add({
|
||||
name: 'documents.restore',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
} else {
|
||||
ctx.assertPresent(revisionId, 'revisionId is required');
|
||||
}
|
||||
|
||||
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(
|
||||
results.map(async result => {
|
||||
const document = await presentDocument(ctx, result.document);
|
||||
const document = await presentDocument(result.document);
|
||||
return { ...result, document };
|
||||
})
|
||||
);
|
||||
|
@ -402,8 +415,16 @@ router.post('documents.pin', auth(), async ctx => {
|
|||
document.pinnedById = user.id;
|
||||
await document.save();
|
||||
|
||||
events.add({
|
||||
name: 'documents.pin',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
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;
|
||||
await document.save();
|
||||
|
||||
events.add({
|
||||
name: 'documents.unpin',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
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({
|
||||
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 => {
|
||||
|
@ -447,16 +484,32 @@ router.post('documents.unstar', auth(), async ctx => {
|
|||
await Star.destroy({
|
||||
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 => {
|
||||
const { title, text, publish, parentDocument, index } = ctx.body;
|
||||
const collectionId = ctx.body.collection;
|
||||
ctx.assertUuid(collectionId, 'collection must be an uuid');
|
||||
const {
|
||||
title,
|
||||
text,
|
||||
publish,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
index,
|
||||
} = ctx.body;
|
||||
ctx.assertUuid(collectionId, 'collectionId must be an uuid');
|
||||
ctx.assertPresent(title, 'title is required');
|
||||
ctx.assertPresent(text, 'text is required');
|
||||
if (parentDocument)
|
||||
ctx.assertUuid(parentDocument, 'parentDocument must be an uuid');
|
||||
if (parentDocumentId) {
|
||||
ctx.assertUuid(parentDocumentId, 'parentDocumentId must be an uuid');
|
||||
}
|
||||
|
||||
if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)');
|
||||
|
||||
const user = ctx.state.user;
|
||||
|
@ -470,19 +523,19 @@ router.post('documents.create', auth(), async ctx => {
|
|||
});
|
||||
authorize(user, 'publish', collection);
|
||||
|
||||
let parentDocumentObj = {};
|
||||
if (parentDocument && collection.type === 'atlas') {
|
||||
parentDocumentObj = await Document.findOne({
|
||||
let parentDocument;
|
||||
if (parentDocumentId && collection.type === 'atlas') {
|
||||
parentDocument = await Document.findOne({
|
||||
where: {
|
||||
id: parentDocument,
|
||||
id: parentDocumentId,
|
||||
collectionId: collection.id,
|
||||
},
|
||||
});
|
||||
authorize(user, 'read', parentDocumentObj);
|
||||
authorize(user, 'read', parentDocument);
|
||||
}
|
||||
|
||||
let document = await Document.create({
|
||||
parentDocumentId: parentDocumentObj.id,
|
||||
parentDocumentId,
|
||||
collectionId: collection.id,
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
|
@ -492,8 +545,24 @@ router.post('documents.create', auth(), async ctx => {
|
|||
text,
|
||||
});
|
||||
|
||||
events.add({
|
||||
name: 'documents.create',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
if (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)
|
||||
|
@ -504,12 +573,12 @@ router.post('documents.create', auth(), async ctx => {
|
|||
});
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(ctx, document),
|
||||
data: await presentDocument(document),
|
||||
};
|
||||
});
|
||||
|
||||
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(title || text, 'title or text is required');
|
||||
|
||||
|
@ -529,16 +598,28 @@ router.post('documents.update', auth(), async ctx => {
|
|||
|
||||
if (publish) {
|
||||
await document.publish();
|
||||
|
||||
events.add({
|
||||
name: 'documents.publish',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
} else {
|
||||
await document.save({ autosave });
|
||||
|
||||
if (document.publishedAt && done) {
|
||||
events.add({ name: 'documents.update', model: document });
|
||||
}
|
||||
events.add({
|
||||
name: 'documents.update',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(ctx, document),
|
||||
data: await presentDocument(document),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -587,10 +668,10 @@ router.post('documents.move', auth(), async ctx => {
|
|||
ctx.body = {
|
||||
data: {
|
||||
documents: await Promise.all(
|
||||
documents.map(document => presentDocument(ctx, document))
|
||||
documents.map(document => presentDocument(document))
|
||||
),
|
||||
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);
|
||||
|
||||
events.add({
|
||||
name: 'documents.archive',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
events.add({
|
||||
name: 'documents.delete',
|
||||
modelId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import app from '..';
|
||||
import app from '../app';
|
||||
import { Document, View, Star, Revision } from '../models';
|
||||
import { flushdb, seed } from '../test/support';
|
||||
import {
|
||||
|
@ -79,7 +79,6 @@ describe('#documents.info', async () => {
|
|||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(document.id);
|
||||
expect(body.data.collection).toEqual(undefined);
|
||||
expect(body.data.createdBy).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 () => {
|
||||
const { user, document, collection } = await seed();
|
||||
const { user, document } = await seed();
|
||||
const share = await buildShare({
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
|
@ -126,7 +125,6 @@ describe('#documents.info', async () => {
|
|||
|
||||
expect(res.status).toEqual(200);
|
||||
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.updatedBy.id).toEqual(user.id);
|
||||
});
|
||||
|
@ -892,7 +890,7 @@ describe('#documents.create', async () => {
|
|||
const res = await server.post('/api/documents.create', {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collection: collection.id,
|
||||
collectionId: collection.id,
|
||||
title: 'new document',
|
||||
text: 'hello',
|
||||
publish: true,
|
||||
|
@ -910,7 +908,7 @@ describe('#documents.create', async () => {
|
|||
const res = await server.post('/api/documents.create', {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collection: collection.id,
|
||||
collectionId: collection.id,
|
||||
title: ' ',
|
||||
text: ' ',
|
||||
},
|
||||
|
@ -926,7 +924,7 @@ describe('#documents.create', async () => {
|
|||
const res = await server.post('/api/documents.create', {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collection: collection.id,
|
||||
collectionId: collection.id,
|
||||
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',
|
||||
text: ' ',
|
||||
|
@ -940,10 +938,10 @@ describe('#documents.create', async () => {
|
|||
const res = await server.post('/api/documents.create', {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collection: collection.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: document.id,
|
||||
title: 'new document',
|
||||
text: 'hello',
|
||||
parentDocument: document.id,
|
||||
publish: true,
|
||||
},
|
||||
});
|
||||
|
@ -951,8 +949,6 @@ describe('#documents.create', async () => {
|
|||
|
||||
expect(res.status).toEqual(200);
|
||||
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 () => {
|
||||
|
@ -960,10 +956,10 @@ describe('#documents.create', async () => {
|
|||
const res = await server.post('/api/documents.create', {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collection: collection.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: 'd7a4eb73-fac1-4028-af45-d7e34d54db8e',
|
||||
title: 'new document',
|
||||
text: 'hello',
|
||||
parentDocument: 'd7a4eb73-fac1-4028-af45-d7e34d54db8e',
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
@ -977,17 +973,16 @@ describe('#documents.create', async () => {
|
|||
const res = await server.post('/api/documents.create', {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collection: collection.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: document.id,
|
||||
title: 'new document',
|
||||
text: 'hello',
|
||||
parentDocument: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
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(body.data.title).toBe('Updated title');
|
||||
expect(body.data.text).toBe('Updated text');
|
||||
expect(body.data.collection.documents[0].title).toBe('Updated title');
|
||||
});
|
||||
|
||||
it('should not edit archived document', async () => {
|
||||
|
@ -1070,7 +1064,6 @@ describe('#documents.update', async () => {
|
|||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).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 () => {
|
||||
|
@ -1121,9 +1114,6 @@ describe('#documents.update', async () => {
|
|||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.title).toBe('Updated title');
|
||||
expect(body.data.collection.documents[0].children[1].title).toBe(
|
||||
'Updated title'
|
||||
);
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import app from '..';
|
||||
import app from '../app';
|
||||
import { Authentication } from '../models';
|
||||
import { flushdb, seed } from '../test/support';
|
||||
import { buildDocument, buildUser } from '../test/factories';
|
||||
|
|
|
@ -5,6 +5,7 @@ import pagination from './middlewares/pagination';
|
|||
import auth from '../middlewares/authentication';
|
||||
import { presentIntegration } from '../presenters';
|
||||
import policy from '../policies';
|
||||
import events from '../events';
|
||||
|
||||
const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
@ -21,9 +22,7 @@ router.post('integrations.list', auth(), pagination(), async ctx => {
|
|||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
const data = await Promise.all(
|
||||
integrations.map(integration => presentIntegration(ctx, integration))
|
||||
);
|
||||
const data = await Promise.all(integrations.map(presentIntegration));
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
|
@ -35,11 +34,19 @@ router.post('integrations.delete', auth(), async ctx => {
|
|||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, 'id is required');
|
||||
|
||||
const user = ctx.state.user;
|
||||
const integration = await Integration.findById(id);
|
||||
authorize(ctx.state.user, 'delete', integration);
|
||||
authorize(user, 'delete', integration);
|
||||
|
||||
await integration.destroy();
|
||||
|
||||
events.add({
|
||||
name: 'integrations.delete',
|
||||
modelId: integration.id,
|
||||
teamId: integration.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
|
|
|
@ -25,7 +25,7 @@ router.post('notificationSettings.create', auth(), async ctx => {
|
|||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentNotificationSetting(ctx, setting),
|
||||
data: presentNotificationSetting(setting),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -38,7 +38,7 @@ router.post('notificationSettings.list', auth(), async ctx => {
|
|||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const data = await Promise.all(shares.map(share => presentShare(ctx, share)));
|
||||
|
||||
ctx.body = {
|
||||
data,
|
||||
data: shares.map(presentShare),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -78,7 +76,7 @@ router.post('shares.create', auth(), async ctx => {
|
|||
share.document = document;
|
||||
|
||||
ctx.body = {
|
||||
data: presentShare(ctx, share),
|
||||
data: presentShare(share),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import app from '..';
|
||||
import app from '../app';
|
||||
import { flushdb, seed } from '../test/support';
|
||||
import { buildUser, buildShare } from '../test/factories';
|
||||
|
||||
|
|
|
@ -30,7 +30,9 @@ router.post('team.update', auth(), async ctx => {
|
|||
}
|
||||
await team.save();
|
||||
|
||||
ctx.body = { data: await presentTeam(ctx, team) };
|
||||
ctx.body = {
|
||||
data: presentTeam(team),
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import app from '..';
|
||||
import app from '../app';
|
||||
|
||||
import { flushdb, seed } from '../test/support';
|
||||
|
||||
|
|
|
@ -27,13 +27,13 @@ router.post('users.list', auth(), pagination(), async ctx => {
|
|||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: users.map(listUser =>
|
||||
presentUser(ctx, listUser, { includeDetails: user.isAdmin })
|
||||
presentUser(listUser, { includeDetails: user.isAdmin })
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
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 => {
|
||||
|
@ -48,7 +48,7 @@ router.post('users.update', auth(), async ctx => {
|
|||
|
||||
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 => {
|
||||
|
@ -112,7 +112,7 @@ router.post('users.promote', auth(), async ctx => {
|
|||
await team.addAdmin(user);
|
||||
|
||||
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 = {
|
||||
data: presentUser(ctx, user, { includeDetails: true }),
|
||||
data: presentUser(user, { includeDetails: true }),
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -158,7 +158,7 @@ router.post('users.suspend', auth(), async ctx => {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
ctx.body = {
|
||||
data: presentUser(ctx, user, { includeDetails: true }),
|
||||
data: presentUser(user, { includeDetails: true }),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import app from '..';
|
||||
import app from '../app';
|
||||
|
||||
import { flushdb, seed } from '../test/support';
|
||||
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 = {
|
||||
data,
|
||||
data: views.map(presentView),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import app from '..';
|
||||
import app from '../app';
|
||||
import { View } from '../models';
|
||||
import { flushdb, seed } from '../test/support';
|
||||
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
|
||||
import { Document, Collection } from '../models';
|
||||
import { sequelize } from '../sequelize';
|
||||
import events from '../events';
|
||||
|
||||
export default async function documentMover({
|
||||
document,
|
||||
|
@ -67,10 +68,17 @@ export default async function documentMover({
|
|||
}
|
||||
|
||||
await document.save({ transaction });
|
||||
document.collection = newCollection;
|
||||
result.documents.push(document);
|
||||
|
||||
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) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
|
|
|
@ -1,24 +1,74 @@
|
|||
// @flow
|
||||
import Queue from 'bull';
|
||||
import services from './services';
|
||||
import { Collection, Document, Integration } from './models';
|
||||
|
||||
type DocumentEvent = {
|
||||
name: 'documents.create' | 'documents.update' | 'documents.publish',
|
||||
model: Document,
|
||||
type UserEvent = {
|
||||
name: | 'users.create' // eslint-disable-line
|
||||
| 'users.update'
|
||||
| 'users.suspend'
|
||||
| 'users.activate'
|
||||
| 'users.delete',
|
||||
modelId: string,
|
||||
teamId: string,
|
||||
actorId: string,
|
||||
};
|
||||
|
||||
type CollectionEvent = {
|
||||
name: 'collections.create' | 'collections.update',
|
||||
model: Collection,
|
||||
};
|
||||
type DocumentEvent =
|
||||
| {
|
||||
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 = {
|
||||
name: 'integrations.create' | 'integrations.update',
|
||||
model: Integration,
|
||||
name: 'integrations.create' | 'integrations.update' | 'collections.delete',
|
||||
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 serviceEventsQueue = new Queue('service events', process.env.REDIS_URL);
|
||||
|
|
183
server/index.js
183
server/index.js
|
@ -1,128 +1,81 @@
|
|||
// @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 http from 'http';
|
||||
import IO from 'socket.io';
|
||||
import SocketAuth from 'socketio-auth';
|
||||
import socketRedisAdapter from 'socket.io-redis';
|
||||
import { getUserForJWT } from './utils/jwt';
|
||||
import { Collection } from './models';
|
||||
import app from './app';
|
||||
import policy from './policies';
|
||||
|
||||
import auth from './auth';
|
||||
import api from './api';
|
||||
import emails from './emails';
|
||||
import routes from './routes';
|
||||
const server = http.createServer(app.callback());
|
||||
let io;
|
||||
|
||||
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') {
|
||||
/* 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 */
|
||||
io.adapter(socketRedisAdapter(process.env.REDIS_URL));
|
||||
|
||||
app.use(
|
||||
convert(
|
||||
devMiddleware(compile, {
|
||||
// display no info to console (only warnings and errors)
|
||||
noInfo: true,
|
||||
SocketAuth(io, {
|
||||
authenticate: async (socket, data, callback) => {
|
||||
const { token } = data;
|
||||
|
||||
// display nothing to the console
|
||||
quiet: false,
|
||||
try {
|
||||
const user = await getUserForJWT(token);
|
||||
socket.client.user = user;
|
||||
|
||||
// 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);
|
||||
return callback(null, true);
|
||||
} catch (err) {
|
||||
return callback(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
// join rooms associated with collections this user
|
||||
// has access to on connection. New collection subscriptions
|
||||
// are managed from the client as needed
|
||||
const collectionIds = await user.collectionIds();
|
||||
collectionIds.forEach(collectionId => socket.join(collectionId));
|
||||
|
||||
// allow the client to request to join rooms based on
|
||||
// new collections being created.
|
||||
socket.on('join', async event => {
|
||||
const collection = await Collection.findById(event.roomId);
|
||||
|
||||
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 { type Context } from 'koa';
|
||||
import { User, ApiKey } from '../models';
|
||||
import { getUserForJWT } from '../utils/jwt';
|
||||
import { AuthenticationError, UserSuspendedError } from '../errors';
|
||||
import addMonths from 'date-fns/add_months';
|
||||
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');
|
||||
} else {
|
||||
// JWT
|
||||
// Get user without verifying payload signature
|
||||
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');
|
||||
}
|
||||
user = await getUserForJWT(token);
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
|
|
|
@ -4,7 +4,6 @@ import slug from 'slug';
|
|||
import randomstring from 'randomstring';
|
||||
import { DataTypes, sequelize } from '../sequelize';
|
||||
import { asyncLock } from '../redis';
|
||||
import events from '../events';
|
||||
import Document from './Document';
|
||||
import CollectionUser from './CollectionUser';
|
||||
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) => {
|
||||
if (model.private) {
|
||||
return CollectionUser.findOrCreate({
|
||||
|
|
|
@ -10,7 +10,6 @@ import removeMarkdown from '@tommoor/remove-markdown';
|
|||
import isUUID from 'validator/lib/isUUID';
|
||||
import { Collection, User } from '../models';
|
||||
import { DataTypes, sequelize } from '../sequelize';
|
||||
import events from '../events';
|
||||
import parseTitle from '../../shared/utils/parseTitle';
|
||||
import unescape from '../../shared/utils/unescape';
|
||||
import Revision from './Revision';
|
||||
|
@ -289,14 +288,9 @@ Document.addHook('afterCreate', async model => {
|
|||
await collection.addDocumentToStructure(model);
|
||||
model.collection = collection;
|
||||
|
||||
events.add({ name: 'documents.create', model });
|
||||
return model;
|
||||
});
|
||||
|
||||
Document.addHook('afterDestroy', model =>
|
||||
events.add({ name: 'documents.delete', model })
|
||||
);
|
||||
|
||||
// Instance methods
|
||||
|
||||
// Note: This method marks the document and it's children as deleted
|
||||
|
@ -353,7 +347,6 @@ Document.prototype.publish = async function() {
|
|||
await this.save();
|
||||
this.collection = collection;
|
||||
|
||||
events.add({ name: 'documents.publish', model: this });
|
||||
return this;
|
||||
};
|
||||
|
||||
|
@ -367,7 +360,6 @@ Document.prototype.archive = async function(userId) {
|
|||
|
||||
await this.archiveWithChildren(userId);
|
||||
|
||||
events.add({ name: 'documents.archive', model: this });
|
||||
return this;
|
||||
};
|
||||
|
||||
|
@ -397,7 +389,6 @@ Document.prototype.unarchive = async function(userId) {
|
|||
this.lastModifiedById = userId;
|
||||
await this.save();
|
||||
|
||||
events.add({ name: 'documents.unarchive', model: this });
|
||||
return this;
|
||||
};
|
||||
|
||||
|
@ -417,7 +408,6 @@ Document.prototype.delete = function(options) {
|
|||
|
||||
await this.destroy({ transaction, ...options });
|
||||
|
||||
events.add({ name: 'documents.delete', model: this });
|
||||
return this;
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
// @flow
|
||||
import { DataTypes, sequelize } from '../sequelize';
|
||||
import events from '../events';
|
||||
|
||||
const Integration = sequelize.define('integration', {
|
||||
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;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
// @flow
|
||||
import * as React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { groupBy, map } from 'lodash';
|
||||
import format from 'date-fns/format';
|
||||
import styled from 'styled-components';
|
||||
|
@ -25,6 +26,14 @@ function Changelog({ releases }: Props) {
|
|||
|
||||
return (
|
||||
<Grid>
|
||||
<Helmet>
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/atom+xml"
|
||||
title="Release Notes"
|
||||
href="https://github.com/outline/outline/releases.atom"
|
||||
/>
|
||||
</Helmet>
|
||||
<PageTitle title="Changelog" />
|
||||
<Header background="#00ADFF">
|
||||
<h1>Changelog</h1>
|
||||
|
|
|
@ -265,11 +265,11 @@ export default function Pricing() {
|
|||
This method allows you to publish a new document under an existing
|
||||
collection. By default a document is set to the parent collection
|
||||
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>
|
||||
<Arguments>
|
||||
<Argument
|
||||
id="collection"
|
||||
id="collectionId"
|
||||
description={
|
||||
<span>
|
||||
<Code>ID</Code> of the collection to which the document is
|
||||
|
@ -289,7 +289,7 @@ export default function Pricing() {
|
|||
required
|
||||
/>
|
||||
<Argument
|
||||
id="parentDocument"
|
||||
id="parentDocumentId"
|
||||
description={
|
||||
<span>
|
||||
<Code>ID</Code> of the parent document within the collection
|
||||
|
|
|
@ -18,8 +18,9 @@ allow(
|
|||
if (
|
||||
collection.private &&
|
||||
!map(collection.users, u => u.id).includes(user.id)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -28,8 +29,12 @@ allow(
|
|||
allow(User, 'delete', Collection, (user, collection) => {
|
||||
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;
|
||||
}
|
||||
|
||||
if (user.isAdmin) return true;
|
||||
if (user.id === collection.creatorId) return true;
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
// @flow
|
||||
import { type Context } from 'koa';
|
||||
import { ApiKey } from '../models';
|
||||
|
||||
function present(ctx: Context, key: ApiKey) {
|
||||
export default function present(key: ApiKey) {
|
||||
return {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
secret: key.secret,
|
||||
};
|
||||
}
|
||||
|
||||
export default present;
|
||||
|
|
|
@ -18,9 +18,7 @@ const sortDocuments = (documents: Document[]): Document[] => {
|
|||
}));
|
||||
};
|
||||
|
||||
async function present(ctx: Object, collection: Collection) {
|
||||
ctx.cache.set(collection.id, collection);
|
||||
|
||||
export default function present(collection: Collection) {
|
||||
const data = {
|
||||
id: collection.id,
|
||||
url: collection.url,
|
||||
|
@ -31,6 +29,7 @@ async function present(ctx: Object, collection: Collection) {
|
|||
private: collection.private,
|
||||
createdAt: collection.createdAt,
|
||||
updatedAt: collection.updatedAt,
|
||||
deletedAt: collection.deletedAt,
|
||||
documents: undefined,
|
||||
};
|
||||
|
||||
|
@ -41,5 +40,3 @@ async function present(ctx: Object, collection: Collection) {
|
|||
|
||||
return data;
|
||||
}
|
||||
|
||||
export default present;
|
||||
|
|
|
@ -2,18 +2,16 @@
|
|||
import { takeRight } from 'lodash';
|
||||
import { User, Document } from '../models';
|
||||
import presentUser from './user';
|
||||
import presentCollection from './collection';
|
||||
|
||||
type Options = {
|
||||
isPublic?: boolean,
|
||||
};
|
||||
|
||||
async function present(ctx: Object, document: Document, options: ?Options) {
|
||||
export default async function present(document: Document, options: ?Options) {
|
||||
options = {
|
||||
isPublic: false,
|
||||
...options,
|
||||
};
|
||||
ctx.cache.set(document.id, document);
|
||||
|
||||
// For empty document content, return the title
|
||||
if (!document.text.trim()) {
|
||||
|
@ -36,32 +34,27 @@ async function present(ctx: Object, document: Document, options: ?Options) {
|
|||
deletedAt: document.deletedAt,
|
||||
team: document.teamId,
|
||||
collaborators: [],
|
||||
starred: !!(document.starred && document.starred.length),
|
||||
starred: document.starred ? !!document.starred.length : undefined,
|
||||
revision: document.revisionCount,
|
||||
pinned: undefined,
|
||||
collectionId: undefined,
|
||||
collection: undefined,
|
||||
parentDocumentId: undefined,
|
||||
};
|
||||
|
||||
if (!options.isPublic) {
|
||||
data.pinned = !!document.pinnedById;
|
||||
data.collectionId = document.collectionId;
|
||||
data.createdBy = presentUser(ctx, document.createdBy);
|
||||
data.updatedBy = presentUser(ctx, document.updatedBy);
|
||||
data.parentDocumentId = document.parentDocumentId;
|
||||
data.createdBy = presentUser(document.createdBy);
|
||||
data.updatedBy = presentUser(document.updatedBy);
|
||||
|
||||
if (document.collection) {
|
||||
data.collection = await presentCollection(ctx, document.collection);
|
||||
}
|
||||
|
||||
// This could be further optimized by using ctx.cache
|
||||
// TODO: This could be further optimized
|
||||
data.collaborators = await User.findAll({
|
||||
where: {
|
||||
id: takeRight(document.collaboratorIds, 10) || [],
|
||||
},
|
||||
}).map(user => presentUser(ctx, user));
|
||||
}).map(presentUser);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export default present;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @flow
|
||||
import { Integration } from '../models';
|
||||
|
||||
function present(ctx: Object, integration: Integration) {
|
||||
export default function present(integration: Integration) {
|
||||
return {
|
||||
id: integration.id,
|
||||
type: integration.type,
|
||||
|
@ -16,5 +16,3 @@ function present(ctx: Object, integration: Integration) {
|
|||
updatedAt: integration.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export default present;
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
// @flow
|
||||
import type { Context } from 'koa';
|
||||
import { NotificationSetting } from '../models';
|
||||
|
||||
function present(ctx: Context, setting: NotificationSetting) {
|
||||
export default function present(setting: NotificationSetting) {
|
||||
return {
|
||||
id: setting.id,
|
||||
event: setting.event,
|
||||
};
|
||||
}
|
||||
|
||||
export default present;
|
||||
|
|
|
@ -2,15 +2,13 @@
|
|||
import { Revision } from '../models';
|
||||
import presentUser from './user';
|
||||
|
||||
function present(ctx: Object, revision: Revision) {
|
||||
export default function present(revision: Revision) {
|
||||
return {
|
||||
id: revision.id,
|
||||
documentId: revision.documentId,
|
||||
title: revision.title,
|
||||
text: revision.text,
|
||||
createdAt: revision.createdAt,
|
||||
createdBy: presentUser(ctx, revision.user),
|
||||
createdBy: presentUser(revision.user),
|
||||
};
|
||||
}
|
||||
|
||||
export default present;
|
||||
|
|
|
@ -2,16 +2,14 @@
|
|||
import { Share } from '../models';
|
||||
import { presentUser } from '.';
|
||||
|
||||
function present(ctx: Object, share: Share) {
|
||||
export default function present(share: Share) {
|
||||
return {
|
||||
id: share.id,
|
||||
documentTitle: share.document.title,
|
||||
documentUrl: share.document.url,
|
||||
url: `${process.env.URL}/share/${share.id}`,
|
||||
createdBy: presentUser(ctx, share.user),
|
||||
createdBy: presentUser(share.user),
|
||||
createdAt: share.createdAt,
|
||||
updatedAt: share.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export default present;
|
||||
|
|
|
@ -8,7 +8,7 @@ type Action = {
|
|||
value: string,
|
||||
};
|
||||
|
||||
function present(
|
||||
export default function present(
|
||||
document: Document,
|
||||
team: Team,
|
||||
context?: string,
|
||||
|
@ -31,5 +31,3 @@ function present(
|
|||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
export default present;
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
// @flow
|
||||
import { Team } from '../models';
|
||||
|
||||
function present(ctx: Object, team: Team) {
|
||||
ctx.cache.set(team.id, team);
|
||||
|
||||
export default function present(team: Team) {
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
|
@ -16,5 +14,3 @@ function present(ctx: Object, team: Team) {
|
|||
url: team.url,
|
||||
};
|
||||
}
|
||||
|
||||
export default present;
|
||||
|
|
|
@ -14,11 +14,7 @@ type UserPresentation = {
|
|||
isSuspended: boolean,
|
||||
};
|
||||
|
||||
export default (
|
||||
ctx: Object,
|
||||
user: User,
|
||||
options: Options = {}
|
||||
): ?UserPresentation => {
|
||||
export default (user: User, options: Options = {}): ?UserPresentation => {
|
||||
const userData = {};
|
||||
userData.id = user.id;
|
||||
userData.createdAt = user.createdAt;
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import presentUser from './user';
|
||||
import ctx from '../../__mocks__/ctx';
|
||||
|
||||
it('presents a user', async () => {
|
||||
const user = await presentUser(ctx, {
|
||||
const user = await presentUser({
|
||||
id: '123',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
|
@ -16,7 +15,7 @@ it('presents a user', async () => {
|
|||
});
|
||||
|
||||
it('presents a user without slack data', async () => {
|
||||
const user = await presentUser(ctx, {
|
||||
const user = await presentUser({
|
||||
id: '123',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
|
|
|
@ -2,15 +2,13 @@
|
|||
import { View } from '../models';
|
||||
import { presentUser } from '../presenters';
|
||||
|
||||
function present(ctx: Object, view: View) {
|
||||
export default function present(view: View) {
|
||||
return {
|
||||
id: view.id,
|
||||
documentId: view.documentId,
|
||||
count: view.count,
|
||||
firstViewedAt: view.createdAt,
|
||||
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)));
|
||||
|
||||
// catch all for react app
|
||||
router.get('*', async ctx => {
|
||||
router.get('*', async (ctx, next) => {
|
||||
if (ctx.request.path === '/realtime/') return next();
|
||||
|
||||
await renderapp(ctx);
|
||||
if (!ctx.status) ctx.throw(new NotFoundError());
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from 'fetch-test-server';
|
||||
import app from '.';
|
||||
import app from './app';
|
||||
import { flushdb } from './test/support';
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
|
|
|
@ -17,7 +17,7 @@ export default class Notifications {
|
|||
}
|
||||
|
||||
async documentUpdated(event: Event) {
|
||||
const document = await Document.findById(event.model.id);
|
||||
const document = await Document.findById(event.modelId);
|
||||
if (!document) return;
|
||||
|
||||
const { collection } = document;
|
||||
|
@ -67,7 +67,7 @@ export default class Notifications {
|
|||
}
|
||||
|
||||
async collectionCreated(event: Event) {
|
||||
const collection = await Collection.findById(event.model.id, {
|
||||
const collection = await Collection.findById(event.modelId, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
|
|
|
@ -18,7 +18,7 @@ export default class Slack {
|
|||
async integrationCreated(event: Event) {
|
||||
const integration = await Integration.findOne({
|
||||
where: {
|
||||
id: event.model.id,
|
||||
id: event.modelId,
|
||||
service: 'slack',
|
||||
type: 'post',
|
||||
},
|
||||
|
@ -57,9 +57,12 @@ export default class Slack {
|
|||
}
|
||||
|
||||
async documentUpdated(event: Event) {
|
||||
const document = await Document.findById(event.model.id);
|
||||
const document = await Document.findById(event.modelId);
|
||||
if (!document) return;
|
||||
|
||||
// never send information on draft documents
|
||||
if (!document.publishedAt) return;
|
||||
|
||||
const integration = await Integration.findOne({
|
||||
where: {
|
||||
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
|
||||
/* global jest */
|
||||
require('dotenv').config({ silent: true });
|
||||
|
||||
// test environment variables
|
||||
|
@ -26,3 +27,7 @@ function 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',
|
||||
});
|
||||
|
||||
await collection.reload();
|
||||
|
||||
return {
|
||||
user,
|
||||
admin,
|
||||
collection: document.collection,
|
||||
collection,
|
||||
document,
|
||||
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 = {
|
||||
title: 'Getting started with codebase',
|
||||
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
|
||||
};
|
||||
|
||||
|
|
|
@ -18,11 +18,10 @@ type Props = {
|
|||
};
|
||||
|
||||
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||
const path = document.pathToDocument.slice(0, -1);
|
||||
if (!document.collection) return null;
|
||||
const collection = collections.get(document.collectionId);
|
||||
if (!collection) return null;
|
||||
|
||||
const collection =
|
||||
collections.data.get(document.collection.id) || document.collection;
|
||||
const path = collection.pathToDocument(document).slice(0, -1);
|
||||
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
|
|
|
@ -17,7 +17,8 @@ const definePlugin = new webpack.DefinePlugin({
|
|||
'process.env': {
|
||||
URL: JSON.stringify(process.env.URL),
|
||||
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.GOOGLE_ANALYTICS_ID': JSON.stringify(process.env.GOOGLE_ANALYTICS_ID),
|
||||
'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:
|
||||
event-target-shim "^5.0.0"
|
||||
|
||||
accepts@^1.3.5:
|
||||
accepts@^1.3.5, accepts@~1.3.4:
|
||||
version "1.3.5"
|
||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
|
||||
dependencies:
|
||||
|
@ -215,6 +215,10 @@ acorn@^6.0.1, acorn@^6.0.7:
|
|||
version "6.1.1"
|
||||
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:
|
||||
version "4.2.1"
|
||||
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"
|
||||
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:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
|
||||
|
@ -1192,6 +1200,10 @@ babylon@^6.18.0:
|
|||
version "6.18.0"
|
||||
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:
|
||||
version "1.0.3"
|
||||
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"
|
||||
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:
|
||||
version "1.3.0"
|
||||
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:
|
||||
version "0.11.2"
|
||||
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"
|
||||
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:
|
||||
version "1.6.42"
|
||||
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"
|
||||
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:
|
||||
version "3.5.3"
|
||||
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"
|
||||
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:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50"
|
||||
|
@ -1986,10 +2020,18 @@ commondir@^1.0.1:
|
|||
version "1.0.1"
|
||||
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"
|
||||
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:
|
||||
version "2.0.16"
|
||||
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:
|
||||
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:
|
||||
version "0.7.3"
|
||||
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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
|
||||
dependencies:
|
||||
|
@ -2381,7 +2427,7 @@ debug@3.1.0, debug@~3.1.0:
|
|||
dependencies:
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||
dependencies:
|
||||
|
@ -2724,6 +2770,43 @@ ends-with@^0.2.0:
|
|||
version "0.2.0"
|
||||
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:
|
||||
version "3.4.1"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e"
|
||||
|
@ -3809,6 +3892,16 @@ has-ansi@^2.0.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "1.0.0"
|
||||
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"
|
||||
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:
|
||||
version "2.0.0"
|
||||
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"
|
||||
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:
|
||||
version "1.0.6"
|
||||
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"
|
||||
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:
|
||||
version "0.1.0"
|
||||
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"
|
||||
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:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
|
||||
|
@ -7469,7 +7586,7 @@ redis-parser@^3.0.0:
|
|||
dependencies:
|
||||
redis-errors "^1.0.0"
|
||||
|
||||
redis@^2.6.2:
|
||||
redis@^2.6.2, redis@~2.8.0:
|
||||
version "2.8.0"
|
||||
resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02"
|
||||
dependencies:
|
||||
|
@ -8208,6 +8325,65 @@ snapdragon@^0.8.1:
|
|||
source-map-resolve "^0.5.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:
|
||||
version "1.1.2"
|
||||
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"
|
||||
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:
|
||||
version "1.0.1"
|
||||
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"
|
||||
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:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/umzug/-/umzug-2.2.0.tgz#6160bdc1817e4a63a625946775063c638623e62e"
|
||||
|
@ -9514,6 +9698,12 @@ ws@^5.2.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "0.1.0"
|
||||
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"
|
||||
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:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
|
||||
|
@ -9671,6 +9865,10 @@ yargs@~3.10.0:
|
|||
decamelize "^1.0.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:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
|
||||
|
|
Reference in New Issue