Merge develop
This commit is contained in:
commit
0b2107c1ee
4
LICENSE
4
LICENSE
|
@ -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
|
||||
|
||||
|
|
|
@ -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" />
|
||||
|
||||
<span>Trash</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isArchived) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/archive">
|
||||
<ArchiveIcon color="currentColor" />
|
||||
|
||||
<span>Archive</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isDraft) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>Drafts</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isTemplate) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<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" />
|
||||
|
||||
<span>Templates</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
)}
|
||||
{isDraft && (
|
||||
<>
|
||||
<CollectionName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>Drafts</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
)}
|
||||
<Icon document={document} />
|
||||
<CollectionName to={collectionUrl(collection.id)}>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
|
@ -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};
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -181,7 +181,6 @@ const DocumentLink = styled(Link)`
|
|||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${SecondaryActions} {
|
||||
opacity: 1;
|
||||
|
|
|
@ -80,7 +80,6 @@ const MenuItem = styled.a`
|
|||
&:focus {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
outline: none;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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%;
|
||||
|
||||
|
|
|
@ -143,7 +143,6 @@ const StyledNavLink = styled(NavLink)`
|
|||
&:focus {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.black05};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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" />;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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",
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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} />
|
||||
{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>
|
||||
)}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<>) => {
|
||||
|
|
|
@ -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…" : "I’m sure – Delete"}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -27,7 +27,6 @@ const DocumentLink = styled(Link)`
|
|||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
@ -47,7 +47,7 @@ class References extends React.Component<Props> {
|
|||
)}
|
||||
{showBacklinks && (
|
||||
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
|
||||
References
|
||||
Referenced by
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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};
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -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\//, "");
|
||||
}
|
||||
|
|
|
@ -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
151
yarn.lock
|
@ -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==
|
||||
|
|
Reference in New Issue