Merge develop

This commit is contained in:
Tom Moor 2020-09-13 10:50:13 -07:00
commit 0b2107c1ee
63 changed files with 1045 additions and 301 deletions

View File

@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.46.0
Licensed Work: Outline 0.47.1
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2023-08-12
Change Date: 2023-09-11
Change License: Apache License, Version 2.0

View File

@ -1,11 +1,13 @@
// @flow
import { observer, inject } from "mobx-react";
import {
PadlockIcon,
ArchiveIcon,
EditIcon,
GoToIcon,
MoreIcon,
PadlockIcon,
ShapesIcon,
EditIcon,
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
@ -25,11 +27,73 @@ type Props = {
onlyText: boolean,
};
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
const collection = collections.get(document.collectionId);
if (!collection) return <div />;
function Icon({ document }) {
if (document.isDeleted) {
return (
<>
<CollectionName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>Trash</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CollectionName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>Archive</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>Templates</span>
</CollectionName>
<Slash />
</>
);
}
return null;
}
const path = collection.pathToDocument(document).slice(0, -1);
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
let collection = collections.get(document.collectionId);
if (!collection) {
if (!document.deletedAt) return <div />;
collection = {
id: document.collectionId,
name: "Deleted Collection",
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document).slice(0, -1)
: [];
if (onlyText === true) {
return (
@ -50,34 +114,13 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
);
}
const isTemplate = document.isTemplate;
const isDraft = !document.publishedAt && !isTemplate;
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Wrapper justify="flex-start" align="center">
{isTemplate && (
<>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>Templates</span>
</CollectionName>
<Slash />
</>
)}
{isDraft && (
<>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</>
)}
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
@ -127,12 +170,12 @@ export const Slash = styled(GoToIcon)`
const Overflow = styled(MoreIcon)`
flex-shrink: 0;
opacity: 0.25;
transition: opacity 100ms ease-in-out;
fill: ${(props) => props.theme.divider};
&:hover,
&:active {
opacity: 1;
&:active,
&:hover {
fill: ${(props) => props.theme.text};
}
`;

View File

@ -1,6 +1,6 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import { darken } from "polished";
import * as React from "react";
import styled from "styled-components";
@ -19,7 +19,6 @@ const RealButton = styled.button`
height: 32px;
text-decoration: none;
flex-shrink: 0;
outline: none;
cursor: pointer;
user-select: none;
@ -36,13 +35,6 @@ const RealButton = styled.button`
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
&:focus {
transition-duration: 0.05s;
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 3px;
outline: none;
}
&:disabled {
cursor: default;
pointer-events: none;
@ -70,13 +62,6 @@ const RealButton = styled.button`
border: 1px solid ${props.theme.buttonNeutralBorder};
}
&:focus {
transition-duration: 0.05s;
border: 1px solid ${lighten(0.4, props.theme.buttonBackground)};
box-shadow: ${lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 2px;
}
&:disabled {
color: ${props.theme.textTertiary};
}
@ -89,12 +74,6 @@ const RealButton = styled.button`
&:hover {
background: ${darken(0.05, props.theme.danger)};
}
&:focus {
transition-duration: 0.05s;
box-shadow: ${lighten(0.4, props.theme.danger)} 0px 0px
0px 3px;
}
`};
`;

View File

@ -18,7 +18,7 @@ function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === "dark"
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.12
? collection.color
: "currentColor"

View File

@ -193,6 +193,7 @@ const Header = styled(Flex)`
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.divider};
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default inject("documents", "revisions")(DocumentHistory);

View File

@ -36,7 +36,7 @@ class RevisionListItem extends React.Component<Props> {
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt}>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
</Time>
</Meta>

View File

@ -181,7 +181,6 @@ const DocumentLink = styled(Link)`
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
outline: none;
${SecondaryActions} {
opacity: 1;

View File

@ -80,7 +80,6 @@ const MenuItem = styled.a`
&:focus {
color: ${props.theme.white};
background: ${props.theme.primary};
outline: none;
}
`};
`;

View File

@ -34,14 +34,14 @@ class Editor extends React.Component<PropsWithRef> {
return result.url;
};
onClickLink = (href: string) => {
onClickLink = (href: string, event: MouseEvent) => {
// on page hash
if (href[0] === "#") {
window.location.href = href;
return;
}
if (isInternalUrl(href)) {
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
// relative
let navigateTo = href;

View File

@ -55,7 +55,26 @@ class ErrorBoundary extends React.Component<Props> {
render() {
if (this.error) {
const error = this.error;
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
return (
<CenteredContent>
<PageTitle title="Module failed to load" />
<h1>Loading Failed</h1>
<HelpText>
Sorry, part of the application failed to load. This may be because
it was updated since you opened the tab or because of a failed
network request. Please try reloading.
</HelpText>
<p>
<Button onClick={this.handleReload}>Reload</Button>
</p>
</CenteredContent>
);
}
return (
<CenteredContent>
@ -66,7 +85,7 @@ class ErrorBoundary extends React.Component<Props> {
{isReported && " our engineers have been notified"}. Please try
reloading the page, it may have been a temporary glitch.
</HelpText>
{this.showDetails && <Pre>{this.error.toString()}</Pre>}
{this.showDetails && <Pre>{error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>Reload</Button>{" "}
{this.showDetails ? (

View File

@ -5,7 +5,7 @@ import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { fadeAndSlideIn } from "shared/styles/animations";
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import isInternalUrl from "utils/isInternalUrl";
@ -21,7 +21,7 @@ type Props = {
};
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
const slug = parseDocumentSlugFromUrl(node.href);
const slug = parseDocumentSlug(node.href);
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef();

View File

@ -3,7 +3,7 @@ import { inject, observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
import Editor from "components/Editor";
@ -15,7 +15,7 @@ type Props = {
};
function HoverPreviewDocument({ url, documents, children }: Props) {
const slug = parseDocumentSlugFromUrl(url);
const slug = parseDocumentSlug(url);
documents.prefetchDocument(slug, {
prefetch: true,

View File

@ -1,5 +1,4 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
@ -11,13 +10,6 @@ const Button = styled.button`
line-height: 0;
border: 0;
padding: 0;
&:focus {
transition-duration: 0.05s;
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 3px;
outline: none;
}
`;
export default React.forwardRef<any, typeof Button>((props, ref) => (

View File

@ -57,10 +57,16 @@ const TeamName = styled.div`
font-size: 16px;
`;
const Header = styled(Flex)`
const Header = styled.button`
display: flex;
align-items: center;
flex-shrink: 0;
padding: 16px 24px;
position: relative;
background: none;
line-height: inherit;
border: 0;
margin: 0;
cursor: pointer;
width: 100%;

View File

@ -143,7 +143,6 @@ const StyledNavLink = styled(NavLink)`
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.black05};
outline: none;
}
&:hover {

View File

@ -125,6 +125,8 @@ class SocketProvider extends React.Component<Props> {
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
policies.remove(documentId);
continue;
}
@ -172,7 +174,21 @@ class SocketProvider extends React.Component<Props> {
const collection = collections.get(collectionId) || {};
if (event.event === "collections.delete") {
const collection = collections.get(collectionId);
if (collection) {
collection.deletedAt = collectionDescriptor.updatedAt;
}
const deletedDocuments = documents.inCollection(collectionId);
deletedDocuments.forEach((doc) => {
doc.deletedAt = collectionDescriptor.updatedAt;
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
continue;
}
@ -187,9 +203,10 @@ class SocketProvider extends React.Component<Props> {
await collections.fetch(collectionId, { force: true });
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
collections.remove(collectionId);
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
return;
}
}

View File

@ -1,5 +1,4 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@ -10,7 +9,6 @@ type Props = {
const StyledNavLink = styled(NavLink)`
position: relative;
bottom: -1px;
display: inline-block;
font-weight: 500;
@ -24,13 +22,6 @@ const StyledNavLink = styled(NavLink)`
border-bottom: 3px solid ${(props) => props.theme.divider};
padding-bottom: 5px;
}
&:focus {
outline: none;
border-bottom: 3px solid
${(props) => lighten(0.4, props.theme.buttonBackground)};
padding-bottom: 5px;
}
`;
function Tab({ theme, ...rest }: Props) {

View File

@ -6,7 +6,7 @@ const TeamLogo = styled.img`
height: 38px;
border-radius: 4px;
background: ${(props) => props.theme.background};
border: 1px solid ${(props) => props.theme.divider};
outline: 1px solid ${(props) => props.theme.divider};
`;
export default TeamLogo;

View File

@ -23,6 +23,7 @@ function eachMinute(fn) {
type Props = {
dateTime: string,
children?: React.Node,
tooltipDelay?: number,
};
class Time extends React.Component<Props> {
@ -42,6 +43,7 @@ class Time extends React.Component<Props> {
return (
<Tooltip
tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")}
delay={this.props.tooltipDelay}
placement="bottom"
>
<time dateTime={this.props.dateTime}>

22
app/embeds/ClickUp.js Normal file
View File

@ -0,0 +1,22 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://share.clickup.com/[a-z]/[a-z]/(.*)/(.*)$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class ClickUp extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.attrs.href} title="ClickUp Embed" />;
}
}

View File

@ -0,0 +1,17 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import ClickUp from "./ClickUp";
describe("ClickUp", () => {
const match = ClickUp.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://share.clickup.com/b/h/6-9310960-2/c9d837d74182317".match(match)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://share.clickup.com".match(match)).toBe(null);
expect("https://clickup.com/".match(match)).toBe(null);
expect("https://clickup.com/features".match(match)).toBe(null);
});
});

View File

@ -3,6 +3,7 @@ import * as React from "react";
import styled from "styled-components";
import Abstract from "./Abstract";
import Airtable from "./Airtable";
import ClickUp from "./ClickUp";
import Codepen from "./Codepen";
import Figma from "./Figma";
import Framer from "./Framer";
@ -57,6 +58,13 @@ export default [
component: Airtable,
matcher: matcher(Airtable),
},
{
title: "ClickUp",
keywords: "project",
icon: () => <Img src="/images/clickup.png" />,
component: ClickUp,
matcher: matcher(ClickUp),
},
{
title: "Codepen",
keywords: "code editor",

View File

@ -1,5 +1,6 @@
// @flow
import "mobx-react-lite/batchingForReactDom";
import "focus-visible";
import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";

View File

@ -3,7 +3,6 @@ import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { Redirect } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import CollectionStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
@ -12,13 +11,18 @@ import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentShare from "scenes/DocumentShare";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import CollectionIcon from "components/CollectionIcon";
import {
DropdownMenu,
DropdownMenuItem,
Header,
} from "components/DropdownMenu";
import Modal from "components/Modal";
import {
documentUrl,
documentMoveUrl,
editDocumentUrl,
documentHistoryUrl,
documentMoveUrl,
documentUrl,
editDocumentUrl,
newDocumentUrl,
} from "utils/routeHelpers";
@ -34,6 +38,7 @@ type Props = {
showPrint?: boolean,
showToggleEmbeds?: boolean,
showPin?: boolean,
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
};
@ -101,11 +106,19 @@ class DocumentMenu extends React.Component<Props> {
this.props.ui.showToast("Document archived");
};
handleRestore = async (ev: SyntheticEvent<>) => {
await this.props.document.restore();
handleRestore = async (
ev: SyntheticEvent<>,
options?: { collectionId: string }
) => {
await this.props.document.restore(options);
this.props.ui.showToast("Document restored");
};
handleUnpublish = async (ev: SyntheticEvent<>) => {
await this.props.document.unpublish();
this.props.ui.showToast("Document unpublished");
};
handlePin = (ev: SyntheticEvent<>) => {
this.props.document.pin();
};
@ -150,6 +163,8 @@ class DocumentMenu extends React.Component<Props> {
showPrint,
showPin,
auth,
collections,
label,
onOpen,
onClose,
} = this.props;
@ -157,6 +172,7 @@ class DocumentMenu extends React.Component<Props> {
const can = policies.abilities(document.id);
const canShareDocuments = can.share && auth.team && auth.team.sharing;
const canViewHistory = can.read && !can.restore;
const collection = collections.get(document.collectionId);
return (
<>
@ -165,12 +181,47 @@ class DocumentMenu extends React.Component<Props> {
position={position}
onOpen={onOpen}
onClose={onClose}
label={label}
>
{(can.unarchive || can.restore) && (
{can.unarchive && (
<DropdownMenuItem onClick={this.handleRestore}>
Restore
</DropdownMenuItem>
)}
{can.restore &&
(collection ? (
<DropdownMenuItem onClick={this.handleRestore}>
Restore
</DropdownMenuItem>
) : (
<DropdownMenu
label={<DropdownMenuItem>Restore</DropdownMenuItem>}
style={{
left: -170,
position: "relative",
top: -40,
}}
hover
>
<Header>Choose a collection</Header>
{collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return (
<DropdownMenuItem
key={collection.id}
onClick={(ev) =>
this.handleRestore(ev, { collectionId: collection.id })
}
disabled={!can.update}
>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</DropdownMenuItem>
);
})}
</DropdownMenu>
))}
{showPin &&
(document.pinned
? can.unpin && (
@ -230,6 +281,11 @@ class DocumentMenu extends React.Component<Props> {
Create template
</DropdownMenuItem>
)}
{can.unpublish && (
<DropdownMenuItem onClick={this.handleUnpublish}>
Unpublish
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
)}

View File

@ -24,7 +24,7 @@ type Props = {
class RevisionMenu extends React.Component<Props> {
handleRestore = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await this.props.document.restore(this.props.revision);
await this.props.document.restore({ revisionId: this.props.revision.id });
this.props.ui.showToast("Document restored");
this.props.history.push(this.props.document.url);
};

View File

@ -31,10 +31,15 @@ class ShareMenu extends React.Component<Props> {
this.redirectTo = this.props.share.documentUrl;
};
handleRevoke = (ev: SyntheticEvent<>) => {
handleRevoke = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.props.shares.revoke(this.props.share);
this.props.ui.showToast("Share link revoked");
try {
await this.props.shares.revoke(this.props.share);
this.props.ui.showToast("Share link revoked");
} catch (err) {
this.props.ui.showToast(err.message);
}
};
handleCopy = () => {

View File

@ -1,12 +1,11 @@
// @flow
import addDays from "date-fns/add_days";
import invariant from "invariant";
import { action, set, observable, computed } from "mobx";
import { action, computed, observable, set } from "mobx";
import parseTitle from "shared/utils/parseTitle";
import unescape from "shared/utils/unescape";
import DocumentsStore from "stores/DocumentsStore";
import BaseModel from "models/BaseModel";
import Revision from "models/Revision";
import User from "models/User";
type SaveOptions = {
@ -141,8 +140,12 @@ export default class Document extends BaseModel {
return this.store.archive(this);
};
restore = (revision: Revision) => {
return this.store.restore(this, revision);
restore = (options) => {
return this.store.restore(this, options);
};
unpublish = () => {
return this.store.unpublish(this);
};
@action

View File

@ -36,6 +36,7 @@ import Tab from "components/Tab";
import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import CollectionMenu from "menus/CollectionMenu";
import { AuthorizationError } from "utils/errors";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
type Props = {
@ -65,6 +66,15 @@ class CollectionScene extends React.Component<Props> {
componentDidUpdate(prevProps) {
const { id } = this.props.match.params;
if (this.collection) {
const { collection } = this;
const policy = this.props.policies.get(collection.id);
if (!policy) {
this.loadContent(collection.id);
}
}
if (id && id !== prevProps.match.params.id) {
this.loadContent(id);
}
@ -75,18 +85,24 @@ class CollectionScene extends React.Component<Props> {
}
loadContent = async (id: string) => {
const collection = await this.props.collections.fetch(id);
try {
const collection = await this.props.collections.fetch(id);
if (collection) {
this.props.ui.setActiveCollection(collection);
this.collection = collection;
if (collection) {
this.props.ui.setActiveCollection(collection);
this.collection = collection;
await this.props.documents.fetchPinned({
collectionId: id,
});
await this.props.documents.fetchPinned({
collectionId: id,
});
}
} catch (error) {
if (error instanceof AuthorizationError) {
this.collection = null;
}
} finally {
this.isFetching = false;
}
this.isFetching = false;
};
onNewDocument = (ev: SyntheticEvent<>) => {

View File

@ -46,8 +46,9 @@ class CollectionDelete extends React.Component<Props> {
<form onSubmit={this.handleSubmit}>
<HelpText>
Are you sure about that? Deleting the{" "}
<strong>{collection.name}</strong> collection is permanent and will
also delete all of the documents within it, so be extra careful.
<strong>{collection.name}</strong> collection is permanent and
cannot be restored, however documents within will be moved to the
trash.
</HelpText>
<Button type="submit" disabled={this.isDeleting} autoFocus danger>
{this.isDeleting ? "Deleting…" : "Im sure  Delete"}

View File

@ -20,20 +20,12 @@ type Props = {
@observer
class CollectionEdit extends React.Component<Props> {
@observable name: string;
@observable description: string = "";
@observable icon: string = "";
@observable color: string = "#4E5C6E";
@observable name: string = this.props.collection.name;
@observable description: string = this.props.collection.description;
@observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E";
@observable private: boolean = this.props.collection.private;
@observable isSaving: boolean;
@observable private: boolean = false;
componentDidMount() {
this.name = this.props.collection.name;
this.description = this.props.collection.description;
this.icon = this.props.collection.icon;
this.color = this.props.collection.color;
this.private = this.props.collection.private;
}
handleSubmit = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();

View File

@ -5,6 +5,7 @@ import { observer, inject } from "mobx-react";
import * as React from "react";
import type { RouterHistory, Match } from "react-router-dom";
import { withRouter } from "react-router-dom";
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import RevisionsStore from "stores/RevisionsStore";
@ -20,6 +21,7 @@ import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
import { type LocationWithState } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import isInternalUrl from "utils/isInternalUrl";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
type Props = {|
@ -50,7 +52,8 @@ class DataLoader extends React.Component<Props> {
// reload from the server otherwise the UI will not know which authorizations
// the user has
if (this.document) {
const policy = this.props.policies.get(this.document.id);
const document = this.document;
const policy = this.props.policies.get(document.id);
if (!policy && !this.error) {
this.loadDocument();
@ -69,6 +72,26 @@ class DataLoader extends React.Component<Props> {
}
onSearchLink = async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
try {
const document = await this.props.documents.fetch(slug);
return [
{
title: document.title,
url: document.url,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await this.props.documents.search(term);
return results
@ -101,6 +124,11 @@ class DataLoader extends React.Component<Props> {
loadDocument = async () => {
const { shareId, documentSlug, revisionId } = this.props.match.params;
// sets the document as active in the sidebar if we already have it loaded
if (this.document) {
this.props.ui.setActiveDocument(this.document);
}
try {
this.document = await this.props.documents.fetch(documentSlug, {
shareId,

View File

@ -21,6 +21,7 @@ type Props = {
isDraft: boolean,
isShare: boolean,
readOnly?: boolean,
onSave: () => mixed,
innerRef: { current: any },
};
@ -40,10 +41,30 @@ class DocumentEditor extends React.Component<Props> {
}
};
insertParagraph = () => {
if (this.props.innerRef.current) {
const { view } = this.props.innerRef.current;
const { dispatch, state } = view;
dispatch(state.tr.insert(0, state.schema.nodes.paragraph.create()));
}
};
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
if (event.key === "Enter" || event.key === "Tab") {
if (event.key === "Enter" && !event.metaKey) {
event.preventDefault();
this.insertParagraph();
this.focusAtStart();
return;
}
if (event.key === "Tab" || event.key === "ArrowDown") {
event.preventDefault();
this.focusAtStart();
return;
}
if (event.key === "s" && event.metaKey) {
event.preventDefault();
this.props.onSave();
return;
}
};
@ -78,6 +99,7 @@ class DocumentEditor extends React.Component<Props> {
value={!title && readOnly ? document.titleWithDefault : title}
style={startsWithEmojiAndSpace ? { marginLeft: "-1.2em" } : undefined}
readOnly={readOnly}
disabled={readOnly}
autoFocus={!title}
maxLength={100}
/>

View File

@ -7,6 +7,7 @@ import {
EditIcon,
GlobeIcon,
PlusIcon,
MoreIcon,
} from "outline-icons";
import { transparentize, darken } from "polished";
import * as React from "react";
@ -336,6 +337,15 @@ class Header extends React.Component<Props> {
<DocumentMenu
document={document}
isRevision={isRevision}
label={
<Button
icon={<MoreIcon />}
iconColor="currentColor"
borderOnHover
neutral
small
/>
}
showToggleEmbeds={canToggleEmbeds}
showPrint
/>

View File

@ -27,7 +27,6 @@ const DocumentLink = styled(Link)`
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
outline: none;
}
`;

View File

@ -47,7 +47,7 @@ class References extends React.Component<Props> {
)}
{showBacklinks && (
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
References
Referenced by
</Tab>
)}
</Tabs>

View File

@ -41,8 +41,10 @@ class Details extends React.Component<Props> {
clearTimeout(this.timeout);
}
handleSubmit = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
handleSubmit = async (event: ?SyntheticEvent<>) => {
if (event) {
event.preventDefault();
}
try {
await this.props.auth.updateTeam({
@ -66,6 +68,7 @@ class Details extends React.Component<Props> {
handleAvatarUpload = (avatarUrl: string) => {
this.avatarUrl = avatarUrl;
this.handleSubmit();
};
handleAvatarError = (error: ?string) => {

View File

@ -86,7 +86,7 @@ class Events extends React.Component<Props> {
) : (
<>
{events.orderedData.map((event) => (
<EventListItem event={event} />
<EventListItem event={event} key={event.id} />
))}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />

View File

@ -12,6 +12,8 @@ import LoadingIndicator from "components/LoadingIndicator";
import Modal from "components/Modal";
import { uploadFile, dataUrlToBlob } from "utils/uploadFile";
const EMPTY_OBJECT = {};
type Props = {
children?: React.Node,
onSuccess: (string) => void | Promise<void>,
@ -123,10 +125,8 @@ class ImageUpload extends React.Component<Props> {
<Dropzone
accept="image/png, image/jpeg"
onDropAccepted={this.onDropAccepted}
style={{}}
style={EMPTY_OBJECT}
disablePreview
onSuccess={this.props.onSuccess}
onError={this.props.onError}
>
{this.props.children}
</Dropzone>

View File

@ -12,11 +12,9 @@ import {
} from "lodash";
import { observable, action, computed, runInAction } from "mobx";
import naturalSort from "shared/utils/naturalSort";
import BaseStore from "stores/BaseStore";
import RootStore from "stores/RootStore";
import Document from "models/Document";
import Revision from "models/Revision";
import type { FetchOptions, PaginationParams, SearchResult } from "types";
import { client } from "utils/ApiClient";
@ -435,6 +433,7 @@ export default class DocumentsStore extends BaseStore<Document> {
res.data.documents.forEach(this.add);
res.data.collections.forEach(this.rootStore.collections.add);
this.addPolicies(res.policies);
};
@action
@ -500,6 +499,13 @@ export default class DocumentsStore extends BaseStore<Document> {
this.recentlyViewedIds = without(this.recentlyViewedIds, document.id);
});
// check to see if we have any shares related to this document already
// loaded in local state. If so we can go ahead and remove those too.
const share = this.rootStore.shares.getByDocumentId(document.id);
if (share) {
this.rootStore.shares.remove(share.id);
}
const collection = this.getCollectionForDocument(document);
if (collection) collection.refresh();
}
@ -520,10 +526,10 @@ export default class DocumentsStore extends BaseStore<Document> {
};
@action
restore = async (document: Document, revision?: Revision) => {
restore = async (document: Document, options = {}) => {
const res = await client.post("/documents.restore", {
id: document.id,
revisionId: revision ? revision.id : undefined,
...options,
});
runInAction("Document#restore", () => {
invariant(res && res.data, "Data should be available");
@ -535,6 +541,22 @@ export default class DocumentsStore extends BaseStore<Document> {
if (collection) collection.refresh();
};
@action
unpublish = async (document: Document) => {
const res = await client.post("/documents.unpublish", {
id: document.id,
});
runInAction("Document#unpublish", () => {
invariant(res && res.data, "Data should be available");
document.updateFromJson(res.data);
this.addPolicies(res.policies);
});
const collection = this.getCollectionForDocument(document);
if (collection) collection.refresh();
};
pin = (document: Document) => {
return client.post("/documents.pin", { id: document.id });
};

View File

@ -66,7 +66,7 @@
"@babel/preset-flow": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@rehooks/window-scroll-position": "^1.0.1",
"@sentry/node": "^5.22.3",
"@sentry/node": "^5.23.0",
"@tippy.js/react": "^2.2.2",
"@tommoor/remove-markdown": "0.3.1",
"autotrack": "^2.4.1",
@ -88,6 +88,7 @@
"exports-loader": "^0.6.4",
"file-loader": "^1.1.6",
"flow-typed": "^2.6.2",
"focus-visible": "^5.1.0",
"fs-extra": "^4.0.2",
"google-auth-library": "^5.5.1",
"http-errors": "1.4.0",
@ -138,7 +139,7 @@
"react-portal": "^4.0.0",
"react-router-dom": "^5.1.2",
"react-waypoint": "^9.0.2",
"rich-markdown-editor": "^10.6.5",
"rich-markdown-editor": "^11.0.0-4",
"semver": "^7.3.2",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",
@ -191,5 +192,5 @@
"dot-prop": "^5.2.0",
"js-yaml": "^3.13.1"
},
"version": "0.46.1"
}
"version": "0.47.1"
}

BIN
public/images/clickup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -2,23 +2,23 @@
import Router from "koa-router";
import Sequelize from "sequelize";
import documentMover from "../commands/documentMover";
import { InvalidRequestError } from "../errors";
import { NotFoundError, InvalidRequestError } from "../errors";
import auth from "../middlewares/authentication";
import {
Backlink,
Collection,
Document,
Event,
Revision,
Share,
Star,
View,
Revision,
Backlink,
User,
View,
} from "../models";
import policy from "../policies";
import {
presentDocument,
presentCollection,
presentDocument,
presentPolicies,
} from "../presenters";
import { sequelize } from "../sequelize";
@ -210,7 +210,7 @@ router.post("documents.deleted", auth(), pagination(), async (ctx) => {
if (direction !== "ASC") direction = "DESC";
const user = ctx.state.user;
const collectionIds = await user.collectionIds();
const collectionIds = await user.collectionIds({ paranoid: false });
const collectionScope = { method: ["withCollection", user.id] };
const documents = await Document.scope(collectionScope).findAll({
@ -407,11 +407,19 @@ async function loadDocument({ id, shareId, user }) {
authorize(user, "read", document);
}
} else {
document = await Document.findByPk(
id,
user ? { userId: user.id } : undefined
);
authorize(user, "read", document);
document = await Document.findByPk(id, {
userId: user ? user.id : undefined,
paranoid: false,
});
if (!document) {
throw new NotFoundError();
}
if (document.deletedAt) {
authorize(user, "restore", document);
} else {
authorize(user, "read", document);
}
}
return document;
@ -444,7 +452,7 @@ router.post("documents.export", auth({ required: false }), async (ctx) => {
});
router.post("documents.restore", auth(), async (ctx) => {
const { id, revisionId } = ctx.body;
const { id, collectionId, revisionId } = ctx.body;
ctx.assertPresent(id, "id is required");
const user = ctx.state.user;
@ -452,6 +460,21 @@ router.post("documents.restore", auth(), async (ctx) => {
userId: user.id,
paranoid: false,
});
if (!document) {
throw new NotFoundError();
}
if (collectionId) {
ctx.assertUuid(collectionId, "collectionId must be a uuid");
authorize(user, "restore", document);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "update", collection);
document.collectionId = collectionId;
}
if (document.deletedAt) {
authorize(user, "restore", document);
@ -938,6 +961,11 @@ router.post("documents.move", auth(), async (ctx) => {
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "move", document);
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(collectionId);
authorize(user, "update", collection);
if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId, {
userId: user.id,
@ -1018,4 +1046,31 @@ router.post("documents.delete", auth(), async (ctx) => {
};
});
router.post("documents.unpublish", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertPresent(id, "id is required");
const user = ctx.state.user;
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "unpublish", document);
await document.unpublish();
await Event.create({
name: "documents.unpublish",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
ctx.body = {
data: await presentDocument(document),
policies: presentPolicies(user, [document]),
};
});
export default router;

View File

@ -185,6 +185,15 @@ describe("#documents.info", () => {
expect(body.data.id).toEqual(document.id);
});
it("should not error if document doesn't exist", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.info", {
body: { token: user.getJwtToken(), id: "test" },
});
expect(res.status).toEqual(404);
});
it("should require authorization without token", async () => {
const { document } = await seed();
const res = await server.post("/api/documents.info", {
@ -1137,7 +1146,109 @@ describe("#documents.pin", () => {
});
});
describe("#documents.move", () => {
it("should move the document", async () => {
const { user, document } = await seed();
const collection = await buildCollection({ teamId: user.teamId });
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.documents[0].collectionId).toEqual(collection.id);
});
it("should not allow moving the document to a collection the user cannot access", async () => {
const { user, document } = await seed();
const collection = await buildCollection();
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
},
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const res = await server.post("/api/documents.move");
expect(res.status).toEqual(401);
});
it("should require authorization", async () => {
const { document, collection } = await seed();
const user = await buildUser();
const res = await server.post("/api/documents.move", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
},
});
expect(res.status).toEqual(403);
});
});
describe("#documents.restore", () => {
it("should allow restore of trashed documents", async () => {
const { user, document } = await seed();
await document.destroy(user.id);
const res = await server.post("/api/documents.restore", {
body: { token: user.getJwtToken(), id: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
});
it("should allow restore of trashed documents with collectionId", async () => {
const { user, document } = await seed();
const collection = await buildCollection({
userId: user.id,
teamId: user.teamId,
});
await document.destroy(user.id);
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.deletedAt).toEqual(null);
expect(body.data.collectionId).toEqual(collection.id);
});
it("should now allow restore of trashed documents to collection user cannot access", async () => {
const { user, document } = await seed();
const collection = await buildCollection();
await document.destroy(user.id);
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: document.id,
collectionId: collection.id,
},
});
expect(res.status).toEqual(403);
});
it("should allow restore of archived documents", async () => {
const { user, document } = await seed();
await document.archive(user.id);
@ -1146,6 +1257,8 @@ describe("#documents.restore", () => {
body: { token: user.getJwtToken(), id: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.archivedAt).toEqual(null);
});
@ -1164,6 +1277,8 @@ describe("#documents.restore", () => {
body: { token: user.getJwtToken(), id: childDocument.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.parentDocumentId).toEqual(undefined);
expect(body.data.archivedAt).toEqual(null);
});
@ -1184,6 +1299,8 @@ describe("#documents.restore", () => {
body: { token: user.getJwtToken(), id: document.id, revisionId },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.text).toEqual(previousText);
});
@ -1201,6 +1318,15 @@ describe("#documents.restore", () => {
expect(res.status).toEqual(403);
});
it("should not error if document doesn't exist", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.restore", {
body: { token: user.getJwtToken(), id: "test" },
});
expect(res.status).toEqual(404);
});
it("should require authentication", async () => {
const res = await server.post("/api/documents.restore");
const body = await res.json();
@ -1736,3 +1862,59 @@ describe("#documents.delete", () => {
expect(body).toMatchSnapshot();
});
});
describe("#documents.unpublish", () => {
it("should unpublish a document", async () => {
const { user, document } = await seed();
const res = await server.post("/api/documents.unpublish", {
body: { token: user.getJwtToken(), id: document.id },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
expect(body.data.publishedAt).toBeNull();
});
it("should fail to unpublish a draft document", async () => {
const { user, document } = await seed();
document.publishedAt = null;
await document.save();
const res = await server.post("/api/documents.unpublish", {
body: { token: user.getJwtToken(), id: document.id },
});
expect(res.status).toEqual(403);
});
it("should fail to unpublish a deleted document", async () => {
const { user, document } = await seed();
await document.delete();
const res = await server.post("/api/documents.unpublish", {
body: { token: user.getJwtToken(), id: document.id },
});
expect(res.status).toEqual(403);
});
it("should fail to unpublish a archived document", async () => {
const { user, document } = await seed();
await document.archive();
const res = await server.post("/api/documents.unpublish", {
body: { token: user.getJwtToken(), id: document.id },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => {
const { document } = await seed();
const res = await server.post("/api/documents.unpublish", {
body: { id: document.id },
});
expect(res.status).toEqual(401);
});
});

View File

@ -16,8 +16,7 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
if (direction !== "ASC") direction = "DESC";
const user = ctx.state.user;
const paranoid = false;
const collectionIds = await user.collectionIds(paranoid);
const collectionIds = await user.collectionIds({ paranoid: false });
let where = {
name: Event.ACTIVITY_EVENTS,

View File

@ -21,13 +21,15 @@ router.post("shares.info", auth(), async (ctx) => {
where: id
? {
id,
revokedAt: { [Op.eq]: null },
}
: {
documentId,
userId: user.id,
revokedAt: { [Op.eq]: null },
},
});
if (!share) {
if (!share || !share.document) {
throw new NotFoundError();
}
@ -63,6 +65,7 @@ router.post("shares.list", auth(), pagination(), async (ctx) => {
{
model: Document,
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
@ -168,9 +171,12 @@ router.post("shares.revoke", auth(), async (ctx) => {
const share = await Share.findByPk(id);
authorize(user, "revoke", share);
await share.revoke(user.id);
const document = await Document.findByPk(share.documentId);
if (!document) {
throw new NotFoundError();
}
await share.revoke(user.id);
await Event.create({
name: "shares.revoke",

View File

@ -70,6 +70,25 @@ describe("#shares.list", () => {
expect(body.data.length).toEqual(0);
});
it("should not return shares to deleted documents", async () => {
const { user, document } = await seed();
await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await document.delete(user.id);
const res = await server.post("/api/shares.list", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("admins should return shares created by all users", async () => {
const { user, admin, document } = await seed();
const share = await buildShare({
@ -268,6 +287,34 @@ describe("#shares.info", () => {
expect(res.status).toEqual(404);
});
it("should not find revoked share", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await share.revoke();
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(404);
});
it("should not find share for deleted document", async () => {
const { user, document } = await seed();
await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await document.delete(user.id);
const res = await server.post("/api/shares.info", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(404);
});
it("should require authentication", async () => {
const { user, document } = await seed();
const share = await buildShare({
@ -382,6 +429,22 @@ describe("#shares.revoke", () => {
expect(res.status).toEqual(200);
});
it("should 404 if shares document is deleted", async () => {
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
await document.delete(user.id);
const res = await server.post("/api/shares.revoke", {
body: { token: user.getJwtToken(), id: share.id },
});
expect(res.status).toEqual(404);
});
it("should allow admin to revoke a share", async () => {
const { user, admin, document } = await seed();
const share = await buildShare({

View File

@ -14,7 +14,7 @@ export default async function documentMover({
user: Context,
document: Document,
collectionId: string,
parentDocumentId: string,
parentDocumentId?: string,
index?: number,
ip: string,
}) {
@ -39,6 +39,7 @@ export default async function documentMover({
// remove from original collection
const collection = await Collection.findByPk(document.collectionId, {
transaction,
paranoid: false,
});
const documentJson = await collection.removeDocumentInStructure(
document,

View File

@ -304,7 +304,12 @@ Collection.prototype.updateDocument = async function (
};
this.documentStructure = updateChildren(this.documentStructure);
await this.save({ transaction });
// Sequelize doesn't seem to set the value with splice on JSONB field
// https://github.com/sequelize/sequelize/blob/e1446837196c07b8ff0c23359b958d68af40fd6d/src/model.js#L3937
this.changed("documentStructure", true);
await this.save({ fields: ["documentStructure"], transaction });
await transaction.commit();
} catch (err) {
if (transaction) {
@ -357,10 +362,11 @@ Collection.prototype.removeDocumentInStructure = async function (
document.id
);
await this.save({
...options,
transaction,
});
// Sequelize doesn't seem to set the value with splice on JSONB field
// https://github.com/sequelize/sequelize/blob/e1446837196c07b8ff0c23359b958d68af40fd6d/src/model.js#L3937
this.changed("documentStructure", true);
await this.save({ ...options, fields: ["documentStructure"], transaction });
await transaction.commit();
} catch (err) {
if (transaction) {

View File

@ -144,7 +144,9 @@ describe("#updateDocument", () => {
await collection.updateDocument(newDocument);
expect(collection.documentStructure[0].children[0].title).toBe(
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded.documentStructure[0].children[0].title).toBe(
"Updated title"
);
});
@ -224,8 +226,10 @@ describe("#removeDocument", () => {
// Remove the document
await collection.deleteDocument(newDocument);
expect(collection.documentStructure.length).toBe(1);
expect(collection.documentStructure[0].children.length).toBe(0);
const reloaded = await Collection.findByPk(collection.id);
expect(reloaded.documentStructure.length).toBe(1);
expect(reloaded.documentStructure[0].children.length).toBe(0);
const collectionDocuments = await Document.findAndCountAll({
where: {

View File

@ -1,10 +1,9 @@
// @flow
import removeMarkdown from "@tommoor/remove-markdown";
import { map, find, compact, uniq } from "lodash";
import { compact, find, map, uniq } from "lodash";
import randomstring from "randomstring";
import Sequelize, { type Transaction } from "sequelize";
import Sequelize, { Transaction } from "sequelize";
import MarkdownSerializer from "slate-md-serializer";
import isUUID from "validator/lib/isUUID";
import parseTitle from "../../shared/utils/parseTitle";
import unescape from "../../shared/utils/unescape";
@ -19,15 +18,14 @@ const serializer = new MarkdownSerializer();
export const DOCUMENT_VERSION = 2;
const createRevision = (doc, options = {}) => {
const createRevision = async (doc, options = {}) => {
// we don't create revisions for autosaves
if (options.autosave) return;
const previous = await Revision.findLatest(doc.id);
// we don't create revisions if identical to previous
if (
doc.text === doc.previous("text") &&
doc.title === doc.previous("title")
) {
if (previous && doc.text === previous.text && doc.title === previous.title) {
return;
}
@ -556,6 +554,18 @@ Document.prototype.publish = async function (options) {
return this;
};
Document.prototype.unpublish = async function (options) {
if (!this.publishedAt) return this;
const collection = await this.getCollection();
await collection.removeDocumentInStructure(this);
this.publishedAt = null;
await this.save(options);
return this;
};
// Moves a document from being visible to the team within a collection
// to the archived area, where it can be subsequently restored.
Document.prototype.archive = async function (userId) {

View File

@ -1,5 +1,5 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { Document } from "../models";
import { Document, Revision } from "../models";
import {
buildDocument,
buildCollection,
@ -11,6 +11,37 @@ import { flushdb } from "../test/support";
beforeEach(() => flushdb());
beforeEach(jest.resetAllMocks);
describe("#createRevision", () => {
test("should create revision on document creation", async () => {
const document = await buildDocument();
document.title = "Changed";
await document.save({ autosave: true });
const amount = await Revision.count({ where: { documentId: document.id } });
expect(amount).toBe(1);
});
test("should create revision on document update identical to previous autosave", async () => {
const document = await buildDocument();
document.title = "Changed";
await document.save({ autosave: true });
document.title = "Changed";
await document.save();
const amount = await Revision.count({ where: { documentId: document.id } });
expect(amount).toBe(2);
});
test("should not create revision if autosave", async () => {
const document = await buildDocument();
const amount = await Revision.count({ where: { documentId: document.id } });
expect(amount).toBe(1);
});
});
describe("#getSummary", () => {
test("should strip markdown", async () => {
const document = await buildDocument({

View File

@ -40,6 +40,15 @@ Revision.associate = (models) => {
);
};
Revision.findLatest = function (documentId) {
return Revision.findOne({
where: {
documentId,
},
order: [["createdAt", "DESC"]],
});
};
Revision.prototype.migrateVersion = function () {
let migrated = false;

View File

@ -0,0 +1,27 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { Revision } from "../models";
import { buildDocument } from "../test/factories";
import { flushdb } from "../test/support";
beforeEach(() => flushdb());
beforeEach(jest.resetAllMocks);
describe("#findLatest", () => {
test("should return latest revision", async () => {
const document = await buildDocument({
title: "Title",
text: "Content",
});
document.title = "Changed 1";
await document.save();
document.title = "Changed 2";
await document.save();
const revision = await Revision.findLatest(document.id);
expect(revision.title).toBe("Changed 2");
expect(revision.text).toBe("Content");
});
});

View File

@ -71,13 +71,14 @@ User.associate = (models) => {
};
// Instance methods
User.prototype.collectionIds = async function (paranoid: boolean = true) {
User.prototype.collectionIds = async function (options = {}) {
const collectionStubs = await Collection.scope({
method: ["withMembership", this.id],
}).findAll({
attributes: ["id", "private"],
where: { teamId: this.teamId },
paranoid,
paranoid: true,
...options,
});
return collectionStubs

View File

@ -58,7 +58,7 @@ allow(User, "update", Document, (user, document) => {
allow(User, "createChildDocument", Document, (user, document) => {
if (document.archivedAt) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (document.template) return false;
if (!document.publishedAt) return false;
@ -157,3 +157,27 @@ allow(
Revision,
(document, revision) => document.id === revision.documentId
);
allow(User, "unpublish", Document, (user, document) => {
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
);
if (!document.publishedAt || !!document.deletedAt || !!document.archivedAt)
return false;
if (cannot(user, "update", document.collection)) return false;
const documentID = document.id;
const hasChild = (documents) =>
documents.some((doc) => {
if (doc.id === documentID) return doc.children.length > 0;
return hasChild(doc.children);
});
return (
!hasChild(document.collection.documentStructure) &&
user.teamId === document.teamId
);
});

View File

@ -4,6 +4,7 @@ import Sequelize from "sequelize";
import EncryptedField from "sequelize-encrypted";
const isProduction = process.env.NODE_ENV === "production";
const isSSLDisabled = process.env.PGSSLMODE === "disable";
export const encryptedFields = () =>
EncryptedField(Sequelize, process.env.SECRET_KEY);
@ -15,11 +16,12 @@ export const sequelize = new Sequelize(process.env.DATABASE_URL, {
logging: debug("sql"),
typeValidation: true,
dialectOptions: {
ssl: isProduction
? {
// Ref.: https://github.com/brianc/node-postgres/issues/2009
rejectUnauthorized: false,
}
: false,
ssl:
isProduction && !isSSLDisabled
? {
// Ref.: https://github.com/brianc/node-postgres/issues/2009
rejectUnauthorized: false,
}
: false,
},
});

View File

@ -44,6 +44,14 @@ export default class Backlinks {
order: [["createdAt", "desc"]],
limit: 2,
});
// before parsing document text we must make sure it's been migrated to
// the latest version or the parser may fail on version differences
await currentRevision.migrateVersion();
if (previousRevision) {
await previousRevision.migrateVersion();
}
const previousLinkIds = previousRevision
? parseDocumentIds(previousRevision.text)
: [];
@ -55,7 +63,9 @@ export default class Backlinks {
await Promise.all(
addedLinkIds.map(async (linkId) => {
const linkedDocument = await Document.findByPk(linkId);
if (linkedDocument.id === event.documentId) return;
if (!linkedDocument || linkedDocument.id === event.documentId) {
return;
}
await Backlink.findOrCreate({
where: {

View File

@ -32,6 +32,32 @@ describe("documents.update", () => {
expect(backlinks.length).toBe(1);
});
test("should not fail when previous revision is different document version", async () => {
const otherDocument = await buildDocument();
const document = await buildDocument({
version: null,
text: `[ ] checklist item`,
});
document.text = `[this is a link](${otherDocument.url})`;
await document.save();
await Backlinks.on({
name: "documents.update",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: document.createdById,
data: { autosave: false },
});
const backlinks = await Backlink.findAll({
where: { reverseDocumentId: document.id },
});
expect(backlinks.length).toBe(1);
});
test("should create new backlink records", async () => {
const otherDocument = await buildDocument();
const document = await buildDocument();

View File

@ -26,22 +26,24 @@ export default class Websockets {
paranoid: false,
});
return socketio
.to(`collection-${document.collectionId}`)
.emit("entities", {
event: event.name,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
collectionIds: [
{
id: document.collectionId,
},
],
});
const channel = document.publishedAt
? `collection-${document.collectionId}`
: `user-${event.actorId}`;
return socketio.to(channel).emit("entities", {
event: event.name,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
collectionIds: [
{
id: document.collectionId,
},
],
});
}
case "documents.delete": {
const document = await Document.findByPk(event.documentId, {
@ -84,17 +86,19 @@ export default class Websockets {
paranoid: false,
});
return socketio
.to(`collection-${document.collectionId}`)
.emit("entities", {
event: event.name,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
});
const channel = document.publishedAt
? `collection-${document.collectionId}`
: `user-${event.actorId}`;
return socketio.to(channel).emit("entities", {
event: event.name,
documentIds: [
{
id: document.id,
updatedAt: document.updatedAt,
},
],
});
}
case "documents.create": {
const document = await Document.findByPk(event.documentId);

View File

@ -76,4 +76,12 @@ export default createGlobalStyle`
height: 0;
border-top: 1px solid ${(props) => props.theme.divider};
}
.js-focus-visible :focus:not(.focus-visible) {
outline: none;
}
.js-focus-visible .focus-visible {
outline-color: ${(props) => props.theme.primary};
}
`;

View File

@ -1,12 +1,16 @@
// @flow
export function parseDocumentSlugFromUrl(url: string) {
export default function parseDocumentSlug(url: string) {
let parsed;
try {
parsed = new URL(url);
} catch (err) {
return;
if (url[0] === "/") {
parsed = url;
} else {
try {
parsed = new URL(url).pathname;
} catch (err) {
return;
}
}
return parsed.pathname.replace(/^\/doc\//, "");
return parsed.replace(/^\/doc\//, "");
}

View File

@ -0,0 +1,22 @@
// @flow
import parseDocumentSlug from "./parseDocumentSlug";
describe("#parseDocumentSlug", () => {
it("should work with fully qualified url", () => {
expect(
parseDocumentSlug("http://example.com/doc/my-doc-y4j4tR4UuV")
).toEqual("my-doc-y4j4tR4UuV");
});
it("should work with subdomain qualified url", () => {
expect(
parseDocumentSlug("http://mywiki.getoutline.com/doc/my-doc-y4j4tR4UuV")
).toEqual("my-doc-y4j4tR4UuV");
});
it("should work with path", () => {
expect(parseDocumentSlug("/doc/my-doc-y4j4tR4UuV")).toEqual(
"my-doc-y4j4tR4UuV"
);
});
});

151
yarn.lock
View File

@ -1372,72 +1372,72 @@
execa "^4.0.0"
java-properties "^1.0.0"
"@sentry/core@5.22.3":
version "5.22.3"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.22.3.tgz#030f435f2b518f282ba8bd954dac90cd70888bd7"
integrity sha512-eGL5uUarw3o4i9QUb9JoFHnhriPpWCaqeaIBB06HUpdcvhrjoowcKZj1+WPec5lFg5XusE35vez7z/FPzmJUDw==
"@sentry/core@5.23.0":
version "5.23.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.23.0.tgz#4d12ee4f5593e66fa5ffde0f9e9164a5468e5cec"
integrity sha512-K8Wp/g1opaauKJh2w5Z1Vw/YdudHQgH6Ng5fBazHZxA7zB9R8EbVKDsjy8XEcyHsWB7fTSlYX/7coqmZNOADdg==
dependencies:
"@sentry/hub" "5.22.3"
"@sentry/minimal" "5.22.3"
"@sentry/types" "5.22.3"
"@sentry/utils" "5.22.3"
"@sentry/hub" "5.23.0"
"@sentry/minimal" "5.23.0"
"@sentry/types" "5.23.0"
"@sentry/utils" "5.23.0"
tslib "^1.9.3"
"@sentry/hub@5.22.3":
version "5.22.3"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.22.3.tgz#08309a70d2ea8d5e313d05840c1711f34f2fffe5"
integrity sha512-INo47m6N5HFEs/7GMP9cqxOIt7rmRxdERunA3H2L37owjcr77MwHVeeJ9yawRS6FMtbWXplgWTyTIWIYOuqVbw==
"@sentry/hub@5.23.0":
version "5.23.0"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-5.23.0.tgz#7350c2971fafdb9f883f0629fd1b35d2288cd6e1"
integrity sha512-P0sevLI9qAQc1J+AcHzNXwj83aG3GKiABVQJp0rgCUMtrXqLawa+j8pOHg8p7QWroHM7TKDMKeny9WemXBgzBQ==
dependencies:
"@sentry/types" "5.22.3"
"@sentry/utils" "5.22.3"
"@sentry/types" "5.23.0"
"@sentry/utils" "5.23.0"
tslib "^1.9.3"
"@sentry/minimal@5.22.3":
version "5.22.3"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.22.3.tgz#706e4029ae5494123d3875c658ba8911aa5cc440"
integrity sha512-HoINpYnVYCpNjn2XIPIlqH5o4BAITpTljXjtAftOx6Hzj+Opjg8tR8PWliyKDvkXPpc4kXK9D6TpEDw8MO0wZA==
"@sentry/minimal@5.23.0":
version "5.23.0"
resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-5.23.0.tgz#93df781a98f0b334425f68b1fa0f4e1a86d8fa45"
integrity sha512-/w/B7ShMVu/tLI0/A5X+w6GfdZIQdFQihWyIK1vXaYS5NS6biGI3K6DcACuMrD/h4BsqlfgdXSOHHrmCJcyCXQ==
dependencies:
"@sentry/hub" "5.22.3"
"@sentry/types" "5.22.3"
"@sentry/hub" "5.23.0"
"@sentry/types" "5.23.0"
tslib "^1.9.3"
"@sentry/node@^5.22.3":
version "5.22.3"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.22.3.tgz#adea622eae6811e11edc8f34209c912caed91336"
integrity sha512-TCCKO7hJKiQi1nGmJcQfvbbqv98P08LULh7pb/NaO5pV20t1FtICfGx8UMpORRDehbcAiYq/f7rPOF6X/Xl5iw==
"@sentry/node@^5.23.0":
version "5.23.0"
resolved "https://registry.yarnpkg.com/@sentry/node/-/node-5.23.0.tgz#c5deb3e4842abacbd40ffaf296971e47df2f6e6c"
integrity sha512-WFiAI9+XALB144LRYsWt4aM6soxMRAp1SQ72H0LNOYQXyei5hnKXLmL8UH5RHJFD60Y8S42tIhZkdPPXSq7HgQ==
dependencies:
"@sentry/core" "5.22.3"
"@sentry/hub" "5.22.3"
"@sentry/tracing" "5.22.3"
"@sentry/types" "5.22.3"
"@sentry/utils" "5.22.3"
"@sentry/core" "5.23.0"
"@sentry/hub" "5.23.0"
"@sentry/tracing" "5.23.0"
"@sentry/types" "5.23.0"
"@sentry/utils" "5.23.0"
cookie "^0.4.1"
https-proxy-agent "^5.0.0"
lru_map "^0.3.3"
tslib "^1.9.3"
"@sentry/tracing@5.22.3":
version "5.22.3"
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-5.22.3.tgz#9b5a376e3164c007a22e8642ec094104468cac0c"
integrity sha512-Zp59kMCk5v56ZAyErqjv/QvGOWOQ5fRltzeVQVp8unIDTk6gEFXfhwPsYHOokJe1mfkmrgPDV6xAkYgtL3KCDQ==
"@sentry/tracing@5.23.0":
version "5.23.0"
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-5.23.0.tgz#6627a6c659abce968e45e897ebc038873cc83fb2"
integrity sha512-cexFQCuGcFukqyaP8p8Uf/aCuMkzJeiU4Trx7vYHf16L95aSn5TGELK0SZOugEb2Gi9D9Z6NHfuK16nWjwPSRQ==
dependencies:
"@sentry/hub" "5.22.3"
"@sentry/minimal" "5.22.3"
"@sentry/types" "5.22.3"
"@sentry/utils" "5.22.3"
"@sentry/hub" "5.23.0"
"@sentry/minimal" "5.23.0"
"@sentry/types" "5.23.0"
"@sentry/utils" "5.23.0"
tslib "^1.9.3"
"@sentry/types@5.22.3":
version "5.22.3"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.22.3.tgz#d1d547b30ee8bd7771fa893af74c4f3d71f0fd18"
integrity sha512-cv+VWK0YFgCVDvD1/HrrBWOWYG3MLuCUJRBTkV/Opdy7nkdNjhCAJQrEyMM9zX0sac8FKWKOHT0sykNh8KgmYw==
"@sentry/types@5.23.0":
version "5.23.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-5.23.0.tgz#935701637c2d5b1c123ac292bc253bddf451b076"
integrity sha512-PbN5MVWxrq05sZ707lc8lleV0xSsI6jWr9h9snvbAuMjcauE0lmdWmjoWKY3PAz2s1mGYFh55kIo8SmQuVwbYg==
"@sentry/utils@5.22.3":
version "5.22.3"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.22.3.tgz#e3bda3e789239eb16d436f768daa12829f33d18f"
integrity sha512-AHNryXMBvIkIE+GQxTlmhBXD0Ksh+5w1SwM5qi6AttH+1qjWLvV6WB4+4pvVvEoS8t5F+WaVUZPQLmCCWp6zKw==
"@sentry/utils@5.23.0":
version "5.23.0"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-5.23.0.tgz#5e15f684b5a8f9c86e4ba15d81101eac7d6c240b"
integrity sha512-D5gQDM0wEjKxhE+YNvCuCHo/6JuaORF2/3aOhoJBR+dy9EACRspg7kp3+9KF44xd2HVEXkSVCJkv8/+sHePYRQ==
dependencies:
"@sentry/types" "5.22.3"
"@sentry/types" "5.23.0"
tslib "^1.9.3"
"@sindresorhus/is@^0.7.0":
@ -4917,6 +4917,11 @@ flush-write-stream@^1.0.0:
inherits "^2.0.3"
readable-stream "^2.3.6"
focus-visible@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/focus-visible/-/focus-visible-5.1.0.tgz#4b9d40143b865f53eafbd93ca66672b3bf9e7b6a"
integrity sha512-nPer0rjtzdZ7csVIu233P2cUm/ks/4aVSI+5KUkYrYpgA7ujgC3p6J7FtFU+AIMWwnwYQOB/yeiOITxFeYIXiw==
for-in@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@ -7610,11 +7615,6 @@ markdown-it-container@^3.0.0:
resolved "https://registry.yarnpkg.com/markdown-it-container/-/markdown-it-container-3.0.0.tgz#1d19b06040a020f9a827577bb7dbf67aa5de9a5b"
integrity sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw==
markdown-it-mark@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/markdown-it-mark/-/markdown-it-mark-3.0.0.tgz#27c3e39ef3cc310b2dde5375082c9fa912983cda"
integrity sha512-HqMWeKfMMOu4zBO0emmxsoMWmbf2cPKZY1wP6FsTbKmicFfp5y4L3KXAsNeO1rM6NTJVOrNlLKMPjWzriBGspw==
markdown-it@^10.0.0:
version "10.0.0"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc"
@ -8570,10 +8570,10 @@ parse-asn1@^5.0.0:
pbkdf2 "^3.0.3"
safe-buffer "^5.1.1"
parse-entities@^1.1.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50"
integrity sha512-NzfpbxW/NPrzZ/yYSoQxyqUZMZXIdCfE0OIN4ESsnptHJECoUk3FZktxNuzQf4tjt5UEopnxpYJbvYuxIFDdsg==
parse-entities@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8"
integrity sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==
dependencies:
character-entities "^1.0.0"
character-entities-legacy "^1.0.0"
@ -8924,20 +8924,13 @@ pretty-format@^26.2.0:
ansi-styles "^4.0.0"
react-is "^16.12.0"
prismjs@^1.19.0:
prismjs@^1.19.0, prismjs@~1.21.0:
version "1.21.0"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.21.0.tgz#36c086ec36b45319ec4218ee164c110f9fc015a3"
integrity sha512-uGdSIu1nk3kej2iZsLyDoJ7e9bnPzIgY0naW/HdknGj61zScaprVEVGHrPoXqI+M9sP0NDnTK2jpkvmldpuqDw==
optionalDependencies:
clipboard "^2.0.0"
prismjs@~1.17.0:
version "1.17.1"
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.17.1.tgz#e669fcbd4cdd873c35102881c33b14d0d68519be"
integrity sha512-PrEDJAFdUGbOP6xK/UsfkC5ghJsPJviKgnQOoxaDbBjwc8op68Quupwt1DeAFoG8GImPhiKXAvvsH7wDSLsu1Q==
optionalDependencies:
clipboard "^2.0.0"
process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@ -9551,14 +9544,14 @@ referrer-policy@1.2.0:
resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.2.0.tgz#b99cfb8b57090dc454895ef897a4cc35ef67a98e"
integrity sha512-LgQJIuS6nAy1Jd88DCQRemyE3mS+ispwlqMk3b0yjZ257fI1v9c+/p6SD5gP5FGyXUIgrNOAfmyioHwZtYv2VA==
refractor@^2.10.1:
version "2.10.1"
resolved "https://registry.yarnpkg.com/refractor/-/refractor-2.10.1.tgz#166c32f114ed16fd96190ad21d5193d3afc7d34e"
integrity sha512-Xh9o7hQiQlDbxo5/XkOX6H+x/q8rmlmZKr97Ie1Q8ZM32IRRd3B/UxuA/yXDW79DBSXGWxm2yRTbcTVmAciJRw==
refractor@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/refractor/-/refractor-3.1.0.tgz#b05a43c8a1b4fccb30001ffcbd5cd781f7f06f78"
integrity sha512-bN8GvY6hpeXfC4SzWmYNQGLLF2ZakRDNBkgCL0vvl5hnpMrnyURk8Mv61v6pzn4/RBHzSWLp44SzMmVHqMGNww==
dependencies:
hastscript "^5.0.0"
parse-entities "^1.1.2"
prismjs "~1.17.0"
parse-entities "^2.0.0"
prismjs "~1.21.0"
regenerate-unicode-properties@^8.2.0:
version "8.2.0"
@ -9747,6 +9740,11 @@ require-package-name@^2.0.1:
resolved "https://registry.yarnpkg.com/require-package-name/-/require-package-name-2.0.1.tgz#c11e97276b65b8e2923f75dabf5fb2ef0c3841b9"
integrity sha1-wR6XJ2tluOKSP3Xav1+y7ww4Qbk=
resize-observer-polyfill@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
resolve-cwd@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"
@ -9828,15 +9826,14 @@ retry-as-promised@^3.2.0:
dependencies:
any-promise "^1.3.0"
rich-markdown-editor@^10.6.5:
version "10.6.5"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-10.6.5.tgz#b74ae2e7d05eaa3c8ef34744e5cb0ed2dbdb0958"
integrity sha512-C/C+6L7BTXC4zSHgOYMljOQ3CvFt8zNCT829woKBHcDWSnXiUzpjgZZ4qEeNRlh/XJmqeFZYfqY+OzIMsVP2+Q==
rich-markdown-editor@^11.0.0-4:
version "11.0.0-4"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.0.0-4.tgz#b65f5b03502d70a2b2bbea5c916c23b071f4bab6"
integrity sha512-+llzd8Plxzsc/jJ8RwtMSV5QIpxpZdM5nQejG/SLe/lfqHNOFNnIiOszSPERIcULLxsLdMT5Ajz+Yr5PXPicOQ==
dependencies:
copy-to-clipboard "^3.0.8"
lodash "^4.17.11"
markdown-it-container "^3.0.0"
markdown-it-mark "^3.0.0"
outline-icons "^1.21.0-3"
prismjs "^1.19.0"
prosemirror-commands "^1.1.4"
@ -9855,10 +9852,10 @@ rich-markdown-editor@^10.6.5:
prosemirror-view "^1.14.11"
react-medium-image-zoom "^3.0.16"
react-portal "^4.2.1"
refractor "^2.10.1"
refractor "^3.1.0"
resize-observer-polyfill "^1.5.1"
slugify "^1.4.0"
smooth-scroll-into-view-if-needed "^1.1.27"
styled-components "^5.1.0"
typescript "3.7.5"
rimraf@2, rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3:
@ -10801,7 +10798,7 @@ styled-components-breakpoint@^2.1.1:
resolved "https://registry.yarnpkg.com/styled-components-breakpoint/-/styled-components-breakpoint-2.1.1.tgz#37c1b92b0e96c1bbc5d293724d7a114daaa15fca"
integrity sha512-PkS7p3MkPJx/v930Q3MPJU8llfFJTxk8o009jl0p+OUFmVb2AlHmVclX1MBHSXk8sZYGoVTTVIPDuZCELi7QIg==
styled-components@^5.0.0, styled-components@^5.1.0:
styled-components@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/styled-components/-/styled-components-5.1.1.tgz#96dfb02a8025794960863b9e8e365e3b6be5518d"
integrity sha512-1ps8ZAYu2Husx+Vz8D+MvXwEwvMwFv+hqqUwhNlDN5ybg6A+3xyW1ECrAgywhvXapNfXiz79jJyU0x22z0FFTg==