Compare commits

..

3 Commits

Author SHA1 Message Date
Tom Moor 2aaad03270 refactor 2020-08-20 19:05:48 -07:00
Tom Moor 9252683260 fix: SocketPresence account for socket changing 2020-08-20 00:06:51 -07:00
Tom Moor f5748eb5e7 check connection on page visibility change 2020-08-19 22:51:18 -07:00
263 changed files with 3227 additions and 8986 deletions
+3 -3
View File
@@ -3,7 +3,7 @@ jobs:
build:
working_directory: ~/outline
docker:
- image: circleci/node:14
- image: circleci/node:12
- image: circleci/redis:latest
- image: circleci/postgres:9.6.5-alpine-ram
environment:
@@ -39,5 +39,5 @@ jobs:
name: test
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
name: build
command: yarn build
-19
View File
@@ -1,19 +0,0 @@
__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 -2
View File
@@ -14,7 +14,7 @@ URL=http://localhost:3000
PORT=3000
# enforce (auto redirect to) https in production, (optional) default is true.
# set to false if your SSL is terminated at a loadbalancer, for example
# set to false if your SSL is terminated at a loadbalancer, for example
FORCE_HTTPS=true
ENABLE_UPDATES=true
@@ -45,7 +45,6 @@ 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
+1 -2
View File
@@ -4,8 +4,7 @@
"react-app",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:flowtype/recommended",
"plugin:react-hooks/recommended"
"plugin:flowtype/recommended"
],
"plugins": [
"prettier",
-4
View File
@@ -11,10 +11,6 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/@tommoor/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
-2
View File
@@ -1,5 +1,4 @@
dist
build
node_modules/*
server/scripts
.env
@@ -8,4 +7,3 @@ npm-debug.log
stats.json
.DS_Store
fakes3/*
.idea
+7 -13
View File
@@ -1,23 +1,17 @@
FROM node:14-alpine
FROM node:12-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
COPY package.json ./
COPY yarn.lock ./
RUN yarn install --pure-lockfile
RUN yarn build
RUN cp -r /opt/outline/node_modules /opt/node_modules
RUN yarn --pure-lockfile
COPY . .
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
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.49.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-10-26
Change Date: 2023-08-12
Change License: Apache License, Version 2.0
-6
View File
@@ -9,16 +9,10 @@ build:
test:
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test
watch:
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test:watch
destroy:
+1 -1
View File
@@ -1 +1 @@
web: node ./build/server/index.js
web: node index.js
+31 -38
View File
@@ -22,38 +22,13 @@ If you'd like to run your own copy of Outline or contribute to development then
Outline requires the following dependencies:
- [Node.js](https://nodejs.org/) >= 12
- [Yarn](https://yarnpkg.com)
- [Postgres](https://www.postgresql.org/download/) >=9.5
- [Redis](https://redis.io/) >= 4
- AWS S3 bucket or compatible API for file storage
- Node.js >= 12
- Postgres >=9.5
- Redis >= 4
- AWS S3 storage bucket for media and other attachments
- Slack or Google developer application for authentication
### Production
For a manual self-hosted production installation these are the suggested steps:
1. Clone this repo and install dependencies with `yarn install`
1. Build the source code with `yarn build`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate`. Production assumes an SSL connection, if
Postgres is on the same machine and is not SSL you can migrate with `yarn sequelize:migrate --env=production-ssl-disabled`.
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed using the `PORT` environment variable
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
### Development
In development you can quickly get an environment running using Docker by following these steps:
@@ -75,6 +50,32 @@ In development you can quickly get an environment running using Docker by follow
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
### Production
For a self-hosted production installation there is more flexibility, but these are the suggested steps:
1. Clone this repo and install dependencies with `yarn` or `npm install`
> Requires [Node.js](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed
1. Build the web app with `yarn build:webpack` or `npm run build:webpack`
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
1. `URL` (the public facing URL of your installation)
1. `AWS_` (all of the keys beginning with AWS)
1. Migrate database schema with `yarn sequelize:migrate` or `npm run sequelize:migrate `
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start index.js --name outline `
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
> Port number can be changed in the `.env` file
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
## Development
### Server
@@ -139,16 +140,8 @@ To add new tests, write your tests with [Jest](https://facebook.github.io/jest/)
```shell
# To run all tests
make test
yarn test
# To run backend tests in watch mode
make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
```shell
# To run backend tests
yarn test:server
-5
View File
@@ -92,11 +92,6 @@
"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",
+3 -8
View File
@@ -21,14 +21,9 @@ const Authenticated = observer(({ auth, children }: Props) => {
return <LoadingIndicator />;
}
// If we're authenticated but viewing a domain that doesn't match the
// current team then kick the user to the teams correct domain.
if (team.domain) {
if (team.domain !== hostname) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
} else if (
// If we're authenticated but viewing a subdomain that doesn't match the
// currently authenticated team then kick the user to the teams subdomain.
if (
env.SUBDOMAINS_ENABLED &&
team.subdomain &&
isCustomSubdomain(hostname) &&
+3 -4
View File
@@ -4,10 +4,9 @@ import styled from "styled-components";
const Badge = styled.span`
margin-left: 10px;
padding: 2px 6px 3px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
color: ${({ primary, yellow, theme }) =>
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
background-color: ${({ primary, theme }) =>
primary ? theme.primary : theme.textTertiary};
color: ${({ primary, theme }) => (primary ? theme.white : theme.background)};
border-radius: 4px;
font-size: 11px;
font-weight: 500;
+31 -74
View File
@@ -1,13 +1,11 @@
// @flow
import { observer, inject } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
PadlockIcon,
GoToIcon,
MoreIcon,
PadlockIcon,
ShapesIcon,
TrashIcon,
EditIcon,
} from "outline-icons";
import * as React from "react";
import { Link } from "react-router-dom";
@@ -27,73 +25,11 @@ type Props = {
onlyText: boolean,
};
function Icon({ document }) {
if (document.isDeleted) {
return (
<>
<CollectionName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>Trash</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CollectionName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>Archive</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>Templates</span>
</CollectionName>
<Slash />
</>
);
}
return null;
}
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
let collection = collections.get(document.collectionId);
if (!collection) {
if (!document.deletedAt) return <div />;
const collection = collections.get(document.collectionId);
if (!collection) return <div />;
collection = {
id: document.collectionId,
name: "Deleted Collection",
color: "currentColor",
};
}
const path = collection.pathToDocument
? collection.pathToDocument(document).slice(0, -1)
: [];
const path = collection.pathToDocument(document).slice(0, -1);
if (onlyText === true) {
return (
@@ -114,13 +50,34 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
);
}
const isTemplate = document.isTemplate;
const isDraft = !document.publishedAt && !isTemplate;
const isNestedDocument = path.length > 1;
const lastPath = path.length ? path[path.length - 1] : undefined;
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
return (
<Wrapper justify="flex-start" align="center">
<Icon document={document} />
{isTemplate && (
<>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>Templates</span>
</CollectionName>
<Slash />
</>
)}
{isDraft && (
<>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</>
)}
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
@@ -170,12 +127,12 @@ export const Slash = styled(GoToIcon)`
const Overflow = styled(MoreIcon)`
flex-shrink: 0;
opacity: 0.25;
transition: opacity 100ms ease-in-out;
fill: ${(props) => props.theme.divider};
&:active,
&:hover {
fill: ${(props) => props.theme.text};
&:hover,
&:active {
opacity: 1;
}
`;
+16 -13
View File
@@ -1,22 +1,25 @@
// @flow
import * as React from "react";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { Link } from "react-router-dom";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
type Props = {
label: React.Node,
path: Array<any>,
};
export default function BreadcrumbMenu({ label, path }: Props) {
return (
<DropdownMenu label={label} position="center">
<DropdownMenuItems
items={path.map((item) => ({
title: item.title,
to: item.url,
}))}
/>
</DropdownMenu>
);
export default class BreadcrumbMenu extends React.Component<Props> {
render() {
const { path } = this.props;
return (
<DropdownMenu label={this.props.label} position="center">
{path.map((item) => (
<DropdownMenuItem as={Link} to={item.url} key={item.id}>
{item.title}
</DropdownMenuItem>
))}
</DropdownMenu>
);
}
}
+22 -1
View File
@@ -1,6 +1,6 @@
// @flow
import { ExpandedIcon } from "outline-icons";
import { darken } from "polished";
import { darken, lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
@@ -19,6 +19,7 @@ const RealButton = styled.button`
height: 32px;
text-decoration: none;
flex-shrink: 0;
outline: none;
cursor: pointer;
user-select: none;
@@ -35,6 +36,13 @@ const RealButton = styled.button`
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
&:focus {
transition-duration: 0.05s;
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 3px;
outline: none;
}
&:disabled {
cursor: default;
pointer-events: none;
@@ -62,6 +70,13 @@ const RealButton = styled.button`
border: 1px solid ${props.theme.buttonNeutralBorder};
}
&:focus {
transition-duration: 0.05s;
border: 1px solid ${lighten(0.4, props.theme.buttonBackground)};
box-shadow: ${lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 2px;
}
&:disabled {
color: ${props.theme.textTertiary};
}
@@ -74,6 +89,12 @@ const RealButton = styled.button`
&:hover {
background: ${darken(0.05, props.theme.danger)};
}
&:focus {
transition-duration: 0.05s;
box-shadow: ${lighten(0.4, props.theme.danger)} 0px 0px
0px 3px;
}
`};
`;
+1 -1
View File
@@ -18,7 +18,7 @@ function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
// If the chosen icon color is very dark then we invert it in dark mode
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
ui.resolvedTheme === "dark"
? getLuminance(collection.color) > 0.12
? collection.color
: "currentColor"
+1 -1
View File
@@ -14,7 +14,7 @@ export default function DelayedMount({ delay = 250, children }: Props) {
return () => {
clearTimeout(timeout);
};
}, [delay]);
}, []);
if (!isShowing) {
return null;
@@ -1,23 +1,20 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { action, observable } from "mobx";
import { inject, observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import { observable, action } from "mobx";
import { observer, inject } from "mobx-react";
import * as React from "react";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { type RouterHistory, type 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";
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, documentUrl } from "utils/routeHelpers";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
match: Match,
@@ -32,7 +29,6 @@ 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();
@@ -90,34 +86,15 @@ 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} />
@@ -163,37 +140,10 @@ 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};
flex-shrink: 0;
`;
export default inject("documents", "revisions")(DocumentHistory);
@@ -11,12 +11,11 @@ import Avatar from "components/Avatar";
import Flex from "components/Flex";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { type Theme } from "types";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {
theme: Theme,
theme: Object,
showMenu: boolean,
selected: boolean,
document: Document,
@@ -37,7 +36,7 @@ class RevisionListItem extends React.Component<Props> {
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
<Time dateTime={revision.createdAt}>
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
</Time>
</Meta>
+8 -30
View File
@@ -18,7 +18,8 @@ const Container = styled(Flex)`
`;
const Modified = styled.span`
color: ${(props) => props.theme.textTertiary};
color: ${(props) =>
props.highlight ? props.theme.text : props.theme.textTertiary};
font-weight: ${(props) => (props.highlight ? "600" : "400")};
`;
@@ -27,7 +28,6 @@ type Props = {
auth: AuthStore,
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
document: Document,
children: React.Node,
to?: string,
@@ -38,7 +38,6 @@ function DocumentMeta({
collections,
showPublished,
showCollection,
showLastViewed,
document,
children,
to,
@@ -53,7 +52,6 @@ function DocumentMeta({
archivedAt,
deletedAt,
isDraft,
lastViewedAt,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -67,37 +65,37 @@ function DocumentMeta({
if (deletedAt) {
content = (
<span>
deleted <Time dateTime={deletedAt} addSuffix />
deleted <Time dateTime={deletedAt} /> ago
</span>
);
} else if (archivedAt) {
content = (
<span>
archived <Time dateTime={archivedAt} addSuffix />
archived <Time dateTime={archivedAt} /> ago
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
created <Time dateTime={updatedAt} addSuffix />
created <Time dateTime={updatedAt} /> ago
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = (
<span>
published <Time dateTime={publishedAt} addSuffix />
published <Time dateTime={publishedAt} /> ago
</span>
);
} else if (isDraft) {
content = (
<span>
saved <Time dateTime={updatedAt} addSuffix />
saved <Time dateTime={updatedAt} /> ago
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
updated <Time dateTime={updatedAt} addSuffix />
updated <Time dateTime={updatedAt} /> ago
</Modified>
);
}
@@ -105,25 +103,6 @@ function DocumentMeta({
const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
return null;
}
if (!lastViewedAt) {
return (
<>
&nbsp;<Modified highlight>Never viewed</Modified>
</>
);
}
return (
<span>
&nbsp;Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
</span>
);
};
return (
<Container align="center" {...rest}>
{updatedByMe ? "You" : updatedBy.name}&nbsp;
@@ -136,7 +115,6 @@ function DocumentMeta({
</strong>
</span>
)}
&nbsp;{timeSinceNow()}
{children}
</Container>
);
+9 -13
View File
@@ -1,31 +1,27 @@
// @flow
import { useObserver } from "mobx-react";
import { inject } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import ViewsStore from "stores/ViewsStore";
import Document from "models/Document";
import DocumentMeta from "components/DocumentMeta";
import useStores from "../hooks/useStores";
type Props = {|
views: ViewsStore,
document: Document,
isDraft: boolean,
to?: string,
|};
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
const { views } = useStores();
const documentViews = useObserver(() => views.inDocument(document.id));
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
function DocumentMetaWithViews({ views, to, isDraft, document }: Props) {
const totalViews = views.countForDocument(document.id);
return (
<Meta document={document} to={to}>
{totalViewers && !isDraft ? (
{totalViews && !isDraft ? (
<>
&nbsp;&middot; Viewed by{" "}
{onlyYou
? "only you"
: `${totalViewers} ${totalViewers === 1 ? "person" : "people"}`}
&nbsp;&middot; Viewed{" "}
{totalViews === 1 ? "once" : `${totalViews} times`}
</>
) : null}
</Meta>
@@ -49,4 +45,4 @@ const Meta = styled(DocumentMeta)`
}
`;
export default DocumentMetaWithViews;
export default inject("views")(DocumentMetaWithViews);
@@ -1,15 +1,13 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { StarredIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { Link, Redirect } from "react-router-dom";
import { Link, withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Badge from "components/Badge";
import Button from "components/Button";
import DocumentMeta from "components/DocumentMeta";
import EventBoundary from "components/EventBoundary";
import Flex from "components/Flex";
import Highlight from "components/Highlight";
import Tooltip from "components/Tooltip";
@@ -17,6 +15,7 @@ import DocumentMenu from "menus/DocumentMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
document: Document,
highlight?: ?string,
context?: ?string,
@@ -31,8 +30,6 @@ const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@observer
class DocumentPreview extends React.Component<Props> {
@observable redirectTo: ?string;
handleStar = (ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
@@ -51,15 +48,17 @@ class DocumentPreview extends React.Component<Props> {
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
};
handleNewFromTemplate = (event: SyntheticEvent<>) => {
handleNewFromTemplate = (event) => {
event.preventDefault();
event.stopPropagation();
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId, {
templateId: document.id,
});
this.props.history.push(
newDocumentUrl(document.collectionId, {
templateId: document.id,
})
);
};
render() {
@@ -74,10 +73,6 @@ class DocumentPreview extends React.Component<Props> {
context,
} = this.props;
if (this.redirectTo) {
return <Redirect to={this.redirectTo} push />;
}
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
@@ -91,7 +86,6 @@ class DocumentPreview extends React.Component<Props> {
>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && <Badge yellow>New</Badge>}
{!document.isDraft &&
!document.isArchived &&
!document.isTemplate && (
@@ -124,9 +118,7 @@ class DocumentPreview extends React.Component<Props> {
</Button>
)}
&nbsp;
<EventBoundary>
<DocumentMenu document={document} showPin={showPin} />
</EventBoundary>
<DocumentMenu document={document} showPin={showPin} />
</SecondaryActions>
</Heading>
@@ -141,7 +133,6 @@ class DocumentPreview extends React.Component<Props> {
document={document}
showCollection={showCollection}
showPublished={showPublished}
showLastViewed
/>
</DocumentLink>
);
@@ -190,6 +181,7 @@ const DocumentLink = styled(Link)`
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
outline: none;
${SecondaryActions} {
opacity: 1;
@@ -237,4 +229,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
export default DocumentPreview;
export default withRouter(DocumentPreview);
+6 -9
View File
@@ -7,8 +7,8 @@ import Dropzone from "react-dropzone";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import { createGlobalStyle } from "styled-components";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import LoadingIndicator from "components/LoadingIndicator";
import importFile from "utils/importFile";
const EMPTY_OBJECT = {};
let importingLock = false;
@@ -19,7 +19,6 @@ type Props = {
documentId?: string,
activeClassName?: string,
rejectClassName?: string,
ui: UiStore,
documents: DocumentsStore,
disabled: boolean,
location: Object,
@@ -62,19 +61,17 @@ class DropToImport extends React.Component<Props> {
}
for (const file of files) {
const doc = await this.props.documents.import(
const doc = await importFile({
documents: this.props.documents,
file,
documentId,
collectionId,
{ publish: true }
);
});
if (redirect) {
this.props.history.push(doc.url);
}
}
} catch (err) {
this.props.ui.showToast(`Could not import file. ${err.message}`);
} finally {
this.isImporting = false;
importingLock = false;
@@ -98,7 +95,7 @@ class DropToImport extends React.Component<Props> {
return (
<Dropzone
accept={documents.importFileTypes.join(", ")}
accept="text/markdown, text/plain"
onDropAccepted={this.onDropAccepted}
style={EMPTY_OBJECT}
disableClick
@@ -113,4 +110,4 @@ class DropToImport extends React.Component<Props> {
}
}
export default inject("documents", "ui")(withRouter(DropToImport));
export default inject("documents")(withRouter(DropToImport));
+2 -3
View File
@@ -18,7 +18,7 @@ type Children =
| React.Node
| ((options: { closePortal: () => void }) => React.Node);
type Props = {|
type Props = {
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
@@ -27,7 +27,7 @@ type Props = {|
hover?: boolean,
style?: Object,
position?: "left" | "right" | "center",
|};
};
@observer
class DropdownMenu extends React.Component<Props> {
@@ -177,7 +177,6 @@ class DropdownMenu extends React.Component<Props> {
{label || (
<NudeButton
id={`${this.id}button`}
aria-label="More options"
aria-haspopup="true"
aria-expanded={isOpen ? "true" : "false"}
aria-controls={this.id}
@@ -80,6 +80,7 @@ const MenuItem = styled.a`
&:focus {
color: ${props.theme.white};
background: ${props.theme.primary};
outline: none;
}
`};
`;
@@ -1,128 +0,0 @@
// @flow
import * as React from "react";
import { Link } from "react-router-dom";
import DropdownMenu from "./DropdownMenu";
import DropdownMenuItem from "./DropdownMenuItem";
type MenuItem =
| {|
title: React.Node,
to: string,
visible?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
onClick: (event: SyntheticEvent<>) => void | Promise<void>,
visible?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
href: string,
visible?: boolean,
disabled?: boolean,
|}
| {|
title: React.Node,
visible?: boolean,
disabled?: boolean,
style?: Object,
hover?: boolean,
items: MenuItem[],
|}
| {|
type: "separator",
visible?: boolean,
|}
| {|
type: "heading",
visible?: boolean,
title: React.Node,
|};
type Props = {|
items: MenuItem[],
|};
export default function DropdownMenuItems({ items }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) return acc;
if (item.type === "separator" && index === filtered.length - 1) return acc;
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator")
return acc;
// otherwise, continue
return [...acc, item];
}, []);
return filtered.map((item, index) => {
if (item.to) {
return (
<DropdownMenuItem
as={Link}
to={item.to}
key={index}
disabled={item.disabled}
>
{item.title}
</DropdownMenuItem>
);
}
if (item.href) {
return (
<DropdownMenuItem
href={item.href}
key={index}
disabled={item.disabled}
target="_blank"
>
{item.title}
</DropdownMenuItem>
);
}
if (item.onClick) {
return (
<DropdownMenuItem
onClick={item.onClick}
disabled={item.disabled}
key={index}
>
{item.title}
</DropdownMenuItem>
);
}
if (item.items) {
return (
<DropdownMenu
style={item.style}
label={
<DropdownMenuItem disabled={item.disabled}>
{item.title}
</DropdownMenuItem>
}
hover={item.hover}
key={index}
>
<DropdownMenuItems items={item.items} />
</DropdownMenu>
);
}
if (item.type === "separator") {
return <hr key={index} />;
}
return null;
});
}
+3 -77
View File
@@ -34,14 +34,14 @@ class Editor extends React.Component<PropsWithRef> {
return result.url;
};
onClickLink = (href: string, event: MouseEvent) => {
onClickLink = (href: string) => {
// on page hash
if (href[0] === "#") {
window.location.href = href;
return;
}
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
if (isInternalUrl(href)) {
// relative
let navigateTo = href;
@@ -56,7 +56,7 @@ class Editor extends React.Component<PropsWithRef> {
}
this.props.history.push(navigateTo);
} else if (href) {
} else {
window.open(href, "_blank");
}
};
@@ -97,66 +97,6 @@ const StyledEditor = styled(RichMarkdownEditor)`
font-weight: 500;
}
.ProseMirror {
.ProseMirror-yjs-cursor {
position: relative;
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid black;
border-right: 1px solid black;
height: 1em;
word-break: normal;
&:after {
content: "";
display: block;
position: absolute;
left: -8px;
right: -8px;
top: 0;
bottom: 0;
}
> div {
opacity: 0;
position: absolute;
top: -1.8em;
font-size: 13px;
background-color: rgb(250, 129, 0);
font-style: normal;
line-height: normal;
user-select: none;
white-space: nowrap;
color: white;
padding: 2px 6px;
font-weight: 500;
border-radius: 4px;
pointer-events: none;
left: -1px;
}
&:hover {
> div {
opacity: 1;
transition: opacity 100ms ease-in-out;
}
}
}
}
.heading-name {
pointer-events: none;
}
.heading-name:first-child {
& + h1,
& + h2,
& + h3,
& + h4 {
margin-top: 0;
}
}
p {
a {
color: ${(props) => props.theme.text};
@@ -187,17 +127,3 @@ const EditorWithRouterAndTheme = withRouter(withTheme(Editor));
export default React.forwardRef<Props, typeof Editor>((props, ref) => (
<EditorWithRouterAndTheme {...props} forwardedRef={ref} />
));
// > .ProseMirror-yjs-cursor:first-child {
// margin-top: 16px;
// }
// p:first-child,
// h1:first-child,
// h2:first-child,
// h3:first-child,
// h4:first-child,
// h5:first-child,
// h6:first-child {
// margin-top: 16px;
// }
+1 -20
View File
@@ -55,26 +55,7 @@ class ErrorBoundary extends React.Component<Props> {
render() {
if (this.error) {
const error = this.error;
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
return (
<CenteredContent>
<PageTitle title="Module failed to load" />
<h1>Loading Failed</h1>
<HelpText>
Sorry, part of the application failed to load. This may be because
it was updated since you opened the tab or because of a failed
network request. Please try reloading.
</HelpText>
<p>
<Button onClick={this.handleReload}>Reload</Button>
</p>
</CenteredContent>
);
}
return (
<CenteredContent>
@@ -85,7 +66,7 @@ class ErrorBoundary extends React.Component<Props> {
{isReported && " our engineers have been notified"}. Please try
reloading the page, it may have been a temporary glitch.
</HelpText>
{this.showDetails && <Pre>{error.toString()}</Pre>}
{this.showDetails && <Pre>{this.error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>Reload</Button>{" "}
{this.showDetails ? (
-16
View File
@@ -1,16 +0,0 @@
// @flow
import * as React from "react";
type Props = {
children: React.Node,
};
export default function EventBoundary({ children }: Props) {
const handleClick = React.useCallback((event: SyntheticEvent<>) => {
event.preventDefault();
event.stopPropagation();
}, []);
return <span onClick={handleClick}>{children}</span>;
}
+3 -6
View File
@@ -18,7 +18,6 @@ function Highlight({
...rest
}: Props) {
let regex;
let index = 0;
if (highlight instanceof RegExp) {
regex = highlight;
} else {
@@ -30,10 +29,8 @@ function Highlight({
return (
<span {...rest}>
{highlight
? replace(text, regex, (tag) => (
<Mark key={index++}>
{processResult ? processResult(tag) : tag}
</Mark>
? replace(text, regex, (tag, index) => (
<Mark key={index}>{processResult ? processResult(tag) : tag}</Mark>
))
: text}
</span>
@@ -41,7 +38,7 @@ function Highlight({
}
const Mark = styled.mark`
background: ${(props) => props.theme.searchHighlight};
background: ${(props) => props.theme.yellow};
border-radius: 2px;
padding: 0 4px;
`;
+8 -13
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 parseDocumentSlug from "shared/utils/parseDocumentSlug";
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import HoverPreviewDocument from "components/HoverPreviewDocument";
import isInternalUrl from "utils/isInternalUrl";
@@ -20,8 +20,13 @@ type Props = {
onClose: () => void,
};
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
const slug = parseDocumentSlug(node.href);
function HoverPreview({ node, documents, onClose, event }: Props) {
// previews only work for internal doc links for now
if (!isInternalUrl(node.href)) {
return null;
}
const slug = parseDocumentSlugFromUrl(node.href);
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef();
@@ -126,15 +131,6 @@ function HoverPreviewInternal({ 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;
@@ -215,7 +211,6 @@ const Pointer = styled.div`
height: 22px;
position: absolute;
transform: translateX(-50%);
pointer-events: none;
&:before,
&:after {
+2 -2
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 parseDocumentSlug from "shared/utils/parseDocumentSlug";
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
import DocumentsStore from "stores/DocumentsStore";
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
import Editor from "components/Editor";
@@ -15,7 +15,7 @@ type Props = {
};
function HoverPreviewDocument({ url, documents, children }: Props) {
const slug = parseDocumentSlug(url);
const slug = parseDocumentSlugFromUrl(url);
documents.prefetchDocument(slug, {
prefetch: true,
+4
View File
@@ -33,6 +33,10 @@ const RealInput = styled.input`
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
&::-webkit-search-cancel-button {
-webkit-appearance: searchfield-cancel-button;
}
`;
const Wrapper = styled.div`
+2 -7
View File
@@ -7,13 +7,11 @@ import keydown from "react-keydown";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Input from "./Input";
import { type Theme } from "types";
import { searchUrl } from "utils/routeHelpers";
type Props = {
history: RouterHistory,
theme: Theme,
source: string,
theme: Object,
placeholder?: string,
collectionId?: string,
};
@@ -35,10 +33,7 @@ class InputSearch extends React.Component<Props> {
handleSearchInput = (ev) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
searchUrl(ev.target.value, this.props.collectionId)
);
};
+6 -8
View File
@@ -21,7 +21,6 @@ import { LoadingIndicatorBar } from "components/LoadingIndicator";
import Modal from "components/Modal";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import { type Theme } from "types";
import {
homeUrl,
searchUrl,
@@ -36,7 +35,7 @@ type Props = {
auth: AuthStore,
ui: UiStore,
notifications?: React.Node,
theme: Theme,
theme: Object,
};
@observer
@@ -45,22 +44,21 @@ class Layout extends React.Component<Props> {
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
constructor(props) {
super();
this.updateBackground(props);
componentWillMount() {
this.updateBackground();
}
componentDidUpdate() {
this.updateBackground(this.props);
this.updateBackground();
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
updateBackground(props) {
updateBackground() {
// ensure the wider page color always matches the theme
window.document.body.style.background = props.theme.background;
window.document.body.style.background = this.props.theme.background;
}
@keydown("shift+/")
+1 -2
View File
@@ -17,8 +17,7 @@ class Mask extends React.Component<Props> {
return false;
}
constructor() {
super();
componentWillMount() {
this.width = randomInteger(75, 100);
}
+16 -24
View File
@@ -9,7 +9,6 @@ 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");
@@ -28,8 +27,7 @@ const GlobalStyles = createGlobalStyle`
}
${breakpoint("tablet")`
.ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 12px;
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
@@ -38,15 +36,13 @@ const GlobalStyles = createGlobalStyle`
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 24px;
}
}
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
.ReactModal__Overlay {
margin-left: 36px;
}
@@ -76,11 +72,10 @@ const Modal = ({
isOpen={isOpen}
{...rest}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
<Content onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Content>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
@@ -94,20 +89,10 @@ const Modal = ({
);
};
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 2rem 2rem;
${breakpoint("tablet")`
padding-top: 13vh;
`};
`;
const Centered = styled(Flex)`
const Content = styled(Flex)`
width: 640px;
max-width: 100%;
position: relative;
margin: 0 auto;
`;
const StyledModal = styled(ReactModal)`
@@ -122,9 +107,16 @@ const StyledModal = styled(ReactModal)`
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`
@@ -155,7 +147,7 @@ const Close = styled(NudeButton)`
`;
const Back = styled(NudeButton)`
position: absolute;
position: fixed;
display: none;
align-items: center;
top: 2rem;
+8
View File
@@ -1,4 +1,5 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import styled from "styled-components";
@@ -10,6 +11,13 @@ const Button = styled.button`
line-height: 0;
border: 0;
padding: 0;
&:focus {
transition-duration: 0.05s;
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
0px 3px;
outline: none;
}
`;
export default React.forwardRef<any, typeof Button>((props, ref) => (
-4
View File
@@ -1,6 +1,5 @@
// @flow
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -41,9 +40,6 @@ class PaginatedList extends React.Component<Props> {
if (prevProps.fetch !== this.props.fetch) {
this.fetchResults();
}
if (!isEqual(prevProps.options, this.props.options)) {
this.fetchResults();
}
}
fetchResults = async () => {
+8 -32
View File
@@ -14,7 +14,6 @@ type Props = {
document?: ?Document,
collection: ?Collection,
onSuccess?: () => void,
style?: Object,
ref?: (?React.ElementRef<"div">) => void,
};
@@ -35,38 +34,28 @@ class PathToDocument extends React.Component<Props> {
};
render() {
const { result, collection, document, ref, style } = this.props;
const { result, collection, document, ref } = this.props;
const Component = document ? ResultWrapperLink : ResultWrapper;
if (!result) return <div />;
return (
<Component
ref={ref}
onClick={this.handleClick}
href=""
style={style}
role="option"
selectable
>
<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 && (
<DocumentTitle>
<Flex>
{" "}
<StyledGoToIcon /> <Title>{document.title}</Title>
</DocumentTitle>
</Flex>
)}
</Component>
);
}
}
const DocumentTitle = styled(Flex)``;
const Title = styled.span`
white-space: nowrap;
overflow: hidden;
@@ -74,42 +63,29 @@ const Title = styled.span`
`;
const StyledGoToIcon = styled(GoToIcon)`
fill: ${(props) => props.theme.divider};
opacity: 0.25;
`;
const ResultWrapper = styled.div`
display: flex;
margin-bottom: 10px;
margin-left: -4px;
user-select: none;
color: ${(props) => props.theme.text};
cursor: default;
svg {
flex-shrink: 0;
}
`;
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
margin: 0 -8px;
padding: 8px 4px;
${DocumentTitle} {
display: none;
}
svg {
flex-shrink: 0;
}
border-radius: 8px;
&:hover,
&:active,
&:focus {
background: ${(props) => props.theme.listItemHoverBackground};
outline: none;
${DocumentTitle} {
display: flex;
}
}
`;
+3 -2
View File
@@ -69,6 +69,7 @@ class MainSidebar extends React.Component<Props> {
const { user, team } = auth;
if (!user || !team) return null;
const draftDocumentsCount = documents.drafts.length;
const can = policies.abilities(team.id);
return (
@@ -122,8 +123,8 @@ class MainSidebar extends React.Component<Props> {
label={
<Drafts align="center">
Drafts
{documents.totalDrafts > 0 && (
<Bubble count={documents.totalDrafts} />
{draftDocumentsCount > 0 && (
<Bubble count={draftDocumentsCount} />
)}
</Drafts>
}
+14 -10
View File
@@ -10,6 +10,7 @@ import {
GroupIcon,
LinkIcon,
TeamIcon,
BulletedListIcon,
ExpandedIcon,
} from "outline-icons";
import * as React from "react";
@@ -30,8 +31,6 @@ import SlackIcon from "./icons/Slack";
import ZapierIcon from "./icons/Zapier";
import env from "env";
const isHosted = env.DEPLOYMENT === "hosted";
type Props = {
history: RouterHistory,
policies: PoliciesStore,
@@ -117,6 +116,13 @@ class SettingsSidebar extends React.Component<Props> {
icon={<LinkIcon color="currentColor" />}
label="Share Links"
/>
{can.auditLog && (
<SidebarLink
to="/settings/events"
icon={<BulletedListIcon color="currentColor" />}
label="Audit Log"
/>
)}
{can.export && (
<SidebarLink
to="/settings/export"
@@ -133,16 +139,14 @@ class SettingsSidebar extends React.Component<Props> {
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
)}
<SidebarLink
to="/settings/integrations/zapier"
icon={<ZapierIcon color="currentColor" />}
label="Zapier"
/>
</Section>
)}
{can.update && !isHosted && (
{can.update && env.DEPLOYMENT !== "hosted" && (
<Section>
<Header>Installation</Header>
<Version />
+40 -35
View File
@@ -1,60 +1,65 @@
// @flow
import { observer } from "mobx-react";
import { observer, inject } 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,
};
function Sidebar({ location, children }: Props) {
const { ui } = useStores();
const previousLocation = usePrevious(location);
React.useEffect(() => {
if (location !== previousLocation) {
ui.hideMobileSidebar();
@observer
class Sidebar extends React.Component<Props> {
componentWillReceiveProps = (nextProps: Props) => {
if (this.props.location !== nextProps.location) {
this.props.ui.hideMobileSidebar();
}
}, [ui, location]);
};
const content = (
<Container
editMode={ui.editMode}
mobileSidebarVisible={ui.mobileSidebarVisible}
column
>
<Toggle
onClick={ui.toggleMobileSidebar}
toggleSidebar = () => {
this.props.ui.toggleMobileSidebar();
};
render() {
const { children, ui } = this.props;
const content = (
<Container
editMode={ui.editMode}
mobileSidebarVisible={ui.mobileSidebarVisible}
column
>
{ui.mobileSidebarVisible ? (
<CloseIcon size={32} />
) : (
<MenuIcon size={32} />
)}
</Toggle>
{children}
</Container>
);
<Toggle
onClick={this.toggleSidebar}
mobileSidebarVisible={ui.mobileSidebarVisible}
>
{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>;
// Fade in the sidebar on first render after page load
if (firstRender) {
firstRender = false;
return <Fade>{content}</Fade>;
}
return content;
}
return content;
}
const Container = styled(Flex)`
@@ -112,4 +117,4 @@ const Toggle = styled.a`
`};
`;
export default withRouter(observer(Sidebar));
export default withRouter(inject("ui")(Sidebar));
@@ -10,34 +10,27 @@ import CollectionIcon from "components/CollectionIcon";
import DropToImport from "components/DropToImport";
import Flex from "components/Flex";
import DocumentLink from "./DocumentLink";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import CollectionMenu from "menus/CollectionMenu";
type Props = {|
type Props = {
collection: Collection,
ui: UiStore,
canUpdate: boolean,
documents: DocumentsStore,
activeDocument: ?Document,
prefetchDocument: (id: string) => Promise<void>,
|};
};
@observer
class CollectionLink extends React.Component<Props> {
@observable menuOpen = false;
handleTitleChange = async (name: string) => {
await this.props.collection.save({ name });
};
render() {
const {
collection,
documents,
activeDocument,
prefetchDocument,
canUpdate,
ui,
} = this.props;
const expanded = collection.id === ui.activeCollectionId;
@@ -56,13 +49,7 @@ class CollectionLink extends React.Component<Props> {
expanded={expanded}
hideDisclosure
menuOpen={this.menuOpen}
label={
<EditableTitle
title={collection.name}
onSubmit={this.handleTitleChange}
canUpdate={canUpdate}
/>
}
label={collection.name}
exact={false}
menu={
<CollectionMenu
@@ -82,7 +69,6 @@ class CollectionLink extends React.Component<Props> {
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={1.5}
/>
))}
@@ -52,7 +52,7 @@ class Collections extends React.Component<Props> {
}
render() {
const { collections, ui, policies, documents } = this.props;
const { collections, ui, documents } = this.props;
const content = (
<>
@@ -63,7 +63,6 @@ class Collections extends React.Component<Props> {
collection={collection}
activeDocument={documents.active}
prefetchDocument={documents.prefetchDocument}
canUpdate={policies.abilities(collection.id).update}
ui={ui}
/>
))}
@@ -9,21 +9,19 @@ import Document from "models/Document";
import DropToImport from "components/DropToImport";
import Fade from "components/Fade";
import Flex from "components/Flex";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import DocumentMenu from "menus/DocumentMenu";
import { type NavigationNode } from "types";
type Props = {|
type Props = {
node: NavigationNode,
documents: DocumentsStore,
canUpdate: boolean,
collection?: Collection,
activeDocument: ?Document,
activeDocumentRef?: (?HTMLElement) => void,
prefetchDocument: (documentId: string) => Promise<void>,
depth: number,
|};
};
@observer
class DocumentLink extends React.Component<Props> {
@@ -51,18 +49,6 @@ class DocumentLink extends React.Component<Props> {
prefetchDocument(node.id);
};
handleTitleChange = async (title: string) => {
const document = this.props.documents.get(this.props.node.id);
if (!document) return;
await this.props.documents.update({
id: document.id,
lastRevision: document.revision,
text: document.text,
title,
});
};
isActiveDocument = () => {
return (
this.props.activeDocument &&
@@ -83,7 +69,6 @@ class DocumentLink extends React.Component<Props> {
activeDocumentRef,
prefetchDocument,
depth,
canUpdate,
} = this.props;
const showChildren = !!(
@@ -96,7 +81,6 @@ class DocumentLink extends React.Component<Props> {
this.isActiveDocument())
);
const document = documents.get(node.id);
const title = node.title || "Untitled";
return (
<Flex
@@ -112,13 +96,7 @@ class DocumentLink extends React.Component<Props> {
state: { title: node.title },
}}
expanded={showChildren ? true : undefined}
label={
<EditableTitle
title={title}
onSubmit={this.handleTitleChange}
canUpdate={canUpdate}
/>
}
label={node.title || "Untitled"}
depth={depth}
exact={false}
menuOpen={this.menuOpen}
@@ -146,7 +124,6 @@ class DocumentLink extends React.Component<Props> {
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
depth={depth + 1}
canUpdate={canUpdate}
/>
))}
</DocumentChildren>
@@ -1,98 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import useStores from "hooks/useStores";
type Props = {|
onSubmit: (title: string) => Promise<void>,
title: string,
canUpdate: boolean,
|};
function EditableTitle({ title, onSubmit, canUpdate }: Props) {
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
const { ui } = useStores();
React.useEffect(() => {
setValue(title);
}, [title]);
const handleChange = React.useCallback((event) => {
setValue(event.target.value);
}, []);
const handleDoubleClick = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
setIsEditing(true);
}, []);
const handleKeyDown = React.useCallback(
(event) => {
if (event.key === "Escape") {
setIsEditing(false);
setValue(originalValue);
}
},
[originalValue]
);
const handleSave = React.useCallback(async () => {
setIsEditing(false);
if (value === originalValue) {
return;
}
if (document) {
try {
await onSubmit(value);
setOriginalValue(value);
} catch (error) {
setValue(originalValue);
ui.showToast(error.message);
throw error;
}
}
}, [ui, originalValue, value, onSubmit]);
return (
<>
{isEditing ? (
<form onSubmit={handleSave}>
<Input
type="text"
value={value}
onKeyDown={handleKeyDown}
onChange={handleChange}
onBlur={handleSave}
autoFocus
/>
</form>
) : (
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
{value}
</span>
)}
</>
);
}
const Input = styled.input`
margin-left: -4px;
background: ${(props) => props.theme.background};
width: calc(100% - 10px);
border-radius: 3px;
border: 1px solid ${(props) => props.theme.inputBorderFocused};
padding: 5px 6px;
margin: -4px;
height: 32px;
&:focus {
outline-color: ${(props) => props.theme.primary};
}
`;
export default EditableTitle;
@@ -21,7 +21,7 @@ function HeaderBlock({
}: Props) {
return (
<Header justify="flex-start" align="center" {...rest}>
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
<TeamLogo alt={`${teamName} logo`} src={logoUrl} />
<Flex align="flex-start" column>
<TeamName showDisclosure>
{teamName}{" "}
@@ -57,16 +57,10 @@ const TeamName = styled.div`
font-size: 16px;
`;
const Header = styled.button`
display: flex;
align-items: center;
const Header = styled(Flex)`
flex-shrink: 0;
padding: 16px 24px;
position: relative;
background: none;
line-height: inherit;
border: 0;
margin: 0;
cursor: pointer;
width: 100%;
@@ -1,11 +1,11 @@
// @flow
import { observable, action } from "mobx";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { withRouter, NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Flex from "components/Flex";
import { type Theme } from "types";
type Props = {
to?: string | Object,
@@ -20,85 +20,84 @@ type Props = {
hideDisclosure?: boolean,
iconColor?: string,
active?: boolean,
theme: Theme,
theme: Object,
exact?: boolean,
depth?: number,
};
function SidebarLink({
icon,
children,
onClick,
to,
label,
active,
menu,
menuOpen,
hideDisclosure,
theme,
exact,
href,
depth,
...rest
}: Props) {
const [expanded, setExpanded] = React.useState(rest.expanded);
@observer
class SidebarLink extends React.Component<Props> {
@observable expanded: ?boolean = this.props.expanded;
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
};
}, [depth]);
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,
style = {
paddingLeft: `${(this.props.depth || 0) * 16 + 16}px`,
};
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>
);
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,
};
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>
);
}
}
// accounts for whitespace around icon
@@ -144,6 +143,7 @@ const StyledNavLink = styled(NavLink)`
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.black05};
outline: none;
}
&:hover {
@@ -171,4 +171,4 @@ const Disclosure = styled(CollapsedIcon)`
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default withRouter(withTheme(observer(SidebarLink)));
export default withRouter(withTheme(SidebarLink));
+1 -18
View File
@@ -125,8 +125,6 @@ class SocketProvider extends React.Component<Props> {
if (document) {
document.deletedAt = documentDescriptor.updatedAt;
}
policies.remove(documentId);
continue;
}
@@ -174,21 +172,7 @@ class SocketProvider extends React.Component<Props> {
const collection = collections.get(collectionId) || {};
if (event.event === "collections.delete") {
const collection = collections.get(collectionId);
if (collection) {
collection.deletedAt = collectionDescriptor.updatedAt;
}
const deletedDocuments = documents.inCollection(collectionId);
deletedDocuments.forEach((doc) => {
doc.deletedAt = collectionDescriptor.updatedAt;
policies.remove(doc.id);
});
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
continue;
}
@@ -203,10 +187,9 @@ class SocketProvider extends React.Component<Props> {
await collections.fetch(collectionId, { force: true });
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 403) {
collections.remove(collectionId);
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
policies.remove(collectionId);
return;
}
}
+3 -2
View File
@@ -11,11 +11,12 @@ const H3 = styled.h3`
margin-top: 22px;
margin-bottom: 12px;
line-height: 1;
position: relative;
`;
const Underline = styled("span")`
margin-top: -1px;
position: relative;
top: 1px;
display: inline-block;
font-weight: 500;
font-size: 14px;
+10 -2
View File
@@ -1,15 +1,16 @@
// @flow
import { lighten } from "polished";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
type Props = {
theme: Theme,
theme: Object,
};
const StyledNavLink = styled(NavLink)`
position: relative;
bottom: -1px;
display: inline-block;
font-weight: 500;
@@ -23,6 +24,13 @@ const StyledNavLink = styled(NavLink)`
border-bottom: 3px solid ${(props) => props.theme.divider};
padding-bottom: 5px;
}
&:focus {
outline: none;
border-bottom: 3px solid
${(props) => lighten(0.4, props.theme.buttonBackground)};
padding-bottom: 5px;
}
`;
function Tab({ theme, ...rest }: Props) {
+2 -3
View File
@@ -2,12 +2,11 @@
import styled from "styled-components";
const TeamLogo = styled.img`
width: ${(props) => props.size || "auto"};
height: ${(props) => props.size || "38px"};
width: auto;
height: 38px;
border-radius: 4px;
background: ${(props) => props.theme.background};
border: 1px solid ${(props) => props.theme.divider};
overflow: hidden;
`;
export default TeamLogo;
+1 -17
View File
@@ -23,9 +23,6 @@ function eachMinute(fn) {
type Props = {
dateTime: string,
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
};
class Time extends React.Component<Props> {
@@ -42,26 +39,13 @@ class Time extends React.Component<Props> {
}
render() {
const { shorten, addSuffix } = this.props;
let content = distanceInWordsToNow(this.props.dateTime, {
addSuffix,
});
if (shorten) {
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
return (
<Tooltip
tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")}
delay={this.props.tooltipDelay}
placement="bottom"
>
<time dateTime={this.props.dateTime}>
{this.props.children || content}
{this.props.children || distanceInWordsToNow(this.props.dateTime)}
</time>
</Tooltip>
);
-1
View File
@@ -21,7 +21,6 @@ export default class Abstract extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://app.goabstract.com/embed/${shareId}`}
title={`Abstract (${shareId})`}
/>
-1
View File
@@ -20,7 +20,6 @@ export default class Airtable extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://airtable.com/embed/${shareId}`}
title={`Airtable (${shareId})`}
border
-28
View File
@@ -1,28 +0,0 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://share.clickup.com/[a-z]/[a-z]/(.*)/(.*)$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class ClickUp extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="ClickUp Embed"
/>
);
}
}
-17
View File
@@ -1,17 +0,0 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import ClickUp from "./ClickUp";
describe("ClickUp", () => {
const match = ClickUp.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://share.clickup.com/b/h/6-9310960-2/c9d837d74182317".match(match)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://share.clickup.com".match(match)).toBe(null);
expect("https://clickup.com/".match(match)).toBe(null);
expect("https://clickup.com/features".match(match)).toBe(null);
});
});
+1 -1
View File
@@ -17,6 +17,6 @@ export default class Codepen extends React.Component<Props> {
render() {
const normalizedUrl = this.props.attrs.href.replace(/\/pen\//, "/embed/");
return <Frame {...this.props} src={normalizedUrl} title="Codepen Embed" />;
return <Frame src={normalizedUrl} title="Codepen Embed" />;
}
}
-1
View File
@@ -19,7 +19,6 @@ export default class Figma extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={`https://www.figma.com/embed?embed_host=outline&url=${this.props.attrs.href}`}
title="Figma Embed"
border
+1 -8
View File
@@ -15,13 +15,6 @@ export default class Framer extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Framer Embed"
border
/>
);
return <Frame src={this.props.attrs.href} title="Framer Embed" border />;
}
}
+1 -3
View File
@@ -2,11 +2,10 @@
import * as React from "react";
const URL_REGEX = new RegExp(
"^https://gist.github.com/([a-z\\d](?:[a-z\\d]|-(?=[a-z\\d])){0,38})/(.*)$"
"^https://gist.github.com/([a-zd](?:[a-zd]|-(?=[a-zd])){0,38})/(.*)$"
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -49,7 +48,6 @@ class Gist extends React.Component<Props> {
return (
<iframe
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
ref={this.updateIframeContent}
type="text/html"
frameBorder="0"
-6
View File
@@ -9,12 +9,6 @@ describe("Gist", () => {
match
)
).toBeTruthy();
expect(
"https://gist.github.com/n3n/eb51ada6308b539d388c8ff97711adfa".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
+4 -16
View File
@@ -2,7 +2,9 @@
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
const URL_REGEX = new RegExp(
"^https?://docs.google.com/document/d/(.*)/pub(.*)$"
);
type Props = {|
attrs: {|
@@ -16,21 +18,7 @@ export default class GoogleDocs extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img
src="/images/google-docs.png"
alt="Google Docs Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Docs"
border
/>
<Frame src={this.props.attrs.href} title="Google Docs Embed" border />
);
}
}
+5 -10
View File
@@ -14,19 +14,14 @@ describe("GoogleDocs", () => {
match
)
).toBeTruthy();
expect(
"https://docs.google.com/document/d/1SsDfWzFFTjZM2LanvpyUzjKhqVQpwpTMeiPeYxhVqOg/edit".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/document/d/1SsDfWzFFTjZM2LanvpyUzjKhqVQpwpTMeiPeYxhVqOg/preview".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect(
"https://docs.google.com/document/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
match
)
).toBe(null);
expect("https://docs.google.com/document".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
+4 -16
View File
@@ -2,7 +2,9 @@
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
const URL_REGEX = new RegExp(
"^https?://docs.google.com/spreadsheets/d/(.*)/pub(.*)$"
);
type Props = {|
attrs: {|
@@ -16,21 +18,7 @@ export default class GoogleSlides extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<img
src="/images/google-sheets.png"
alt="Google Sheets Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Sheets"
border
/>
<Frame src={this.props.attrs.href} title="Google Sheets Embed" border />
);
}
}
+4 -4
View File
@@ -9,14 +9,14 @@ describe("GoogleSheets", () => {
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect(
"https://docs.google.com/spreadsheets/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
).toBe(null);
expect("https://docs.google.com/spreadsheets".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
+5 -15
View File
@@ -2,7 +2,9 @@
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
const URL_REGEX = new RegExp(
"^https?://docs.google.com/presentation/d/(.*)/pub(.*)$"
);
type Props = {|
attrs: {|
@@ -17,20 +19,8 @@ export default class GoogleSlides extends React.Component<Props> {
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href
.replace("/edit", "/preview")
.replace("/pub", "/embed")}
icon={
<img
src="/images/google-slides.png"
alt="Google Slides Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Slides"
src={this.props.attrs.href.replace("/pub", "/embed")}
title="Google Slides Embed"
border
/>
);
+4 -4
View File
@@ -14,14 +14,14 @@ describe("GoogleSlides", () => {
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect(
"https://docs.google.com/presentation/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
).toBe(null);
expect("https://docs.google.com/presentation".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
+1 -9
View File
@@ -12,7 +12,6 @@ const IMAGE_REGEX = new RegExp(
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -26,7 +25,6 @@ export default class InVision extends React.Component<Props> {
if (IMAGE_REGEX.test(this.props.attrs.href)) {
return (
<ImageZoom
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
image={{
src: this.props.attrs.href,
alt: "InVision Embed",
@@ -39,12 +37,6 @@ export default class InVision extends React.Component<Props> {
/>
);
}
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="InVision Embed"
/>
);
return <Frame src={this.props.attrs.href} title="InVision Embed" />;
}
}
+1 -1
View File
@@ -17,6 +17,6 @@ export default class Loom extends React.Component<Props> {
render() {
const normalizedUrl = this.props.attrs.href.replace("share", "embed");
return <Frame {...this.props} src={normalizedUrl} title="Loom Embed" />;
return <Frame src={normalizedUrl} title="Loom Embed" />;
}
}
-1
View File
@@ -20,7 +20,6 @@ export default class Lucidchart extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://lucidchart.com/documents/embeddedchart/${chartId}`}
title="Lucidchart Embed"
/>
+1 -8
View File
@@ -15,13 +15,6 @@ export default class Marvel extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Marvel Embed"
border
/>
);
return <Frame src={this.props.attrs.href} title="Marvel Embed" border />;
}
}
-1
View File
@@ -21,7 +21,6 @@ export default class Mindmeister extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://www.mindmeister.com/maps/public_map_shell/${chartId}`}
title="Mindmeister Embed"
border
-1
View File
@@ -20,7 +20,6 @@ export default class RealtimeBoard extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://realtimeboard.com/app/embed/${boardId}`}
title={`RealtimeBoard (${boardId})`}
/>
+1 -5
View File
@@ -21,11 +21,7 @@ export default class ModeAnalytics extends React.Component<Props> {
const normalizedUrl = this.props.attrs.href.replace(/\/embed$/, "");
return (
<Frame
{...this.props}
src={`${normalizedUrl}/embed`}
title="Mode Analytics Embed"
/>
<Frame src={`${normalizedUrl}/embed`} title="Mode Analytics Embed" />
);
}
}
+1 -3
View File
@@ -17,8 +17,6 @@ export default class Prezi extends React.Component<Props> {
render() {
const url = this.props.attrs.href.replace(/\/embed$/, "");
return (
<Frame {...this.props} src={`${url}/embed`} title="Prezi Embed" border />
);
return <Frame src={`${url}/embed`} title="Prezi Embed" border />;
}
}
+2 -13
View File
@@ -25,21 +25,10 @@ export default class Spotify extends React.Component<Props> {
render() {
const normalizedPath = this.pathname.replace(/^\/embed/, "/");
var height;
if (normalizedPath.includes("episode") || normalizedPath.includes("show")) {
height = 232;
} else if (normalizedPath.includes("track")) {
height = 80;
} else {
height = 380;
}
return (
<Frame
{...this.props}
width="100%"
height={`${height}px`}
width="300px"
height="380px"
src={`https://open.spotify.com/embed${normalizedPath}`}
title="Spotify Embed"
allow="encrypted-media"
-1
View File
@@ -31,7 +31,6 @@ export default class Trello extends React.Component<Props> {
return (
<Frame
{...this.props}
width="248px"
height="185px"
src={`https://trello.com/embed/board?id=${objectId}`}
+1 -7
View File
@@ -17,12 +17,6 @@ export default class Typeform extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Typeform Embed"
/>
);
return <Frame src={this.props.attrs.href} title="Typeform Embed" />;
}
}
-1
View File
@@ -20,7 +20,6 @@ export default class Vimeo extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://player.vimeo.com/video/${videoId}?byline=0`}
title={`Vimeo Embed (${videoId})`}
/>
-2
View File
@@ -5,7 +5,6 @@ import Frame from "./components/Frame";
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i;
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
@@ -21,7 +20,6 @@ export default class YouTube extends React.Component<Props> {
return (
<Frame
{...this.props}
src={`https://www.youtube.com/embed/${videoId}?modestbranding=1`}
title={`YouTube (${videoId})`}
/>
+6 -64
View File
@@ -1,18 +1,12 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { OpenIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
type Props = {
src?: string,
border?: boolean,
title?: string,
icon?: React.Node,
canonicalUrl?: string,
isSelected?: boolean,
width?: string,
height?: string,
};
@@ -46,26 +40,15 @@ class Frame extends React.Component<PropsWithRef> {
width = "100%",
height = "400px",
forwardedRef,
icon,
title,
canonicalUrl,
isSelected,
src,
...rest
} = this.props;
const Component = border ? StyledIframe : "iframe";
const withBar = !!(icon || canonicalUrl);
return (
<Rounded
width={width}
height={height}
withBar={withBar}
className={isSelected ? "ProseMirror-selectednode" : ""}
>
<Rounded width={width} height={height}>
{this.isLoaded && (
<Component
ref={forwardedRef}
withBar={withBar}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
width={width}
height={height}
@@ -73,60 +56,20 @@ class Frame extends React.Component<PropsWithRef> {
frameBorder="0"
title="embed"
loading="lazy"
src={src}
allowFullScreen
{...rest}
/>
)}
{withBar && (
<Bar align="center">
{icon} <Title>{title}</Title>
{canonicalUrl && (
<Open
href={canonicalUrl}
target="_blank"
rel="noopener noreferrer"
>
<OpenIcon color="currentColor" size={18} /> Open
</Open>
)}
</Bar>
)}
</Rounded>
);
}
}
const Rounded = styled.div`
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
border-radius: 3px;
overflow: hidden;
width: ${(props) => props.width};
height: ${(props) => (props.withBar ? props.height + 28 : props.height)};
`;
const Open = styled.a`
color: ${(props) => props.theme.textSecondary} !important;
font-size: 13px;
font-weight: 500;
align-items: center;
display: flex;
position: absolute;
right: 0;
padding: 0 8px;
`;
const Title = styled.span`
font-size: 13px;
font-weight: 500;
padding-left: 4px;
`;
const Bar = styled(Flex)`
background: ${(props) => props.theme.secondaryBackground};
color: ${(props) => props.theme.textSecondary};
padding: 0 8px;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
user-select: none;
height: ${(props) => props.height};
`;
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
@@ -136,8 +79,7 @@ const Iframe = (props) => <iframe {...props} />;
const StyledIframe = styled(Iframe)`
border: 1px solid;
border-color: ${(props) => props.theme.embedBorder};
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
display: block;
border-radius: 3px;
`;
export default React.forwardRef<Props, typeof Frame>((props, ref) => (
-8
View File
@@ -3,7 +3,6 @@ import * as React from "react";
import styled from "styled-components";
import Abstract from "./Abstract";
import Airtable from "./Airtable";
import ClickUp from "./ClickUp";
import Codepen from "./Codepen";
import Figma from "./Figma";
import Framer from "./Framer";
@@ -58,13 +57,6 @@ export default [
component: Airtable,
matcher: matcher(Airtable),
},
{
title: "ClickUp",
keywords: "project",
icon: () => <Img src="/images/clickup.png" />,
component: ClickUp,
matcher: matcher(ClickUp),
},
{
title: "Codepen",
keywords: "code editor",
-10
View File
@@ -1,10 +0,0 @@
// @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
@@ -1,8 +0,0 @@
// @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);
}
+22 -16
View File
@@ -1,6 +1,4 @@
// @flow
import "mobx-react-lite/batchingForReactDom";
import "focus-visible";
import { Provider } from "mobx-react";
import * as React from "react";
import { render } from "react-dom";
@@ -14,24 +12,32 @@ 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>,
<>
<ErrorBoundary>
<Provider {...stores}>
<Theme>
<Router>
<>
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</Theme>
</Provider>
</ErrorBoundary>
{DevTools && <DevTools position={{ bottom: 0, right: 0 }} />}
</>,
element
);
}
+44 -56
View File
@@ -11,11 +11,11 @@ import CollectionDelete from "scenes/CollectionDelete";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionExport from "scenes/CollectionExport";
import CollectionMembers from "scenes/CollectionMembers";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Modal from "components/Modal";
import VisuallyHidden from "components/VisuallyHidden";
import getDataTransferFiles from "utils/getDataTransferFiles";
import importFile from "utils/importFile";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
@@ -55,13 +55,11 @@ class CollectionMenu extends React.Component<Props> {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
const document = await this.props.documents.import(
file,
null,
this.props.collection.id,
{ publish: true }
);
const document = await importFile({
file: files[0],
documents: this.props.documents,
collectionId: this.props.collection.id,
});
this.props.history.push(document.url);
} catch (err) {
this.props.ui.showToast(err.message);
@@ -105,14 +103,7 @@ class CollectionMenu extends React.Component<Props> {
};
render() {
const {
policies,
documents,
collection,
position,
onOpen,
onClose,
} = this.props;
const { policies, collection, position, onOpen, onClose } = this.props;
const can = policies.abilities(collection.id);
return (
@@ -123,7 +114,7 @@ class CollectionMenu extends React.Component<Props> {
ref={(ref) => (this.file = ref)}
onChange={this.onFilePicked}
onClick={(ev) => ev.stopPropagation()}
accept={documents.importFileTypes.join(", ")}
accept="text/markdown, text/plain"
/>
</VisuallyHidden>
@@ -136,47 +127,44 @@ class CollectionMenu extends React.Component<Props> {
collection={collection}
onSubmit={this.handleMembersModalClose}
handleEditCollectionOpen={this.handleEditCollectionOpen}
onEdit={this.handleEditCollectionOpen}
/>
</Modal>
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
<DropdownMenuItems
items={[
{
title: "New document",
visible: !!(collection && can.update),
onClick: this.onNewDocument,
},
{
title: "Import document",
visible: !!(collection && can.update),
onClick: this.onImportDocument,
},
{
type: "separator",
},
{
title: "Edit…",
visible: !!(collection && can.update),
onClick: this.handleEditCollectionOpen,
},
{
title: "Permissions…",
visible: !!(collection && can.update),
onClick: this.handleMembersModalOpen,
},
{
title: "Export…",
visible: !!(collection && can.export),
onClick: this.handleExportCollectionOpen,
},
{
title: "Delete…",
visible: !!(collection && can.delete),
onClick: this.handleDeleteCollectionOpen,
},
]}
/>
{collection && (
<>
{can.update && (
<DropdownMenuItem onClick={this.onNewDocument}>
New document
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.onImportDocument}>
Import document
</DropdownMenuItem>
)}
{can.update && <hr />}
{can.update && (
<DropdownMenuItem onClick={this.handleEditCollectionOpen}>
Edit
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleMembersModalOpen}>
Permissions
</DropdownMenuItem>
)}
{can.export && (
<DropdownMenuItem onClick={this.handleExportCollectionOpen}>
Export
</DropdownMenuItem>
)}
</>
)}
{can.delete && (
<DropdownMenuItem onClick={this.handleDeleteCollectionOpen}>
Delete
</DropdownMenuItem>
)}
</DropdownMenu>
<Modal
title="Edit collection"
+107 -161
View File
@@ -3,6 +3,7 @@ import { observable } from "mobx";
import { inject, observer } from "mobx-react";
import * as React from "react";
import { Redirect } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import CollectionStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
@@ -11,15 +12,13 @@ import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentShare from "scenes/DocumentShare";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import CollectionIcon from "components/CollectionIcon";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Modal from "components/Modal";
import {
documentHistoryUrl,
documentMoveUrl,
documentUrl,
documentMoveUrl,
editDocumentUrl,
documentHistoryUrl,
newDocumentUrl,
} from "utils/routeHelpers";
@@ -35,7 +34,6 @@ type Props = {
showPrint?: boolean,
showToggleEmbeds?: boolean,
showPin?: boolean,
label?: React.Node,
onOpen?: () => void,
onClose?: () => void,
};
@@ -103,19 +101,11 @@ class DocumentMenu extends React.Component<Props> {
this.props.ui.showToast("Document archived");
};
handleRestore = async (
ev: SyntheticEvent<>,
options?: { collectionId: string }
) => {
await this.props.document.restore(options);
handleRestore = async (ev: SyntheticEvent<>) => {
await this.props.document.restore();
this.props.ui.showToast("Document restored");
};
handleUnpublish = async (ev: SyntheticEvent<>) => {
await this.props.document.unpublish();
this.props.ui.showToast("Document unpublished");
};
handlePin = (ev: SyntheticEvent<>) => {
this.props.document.pin();
};
@@ -160,16 +150,13 @@ class DocumentMenu extends React.Component<Props> {
showPrint,
showPin,
auth,
collections,
label,
onOpen,
onClose,
} = this.props;
const can = policies.abilities(document.id);
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
const canShareDocuments = can.share && auth.team && auth.team.sharing;
const canViewHistory = can.read && !can.restore;
const collection = collections.get(document.collectionId);
return (
<>
@@ -178,149 +165,108 @@ class DocumentMenu extends React.Component<Props> {
position={position}
onOpen={onOpen}
onClose={onClose}
label={label}
>
<DropdownMenuItems
items={[
{
title: "Restore",
visible: !!can.unarchive,
onClick: this.handleRestore,
},
{
title: "Restore",
visible: !!(collection && can.restore),
onClick: this.handleRestore,
},
{
title: "Restore…",
visible: !collection && !!can.restore,
style: {
left: -170,
position: "relative",
top: -40,
},
hover: true,
items: [
{
type: "heading",
title: "Choose a collection",
},
...collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
{(can.unarchive || can.restore) && (
<DropdownMenuItem onClick={this.handleRestore}>
Restore
</DropdownMenuItem>
)}
{showPin &&
(document.pinned
? can.unpin && (
<DropdownMenuItem onClick={this.handleUnpin}>
Unpin
</DropdownMenuItem>
)
: can.pin && (
<DropdownMenuItem onClick={this.handlePin}>
Pin to collection
</DropdownMenuItem>
))}
{document.isStarred
? can.unstar && (
<DropdownMenuItem onClick={this.handleUnstar}>
Unstar
</DropdownMenuItem>
)
: can.star && (
<DropdownMenuItem onClick={this.handleStar}>
Star
</DropdownMenuItem>
)}
{canShareDocuments && (
<DropdownMenuItem
onClick={this.handleShareLink}
title="Create a public share link"
>
Share link
</DropdownMenuItem>
)}
{showToggleEmbeds && (
<>
{document.embedsDisabled ? (
<DropdownMenuItem onClick={document.enableEmbeds}>
Enable embeds
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={document.disableEmbeds}>
Disable embeds
</DropdownMenuItem>
)}
</>
)}
{!can.restore && <hr />}
return {
title: (
<>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</>
),
onClick: (ev) =>
this.handleRestore(ev, { collectionId: collection.id }),
disabled: !can.update,
};
}),
],
},
{
title: "Unpin",
onClick: this.handleUnpin,
visible: !!(showPin && document.pinned && can.unpin),
},
{
title: "Pin to collection",
onClick: this.handlePin,
visible: !!(showPin && !document.pinned && can.pin),
},
{
title: "Unstar",
onClick: this.handleUnstar,
visible: document.isStarred && !!can.unstar,
},
{
title: "Star",
onClick: this.handleStar,
visible: !document.isStarred && !!can.star,
},
{
title: "Share link…",
onClick: this.handleShareLink,
visible: canShareDocuments,
},
{
title: "Enable embeds",
onClick: document.enableEmbeds,
visible: !!showToggleEmbeds && document.embedsDisabled,
},
{
title: "Disable embeds",
onClick: document.disableEmbeds,
visible: !!showToggleEmbeds && !document.embedsDisabled,
},
{
type: "separator",
},
{
title: "New nested document",
onClick: this.handleNewChild,
visible: !!can.createChildDocument,
},
{
title: "Create template…",
onClick: this.handleOpenTemplateModal,
visible: !!can.update && !document.isTemplate,
},
{
title: "Edit",
onClick: this.handleEdit,
visible: !!can.update,
},
{
title: "Duplicate",
onClick: this.handleDuplicate,
visible: !!can.update,
},
{
title: "Unpublish",
onClick: this.handleUnpublish,
visible: !!can.unpublish,
},
{
title: "Archive",
onClick: this.handleArchive,
visible: !!can.archive,
},
{
title: "Delete…",
onClick: this.handleDelete,
visible: !!can.delete,
},
{
title: "Move…",
onClick: this.handleMove,
visible: !!can.move,
},
{
type: "separator",
},
{
title: "History",
onClick: this.handleDocumentHistory,
visible: canViewHistory,
},
{
title: "Download",
onClick: this.handleExport,
visible: !!can.download,
},
{
title: "Print",
onClick: window.print,
visible: !!showPrint,
},
]}
/>
{can.createChildDocument && (
<DropdownMenuItem
onClick={this.handleNewChild}
title="Create a nested document inside the current document"
>
New nested document
</DropdownMenuItem>
)}
{can.update && !document.isTemplate && (
<DropdownMenuItem onClick={this.handleOpenTemplateModal}>
Create template
</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
)}
{can.update && (
<DropdownMenuItem onClick={this.handleDuplicate}>
Duplicate
</DropdownMenuItem>
)}
{can.archive && (
<DropdownMenuItem onClick={this.handleArchive}>
Archive
</DropdownMenuItem>
)}
{can.delete && (
<DropdownMenuItem onClick={this.handleDelete}>
Delete
</DropdownMenuItem>
)}
{can.move && (
<DropdownMenuItem onClick={this.handleMove}>Move</DropdownMenuItem>
)}
<hr />
{canViewHistory && (
<>
<DropdownMenuItem onClick={this.handleDocumentHistory}>
History
</DropdownMenuItem>
</>
)}
{can.download && (
<DropdownMenuItem onClick={this.handleExport}>
Download
</DropdownMenuItem>
)}
{showPrint && (
<DropdownMenuItem onClick={window.print}>Print</DropdownMenuItem>
)}
</DropdownMenu>
<Modal
title={`Delete ${this.props.document.noun}`}
+22 -24
View File
@@ -8,8 +8,8 @@ import UiStore from "stores/UiStore";
import Group from "models/Group";
import GroupDelete from "scenes/GroupDelete";
import GroupEdit from "scenes/GroupEdit";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import Modal from "components/Modal";
type Props = {
@@ -72,29 +72,27 @@ class GroupMenu extends React.Component<Props> {
onSubmit={this.handleDeleteModalClose}
/>
</Modal>
<DropdownMenu onOpen={onOpen} onClose={onClose}>
<DropdownMenuItems
items={[
{
title: "Members…",
onClick: this.props.onMembers,
visible: !!(group && can.read),
},
{
type: "separator",
},
{
title: "Edit…",
onClick: this.onEdit,
visible: !!(group && can.update),
},
{
title: "Delete…",
onClick: this.onDelete,
visible: !!(group && can.delete),
},
]}
/>
{group && (
<>
<DropdownMenuItem onClick={this.props.onMembers}>
Members
</DropdownMenuItem>
{(can.update || can.delete) && <hr />}
{can.update && (
<DropdownMenuItem onClick={this.onEdit}>Edit</DropdownMenuItem>
)}
{can.delete && (
<DropdownMenuItem onClick={this.onDelete}>
Delete
</DropdownMenuItem>
)}
</>
)}
</DropdownMenu>
</>
);
+13 -21
View File
@@ -1,13 +1,13 @@
// @flow
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { Redirect } from "react-router-dom";
import CollectionsStore from "stores/CollectionsStore";
import Document from "models/Document";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
@@ -39,28 +39,20 @@ class NewChildDocumentMenu extends React.Component<Props> {
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { label, document, collections } = this.props;
const { label, document, collections, ...rest } = this.props;
const collection = collections.get(document.collectionId);
return (
<DropdownMenu label={label}>
<DropdownMenuItems
items={[
{
title: (
<span>
New document in{" "}
<strong>{collection ? collection.name : "collection"}</strong>
</span>
),
onClick: this.handleNewDocument,
},
{
title: "New nested document",
onClick: this.handleNewChild,
},
]}
/>
<DropdownMenu label={label || <MoreIcon />} {...rest}>
<DropdownMenuItem onClick={this.handleNewDocument}>
<span>
New document in{" "}
<strong>{collection ? collection.name : "collection"}</strong>
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleNewChild}>
New nested document
</DropdownMenuItem>
</DropdownMenu>
);
}
+19 -14
View File
@@ -10,8 +10,11 @@ import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
import { DropdownMenu, Header } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import {
DropdownMenu,
DropdownMenuItem,
Header,
} from "components/DropdownMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
@@ -60,18 +63,20 @@ class NewDocumentMenu extends React.Component<Props> {
{...rest}
>
<Header>Choose a collection</Header>
<DropdownMenuItems
items={collections.orderedData.map((collection) => ({
onClick: () => this.handleNewDocument(collection.id),
disabled: !policies.abilities(collection.id).update,
title: (
<>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</>
),
}))}
/>
{collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return (
<DropdownMenuItem
key={collection.id}
onClick={() => this.handleNewDocument(collection.id)}
disabled={!can.update}
>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
+19 -14
View File
@@ -9,8 +9,11 @@ import CollectionsStore from "stores/CollectionsStore";
import PoliciesStore from "stores/PoliciesStore";
import Button from "components/Button";
import CollectionIcon from "components/CollectionIcon";
import { DropdownMenu, Header } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import {
DropdownMenu,
DropdownMenuItem,
Header,
} from "components/DropdownMenu";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {
@@ -50,18 +53,20 @@ class NewTemplateMenu extends React.Component<Props> {
{...rest}
>
<Header>Choose a collection</Header>
<DropdownMenuItems
items={collections.orderedData.map((collection) => ({
onClick: () => this.handleNewDocument(collection.id),
disabled: !policies.abilities(collection.id).update,
title: (
<>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</>
),
}))}
/>
{collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return (
<DropdownMenuItem
key={collection.id}
onClick={() => this.handleNewDocument(collection.id)}
disabled={!can.update}
>
<CollectionIcon collection={collection} />
&nbsp;{collection.name}
</DropdownMenuItem>
);
})}
</DropdownMenu>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ type Props = {
class RevisionMenu extends React.Component<Props> {
handleRestore = async (ev: SyntheticEvent<>) => {
ev.preventDefault();
await this.props.document.restore({ revisionId: this.props.revision.id });
await this.props.document.restore(this.props.revision);
this.props.ui.showToast("Document restored");
this.props.history.push(this.props.document.url);
};
+3 -8
View File
@@ -31,15 +31,10 @@ class ShareMenu extends React.Component<Props> {
this.redirectTo = this.props.share.documentUrl;
};
handleRevoke = async (ev: SyntheticEvent<>) => {
handleRevoke = (ev: SyntheticEvent<>) => {
ev.preventDefault();
try {
await this.props.shares.revoke(this.props.share);
this.props.ui.showToast("Share link revoked");
} catch (err) {
this.props.ui.showToast(err.message);
}
this.props.shares.revoke(this.props.share);
this.props.ui.showToast("Share link revoked");
};
handleCopy = () => {
+26 -34
View File
@@ -4,8 +4,7 @@ import * as React from "react";
import UsersStore from "stores/UsersStore";
import User from "models/User";
import { DropdownMenu } from "components/DropdownMenu";
import DropdownMenuItems from "components/DropdownMenu/DropdownMenuItems";
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
type Props = {
user: User,
@@ -66,38 +65,31 @@ class UserMenu extends React.Component<Props> {
return (
<DropdownMenu>
<DropdownMenuItems
items={[
{
title: `Make ${user.name} a member…`,
onClick: this.handleDemote,
visible: user.isAdmin,
},
{
title: `Make ${user.name} an admin…`,
onClick: this.handlePromote,
visible: !user.isAdmin && !user.isSuspended,
},
{
type: "separator",
},
{
title: "Revoke invite…",
onClick: this.handleRevoke,
visible: user.isInvited,
},
{
title: "Reactivate account",
onClick: this.handleActivate,
visible: !user.isInvited && user.isSuspended,
},
{
title: "Suspend account",
onClick: this.handleSuspend,
visible: !user.isInvited && !user.isSuspended,
},
]}
/>
{user.isAdmin && (
<DropdownMenuItem onClick={this.handleDemote}>
Make {user.name} a member
</DropdownMenuItem>
)}
{!user.isAdmin && !user.isSuspended && (
<DropdownMenuItem onClick={this.handlePromote}>
Make {user.name} an admin
</DropdownMenuItem>
)}
{!user.lastActiveAt && (
<DropdownMenuItem onClick={this.handleRevoke}>
Revoke invite
</DropdownMenuItem>
)}
{user.lastActiveAt &&
(user.isSuspended ? (
<DropdownMenuItem onClick={this.handleActivate}>
Activate account
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={this.handleSuspend}>
Suspend account
</DropdownMenuItem>
))}
</DropdownMenu>
);
}
+7 -25
View File
@@ -1,14 +1,13 @@
// @flow
import addDays from "date-fns/add_days";
import differenceInDays from "date-fns/difference_in_days";
import invariant from "invariant";
import { action, computed, observable, set } from "mobx";
import { action, set, observable, computed } from "mobx";
import parseTitle from "shared/utils/parseTitle";
import unescape from "shared/utils/unescape";
import DocumentsStore from "stores/DocumentsStore";
import BaseModel from "models/BaseModel";
import Revision from "models/Revision";
import User from "models/User";
import View from "./View";
type SaveOptions = {
publish?: boolean,
@@ -21,11 +20,11 @@ export default class Document extends BaseModel {
@observable isSaving: boolean = false;
@observable embedsDisabled: boolean = false;
@observable injectTemplate: boolean = false;
@observable lastViewedAt: ?string;
store: DocumentsStore;
collaborators: User[];
collectionId: string;
lastViewedAt: ?string;
createdAt: string;
createdBy: User;
updatedAt: string;
@@ -49,7 +48,7 @@ export default class Document extends BaseModel {
constructor(fields: Object, store: DocumentsStore) {
super(fields, store);
if (this.isNewDocument && this.isFromTemplate) {
if (this.isNew && this.isFromTemplate) {
this.title = "";
}
}
@@ -74,14 +73,6 @@ export default class Document extends BaseModel {
return !!this.lastViewedAt && this.lastViewedAt < this.updatedAt;
}
@computed
get isNew(): boolean {
return (
!this.lastViewedAt &&
differenceInDays(new Date(), new Date(this.createdAt)) < 14
);
}
@computed
get isStarred(): boolean {
return !!this.store.starredIds.get(this.id);
@@ -122,7 +113,7 @@ export default class Document extends BaseModel {
}
@computed
get isNewDocument(): boolean {
get isNew(): boolean {
return this.createdAt === this.updatedAt;
}
@@ -150,12 +141,8 @@ export default class Document extends BaseModel {
return this.store.archive(this);
};
restore = (options) => {
return this.store.restore(this, options);
};
unpublish = () => {
return this.store.unpublish(this);
restore = (revision: Revision) => {
return this.store.restore(this, revision);
};
@action
@@ -209,11 +196,6 @@ export default class Document extends BaseModel {
return this.store.rootStore.views.create({ documentId: this.id });
};
@action
updateLastViewed = (view: View) => {
this.lastViewedAt = view.lastViewedAt;
};
@action
templatize = async () => {
return this.store.templatize(this.id);
-2
View File
@@ -10,10 +10,8 @@ class Team extends BaseModel {
googleConnected: boolean;
sharing: boolean;
documentEmbeds: boolean;
multiplayerEditor: boolean;
guestSignin: boolean;
subdomain: ?string;
domain: ?string;
url: string;
@computed

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