Compare commits

...

52 Commits

Author SHA1 Message Date
Tom Moor f2abf38fe4 perf: Remove source once compiled 2020-09-05 12:57:27 -07:00
Tom Moor f0712e22d8 perf: Improving dockerfile 2020-09-05 12:44:40 -07:00
Tom Moor e7e289d9fa end 2020-09-04 23:28:29 -07:00
Tom Moor 713187cfb4 fix 2020-09-04 15:39:36 -07:00
Tom Moor 8f41895e66 Merge develop 2020-08-31 19:40:41 -07:00
Tom Moor de8ac4acf5 fix: Configure mobx-react-lite observer batching
Removes development warning
2020-08-31 18:42:12 -07:00
Tom Moor de59147418 chore: Upgrade Sentry to 5.22.3
closes #1498
2020-08-31 18:36:30 -07:00
Tom Moor cf522cc85f fix: Regression with TOC not showing when navigating directly to document (#1500)
fix: Editing document too wide when TOC visible in read only
2020-08-31 18:31:13 -07:00
Tom Moor 8c7200fa87 chore: yarn deduplicate 2020-08-30 19:44:30 -07:00
Tom Moor f2310be173 Updated Yarn lockfile 2020-08-29 12:11:12 -07:00
Tom Moor 29f4dc9331 Bump RME
Fixes #1107 - It's now possible to use line breaks in table cells with Shift+Enter
Fixes #1253 - Selected content can now be dragged to reorder
2020-08-29 12:00:55 -07:00
Tom Moor 03b6dd62a8 fix: Missing click action to change permissions on a collection
fix: Modals no longer stacking correctly since upgrading react-portal
2020-08-25 21:00:50 -07:00
Tom Moor 7f0c608dbb Merge branch 'guilherme-diniz-feature/document-history-header' into develop 2020-08-25 20:04:02 -07:00
Tom Moor c52fbb944e Styling tidy up 2020-08-25 20:03:52 -07:00
Tom Moor e22e952606 Merge branch 'feature/document-history-header' of git://github.com/guilherme-diniz/outline into guilherme-diniz-feature/document-history-header 2020-08-25 19:44:56 -07:00
Guilherme Diniz 197cdff6c3 fix layout issues 2020-08-25 17:22:13 -03:00
Tom Moor 85d09b2351 fix: Deleting a document should correctly show who deleted (#1488) 2020-08-25 08:51:12 -07:00
Tom Moor 69611638b9 fix: Redirect to parent document when deleting a child document if possible (#1489) 2020-08-25 08:45:04 -07:00
Tom Moor e117d5f103 fix: Unable to view all possible locations when moving document (#1490)
* fix: Remove limit of displayed results on Move dialog

* fix: Filter templates from results

* Show final document location on hover/active, reduces visual noise
2020-08-25 08:44:46 -07:00
Tom Moor 03db975217 Merge branch 'feature/document-history-header' of git://github.com/guilherme-diniz/outline into guilherme-diniz-feature/document-history-header 2020-08-24 23:46:16 -07:00
Tom Moor 76279902f9 chore: Introduce AWS_S3_FORCE_PATH_STYLE option to maintain compatability with Minio et al (#1443)
- Make AWS_S3_UPLOAD_BUCKET_NAME optional
2020-08-24 23:27:10 -07:00
Tom Moor a304e91ffc Merge branch 'develop' into perf/issue-1464 2020-08-24 20:58:56 -07:00
Tom Moor 9b5573c5e2 0.46.1 2020-08-24 20:22:08 -07:00
Tom Moor ec61efa12b Remove unused scripts 2020-08-23 21:10:32 -07:00
Tom Moor b01778a39f fix: Public assets path 2020-08-23 20:44:44 -07:00
Tom Moor 5aa092853b fix: Production file paths 2020-08-23 20:35:59 -07:00
Tom Moor 1fa3db4bdc fix: package.json must be copied, not linked for production build 2020-08-23 20:29:17 -07:00
Tom Moor 6a9f74e6cc fix: Update procfile location 2020-08-23 19:21:43 -07:00
Tom Moor e8719340d1 refactor: Remove babel/register for instant production server startup 2020-08-23 19:10:16 -07:00
Tom Moor 70838918c3 fix: Collections not collapsing 2020-08-23 12:51:35 -07:00
Tom Moor ec38f5d79c refactor: Remove old react lifecycle methods (#1480)
* refactor: Remove deprecated APIs

* bump mobx-react for hooks support

* inject -> useStores
https://mobx-react.js.org/recipes-migration\#hooks-to-the-rescue

* chore: React rules of hooks lint
2020-08-23 11:51:56 -07:00
Jonathan Killian 179176c312 fix: Update package.json build script to use yarn instead of npm. (#1476)
* fix: Update package.json build script to yarn.

Update package.json build script to use yarn instead of npm to maintain consistency with the rest of scripts. I was running into an issue with the Dockerfile when using nvm with yarn and this fixed the issue.
2020-08-22 19:56:52 -07:00
Tom Moor c446a91f7d fix: Restore Postgres SSL support on Heroku
https://github.com/brianc/node-postgres/issues/2009
2020-08-22 08:27:42 -07:00
Guilherme Diniz 05f48f054b feat: Add Header to Document History Sidebar 2020-08-21 20:58:57 -03:00
Tom Moor ec55299c8b fix: Improve websocket reliability (#1470)
* check connection on page visibility change

* fix: SocketPresence account for socket changing
2020-08-20 20:37:54 -07:00
Tom Moor 26c574ab58 chore: Upgrade pg and sequelize to support node 14+ (#1462)
* Upgrade pg and sequelize to support node 14+

When Node 14 came out the app was incompatible, we should always have a maximum version defined here until the server code has been tested to prove compatibility

Co-authored-by: Lance Whatley <whatl3y@gmail.com>
2020-08-20 20:19:44 -07:00
Tom Moor 6dd6768f07 feat: Allow moving templates between collections (#1454)
- Allow template move in document policy
- fix: Ensure that document is not added to collection structure in documentMover command
- fix: Moving a template should now show nested documents as options
- fix: Hitting 'm' should not allow moving a draft
- fix: Styling of seperators on move screen
2020-08-20 19:46:29 -07:00
Tom Moor 0555fd2caa pref: JS bundling improvements (#1461)
* perf: Split only initial vendors
2020-08-17 22:09:12 -07:00
Tom Moor d885252fb0 fix: Mobile style fixes and improvements (#1459)
* fixes #1457 – check for matchMedia function before using it

* fixes: Depth issues
closes #1458

* fixes: Long breadcrumbs cause horizontal overflow

* fix: Improve tabs and overflow on mobile
2020-08-17 00:08:22 -07:00
Tom Moor df9b0bcf91 fix: Websocket reconnect when navigating from settings -> home 2020-08-14 17:47:12 -07:00
Tom Moor 31910f1628 Remove auto reconnect, increase reconnectionDelayMax 2020-08-14 17:25:55 -07:00
Tom Moor 14cb3a36c1 perf: Reduce initial bundle size / async bundle loading (#1456)
* feat: Move to React.lazy

* perf: Remove duplicate babel/runtime

* fix: Run yarn-deduplicate

* Further attempts to remove rich-markdown-editor from initial chunk

* perf: Lazy loading of authenticated routes

* perf: Move color picker to async loading
fix: Display placeholder when loading rich editor

* fix: Cache bust on auto reload
2020-08-14 17:23:58 -07:00
Tom Moor d3350c20b6 perf: Attempt websocket connection before polling 2020-08-14 13:37:11 -07:00
Tom Moor 174acfac32 fix: Unnecessary shares.info request when loading public share (#1453)
closes #1450
2020-08-13 16:48:03 -07:00
Tom Moor 9ef4e2b437 Update LICENSE 2020-08-12 20:04:48 -07:00
Tom Moor 8088da8cf3 0.46.0 2020-08-12 20:03:57 -07:00
Tom Moor 221ee48429 fix: Don't mangle class names in production 2020-08-12 19:28:15 -07:00
Tom Moor ffe8c046ef fix: Bump RME – Improves floating toolbar behavior 2020-08-12 17:01:27 -07:00
Tom Moor dbe8a10702 fix: Login to X should be centered when team name wraps to newline 2020-08-12 14:05:32 -07:00
Tom Moor 11f7e3a060 chore: Bundle Stats / Webpack v4 (#1448)
* chore: Experiment with bundle size monitoring service

* chore: Ensure build runs on CI, move lint and flow before test

* chore: Upgrade Webpack v3 -> v4

* chore: Add webpack-cli
Remove unused dep
Move deps to dev

* Move babel deps to production

* Move babel deps to production
2020-08-12 13:16:10 -07:00
Tom Moor 0f41a04e49 refactor: Remove centralized Modal management (#1444)
* refactor: Finally remove centralized Modals component

* chore: Cleanup related unused methods in UiStore
2020-08-12 10:49:15 -07:00
Tom Moor d055021ad4 chore: Remove all usage of collection.type (#1445)
* chore: Remove all usage of collection.type

* migration: Remove type column
2020-08-12 10:49:02 -07:00
104 changed files with 2749 additions and 2319 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
"version": "2",
"proposals": true
},
"useBuiltIns": "usage",
"useBuiltIns": "usage"
}
]
],
+8 -5
View File
@@ -3,7 +3,7 @@ jobs:
build:
working_directory: ~/outline
docker:
- image: circleci/node:12
- image: circleci/node:14
- image: circleci/redis:latest
- image: circleci/postgres:9.6.5-alpine-ram
environment:
@@ -29,12 +29,15 @@ jobs:
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: yarn test
- run:
name: lint
command: yarn lint
- run:
name: flow
command: yarn flow check --max-workers 4
command: yarn flow check --max-workers 4
- run:
name: test
command: yarn test
- run:
name: build
command: yarn build
+19
View File
@@ -0,0 +1,19 @@
__mocks__
.git
.vscode
.github
.circleci
.DS_Store
.env*
.eslint*
.flowconfig
.log
Makefile
Procfile
app.json
build
docker-compose.yml
fakes3
flow-typed
node_modules
setupJest.js
+1
View File
@@ -45,6 +45,7 @@ AWS_REGION=xx-xxxx-x
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
# uploaded s3 objects permission level, default is private
# set to "public-read" to allow public access
AWS_S3_ACL=private
+5 -1
View File
@@ -4,7 +4,8 @@
"react-app",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:flowtype/recommended"
"plugin:flowtype/recommended",
"plugin:react-hooks/recommended"
],
"plugins": [
"prettier",
@@ -97,5 +98,8 @@
},
"env": {
"jest": true
},
"globals": {
"EDITOR_VERSION": true
}
}
+1
View File
@@ -1,4 +1,5 @@
dist
build
node_modules/*
server/scripts
.env
+14 -7
View File
@@ -1,17 +1,24 @@
FROM node:12-alpine
FROM node:14-alpine
ENV PATH /opt/outline/node_modules/.bin:/opt/node_modules/.bin:$PATH
ENV NODE_PATH /opt/outline/node_modules:/opt/node_modules
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
WORKDIR $APP_PATH
COPY . $APP_PATH
RUN yarn install --pure-lockfile
RUN yarn build
RUN cp -r /opt/outline/node_modules /opt/node_modules
COPY package.json ./
COPY yarn.lock ./
RUN yarn --pure-lockfile
COPY . .
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf server && \
rm -rf shared && \
rm -rf app
ENV NODE_ENV production
CMD yarn start
EXPOSE 3000
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.44.0
Licensed Work: Outline 0.46.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2023-07-03
Change Date: 2023-08-12
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -1 +1 @@
web: node index.js
web: node ./build/server/index.js
+5
View File
@@ -92,6 +92,11 @@
"value": "26214400",
"required": false
},
"AWS_S3_FORCE_PATH_STYLE": {
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
"value": "true",
"required": false
},
"AWS_REGION": {
"value": "us-east-1",
"description": "Region in which the above S3 bucket exists",
+1
View File
@@ -9,6 +9,7 @@ type Props = {
const Container = styled.div`
width: 100%;
max-width: 100vw;
padding: 60px 20px;
${breakpoint("tablet")`
+2 -2
View File
@@ -6,7 +6,7 @@ type Props = {
children: React.Node,
};
export default function DelayedMount({ delay = 150, children }: Props) {
export default function DelayedMount({ delay = 250, children }: Props) {
const [isShowing, setShowing] = React.useState(false);
React.useEffect(() => {
@@ -14,7 +14,7 @@ export default function DelayedMount({ delay = 150, children }: Props) {
return () => {
clearTimeout(timeout);
};
}, []);
}, [delay]);
if (!isShowing) {
return null;
@@ -1,20 +1,23 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { observable, action } from "mobx";
import { observer, inject } from "mobx-react";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { type RouterHistory, type Match } from "react-router-dom";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import { ListPlaceholder } from "components/LoadingPlaceholder";
import Revision from "./components/Revision";
import { documentHistoryUrl } from "utils/routeHelpers";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
type Props = {
match: Match,
@@ -29,6 +32,7 @@ class DocumentHistory extends React.Component<Props> {
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
@@ -86,15 +90,34 @@ class DocumentHistory extends React.Component<Props> {
return this.props.revisions.getDocumentRevisions(document.id);
}
onCloseHistory = () => {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
this.redirectTo = documentUrl(document);
};
render() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
const showLoading = (!this.isLoaded && this.isFetching) || !document;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Sidebar>
<Wrapper column>
<Header>
<Title>History</Title>
<Button
icon={<CloseIcon />}
onClick={this.onCloseHistory}
borderOnHover
neutral
/>
</Header>
{showLoading ? (
<Loading>
<ListPlaceholder count={5} />
@@ -140,10 +163,36 @@ const Wrapper = styled(Flex)`
`;
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth};
border-left: 1px solid ${(props) => props.theme.divider};
z-index: 1;
${breakpoint("tablet")`
display: flex;
`};
`;
const Title = styled(Flex)`
font-size: 16px;
font-weight: 600;
text-align: center;
align-items: center;
justify-content: flex-start;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
width: 0;
flex-grow: 1;
`;
const Header = styled(Flex)`
align-items: center;
position: relative;
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.divider};
color: ${(props) => props.theme.text};
`;
export default inject("documents", "revisions")(DocumentHistory);
@@ -169,6 +169,7 @@ const DocumentLink = styled(Link)`
border-radius: 8px;
max-height: 50vh;
min-width: 100%;
max-width: calc(100vw - 40px);
overflow: hidden;
position: relative;
+2 -2
View File
@@ -232,7 +232,7 @@ const Label = styled(Flex).attrs({
justify: "center",
align: "center",
})`
z-index: 1000;
z-index: ${(props) => props.theme.depths.menu};
cursor: pointer;
`;
@@ -244,7 +244,7 @@ const Position = styled.div`
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
max-height: 75%;
z-index: 1000;
z-index: ${(props) => props.theme.depths.menu};
transform: ${(props) =>
props.position === "center" ? "translateX(-50%)" : "initial"};
pointer-events: none;
+15 -11
View File
@@ -2,14 +2,16 @@
import { lighten } from "polished";
import * as React from "react";
import { withRouter, type RouterHistory } from "react-router-dom";
import RichMarkdownEditor from "rich-markdown-editor";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import isInternalUrl from "utils/isInternalUrl";
import { uploadFile } from "utils/uploadFile";
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
const EMPTY_ARRAY = [];
type Props = {
@@ -22,7 +24,7 @@ type Props = {
};
type PropsWithRef = Props & {
forwardedRef: React.Ref<RichMarkdownEditor>,
forwardedRef: React.Ref<any>,
history: RouterHistory,
};
@@ -67,15 +69,17 @@ class Editor extends React.Component<PropsWithRef> {
render() {
return (
<StyledEditor
ref={this.props.forwardedRef}
uploadImage={this.onUploadImage}
onClickLink={this.onClickLink}
onShowToast={this.onShowToast}
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
tooltip={EditorTooltip}
{...this.props}
/>
<ErrorBoundary reloadOnChunkMissing>
<StyledEditor
ref={this.props.forwardedRef}
uploadImage={this.onUploadImage}
onClickLink={this.onClickLink}
onShowToast={this.onShowToast}
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
tooltip={EditorTooltip}
{...this.props}
/>
</ErrorBoundary>
);
}
}
+14 -1
View File
@@ -12,6 +12,7 @@ import env from "env";
type Props = {
children: React.Node,
reloadOnChunkMissing?: boolean,
};
@observer
@@ -23,13 +24,25 @@ class ErrorBoundary extends React.Component<Props> {
this.error = error;
console.error(error);
if (
this.props.reloadOnChunkMissing &&
error.message &&
error.message.match(/chunk/)
) {
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
window.location.reload(true);
return;
}
if (window.Sentry) {
window.Sentry.captureException(error);
}
}
handleReload = () => {
window.location.reload();
window.location.reload(true);
};
handleShowDetails = () => {
+24
View File
@@ -0,0 +1,24 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Empty from "components/Empty";
import Fade from "components/Fade";
import Flex from "components/Flex";
export default function FullscreenLoading() {
return (
<Fade timing={500}>
<Centered>
<Empty>Loading</Empty>
</Centered>
</Fade>
);
}
const Centered = styled(Flex)`
text-align: center;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
`;
+1
View File
@@ -4,6 +4,7 @@ import styled from "styled-components";
const Heading = styled.h1`
display: flex;
align-items: center;
${(props) => (props.centered ? "text-align: center;" : "")}
svg {
margin-left: -6px;
+11 -7
View File
@@ -5,7 +5,7 @@ import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { fadeAndSlideIn } from "shared/styles/animations";
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentIds";
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import isInternalUrl from "utils/isInternalUrl";
@@ -20,12 +20,7 @@ type Props = {
onClose: () => void,
};
function HoverPreview({ node, documents, onClose, event }: Props) {
// previews only work for internal doc links for now
if (!isInternalUrl(node.href)) {
return null;
}
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
const slug = parseDocumentSlugFromUrl(node.href);
const [isVisible, setVisible] = React.useState(false);
@@ -131,6 +126,15 @@ function HoverPreview({ node, documents, onClose, event }: Props) {
);
}
function HoverPreview({ node, ...rest }: Props) {
// previews only work for internal doc links for now
if (!isInternalUrl(node.href)) {
return null;
}
return <HoverPreviewInternal {...rest} node={node} />;
}
const Animate = styled.div`
animation: ${fadeAndSlideIn} 150ms ease;
+9 -7
View File
@@ -3,7 +3,7 @@ import { inject, observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentIds";
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
import Editor from "components/Editor";
@@ -29,12 +29,14 @@ function HoverPreviewDocument({ url, documents, children }: Props) {
<Heading>{document.titleWithDefault}</Heading>
<DocumentMetaWithViews isDraft={document.isDraft} document={document} />
<Editor
key={document.id}
defaultValue={document.getSummary()}
disableEmbeds
readOnly
/>
<React.Suspense fallback={<div />}>
<Editor
key={document.id}
defaultValue={document.getSummary()}
disableEmbeds
readOnly
/>
</React.Suspense>
</Content>
);
}
+19 -9
View File
@@ -22,13 +22,17 @@ import {
VehicleIcon,
} from "outline-icons";
import * as React from "react";
import { TwitterPicker } from "react-color";
import styled from "styled-components";
import { DropdownMenu } from "components/DropdownMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import { LabelText } from "components/Input";
import NudeButton from "components/NudeButton";
const TwitterPicker = React.lazy(() =>
import("react-color/lib/components/twitter/Twitter")
);
export const icons = {
collection: {
component: CollectionIcon,
@@ -193,14 +197,16 @@ class IconPicker extends React.Component<Props> {
})}
</Icons>
<Flex onClick={preventEventBubble}>
<ColorPicker
color={this.props.color}
onChange={(color) =>
this.props.onChange(color.hex, this.props.icon)
}
colors={colors}
triangle="hide"
/>
<React.Suspense fallback={<Loading>Loading</Loading>}>
<ColorPicker
color={this.props.color}
onChange={(color) =>
this.props.onChange(color.hex, this.props.icon)
}
colors={colors}
triangle="hide"
/>
</React.Suspense>
</Flex>
</DropdownMenu>
</Wrapper>
@@ -226,6 +232,10 @@ const IconButton = styled(NudeButton)`
height: 30px;
`;
const Loading = styled(HelpText)`
padding: 16px;
`;
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
+4 -26
View File
@@ -4,6 +4,8 @@ import { observer, inject } from "mobx-react";
import * as React from "react";
import styled, { withTheme } from "styled-components";
import UiStore from "stores/UiStore";
import Editor from "components/Editor";
import HelpText from "components/HelpText";
import { LabelText, Outline } from "components/Input";
type Props = {
@@ -19,10 +21,6 @@ class InputRich extends React.Component<Props> {
@observable editorComponent: React.ComponentType<any>;
@observable focused: boolean = false;
componentDidMount() {
this.loadEditor();
}
handleBlur = () => {
this.focused = false;
};
@@ -31,36 +29,18 @@ class InputRich extends React.Component<Props> {
this.focused = true;
};
loadEditor = async () => {
try {
const EditorImport = await import("./Editor");
this.editorComponent = EditorImport.default;
} catch (err) {
if (err.message && err.message.match(/chunk/)) {
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
window.location.reload();
return;
}
throw err;
}
};
render() {
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
const Editor = this.editorComponent;
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={this.focused}
>
{Editor ? (
<React.Suspense fallback={<HelpText>Loading editor</HelpText>}>
<Editor
onBlur={this.handleBlur}
onFocus={this.handleFocus}
@@ -68,9 +48,7 @@ class InputRich extends React.Component<Props> {
grow
{...rest}
/>
) : (
"Loading…"
)}
</React.Suspense>
</StyledOutline>
</>
);
+6 -7
View File
@@ -19,7 +19,6 @@ import Flex from "components/Flex";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import Modal from "components/Modal";
import Modals from "components/Modals";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import {
@@ -45,21 +44,22 @@ class Layout extends React.Component<Props> {
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
componentWillMount() {
this.updateBackground();
constructor(props) {
super();
this.updateBackground(props);
}
componentDidUpdate() {
this.updateBackground();
this.updateBackground(this.props);
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
updateBackground() {
updateBackground(props) {
// ensure the wider page color always matches the theme
window.document.body.style.background = this.props.theme.background;
window.document.body.style.background = props.theme.background;
}
@keydown("shift+/")
@@ -127,7 +127,6 @@ class Layout extends React.Component<Props> {
/>
</Switch>
</Container>
<Modals ui={ui} />
<Modal
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
@@ -18,7 +18,7 @@ const loadingFrame = keyframes`
const Container = styled.div`
position: fixed;
top: 0;
z-index: 9999;
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
background-color: #03a9f4;
width: 100%;
+2 -1
View File
@@ -17,7 +17,8 @@ class Mask extends React.Component<Props> {
return false;
}
componentWillMount() {
constructor() {
super();
this.width = randomInteger(75, 100);
}
+26 -18
View File
@@ -9,6 +9,7 @@ import breakpoint from "styled-components-breakpoint";
import { fadeAndScaleIn } from "shared/styles/animations";
import Flex from "components/Flex";
import NudeButton from "components/NudeButton";
import Scrollable from "components/Scrollable";
ReactModal.setAppElement("#root");
@@ -23,11 +24,12 @@ const GlobalStyles = createGlobalStyle`
.ReactModal__Overlay {
background-color: ${(props) =>
transparentize(0.25, props.theme.background)} !important;
z-index: 100;
z-index: ${(props) => props.theme.depths.modalOverlay};
}
${breakpoint("tablet")`
.ReactModalPortal + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 12px;
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
@@ -36,13 +38,15 @@ const GlobalStyles = createGlobalStyle`
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 24px;
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 36px;
}
@@ -72,10 +76,11 @@ const Modal = ({
isOpen={isOpen}
{...rest}
>
<Content onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
</Content>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
@@ -89,10 +94,20 @@ const Modal = ({
);
};
const Content = styled(Flex)`
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 2rem 2rem;
${breakpoint("tablet")`
padding-top: 13vh;
`};
`;
const Centered = styled(Flex)`
width: 640px;
max-width: 100%;
position: relative;
margin: 0 auto;
`;
const StyledModal = styled(ReactModal)`
@@ -103,20 +118,13 @@ const StyledModal = styled(ReactModal)`
left: 0;
bottom: 0;
right: 0;
z-index: 100;
z-index: ${(props) => props.theme.depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
overflow-x: hidden;
overflow-y: auto;
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
padding: 8vh 2rem 2rem;
outline: none;
${breakpoint("tablet")`
padding-top: 13vh;
`};
`;
const Text = styled.span`
@@ -147,7 +155,7 @@ const Close = styled(NudeButton)`
`;
const Back = styled(NudeButton)`
position: fixed;
position: absolute;
display: none;
align-items: center;
top: 2rem;
-58
View File
@@ -1,58 +0,0 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import UiStore from "stores/UiStore";
import CollectionDelete from "scenes/CollectionDelete";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionExport from "scenes/CollectionExport";
import CollectionNew from "scenes/CollectionNew";
import DocumentShare from "scenes/DocumentShare";
import BaseModal from "components/Modal";
type Props = {
ui: UiStore,
};
@observer
class Modals extends React.Component<Props> {
handleClose = () => {
this.props.ui.clearActiveModal();
};
render() {
const { activeModalName, activeModalProps } = this.props.ui;
const Modal = ({ name, children, ...rest }) => {
return (
<BaseModal
isOpen={activeModalName === name}
onRequestClose={this.handleClose}
{...rest}
>
{React.cloneElement(children, activeModalProps)}
</BaseModal>
);
};
return (
<span>
<Modal name="collection-new" title="Create a collection">
<CollectionNew onSubmit={this.handleClose} />
</Modal>
<Modal name="collection-edit" title="Edit collection">
<CollectionEdit onSubmit={this.handleClose} />
</Modal>
<Modal name="collection-delete" title="Delete collection">
<CollectionDelete onSubmit={this.handleClose} />
</Modal>
<Modal name="collection-export" title="Export collection">
<CollectionExport onSubmit={this.handleClose} />
</Modal>
<Modal name="document-share" title="Share document">
<DocumentShare onSubmit={this.handleClose} />
</Modal>
</span>
);
}
}
export default Modals;
+14 -4
View File
@@ -42,20 +42,23 @@ class PathToDocument extends React.Component<Props> {
return (
<Component ref={ref} onClick={this.handleClick} href="" selectable>
{collection && <CollectionIcon collection={collection} />}
&nbsp;
{result.path
.map((doc) => <Title key={doc.id}>{doc.title}</Title>)
.reduce((prev, curr) => [prev, <StyledGoToIcon />, curr])}
{document && (
<Flex>
<DocumentTitle>
{" "}
<StyledGoToIcon /> <Title>{document.title}</Title>
</Flex>
</DocumentTitle>
)}
</Component>
);
}
}
const DocumentTitle = styled(Flex)``;
const Title = styled.span`
white-space: nowrap;
overflow: hidden;
@@ -63,7 +66,7 @@ const Title = styled.span`
`;
const StyledGoToIcon = styled(GoToIcon)`
opacity: 0.25;
fill: ${(props) => props.theme.divider};
`;
const ResultWrapper = styled.div`
@@ -79,13 +82,20 @@ const ResultWrapper = styled.div`
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
margin: 0 -8px;
padding: 8px 4px;
border-radius: 8px;
${DocumentTitle} {
display: none;
}
&:hover,
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
outline: none;
${DocumentTitle} {
display: flex;
}
}
`;
+1 -1
View File
@@ -22,7 +22,7 @@ const StyledPopover = styled(BoundlessPopover)`
position: absolute;
top: 0;
left: 0;
z-index: 9999;
z-index: ${(props) => props.theme.depths.popover};
svg {
height: 16px;
+20 -7
View File
@@ -17,7 +17,7 @@ import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite";
import Flex from "components/Flex";
import Modal from "components/Modal";
@@ -34,21 +34,25 @@ type Props = {
auth: AuthStore,
documents: DocumentsStore,
policies: PoliciesStore,
ui: UiStore,
};
@observer
class MainSidebar extends React.Component<Props> {
@observable inviteModalOpen: boolean = false;
@observable inviteModalOpen = false;
@observable createCollectionModalOpen = false;
componentDidMount() {
this.props.documents.fetchDrafts();
this.props.documents.fetchTemplates();
}
handleCreateCollection = (ev: SyntheticEvent<>) => {
handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.props.ui.setActiveModal("collection-new");
this.createCollectionModalOpen = true;
};
handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
this.createCollectionModalOpen = false;
};
handleInviteModalOpen = (ev: SyntheticEvent<>) => {
@@ -134,7 +138,9 @@ class MainSidebar extends React.Component<Props> {
/>
</Section>
<Section>
<Collections onCreateCollection={this.handleCreateCollection} />
<Collections
onCreateCollection={this.handleCreateCollectionModalOpen}
/>
</Section>
<Section>
<SidebarLink
@@ -175,6 +181,13 @@ class MainSidebar extends React.Component<Props> {
>
<Invite onSubmit={this.handleInviteModalClose} />
</Modal>
<Modal
title="Create a collection"
onRequestClose={this.handleCreateCollectionModalClose}
isOpen={this.createCollectionModalOpen}
>
<CollectionNew onSubmit={this.handleCreateCollectionModalClose} />
</Modal>
</Sidebar>
);
}
@@ -184,4 +197,4 @@ const Drafts = styled(Flex)`
height: 24px;
`;
export default inject("documents", "policies", "auth", "ui")(MainSidebar);
export default inject("documents", "policies", "auth")(MainSidebar);
+1 -1
View File
@@ -40,7 +40,7 @@ type Props = {
@observer
class SettingsSidebar extends React.Component<Props> {
returnToDashboard = () => {
this.props.history.push("/");
this.props.history.push("/home");
};
render() {
+36 -41
View File
@@ -1,65 +1,60 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import { CloseIcon, MenuIcon } from "outline-icons";
import * as React from "react";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import UiStore from "stores/UiStore";
import Fade from "components/Fade";
import Flex from "components/Flex";
import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores";
let firstRender = true;
type Props = {
children: React.Node,
location: Location,
ui: UiStore,
};
@observer
class Sidebar extends React.Component<Props> {
componentWillReceiveProps = (nextProps: Props) => {
if (this.props.location !== nextProps.location) {
this.props.ui.hideMobileSidebar();
function Sidebar({ location, children }: Props) {
const { ui } = useStores();
const previousLocation = usePrevious(location);
React.useEffect(() => {
if (location !== previousLocation) {
ui.hideMobileSidebar();
}
};
}, [ui, location]);
toggleSidebar = () => {
this.props.ui.toggleMobileSidebar();
};
render() {
const { children, ui } = this.props;
const content = (
<Container
editMode={ui.editMode}
const content = (
<Container
editMode={ui.editMode}
mobileSidebarVisible={ui.mobileSidebarVisible}
column
>
<Toggle
onClick={ui.toggleMobileSidebar}
mobileSidebarVisible={ui.mobileSidebarVisible}
column
>
<Toggle
onClick={this.toggleSidebar}
mobileSidebarVisible={ui.mobileSidebarVisible}
>
{ui.mobileSidebarVisible ? (
<CloseIcon size={32} />
) : (
<MenuIcon size={32} />
)}
</Toggle>
{children}
</Container>
);
{ui.mobileSidebarVisible ? (
<CloseIcon size={32} />
) : (
<MenuIcon size={32} />
)}
</Toggle>
{children}
</Container>
);
// Fade in the sidebar on first render after page load
if (firstRender) {
firstRender = false;
return <Fade>{content}</Fade>;
}
return content;
// Fade in the sidebar on first render after page load
if (firstRender) {
firstRender = false;
return <Fade>{content}</Fade>;
}
return content;
}
const Container = styled(Flex)`
@@ -71,7 +66,7 @@ const Container = styled(Flex)`
transition: left 100ms ease-out,
${(props) => props.theme.backgroundTransition};
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
z-index: 1000;
z-index: ${(props) => props.theme.depths.sidebar};
@media print {
display: none;
@@ -117,4 +112,4 @@ const Toggle = styled.a`
`};
`;
export default withRouter(inject("ui")(Sidebar));
export default withRouter(observer(Sidebar));
@@ -1,16 +1,15 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled, { withTheme } from "styled-components";
import styled from "styled-components";
import Flex from "components/Flex";
import TeamLogo from "components/TeamLogo";
type Props = {
teamName: string,
subheading: string,
subheading: React.Node,
showDisclosure?: boolean,
logoUrl: string,
theme: Object,
};
function HeaderBlock({
@@ -18,7 +17,6 @@ function HeaderBlock({
teamName,
subheading,
logoUrl,
theme,
...rest
}: Props) {
return (
@@ -27,7 +25,7 @@ function HeaderBlock({
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
{showDisclosure && <StyledExpandedIcon color={theme.text} />}
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
</TeamName>
<Subheading>{subheading}</Subheading>
</Flex>
@@ -73,4 +71,4 @@ const Header = styled(Flex)`
}
`;
export default withTheme(HeaderBlock);
export default HeaderBlock;
@@ -1,5 +1,4 @@
// @flow
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
@@ -25,79 +24,80 @@ type Props = {
depth?: number,
};
@observer
class SidebarLink extends React.Component<Props> {
@observable expanded: ?boolean = this.props.expanded;
function SidebarLink({
icon,
children,
onClick,
to,
label,
active,
menu,
menuOpen,
hideDisclosure,
theme,
exact,
href,
depth,
...rest
}: Props) {
const [expanded, setExpanded] = React.useState(rest.expanded);
style = {
paddingLeft: `${(this.props.depth || 0) * 16 + 16}px`,
};
componentWillReceiveProps(nextProps: Props) {
if (nextProps.expanded !== undefined) {
this.expanded = nextProps.expanded;
}
}
@action
handleClick = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
this.expanded = !this.expanded;
};
@action
handleExpand = () => {
this.expanded = true;
};
render() {
const {
icon,
children,
onClick,
to,
label,
active,
menu,
menuOpen,
hideDisclosure,
exact,
href,
} = this.props;
const showDisclosure = !!children && !hideDisclosure;
const activeStyle = {
color: this.props.theme.text,
background: this.props.theme.sidebarItemBackground,
fontWeight: 600,
...this.style,
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
};
}, [depth]);
return (
<Wrapper column>
<StyledNavLink
activeStyle={activeStyle}
style={active ? activeStyle : this.style}
onClick={onClick}
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label onClick={this.handleExpand}>
{showDisclosure && (
<Disclosure expanded={this.expanded} onClick={this.handleClick} />
)}
{label}
</Label>
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
</StyledNavLink>
{this.expanded && children}
</Wrapper>
);
}
React.useEffect(() => {
if (rest.expanded !== undefined) {
setExpanded(rest.expanded);
}
}, [rest.expanded]);
const handleClick = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded(!expanded);
},
[expanded]
);
const handleExpand = React.useCallback(() => {
setExpanded(true);
}, []);
const showDisclosure = !!children && !hideDisclosure;
const activeStyle = {
color: theme.text,
background: theme.sidebarItemBackground,
fontWeight: 600,
...style,
};
return (
<Wrapper column>
<StyledNavLink
activeStyle={activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
exact={exact !== false}
to={to}
as={to ? undefined : href ? "a" : "div"}
href={href}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label onClick={handleExpand}>
{showDisclosure && (
<Disclosure expanded={expanded} onClick={handleClick} />
)}
{label}
</Label>
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
</StyledNavLink>
{expanded && children}
</Wrapper>
);
}
// accounts for whitespace around icon
@@ -171,4 +171,4 @@ const Disclosure = styled(CollapsedIcon)`
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default withRouter(withTheme(SidebarLink));
export default withRouter(withTheme(observer(SidebarLink)));
+39 -15
View File
@@ -3,7 +3,7 @@ import { find } from "lodash";
import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import io from "socket.io-client";
import io, { Socket } from "socket.io-client";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
@@ -13,6 +13,7 @@ import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import ViewsStore from "stores/ViewsStore";
import { getVisibilityListener, getPageVisible } from "utils/pageVisibility";
export const SocketContext: any = React.createContext();
@@ -31,12 +32,42 @@ type Props = {
@observer
class SocketProvider extends React.Component<Props> {
@observable socket;
@observable socket: Socket;
componentDidMount() {
this.createConnection();
document.addEventListener(getVisibilityListener(), this.checkConnection);
}
componentWillUnmount() {
if (this.socket) {
this.socket.authenticated = false;
this.socket.disconnect();
}
document.removeEventListener(getVisibilityListener(), this.checkConnection);
}
checkConnection = () => {
if (this.socket && this.socket.disconnected && getPageVisible()) {
// null-ifying this reference is important, do not remove. Without it
// references to old sockets are potentially held in context
this.socket.close();
this.socket = null;
this.createConnection();
}
};
createConnection = () => {
this.socket = io(window.location.origin, {
path: "/realtime",
transports: ["websocket"],
reconnectionDelay: 1000,
reconnectionDelayMax: 30000,
});
this.socket.authenticated = false;
const {
@@ -65,12 +96,12 @@ class SocketProvider extends React.Component<Props> {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
if (reason === "io server disconnect") {
// the disconnection was initiated by the server, need to reconnect
// manually, else the socket will automatically try to reconnect
this.socket.connect();
}
// on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.on("reconnect_attempt", () => {
this.socket.io.opts.transports = ["polling", "websocket"];
});
this.socket.on("authenticated", () => {
@@ -260,14 +291,7 @@ class SocketProvider extends React.Component<Props> {
this.socket.on("user.presence", (event) => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
}
componentWillUnmount() {
if (this.socket) {
this.socket.disconnect();
this.socket.authenticated = false;
}
}
};
render() {
return (
+2
View File
@@ -6,6 +6,8 @@ const Tabs = styled.nav`
border-bottom: 1px solid ${(props) => props.theme.divider};
margin-top: 22px;
margin-bottom: 12px;
overflow-y: auto;
white-space: nowrap;
`;
export const Separator = styled.span`
+1 -1
View File
@@ -34,7 +34,7 @@ const List = styled.ol`
list-style: none;
margin: 0;
padding: 0;
z-index: 1000;
z-index: ${(props) => props.theme.depths.toasts};
`;
export default inject("ui")(Toasts);
+1 -1
View File
@@ -3,7 +3,7 @@ import { darken } from "polished";
import * as React from "react";
import styled from "styled-components";
import { fadeAndScaleIn } from "shared/styles/animations";
import type { Toast as TToast } from "../../../types";
import type { Toast as TToast } from "types";
type Props = {
onRequestClose: () => void,
+10
View File
@@ -0,0 +1,10 @@
// @flow
import * as React from "react";
export default function usePrevious(value: any) {
const ref = React.useRef();
React.useEffect(() => {
ref.current = value;
});
return ref.current;
}
+8
View File
@@ -0,0 +1,8 @@
// @flow
import { MobXProviderContext } from "mobx-react";
import * as React from "react";
import RootStore from "stores";
export default function useStores(): typeof RootStore {
return React.useContext(MobXProviderContext);
}
+15 -22
View File
@@ -1,4 +1,5 @@
// @flow
import "mobx-react-lite/batchingForReactDom";
import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";
@@ -12,32 +13,24 @@ import Toasts from "components/Toasts";
import Routes from "./routes";
import env from "env";
let DevTools;
if (process.env.NODE_ENV !== "production") {
DevTools = require("mobx-react-devtools").default; // eslint-disable-line global-require
}
const element = document.getElementById("root");
if (element) {
render(
<>
<ErrorBoundary>
<Provider {...stores}>
<Theme>
<Router>
<>
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</Theme>
</Provider>
</ErrorBoundary>
{DevTools && <DevTools position={{ bottom: 0, right: 0 }} />}
</>,
<ErrorBoundary>
<Provider {...stores}>
<Theme>
<Router>
<>
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</Theme>
</Provider>
</ErrorBoundary>,
element
);
}
+70 -21
View File
@@ -7,8 +7,10 @@ import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import Collection from "models/Collection";
import CollectionDelete from "scenes/CollectionDelete";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionExport from "scenes/CollectionExport";
import CollectionMembers from "scenes/CollectionMembers";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Modal from "components/Modal";
import VisuallyHidden from "components/VisuallyHidden";
@@ -30,7 +32,10 @@ type Props = {
@observer
class CollectionMenu extends React.Component<Props> {
file: ?HTMLInputElement;
@observable membersModalOpen: boolean = false;
@observable showCollectionMembers = false;
@observable showCollectionEdit = false;
@observable showCollectionDelete = false;
@observable showCollectionExport = false;
onNewDocument = (ev: SyntheticEvent<>) => {
ev.preventDefault();
@@ -61,31 +66,40 @@ class CollectionMenu extends React.Component<Props> {
}
};
onEdit = (ev: SyntheticEvent<>) => {
handleEditCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { collection } = this.props;
this.props.ui.setActiveModal("collection-edit", { collection });
this.showCollectionEdit = true;
};
onDelete = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { collection } = this.props;
this.props.ui.setActiveModal("collection-delete", { collection });
handleEditCollectionClose = () => {
this.showCollectionEdit = false;
};
onExport = (ev: SyntheticEvent<>) => {
handleDeleteCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
const { collection } = this.props;
this.props.ui.setActiveModal("collection-export", { collection });
this.showCollectionDelete = true;
};
onPermissions = (ev: SyntheticEvent<>) => {
handleDeleteCollectionClose = () => {
this.showCollectionDelete = false;
};
handleExportCollectionOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.membersModalOpen = true;
this.showCollectionExport = true;
};
handleExportCollectionClose = () => {
this.showCollectionExport = false;
};
handleMembersModalOpen = (ev: SyntheticEvent<>) => {
ev.preventDefault();
this.showCollectionMembers = true;
};
handleMembersModalClose = () => {
this.membersModalOpen = false;
this.showCollectionMembers = false;
};
render() {
@@ -107,12 +121,13 @@ class CollectionMenu extends React.Component<Props> {
<Modal
title="Collection permissions"
onRequestClose={this.handleMembersModalClose}
isOpen={this.membersModalOpen}
isOpen={this.showCollectionMembers}
>
<CollectionMembers
collection={collection}
onSubmit={this.handleMembersModalClose}
onEdit={this.onEdit}
handleEditCollectionOpen={this.handleEditCollectionOpen}
onEdit={this.handleEditCollectionOpen}
/>
</Modal>
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
@@ -130,24 +145,58 @@ class CollectionMenu extends React.Component<Props> {
)}
{can.update && <hr />}
{can.update && (
<DropdownMenuItem onClick={this.onEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleEditCollectionOpen}>
Edit
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.onPermissions}>
<DropdownMenuItem onClick={this.handleMembersModalOpen}>
Permissions
</DropdownMenuItem>
)}
{can.export && (
<DropdownMenuItem onClick={this.onExport}>
<DropdownMenuItem onClick={this.handleExportCollectionOpen}>
Export
</DropdownMenuItem>
)}
</>
)}
{can.delete && (
<DropdownMenuItem onClick={this.onDelete}>Delete</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleDeleteCollectionOpen}>
Delete
</DropdownMenuItem>
)}
</DropdownMenu>
<Modal
title="Edit collection"
isOpen={this.showCollectionEdit}
onRequestClose={this.handleEditCollectionClose}
>
<CollectionEdit
onSubmit={this.handleEditCollectionClose}
collection={collection}
/>
</Modal>
<Modal
title="Delete collection"
isOpen={this.showCollectionDelete}
onRequestClose={this.handleDeleteCollectionClose}
>
<CollectionDelete
onSubmit={this.handleDeleteCollectionClose}
collection={collection}
/>
</Modal>
<Modal
title="Export collection"
isOpen={this.showCollectionExport}
onRequestClose={this.handleExportCollectionClose}
>
<CollectionExport
onSubmit={this.handleExportCollectionClose}
collection={collection}
/>
</Modal>
</>
);
}
+19 -3
View File
@@ -10,6 +10,7 @@ import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentShare from "scenes/DocumentShare";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Modal from "components/Modal";
@@ -40,8 +41,9 @@ type Props = {
@observer
class DocumentMenu extends React.Component<Props> {
@observable redirectTo: ?string;
@observable showDeleteModal: boolean = false;
@observable showTemplateModal: boolean = false;
@observable showDeleteModal = false;
@observable showTemplateModal = false;
@observable showShareModal = false;
componentDidUpdate() {
this.redirectTo = undefined;
@@ -129,7 +131,11 @@ class DocumentMenu extends React.Component<Props> {
handleShareLink = async (ev: SyntheticEvent<>) => {
const { document } = this.props;
await document.share();
this.props.ui.setActiveModal("document-share", { document });
this.showShareModal = true;
};
handleCloseShareModal = () => {
this.showShareModal = false;
};
render() {
@@ -282,6 +288,16 @@ class DocumentMenu extends React.Component<Props> {
onSubmit={this.handleCloseTemplateModal}
/>
</Modal>
<Modal
title="Share document"
onRequestClose={this.handleCloseShareModal}
isOpen={this.showShareModal}
>
<DocumentShare
document={this.props.document}
onSubmit={this.handleCloseShareModal}
/>
</Modal>
</>
);
}
-1
View File
@@ -16,7 +16,6 @@ export default class Collection extends BaseModel {
icon: string;
color: string;
private: boolean;
type: "atlas" | "journal";
documents: NavigationNode[];
createdAt: ?string;
updatedAt: ?string;
-121
View File
@@ -1,121 +0,0 @@
// @flow
import * as React from "react";
import { Switch, Route, Redirect, type Match } from "react-router-dom";
import Archive from "scenes/Archive";
import Collection from "scenes/Collection";
import Dashboard from "scenes/Dashboard";
import KeyedDocument from "scenes/Document/KeyedDocument";
import DocumentNew from "scenes/DocumentNew";
import Drafts from "scenes/Drafts";
import Error404 from "scenes/Error404";
import Login from "scenes/Login";
import Search from "scenes/Search";
import Settings from "scenes/Settings";
import Details from "scenes/Settings/Details";
import Events from "scenes/Settings/Events";
import Export from "scenes/Settings/Export";
import Groups from "scenes/Settings/Groups";
import Notifications from "scenes/Settings/Notifications";
import People from "scenes/Settings/People";
import Security from "scenes/Settings/Security";
import Shares from "scenes/Settings/Shares";
import Slack from "scenes/Settings/Slack";
import Tokens from "scenes/Settings/Tokens";
import Zapier from "scenes/Settings/Zapier";
import Starred from "scenes/Starred";
import Templates from "scenes/Templates";
import Trash from "scenes/Trash";
import Authenticated from "components/Authenticated";
import Layout from "components/Layout";
import SocketProvider from "components/SocketProvider";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
<Redirect
to={
match.params.documentSlug ? `/doc/${match.params.documentSlug}` : "/home"
}
/>
);
export default function Routes() {
return (
<Switch>
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Authenticated>
<SocketProvider>
<Layout>
<Switch>
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Dashboard} />
<Route path="/home" component={Dashboard} />
<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="/settings" component={Settings} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/people" component={People} />
<Route exact path="/settings/people/:filter" component={People} />
<Route exact path="/settings/groups" component={Groups} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route exact path="/settings/events" component={Events} />
<Route
exact
path="/settings/notifications"
component={Notifications}
/>
<Route
exact
path="/settings/integrations/slack"
component={Slack}
/>
<Route
exact
path="/settings/integrations/zapier"
component={Zapier}
/>
<Route exact path="/settings/export" component={Export} />
<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} />
<Route component={NotFound} />
</Switch>
</Layout>
</SocketProvider>
</Authenticated>
</Switch>
);
}
+76
View File
@@ -0,0 +1,76 @@
// @flow
import * as React from "react";
import { Switch, Route, Redirect, type Match } from "react-router-dom";
import Archive from "scenes/Archive";
import Collection from "scenes/Collection";
import Dashboard from "scenes/Dashboard";
import KeyedDocument from "scenes/Document/KeyedDocument";
import DocumentNew from "scenes/DocumentNew";
import Drafts from "scenes/Drafts";
import Error404 from "scenes/Error404";
import Search from "scenes/Search";
import Starred from "scenes/Starred";
import Templates from "scenes/Templates";
import Trash from "scenes/Trash";
import CenteredContent from "components/CenteredContent";
import Layout from "components/Layout";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import SocketProvider from "components/SocketProvider";
import { matchDocumentSlug as slug } from "utils/routeHelpers";
const SettingsRoutes = React.lazy(() => import("./settings"));
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
<Redirect
to={
match.params.documentSlug ? `/doc/${match.params.documentSlug}` : "/home"
}
/>
);
export default function AuthenticatedRoutes() {
return (
<SocketProvider>
<Layout>
<Switch>
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Dashboard} />
<Route path="/home" component={Dashboard} />
<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>
}
>
<SettingsRoutes />
</React.Suspense>
<Route component={NotFound} />
</Switch>
</Layout>
</SocketProvider>
);
}
+31
View File
@@ -0,0 +1,31 @@
// @flow
import * as React from "react";
import { Switch, Route } from "react-router-dom";
import DelayedMount from "components/DelayedMount";
import FullscreenLoading from "components/FullscreenLoading";
const Authenticated = React.lazy(() => import("components/Authenticated"));
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
const KeyedDocument = React.lazy(() => import("scenes/Document/KeyedDocument"));
const Login = React.lazy(() => import("scenes/Login"));
export default function Routes() {
return (
<React.Suspense
fallback={
<DelayedMount delay={2000}>
<FullscreenLoading />
</DelayedMount>
}
>
<Switch>
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Authenticated>
<AuthenticatedRoutes />
</Authenticated>
</Switch>
</React.Suspense>
);
}
+35
View File
@@ -0,0 +1,35 @@
// @flow
import * as React from "react";
import { Switch, Route } from "react-router-dom";
import Settings from "scenes/Settings";
import Details from "scenes/Settings/Details";
import Events from "scenes/Settings/Events";
import Export from "scenes/Settings/Export";
import Groups from "scenes/Settings/Groups";
import Notifications from "scenes/Settings/Notifications";
import People from "scenes/Settings/People";
import Security from "scenes/Settings/Security";
import Shares from "scenes/Settings/Shares";
import Slack from "scenes/Settings/Slack";
import Tokens from "scenes/Settings/Tokens";
import Zapier from "scenes/Settings/Zapier";
export default function SettingsRoutes() {
return (
<Switch>
<Route exact path="/settings" component={Settings} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/people" component={People} />
<Route exact path="/settings/people/:filter" component={People} />
<Route exact path="/settings/groups" component={Groups} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route exact path="/settings/events" component={Events} />
<Route exact path="/settings/notifications" component={Notifications} />
<Route exact path="/settings/integrations/slack" component={Slack} />
<Route exact path="/settings/integrations/zapier" component={Zapier} />
<Route exact path="/settings/export" component={Export} />
</Switch>
);
}
+12 -11
View File
@@ -5,7 +5,6 @@ import { observer, inject } from "mobx-react";
import { NewDocumentIcon, PlusIcon, PinIcon } from "outline-icons";
import * as React from "react";
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
import RichMarkdownEditor from "rich-markdown-editor";
import styled, { withTheme } from "styled-components";
import CollectionsStore from "stores/CollectionsStore";
@@ -22,6 +21,7 @@ import Button from "components/Button";
import CenteredContent from "components/CenteredContent";
import CollectionIcon from "components/CollectionIcon";
import DocumentList from "components/DocumentList";
import Editor from "components/Editor";
import Flex from "components/Flex";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
@@ -62,10 +62,10 @@ class CollectionScene extends React.Component<Props> {
}
}
componentWillReceiveProps(nextProps) {
const { id } = nextProps.match.params;
componentDidUpdate(prevProps) {
const { id } = this.props.match.params;
if (id && id !== this.props.match.params.id) {
if (id && id !== prevProps.match.params.id) {
this.loadContent(id);
}
}
@@ -218,13 +218,14 @@ class CollectionScene extends React.Component<Props> {
</Heading>
{collection.description && (
<RichMarkdownEditor
id={collection.id}
key={collection.description}
defaultValue={collection.description}
theme={theme}
readOnly
/>
<React.Suspense fallback={<p>Loading</p>}>
<Editor
id={collection.id}
key={collection.description}
defaultValue={collection.description}
readOnly
/>
</React.Suspense>
)}
{hasPinnedDocuments && (
+1 -1
View File
@@ -49,7 +49,7 @@ class CollectionDelete extends React.Component<Props> {
<strong>{collection.name}</strong> collection is permanent and will
also delete all of the documents within it, so be extra careful.
</HelpText>
<Button type="submit" disabled={this.isDeleting} danger>
<Button type="submit" disabled={this.isDeleting} autoFocus danger>
{this.isDeleting ? "Deleting…" : "Im sure  Delete"}
</Button>
</form>
@@ -132,7 +132,7 @@ class CollectionMembers extends React.Component<Props> {
collection. You can make this collection visible to the entire
team by{" "}
<a role="button" onClick={this.props.onEdit}>
changing its visibility
changing the visibility
</a>
.
</HelpText>
+18 -13
View File
@@ -64,12 +64,6 @@ class DataLoader extends React.Component<Props> {
}
}
goToDocumentCanonical = () => {
if (this.document) {
this.props.history.push(this.document.url);
}
};
get isEditing() {
return this.props.match.path === matchDocumentEdit;
}
@@ -125,17 +119,28 @@ class DataLoader extends React.Component<Props> {
const document = this.document;
if (document) {
const can = this.props.policies.abilities(document.id);
// sets the document as active in the sidebar, ideally in the future this
// will be route driven.
this.props.ui.setActiveDocument(document);
if (document.isArchived && this.isEditing) {
return this.goToDocumentCanonical();
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!can.update && this.isEditing) {
this.props.history.push(document.url);
return;
}
this.props.shares.fetch(document.id).catch((err) => {
if (!(err instanceof NotFoundError)) {
throw err;
}
});
// Prevents unauthorized request to load share information for the document
// when viewing a public share link
if (can.read) {
this.props.shares.fetch(document.id).catch((err) => {
if (!(err instanceof NotFoundError)) {
throw err;
}
});
}
const isMove = this.props.location.pathname.match(/move$/);
const canRedirect = !revisionId && !isMove && !shareId;
+49 -74
View File
@@ -18,15 +18,16 @@ import Branding from "components/Branding";
import ErrorBoundary from "components/ErrorBoundary";
import Flex from "components/Flex";
import LoadingIndicator from "components/LoadingIndicator";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import Notice from "components/Notice";
import PageTitle from "components/PageTitle";
import Time from "components/Time";
import Container from "./Container";
import Contents from "./Contents";
import DocumentMove from "./DocumentMove";
import Editor from "./Editor";
import Header from "./Header";
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
import Loading from "./Loading";
import MarkAsViewed from "./MarkAsViewed";
import References from "./References";
import { type LocationWithState } from "types";
@@ -39,7 +40,6 @@ import {
documentUrl,
} from "utils/routeHelpers";
let EditorImport;
const AUTOSAVE_DELAY = 3000;
const IS_DIRTY_DELAY = 500;
const DISCARD_CHANGES = `
@@ -68,8 +68,7 @@ type Props = {
@observer
class DocumentScene extends React.Component<Props> {
@observable editor: ?any;
@observable editorComponent = EditorImport;
@observable editor = React.createRef();
@observable isUploading: boolean = false;
@observable isSaving: boolean = false;
@observable isPublishing: boolean = false;
@@ -84,7 +83,6 @@ class DocumentScene extends React.Component<Props> {
super();
this.title = props.document.title;
this.lastRevision = props.document.revision;
this.loadEditor();
}
componentDidMount() {
@@ -136,7 +134,7 @@ class DocumentScene extends React.Component<Props> {
ev.preventDefault();
const { document, abilities } = this.props;
if (abilities.update) {
if (abilities.move) {
this.props.history.push(documentMoveUrl(document));
}
}
@@ -197,25 +195,6 @@ class DocumentScene extends React.Component<Props> {
}
}
loadEditor = async () => {
if (this.editorComponent) return;
try {
const EditorImport = await import("./Editor");
this.editorComponent = EditorImport.default;
} catch (err) {
if (err.message && err.message.match(/chunk/)) {
// If the editor bundle fails to load then reload the entire window. This
// can happen if a deploy happens between the user loading the initial JS
// bundle and the async-loaded editor JS bundle as the hash will change.
window.location.reload();
return;
}
throw err;
}
};
handleCloseMoveModal = () => (this.moveModalOpen = false);
handleOpenMoveModal = () => (this.moveModalOpen = true);
@@ -338,20 +317,14 @@ class DocumentScene extends React.Component<Props> {
document,
revision,
readOnly,
location,
abilities,
auth,
ui,
match,
} = this.props;
const team = auth.team;
const Editor = this.editorComponent;
const isShare = !!match.params.shareId;
if (!Editor) {
return <Loading location={location} />;
}
const value = revision ? revision.text : document.text;
const injectTemplate = document.injectTemplate;
const disableEmbeds =
@@ -408,7 +381,7 @@ class DocumentScene extends React.Component<Props> {
)}
<MaxWidth
archived={document.isArchived}
tocVisible={ui.tocVisible}
tocVisible={ui.tocVisible && readOnly}
column
auto
>
@@ -440,50 +413,52 @@ class DocumentScene extends React.Component<Props> {
)}
</Notice>
)}
<Flex auto={!readOnly}>
{ui.tocVisible && readOnly && (
<Contents
headings={this.editor ? this.editor.getHeadings() : []}
<React.Suspense fallback={<LoadingPlaceholder />}>
<Flex auto={!readOnly}>
{ui.tocVisible && readOnly && (
<Contents
headings={
this.editor.current
? this.editor.current.getHeadings()
: []
}
/>
)}
<Editor
id={document.id}
innerRef={this.editor}
isShare={isShare}
isDraft={document.isDraft}
template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")}
title={revision ? revision.title : this.title}
document={document}
value={readOnly ? value : undefined}
defaultValue={value}
disableEmbeds={disableEmbeds}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
ui={this.props.ui}
/>
</Flex>
{readOnly && !isShare && !revision && (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<References document={document} />
</ReferencesWrapper>
</>
)}
<Editor
id={document.id}
ref={(ref) => {
if (ref) {
this.editor = ref;
}
}}
isShare={isShare}
isDraft={document.isDraft}
template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")}
title={revision ? revision.title : this.title}
document={document}
value={readOnly ? value : undefined}
defaultValue={value}
disableEmbeds={disableEmbeds}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onSearchLink={this.props.onSearchLink}
onCreateLink={this.props.onCreateLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange}
onSave={this.onSave}
onPublish={this.onPublish}
onCancel={this.goBack}
readOnly={readOnly}
readOnlyWriteCheckboxes={readOnly && abilities.update}
ui={this.props.ui}
/>
</Flex>
{readOnly && !isShare && !revision && (
<>
<MarkAsViewed document={document} />
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
<References document={document} />
</ReferencesWrapper>
</>
)}
</React.Suspense>
</MaxWidth>
</Container>
</Background>
+91 -44
View File
@@ -13,13 +13,11 @@ import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import Document from "models/Document";
import Flex from "components/Flex";
import Input from "components/Input";
import { Outline } from "components/Input";
import Labeled from "components/Labeled";
import Modal from "components/Modal";
import PathToDocument from "components/PathToDocument";
const MAX_RESULTS = 8;
type Props = {|
document: Document,
documents: DocumentsStore,
@@ -36,14 +34,19 @@ class DocumentMove extends React.Component<Props> {
@computed
get searchIndex() {
const { collections } = this.props;
const { collections, documents } = this.props;
const paths = collections.pathsToDocuments;
const index = new Search("id");
index.addIndex("title");
// Build index
const indexeableDocuments = [];
paths.forEach((path) => indexeableDocuments.push(path));
paths.forEach((path) => {
const doc = documents.get(path.id);
if (!doc || !doc.isTemplate) {
indexeableDocuments.push(path);
}
});
index.addDocuments(indexeableDocuments);
return index;
@@ -52,6 +55,7 @@ class DocumentMove extends React.Component<Props> {
@computed
get results(): DocumentPath[] {
const { document, collections } = this.props;
const onlyShowCollections = document.isTemplate;
let results = [];
if (collections.isLoaded) {
@@ -62,17 +66,23 @@ class DocumentMove extends React.Component<Props> {
}
}
// Exclude root from search results if document is already at the root
if (!document.parentDocumentId) {
results = results.filter((result) => result.id !== document.collectionId);
}
if (onlyShowCollections) {
results = results.filter((result) => result.type === "collection");
} else {
// Exclude root from search results if document is already at the root
if (!document.parentDocumentId) {
results = results.filter(
(result) => result.id !== document.collectionId
);
}
// Exclude document if on the path to result, or the same result
results = results.filter(
(result) =>
!result.path.map((doc) => doc.id).includes(document.id) &&
last(result.path.map((doc) => doc.id)) !== document.parentDocumentId
);
// Exclude document if on the path to result, or the same result
results = results.filter(
(result) =>
!result.path.map((doc) => doc.id).includes(document.id) &&
last(result.path.map((doc) => doc.id)) !== document.parentDocumentId
);
}
return results;
}
@@ -129,35 +139,41 @@ class DocumentMove extends React.Component<Props> {
</Section>
<Section column>
<Labeled label="Choose a new location">
<Input
type="search"
placeholder="Search collections & documents…"
onKeyDown={this.handleKeyDown}
onChange={this.handleFilter}
required
autoFocus
/>
</Labeled>
<Flex column>
<StyledArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.results.slice(0, MAX_RESULTS).map((result, index) => (
<PathToDocument
key={result.id}
result={result}
document={document}
collection={collections.get(result.collectionId)}
ref={(ref) =>
index === 0 && this.setFirstDocumentRef(ref)
}
onSuccess={this.handleSuccess}
/>
))}
</StyledArrowKeyNavigation>
</Flex>
<Labeled label="Choose a new location" />
<NewLocation>
<InputWrapper>
<Input
type="search"
placeholder="Search collections & documents…"
onKeyDown={this.handleKeyDown}
onChange={this.handleFilter}
required
autoFocus
/>
</InputWrapper>
<Results>
<Flex column>
<StyledArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.results.map((result, index) => (
<PathToDocument
key={result.id}
result={result}
document={document}
collection={collections.get(result.collectionId)}
ref={(ref) =>
index === 0 && this.setFirstDocumentRef(ref)
}
onSuccess={this.handleSuccess}
/>
))}
</StyledArrowKeyNavigation>
</Flex>
</Results>
</NewLocation>
</Section>
</Flex>
)}
@@ -166,6 +182,37 @@ class DocumentMove extends React.Component<Props> {
}
}
const InputWrapper = styled("div")`
padding: 8px;
width: 100%;
`;
const Input = styled("input")`
width: 100%;
outline: none;
background: none;
border-radius: 4px;
height: 30px;
border: 0;
color: ${(props) => props.theme.text};
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
const NewLocation = styled(Outline)`
flex-direction: column;
`;
const Results = styled(Flex)`
display: block;
width: 100%;
max-height: 40vh;
overflow-y: auto;
padding: 8px;
`;
const Section = styled(Flex)`
margin-bottom: 24px;
`;
+7 -15
View File
@@ -3,7 +3,6 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import Textarea from "react-autosize-textarea";
import RichMarkdownEditor from "rich-markdown-editor";
import styled from "styled-components";
import parseTitle from "shared/utils/parseTitle";
import Document from "models/Document";
@@ -22,33 +21,25 @@ type Props = {
isDraft: boolean,
isShare: boolean,
readOnly?: boolean,
innerRef: { current: any },
};
@observer
class DocumentEditor extends React.Component<Props> {
@observable activeLinkEvent: ?MouseEvent;
editor = React.createRef<RichMarkdownEditor>();
focusAtStart = () => {
if (this.editor.current) {
this.editor.current.focusAtStart();
if (this.props.innerRef.current) {
this.props.innerRef.current.focusAtStart();
}
};
focusAtEnd = () => {
if (this.editor.current) {
this.editor.current.focusAtEnd();
if (this.props.innerRef.current) {
this.props.innerRef.current.focusAtEnd();
}
};
getHeadings = () => {
if (this.editor.current) {
return this.editor.current.getHeadings();
}
return [];
};
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
@@ -72,6 +63,7 @@ class DocumentEditor extends React.Component<Props> {
isDraft,
isShare,
readOnly,
innerRef,
} = this.props;
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
@@ -95,7 +87,7 @@ class DocumentEditor extends React.Component<Props> {
to={documentHistoryUrl(document)}
/>
<Editor
ref={this.editor}
ref={innerRef}
autoFocus={title && !this.props.defaultValue}
placeholder="…the rest is up to you"
onHoverLink={this.handleLinkActive}
@@ -41,7 +41,7 @@ export default class SocketPresence extends React.Component<Props> {
}
setupOnce = () => {
if (this.context && !this.previousContext) {
if (this.context && this.context !== this.previousContext) {
this.previousContext = this.context;
if (this.context.authenticated) {
+18 -6
View File
@@ -9,7 +9,7 @@ import Document from "models/Document";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import { collectionUrl } from "utils/routeHelpers";
import { collectionUrl, documentUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
@@ -24,15 +24,27 @@ class DocumentDelete extends React.Component<Props> {
@observable isDeleting: boolean;
handleSubmit = async (ev: SyntheticEvent<>) => {
const { documents, document } = this.props;
ev.preventDefault();
this.isDeleting = true;
try {
await this.props.document.delete();
if (this.props.ui.activeDocumentId === this.props.document.id) {
this.props.history.push(
collectionUrl(this.props.document.collectionId)
);
await document.delete();
// only redirect if we're currently viewing the document that's deleted
if (this.props.ui.activeDocumentId === document.id) {
// If the document has a parent and it's available in the store then
// redirect to it
if (document.parentDocumentId) {
const parent = documents.get(document.parentDocumentId);
if (parent) {
this.props.history.push(documentUrl(parent));
return;
}
}
// otherwise, redirect to the collection home
this.props.history.push(collectionUrl(document.collectionId));
}
this.props.onSubmit();
} catch (err) {
+3 -3
View File
@@ -82,7 +82,7 @@ class Login extends React.Component<Props, State> {
<PageTitle title="Check your email" />
<CheckEmailIcon size={38} color="currentColor" />
<Heading>Check your email</Heading>
<Heading centered>Check your email</Heading>
<Note>
A magic sign-in link has been sent to the email{" "}
<em>{this.state.emailLinkSentTo}</em>, no password needed.
@@ -110,9 +110,9 @@ class Login extends React.Component<Props, State> {
</Logo>
{isCreate ? (
<Heading>Create an account</Heading>
<Heading centered>Create an account</Heading>
) : (
<Heading>Login to {config.name || "Outline"}</Heading>
<Heading centered>Login to {config.name || "Outline"}</Heading>
)}
<Notices notice={getQueryVariable("notice")} />
+7
View File
@@ -12,6 +12,7 @@ import { withRouter, Link } from "react-router-dom";
import type { RouterHistory, Match } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
@@ -387,6 +388,12 @@ const Filters = styled(Flex)`
margin-bottom: 12px;
opacity: 0.85;
transition: opacity 100ms ease-in-out;
overflow-y: auto;
padding: 8px 0;
${breakpoint("tablet")`
padding: 0;
`};
&:hover {
opacity: 1;
+24 -36
View File
@@ -4,7 +4,7 @@ import { observable, action, autorun, computed } from "mobx";
import { v4 } from "uuid";
import Collection from "models/Collection";
import Document from "models/Document";
import type { Toast } from "../types";
import type { Toast } from "types";
const UI_STORE = "UI_STORE";
@@ -14,8 +14,6 @@ class UiStore {
// systemTheme represents the system UI theme (Settings -> General in macOS)
@observable systemTheme: "light" | "dark";
@observable activeModalName: ?string;
@observable activeModalProps: ?Object;
@observable activeDocumentId: ?string;
@observable activeCollectionId: ?string;
@observable progressBarVisible: boolean = false;
@@ -34,16 +32,18 @@ class UiStore {
}
// system theme listeners
const colorSchemeQueryList = window.matchMedia(
"(prefers-color-scheme: dark)"
);
if (window.matchMedia) {
const colorSchemeQueryList = window.matchMedia(
"(prefers-color-scheme: dark)"
);
const setSystemTheme = (event) => {
this.systemTheme = event.matches ? "dark" : "light";
};
setSystemTheme(colorSchemeQueryList);
if (colorSchemeQueryList.addListener) {
colorSchemeQueryList.addListener(setSystemTheme);
const setSystemTheme = (event) => {
this.systemTheme = event.matches ? "dark" : "light";
};
setSystemTheme(colorSchemeQueryList);
if (colorSchemeQueryList.addListener) {
colorSchemeQueryList.addListener(setSystemTheme);
}
}
// persisted keys
@@ -68,18 +68,6 @@ class UiStore {
}
};
@action
setActiveModal = (name: string, props: ?Object): void => {
this.activeModalName = name;
this.activeModalProps = props;
};
@action
clearActiveModal = (): void => {
this.activeModalName = undefined;
this.activeModalProps = undefined;
};
@action
setActiveDocument = (document: Document): void => {
this.activeDocumentId = document.id;
@@ -121,34 +109,34 @@ class UiStore {
};
@action
enableEditMode() {
enableEditMode = () => {
this.editMode = true;
}
};
@action
disableEditMode() {
disableEditMode = () => {
this.editMode = false;
}
};
@action
enableProgressBar() {
enableProgressBar = () => {
this.progressBarVisible = true;
}
};
@action
disableProgressBar() {
disableProgressBar = () => {
this.progressBarVisible = false;
}
};
@action
toggleMobileSidebar() {
toggleMobileSidebar = () => {
this.mobileSidebarVisible = !this.mobileSidebarVisible;
}
};
@action
hideMobileSidebar() {
hideMobileSidebar = () => {
this.mobileSidebarVisible = false;
}
};
@action
showToast = (
+1 -2
View File
@@ -1,7 +1,6 @@
// @flow
import invariant from "invariant";
import { map, trim } from "lodash";
import pkg from "rich-markdown-editor/package.json";
import stores from "stores";
import download from "./download";
import {
@@ -57,7 +56,7 @@ class ApiClient {
Accept: "application/json",
"Content-Type": "application/json",
"cache-control": "no-cache",
"x-editor-version": pkg.version,
"x-editor-version": EDITOR_VERSION,
pragma: "no-cache",
});
if (stores.auth.authenticated) {
+25
View File
@@ -0,0 +1,25 @@
// @flow
let hidden = "hidden";
let visibilityChange = "visibilitychange";
if ("hidden" in document) {
hidden = "hidden";
visibilityChange = "visibilitychange";
} else if ("mozHidden" in document) {
// Firefox up to v17
hidden = "mozHidden";
visibilityChange = "mozvisibilitychange";
} else if ("webkitHidden" in document) {
// Chrome up to v32, Android up to v4.4, Blackberry up to v10
hidden = "webkitHidden";
visibilityChange = "webkitvisibilitychange";
}
export function getVisibilityListener(): string {
return visibilityChange;
}
export function getPageVisible(): boolean {
// $FlowFixMe
return !document[hidden];
}
+2
View File
@@ -4,3 +4,5 @@ declare var process: {
[string]: string,
},
};
declare var EDITOR_VERSION: string;
-119
View File
@@ -1,119 +0,0 @@
// flow-typed signature: d828559e8abc3863ee0f8ef88f5b646e
// flow-typed version: <<STUB>>/identity-obj-proxy_v^3.0.0/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'identity-obj-proxy'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'identity-obj-proxy' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'identity-obj-proxy/src/__tests__/import-es6-export-test' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/__tests__/import-es6-import-export-test' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/__tests__/import-es6-import-test' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/__tests__/import-vanilla-test' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/__tests__/index-test' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/__tests__/require-es6-export-test' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/__tests__/require-es6-import-export-test' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/__tests__/require-es6-import-test' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/__tests__/require-vanilla-test' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/test-redirections/idObjES6Export' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/test-redirections/idObjES6Import' {
declare module.exports: any;
}
declare module 'identity-obj-proxy/src/test-redirections/idObjES6ImportExport' {
declare module.exports: any;
}
// Filename aliases
declare module 'identity-obj-proxy/src/__tests__/import-es6-export-test.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/import-es6-export-test'>;
}
declare module 'identity-obj-proxy/src/__tests__/import-es6-import-export-test.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/import-es6-import-export-test'>;
}
declare module 'identity-obj-proxy/src/__tests__/import-es6-import-test.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/import-es6-import-test'>;
}
declare module 'identity-obj-proxy/src/__tests__/import-vanilla-test.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/import-vanilla-test'>;
}
declare module 'identity-obj-proxy/src/__tests__/index-test.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/index-test'>;
}
declare module 'identity-obj-proxy/src/__tests__/require-es6-export-test.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/require-es6-export-test'>;
}
declare module 'identity-obj-proxy/src/__tests__/require-es6-import-export-test.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/require-es6-import-export-test'>;
}
declare module 'identity-obj-proxy/src/__tests__/require-es6-import-test.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/require-es6-import-test'>;
}
declare module 'identity-obj-proxy/src/__tests__/require-vanilla-test.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/require-vanilla-test'>;
}
declare module 'identity-obj-proxy/src/index' {
declare module.exports: $Exports<'identity-obj-proxy/src'>;
}
declare module 'identity-obj-proxy/src/index.js' {
declare module.exports: $Exports<'identity-obj-proxy/src'>;
}
declare module 'identity-obj-proxy/src/test-redirections/idObjES6Export.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/test-redirections/idObjES6Export'>;
}
declare module 'identity-obj-proxy/src/test-redirections/idObjES6Import.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/test-redirections/idObjES6Import'>;
}
declare module 'identity-obj-proxy/src/test-redirections/idObjES6ImportExport.js' {
declare module.exports: $Exports<'identity-obj-proxy/src/test-redirections/idObjES6ImportExport'>;
}
-83
View File
@@ -1,83 +0,0 @@
// flow-typed signature: 90824cfc39ff764d3f06f9f71bdb6ef1
// flow-typed version: <<STUB>>/uglifyjs-webpack-plugin_v1.2.5/flow_v0.104.0
/**
* This is an autogenerated libdef stub for:
*
* 'uglifyjs-webpack-plugin'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'uglifyjs-webpack-plugin' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'uglifyjs-webpack-plugin/dist/cjs' {
declare module.exports: any;
}
declare module 'uglifyjs-webpack-plugin/dist' {
declare module.exports: any;
}
declare module 'uglifyjs-webpack-plugin/dist/uglify' {
declare module.exports: any;
}
declare module 'uglifyjs-webpack-plugin/dist/uglify/minify' {
declare module.exports: any;
}
declare module 'uglifyjs-webpack-plugin/dist/uglify/versions' {
declare module.exports: any;
}
declare module 'uglifyjs-webpack-plugin/dist/uglify/worker' {
declare module.exports: any;
}
declare module 'uglifyjs-webpack-plugin/dist/utils' {
declare module.exports: any;
}
// Filename aliases
declare module 'uglifyjs-webpack-plugin/dist/cjs.js' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/cjs'>;
}
declare module 'uglifyjs-webpack-plugin/dist/index' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist'>;
}
declare module 'uglifyjs-webpack-plugin/dist/index.js' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist'>;
}
declare module 'uglifyjs-webpack-plugin/dist/uglify/index' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify'>;
}
declare module 'uglifyjs-webpack-plugin/dist/uglify/index.js' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify'>;
}
declare module 'uglifyjs-webpack-plugin/dist/uglify/minify.js' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify/minify'>;
}
declare module 'uglifyjs-webpack-plugin/dist/uglify/versions.js' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify/versions'>;
}
declare module 'uglifyjs-webpack-plugin/dist/uglify/worker.js' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify/worker'>;
}
declare module 'uglifyjs-webpack-plugin/dist/utils/index' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/utils'>;
}
declare module 'uglifyjs-webpack-plugin/dist/utils/index.js' {
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/utils'>;
}
-74
View File
@@ -1,74 +0,0 @@
// @flow
require("./init");
if (
!process.env.SECRET_KEY ||
process.env.SECRET_KEY === "generate_a_new_key"
) {
console.error(
"The SECRET_KEY env variable must be set with the output of `openssl rand -hex 32`"
);
// $FlowFixMe
process.exit(1);
}
if (process.env.AWS_ACCESS_KEY_ID) {
[
"AWS_REGION",
"AWS_SECRET_ACCESS_KEY",
"AWS_S3_UPLOAD_BUCKET_URL",
"AWS_S3_UPLOAD_BUCKET_NAME",
"AWS_S3_UPLOAD_MAX_SIZE",
].forEach(key => {
if (!process.env[key]) {
console.error(`The ${key} env variable must be set when using AWS`);
// $FlowFixMe
process.exit(1);
}
});
}
if (process.env.SLACK_KEY) {
if (!process.env.SLACK_SECRET) {
console.error(
`The SLACK_SECRET env variable must be set when using Slack Sign In`
);
// $FlowFixMe
process.exit(1);
}
}
if (!process.env.URL) {
console.error(
"The URL env variable must be set to the externally accessible URL, e.g (https://www.getoutline.com)"
);
// $FlowFixMe
process.exit(1);
}
if (!process.env.DATABASE_URL) {
console.error(
"The DATABASE_URL env variable must be set to the location of your postgres server, including authentication and port"
);
// $FlowFixMe
process.exit(1);
}
if (!process.env.REDIS_URL) {
console.error(
"The REDIS_URL env variable must be set to the location of your redis server, including authentication and port"
);
// $FlowFixMe
process.exit(1);
}
if (process.env.NODE_ENV === "production") {
console.log("\n\x1b[33m%s\x1b[0m", "Running Outline in production mode.");
} else if (process.env.NODE_ENV === "development") {
console.log(
"\n\x1b[33m%s\x1b[0m",
'Running Outline in development mode with hot reloading. To run Outline in production mode set the NODE_ENV env variable to "production"'
);
}
require("./server");
-4
View File
@@ -1,4 +0,0 @@
// @flow
require("@babel/register");
require("@babel/polyfill");
require("dotenv").config({ silent: true });
+41 -39
View File
@@ -1,21 +1,22 @@
{
"name": "outline",
"private": true,
"license": "Business Source License 1.1",
"main": "index.js",
"scripts": {
"clean": "rimraf dist",
"build:webpack": "NODE_ENV=production webpack --config webpack.config.prod.js",
"build:analyze": "NODE_ENV=production webpack --config webpack.config.prod.js --json > stats.json",
"build": "npm run clean && npm run build:webpack",
"start": "NODE_ENV=production node index.js",
"dev": "NODE_ENV=development nodemon --watch server index.js",
"clean": "rimraf build",
"build:server": "babel -d ./build/server ./server && babel -d ./build/shared ./shared && cp package.json ./build && ln -sf \"$(pwd)/webpack.config.dev.js\" ./build",
"build:webpack": "webpack --config webpack.config.prod.js",
"build": "yarn clean && yarn build:webpack && yarn build:server",
"start": "node ./build/server/index.js",
"dev": "nodemon --exec \"yarn build:server && node build/server/index.js\" -e js --ignore build/",
"lint": "eslint app server shared",
"flow": "flow",
"deploy": "git push heroku master",
"heroku-postbuild": "npm run build && npm run sequelize:migrate",
"heroku-postbuild": "yarn build && yarn sequelize:migrate",
"sequelize:create-migration": "sequelize migration:create",
"sequelize:migrate": "sequelize db:migrate",
"test": "npm run test:app && npm run test:server",
"test": "yarn test:app && yarn test:server",
"test:app": "jest",
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
"test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll"
@@ -48,7 +49,7 @@
]
},
"engines": {
"node": ">= 12 <14"
"node": ">= 12 <15"
},
"repository": {
"type": "git",
@@ -56,14 +57,23 @@
},
"browserslist": "> 0.25%, not dead",
"dependencies": {
"@babel/core": "^7.11.1",
"@babel/plugin-proposal-decorators": "^7.10.5",
"@babel/plugin-transform-destructuring": "^7.10.4",
"@babel/plugin-transform-regenerator": "^7.10.4",
"@babel/polyfill": "^7.10.4",
"@babel/register": "^7.10.5",
"@babel/preset-env": "^7.11.0",
"@babel/preset-flow": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@rehooks/window-scroll-position": "^1.0.1",
"@sentry/node": "^5.12.2",
"@sentry/node": "^5.22.3",
"@tippy.js/react": "^2.2.2",
"@tommoor/remove-markdown": "0.3.1",
"autotrack": "^2.4.1",
"aws-sdk": "^2.135.0",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-styled-components": "^1.11.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"boundless-arrow-key-navigation": "^1.0.4",
"boundless-popover": "^1.0.4",
"bull": "^3.5.2",
@@ -80,7 +90,6 @@
"flow-typed": "^2.6.2",
"fs-extra": "^4.0.2",
"google-auth-library": "^5.5.1",
"html-webpack-plugin": "3.2.0",
"http-errors": "1.4.0",
"immutable": "^3.8.2",
"imports-loader": "0.6.5",
@@ -106,13 +115,13 @@
"koa-static": "^4.0.1",
"lodash": "^4.17.19",
"mobx": "4.6.0",
"mobx-react": "^5.4.2",
"mobx-react": "^6.2.5",
"natural-sort": "^1.0.0",
"nodemailer": "^4.4.0",
"outline-icons": "^1.21.0-6",
"oy-vey": "^0.10.0",
"pg": "^6.1.5",
"pg-hstore": "2.3.2",
"pg": "^8.3.0",
"pg-hstore": "^2.3.3",
"polished": "3.6.5",
"query-string": "^4.3.4",
"randomstring": "1.1.5",
@@ -129,11 +138,11 @@
"react-portal": "^4.0.0",
"react-router-dom": "^5.1.2",
"react-waypoint": "^9.0.2",
"rich-markdown-editor": "^10.5.0",
"rich-markdown-editor": "^10.6.5",
"semver": "^7.3.2",
"sequelize": "^5.21.1",
"sequelize-cli": "^5.5.0",
"sequelize-encrypted": "^0.1.0",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",
"sequelize-encrypted": "^1.0.0",
"slate": "0.45.0",
"slate-md-serializer": "5.5.4",
"slug": "^1.0.0",
@@ -146,27 +155,15 @@
"styled-normalize": "^8.0.4",
"tiny-cookie": "^2.3.1",
"tmp": "0.0.33",
"uglifyjs-webpack-plugin": "1.2.5",
"url-loader": "^0.6.2",
"uuid": "2.0.2",
"validator": "5.2.0",
"webpack": "3.10.0",
"webpack-manifest-plugin": "^1.3.2"
"validator": "5.2.0"
},
"devDependencies": {
"@babel/core": "^7.11.1",
"@babel/plugin-proposal-decorators": "^7.10.5",
"@babel/plugin-transform-destructuring": "^7.10.4",
"@babel/plugin-transform-regenerator": "^7.10.4",
"@babel/preset-env": "^7.11.0",
"@babel/preset-flow": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@babel/cli": "^7.10.5",
"@relative-ci/agent": "^1.3.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.2.2",
"babel-loader": "^8.1.0",
"babel-plugin-lodash": "^3.3.4",
"babel-plugin-styled-components": "^1.11.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"eslint": "^7.6.0",
"eslint-config-react-app": "3.0.6",
"eslint-plugin-flowtype": "^5.2.0",
@@ -174,20 +171,25 @@
"eslint-plugin-jsx-a11y": "^6.1.0",
"eslint-plugin-prettier": "^3.1.0",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-react-hooks": "^4.1.0",
"fetch-test-server": "^1.1.0",
"flow-bin": "^0.104.0",
"identity-obj-proxy": "^3.0.0",
"html-webpack-plugin": "3.2.0",
"jest-cli": "^26.0.0",
"koa-webpack-dev-middleware": "^1.4.5",
"koa-webpack-hot-middleware": "^1.0.3",
"mobx-react-devtools": "^6.0.3",
"nodemon": "^1.19.4",
"prettier": "^2.0.5",
"rimraf": "^2.5.4"
"rimraf": "^2.5.4",
"terser-webpack-plugin": "^4.1.0",
"url-loader": "^0.6.2",
"webpack": "4.44.1",
"webpack-cli": "^3.3.12",
"webpack-manifest-plugin": "^2.2.0"
},
"resolutions": {
"dot-prop": "^5.2.0",
"js-yaml": "^3.13.1"
},
"version": "0.45.0"
}
"version": "0.46.1"
}
+1 -2
View File
@@ -30,7 +30,7 @@ const { authorize } = policy;
const router = new Router();
router.post("collections.create", auth(), async (ctx) => {
const { name, color, description, icon, type } = ctx.body;
const { name, color, description, icon } = ctx.body;
const isPrivate = ctx.body.private;
ctx.assertPresent(name, "name is required");
@@ -46,7 +46,6 @@ router.post("collections.create", auth(), async (ctx) => {
description,
icon,
color,
type: type || "atlas",
teamId: user.teamId,
creatorId: user.id,
private: isPrivate,
-1
View File
@@ -1062,7 +1062,6 @@ describe("#collections.delete", () => {
urlId: "blah",
teamId: user.teamId,
creatorId: user.id,
type: "atlas",
});
const res = await server.post("/api/collections.delete", {
+2 -9
View File
@@ -718,7 +718,7 @@ router.post("documents.create", auth(), async (ctx) => {
authorize(user, "publish", collection);
let parentDocument;
if (parentDocumentId && collection.type === "atlas") {
if (parentDocumentId) {
parentDocument = await Document.findOne({
where: {
id: parentDocumentId,
@@ -938,13 +938,6 @@ router.post("documents.move", auth(), async (ctx) => {
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "move", document);
const { collection } = document;
if (collection.type !== "atlas" && parentDocumentId) {
throw new InvalidRequestError(
"Document cannot be nested in this collection type"
);
}
if (parentDocumentId) {
const parent = await Document.findByPk(parentDocumentId, {
userId: user.id,
@@ -1008,7 +1001,7 @@ router.post("documents.delete", auth(), async (ctx) => {
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "delete", document);
await document.delete();
await document.delete(user.id);
await Event.create({
name: "documents.delete",
+82 -65
View File
@@ -22,80 +22,97 @@ export default async function documentMover({
const result = { collections: [], documents: [] };
const collectionChanged = collectionId !== document.collectionId;
try {
transaction = await sequelize.transaction();
if (document.template) {
if (!collectionChanged) {
return result;
}
// remove from original collection
const collection = await document.getCollection({ transaction });
const documentJson = await collection.removeDocumentInStructure(document, {
save: false,
});
// if the collection is the same then it will get saved below, this
// line prevents a pointless intermediate save from occurring.
if (collectionChanged) await collection.save({ transaction });
// add to new collection (may be the same)
document.collectionId = collectionId;
document.parentDocumentId = parentDocumentId;
document.parentDocumentId = null;
const newCollection: Collection = collectionChanged
? await Collection.findByPk(collectionId, { transaction })
: collection;
await newCollection.addDocumentToStructure(document, index, {
documentJson,
});
result.collections.push(collection);
// if collection does not remain the same loop through children and change their
// collectionId too. This includes archived children, otherwise their collection
// would be wrong once restored.
if (collectionChanged) {
result.collections.push(newCollection);
const loopChildren = async (documentId) => {
const childDocuments = await Document.findAll({
where: { parentDocumentId: documentId },
});
await Promise.all(
childDocuments.map(async (child) => {
await loopChildren(child.id);
await child.update({ collectionId }, { transaction });
child.collection = newCollection;
result.documents.push(child);
})
);
};
await loopChildren(document.id);
}
await document.save({ transaction });
await document.save();
result.documents.push(document);
} else {
try {
transaction = await sequelize.transaction();
await transaction.commit();
// remove from original collection
const collection = await Collection.findByPk(document.collectionId, {
transaction,
});
const documentJson = await collection.removeDocumentInStructure(
document,
{
save: false,
}
);
await Event.create({
name: "documents.move",
actorId: user.id,
documentId: document.id,
collectionId,
teamId: document.teamId,
data: {
title: document.title,
collectionIds: result.collections.map((c) => c.id),
documentIds: result.documents.map((d) => d.id),
},
ip,
});
} catch (err) {
if (transaction) {
await transaction.rollback();
// if the collection is the same then it will get saved below, this
// line prevents a pointless intermediate save from occurring.
if (collectionChanged) await collection.save({ transaction });
// add to new collection (may be the same)
document.collectionId = collectionId;
document.parentDocumentId = parentDocumentId;
const newCollection: Collection = collectionChanged
? await Collection.findByPk(collectionId, { transaction })
: collection;
await newCollection.addDocumentToStructure(document, index, {
documentJson,
});
result.collections.push(collection);
// if collection does not remain the same loop through children and change their
// collectionId too. This includes archived children, otherwise their collection
// would be wrong once restored.
if (collectionChanged) {
result.collections.push(newCollection);
const loopChildren = async (documentId) => {
const childDocuments = await Document.findAll({
where: { parentDocumentId: documentId },
});
await Promise.all(
childDocuments.map(async (child) => {
await loopChildren(child.id);
await child.update({ collectionId }, { transaction });
child.collection = newCollection;
result.documents.push(child);
})
);
};
await loopChildren(document.id);
}
await document.save({ transaction });
result.documents.push(document);
await transaction.commit();
} catch (err) {
if (transaction) {
await transaction.rollback();
}
throw err;
}
throw err;
}
await Event.create({
name: "documents.move",
actorId: user.id,
documentId: document.id,
collectionId,
teamId: document.teamId,
data: {
title: document.title,
collectionIds: result.collections.map((c) => c.id),
documentIds: result.documents.map((d) => d.id),
},
ip,
});
// we need to send all updated models back to the client
return result;
}
+6 -1
View File
@@ -9,6 +9,11 @@
},
"production": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres"
"dialect": "postgres",
"dialectOptions": {
"ssl": {
"rejectUnauthorized": false
}
}
}
}
+62 -178
View File
@@ -1,189 +1,73 @@
// @flow
import http from "http";
import IO from "socket.io";
import socketRedisAdapter from "socket.io-redis";
import SocketAuth from "socketio-auth";
import app from "./app";
import { Document, Collection, View } from "./models";
import policy from "./policies";
import { client, subscriber } from "./redis";
import { getUserForJWT } from "./utils/jwt";
require("dotenv").config({ silent: true });
const server = http.createServer(app.callback());
let io;
if (
!process.env.SECRET_KEY ||
process.env.SECRET_KEY === "generate_a_new_key"
) {
console.error(
"The SECRET_KEY env variable must be set with the output of `openssl rand -hex 32`"
);
// $FlowFixMe
process.exit(1);
}
const { can } = policy;
io = IO(server, {
path: "/realtime",
serveClient: false,
cookie: false,
});
io.adapter(
socketRedisAdapter({
pubClient: client,
subClient: subscriber,
})
);
SocketAuth(io, {
authenticate: async (socket, data, callback) => {
const { token } = data;
try {
const user = await getUserForJWT(token);
socket.client.user = user;
// store the mapping between socket id and user id in redis
// so that it is accessible across multiple server nodes
await client.hset(socket.id, "userId", user.id);
return callback(null, true);
} catch (err) {
return callback(err);
if (process.env.AWS_ACCESS_KEY_ID) {
[
"AWS_REGION",
"AWS_SECRET_ACCESS_KEY",
"AWS_S3_UPLOAD_BUCKET_URL",
"AWS_S3_UPLOAD_MAX_SIZE",
].forEach((key) => {
if (!process.env[key]) {
console.error(`The ${key} env variable must be set when using AWS`);
// $FlowFixMe
process.exit(1);
}
},
postAuthenticate: async (socket, data) => {
const { user } = socket.client;
});
}
// the rooms associated with the current team
// and user so we can send authenticated events
let rooms = [`team-${user.teamId}`, `user-${user.id}`];
// the rooms associated with collections this user
// has access to on connection. New collection subscriptions
// are managed from the client as needed through the 'join' event
const collectionIds = await user.collectionIds();
collectionIds.forEach((collectionId) =>
rooms.push(`collection-${collectionId}`)
if (process.env.SLACK_KEY) {
if (!process.env.SLACK_SECRET) {
console.error(
`The SLACK_SECRET env variable must be set when using Slack Sign In`
);
// $FlowFixMe
process.exit(1);
}
}
// join all of the rooms at once
socket.join(rooms);
if (!process.env.URL) {
console.error(
"The URL env variable must be set to the externally accessible URL, e.g (https://www.getoutline.com)"
);
// $FlowFixMe
process.exit(1);
}
// allow the client to request to join rooms
socket.on("join", async (event) => {
// user is joining a collection channel, because their permissions have
// changed, granting them access.
if (event.collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
if (!process.env.DATABASE_URL) {
console.error(
"The DATABASE_URL env variable must be set to the location of your postgres server, including authentication and port"
);
// $FlowFixMe
process.exit(1);
}
if (can(user, "read", collection)) {
socket.join(`collection-${event.collectionId}`);
}
}
if (!process.env.REDIS_URL) {
console.error(
"The REDIS_URL env variable must be set to the location of your redis server, including authentication and port"
);
// $FlowFixMe
process.exit(1);
}
// user is joining a document channel, because they have navigated to
// view a document.
if (event.documentId) {
const document = await Document.findByPk(event.documentId, {
userId: user.id,
});
if (process.env.NODE_ENV === "production") {
console.log("\n\x1b[33m%s\x1b[0m", "Running Outline in production mode.");
} else if (process.env.NODE_ENV === "development") {
console.log(
"\n\x1b[33m%s\x1b[0m",
'Running Outline in development mode with hot reloading. To run Outline in production mode set the NODE_ENV env variable to "production"'
);
}
if (can(user, "read", document)) {
const room = `document-${event.documentId}`;
await View.touch(event.documentId, user.id, event.isEditing);
const editing = await View.findRecentlyEditingByDocument(
event.documentId
);
socket.join(room, () => {
// let everyone else in the room know that a new user joined
io.to(room).emit("user.join", {
userId: user.id,
documentId: event.documentId,
isEditing: event.isEditing,
});
// let this user know who else is already present in the room
io.in(room).clients(async (err, sockets) => {
if (err) throw err;
// because a single user can have multiple socket connections we
// need to make sure that only unique userIds are returned. A Map
// makes this easy.
let userIds = new Map();
for (const socketId of sockets) {
const userId = await client.hget(socketId, "userId");
userIds.set(userId, userId);
}
socket.emit("document.presence", {
documentId: event.documentId,
userIds: Array.from(userIds.keys()),
editingIds: editing.map((view) => view.userId),
});
});
});
}
}
});
// allow the client to request to leave rooms
socket.on("leave", (event) => {
if (event.collectionId) {
socket.leave(`collection-${event.collectionId}`);
}
if (event.documentId) {
const room = `document-${event.documentId}`;
socket.leave(room, () => {
io.to(room).emit("user.leave", {
userId: user.id,
documentId: event.documentId,
});
});
}
});
socket.on("disconnecting", () => {
const rooms = Object.keys(socket.rooms);
rooms.forEach((room) => {
if (room.startsWith("document-")) {
const documentId = room.replace("document-", "");
io.to(room).emit("user.leave", {
userId: user.id,
documentId,
});
}
});
});
socket.on("presence", async (event) => {
const room = `document-${event.documentId}`;
if (event.documentId && socket.rooms[room]) {
const view = await View.touch(
event.documentId,
user.id,
event.isEditing
);
view.user = user;
io.to(room).emit("user.presence", {
userId: user.id,
documentId: event.documentId,
isEditing: event.isEditing,
});
}
});
},
});
server.on("error", (err) => {
throw err;
});
server.on("listening", () => {
const address = server.address();
console.log(`\n> Listening on http://localhost:${address.port}\n`);
});
server.listen(process.env.PORT || "3000");
export const socketio = io;
export default server;
require("./main");
+189
View File
@@ -0,0 +1,189 @@
// @flow
import http from "http";
import IO from "socket.io";
import socketRedisAdapter from "socket.io-redis";
import SocketAuth from "socketio-auth";
import app from "./app";
import { Document, Collection, View } from "./models";
import policy from "./policies";
import { client, subscriber } from "./redis";
import { getUserForJWT } from "./utils/jwt";
const server = http.createServer(app.callback());
let io;
const { can } = policy;
io = IO(server, {
path: "/realtime",
serveClient: false,
cookie: false,
});
io.adapter(
socketRedisAdapter({
pubClient: client,
subClient: subscriber,
})
);
SocketAuth(io, {
authenticate: async (socket, data, callback) => {
const { token } = data;
try {
const user = await getUserForJWT(token);
socket.client.user = user;
// store the mapping between socket id and user id in redis
// so that it is accessible across multiple server nodes
await client.hset(socket.id, "userId", user.id);
return callback(null, true);
} catch (err) {
return callback(err);
}
},
postAuthenticate: async (socket, data) => {
const { user } = socket.client;
// the rooms associated with the current team
// and user so we can send authenticated events
let rooms = [`team-${user.teamId}`, `user-${user.id}`];
// the rooms associated with collections this user
// has access to on connection. New collection subscriptions
// are managed from the client as needed through the 'join' event
const collectionIds = await user.collectionIds();
collectionIds.forEach((collectionId) =>
rooms.push(`collection-${collectionId}`)
);
// join all of the rooms at once
socket.join(rooms);
// allow the client to request to join rooms
socket.on("join", async (event) => {
// user is joining a collection channel, because their permissions have
// changed, granting them access.
if (event.collectionId) {
const collection = await Collection.scope({
method: ["withMembership", user.id],
}).findByPk(event.collectionId);
if (can(user, "read", collection)) {
socket.join(`collection-${event.collectionId}`);
}
}
// user is joining a document channel, because they have navigated to
// view a document.
if (event.documentId) {
const document = await Document.findByPk(event.documentId, {
userId: user.id,
});
if (can(user, "read", document)) {
const room = `document-${event.documentId}`;
await View.touch(event.documentId, user.id, event.isEditing);
const editing = await View.findRecentlyEditingByDocument(
event.documentId
);
socket.join(room, () => {
// let everyone else in the room know that a new user joined
io.to(room).emit("user.join", {
userId: user.id,
documentId: event.documentId,
isEditing: event.isEditing,
});
// let this user know who else is already present in the room
io.in(room).clients(async (err, sockets) => {
if (err) throw err;
// because a single user can have multiple socket connections we
// need to make sure that only unique userIds are returned. A Map
// makes this easy.
let userIds = new Map();
for (const socketId of sockets) {
const userId = await client.hget(socketId, "userId");
userIds.set(userId, userId);
}
socket.emit("document.presence", {
documentId: event.documentId,
userIds: Array.from(userIds.keys()),
editingIds: editing.map((view) => view.userId),
});
});
});
}
}
});
// allow the client to request to leave rooms
socket.on("leave", (event) => {
if (event.collectionId) {
socket.leave(`collection-${event.collectionId}`);
}
if (event.documentId) {
const room = `document-${event.documentId}`;
socket.leave(room, () => {
io.to(room).emit("user.leave", {
userId: user.id,
documentId: event.documentId,
});
});
}
});
socket.on("disconnecting", () => {
const rooms = Object.keys(socket.rooms);
rooms.forEach((room) => {
if (room.startsWith("document-")) {
const documentId = room.replace("document-", "");
io.to(room).emit("user.leave", {
userId: user.id,
documentId,
});
}
});
});
socket.on("presence", async (event) => {
const room = `document-${event.documentId}`;
if (event.documentId && socket.rooms[room]) {
const view = await View.touch(
event.documentId,
user.id,
event.isEditing
);
view.user = user;
io.to(room).emit("user.presence", {
userId: user.id,
documentId: event.documentId,
isEditing: event.isEditing,
});
}
});
},
});
server.on("error", (err) => {
throw err;
});
server.on("listening", () => {
const address = server.address();
console.log(`\n> Listening on http://localhost:${address.port}\n`);
});
server.listen(process.env.PORT || "3000");
export const socketio = io;
export default server;
@@ -0,0 +1,14 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('collections', 'type');
},
down: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('collections', 'type', {
type: Sequelize.STRING,
defaultValue: "atlas"
});
}
};
+1 -1
View File
@@ -9,7 +9,7 @@ const Authentication = sequelize.define("authentication", {
},
service: DataTypes.STRING,
scopes: DataTypes.ARRAY(DataTypes.STRING),
token: encryptedFields.vault("token"),
token: encryptedFields().vault("token"),
});
Authentication.associate = (models) => {
+3 -7
View File
@@ -23,12 +23,6 @@ const Collection = sequelize.define(
color: DataTypes.STRING,
private: DataTypes.BOOLEAN,
maintainerApprovalRequired: DataTypes.BOOLEAN,
type: {
type: DataTypes.STRING,
validate: { isIn: [["atlas", "journal"]] },
},
/* type: atlas */
documentStructure: DataTypes.JSONB,
},
{
@@ -256,11 +250,13 @@ Collection.prototype.addDocumentToStructure = async function (
}
// Sequelize doesn't seem to set the value with splice on JSONB field
this.documentStructure = this.documentStructure;
// https://github.com/sequelize/sequelize/blob/e1446837196c07b8ff0c23359b958d68af40fd6d/src/model.js#L3937
this.changed("documentStructure", true);
if (options.save !== false) {
await this.save({
...options,
fields: ["documentStructure"],
transaction,
});
if (transaction) {
+23 -21
View File
@@ -441,7 +441,7 @@ Document.addHook("beforeSave", async (model) => {
}
const collection = await Collection.findByPk(model.collectionId);
if (!collection || collection.type !== "atlas") {
if (!collection) {
return;
}
@@ -455,7 +455,7 @@ Document.addHook("afterCreate", async (model) => {
}
const collection = await Collection.findByPk(model.collectionId);
if (!collection || collection.type !== "atlas") {
if (!collection) {
return;
}
@@ -548,8 +548,6 @@ Document.prototype.publish = async function (options) {
if (this.publishedAt) return this.save(options);
const collection = await Collection.findByPk(this.collectionId);
if (collection.type !== "atlas") return this.save(options);
await collection.addDocumentToStructure(this);
this.publishedAt = new Date();
@@ -572,7 +570,7 @@ Document.prototype.archive = async function (userId) {
};
// Restore an archived document back to being visible to the team
Document.prototype.unarchive = async function (userId) {
Document.prototype.unarchive = async function (userId: string) {
const collection = await this.getCollection();
// check to see if the documents parent hasn't been archived also
@@ -604,23 +602,27 @@ Document.prototype.unarchive = async function (userId) {
};
// Delete a document, archived or otherwise.
Document.prototype.delete = function (options) {
return sequelize.transaction(async (transaction: Transaction): Promise<*> => {
if (!this.archivedAt) {
// delete any children and remove from the document structure
const collection = await this.getCollection();
if (collection) await collection.deleteDocument(this, { transaction });
Document.prototype.delete = function (userId: string) {
return sequelize.transaction(
async (transaction: Transaction): Promise<Document> => {
if (!this.archivedAt && !this.template) {
// delete any children and remove from the document structure
const collection = await this.getCollection();
if (collection) await collection.deleteDocument(this, { transaction });
}
await Revision.destroy({
where: { documentId: this.id },
transaction,
});
this.lastModifiedById = userId;
this.deletedAt = new Date();
await this.save({ transaction });
return this;
}
await Revision.destroy({
where: { documentId: this.id },
transaction,
});
await this.destroy({ transaction, ...options });
return this;
});
);
};
Document.prototype.getTimestamp = function () {
+19 -1
View File
@@ -1,6 +1,11 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { Document } from "../models";
import { buildDocument, buildCollection, buildTeam } from "../test/factories";
import {
buildDocument,
buildCollection,
buildTeam,
buildUser,
} from "../test/factories";
import { flushdb } from "../test/support";
beforeEach(() => flushdb());
@@ -192,3 +197,16 @@ describe("#searchForTeam", () => {
expect(results.length).toBe(0);
});
});
describe("#delete", () => {
test("should soft delete and set last modified", async () => {
let document = await buildDocument();
let user = await buildUser();
await document.delete(user.id);
document = await Document.findByPk(document.id, { paranoid: false });
expect(document.lastModifiedById).toBe(user.id);
expect(document.deletedAt).toBeTruthy();
});
});
-1
View File
@@ -134,7 +134,6 @@ Team.prototype.provisionFirstCollection = async function (userId) {
name: "Welcome",
description:
"This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!",
type: "atlas",
teamId: this.id,
creatorId: userId,
});
+1 -1
View File
@@ -27,7 +27,7 @@ const User = sequelize.define(
service: { type: DataTypes.STRING, allowNull: true },
serviceId: { type: DataTypes.STRING, allowNull: true, unique: true },
slackData: DataTypes.JSONB,
jwtSecret: encryptedFields.vault("jwtSecret"),
jwtSecret: encryptedFields().vault("jwtSecret"),
lastActiveAt: DataTypes.DATE,
lastActiveIp: { type: DataTypes.STRING, allowNull: true },
lastSignedInAt: DataTypes.DATE,
+19 -5
View File
@@ -71,7 +71,21 @@ allow(User, "createChildDocument", Document, (user, document) => {
return user.teamId === document.teamId;
});
allow(User, ["move", "pin", "unpin"], Document, (user, document) => {
allow(User, "move", Document, (user, document) => {
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (!document.publishedAt) return false;
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
);
if (cannot(user, "update", document.collection)) return false;
return user.teamId === document.teamId;
});
allow(User, ["pin", "unpin"], Document, (user, document) => {
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (document.template) return false;
@@ -112,16 +126,16 @@ allow(User, "restore", Document, (user, document) => {
});
allow(User, "archive", Document, (user, document) => {
if (!document.publishedAt) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
invariant(
document.collection,
"collection is missing, did you forget to include in the query scope?"
);
if (cannot(user, "update", document.collection)) return false;
if (!document.publishedAt) return false;
if (document.archivedAt) return false;
if (document.deletedAt) return false;
return user.teamId === document.teamId;
});
+2 -5
View File
@@ -26,7 +26,6 @@ export default function present(collection: Collection) {
description: collection.description,
icon: collection.icon,
color: collection.color || "#4E5C6E",
type: collection.type,
private: collection.private,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
@@ -34,10 +33,8 @@ export default function present(collection: Collection) {
documents: undefined,
};
if (collection.type === "atlas") {
// Force alphabetical sorting
data.documents = sortDocuments(collection.documentStructure);
}
// Force alphabetical sorting
data.documents = sortDocuments(collection.documentStructure);
return data;
}
+7 -5
View File
@@ -72,11 +72,13 @@ export default async function present(document: Document, options: ?Options) {
data.updatedBy = presentUser(document.updatedBy);
// TODO: This could be further optimized
data.collaborators = await User.findAll({
where: {
id: takeRight(document.collaboratorIds, 10) || [],
},
}).map(presentUser);
data.collaborators = (
await User.findAll({
where: {
id: takeRight(document.collaboratorIds, 10) || [],
},
})
).map(presentUser);
}
return data;
+3 -6
View File
@@ -18,7 +18,7 @@ const readFile = util.promisify(fs.readFile);
const readIndexFile = async (ctx) => {
if (isProduction) {
return readFile(path.join(__dirname, "../dist/index.html"));
return readFile(path.join(__dirname, "../app/index.html"));
}
const middleware = ctx.devMiddleware;
@@ -39,7 +39,7 @@ const readIndexFile = async (ctx) => {
// serve static assets
koa.use(
serve(path.resolve(__dirname, "../public"), {
serve(path.resolve(__dirname, "../../public"), {
maxage: 60 * 60 * 24 * 30 * 1000,
})
);
@@ -52,10 +52,7 @@ if (process.env.NODE_ENV === "production") {
"Cache-Control": `max-age=${356 * 24 * 60 * 60}`,
});
await sendfile(
ctx,
path.join(__dirname, "../dist/", ctx.path.substring(8))
);
await sendfile(ctx, path.join(__dirname, "../app/", ctx.path.substring(8)));
});
}
+12 -5
View File
@@ -3,16 +3,23 @@ import debug from "debug";
import Sequelize from "sequelize";
import EncryptedField from "sequelize-encrypted";
export const encryptedFields = EncryptedField(
Sequelize,
process.env.SECRET_KEY
);
const isProduction = process.env.NODE_ENV === "production";
export const encryptedFields = () =>
EncryptedField(Sequelize, process.env.SECRET_KEY);
export const DataTypes = Sequelize;
export const Op = Sequelize.Op;
export const sequelize = new Sequelize(process.env.DATABASE_URL, {
// logging: console.log,
logging: debug("sql"),
typeValidation: true,
dialectOptions: {
ssl: isProduction
? {
// Ref.: https://github.com/brianc/node-postgres/issues/2009
rejectUnauthorized: false,
}
: false,
},
});
+1 -1
View File
@@ -1,8 +1,8 @@
// @flow
import { difference } from "lodash";
import parseDocumentIds from "../../shared/utils/parseDocumentIds";
import type { DocumentEvent } from "../events";
import { Document, Revision, Backlink } from "../models";
import parseDocumentIds from "../utils/parseDocumentIds";
import slugify from "../utils/slugify";
export default class Backlinks {
+1 -1
View File
@@ -1,7 +1,7 @@
// @flow
import subHours from "date-fns/sub_hours";
import { socketio } from "../";
import type { Event } from "../events";
import { socketio } from "../main";
import {
Document,
Collection,
+3 -2
View File
@@ -31,8 +31,8 @@
<body>
<div id="root"></div>
<script>//inject-env//</script>
<script src="https://browser.sentry-cdn.com/5.12.1/bundle.min.js"
integrity="sha384-y+an4eARFKvjzOivf/Z7JtMJhaN6b+lLQ5oFbBbUwZNNVir39cYtkjW1r6Xjbxg3" crossorigin="anonymous">
<script src="https://browser.sentry-cdn.com/5.22.3/bundle.min.js"
integrity="sha384-A1qzcXXJWl+bzYr+r8AdFzSaLbdcbYRFmG37MEDKr4EYjtraUyoZ6UiMw31jHcV9" crossorigin="anonymous">
</script>
<script>
if ('//inject-sentry-dsn//') {
@@ -44,6 +44,7 @@
'NotFoundError',
'OfflineError',
'UpdateRequiredError',
'ChunkLoadError'
],
});
}
-1
View File
@@ -85,7 +85,6 @@ export async function buildCollection(overrides: Object = {}) {
name: `Test Collection ${count}`,
description: "Test collection description",
creatorId: overrides.userId,
type: "atlas",
...overrides,
});
}
-1
View File
@@ -28,5 +28,4 @@ function runMigrations() {
runMigrations();
// This is needed for the relative manual mock to be picked up
// $FlowFixMe
jest.mock("../events");
+3 -2
View File
@@ -6,7 +6,9 @@ export function flushdb() {
const sql = sequelize.getQueryInterface();
const tables = Object.keys(sequelize.models).map((model) => {
const n = sequelize.models[model].getTableName();
return sql.quoteTable(typeof n === "string" ? n : n.tableName);
return sql.queryGenerator.quoteTable(
typeof n === "string" ? n : n.tableName
);
});
const query = `TRUNCATE ${tables.join(", ")} CASCADE`;
@@ -60,7 +62,6 @@ const seed = async () => {
urlId: "collection",
teamId: team.id,
creatorId: user.id,
type: "atlas",
});
const document = await Document.create({
@@ -37,14 +37,3 @@ export default function parseDocumentIds(text: string): string[] {
findLinks(value);
return links;
}
export function parseDocumentSlugFromUrl(url: string) {
let parsed;
try {
parsed = new URL(url);
} catch (err) {
return;
}
return parsed.pathname.replace(/^\/doc\//, "");
}
+1 -1
View File
@@ -16,7 +16,7 @@ const prefetchTags = [
try {
const manifest = fs.readFileSync(
path.join(__dirname, "../../dist/manifest.json"),
path.join(__dirname, "../../app/manifest.json"),
"utf8"
);
const manifestData = JSON.parse(manifest);
+12 -15
View File
@@ -4,18 +4,18 @@ import * as Sentry from "@sentry/node";
import AWS from "aws-sdk";
import addHours from "date-fns/add_hours";
import format from "date-fns/format";
import invariant from "invariant";
import fetch from "isomorphic-fetch";
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
const AWS_REGION = process.env.AWS_REGION;
const AWS_S3_UPLOAD_BUCKET_NAME = process.env.AWS_S3_UPLOAD_BUCKET_NAME;
const AWS_S3_UPLOAD_BUCKET_NAME = process.env.AWS_S3_UPLOAD_BUCKET_NAME || "";
const AWS_S3_FORCE_PATH_STYLE = process.env.AWS_S3_FORCE_PATH_STYLE !== "false";
const s3 = new AWS.S3({
s3ForcePathStyle: true,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
s3ForcePathStyle: AWS_S3_FORCE_PATH_STYLE,
accessKeyId: AWS_ACCESS_KEY_ID,
secretAccessKey: AWS_SECRET_ACCESS_KEY,
endpoint: new AWS.Endpoint(process.env.AWS_S3_UPLOAD_BUCKET_URL),
signatureVersion: "v4",
});
@@ -84,9 +84,9 @@ export const publicS3Endpoint = (isServerUpload?: boolean) => {
"localhost:"
).replace(/\/$/, "");
return `${host}/${isServerUpload && isDocker ? "s3/" : ""}${
process.env.AWS_S3_UPLOAD_BUCKET_NAME
}`;
return `${host}/${
isServerUpload && isDocker ? "s3/" : ""
}${AWS_S3_UPLOAD_BUCKET_NAME}`;
};
export const uploadToS3FromUrl = async (
@@ -94,8 +94,6 @@ export const uploadToS3FromUrl = async (
key: string,
acl: string
) => {
invariant(AWS_S3_UPLOAD_BUCKET_NAME, "AWS_S3_UPLOAD_BUCKET_NAME not set");
try {
// $FlowIssue https://github.com/facebook/flow/issues/2171
const res = await fetch(url);
@@ -103,7 +101,7 @@ export const uploadToS3FromUrl = async (
await s3
.putObject({
ACL: acl,
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
Key: key,
ContentType: res.headers["content-type"],
ContentLength: res.headers["content-length"],
@@ -126,18 +124,17 @@ export const uploadToS3FromUrl = async (
export const deleteFromS3 = (key: string) => {
return s3
.deleteObject({
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
Key: key,
})
.promise();
};
export const getSignedImageUrl = async (key: string) => {
invariant(AWS_S3_UPLOAD_BUCKET_NAME, "AWS_S3_UPLOAD_BUCKET_NAME not set");
const isDocker = process.env.AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/);
const params = {
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
Key: key,
Expires: 60,
};
@@ -149,7 +146,7 @@ export const getSignedImageUrl = async (key: string) => {
export const getImageByKey = async (key: string) => {
const params = {
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
Key: key,
};
+10
View File
@@ -101,6 +101,16 @@ export const base = {
desktop: 1025, // targeting devices that are larger than the iPad (which is 1024px in landscape mode)
desktopLarge: 1600,
},
depths: {
sidebar: 1000,
modalOverlay: 2000,
modal: 3000,
menu: 4000,
toasts: 5000,
loadingIndicatorBar: 6000,
popover: 9000,
},
};
export const light = {
+12
View File
@@ -0,0 +1,12 @@
// @flow
export function parseDocumentSlugFromUrl(url: string) {
let parsed;
try {
parsed = new URL(url);
} catch (err) {
return;
}
return parsed.pathname.replace(/^\/doc\//, "");
}

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