mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4c594423f | |||
| 2bf237d54b | |||
| 9a1c8f07d1 | |||
| 241cb11493 | |||
| 8195791bb2 | |||
| b037ae5dc1 | |||
| aeba8ce4eb | |||
| 3565e68725 | |||
| 429c5fba85 | |||
| 9495ddba25 | |||
| 486a60e97c | |||
| c687745263 | |||
| 1b92993b90 | |||
| 181a20a268 | |||
| f8ffa4e25a | |||
| 7e139ca8f7 | |||
| bb58db507d | |||
| 49bf86d6d9 | |||
| 286a15cf10 | |||
| f65469b777 | |||
| fe65a79d66 | |||
| a1d5ac0907 | |||
| 04eabe68a7 | |||
| 1c0c694c22 | |||
| 2ae74f2834 | |||
| 0f01fc5faa | |||
| 7f1322b7ba | |||
| 3c98133e24 | |||
| 088353d61f | |||
| 31180619e1 | |||
| 9fccc280d7 | |||
| c69b4efc34 | |||
| 61039e9d0d | |||
| 6d09122d56 | |||
| 5fb6097153 | |||
| ec17874568 | |||
| 40c3e9e85f | |||
| 9f739f3788 | |||
| 3cec6b4903 | |||
| ede7f2e3e6 | |||
| f6837b4742 | |||
| 1560e3c9f7 | |||
| ca74908dc5 | |||
| de7ec1119b | |||
| 2093b4297f | |||
| cf8fa5ffa3 | |||
| 1a2a0f4264 | |||
| 5f3a38bf87 | |||
| afff3a6f25 | |||
| b5824879a3 | |||
| 1c82e292e0 | |||
| 317289ac2a | |||
| 8331026cb3 | |||
| de285f2b63 | |||
| d205c48296 | |||
| 277c37dae6 | |||
| 2c39cd6496 | |||
| d85592b5f3 | |||
| cdf0df0faa | |||
| 48f54b5aa2 | |||
| 2ca57fc7cf | |||
| 470920e2c3 | |||
| beee8ebee7 | |||
| 9f05c9bd43 | |||
| 65be808556 | |||
| 89f8df619c | |||
| 756ec92cdb | |||
| a8e2e349e9 | |||
| 25f745e7e5 | |||
| 07b1811993 | |||
| d71f0ae6bd | |||
| f58032d305 | |||
| 6beb6febc4 | |||
| a6d4d4ea36 | |||
| a99f6bed42 | |||
| 4cd61db1ea | |||
| 0db7bb7f3e | |||
| d8ca9c6111 | |||
| 4a8d357084 | |||
| e0fb76cb63 | |||
| ffed38bf71 | |||
| b4c08a027b | |||
| 3df82c500b |
+3
-2
@@ -8,8 +8,7 @@
|
||||
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
# Generate a unique 32 character hexadecimal key. The format is important as this
|
||||
# value is fed directly into encryption libraries. You should use `openssl rand -hex 32`
|
||||
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
|
||||
# in your terminal to generate a random value.
|
||||
SECRET_KEY=generate_a_new_key
|
||||
|
||||
@@ -129,6 +128,8 @@ SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=
|
||||
SMTP_REPLY_EMAIL=
|
||||
SMTP_TLS_CIPHERS=
|
||||
SMTP_SECURE=true
|
||||
|
||||
# Custom logo that displays on the authentication screen, scaled to height: 60px
|
||||
# TEAM_LOGO=https://example.com/images/logo.png
|
||||
|
||||
@@ -96,7 +96,8 @@ For contributing features and fixes you can quickly get an environment running u
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. Configure your Slack app's Oauth & Permissions settings
|
||||
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
|
||||
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
|
||||
1. Ensure that the bot token scope contains at least `users:read`
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
|
||||
@@ -135,6 +135,15 @@
|
||||
"description": "wikireply@example.com (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_SECURE": {
|
||||
"value": "true",
|
||||
"description": "Use a secure SMTP connection (optional)",
|
||||
"required": false
|
||||
},
|
||||
"SMTP_TLS_CIPHERS": {
|
||||
"description": "Override SMTP cipher configuration (optional)",
|
||||
"required": false
|
||||
},
|
||||
"GOOGLE_ANALYTICS_ID": {
|
||||
"description": "UA-xxxx (optional)",
|
||||
"required": false
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"testURL": "http://localhost",
|
||||
"verbose": false,
|
||||
"rootDir": "..",
|
||||
"roots": [
|
||||
"<rootDir>/app",
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"jsx",
|
||||
"json"
|
||||
],
|
||||
"moduleDirectories": [
|
||||
"node_modules"
|
||||
],
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./app/test/setup.js"
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,9 @@ type Props = {
|
||||
|
||||
export default class Analytics extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (!env.GOOGLE_ANALYTICS_ID) return;
|
||||
if (!env.GOOGLE_ANALYTICS_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// standard Google Analytics script
|
||||
window.ga =
|
||||
|
||||
@@ -29,15 +29,26 @@ const MenuItem = ({
|
||||
const handleClick = React.useCallback(
|
||||
(ev) => {
|
||||
if (onClick) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onClick(ev);
|
||||
}
|
||||
|
||||
if (hide) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
[hide, onClick]
|
||||
[onClick, hide]
|
||||
);
|
||||
|
||||
// Preventing default mousedown otherwise menu items do not work in Firefox,
|
||||
// which triggers the hideOnClickOutside handler first via mousedown – hiding
|
||||
// and un-rendering the menu contents.
|
||||
const handleMouseDown = React.useCallback((ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BaseMenuItem
|
||||
onClick={disabled ? undefined : onClick}
|
||||
@@ -51,6 +62,7 @@ const MenuItem = ({
|
||||
$toggleable={selected !== undefined}
|
||||
as={onClick ? "button" : as}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{selected !== undefined && (
|
||||
<>
|
||||
@@ -101,7 +113,8 @@ export const MenuAnchor = styled.a`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:hover,
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
@@ -112,11 +125,6 @@ export const MenuAnchor = styled.a`
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
}
|
||||
`};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -83,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
|
||||
);
|
||||
});
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unneccessary separators
|
||||
@@ -101,7 +101,11 @@ function Template({ items, ...menu }: Props): React.Node {
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered.map((item, index) => {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Template({ items, ...menu }: Props): React.Node {
|
||||
return filterTemplateItems(items).map((item, index) => {
|
||||
if (item.to) {
|
||||
return (
|
||||
<MenuItem
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function ContextMenu({
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => (
|
||||
<Position {...props}>
|
||||
<Background>
|
||||
<Background dir="auto">
|
||||
{rest.visible || rest.animating ? children : null}
|
||||
</Background>
|
||||
</Position>
|
||||
|
||||
@@ -15,6 +15,7 @@ class CopyToClipboard extends React.PureComponent<Props> {
|
||||
const elem = React.Children.only(children);
|
||||
copy(text, {
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
|
||||
if (onCopy) onCopy();
|
||||
|
||||
@@ -67,6 +67,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
id: document.collectionId,
|
||||
name: t("Deleted Collection"),
|
||||
color: "currentColor",
|
||||
url: "deleted-collection",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
output.push({
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
title: collection.name,
|
||||
to: collectionUrl(collection.id),
|
||||
to: collectionUrl(collection.url),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,7 +105,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
|
||||
}, [path, category, collection]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (onlyText === true) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -37,7 +37,7 @@ class RevisionListItem extends React.Component<Props> {
|
||||
</Author>
|
||||
<Meta>
|
||||
<Time dateTime={revision.createdAt} tooltipDelay={250}>
|
||||
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
|
||||
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
|
||||
</Time>
|
||||
</Meta>
|
||||
{showMenu && (
|
||||
|
||||
@@ -41,7 +41,7 @@ function replaceResultMarks(tag: string) {
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
}
|
||||
|
||||
function DocumentListItem(props: Props) {
|
||||
function DocumentListItem(props: Props, ref) {
|
||||
const { t } = useTranslation();
|
||||
const { policies } = useStores();
|
||||
const currentUser = useCurrentUser();
|
||||
@@ -68,6 +68,8 @@ function DocumentListItem(props: Props) {
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
ref={ref}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
@@ -76,8 +78,12 @@ function DocumentListItem(props: Props) {
|
||||
}}
|
||||
>
|
||||
<Content>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
<Heading dir={document.dir}>
|
||||
<Title
|
||||
text={document.titleWithDefault}
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isNew && document.createdBy.id !== currentUser.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
@@ -221,6 +227,7 @@ const DocumentLink = styled(Link)`
|
||||
|
||||
const Heading = styled.h3`
|
||||
display: flex;
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
@@ -251,4 +258,4 @@ const ResultContext = styled(Highlight)`
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default observer(DocumentListItem);
|
||||
export default observer(React.forwardRef(DocumentListItem));
|
||||
|
||||
@@ -11,6 +11,7 @@ import Time from "components/Time";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const Container = styled(Flex)`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
@@ -135,7 +136,7 @@ function DocumentMeta({
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
{to ? <Link to={to}>{content}</Link> : content}
|
||||
{showCollection && collection && (
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props = {|
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
to?: string,
|
||||
rtl?: boolean,
|
||||
|};
|
||||
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
@@ -23,6 +24,12 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!document.isDeleted) {
|
||||
views.fetchPage({ documentId: document.id });
|
||||
}
|
||||
}, [views, document.id, document.isDeleted]);
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 8,
|
||||
placement: "bottom",
|
||||
@@ -56,6 +63,7 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
}
|
||||
|
||||
const Meta = styled(DocumentMeta)`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -19,10 +19,6 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { views, presence } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
views.fetchPage({ documentId: document.id });
|
||||
}, [views, document.id]);
|
||||
|
||||
let documentPresence = presence.get(document.id);
|
||||
documentPresence = documentPresence
|
||||
? Array.from(documentPresence.values())
|
||||
@@ -59,8 +55,8 @@ function DocumentViews({ document, isOpen }: Props) {
|
||||
? t("Currently editing")
|
||||
: t("Currently viewing")
|
||||
: t("Viewed {{ timeAgo }} ago", {
|
||||
timeAgo: distanceInWordsToNow(
|
||||
view ? new Date(view.lastViewedAt) : new Date()
|
||||
timeAgo: formatDistanceToNow(
|
||||
view ? Date.parse(view.lastViewedAt) : new Date()
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ const Select = styled.select`
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
option {
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import format from "date-fns/format";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
enUS,
|
||||
de,
|
||||
fr,
|
||||
es,
|
||||
it,
|
||||
ko,
|
||||
ptBR,
|
||||
pt,
|
||||
zhCN,
|
||||
zhTW,
|
||||
ru,
|
||||
} from "date-fns/locale";
|
||||
import * as React from "react";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import useUserLocale from "hooks/useUserLocale";
|
||||
|
||||
const locales = {
|
||||
en: require(`date-fns/locale/en`),
|
||||
de: require(`date-fns/locale/de`),
|
||||
es: require(`date-fns/locale/es`),
|
||||
fr: require(`date-fns/locale/fr`),
|
||||
it: require(`date-fns/locale/it`),
|
||||
ko: require(`date-fns/locale/ko`),
|
||||
pt: require(`date-fns/locale/pt`),
|
||||
zh: require(`date-fns/locale/zh_cn`),
|
||||
ru: require(`date-fns/locale/ru`),
|
||||
en_US: enUS,
|
||||
de_DE: de,
|
||||
es_ES: es,
|
||||
fr_FR: fr,
|
||||
it_IT: it,
|
||||
ko_KR: ko,
|
||||
pt_BR: ptBR,
|
||||
pt_PT: pt,
|
||||
zh_CN: zhCN,
|
||||
zh_TW: zhTW,
|
||||
ru_RU: ru,
|
||||
};
|
||||
|
||||
let callbacks = [];
|
||||
@@ -64,7 +78,7 @@ function LocaleTime({
|
||||
};
|
||||
}, []);
|
||||
|
||||
let content = distanceInWordsToNow(dateTime, {
|
||||
let content = formatDistanceToNow(Date.parse(dateTime), {
|
||||
addSuffix,
|
||||
locale: userLocale ? locales[userLocale] : undefined,
|
||||
});
|
||||
@@ -78,7 +92,7 @@ function LocaleTime({
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
|
||||
delay={tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -38,14 +38,24 @@ class PaginatedList extends React.Component<Props> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.fetch !== this.props.fetch) {
|
||||
this.fetchResults();
|
||||
}
|
||||
if (!isEqual(prevProps.options, this.props.options)) {
|
||||
if (
|
||||
prevProps.fetch !== this.props.fetch ||
|
||||
!isEqual(prevProps.options, this.props.options)
|
||||
) {
|
||||
this.reset();
|
||||
this.fetchResults();
|
||||
}
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.offset = 0;
|
||||
this.allowLoadMore = true;
|
||||
this.renderCount = DEFAULT_PAGINATION_LIMIT;
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
this.isLoaded = false;
|
||||
};
|
||||
|
||||
fetchResults = async () => {
|
||||
if (!this.props.fetch) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @flow
|
||||
import "../stores";
|
||||
import { shallow } from "enzyme";
|
||||
import * as React from "react";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import { runAllPromises } from "../test/support";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
|
||||
describe("PaginatedList", () => {
|
||||
const render = () => null;
|
||||
|
||||
it("with no items renders nothing", () => {
|
||||
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
|
||||
expect(list).toEqual({});
|
||||
});
|
||||
|
||||
it("with no items renders empty prop", () => {
|
||||
const list = shallow(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
empty={<p>Sorry, no results</p>}
|
||||
renderItem={render}
|
||||
/>
|
||||
);
|
||||
expect(list.text()).toEqual("Sorry, no results");
|
||||
});
|
||||
|
||||
it("calls fetch with options + pagination on mount", () => {
|
||||
const fetch = jest.fn();
|
||||
const options = { id: "one" };
|
||||
|
||||
shallow(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={render}
|
||||
/>
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
...options,
|
||||
limit: DEFAULT_PAGINATION_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls fetch when options prop changes", async () => {
|
||||
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
|
||||
const fetch = jest.fn().mockReturnValue(fetchedItems);
|
||||
|
||||
const list = shallow(
|
||||
<PaginatedList
|
||||
items={[]}
|
||||
fetch={fetch}
|
||||
options={{ id: "one" }}
|
||||
renderItem={render}
|
||||
/>
|
||||
);
|
||||
|
||||
await runAllPromises();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
id: "one",
|
||||
limit: DEFAULT_PAGINATION_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
fetch.mockReset();
|
||||
|
||||
list.setProps({
|
||||
fetch,
|
||||
items: [],
|
||||
options: { id: "two" },
|
||||
});
|
||||
|
||||
await runAllPromises();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith({
|
||||
id: "two",
|
||||
limit: DEFAULT_PAGINATION_LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,16 +27,19 @@ type Props = {|
|
||||
parentId?: string,
|
||||
|};
|
||||
|
||||
function DocumentLink({
|
||||
node,
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props) {
|
||||
function DocumentLink(
|
||||
{
|
||||
node,
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
index,
|
||||
parentId,
|
||||
}: Props,
|
||||
ref
|
||||
) {
|
||||
const { documents, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -236,6 +239,7 @@ function DocumentLink({
|
||||
depth={depth}
|
||||
exact={false}
|
||||
showActions={menuOpen}
|
||||
ref={ref}
|
||||
menu={
|
||||
document && !isMoving ? (
|
||||
<Fade>
|
||||
@@ -289,5 +293,6 @@ const Disclosure = styled(CollapsedIcon)`
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
const ObservedDocumentLink = observer(DocumentLink);
|
||||
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
|
||||
|
||||
export default ObservedDocumentLink;
|
||||
|
||||
@@ -65,6 +65,7 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
{isEditing ? (
|
||||
<form onSubmit={handleSave}>
|
||||
<Input
|
||||
dir="auto"
|
||||
type="text"
|
||||
value={value}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -7,7 +7,7 @@ const ResizeBorder = styled.div`
|
||||
bottom: 0;
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
`;
|
||||
|
||||
export default ResizeBorder;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -29,25 +30,28 @@ type Props = {
|
||||
depth?: number,
|
||||
};
|
||||
|
||||
function SidebarLink({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
}: Props) {
|
||||
function SidebarLink(
|
||||
{
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
isActiveDrop,
|
||||
menu,
|
||||
showActions,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
history,
|
||||
match,
|
||||
className,
|
||||
}: Props,
|
||||
ref
|
||||
) {
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
paddingLeft: `${(depth || 0) * 16 + 16}px`,
|
||||
@@ -78,6 +82,7 @@ function SidebarLink({
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
className={className}
|
||||
ref={ref}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
@@ -139,30 +144,33 @@ const Link = styled(NavLink)`
|
||||
transition: fill 50ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.black05};
|
||||
}
|
||||
|
||||
&:hover + ${Actions},
|
||||
&:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
background: ${(props) =>
|
||||
transparentize("0.25", props.theme.sidebarItemBackground)};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 32px 4px 16px;
|
||||
font-size: 15px;
|
||||
`}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover + ${Actions},
|
||||
&:active + ${Actions} {
|
||||
display: inline-flex;
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${(props) =>
|
||||
props.$isActiveDrop ? props.theme.white : props.theme.text};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
@@ -170,6 +178,9 @@ const Label = styled.div`
|
||||
width: 100%;
|
||||
max-height: 4.8em;
|
||||
line-height: 1.6;
|
||||
* {
|
||||
unicode-bidi: plaintext;
|
||||
}
|
||||
`;
|
||||
|
||||
export default withRouter(withTheme(SidebarLink));
|
||||
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
|
||||
|
||||
@@ -250,6 +250,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
documents.starredIds.set(event.documentId, false);
|
||||
});
|
||||
|
||||
this.socket.on("documents.permanent_delete", (event) => {
|
||||
documents.remove(event.documentId);
|
||||
});
|
||||
|
||||
// received when a user is given access to a collection
|
||||
// if the user is us then we go ahead and load the collection from API.
|
||||
this.socket.on("collections.add_user", (event) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import * as React from "react";
|
||||
|
||||
const LocaleTime = React.lazy(() =>
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function Time(props: Props) {
|
||||
let content = distanceInWordsToNow(props.dateTime, {
|
||||
let content = formatDistanceToNow(Date.parse(props.dateTime), {
|
||||
addSuffix: props.addSuffix,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ export default class Mindmeister extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
const chartId = this.props.attrs.matches[4] + this.props.attrs.matches[6];
|
||||
const chartId =
|
||||
this.props.attrs.matches[4] +
|
||||
(this.props.attrs.matches[5] || "") +
|
||||
(this.props.attrs.matches[6] || "");
|
||||
|
||||
return (
|
||||
<Frame
|
||||
|
||||
@@ -11,9 +11,7 @@ import Flex from "components/Flex";
|
||||
const Iframe = (props) => <iframe title="Embed" {...props} />;
|
||||
|
||||
const StyledIframe = styled(Iframe)`
|
||||
border: 1px solid;
|
||||
border-color: ${(props) => props.theme.embedBorder};
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
border-radius: ${(props) => (props.$withBar ? "3px 3px 0 0" : "3px")};
|
||||
display: block;
|
||||
`;
|
||||
|
||||
@@ -70,13 +68,13 @@ class Frame extends React.Component<PropsWithRef> {
|
||||
<Rounded
|
||||
width={width}
|
||||
height={height}
|
||||
withBar={withBar}
|
||||
$withBar={withBar}
|
||||
className={isSelected ? "ProseMirror-selectednode" : ""}
|
||||
>
|
||||
{this.isLoaded && (
|
||||
<Component
|
||||
ref={forwardedRef}
|
||||
withBar={withBar}
|
||||
$withBar={withBar}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -108,10 +106,11 @@ class Frame extends React.Component<PropsWithRef> {
|
||||
}
|
||||
|
||||
const Rounded = styled.div`
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
border: 1px solid ${(props) => props.theme.embedBorder};
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
width: ${(props) => props.width};
|
||||
height: ${(props) => (props.withBar ? props.height + 28 : props.height)};
|
||||
height: ${(props) => (props.$withBar ? props.height + 28 : props.height)};
|
||||
`;
|
||||
|
||||
const Open = styled.a`
|
||||
@@ -132,11 +131,12 @@ const Title = styled.span`
|
||||
`;
|
||||
|
||||
const Bar = styled(Flex)`
|
||||
border-top: 1px solid ${(props) => props.theme.embedBorder};
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
padding: 0 8px;
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@ export default function useUserLocale() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return auth.user.language.split("_")[0];
|
||||
return auth.user.language;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @flow
|
||||
// Based on https://github.com/rehooks/window-scroll-position which is no longer
|
||||
// maintained.
|
||||
import { throttle } from "lodash";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
let supportsPassive = false;
|
||||
try {
|
||||
var opts = Object.defineProperty({}, "passive", {
|
||||
get: function () {
|
||||
supportsPassive = true;
|
||||
},
|
||||
});
|
||||
window.addEventListener("testPassive", null, opts);
|
||||
window.removeEventListener("testPassive", null, opts);
|
||||
} catch (e) {}
|
||||
|
||||
const getPosition = () => ({
|
||||
x: window.pageXOffset,
|
||||
y: window.pageYOffset,
|
||||
});
|
||||
|
||||
const defaultOptions = {
|
||||
throttle: 100,
|
||||
};
|
||||
|
||||
export default function useWindowScrollPosition(options: {
|
||||
throttle: number,
|
||||
}): { x: number, y: number } {
|
||||
let opts = Object.assign({}, defaultOptions, options);
|
||||
|
||||
let [position, setPosition] = useState(getPosition());
|
||||
|
||||
useEffect(() => {
|
||||
let handleScroll = throttle(() => {
|
||||
setPosition(getPosition());
|
||||
}, opts.throttle);
|
||||
|
||||
window.addEventListener(
|
||||
"scroll",
|
||||
handleScroll,
|
||||
supportsPassive ? { passive: true } : false
|
||||
);
|
||||
|
||||
return () => {
|
||||
handleScroll.cancel();
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [opts.throttle]);
|
||||
|
||||
return position;
|
||||
}
|
||||
+1
-1
@@ -81,7 +81,7 @@ window.addEventListener("load", async () => {
|
||||
if (!env.GOOGLE_ANALYTICS_ID || !window.ga) return;
|
||||
|
||||
// https://github.com/googleanalytics/autotrack/issues/137#issuecomment-305890099
|
||||
await import(/** webpackChunkName: "autotrack" */ "autotrack/autotrack.js");
|
||||
await import(/* webpackChunkName: "autotrack" */ "autotrack/autotrack.js");
|
||||
|
||||
window.ga("require", "outboundLinkTracker");
|
||||
window.ga("require", "urlChangeTracker");
|
||||
|
||||
+48
-42
@@ -12,7 +12,7 @@ import CollectionExport from "scenes/CollectionExport";
|
||||
import CollectionPermissions from "scenes/CollectionPermissions";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
|
||||
import Modal from "components/Modal";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
@@ -110,6 +110,52 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const can = policies.abilities(collection.id);
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
filterTemplateItems([
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
onClick: handleImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionPermissions(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && can.export),
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: () => setShowCollectionDelete(true),
|
||||
},
|
||||
]),
|
||||
[can, collection, handleNewDocument, handleImportDocument, t]
|
||||
);
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -134,47 +180,7 @@ function CollectionMenu({
|
||||
onClose={onClose}
|
||||
aria-label={t("Collection")}
|
||||
>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
title: t("New document"),
|
||||
visible: can.update,
|
||||
onClick: handleNewDocument,
|
||||
},
|
||||
{
|
||||
title: t("Import document"),
|
||||
visible: can.update,
|
||||
onClick: handleImportDocument,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionPermissions(true),
|
||||
},
|
||||
{
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && can.export),
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
title: `${t("Delete")}…`,
|
||||
visible: !!(collection && can.delete),
|
||||
onClick: () => setShowCollectionDelete(true),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
|
||||
+64
-39
@@ -9,6 +9,7 @@ import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import DocumentDelete from "scenes/DocumentDelete";
|
||||
import DocumentMove from "scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "scenes/DocumentPermanentDelete";
|
||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
@@ -61,6 +62,10 @@ function DocumentMenu({
|
||||
const { t } = useTranslation();
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||
const [
|
||||
showPermanentDeleteModal,
|
||||
setShowPermanentDeleteModal,
|
||||
] = React.useState(false);
|
||||
const [showMoveModal, setShowMoveModal] = React.useState(false);
|
||||
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
|
||||
const file = React.useRef<?HTMLInputElement>();
|
||||
@@ -218,12 +223,7 @@ function DocumentMenu({
|
||||
items={[
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!can.unarchive,
|
||||
onClick: handleRestore,
|
||||
},
|
||||
{
|
||||
title: t("Restore"),
|
||||
visible: !!(collection && can.restore),
|
||||
visible: (!!collection && can.restore) || can.unarchive,
|
||||
onClick: handleRestore,
|
||||
},
|
||||
{
|
||||
@@ -332,6 +332,11 @@ function DocumentMenu({
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
visible: !!can.delete,
|
||||
},
|
||||
{
|
||||
title: `${t("Permanently delete")}…`,
|
||||
onClick: () => setShowPermanentDeleteModal(true),
|
||||
visible: can.permanentDelete,
|
||||
},
|
||||
{
|
||||
title: `${t("Move")}…`,
|
||||
onClick: () => setShowMoveModal(true),
|
||||
@@ -362,40 +367,60 @@ function DocumentMenu({
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
{can.move && (
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowDeleteModal(false)}
|
||||
isOpen={showDeleteModal}
|
||||
>
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create template")}
|
||||
onRequestClose={() => setShowTemplateModal(false)}
|
||||
isOpen={showTemplateModal}
|
||||
>
|
||||
<DocumentTemplatize
|
||||
document={document}
|
||||
onSubmit={() => setShowTemplateModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.delete && (
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowDeleteModal(false)}
|
||||
isOpen={showDeleteModal}
|
||||
>
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.permanentDelete && (
|
||||
<Modal
|
||||
title={t("Permanently delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowPermanentDeleteModal(false)}
|
||||
isOpen={showPermanentDeleteModal}
|
||||
>
|
||||
<DocumentPermanentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowPermanentDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.update && (
|
||||
<Modal
|
||||
title={t("Create template")}
|
||||
onRequestClose={() => setShowTemplateModal(false)}
|
||||
isOpen={showTemplateModal}
|
||||
>
|
||||
<DocumentTemplatize
|
||||
document={document}
|
||||
onSubmit={() => setShowTemplateModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -25,7 +25,7 @@ function NewDocumentMenu() {
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (singleCollection) {
|
||||
|
||||
@@ -23,7 +23,7 @@ function NewTemplateMenu() {
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
if (!can.createDocument) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+32
-16
@@ -9,6 +9,7 @@ import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import ContextMenu from "components/ContextMenu";
|
||||
import MenuItem from "components/ContextMenu/MenuItem";
|
||||
import Separator from "components/ContextMenu/Separator";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
@@ -19,12 +20,36 @@ function TemplatesMenu({ document }: Props) {
|
||||
const menu = useMenuState({ modal: true });
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const templates = documents.templatesInCollection(document.collectionId);
|
||||
const templates = documents.templates;
|
||||
|
||||
if (!templates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templatesInCollection = templates.filter(
|
||||
(t) => t.collectionId === document.collectionId
|
||||
);
|
||||
const otherTemplates = templates.filter(
|
||||
(t) => t.collectionId !== document.collectionId
|
||||
);
|
||||
|
||||
const renderTemplate = (template) => (
|
||||
<MenuItem
|
||||
key={template.id}
|
||||
onClick={() => document.updateFromTemplate(template)}
|
||||
{...menu}
|
||||
>
|
||||
<DocumentIcon />
|
||||
<div>
|
||||
<strong>{template.titleWithDefault}</strong>
|
||||
<br />
|
||||
<Author>
|
||||
{t("By {{ author }}", { author: template.createdBy.name })}
|
||||
</Author>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
@@ -35,21 +60,11 @@ function TemplatesMenu({ document }: Props) {
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Templates")}>
|
||||
{templates.map((template) => (
|
||||
<MenuItem
|
||||
key={template.id}
|
||||
onClick={() => document.updateFromTemplate(template)}
|
||||
>
|
||||
<DocumentIcon />
|
||||
<div>
|
||||
<strong>{template.titleWithDefault}</strong>
|
||||
<br />
|
||||
<Author>
|
||||
{t("By {{ author }}", { author: template.createdBy.name })}
|
||||
</Author>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
{templatesInCollection.map(renderTemplate)}
|
||||
{otherTemplates.length && templatesInCollection.length ? (
|
||||
<Separator />
|
||||
) : undefined}
|
||||
{otherTemplates.map(renderTemplate)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
@@ -57,6 +72,7 @@ function TemplatesMenu({ document }: Props) {
|
||||
|
||||
const Author = styled.div`
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
`;
|
||||
|
||||
export default observer(TemplatesMenu);
|
||||
|
||||
@@ -24,6 +24,7 @@ export default class Collection extends BaseModel {
|
||||
deletedAt: ?string;
|
||||
sort: { field: string, direction: "asc" | "desc" };
|
||||
url: string;
|
||||
urlId: string;
|
||||
|
||||
@computed
|
||||
get isEmpty(): boolean {
|
||||
|
||||
+21
-2
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import addDays from "date-fns/add_days";
|
||||
import differenceInDays from "date-fns/difference_in_days";
|
||||
import { addDays, differenceInDays } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { action, computed, observable, set } from "mobx";
|
||||
import parseTitle from "shared/utils/parseTitle";
|
||||
@@ -59,6 +58,26 @@ export default class Document extends BaseModel {
|
||||
return emoji;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-guess the text direction of the document based on the language the
|
||||
* title is written in. Note: wrapping as a computed getter means that it will
|
||||
* only be called directly when the title changes.
|
||||
*/
|
||||
@computed
|
||||
get dir(): "rtl" | "ltr" {
|
||||
const element = document.createElement("p");
|
||||
element.innerHTML = this.title;
|
||||
element.style.visibility = "hidden";
|
||||
element.dir = "auto";
|
||||
|
||||
// element must appear in body for direction to be computed
|
||||
document.body?.appendChild(element);
|
||||
|
||||
const direction = window.getComputedStyle(element).direction;
|
||||
document.body?.removeChild(element);
|
||||
return direction;
|
||||
}
|
||||
|
||||
@computed
|
||||
get noun(): string {
|
||||
return this.template ? "template" : "document";
|
||||
|
||||
+37
-35
@@ -40,42 +40,44 @@ export default function AuthenticatedRoutes() {
|
||||
return (
|
||||
<SocketProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Route exact path="/collections/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collections/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collections/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collection/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collection/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
|
||||
<SettingsRoutes />
|
||||
</React.Suspense>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
<Route component={NotFound} />
|
||||
</Switch>{" "}
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</SocketProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
onSubmit: () => void,
|
||||
|};
|
||||
|
||||
function APITokenNew({ onSubmit }: Props) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { apiKeys, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = React.useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await apiKeys.create({ name });
|
||||
ui.showToast(t("API token created", { type: "success" }));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [t, ui, name, onSubmit, apiKeys]);
|
||||
|
||||
const handleNameChange = React.useCallback((event) => {
|
||||
setName(event.target.value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
Name your token something that will help you to remember it's use in
|
||||
the future, for example "local development", "production", or
|
||||
"continuous integration".
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Flex>
|
||||
<Input
|
||||
type="text"
|
||||
label="Name"
|
||||
onChange={handleNameChange}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default APITokenNew;
|
||||
+65
-42
@@ -4,7 +4,15 @@ import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useParams, Redirect, Link, Switch, Route } from "react-router-dom";
|
||||
import {
|
||||
useParams,
|
||||
Redirect,
|
||||
Link,
|
||||
Switch,
|
||||
Route,
|
||||
useHistory,
|
||||
useRouteMatch,
|
||||
} from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import CollectionPermissions from "scenes/CollectionPermissions";
|
||||
import Search from "scenes/Search";
|
||||
@@ -29,15 +37,18 @@ import Subheading from "components/Subheading";
|
||||
import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import Collection from "../models/Collection";
|
||||
import { updateCollectionUrl } from "../utils/routeHelpers";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useImportDocument from "hooks/useImportDocument";
|
||||
import useStores from "hooks/useStores";
|
||||
import useUnmount from "hooks/useUnmount";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
function CollectionScene() {
|
||||
const params = useParams();
|
||||
const history = useHistory();
|
||||
const match = useRouteMatch();
|
||||
const { t } = useTranslation();
|
||||
const { documents, policies, collections, ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
@@ -45,11 +56,21 @@ function CollectionScene() {
|
||||
const [error, setError] = React.useState();
|
||||
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
|
||||
|
||||
const collectionId = params.id || "";
|
||||
const collection = collections.get(collectionId);
|
||||
const can = policies.abilities(collectionId || "");
|
||||
const id = params.id || "";
|
||||
const collection: ?Collection =
|
||||
collections.getByUrl(id) || collections.get(id);
|
||||
const can = policies.abilities(collection?.id || "");
|
||||
const canUser = policies.abilities(team.id);
|
||||
const { handleFiles, isImporting } = useImportDocument(collectionId);
|
||||
const { handleFiles, isImporting } = useImportDocument(collection?.id || "");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection) {
|
||||
const canonicalUrl = updateCollectionUrl(match.url, collection);
|
||||
if (match.url !== canonicalUrl) {
|
||||
history.replace(canonicalUrl);
|
||||
}
|
||||
}
|
||||
}, [collection, history, id, match.url]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (collection) {
|
||||
@@ -59,8 +80,10 @@ function CollectionScene() {
|
||||
|
||||
React.useEffect(() => {
|
||||
setError(null);
|
||||
documents.fetchPinned({ collectionId });
|
||||
}, [documents, collectionId]);
|
||||
if (collection) {
|
||||
documents.fetchPinned({ collectionId: collection.id });
|
||||
}
|
||||
}, [documents, collection]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
@@ -68,7 +91,7 @@ function CollectionScene() {
|
||||
try {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
await collections.fetch(collectionId);
|
||||
await collections.fetch(id);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
@@ -77,9 +100,7 @@ function CollectionScene() {
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, [collections, isFetching, collection, error, collectionId, can]);
|
||||
|
||||
useUnmount(ui.clearActiveCollection);
|
||||
}, [collections, isFetching, collection, error, id, can]);
|
||||
|
||||
const handlePermissionsModalOpen = React.useCallback(() => {
|
||||
setPermissionsModalOpen(true);
|
||||
@@ -124,29 +145,31 @@ function CollectionScene() {
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
label={`${t("Search in collection")}…`}
|
||||
collectionId={collectionId}
|
||||
collectionId={collection.id}
|
||||
/>
|
||||
</Action>
|
||||
{can.update && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentUrl(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
<>
|
||||
<Action>
|
||||
<Tooltip
|
||||
tooltip={t("New document")}
|
||||
shortcut="n"
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Button
|
||||
as={Link}
|
||||
to={collection ? newDocumentUrl(collection.id) : ""}
|
||||
disabled={!collection}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Action>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<Separator />
|
||||
<Action>
|
||||
<CollectionMenu
|
||||
collection={collection}
|
||||
@@ -257,27 +280,27 @@ function CollectionScene() {
|
||||
)}
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
<Tab to={collectionUrl(collection.url)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
<Tab to={collectionUrl(collection.url, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
<Tab to={collectionUrl(collection.url, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
<Tab to={collectionUrl(collection.url, "old")} exact>
|
||||
{t("Least recently updated")}
|
||||
</Tab>
|
||||
<Tab
|
||||
to={collectionUrl(collection.id, "alphabetical")}
|
||||
to={collectionUrl(collection.url, "alphabetical")}
|
||||
exact
|
||||
>
|
||||
{t("A–Z")}
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<Route path={collectionUrl(collection.id, "alphabetical")}>
|
||||
<Route path={collectionUrl(collection.url, "alphabetical")}>
|
||||
<PaginatedDocumentList
|
||||
key="alphabetical"
|
||||
documents={documents.alphabeticalInCollection(
|
||||
@@ -288,7 +311,7 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "old")}>
|
||||
<Route path={collectionUrl(collection.url, "old")}>
|
||||
<PaginatedDocumentList
|
||||
key="old"
|
||||
documents={documents.leastRecentlyUpdatedInCollection(
|
||||
@@ -299,12 +322,12 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Route path={collectionUrl(collection.url, "recent")}>
|
||||
<Redirect
|
||||
to={collectionUrl(collection.id, "published")}
|
||||
to={collectionUrl(collection.url, "published")}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<Route path={collectionUrl(collection.url, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
@@ -316,7 +339,7 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<Route path={collectionUrl(collection.url, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
@@ -327,7 +350,7 @@ function CollectionScene() {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<Route path={collectionUrl(collection.url)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @flow
|
||||
import useWindowScrollPosition from "@rehooks/window-scroll-position";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import HelpText from "components/HelpText";
|
||||
import useWindowScrollPosition from "hooks/useWindowScrollPosition";
|
||||
|
||||
const HEADING_OFFSET = 20;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
@@ -94,8 +94,11 @@ class DataLoader extends React.Component<Props> {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
try {
|
||||
const { document } = await this.props.documents.fetch(slug);
|
||||
const time = distanceInWordsToNow(document.updatedAt, {
|
||||
const {
|
||||
document,
|
||||
}: { document: Document } = await this.props.documents.fetch(slug);
|
||||
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
return [
|
||||
@@ -118,7 +121,7 @@ class DataLoader extends React.Component<Props> {
|
||||
|
||||
return sortBy(
|
||||
results.map((document) => {
|
||||
const time = distanceInWordsToNow(document.updatedAt, {
|
||||
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
|
||||
addSuffix: true,
|
||||
});
|
||||
return {
|
||||
@@ -211,10 +214,7 @@ class DataLoader extends React.Component<Props> {
|
||||
const isMove = this.props.location.pathname.match(/move$/);
|
||||
const canRedirect = !revisionId && !isMove && !shareId;
|
||||
if (canRedirect) {
|
||||
const canonicalUrl = updateDocumentUrl(
|
||||
this.props.match.url,
|
||||
document.url
|
||||
);
|
||||
const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
|
||||
if (this.props.location.pathname !== canonicalUrl) {
|
||||
this.props.history.replace(canonicalUrl);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import { isCustomDomain } from "utils/domains";
|
||||
import { emojiToUrl } from "utils/emoji";
|
||||
import { meta } from "utils/keyboard";
|
||||
import {
|
||||
collectionUrl,
|
||||
documentMoveUrl,
|
||||
documentHistoryUrl,
|
||||
editDocumentUrl,
|
||||
@@ -174,7 +173,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.onSave({ publish: true, done: true });
|
||||
}
|
||||
|
||||
@keydown(`${meta}+ctrl+h`)
|
||||
@keydown("ctrl+alt+h")
|
||||
onToggleTableOfContents(ev) {
|
||||
if (!this.props.readOnly) return;
|
||||
|
||||
@@ -291,15 +290,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
};
|
||||
|
||||
goBack = () => {
|
||||
let url;
|
||||
if (this.props.document.url) {
|
||||
url = this.props.document.url;
|
||||
} else if (this.props.match.params.id) {
|
||||
url = collectionUrl(this.props.match.params.id);
|
||||
}
|
||||
if (url) {
|
||||
this.props.history.push(url);
|
||||
}
|
||||
this.props.history.push(this.props.document.url);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -33,6 +33,7 @@ type Props = {|
|
||||
@observer
|
||||
class DocumentEditor extends React.Component<Props> {
|
||||
@observable activeLinkEvent: ?MouseEvent;
|
||||
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
|
||||
|
||||
focusAtStart = () => {
|
||||
if (this.props.innerRef.current) {
|
||||
@@ -114,8 +115,10 @@ class DocumentEditor extends React.Component<Props> {
|
||||
{readOnly ? (
|
||||
<Title
|
||||
as="div"
|
||||
ref={this.ref}
|
||||
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
|
||||
$isStarred={document.isStarred}
|
||||
dir="auto"
|
||||
>
|
||||
<span>{normalizedTitle}</span>{" "}
|
||||
{!shareId && <StarButton document={document} size={32} />}
|
||||
@@ -123,6 +126,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
) : (
|
||||
<Title
|
||||
type="text"
|
||||
ref={this.ref}
|
||||
onChange={onChangeTitle}
|
||||
onKeyDown={this.handleTitleKeyDown}
|
||||
placeholder={document.placeholder}
|
||||
@@ -130,13 +134,21 @@ class DocumentEditor extends React.Component<Props> {
|
||||
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
|
||||
autoFocus={!title}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
dir="auto"
|
||||
/>
|
||||
)}
|
||||
{!shareId && (
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentHistoryUrl(document)}
|
||||
rtl={
|
||||
this.ref.current
|
||||
? window.getComputedStyle(this.ref.current).direction === "rtl"
|
||||
: false
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentHistoryUrl(document)}
|
||||
/>
|
||||
<Editor
|
||||
ref={innerRef}
|
||||
autoFocus={!!title && !this.props.defaultValue}
|
||||
|
||||
@@ -83,7 +83,7 @@ function DocumentHeader({
|
||||
const toc = (
|
||||
<Tooltip
|
||||
tooltip={ui.tocVisible ? t("Hide contents") : t("Show contents")}
|
||||
shortcut={`ctrl+${metaDisplay}+h`}
|
||||
shortcut="ctrl+alt+h"
|
||||
delay={250}
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -35,7 +35,6 @@ const DocumentLink = styled(Link)`
|
||||
const Title = styled.h3`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
@@ -78,7 +77,7 @@ function ReferenceListItem({
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<Title>
|
||||
<Title dir="auto">
|
||||
{document.emoji ? (
|
||||
<Emoji>{document.emoji}</Emoji>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import invariant from "invariant";
|
||||
import { observer } from "mobx-react";
|
||||
import { GlobeIcon, PadlockIcon } from "outline-icons";
|
||||
@@ -118,9 +118,12 @@ function SharePopover({ document, share, sharedParent, onSubmit }: Props) {
|
||||
<>
|
||||
.{" "}
|
||||
{t("The shared link was last accessed {{ timeAgo }}.", {
|
||||
timeAgo: distanceInWordsToNow(share.lastAccessedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
timeAgo: formatDistanceToNow(
|
||||
Date.parse(share.lastAccessedAt),
|
||||
{
|
||||
addSuffix: true,
|
||||
}
|
||||
),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -17,12 +17,13 @@ type Props = {
|
||||
|
||||
function DocumentDelete({ document, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { ui, documents } = useStores();
|
||||
const { ui, documents, collections } = useStores();
|
||||
const history = useHistory();
|
||||
const [isDeleting, setDeleting] = React.useState(false);
|
||||
const [isArchiving, setArchiving] = React.useState(false);
|
||||
const { showToast } = ui;
|
||||
const canArchive = !document.isDraft && !document.isArchived;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
@@ -45,7 +46,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
// otherwise, redirect to the collection home
|
||||
history.push(collectionUrl(document.collectionId));
|
||||
history.push(collectionUrl(collection?.url || "/"));
|
||||
}
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
@@ -54,7 +55,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
setDeleting(false);
|
||||
}
|
||||
},
|
||||
[showToast, onSubmit, ui, document, documents, history]
|
||||
[showToast, onSubmit, ui, document, documents, history, collection]
|
||||
);
|
||||
|
||||
const handleArchive = React.useCallback(
|
||||
|
||||
+43
-44
@@ -1,58 +1,57 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type RouterHistory,
|
||||
type Location,
|
||||
type Match,
|
||||
} from "react-router-dom";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Flex from "components/Flex";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import useStores from "hooks/useStores";
|
||||
import { editDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
location: Location,
|
||||
documents: DocumentsStore,
|
||||
ui: UiStore,
|
||||
match: Match,
|
||||
};
|
||||
function DocumentNew() {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
const { t } = useTranslation();
|
||||
const { documents, ui, collections } = useStores();
|
||||
const id = match.params.id || "";
|
||||
|
||||
class DocumentNew extends React.Component<Props> {
|
||||
async componentDidMount() {
|
||||
const params = queryString.parse(this.props.location.search);
|
||||
useEffect(() => {
|
||||
async function createDocument() {
|
||||
const params = queryString.parse(location.search);
|
||||
try {
|
||||
const collection = await collections.fetch(id);
|
||||
|
||||
try {
|
||||
const document = await this.props.documents.create({
|
||||
collectionId: this.props.match.params.id,
|
||||
parentDocumentId: params.parentDocumentId,
|
||||
templateId: params.templateId,
|
||||
template: params.template,
|
||||
title: "",
|
||||
text: "",
|
||||
});
|
||||
this.props.history.replace(editDocumentUrl(document));
|
||||
} catch (err) {
|
||||
this.props.ui.showToast("Couldn’t create the document, try again?", {
|
||||
type: "error",
|
||||
});
|
||||
this.props.history.goBack();
|
||||
const document = await documents.create({
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: params.parentDocumentId,
|
||||
templateId: params.templateId,
|
||||
template: params.template,
|
||||
title: "",
|
||||
text: "",
|
||||
});
|
||||
|
||||
history.replace(editDocumentUrl(document));
|
||||
} catch (err) {
|
||||
ui.showToast(t("Couldn’t create the document, try again?"), {
|
||||
type: "error",
|
||||
});
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
createDocument();
|
||||
});
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Flex column auto>
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flex column auto>
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("documents", "ui")(DocumentNew);
|
||||
export default observer(DocumentNew);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Document from "models/Document.js";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
onSubmit: () => void,
|
||||
|};
|
||||
|
||||
function DocumentPermanentDelete({ document, onSubmit }: Props) {
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { ui, documents } = useStores();
|
||||
const { showToast } = ui;
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await documents.delete(document, { permanent: true });
|
||||
showToast(t("Document permanently deleted"), { type: "success" });
|
||||
onSubmit();
|
||||
history.push("/trash");
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
[document, onSubmit, showToast, t, history, documents]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone."
|
||||
values={{ documentTitle: document.titleWithDefault }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(DocumentPermanentDelete);
|
||||
@@ -50,10 +50,13 @@ class Drafts extends React.Component<Props> {
|
||||
}) => {
|
||||
this.props.history.replace({
|
||||
pathname: this.props.location.pathname,
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
}),
|
||||
search: queryString.stringify(
|
||||
{
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
},
|
||||
{ skipEmptyString: true }
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ function KeyboardShortcuts() {
|
||||
{
|
||||
shortcut: (
|
||||
<>
|
||||
<Key>Ctrl</Key> + <Key>{metaDisplay}</Key> + <Key>h</Key>
|
||||
<Key>Ctrl</Key> + <Key>Alt</Key> + <Key>h</Key>
|
||||
</>
|
||||
),
|
||||
label: t("Table of contents"),
|
||||
|
||||
@@ -6,7 +6,6 @@ import { observer, inject } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import queryString from "query-string";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { withTranslation, Trans, type TFunction } from "react-i18next";
|
||||
import keydown from "react-keydown";
|
||||
import { withRouter, Link } from "react-router-dom";
|
||||
@@ -103,8 +102,9 @@ class Search extends React.Component<Props> {
|
||||
if (ev.key === "ArrowDown") {
|
||||
ev.preventDefault();
|
||||
if (this.firstDocument) {
|
||||
const element = ReactDOM.findDOMNode(this.firstDocument);
|
||||
if (element instanceof HTMLElement) element.focus();
|
||||
if (this.firstDocument instanceof HTMLElement) {
|
||||
this.firstDocument.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -140,10 +140,13 @@ class Search extends React.Component<Props> {
|
||||
}) => {
|
||||
this.props.history.replace({
|
||||
pathname: this.props.location.pathname,
|
||||
search: queryString.stringify({
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
}),
|
||||
search: queryString.stringify(
|
||||
{
|
||||
...queryString.parse(this.props.location.search),
|
||||
...search,
|
||||
},
|
||||
{ skipEmptyString: true }
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlusIcon, GroupIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import GroupNew from "scenes/GroupNew";
|
||||
import { Action } from "components/Actions";
|
||||
import Button from "components/Button";
|
||||
import Empty from "components/Empty";
|
||||
import GroupListItem from "components/GroupListItem";
|
||||
@@ -33,25 +34,31 @@ function Groups() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Scene title={t("Groups")} icon={<GroupIcon color="currentColor" />}>
|
||||
<Scene
|
||||
title={t("Groups")}
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
actions={
|
||||
<>
|
||||
{can.createGroup && (
|
||||
<Action>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewGroupModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
>
|
||||
{`${t("New group")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("Groups")}</Heading>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
Groups can be used to organize and manage the people on your team.
|
||||
</Trans>
|
||||
</HelpText>
|
||||
|
||||
{can.createGroup && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleNewGroupModalOpen}
|
||||
icon={<PlusIcon />}
|
||||
neutral
|
||||
>
|
||||
{`${t("New group")}…`}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Subheading>{t("All groups")}</Subheading>
|
||||
<PaginatedList
|
||||
items={groups.orderedData}
|
||||
|
||||
@@ -1,89 +1,86 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CodeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import ApiKeysStore from "stores/ApiKeysStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import APITokenNew from "scenes/APITokenNew";
|
||||
import { Action } from "components/Actions";
|
||||
import Button from "components/Button";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import List from "components/List";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import Scene from "components/Scene";
|
||||
import Subheading from "components/Subheading";
|
||||
import TokenListItem from "./components/TokenListItem";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
apiKeys: ApiKeysStore,
|
||||
ui: UiStore,
|
||||
};
|
||||
function Tokens() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys, policies } = useStores();
|
||||
const [newModalOpen, setNewModalOpen] = React.useState(false);
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
@observer
|
||||
class Tokens extends React.Component<Props> {
|
||||
@observable name: string = "";
|
||||
const handleNewModalOpen = React.useCallback(() => {
|
||||
setNewModalOpen(true);
|
||||
}, []);
|
||||
|
||||
componentDidMount() {
|
||||
this.props.apiKeys.fetchPage({ limit: 100 });
|
||||
}
|
||||
const handleNewModalClose = React.useCallback(() => {
|
||||
setNewModalOpen(false);
|
||||
}, []);
|
||||
|
||||
handleUpdate = (ev: SyntheticInputEvent<*>) => {
|
||||
this.name = ev.target.value;
|
||||
};
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
try {
|
||||
ev.preventDefault();
|
||||
await this.props.apiKeys.create({ name: this.name });
|
||||
this.name = "";
|
||||
} catch (error) {
|
||||
this.props.ui.showToast(error.message, { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { apiKeys } = this.props;
|
||||
const hasApiKeys = apiKeys.orderedData.length > 0;
|
||||
|
||||
return (
|
||||
<Scene title="API Tokens" icon={<CodeIcon color="currentColor" />}>
|
||||
<Heading>API Tokens</Heading>
|
||||
<HelpText>
|
||||
You can create an unlimited amount of personal tokens to authenticate
|
||||
with the API. For more details about the API take a look at the{" "}
|
||||
<a href="https://www.getoutline.com/developers">
|
||||
developer documentation
|
||||
</a>
|
||||
.
|
||||
</HelpText>
|
||||
|
||||
{hasApiKeys && (
|
||||
<List>
|
||||
{apiKeys.orderedData.map((token) => (
|
||||
<TokenListItem
|
||||
key={token.id}
|
||||
token={token}
|
||||
onDelete={token.delete}
|
||||
return (
|
||||
<Scene
|
||||
title={t("API Tokens")}
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New token")}…`}
|
||||
onClick={handleNewModalOpen}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API Tokens")}</Heading>
|
||||
<HelpText>
|
||||
<Trans
|
||||
defaults="You can create an unlimited amount of personal tokens to authenticate
|
||||
with the API. Tokens have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a href="https://www.getoutline.com/developers" target="_blank" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</HelpText>
|
||||
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<Input
|
||||
onChange={this.handleUpdate}
|
||||
placeholder="Token label (eg. development)"
|
||||
value={this.name}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
value="Create Token"
|
||||
disabled={apiKeys.isSaving}
|
||||
/>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
<PaginatedList
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<Subheading sticky>{t("Tokens")}</Subheading>}
|
||||
renderItem={(token) => (
|
||||
<TokenListItem key={token.id} token={token} onDelete={token.delete} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t("Create a token")}
|
||||
onRequestClose={handleNewModalClose}
|
||||
isOpen={newModalOpen}
|
||||
>
|
||||
<APITokenNew onSubmit={handleNewModalClose} />
|
||||
</Modal>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default inject("apiKeys", "ui")(Tokens);
|
||||
export default observer(Tokens);
|
||||
|
||||
@@ -4,17 +4,20 @@ import ApiKey from "models/ApiKey";
|
||||
import Button from "components/Button";
|
||||
import ListItem from "components/List/Item";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
token: ApiKey,
|
||||
onDelete: (tokenId: string) => Promise<void>,
|
||||
};
|
||||
|};
|
||||
|
||||
const TokenListItem = ({ token, onDelete }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
key={token.id}
|
||||
title={token.name}
|
||||
subtitle={<code>{token.secret}</code>}
|
||||
title={
|
||||
<>
|
||||
{token.name} – <code>{token.secret}</code>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<Button onClick={() => onDelete(token.id)} neutral>
|
||||
Revoke
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -52,7 +52,7 @@ function UserProfile(props: Props) {
|
||||
? t("Joined")
|
||||
: t("Invited")}{" "}
|
||||
{t("{{ time }} ago.", {
|
||||
time: distanceInWordsToNow(new Date(user.createdAt)),
|
||||
time: formatDistanceToNow(Date.parse(user.createdAt)),
|
||||
})}
|
||||
{user.isAdmin && (
|
||||
<StyledBadge primary={user.isAdmin}>{t("Admin")}</StyledBadge>
|
||||
|
||||
@@ -139,7 +139,8 @@ export default class BaseStore<T: BaseModel> {
|
||||
throw new Error(`Cannot fetch ${this.modelName}`);
|
||||
}
|
||||
|
||||
let item = this.data.get(id);
|
||||
const item = this.data.get(id);
|
||||
|
||||
if (item && !options.force) return item;
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { concat, last } from "lodash";
|
||||
import { concat, find, last } from "lodash";
|
||||
import { computed, action } from "mobx";
|
||||
import Collection from "models/Collection";
|
||||
import BaseStore from "./BaseStore";
|
||||
@@ -126,6 +126,30 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
return result;
|
||||
}
|
||||
|
||||
@action
|
||||
async fetch(id: string, options: Object = {}): Promise<*> {
|
||||
const item = this.get(id) || this.getByUrl(id);
|
||||
|
||||
if (item && !options.force) return item;
|
||||
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/collections.info`, { id });
|
||||
invariant(res && res.data, "Collection not available");
|
||||
|
||||
this.addPolicies(res.policies);
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 403) {
|
||||
this.remove(id);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
getPathForDocument(documentId: string): ?DocumentPath {
|
||||
return this.pathsToDocuments.find((path) => path.id === documentId);
|
||||
}
|
||||
@@ -135,6 +159,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
if (path) return path.title;
|
||||
}
|
||||
|
||||
getByUrl(url: string): ?Collection {
|
||||
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
|
||||
}
|
||||
|
||||
delete = async (collection: Collection) => {
|
||||
await super.delete(collection);
|
||||
|
||||
|
||||
@@ -616,8 +616,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
}
|
||||
|
||||
@action
|
||||
async delete(document: Document) {
|
||||
await super.delete(document);
|
||||
async delete(document: Document, options?: {| permanent: boolean |}) {
|
||||
await super.delete(document, options);
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -108,15 +108,9 @@ class UiStore {
|
||||
this.activeCollectionId = collection.id;
|
||||
};
|
||||
|
||||
@action
|
||||
clearActiveCollection = (): void => {
|
||||
this.activeCollectionId = undefined;
|
||||
};
|
||||
|
||||
@action
|
||||
clearActiveDocument = (): void => {
|
||||
this.activeDocumentId = undefined;
|
||||
this.activeCollectionId = undefined;
|
||||
};
|
||||
|
||||
@action
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// @flow
|
||||
/* eslint-disable */
|
||||
import localStorage from '../../__mocks__/localStorage';
|
||||
import Enzyme from "enzyme";
|
||||
import Adapter from "enzyme-adapter-react-16";
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
global.localStorage = localStorage;
|
||||
@@ -0,0 +1,2 @@
|
||||
// @flow
|
||||
export const runAllPromises = () => new Promise<void>(setImmediate);
|
||||
+19
-13
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import queryString from "query-string";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
|
||||
export function homeUrl(): string {
|
||||
@@ -11,13 +12,23 @@ export function starredUrl(): string {
|
||||
}
|
||||
|
||||
export function newCollectionUrl(): string {
|
||||
return "/collections/new";
|
||||
return "/collection/new";
|
||||
}
|
||||
|
||||
export function collectionUrl(collectionId: string, section: ?string): string {
|
||||
const path = `/collections/${collectionId}`;
|
||||
if (section) return `${path}/${section}`;
|
||||
return path;
|
||||
export function collectionUrl(url: string, section: ?string): string {
|
||||
if (section) return `${url}/${section}`;
|
||||
return url;
|
||||
}
|
||||
|
||||
export function updateCollectionUrl(
|
||||
oldUrl: string,
|
||||
collection: Collection
|
||||
): string {
|
||||
// Update url to match the current one
|
||||
return oldUrl.replace(
|
||||
new RegExp("/collection/[0-9a-zA-Z-_~]*"),
|
||||
collection.url
|
||||
);
|
||||
}
|
||||
|
||||
export function documentUrl(doc: Document): string {
|
||||
@@ -42,14 +53,9 @@ export function documentHistoryUrl(doc: Document, revisionId?: string): string {
|
||||
* Replace full url's document part with the new one in case
|
||||
* the document slug has been updated
|
||||
*/
|
||||
export function updateDocumentUrl(oldUrl: string, newUrl: string): string {
|
||||
export function updateDocumentUrl(oldUrl: string, document: Document): string {
|
||||
// Update url to match the current one
|
||||
const urlParts = oldUrl.trim().split("/");
|
||||
const actions = urlParts.slice(3);
|
||||
if (actions[0]) {
|
||||
return [newUrl, actions].join("/");
|
||||
}
|
||||
return newUrl;
|
||||
return oldUrl.replace(new RegExp("/doc/[0-9a-zA-Z-_~]*"), document.url);
|
||||
}
|
||||
|
||||
export function newDocumentUrl(
|
||||
@@ -60,7 +66,7 @@ export function newDocumentUrl(
|
||||
template?: boolean,
|
||||
}
|
||||
): string {
|
||||
return `/collections/${collectionId}/new?${queryString.stringify(params)}`;
|
||||
return `/collection/${collectionId}/new?${queryString.stringify(params)}`;
|
||||
}
|
||||
|
||||
export function searchUrl(
|
||||
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
// flow-typed signature: 350413ab85bd03f3d1450c0ae307d106
|
||||
// flow-typed version: c6154227d1/copy-to-clipboard_v3.x.x/flow_>=v0.104.x
|
||||
|
||||
declare module 'copy-to-clipboard' {
|
||||
// @flow
|
||||
declare module "copy-to-clipboard" {
|
||||
declare export type Options = {|
|
||||
debug?: boolean,
|
||||
message?: string,
|
||||
format?: "text/plain" | "text/html",
|
||||
|};
|
||||
|
||||
declare module.exports: (text: string, options?: Options) => boolean;
|
||||
|
||||
+31
-51
@@ -13,6 +13,7 @@
|
||||
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node --inspect=0.0.0.0 build/server/index.js\" -e js --ignore build/ --ignore app/",
|
||||
"lint": "eslint app server shared",
|
||||
"deploy": "git push heroku master",
|
||||
"postinstall": "yarn yarn-deduplicate yarn.lock",
|
||||
"heroku-postbuild": "yarn build && yarn db:migrate",
|
||||
"sequelize:migrate": "sequelize db:migrate",
|
||||
"db:create-migration": "sequelize migration:create",
|
||||
@@ -20,7 +21,7 @@
|
||||
"db:rollback": "sequelize db:migrate:undo",
|
||||
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
|
||||
"test": "yarn test:app && yarn test:server",
|
||||
"test:app": "jest",
|
||||
"test:app": "jest --config=app/.jestconfig.json --runInBand --forceExit",
|
||||
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
|
||||
"test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll"
|
||||
},
|
||||
@@ -28,33 +29,6 @@
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/outline"
|
||||
},
|
||||
"jest": {
|
||||
"testURL": "http://localhost",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"app",
|
||||
"shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"jsx",
|
||||
"json"
|
||||
],
|
||||
"moduleDirectories": [
|
||||
"node_modules"
|
||||
],
|
||||
"modulePaths": [
|
||||
"app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/setupJest.js",
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12 <=16"
|
||||
},
|
||||
@@ -73,7 +47,6 @@
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@outlinewiki/koa-passport": "^4.1.4",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@rehooks/window-scroll-position": "^1.0.1",
|
||||
"@sentry/node": "^6.3.1",
|
||||
"@sentry/react": "^6.3.1",
|
||||
"@sentry/tracing": "^6.3.1",
|
||||
@@ -90,9 +63,10 @@
|
||||
"cancan": "3.1.0",
|
||||
"chalk": "^4.1.0",
|
||||
"compressorjs": "^1.0.7",
|
||||
"copy-to-clipboard": "^3.0.6",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"core-js": "^3.10.2",
|
||||
"date-fns": "1.29.0",
|
||||
"datadog-metrics": "^0.9.3",
|
||||
"date-fns": "2.22.1",
|
||||
"dd-trace": "^0.32.2",
|
||||
"debug": "^4.1.1",
|
||||
"dotenv": "^4.0.0",
|
||||
@@ -101,7 +75,7 @@
|
||||
"exports-loader": "^0.6.4",
|
||||
"fetch-with-proxy": "^3.0.1",
|
||||
"file-loader": "^1.1.6",
|
||||
"flow-typed": "^2.6.2",
|
||||
"flow-typed": "^3.3.1",
|
||||
"focus-visible": "^5.1.0",
|
||||
"fractional-index": "^1.0.0",
|
||||
"fs-extra": "^4.0.2",
|
||||
@@ -133,9 +107,10 @@
|
||||
"koa-static": "^4.0.1",
|
||||
"lodash": "^4.17.19",
|
||||
"mammoth": "^1.4.16",
|
||||
"mobx": "4.6.0",
|
||||
"mobx-react": "^6.2.5",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-htmldiff": "^0.9.3",
|
||||
"nodemailer": "^6.4.16",
|
||||
"outline-icons": "^1.27.0",
|
||||
"oy-vey": "^0.10.0",
|
||||
@@ -145,30 +120,31 @@
|
||||
"pg": "^8.5.1",
|
||||
"pg-hstore": "^2.3.3",
|
||||
"polished": "3.6.5",
|
||||
"query-string": "^4.3.4",
|
||||
"query-string": "^7.0.1",
|
||||
"quoted-printable": "^1.0.1",
|
||||
"randomstring": "1.1.5",
|
||||
"raw-loader": "^0.5.1",
|
||||
"react": "^16.8.6",
|
||||
"react-autosize-textarea": "^6.0.0",
|
||||
"react-avatar-editor": "^10.3.0",
|
||||
"react": "^17.0.2",
|
||||
"react-autosize-textarea": "^7.1.0",
|
||||
"react-avatar-editor": "^11.1.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-dnd": "^14.0.1",
|
||||
"react-dnd-html5-backend": "^14.0.0",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"react-helmet": "^5.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-i18next": "^11.7.3",
|
||||
"react-is": "^17.0.2",
|
||||
"react-keydown": "^1.7.3",
|
||||
"react-portal": "^4.0.0",
|
||||
"react-portal": "^4.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-table": "^7.7.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"react-waypoint": "^9.0.2",
|
||||
"react-virtualized-auto-sizer": "^1.0.5",
|
||||
"react-waypoint": "^10.1.0",
|
||||
"react-window": "^1.8.6",
|
||||
"reakit": "^1.3.6",
|
||||
"reakit": "^1.3.8",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rich-markdown-editor": "^11.9.1",
|
||||
"rich-markdown-editor": "^11.13.0",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.3.4",
|
||||
"sequelize-cli": "^6.2.0",
|
||||
@@ -177,11 +153,11 @@
|
||||
"slate-md-serializer": "5.5.4",
|
||||
"slug": "^4.0.4",
|
||||
"smooth-scroll-into-view-if-needed": "^1.1.29",
|
||||
"socket.io": "^2.3.0",
|
||||
"socket.io": "^2.4.0",
|
||||
"socket.io-redis": "^5.4.0",
|
||||
"socketio-auth": "^0.1.1",
|
||||
"string-replace-to-array": "^1.0.3",
|
||||
"styled-components": "^5.0.0",
|
||||
"styled-components": "^5.2.3",
|
||||
"styled-components-breakpoint": "^2.1.1",
|
||||
"styled-normalize": "^8.0.4",
|
||||
"tiny-cookie": "^2.3.1",
|
||||
@@ -199,6 +175,8 @@
|
||||
"babel-jest": "^26.2.2",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.6",
|
||||
"eslint": "^7.6.0",
|
||||
"eslint-config-react-app": "3.0.6",
|
||||
"eslint-plugin-flowtype": "^5.2.0",
|
||||
@@ -216,7 +194,7 @@
|
||||
"koa-webpack-hot-middleware": "^1.0.3",
|
||||
"nodemon": "^1.19.4",
|
||||
"prettier": "^2.0.5",
|
||||
"react-refresh": "^0.10.0",
|
||||
"react-refresh": "^0.9.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"terser-webpack-plugin": "^4.1.0",
|
||||
"url-loader": "^0.6.2",
|
||||
@@ -224,11 +202,13 @@
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-manifest-plugin": "^3.0.0",
|
||||
"webpack-pwa-manifest": "^4.3.0",
|
||||
"workbox-webpack-plugin": "^6.1.0"
|
||||
"workbox-webpack-plugin": "^6.1.0",
|
||||
"yarn-deduplicate": "^3.1.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"prosemirror-view": "1.18.1",
|
||||
"dot-prop": "^5.2.0",
|
||||
"js-yaml": "^3.13.1"
|
||||
},
|
||||
"version": "0.56.0"
|
||||
}
|
||||
"version": "0.57.0"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"./server/test/helper.js"
|
||||
"./server/test/setup.js"
|
||||
],
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns";
|
||||
import Router from "koa-router";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { NotFoundError } from "../errors";
|
||||
@@ -39,7 +39,7 @@ router.post("attachments.create", auth(), async (ctx) => {
|
||||
const bucket = acl === "public-read" ? "public" : "uploads";
|
||||
const key = `${bucket}/${user.id}/${s3Key}/${name}`;
|
||||
const credential = makeCredential();
|
||||
const longDate = format(new Date(), "YYYYMMDDTHHmmss\\Z");
|
||||
const longDate = format(new Date(), "yyyyMMdd'T'HHmmss'Z'");
|
||||
const policy = makePolicy(credential, longDate, acl, contentType);
|
||||
const endpoint = publicS3Endpoint();
|
||||
const url = `${endpoint}/${key}`;
|
||||
|
||||
@@ -115,7 +115,7 @@ router.post("collections.create", auth(), async (ctx) => {
|
||||
|
||||
router.post("collections.info", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertPresent(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const collection = await Collection.scope({
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("#collections.export", () => {
|
||||
createdById: user.id,
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: "read",
|
||||
permission: "read_write",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/collections.export", {
|
||||
@@ -305,7 +305,7 @@ describe("#collections.export", () => {
|
||||
await group.addUser(user, { through: { createdById: user.id } });
|
||||
|
||||
await collection.addGroup(group, {
|
||||
through: { permission: "read", createdById: user.id },
|
||||
through: { permission: "read_write", createdById: user.id },
|
||||
});
|
||||
|
||||
const res = await server.post("/api/collections.export", {
|
||||
|
||||
+43
-13
@@ -5,6 +5,7 @@ import { subtractDate } from "../../shared/utils/date";
|
||||
import documentCreator from "../commands/documentCreator";
|
||||
import documentImporter from "../commands/documentImporter";
|
||||
import documentMover from "../commands/documentMover";
|
||||
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
|
||||
import env from "../env";
|
||||
import {
|
||||
NotFoundError,
|
||||
@@ -1174,24 +1175,53 @@ router.post("documents.archive", auth(), async (ctx) => {
|
||||
});
|
||||
|
||||
router.post("documents.delete", auth(), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
const { id, permanent } = ctx.body;
|
||||
ctx.assertPresent(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
authorize(user, "delete", document);
|
||||
if (permanent) {
|
||||
const document = await Document.findByPk(id, {
|
||||
userId: user.id,
|
||||
paranoid: false,
|
||||
});
|
||||
authorize(user, "permanentDelete", document);
|
||||
|
||||
await document.delete(user.id);
|
||||
await Document.update(
|
||||
{ parentDocumentId: null },
|
||||
{
|
||||
where: {
|
||||
parentDocumentId: document.id,
|
||||
},
|
||||
paranoid: false,
|
||||
}
|
||||
);
|
||||
|
||||
await Event.create({
|
||||
name: "documents.delete",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: { title: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
await documentPermanentDeleter([document]);
|
||||
|
||||
await Event.create({
|
||||
name: "documents.permanent_delete",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: { title: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
} else {
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
authorize(user, "delete", document);
|
||||
|
||||
await document.delete(user.id);
|
||||
await Event.create({
|
||||
name: "documents.delete",
|
||||
documentId: document.id,
|
||||
collectionId: document.collectionId,
|
||||
teamId: document.teamId,
|
||||
actorId: user.id,
|
||||
data: { title: document.title },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -984,6 +984,21 @@ describe("#documents.search", () => {
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not error when search term is very long", async () => {
|
||||
const { user } = await seed();
|
||||
const res = await server.post("/api/documents.search", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
query:
|
||||
"much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much longer search term",
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should return draft documents created by user if chosen", async () => {
|
||||
const { user } = await seed();
|
||||
const document = await buildDocument({
|
||||
@@ -1588,7 +1603,7 @@ describe("#documents.restore", () => {
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.parentDocumentId).toEqual(undefined);
|
||||
expect(body.data.parentDocumentId).toEqual(null);
|
||||
expect(body.data.archivedAt).toEqual(null);
|
||||
});
|
||||
|
||||
@@ -2186,6 +2201,26 @@ describe("#documents.delete", () => {
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
|
||||
it("should allow permanently deleting a document", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
await server.post("/api/documents.delete", {
|
||||
body: { token: user.getJwtToken(), id: document.id },
|
||||
});
|
||||
|
||||
const res = await server.post("/api/documents.delete", {
|
||||
body: { token: user.getJwtToken(), id: document.id, permanent: true },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
});
|
||||
|
||||
it("should allow deleting document without collection", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
|
||||
let {
|
||||
sort = "createdAt",
|
||||
actorId,
|
||||
documentId,
|
||||
collectionId,
|
||||
direction,
|
||||
name,
|
||||
@@ -31,10 +32,12 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
|
||||
|
||||
if (actorId) {
|
||||
ctx.assertUuid(actorId, "actorId must be a UUID");
|
||||
where = {
|
||||
...where,
|
||||
actorId,
|
||||
};
|
||||
where = { ...where, actorId };
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
ctx.assertUuid(documentId, "documentId must be a UUID");
|
||||
where = { ...where, documentId };
|
||||
}
|
||||
|
||||
if (collectionId) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "../app";
|
||||
import { buildEvent } from "../test/factories";
|
||||
import { buildEvent, buildUser } from "../test/factories";
|
||||
import { flushdb, seed } from "../test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
@@ -101,6 +101,54 @@ describe("#events.list", () => {
|
||||
expect(body.data[0].id).toEqual(auditEvent.id);
|
||||
});
|
||||
|
||||
it("should allow filtering by documentId", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
const event = await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(1);
|
||||
expect(body.data[0].id).toEqual(event.id);
|
||||
});
|
||||
|
||||
it("should not return events for documentId without authorization", async () => {
|
||||
const { user, document, collection } = await seed();
|
||||
const actor = await buildUser();
|
||||
|
||||
await buildEvent({
|
||||
name: "documents.publish",
|
||||
collectionId: collection.id,
|
||||
documentId: document.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/events.list", {
|
||||
body: {
|
||||
token: actor.getJwtToken(),
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should allow filtering by event name", async () => {
|
||||
const { user, admin, document, collection } = await seed();
|
||||
|
||||
|
||||
+1
-1
@@ -258,7 +258,7 @@ router.post("users.activate", auth(), async (ctx) => {
|
||||
|
||||
router.post("users.invite", auth(), async (ctx) => {
|
||||
const { invites } = ctx.body;
|
||||
ctx.assertPresent(invites, "invites is required");
|
||||
ctx.assertArray(invites, "invites must be an array");
|
||||
|
||||
const { user } = ctx.state;
|
||||
const team = await Team.findByPk(user.teamId);
|
||||
|
||||
@@ -167,6 +167,17 @@ describe("#users.invite", () => {
|
||||
expect(body.data.sent.length).toEqual(1);
|
||||
});
|
||||
|
||||
it("should require invites to be an array", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
invites: { email: "test@example.com", name: "Test", guest: false },
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should require admin", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/users.invite", {
|
||||
|
||||
+7
-52
@@ -1,11 +1,11 @@
|
||||
// @flow
|
||||
import subDays from "date-fns/sub_days";
|
||||
import { subDays } from "date-fns";
|
||||
import debug from "debug";
|
||||
import Router from "koa-router";
|
||||
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
|
||||
import { AuthenticationError } from "../errors";
|
||||
import { Document, Attachment } from "../models";
|
||||
import { Op, sequelize } from "../sequelize";
|
||||
import parseAttachmentIds from "../utils/parseAttachmentIds";
|
||||
import { Document } from "../models";
|
||||
import { Op } from "../sequelize";
|
||||
|
||||
const router = new Router();
|
||||
const log = debug("utils");
|
||||
@@ -20,7 +20,7 @@ router.post("utils.gc", async (ctx) => {
|
||||
log(`Permanently destroying upto ${limit} documents older than 30 days…`);
|
||||
|
||||
const documents = await Document.scope("withUnpublished").findAll({
|
||||
attributes: ["id", "teamId", "text"],
|
||||
attributes: ["id", "teamId", "text", "deletedAt"],
|
||||
where: {
|
||||
deletedAt: {
|
||||
[Op.lt]: subDays(new Date(), 30),
|
||||
@@ -30,54 +30,9 @@ router.post("utils.gc", async (ctx) => {
|
||||
limit,
|
||||
});
|
||||
|
||||
const query = `
|
||||
SELECT COUNT(id)
|
||||
FROM documents
|
||||
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
||||
"teamId" = :teamId AND
|
||||
"id" != :documentId
|
||||
`;
|
||||
const countDeletedDocument = await documentPermanentDeleter(documents);
|
||||
|
||||
for (const document of documents) {
|
||||
const attachmentIds = parseAttachmentIds(document.text);
|
||||
|
||||
for (const attachmentId of attachmentIds) {
|
||||
const [{ count }] = await sequelize.query(query, {
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: {
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
query: attachmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (parseInt(count) === 0) {
|
||||
const attachment = await Attachment.findOne({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
id: attachmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
await attachment.destroy();
|
||||
|
||||
log(`Attachment ${attachmentId} deleted`);
|
||||
} else {
|
||||
log(`Unknown attachment ${attachmentId} ignored`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Document.scope("withUnpublished").destroy({
|
||||
where: {
|
||||
id: documents.map((document) => document.id),
|
||||
},
|
||||
force: true,
|
||||
});
|
||||
|
||||
log(`Destroyed ${documents.length} documents`);
|
||||
log(`Destroyed ${countDeletedDocument} documents`);
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import subDays from "date-fns/sub_days";
|
||||
import { subDays } from "date-fns";
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "../app";
|
||||
import { Attachment, Document } from "../models";
|
||||
import { buildAttachment, buildDocument } from "../test/factories";
|
||||
import { Document } from "../models";
|
||||
import { buildDocument } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
@@ -67,94 +67,6 @@ describe("#utils.gc", () => {
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||
});
|
||||
|
||||
it("should destroy attachments no longer referenced", async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: subDays(new Date(), 90),
|
||||
deletedAt: subDays(new Date(), 60),
|
||||
});
|
||||
|
||||
const attachment = await buildAttachment({
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
document.text = ``;
|
||||
await document.save();
|
||||
|
||||
const res = await server.post("/api/utils.gc", {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Attachment.count()).toEqual(0);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||
});
|
||||
|
||||
it("should handle unknown attachment ids", async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: subDays(new Date(), 90),
|
||||
deletedAt: subDays(new Date(), 60),
|
||||
});
|
||||
|
||||
const attachment = await buildAttachment({
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
document.text = ``;
|
||||
await document.save();
|
||||
|
||||
// remove attachment so it no longer exists in the database, this is also
|
||||
// representative of a corrupt attachment id in the doc or the regex returning
|
||||
// an incorrect string
|
||||
await attachment.destroy({ force: true });
|
||||
|
||||
const res = await server.post("/api/utils.gc", {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Attachment.count()).toEqual(0);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not destroy attachments referenced in other documents", async () => {
|
||||
const document1 = await buildDocument();
|
||||
|
||||
const document = await buildDocument({
|
||||
teamId: document1.teamId,
|
||||
publishedAt: subDays(new Date(), 90),
|
||||
deletedAt: subDays(new Date(), 60),
|
||||
});
|
||||
|
||||
const attachment = await buildAttachment({
|
||||
teamId: document1.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
document1.text = ``;
|
||||
await document1.save();
|
||||
|
||||
document.text = ``;
|
||||
await document.save();
|
||||
|
||||
expect(await Attachment.count()).toEqual(1);
|
||||
|
||||
const res = await server.post("/api/utils.gc", {
|
||||
body: {
|
||||
token: process.env.UTILS_SECRET,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(await Attachment.count()).toEqual(1);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
|
||||
});
|
||||
|
||||
it("should destroy draft documents deleted more than 30 days ago", async () => {
|
||||
await buildDocument({
|
||||
publishedAt: undefined,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @flow
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "./app";
|
||||
import { buildShare, buildDocument } from "./test/factories";
|
||||
import { flushdb } from "./test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("/share/:id", () => {
|
||||
it("should return standard title in html when loading share", async () => {
|
||||
const share = await buildShare({ published: false });
|
||||
|
||||
const res = await server.get(`/share/${share.id}`);
|
||||
const body = await res.text();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toContain("<title>Outline</title>");
|
||||
});
|
||||
|
||||
it("should return standard title in html when share does not exist", async () => {
|
||||
const res = await server.get(`/share/junk`);
|
||||
const body = await res.text();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toContain("<title>Outline</title>");
|
||||
});
|
||||
|
||||
it("should return document title in html when loading published share", async () => {
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({ documentId: document.id });
|
||||
|
||||
const res = await server.get(`/share/${share.id}`);
|
||||
const body = await res.text();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toContain(`<title>${document.title}</title>`);
|
||||
});
|
||||
|
||||
it("should return document title in html when loading published share with nested doc route", async () => {
|
||||
const document = await buildDocument();
|
||||
const share = await buildShare({ documentId: document.id });
|
||||
|
||||
const res = await server.get(`/share/${share.id}/doc/test-Cl6g1AgPYn`);
|
||||
const body = await res.text();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body).toContain(`<title>${document.title}</title>`);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import passport from "@outlinewiki/koa-passport";
|
||||
import addMonths from "date-fns/add_months";
|
||||
import { addMonths } from "date-fns";
|
||||
import debug from "debug";
|
||||
import Koa from "koa";
|
||||
import bodyParser from "koa-body";
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// @flow
|
||||
import subMinutes from "date-fns/sub_minutes";
|
||||
import { subMinutes } from "date-fns";
|
||||
import Router from "koa-router";
|
||||
import { find } from "lodash";
|
||||
import { parseDomain, isCustomSubdomain } from "../../../shared/utils/domains";
|
||||
import { AuthorizationError } from "../../errors";
|
||||
import mailer, { sendEmail } from "../../mailer";
|
||||
import errorHandling from "../../middlewares/errorHandling";
|
||||
import methodOverride from "../../middlewares/methodOverride";
|
||||
import validation from "../../middlewares/validation";
|
||||
import { User, Team } from "../../models";
|
||||
import { signIn } from "../../utils/authentication";
|
||||
import { isCustomDomain } from "../../utils/domains";
|
||||
import { getUserForEmailSigninToken } from "../../utils/jwt";
|
||||
|
||||
const router = new Router();
|
||||
@@ -20,19 +23,56 @@ export const config = {
|
||||
router.use(methodOverride());
|
||||
router.use(validation());
|
||||
|
||||
router.post("email", async (ctx) => {
|
||||
router.post("email", errorHandling(), async (ctx) => {
|
||||
const { email } = ctx.body;
|
||||
|
||||
ctx.assertEmail(email, "email is required");
|
||||
|
||||
const user = await User.scope("withAuthentications").findOne({
|
||||
const users = await User.scope("withAuthentications").findAll({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
const team = await Team.scope("withAuthenticationProviders").findByPk(
|
||||
user.teamId
|
||||
);
|
||||
if (users.length) {
|
||||
let team;
|
||||
|
||||
if (isCustomDomain(ctx.request.hostname)) {
|
||||
team = await Team.scope("withAuthenticationProviders").findOne({
|
||||
where: { domain: ctx.request.hostname },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.SUBDOMAINS_ENABLED === "true" &&
|
||||
isCustomSubdomain(ctx.request.hostname) &&
|
||||
!isCustomDomain(ctx.request.hostname)
|
||||
) {
|
||||
const domain = parseDomain(ctx.request.hostname);
|
||||
const subdomain = domain ? domain.subdomain : undefined;
|
||||
team = await Team.scope("withAuthenticationProviders").findOne({
|
||||
where: { subdomain },
|
||||
});
|
||||
}
|
||||
|
||||
// If there are multiple users with this email address then give precedence
|
||||
// to the one that is active on this subdomain/domain (if any)
|
||||
let user = users.find((user) => team && user.teamId === team.id);
|
||||
|
||||
// A user was found for the email address, but they don't belong to the team
|
||||
// that this subdomain belongs to, we load their team and allow the logic to
|
||||
// continue
|
||||
if (!user) {
|
||||
user = users[0];
|
||||
team = await Team.scope("withAuthenticationProviders").findByPk(
|
||||
user.teamId
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
team = await Team.scope("withAuthenticationProviders").findByPk(
|
||||
user.teamId
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
ctx.redirect(`/?notice=auth-error`);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// @flow
|
||||
import TestServer from "fetch-test-server";
|
||||
import app from "../../app";
|
||||
import mailer from "../../mailer";
|
||||
import { buildUser, buildGuestUser, buildTeam } from "../../test/factories";
|
||||
import { flushdb } from "../../test/support";
|
||||
|
||||
const server = new TestServer(app.callback());
|
||||
|
||||
jest.mock("../../mailer");
|
||||
|
||||
beforeEach(async () => {
|
||||
await flushdb();
|
||||
|
||||
// $FlowFixMe – does not understand Jest mocks
|
||||
mailer.signin.mockReset();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
describe("email", () => {
|
||||
it("should require email param", async () => {
|
||||
const res = await server.post("/auth/email", {
|
||||
body: {},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.error).toEqual("validation_error");
|
||||
expect(body.ok).toEqual(false);
|
||||
});
|
||||
|
||||
it("should respond with redirect location when user is SSO enabled", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.post("/auth/email", {
|
||||
body: { email: user.email },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.redirect).toMatch("slack");
|
||||
expect(mailer.signin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should respond with redirect location when user is SSO enabled on another subdomain", async () => {
|
||||
process.env.URL = "http://localoutline.com";
|
||||
process.env.SUBDOMAINS_ENABLED = "true";
|
||||
|
||||
const user = await buildUser();
|
||||
|
||||
await buildTeam({
|
||||
subdomain: "example",
|
||||
});
|
||||
|
||||
const res = await server.post("/auth/email", {
|
||||
body: { email: user.email },
|
||||
headers: { host: "example.localoutline.com" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.redirect).toMatch("slack");
|
||||
expect(mailer.signin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should respond with success when user is not SSO enabled", async () => {
|
||||
const user = await buildGuestUser();
|
||||
|
||||
const res = await server.post("/auth/email", {
|
||||
body: { email: user.email },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(mailer.signin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
|
||||
const res = await server.post("/auth/email", {
|
||||
body: { email: "user@example.com" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(mailer.signin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("with multiple users matching email", () => {
|
||||
it("should default to current subdomain with SSO", async () => {
|
||||
process.env.URL = "http://localoutline.com";
|
||||
process.env.SUBDOMAINS_ENABLED = "true";
|
||||
|
||||
const email = "sso-user@example.org";
|
||||
const team = await buildTeam({
|
||||
subdomain: "example",
|
||||
});
|
||||
|
||||
await buildGuestUser({ email });
|
||||
await buildUser({ email, teamId: team.id });
|
||||
|
||||
const res = await server.post("/auth/email", {
|
||||
body: { email },
|
||||
headers: { host: "example.localoutline.com" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.redirect).toMatch("slack");
|
||||
expect(mailer.signin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should default to current subdomain with guest email", async () => {
|
||||
process.env.URL = "http://localoutline.com";
|
||||
process.env.SUBDOMAINS_ENABLED = "true";
|
||||
|
||||
const email = "guest-user@example.org";
|
||||
const team = await buildTeam({
|
||||
subdomain: "example",
|
||||
});
|
||||
|
||||
await buildUser({ email });
|
||||
await buildGuestUser({ email, teamId: team.id });
|
||||
|
||||
const res = await server.post("/auth/email", {
|
||||
body: { email },
|
||||
headers: { host: "example.localoutline.com" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(mailer.signin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should default to custom domain with SSO", async () => {
|
||||
const email = "sso-user-2@example.org";
|
||||
const team = await buildTeam({
|
||||
domain: "docs.mycompany.com",
|
||||
});
|
||||
|
||||
await buildGuestUser({ email });
|
||||
await buildUser({ email, teamId: team.id });
|
||||
|
||||
const res = await server.post("/auth/email", {
|
||||
body: { email },
|
||||
headers: { host: "docs.mycompany.com" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.redirect).toMatch("slack");
|
||||
expect(mailer.signin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should default to custom domain with guest email", async () => {
|
||||
const email = "guest-user-2@example.org";
|
||||
const team = await buildTeam({
|
||||
domain: "docs.mycompany.com",
|
||||
});
|
||||
|
||||
await buildUser({ email });
|
||||
await buildGuestUser({ email, teamId: team.id });
|
||||
|
||||
const res = await server.post("/auth/email", {
|
||||
body: { email },
|
||||
headers: { host: "docs.mycompany.com" },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.success).toEqual(true);
|
||||
expect(mailer.signin).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,10 @@ jest.mock("aws-sdk", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
flushdb();
|
||||
|
||||
// $FlowFixMe
|
||||
sendEmail.mockReset();
|
||||
|
||||
return flushdb();
|
||||
});
|
||||
|
||||
describe("accountProvisioner", () => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// @flow
|
||||
import debug from "debug";
|
||||
import { Document, Attachment } from "../models";
|
||||
import { sequelize } from "../sequelize";
|
||||
import parseAttachmentIds from "../utils/parseAttachmentIds";
|
||||
|
||||
const log = debug("commands");
|
||||
|
||||
export async function documentPermanentDeleter(documents: Document[]) {
|
||||
const activeDocument = documents.find((doc) => !doc.deletedAt);
|
||||
|
||||
if (activeDocument) {
|
||||
throw new Error(
|
||||
`Cannot permanently delete ${activeDocument.id} document. Please delete it and try again.`
|
||||
);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT COUNT(id)
|
||||
FROM documents
|
||||
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
||||
"teamId" = :teamId AND
|
||||
"id" != :documentId
|
||||
`;
|
||||
|
||||
for (const document of documents) {
|
||||
const attachmentIds = parseAttachmentIds(document.text);
|
||||
|
||||
for (const attachmentId of attachmentIds) {
|
||||
const [{ count }] = await sequelize.query(query, {
|
||||
type: sequelize.QueryTypes.SELECT,
|
||||
replacements: {
|
||||
documentId: document.id,
|
||||
teamId: document.teamId,
|
||||
query: attachmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (parseInt(count) === 0) {
|
||||
const attachment = await Attachment.findOne({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
id: attachmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (attachment) {
|
||||
await attachment.destroy();
|
||||
|
||||
log(`Attachment ${attachmentId} deleted`);
|
||||
} else {
|
||||
log(`Unknown attachment ${attachmentId} ignored`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Document.scope("withUnpublished").destroy({
|
||||
where: {
|
||||
id: documents.map((document) => document.id),
|
||||
},
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// @flow
|
||||
import { subDays } from "date-fns";
|
||||
import { Attachment, Document } from "../models";
|
||||
import { buildAttachment, buildDocument } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
import { documentPermanentDeleter } from "./documentPermanentDeleter";
|
||||
|
||||
jest.mock("aws-sdk", () => {
|
||||
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||
return {
|
||||
S3: jest.fn(() => mS3),
|
||||
Endpoint: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
describe("documentPermanentDeleter", () => {
|
||||
it("should destroy documents", async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: subDays(new Date(), 90),
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
|
||||
const countDeletedDoc = await documentPermanentDeleter([document]);
|
||||
|
||||
expect(countDeletedDoc).toEqual(1);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||
});
|
||||
|
||||
it("should error when trying to destroy undeleted documents", async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
let error;
|
||||
try {
|
||||
await documentPermanentDeleter([document]);
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
}
|
||||
|
||||
expect(error).toEqual(
|
||||
`Cannot permanently delete ${document.id} document. Please delete it and try again.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should destroy attachments no longer referenced", async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: subDays(new Date(), 90),
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
|
||||
const attachment = await buildAttachment({
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
document.text = ``;
|
||||
await document.save();
|
||||
|
||||
const countDeletedDoc = await documentPermanentDeleter([document]);
|
||||
|
||||
expect(countDeletedDoc).toEqual(1);
|
||||
expect(await Attachment.count()).toEqual(0);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||
});
|
||||
|
||||
it("should handle unknown attachment ids", async () => {
|
||||
const document = await buildDocument({
|
||||
publishedAt: subDays(new Date(), 90),
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
|
||||
const attachment = await buildAttachment({
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
document.text = ``;
|
||||
await document.save();
|
||||
|
||||
// remove attachment so it no longer exists in the database, this is also
|
||||
// representative of a corrupt attachment id in the doc or the regex returning
|
||||
// an incorrect string
|
||||
await attachment.destroy({ force: true });
|
||||
|
||||
const countDeletedDoc = await documentPermanentDeleter([document]);
|
||||
|
||||
expect(countDeletedDoc).toEqual(1);
|
||||
expect(await Attachment.count()).toEqual(0);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
||||
});
|
||||
|
||||
it("should not destroy attachments referenced in other documents", async () => {
|
||||
const document1 = await buildDocument();
|
||||
|
||||
const document = await buildDocument({
|
||||
teamId: document1.teamId,
|
||||
publishedAt: subDays(new Date(), 90),
|
||||
deletedAt: subDays(new Date(), 60),
|
||||
});
|
||||
|
||||
const attachment = await buildAttachment({
|
||||
teamId: document1.teamId,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
document1.text = ``;
|
||||
await document1.save();
|
||||
|
||||
document.text = ``;
|
||||
await document.save();
|
||||
|
||||
expect(await Attachment.count()).toEqual(1);
|
||||
|
||||
const countDeletedDoc = await documentPermanentDeleter([document]);
|
||||
|
||||
expect(countDeletedDoc).toEqual(1);
|
||||
expect(await Attachment.count()).toEqual(1);
|
||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -41,11 +41,13 @@ export const CollectionNotificationEmail = ({
|
||||
<Body>
|
||||
<Heading>{collection.name}</Heading>
|
||||
<p>
|
||||
{actor.name} {eventName} the collection "{collection.name}".
|
||||
{actor.name} {eventName} the collection “{collection.name}”.
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${process.env.URL}${collection.url}`}>
|
||||
<Button
|
||||
href={`${process.env.URL}${collection.url}?ref=notification-email`}
|
||||
>
|
||||
Open Collection
|
||||
</Button>
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import theme from "../../shared/styles/theme";
|
||||
import { User, Document, Team, Collection } from "../models";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
import Diff from "./components/Diff";
|
||||
import EmailTemplate from "./components/EmailLayout";
|
||||
import EmptySpace from "./components/EmptySpace";
|
||||
import Footer from "./components/Footer";
|
||||
@@ -15,6 +17,7 @@ export type Props = {
|
||||
document: Document,
|
||||
collection: Collection,
|
||||
eventName: string,
|
||||
summary: string,
|
||||
unsubscribeUrl: string,
|
||||
};
|
||||
|
||||
@@ -38,26 +41,34 @@ export const DocumentNotificationEmail = ({
|
||||
document,
|
||||
collection,
|
||||
eventName = "published",
|
||||
summary,
|
||||
unsubscribeUrl,
|
||||
}: Props) => {
|
||||
const link = `${team.url}${document.url}?ref=notification-email`;
|
||||
|
||||
return (
|
||||
<EmailTemplate>
|
||||
<Header />
|
||||
|
||||
<Body>
|
||||
<Heading>
|
||||
"{document.title}" {eventName}
|
||||
“{document.title}” {eventName}
|
||||
</Heading>
|
||||
<p>
|
||||
{actor.name} {eventName} the document "{document.title}", in the{" "}
|
||||
{collection.name} collection.
|
||||
</p>
|
||||
<hr />
|
||||
<EmptySpace height={10} />
|
||||
<p>{document.getSummary()}</p>
|
||||
<EmptySpace height={10} />
|
||||
{summary && (
|
||||
<>
|
||||
<EmptySpace height={20} />
|
||||
<Diff href={link}>
|
||||
<div dangerouslySetInnerHTML={{ __html: summary }} />
|
||||
</Diff>
|
||||
<EmptySpace height={20} />
|
||||
</>
|
||||
)}
|
||||
<p>
|
||||
<Button href={`${team.url}${document.url}`}>Open Document</Button>
|
||||
<Button href={link}>Open Document</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
@@ -65,3 +76,211 @@ export const DocumentNotificationEmail = ({
|
||||
</EmailTemplate>
|
||||
);
|
||||
};
|
||||
|
||||
export const css = `
|
||||
font-family: ${theme.fontFamily};
|
||||
font-weight: ${theme.fontWeight};
|
||||
font-size: 1em;
|
||||
line-height: 1.7em;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img {
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
img.image-right-50 {
|
||||
float: right;
|
||||
width: 50%;
|
||||
margin-left: 2em;
|
||||
margin-bottom: 1em;
|
||||
clear: initial;
|
||||
}
|
||||
|
||||
img.image-left-50 {
|
||||
float: left;
|
||||
width: 50%;
|
||||
margin-right: 2em;
|
||||
margin-bottom: 1em;
|
||||
clear: initial;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 1em 0 0.5em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${theme.noticeInfoBackground};
|
||||
color: ${theme.noticeInfoText};
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.notice-tip {
|
||||
background: ${theme.noticeTipBackground};
|
||||
color: ${theme.noticeTipText};
|
||||
}
|
||||
|
||||
.notice-warning {
|
||||
background: ${theme.noticeWarningBackground};
|
||||
color: ${theme.noticeWarningText};
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${theme.link};
|
||||
}
|
||||
|
||||
ins {
|
||||
background-color: #128a2929;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
del {
|
||||
background-color: ${theme.slateLight};
|
||||
color: ${theme.slate};
|
||||
text-decoration: strikethrough;
|
||||
}
|
||||
|
||||
hr {
|
||||
position: relative;
|
||||
height: 1em;
|
||||
border: 0;
|
||||
}
|
||||
hr:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-top: 1px solid ${theme.horizontalRule};
|
||||
top: 0.5em;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
hr.page-break {
|
||||
page-break-after: always;
|
||||
}
|
||||
hr.page-break:before {
|
||||
border-top: 1px dashed ${theme.horizontalRule};
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${theme.codeBorder};
|
||||
padding: 3px 4px;
|
||||
font-family: ${theme.fontFamilyMono};
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
mark {
|
||||
border-radius: 1px;
|
||||
color: ${theme.textHighlightForeground};
|
||||
background: ${theme.textHighlight};
|
||||
a {
|
||||
color: ${theme.textHighlightForeground};
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.checkbox-list-item {
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
font-size: 0;
|
||||
display: block;
|
||||
float: left;
|
||||
white-space: nowrap;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 2px;
|
||||
margin-right: 8px;
|
||||
border: 1px solid ${theme.textSecondary};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.75em 1em;
|
||||
line-height: 1.4em;
|
||||
position: relative;
|
||||
background: ${theme.codeBackground};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${theme.codeBorder};
|
||||
-webkit-font-smoothing: initial;
|
||||
font-family: ${theme.fontFamilyMono};
|
||||
font-size: 13px;
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
white-space: pre;
|
||||
word-spacing: normal;
|
||||
word-break: normal;
|
||||
-moz-tab-size: 4;
|
||||
-o-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-hyphens: none;
|
||||
-moz-hyphens: none;
|
||||
-ms-hyphens: none;
|
||||
hyphens: none;
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-radius: 4px;
|
||||
margin-top: 1em;
|
||||
box-sizing: border-box;
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
tr {
|
||||
position: relative;
|
||||
border-bottom: 1px solid ${theme.tableDivider};
|
||||
}
|
||||
td,
|
||||
th {
|
||||
position: relative;
|
||||
vertical-align: top;
|
||||
border: 1px solid ${theme.tableDivider};
|
||||
position: relative;
|
||||
padding: 4px 8px;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -47,7 +47,7 @@ export const InviteEmail = ({
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={teamUrl}>Join now</Button>
|
||||
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ export const WelcomeEmail = ({ teamUrl }: Props) => {
|
||||
</p>
|
||||
<EmptySpace height={10} />
|
||||
<p>
|
||||
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
|
||||
<Button href={`${teamUrl}/home?ref=welcome-email`}>
|
||||
View my dashboard
|
||||
</Button>
|
||||
</p>
|
||||
</Body>
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import theme from "../../../shared/styles/theme";
|
||||
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
href?: string,
|
||||
|};
|
||||
|
||||
export default ({ children, ...rest }: Props) => {
|
||||
const style = {
|
||||
borderRadius: "4px",
|
||||
background: theme.secondaryBackground,
|
||||
padding: ".5em 1em",
|
||||
color: theme.text,
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
return (
|
||||
<a width="100%" style={style} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -3,9 +3,9 @@ import { Table, TBody, TR, TD } from "oy-vey";
|
||||
import * as React from "react";
|
||||
import theme from "../../../shared/styles/theme";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children: React.Node,
|
||||
};
|
||||
|};
|
||||
|
||||
export default (props: Props) => (
|
||||
<Table width="550" padding="40">
|
||||
|
||||
@@ -35,6 +35,7 @@ export type DocumentEvent =
|
||||
name: | "documents.create" // eslint-disable-line
|
||||
| "documents.publish"
|
||||
| "documents.delete"
|
||||
| "documents.permanent_delete"
|
||||
| "documents.pin"
|
||||
| "documents.unpin"
|
||||
| "documents.archive"
|
||||
@@ -99,6 +100,8 @@ export type RevisionEvent = {
|
||||
documentId: string,
|
||||
collectionId: string,
|
||||
teamId: string,
|
||||
actorId: string,
|
||||
modelId: string,
|
||||
};
|
||||
|
||||
export type CollectionImportEvent = {
|
||||
|
||||
+27
-13
@@ -13,6 +13,7 @@ import {
|
||||
type Props as DocumentNotificationEmailT,
|
||||
DocumentNotificationEmail,
|
||||
documentNotificationEmailText,
|
||||
css as documentNotificationEmailCSS,
|
||||
} from "./emails/DocumentNotificationEmail";
|
||||
import { ExportEmail, exportEmailText } from "./emails/ExportEmail";
|
||||
import {
|
||||
@@ -146,8 +147,9 @@ export class Mailer {
|
||||
this.sendMail({
|
||||
to: opts.to,
|
||||
title: `“${opts.document.title}” ${opts.eventName}`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a new document`,
|
||||
previewText: `${opts.actor.name} ${opts.eventName} a document`,
|
||||
html: <DocumentNotificationEmail {...opts} />,
|
||||
headCSS: documentNotificationEmailCSS,
|
||||
text: documentNotificationEmailText(opts),
|
||||
});
|
||||
};
|
||||
@@ -173,8 +175,15 @@ export class Mailer {
|
||||
let smtpConfig = {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure:
|
||||
"SMTP_SECURE" in process.env
|
||||
? process.env.SMTP_SECURE === "true"
|
||||
: process.env.NODE_ENV === "production",
|
||||
auth: undefined,
|
||||
tls:
|
||||
"SMTP_TLS_CIPHERS" in process.env
|
||||
? { ciphers: process.env.SMTP_TLS_CIPHERS }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (process.env.SMTP_USERNAME) {
|
||||
@@ -190,19 +199,24 @@ export class Mailer {
|
||||
|
||||
if (useTestEmailService) {
|
||||
log("SMTP_USERNAME not provided, generating test account…");
|
||||
let testAccount = await nodemailer.createTestAccount();
|
||||
|
||||
const smtpConfig = {
|
||||
host: "smtp.ethereal.email",
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: testAccount.user,
|
||||
pass: testAccount.pass,
|
||||
},
|
||||
};
|
||||
try {
|
||||
let testAccount = await nodemailer.createTestAccount();
|
||||
|
||||
this.transporter = nodemailer.createTransport(smtpConfig);
|
||||
const smtpConfig = {
|
||||
host: "smtp.ethereal.email",
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: testAccount.user,
|
||||
pass: testAccount.pass,
|
||||
},
|
||||
};
|
||||
|
||||
this.transporter = nodemailer.createTransport(smtpConfig);
|
||||
} catch (err) {
|
||||
log(`Could not generate test account: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -9,6 +9,7 @@ import { Document, Collection, View } from "./models";
|
||||
import policy from "./policies";
|
||||
import { client, subscriber } from "./redis";
|
||||
import { getUserForJWT } from "./utils/jwt";
|
||||
import * as metrics from "./utils/metrics";
|
||||
import { checkMigrations } from "./utils/startup";
|
||||
|
||||
const server = http.createServer(app.callback());
|
||||
@@ -29,6 +30,10 @@ io.adapter(
|
||||
})
|
||||
);
|
||||
|
||||
io.origins((_, callback) => {
|
||||
callback(null, true);
|
||||
});
|
||||
|
||||
io.of("/").adapter.on("error", (err) => {
|
||||
if (err.name === "MaxRetriesPerRequestError") {
|
||||
console.error(`Redis error: ${err.message}. Shutting down now.`);
|
||||
@@ -38,6 +43,22 @@ io.of("/").adapter.on("error", (err) => {
|
||||
}
|
||||
});
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
metrics.increment("websockets.connected");
|
||||
metrics.gaugePerInstance(
|
||||
"websockets.count",
|
||||
socket.client.conn.server.clientsCount
|
||||
);
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
metrics.increment("websockets.disconnected");
|
||||
metrics.gaugePerInstance(
|
||||
"websockets.count",
|
||||
socket.client.conn.server.clientsCount
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
SocketAuth(io, {
|
||||
authenticate: async (socket, data, callback) => {
|
||||
const { token } = data;
|
||||
@@ -83,7 +104,9 @@ SocketAuth(io, {
|
||||
}).findByPk(event.collectionId);
|
||||
|
||||
if (can(user, "read", collection)) {
|
||||
socket.join(`collection-${event.collectionId}`);
|
||||
socket.join(`collection-${event.collectionId}`, () => {
|
||||
metrics.increment("websockets.collections.join");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +126,8 @@ SocketAuth(io, {
|
||||
);
|
||||
|
||||
socket.join(room, () => {
|
||||
metrics.increment("websockets.documents.join");
|
||||
|
||||
// let everyone else in the room know that a new user joined
|
||||
io.to(room).emit("user.join", {
|
||||
userId: user.id,
|
||||
@@ -146,11 +171,15 @@ SocketAuth(io, {
|
||||
// allow the client to request to leave rooms
|
||||
socket.on("leave", (event) => {
|
||||
if (event.collectionId) {
|
||||
socket.leave(`collection-${event.collectionId}`);
|
||||
socket.leave(`collection-${event.collectionId}`, () => {
|
||||
metrics.increment("websockets.collections.leave");
|
||||
});
|
||||
}
|
||||
if (event.documentId) {
|
||||
const room = `document-${event.documentId}`;
|
||||
socket.leave(room, () => {
|
||||
metrics.increment("websockets.documents.leave");
|
||||
|
||||
io.to(room).emit("user.leave", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
@@ -174,6 +203,8 @@ SocketAuth(io, {
|
||||
});
|
||||
|
||||
socket.on("presence", async (event) => {
|
||||
metrics.increment("websockets.presence");
|
||||
|
||||
const room = `document-${event.documentId}`;
|
||||
|
||||
if (event.documentId && socket.rooms[room]) {
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function createMiddleware(providerName: string) {
|
||||
return passport.authorize(
|
||||
providerName,
|
||||
{ session: false },
|
||||
async (err, _, result: AccountProvisionerResult) => {
|
||||
async (err, user, result: AccountProvisionerResult) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -24,6 +24,14 @@ export default function createMiddleware(providerName: string) {
|
||||
return ctx.redirect(`/?notice=auth-error`);
|
||||
}
|
||||
|
||||
// Passport.js may invoke this callback with err=null and user=null in
|
||||
// the event that error=access_denied is received from the OAuth server.
|
||||
// I'm not sure why this exception to the rule exists, but it does:
|
||||
// https://github.com/jaredhanson/passport-oauth2/blob/e20f26aad60ed54f0e7952928cbb64979ef8da2b/lib/strategy.js#L135
|
||||
if (!user) {
|
||||
return ctx.redirect(`/?notice=auth-error`);
|
||||
}
|
||||
|
||||
// Handle errors from Azure which come in the format: message, Trace ID,
|
||||
// Correlation ID, Timestamp in these two query string parameters.
|
||||
const { error, error_description } = ctx.request.query;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import { type Context } from "koa";
|
||||
import { isArrayLike } from "lodash";
|
||||
import validator from "validator";
|
||||
import { validateColorHex } from "../../shared/utils/color";
|
||||
import { validateIndexCharacters } from "../../shared/utils/indexCharacters";
|
||||
@@ -13,6 +14,12 @@ export default function validation() {
|
||||
}
|
||||
};
|
||||
|
||||
ctx.assertArray = (value, message) => {
|
||||
if (!isArrayLike(value)) {
|
||||
throw new ValidationError(message);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.assertIn = (value, options, message) => {
|
||||
if (!options.includes(value)) {
|
||||
throw new ValidationError(message);
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.changeColumn('users', 'email', {
|
||||
type: Sequelize.STRING,
|
||||
unique: false,
|
||||
allowNull: false,
|
||||
});
|
||||
await queryInterface.changeColumn('users', 'username', {
|
||||
type: Sequelize.STRING,
|
||||
unique: false,
|
||||
allowNull: false,
|
||||
});
|
||||
await queryInterface.removeConstraint('users', 'users_email_key', {})
|
||||
await queryInterface.removeConstraint('users', 'users_username_key', {})
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @flow
|
||||
import { find, findIndex, concat, remove, uniq } from "lodash";
|
||||
import randomstring from "randomstring";
|
||||
import slug from "slug";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
|
||||
import { Op, DataTypes, sequelize } from "../sequelize";
|
||||
import slugify from "../utils/slugify";
|
||||
import CollectionUser from "./CollectionUser";
|
||||
import Document from "./Document";
|
||||
|
||||
slug.defaults.mode = "rfc3986";
|
||||
|
||||
const Collection = sequelize.define(
|
||||
"collection",
|
||||
{
|
||||
@@ -72,7 +72,9 @@ const Collection = sequelize.define(
|
||||
},
|
||||
getterMethods: {
|
||||
url() {
|
||||
return `/collections/${this.id}`;
|
||||
if (!this.name) return `/collection/untitled-${this.urlId}`;
|
||||
|
||||
return `/collection/${slugify(this.name)}-${this.urlId}`;
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -223,6 +225,17 @@ Collection.addHook("afterCreate", (model: Collection, options) => {
|
||||
|
||||
// Class methods
|
||||
|
||||
Collection.findByPk = async function (id, options = {}) {
|
||||
if (isUUID(id)) {
|
||||
return this.findOne({ where: { id }, ...options });
|
||||
} else if (id.match(SLUG_URL_REGEX)) {
|
||||
return this.findOne({
|
||||
where: { urlId: id.match(SLUG_URL_REGEX)[1] },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// get all the membership relationshps a user could have with the collection
|
||||
Collection.membershipUserIds = async (collectionId: string) => {
|
||||
const collection = await Collection.scope("withAllMemberships").findByPk(
|
||||
@@ -382,7 +395,11 @@ Collection.prototype.isChildDocument = function (
|
||||
let result = false;
|
||||
|
||||
const loopChildren = (documents, input) => {
|
||||
return documents.map((document) => {
|
||||
if (result) {
|
||||
return;
|
||||
}
|
||||
|
||||
documents.forEach((document) => {
|
||||
let parents = [...input];
|
||||
if (document.id === documentId) {
|
||||
result = parents.includes(parentDocumentId);
|
||||
@@ -390,7 +407,6 @@ Collection.prototype.isChildDocument = function (
|
||||
parents.push(document.id);
|
||||
loopChildren(document.children, parents);
|
||||
}
|
||||
return document;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import randomstring from "randomstring";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Collection, Document } from "../models";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
buildDocument,
|
||||
} from "../test/factories";
|
||||
import { flushdb, seed } from "../test/support";
|
||||
import slugify from "../utils/slugify";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
beforeEach(jest.resetAllMocks);
|
||||
@@ -16,7 +18,7 @@ beforeEach(jest.resetAllMocks);
|
||||
describe("#url", () => {
|
||||
test("should return correct url for the collection", () => {
|
||||
const collection = new Collection({ id: "1234" });
|
||||
expect(collection.url).toBe("/collections/1234");
|
||||
expect(collection.url).toBe(`/collection/untitled-${collection.urlId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -416,3 +418,53 @@ describe("#membershipUserIds", () => {
|
||||
expect(membershipUserIds.length).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#findByPk", () => {
|
||||
test("should return collection with collection Id", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id);
|
||||
|
||||
expect(response.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
test("should return collection when urlId is present", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = `${slugify(collection.name)}-${collection.urlId}`;
|
||||
|
||||
const response = await Collection.findByPk(id);
|
||||
|
||||
expect(response.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
test("should return undefined when incorrect uuid type", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id + "-incorrect");
|
||||
|
||||
expect(response).toBe(undefined);
|
||||
});
|
||||
|
||||
test("should return undefined when incorrect urlId length", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
|
||||
|
||||
const response = await Collection.findByPk(id);
|
||||
|
||||
expect(response).toBe(undefined);
|
||||
});
|
||||
|
||||
test("should return null when no collection is found with uuid", async () => {
|
||||
const response = await Collection.findByPk(
|
||||
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
|
||||
);
|
||||
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
|
||||
test("should return null when no collection is found with urlId", async () => {
|
||||
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
|
||||
|
||||
const response = await Collection.findByPk(id);
|
||||
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import MarkdownSerializer from "slate-md-serializer";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { MAX_TITLE_LENGTH } from "../../shared/constants";
|
||||
import parseTitle from "../../shared/utils/parseTitle";
|
||||
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
|
||||
import unescape from "../../shared/utils/unescape";
|
||||
import { Collection, User } from "../models";
|
||||
import { DataTypes, sequelize } from "../sequelize";
|
||||
@@ -14,7 +15,6 @@ import slugify from "../utils/slugify";
|
||||
import Revision from "./Revision";
|
||||
|
||||
const Op = Sequelize.Op;
|
||||
const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;
|
||||
const serializer = new MarkdownSerializer();
|
||||
|
||||
export const DOCUMENT_VERSION = 2;
|
||||
@@ -216,10 +216,10 @@ Document.findByPk = async function (id, options = {}) {
|
||||
where: { id },
|
||||
...options,
|
||||
});
|
||||
} else if (id.match(URL_REGEX)) {
|
||||
} else if (id.match(SLUG_URL_REGEX)) {
|
||||
return scope.findOne({
|
||||
where: {
|
||||
urlId: id.match(URL_REGEX)[1],
|
||||
urlId: id.match(SLUG_URL_REGEX)[1],
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -637,7 +637,7 @@ Document.prototype.unarchive = async function (userId: string) {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!parent) this.parentDocumentId = undefined;
|
||||
if (!parent) this.parentDocumentId = null;
|
||||
}
|
||||
|
||||
if (!this.template) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user