Compare commits

..

82 Commits

Author SHA1 Message Date
Tom Moor d4c594423f More email styling 2021-07-08 22:38:42 -04:00
Tom Moor 2bf237d54b Merge branch 'main' into email-diff 2021-07-08 21:20:18 -04:00
Tom Moor 9a1c8f07d1 feat: Add documentId filter to events.list (#2287) 2021-07-08 10:12:06 -07:00
Tom Moor 241cb11493 chore: Automate running yarn-deduplicate, see comment:
https://github.com/outline/outline/pull/2283\#discussion_r665301770
2021-07-07 22:26:56 -04:00
Saumya Pandey 8195791bb2 fix: Make search query string user friendly (#2283)
* Upgrade query-string package and skip empty string

* Run yarn-deduplicate command
2021-07-07 18:45:40 -07:00
Saumya Pandey b037ae5dc1 fix: Improve isChildDocument performance (#2284) 2021-07-07 04:53:40 -07:00
Tom Moor aeba8ce4eb fix: Empty context menu when user does not have permission to update collection 2021-07-06 22:02:31 -04:00
Tom Moor 3565e68725 Merge branch 'main' into email-diff 2021-07-06 20:43:51 -04:00
Tom Moor 429c5fba85 0.57.0 2021-07-06 09:12:54 -04:00
Tom Moor 9495ddba25 fix: Restore previous WSS CORS behavior 2021-07-05 23:01:25 -04:00
dependabot[bot] 486a60e97c chore(deps): bump socket.io from 2.3.0 to 2.4.0 (#1831)
Bumps [socket.io](https://github.com/socketio/socket.io) from 2.3.0 to 2.4.0.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/2.4.0/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/2.3.0...2.4.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-05 17:11:40 -07:00
Tom Moor c687745263 fix: E-mail signin on incorrect subdomain should allow the process to continue instead of error
closes #2276
2021-07-05 19:25:21 -04:00
Translate-O-Tron 1b92993b90 fix: New Portuguese, Brazilian translations from Crowdin (#2271) 2021-07-05 13:37:09 -07:00
Tom Moor 181a20a268 fix: More context menu fixes 2021-07-05 16:35:46 -04:00
Tom Moor f8ffa4e25a tweak sidebar item background 2021-07-04 18:44:09 -04:00
Farzad 7e139ca8f7 fix: nested link positions for RTL titles in sidebar (#2272) 2021-07-04 12:08:05 -07:00
Tom Moor bb58db507d fix: ew-resize -> col-resize cursor 2021-07-04 11:54:29 -04:00
Translate-O-Tron 49bf86d6d9 New Crowdin updates (#2268) 2021-07-04 06:54:52 -07:00
Tom Moor 286a15cf10 fix: Clicking dropdown menu items in FF (#2269)
* fix: Clicking dropdown menu items in FF
closes #2264

* fix: Anchor items, add comment

* fix: CI test memory issues
2021-07-04 06:54:40 -07:00
Tom Moor f65469b777 lockfile 2021-07-03 21:22:52 -04:00
Translate-O-Tron fe65a79d66 New Crowdin updates (#2267) 2021-07-03 07:02:01 -07:00
Tom Moor a1d5ac0907 RTL document support (#2263)
* Basic RTL support in documents

* fix: DocumentListItem and ReferenceListItem for RTL content
2021-07-03 07:00:10 -07:00
Tom Moor 04eabe68a7 feat: Enable traditional Chinese translations (#2266) 2021-07-02 12:08:08 -07:00
Tom Moor 1c0c694c22 fix: Email auth should allow same guest user on multiple subdomains (#2252)
* test: Add email auth tests to establish current state of system

* fix: Update logic to account for dupe emails used between subdomains

* test

* test
2021-07-02 12:07:43 -07:00
Translate-O-Tron 2ae74f2834 New Crowdin updates (#2262) 2021-07-02 11:23:02 -07:00
Tom Moor 0f01fc5faa test: Reduce memory usage by not requiring stores into all (#2265) 2021-07-02 11:16:07 -07:00
Tom Moor 7f1322b7ba fix: Down arrow in search input should move focus to results (#2257)
closes #2253
2021-07-01 15:01:30 -07:00
dependabot[bot] 3c98133e24 chore(deps): bump socket.io-parser from 3.3.1 to 3.3.2 (#2258)
Bumps [socket.io-parser](https://github.com/socketio/socket.io-parser) from 3.3.1 to 3.3.2.
- [Release notes](https://github.com/socketio/socket.io-parser/releases)
- [Changelog](https://github.com/socketio/socket.io-parser/blob/3.3.2/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io-parser/compare/3.3.1...3.3.2)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-07-01 12:08:57 -07:00
Tom Moor 088353d61f fix: Data loading state not reset when props change to PaginatedList (#2254)
* fix: Data loading state not reset when significant props change to PaginatedList

closes #2251

* test: Add enzyme and component test
2021-06-26 21:49:25 -07:00
Translate-O-Tron 31180619e1 New Crowdin updates (#2182) 2021-06-26 13:43:54 -07:00
Saumya Pandey 9fccc280d7 fix: Add ability to permanently delete documents in trash (#2192)
* Align false conditions before true

* Update documents.delete endpoint for permanent delete

* Add permanent delete to events table and integrate with socket.io

* Add permanent delete to document menu

* Update parentDocumentId of direct child to null

* Add translation

* Add test for permanent delete

* Add space

* Update app/scenes/DocumentPermanentDelete.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/stores/DocumentsStore.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update server/commands/documentPermanentDeleter.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update app/scenes/DocumentPermanentDelete.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Change socket room from team to collection

* Add translation

* Create log func for commands

* Move tests from utils to permanentDeleter command

* Add additional tests

* Set redirect to trash

* Return promise from beforeEach

* Add undeleted documents validation

* Include deleteAt attribute in db query

* Update server/commands/documentPermanentDeleter.js

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* tweak language

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-25 16:14:40 -07:00
Tom Moor c69b4efc34 fix: Aligned images do not load in publicly shared documents (#2248) 2021-06-25 10:09:44 -07:00
Tom Moor 61039e9d0d Allow images in email diff 2021-06-25 09:41:34 -07:00
Tom Moor 6d09122d56 test: Deletion 2021-06-24 20:10:42 -07:00
Tom Moor 5fb6097153 Improved diff 2021-06-23 23:58:32 -07:00
Tom Moor ec17874568 Remove test harness 2021-06-22 07:35:38 -07:00
Tom Moor 40c3e9e85f test 2021-06-22 07:27:55 -07:00
Tom Moor 9f739f3788 Merge main 2021-06-22 07:26:45 -07:00
Tom Moor 3cec6b4903 fix: Allow for offline development 2021-06-21 21:40:28 -07:00
Tom Moor ede7f2e3e6 fix: Bump RME (table and image fixes) 2021-06-21 17:39:14 -07:00
Tom Moor f6837b4742 wip 2021-06-20 23:15:04 -07:00
Tom Moor 1560e3c9f7 refactor 2021-06-20 12:49:15 -07:00
Tom Moor ca74908dc5 test 2021-06-20 00:20:37 -07:00
Tom Moor de7ec1119b Integrate into mailer, basic styling 2021-06-19 23:50:36 -07:00
Tom Moor 2093b4297f Merge main 2021-06-19 17:05:19 -07:00
Tom Moor cf8fa5ffa3 fix: Bump RME (checkbox list fixes) 2021-06-18 16:28:27 -07:00
Tom Moor 1a2a0f4264 fix: Long search term causes server error writing query to db (#2237)
closes #2234
2021-06-17 23:23:35 -07:00
Tom Moor 5f3a38bf87 fix: todo list checkbox consistency issue
closes #2179
2021-06-17 22:57:55 -07:00
Tom Moor afff3a6f25 fix: Server error when user cancels OAuth process with Azure (#2231) 2021-06-16 21:45:20 -07:00
Tom Moor b5824879a3 Merge branch 'fix/concat-tags' 2021-06-16 18:36:28 -07:00
Tom Moor 1c82e292e0 fix: Allow embed of private mindmeister embeds
fix: Missing right and bottom border of some embeds
2021-06-16 18:36:21 -07:00
Tom Moor 317289ac2a fix: Error in Datadog tracking, if only we had TS :( 2021-06-16 08:52:54 -07:00
Tom Moor 8331026cb3 fix: No search results from link editor search due to error parsing date (date-fns upgrade) 2021-06-16 07:54:56 -07:00
Tom Moor de285f2b63 feat: Add TLS ciphers option (#2217)
closes #2175
2021-06-15 21:37:41 -07:00
G. Santos d205c48296 docs: Fix SECRET_KEY variable description (#2229)
Updated the description of the SECRET_KEY variable in the .env.sample
file to clarify that the key needs to be 32 bytes long and hex-encoded.
The previous description of "32 character hexadecimal" was confusing
as it left open the possibility of a hex-encoded 16-byte key.
2021-06-15 21:37:19 -07:00
Tom Moor 277c37dae6 fix: Metrics lib to account for multiple server instances 2021-06-15 20:34:46 -07:00
Tom Moor 2c39cd6496 chore: Normalize "new" actions in settings (#2226)
* fix: Unauthorized request to views.list from shared documents

* Bump dep styled-components

* chore: Normalize 'new' actions in settings area to top right
chore: Add translation hooks to API tokens screen
chore: Move API tokens loading to paginated list
2021-06-15 19:10:50 -07:00
Tom Moor d85592b5f3 feat: DataDog metrics (#2228)
* wip

* chore: Change event names, add additional events

* fix: Not counting connect events
2021-06-15 19:10:38 -07:00
Tom Moor cdf0df0faa Bump dep styled-components 2021-06-13 18:26:25 -07:00
Tom Moor 48f54b5aa2 fix: Unauthorized request to views.list from shared documents 2021-06-13 18:24:02 -07:00
Tom Moor 2ca57fc7cf fix: 3 locations with return undefined (not compatible with React 17) 2021-06-13 17:47:17 -07:00
Tom Moor 470920e2c3 feat: Allow templates from any collection to be used
fix: Hover state of context menu items with icons
2021-06-13 17:43:50 -07:00
Tom Moor beee8ebee7 fix: Sidebar flash when moving between collection/document due to mobx-react upgrade 2021-06-13 17:22:35 -07:00
Tom Moor 9f05c9bd43 chore: Upgrade React to v17 (#2045)
* chore: Upgrade React v17

* chore: Upgrade additional deps to reduce warnings

* fix: Restore react-table dep

* Bump react-avatar-editor, mobx-react

* Remove unmaintained @rehooks/window-scroll-position dep

* Bump react-waypoint dep for React 17 support

* fix: Syntax error in autotrack chunk name comment
2021-06-13 15:23:53 -07:00
Tom Moor 65be808556 fix: Cause of sporadic test failures in CI, promise not returned for flushdb 2021-06-13 14:52:24 -07:00
Tom Moor 89f8df619c fix: Remove export permission for read-only users (#2220) 2021-06-13 14:41:29 -07:00
Tom Moor 756ec92cdb fix: Link copied to clipboard takes dark mode styles (#2218)
Upgrade copy-to-clipboard
closes #2207
2021-06-12 15:44:58 -07:00
Dave a8e2e349e9 fix: change metaDisplay key to Alt for "Table of contents" (#2187)
* change `metaDisplay` key to Alt for "Table of contents"

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-12 15:37:35 -07:00
Tom Moor 25f745e7e5 fix: Text alignment in templates menu
closes #2204
2021-06-12 13:31:22 -07:00
Nainterceptor 07b1811993 feat: Use SMTP_SECURE environment variable to force secure parameter of smtp configuration (#2214) 2021-06-12 11:01:48 -07:00
Saumya Pandey d71f0ae6bd fix: Two restore options when an archived document is deleted (#2194)
* Merge two menu items

* Add deletedAt guard condition in document unarchive policy

* Make the parentDocumentId null

* Update test
2021-06-10 22:52:32 -07:00
Tom Moor f58032d305 fix: Flash of sidebar when first loading Document chunk 2021-06-09 18:01:35 -07:00
Saumya Pandey 6beb6febc4 fix: Use friendly urls for collections (#2162)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-09 17:48:48 -07:00
Saumya Pandey a6d4d4ea36 fix: Add Portugese, Brazil to language options (#2164)
* Add Portugese, Brazil to language options

* Upgrade date-fns package

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2021-06-09 17:42:14 -07:00
Tom Moor a99f6bed42 feat: Return publicly shared document title in SSR HTML (#2191)
* feat: Return publicly shared document title in SSR HTML
closes #2146

* tests
2021-06-09 17:41:39 -07:00
Tom Moor 4cd61db1ea fix: Move views loading to avoid duplicate request 2021-06-08 21:13:56 -07:00
Tom Moor 0db7bb7f3e Bump editor
closes #2156
closes #2067
2021-06-07 20:32:41 -07:00
Tom Moor d8ca9c6111 fix: Server error if non-array passed to users.invite 2021-06-07 20:28:28 -07:00
Dave 4a8d357084 style: add option background for InputSelect (#2188) 2021-06-07 18:34:01 -07:00
Yao Wang e0fb76cb63 documentation: Instructions for local development (#2180)
* Fix the instruction for local development

* update readme for Slack OAuth in local development

* Fix the callback URL setting instruction
2021-06-07 18:11:45 -07:00
Saumya Pandey ffed38bf71 fix: Prevent API request for views (#2193) 2021-06-07 18:10:54 -07:00
Nan Yu 3df82c500b wip 2021-02-21 11:52:00 -08:00
159 changed files with 5290 additions and 2164 deletions
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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
+9
View File
@@ -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
+30
View File
@@ -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"
]
}
+3 -1
View File
@@ -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 =
+15 -7
View File
@@ -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")`
+6 -2
View File
@@ -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
+1 -1
View File
@@ -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>
+1
View File
@@ -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();
+3 -2
View File
@@ -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 && (
+11 -4
View File
@@ -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));
+2 -1
View File
@@ -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}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
+8
View File
@@ -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;
+3 -7
View File
@@ -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()
),
});
+4
View File
@@ -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};
+27 -13
View File
@@ -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"
>
+14 -4
View File
@@ -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;
+84
View File
@@ -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>
@@ -141,7 +146,8 @@ const Link = styled(NavLink)`
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.black05};
background: ${(props) =>
transparentize("0.25", props.theme.sidebarItemBackground)};
}
${breakpoint("tablet")`
@@ -172,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)));
+4
View File
@@ -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) => {
+2 -2
View File
@@ -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,
});
-69
View File
@@ -1,69 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import env from "env";
const URL_REGEX = new RegExp("^https://www.dropbox.com/sh?/(.*)$");
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
|},
|};
class Dropbox extends React.Component<Props> {
static ENABLED = [URL_REGEX];
container = React.createRef<HTMLAnchorElement>();
shouldComponentUpdate(nextProps: Props) {
return (
nextProps.isSelected !== this.props.isSelected ||
nextProps.attrs.href !== this.props.attrs.href
);
}
componentDidMount() {
if (document.getElementById("dropboxjs")) {
if (this.container.current) {
window.Dropbox.embed(
{ link: this.props.attrs.href },
this.container.current
);
}
return;
}
const tag = document.createElement("script");
tag.async = false;
tag.id = "dropboxjs";
tag.setAttribute("data-app-key", env.DROPBOX_APP_KEY);
tag.src = "https://www.dropbox.com/static/api/2/dropins.js";
document.body?.appendChild(tag);
}
render() {
return (
<Rounded
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
>
<a
ref={this.container}
href={this.props.attrs.href}
className="dropbox-embed"
data-height="400px"
/>
</Rounded>
);
}
}
const Rounded = styled.div`
border-radius: 3px;
height: 400px;
`;
export default Dropbox;
+4 -1
View File
@@ -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
+9 -9
View File
@@ -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;
`;
+194 -220
View File
@@ -1,5 +1,4 @@
// @flow
import { filter } from "lodash";
import * as React from "react";
import styled from "styled-components";
import Image from "components/Image";
@@ -9,7 +8,6 @@ import Cawemo from "./Cawemo";
import ClickUp from "./ClickUp";
import Codepen from "./Codepen";
import Descript from "./Descript";
import Dropbox from "./Dropbox";
import Figma from "./Figma";
import Framer from "./Framer";
import Gist from "./Gist";
@@ -31,8 +29,6 @@ import Trello from "./Trello";
import Typeform from "./Typeform";
import Vimeo from "./Vimeo";
import YouTube from "./YouTube";
import env from "env";
import { isCustomDomain } from "utils/domains";
function matcher(Component) {
return (url: string) => {
@@ -52,219 +48,197 @@ const Img = styled(Image)`
height: 18px;
`;
type EmbedSpec = {
title: string,
keywords?: string,
defaultHidden?: boolean,
icon: any,
component: React.ComponentType<any>,
matcher: any,
};
export default filter<void | EmbedSpec>(
[
{
title: "Abstract",
keywords: "design",
icon: () => <Img src="/images/abstract.png" />,
component: Abstract,
matcher: matcher(Abstract),
},
{
title: "Airtable",
keywords: "spreadsheet",
icon: () => <Img src="/images/airtable.png" />,
component: Airtable,
matcher: matcher(Airtable),
},
{
title: "Cawemo",
keywords: "bpmn process",
defaultHidden: true,
icon: () => <Img src="/images/cawemo.png" />,
component: Cawemo,
matcher: matcher(Cawemo),
},
{
title: "ClickUp",
keywords: "project",
defaultHidden: true,
icon: () => <Img src="/images/clickup.png" />,
component: ClickUp,
matcher: matcher(ClickUp),
},
{
title: "Codepen",
keywords: "code editor",
icon: () => <Img src="/images/codepen.png" />,
component: Codepen,
matcher: matcher(Codepen),
},
{
title: "Descript",
keywords: "audio",
defaultHidden: true,
icon: () => <Img src="/images/descript.png" />,
component: Descript,
matcher: matcher(Descript),
},
env.DROPBOX_APP_KEY && !isCustomDomain()
? {
title: "Dropbox",
keywords: "dropbox file pdf",
icon: () => <Img src="/images/dropbox.png" />,
component: Dropbox,
matcher: matcher(Dropbox),
}
: undefined,
{
title: "Figma",
keywords: "design svg vector",
icon: () => <Img src="/images/figma.png" />,
component: Figma,
matcher: matcher(Figma),
},
{
title: "Framer",
keywords: "design prototyping",
icon: () => <Img src="/images/framer.png" />,
component: Framer,
matcher: matcher(Framer),
},
{
title: "GitHub Gist",
keywords: "code",
icon: () => <Img src="/images/github-gist.png" />,
component: Gist,
matcher: matcher(Gist),
},
{
title: "Google Drawings",
keywords: "drawings",
icon: () => <Img src="/images/google-drawings.png" />,
component: GoogleDrawings,
matcher: matcher(GoogleDrawings),
},
{
title: "Google Drive",
keywords: "drive",
icon: () => <Img src="/images/google-drive.png" />,
component: GoogleDrive,
matcher: matcher(GoogleDrive),
},
{
title: "Google Docs",
icon: () => <Img src="/images/google-docs.png" />,
component: GoogleDocs,
matcher: matcher(GoogleDocs),
},
{
title: "Google Sheets",
keywords: "excel spreadsheet",
icon: () => <Img src="/images/google-sheets.png" />,
component: GoogleSheets,
matcher: matcher(GoogleSheets),
},
{
title: "Google Slides",
keywords: "presentation slideshow",
icon: () => <Img src="/images/google-slides.png" />,
component: GoogleSlides,
matcher: matcher(GoogleSlides),
},
{
title: "InVision",
keywords: "design prototype",
defaultHidden: true,
icon: () => <Img src="/images/invision.png" />,
component: InVision,
matcher: matcher(InVision),
},
{
title: "Loom",
keywords: "video screencast",
icon: () => <Img src="/images/loom.png" />,
component: Loom,
matcher: matcher(Loom),
},
{
title: "Lucidchart",
keywords: "chart",
icon: () => <Img src="/images/lucidchart.png" />,
component: Lucidchart,
matcher: matcher(Lucidchart),
},
{
title: "Marvel",
keywords: "design prototype",
icon: () => <Img src="/images/marvel.png" />,
component: Marvel,
matcher: matcher(Marvel),
},
{
title: "Mindmeister",
keywords: "mindmap",
icon: () => <Img src="/images/mindmeister.png" />,
component: Mindmeister,
matcher: matcher(Mindmeister),
},
{
title: "Miro",
keywords: "whiteboard",
icon: () => <Img src="/images/miro.png" />,
component: Miro,
matcher: matcher(Miro),
},
{
title: "Mode",
keywords: "analytics",
defaultHidden: true,
icon: () => <Img src="/images/mode-analytics.png" />,
component: ModeAnalytics,
matcher: matcher(ModeAnalytics),
},
{
title: "Prezi",
keywords: "presentation",
icon: () => <Img src="/images/prezi.png" />,
component: Prezi,
matcher: matcher(Prezi),
},
{
title: "Spotify",
keywords: "music",
icon: () => <Img src="/images/spotify.png" />,
component: Spotify,
matcher: matcher(Spotify),
},
{
title: "Trello",
keywords: "kanban",
icon: () => <Img src="/images/trello.png" />,
component: Trello,
matcher: matcher(Trello),
},
{
title: "Typeform",
keywords: "form survey",
icon: () => <Img src="/images/typeform.png" />,
component: Typeform,
matcher: matcher(Typeform),
},
{
title: "Vimeo",
keywords: "video",
icon: () => <Img src="/images/vimeo.png" />,
component: Vimeo,
matcher: matcher(Vimeo),
},
{
title: "YouTube",
keywords: "google video",
icon: () => <Img src="/images/youtube.png" />,
component: YouTube,
matcher: matcher(YouTube),
},
],
(i: void | EmbedSpec) => !!i
);
export default [
{
title: "Abstract",
keywords: "design",
icon: () => <Img src="/images/abstract.png" />,
component: Abstract,
matcher: matcher(Abstract),
},
{
title: "Airtable",
keywords: "spreadsheet",
icon: () => <Img src="/images/airtable.png" />,
component: Airtable,
matcher: matcher(Airtable),
},
{
title: "Cawemo",
keywords: "bpmn process",
defaultHidden: true,
icon: () => <Img src="/images/cawemo.png" />,
component: Cawemo,
matcher: matcher(Cawemo),
},
{
title: "ClickUp",
keywords: "project",
defaultHidden: true,
icon: () => <Img src="/images/clickup.png" />,
component: ClickUp,
matcher: matcher(ClickUp),
},
{
title: "Codepen",
keywords: "code editor",
icon: () => <Img src="/images/codepen.png" />,
component: Codepen,
matcher: matcher(Codepen),
},
{
title: "Descript",
keywords: "audio",
icon: () => <Img src="/images/descript.png" />,
component: Descript,
matcher: matcher(Descript),
},
{
title: "Figma",
keywords: "design svg vector",
icon: () => <Img src="/images/figma.png" />,
component: Figma,
matcher: matcher(Figma),
},
{
title: "Framer",
keywords: "design prototyping",
icon: () => <Img src="/images/framer.png" />,
component: Framer,
matcher: matcher(Framer),
},
{
title: "GitHub Gist",
keywords: "code",
icon: () => <Img src="/images/github-gist.png" />,
component: Gist,
matcher: matcher(Gist),
},
{
title: "Google Drawings",
keywords: "drawings",
icon: () => <Img src="/images/google-drawings.png" />,
component: GoogleDrawings,
matcher: matcher(GoogleDrawings),
},
{
title: "Google Drive",
keywords: "drive",
icon: () => <Img src="/images/google-drive.png" />,
component: GoogleDrive,
matcher: matcher(GoogleDrive),
},
{
title: "Google Docs",
icon: () => <Img src="/images/google-docs.png" />,
component: GoogleDocs,
matcher: matcher(GoogleDocs),
},
{
title: "Google Sheets",
keywords: "excel spreadsheet",
icon: () => <Img src="/images/google-sheets.png" />,
component: GoogleSheets,
matcher: matcher(GoogleSheets),
},
{
title: "Google Slides",
keywords: "presentation slideshow",
icon: () => <Img src="/images/google-slides.png" />,
component: GoogleSlides,
matcher: matcher(GoogleSlides),
},
{
title: "InVision",
keywords: "design prototype",
defaultHidden: true,
icon: () => <Img src="/images/invision.png" />,
component: InVision,
matcher: matcher(InVision),
},
{
title: "Loom",
keywords: "video screencast",
icon: () => <Img src="/images/loom.png" />,
component: Loom,
matcher: matcher(Loom),
},
{
title: "Lucidchart",
keywords: "chart",
icon: () => <Img src="/images/lucidchart.png" />,
component: Lucidchart,
matcher: matcher(Lucidchart),
},
{
title: "Marvel",
keywords: "design prototype",
icon: () => <Img src="/images/marvel.png" />,
component: Marvel,
matcher: matcher(Marvel),
},
{
title: "Mindmeister",
keywords: "mindmap",
icon: () => <Img src="/images/mindmeister.png" />,
component: Mindmeister,
matcher: matcher(Mindmeister),
},
{
title: "Miro",
keywords: "whiteboard",
icon: () => <Img src="/images/miro.png" />,
component: Miro,
matcher: matcher(Miro),
},
{
title: "Mode",
keywords: "analytics",
defaultHidden: true,
icon: () => <Img src="/images/mode-analytics.png" />,
component: ModeAnalytics,
matcher: matcher(ModeAnalytics),
},
{
title: "Prezi",
keywords: "presentation",
icon: () => <Img src="/images/prezi.png" />,
component: Prezi,
matcher: matcher(Prezi),
},
{
title: "Spotify",
keywords: "music",
icon: () => <Img src="/images/spotify.png" />,
component: Spotify,
matcher: matcher(Spotify),
},
{
title: "Trello",
keywords: "kanban",
icon: () => <Img src="/images/trello.png" />,
component: Trello,
matcher: matcher(Trello),
},
{
title: "Typeform",
keywords: "form survey",
icon: () => <Img src="/images/typeform.png" />,
component: Typeform,
matcher: matcher(Typeform),
},
{
title: "Vimeo",
keywords: "video",
icon: () => <Img src="/images/vimeo.png" />,
component: Vimeo,
matcher: matcher(Vimeo),
},
{
title: "YouTube",
keywords: "google video",
icon: () => <Img src="/images/youtube.png" />,
component: YouTube,
matcher: matcher(YouTube),
},
];
+1 -1
View File
@@ -8,5 +8,5 @@ export default function useUserLocale() {
return undefined;
}
return auth.user.language.split("_")[0];
return auth.user.language;
}
+52
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
)}
</>
)}
</>
+1 -1
View File
@@ -25,7 +25,7 @@ function NewDocumentMenu() {
const can = policies.abilities(team.id);
if (!can.createDocument) {
return;
return null;
}
if (singleCollection) {
+1 -1
View File
@@ -23,7 +23,7 @@ function NewTemplateMenu() {
const can = policies.abilities(team.id);
if (!can.createDocument) {
return;
return null;
}
return (
+32 -16
View File
@@ -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);
+1
View File
@@ -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
View File
@@ -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";
+42 -37
View File
@@ -3,7 +3,6 @@ import * as React from "react";
import { Switch, Redirect, type Match } from "react-router-dom";
import Archive from "scenes/Archive";
import Collection from "scenes/Collection";
import KeyedDocument from "scenes/Document/KeyedDocument";
import DocumentNew from "scenes/DocumentNew";
import Drafts from "scenes/Drafts";
import Error404 from "scenes/Error404";
@@ -23,7 +22,11 @@ import { matchDocumentSlug as slug } from "utils/routeHelpers";
const SettingsRoutes = React.lazy(() =>
import(/* webpackChunkName: "settings" */ "./settings")
);
const KeyedDocument = React.lazy(() =>
import(
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
);
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
<Redirect
@@ -37,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>
);
+65
View File
@@ -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
View File
@@ -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("AZ")}
</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 -1
View File
@@ -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;
+8 -8
View File
@@ -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);
}
+2 -11
View File
@@ -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() {
+17 -5
View File
@@ -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}
+1 -1
View File
@@ -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,
}
),
})}
</>
)}
+4 -3
View File
@@ -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
View File
@@ -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("Couldnt 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("Couldnt 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);
+60
View File
@@ -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("Im sure  Delete")}
</Button>
</form>
</Flex>
);
}
export default observer(DocumentPermanentDelete);
+7 -4
View File
@@ -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 }
),
});
};
+1 -1
View File
@@ -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"),
+10 -7
View File
@@ -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 }
),
});
};
+20 -13
View File
@@ -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}
+70 -73
View File
@@ -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
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -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;
+29 -1
View File
@@ -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);
+2 -2
View File
@@ -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.
-6
View File
@@ -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
+9
View File
@@ -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;
+2
View File
@@ -0,0 +1,2 @@
// @flow
export const runAllPromises = () => new Promise<void>(setImmediate);
+19 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
],
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"./server/test/helper.js"
"./server/test/setup.js"
],
"testEnvironment": "node"
}
+2 -2
View File
@@ -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}`;
+1 -1
View File
@@ -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({
+2 -2
View File
@@ -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
View File
@@ -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,
+36 -1
View File
@@ -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();
+7 -4
View File
@@ -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) {
+49 -1
View File
@@ -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
View File
@@ -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);
+11
View File
@@ -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
View File
@@ -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,
+3 -91
View File
@@ -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 = `![text](${attachment.redirectUrl})`;
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 = `![text](${attachment.redirectUrl})`;
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 = `![text](${attachment.redirectUrl})`;
await document1.save();
document.text = `![text](${attachment.redirectUrl})`;
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,
-1
View File
@@ -31,7 +31,6 @@ const scriptSrc = [
"'unsafe-inline'",
"'unsafe-eval'",
"gist.github.com",
"www.dropbox.com/static/api/2/dropins.js",
];
if (env.GOOGLE_ANALYTICS_ID) {
+52
View File
@@ -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 -1
View File
@@ -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";
+47 -7
View File
@@ -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;
+177
View File
@@ -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();
});
});
});
+2 -2
View File
@@ -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 = `![text](${attachment.redirectUrl})`;
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 = `![text](${attachment.redirectUrl})`;
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 = `![text](${attachment.redirectUrl})`;
await document1.save();
document.text = `![text](${attachment.redirectUrl})`;
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);
});
});
+4 -2
View File
@@ -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>
+225 -6
View File
@@ -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;
}
}
`;
+1 -1
View File
@@ -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>
+3 -1
View File
@@ -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>
+25
View File
@@ -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>
);
};
+2 -2
View File
@@ -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">
-1
View File
@@ -11,7 +11,6 @@ export default {
TEAM_LOGO: process.env.TEAM_LOGO,
SLACK_KEY: process.env.SLACK_KEY,
SLACK_APP_ID: process.env.SLACK_APP_ID,
DROPBOX_APP_KEY: process.env.DROPBOX_APP_KEY,
MAXIMUM_IMPORT_SIZE: process.env.MAXIMUM_IMPORT_SIZE || 1024 * 1000 * 5,
SUBDOMAINS_ENABLED: process.env.SUBDOMAINS_ENABLED === "true",
GOOGLE_ANALYTICS_ID: process.env.GOOGLE_ANALYTICS_ID,
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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;

Some files were not shown because too many files have changed in this diff Show More