Compare commits

..

2 Commits

Author SHA1 Message Date
Tom Moor 6146843845 test: Load policies 2021-07-30 11:47:42 -04:00
Tom Moor 26c5d5e4fe perf: Remove inline require call 2021-07-30 11:31:38 -04:00
480 changed files with 7702 additions and 18422 deletions
+2 -94
View File
@@ -1,12 +1,4 @@
version: 2.1
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
docker:
- image: circleci/buildpack-deps:stretch
version: 2
jobs:
build:
working_directory: ~/outline
@@ -48,88 +40,4 @@ jobs:
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
build-image:
executor: docker-publisher
steps:
- checkout
- setup_remote_docker:
version: 20.10.6
- run:
name: Build Docker image
command: docker build -t $IMAGE_NAME:latest .
- run:
name: Archive Docker image
command: docker save -o image.tar $IMAGE_NAME
- persist_to_workspace:
root: .
paths:
- ./image.tar
publish-latest:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
docker push $IMAGE_NAME:$IMAGE_TAG
publish-tag:
executor: docker-publisher
steps:
- attach_workspace:
at: /tmp/workspace
- setup_remote_docker:
version: 20.10.6
- run:
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Publish Docker Image to Docker Hub
command: |
echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
IMAGE_TAG=${CIRCLE_TAG/v/''}
docker tag $IMAGE_NAME:latest $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:$IMAGE_TAG
workflows:
version: 2
build-and-test:
jobs:
- build:
filters:
tags:
ignore: /^v.*/
build-docker:
jobs:
- build-image:
filters:
tags:
only: /^v.*/
branches:
ignore: /.*/
- publish-latest:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+$/
branches:
ignore: /.*/
- publish-tag:
requires:
- build-image
filters:
tags:
only: /^v\d+\.\d+\.\d+-.*$/
branches:
ignore: /.*/
command: yarn build:webpack
+17 -39
View File
@@ -1,6 +1,6 @@
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# 👋 Welcome, we're glad you're setting up an installation of Outline. Copy this
# file to .env or set the variables in your local environment manually. For
# development with docker this should mostly work out of the box other than
# setting the Slack keys and the SECRET_KEY.
@@ -12,7 +12,7 @@
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
# Generate a unique random key. The format is not important but you could still use
# Generate a unique random key. The format is not important but you could still use
# `openssl rand -hex 32` in your terminal to produce this.
UTILS_SECRET=generate_a_new_key
@@ -29,13 +29,9 @@ REDIS_URL=redis://localhost:6479
URL=http://localhost:3000
PORT=3000
# See [documentation](docs/SERVICES.md) on running a separate collaboration
# server, for normal operation this does not need to be set.
COLLABORATION_URL=
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundency
# however if you want to keep all file storage local an alternative such as
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# A more detailed guide on setting up S3 is available here:
@@ -73,42 +69,24 @@ SLACK_SECRET=get_the_secret_of_above_key
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# To configure Microsoft/Azure auth, you'll need to create an OAuth Client. See
# the guide for details on setting up your Azure App:
# => https://wiki.generaloutline.com/share/dfa77e56-d4d2-4b51-8ff8-84ea6608faa4
AZURE_CLIENT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
AZURE_RESOURCE_APP_ID=
# To configure generic OIDC auth, you'll need some kind of identity provider.
# See documentation for whichever IdP you use to acquire the following info:
# Redirect URI is https://<URL>/auth/oidc.callback
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
OIDC_TOKEN_URI=
OIDC_USERINFO_URI=
# Specify which claims to derive user information from
# Supports any valid JSON path with the JWT payload
OIDC_USERNAME_CLAIM=preferred_username
# Display name for OIDC authentication
OIDC_DISPLAY_NAME=OpenID
# Space separated auth scopes.
OIDC_SCOPES="openid profile email"
# –––––––––––––––– OPTIONAL ––––––––––––––––
# If using a Cloudfront/Cloudflare distribution or similar it can be set below.
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# This will cause paths to javascript, stylesheets, and images to be updated to
# the hostname defined in CDN_URL. In your CDN configuration the origin server
# should be set to the same as URL.
CDN_URL=
# Auto-redirect to https in production. The default is true but you may set to
# Auto-redirect to https in production. The default is true but you may set to
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
@@ -124,15 +102,15 @@ WEB_CONCURRENCY=1
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
# You can remove this line if your reverse proxy already logs incoming http
# requests and this ends up being duplicative
DEBUG=http
# You may enable or disable debugging categories to increase the noisiness of
# logs. The default is a good balance
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,services
# Comma separated list of domains to be allowed to signin to the wiki. If not
# set, all domains are allowed by default when using Google OAuth to signin
ALLOWED_DOMAINS=
# For a complete Slack integration with search and posting to channels the
# For a complete Slack integration with search and posting to channels the
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
#
@@ -140,13 +118,13 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance
SENTRY_DSN=
# To support sending outgoing transactional emails such as "document updated" or
# To support sending outgoing transactional emails such as "document updated" or
# "you've been invited" you'll need to provide authentication for an SMTP server
SMTP_HOST=
SMTP_PORT=
@@ -160,6 +138,6 @@ SMTP_SECURE=true
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
# The default interface language. See translate.getoutline.com for a list of
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
-5
View File
@@ -11,11 +11,6 @@
.*/node_modules/react-side-effect/.*
.*/node_modules/fbjs/.*
.*/node_modules/config-chain/.*
.*/node_modules/yjs/.*
.*/node_modules/y-prosemirror/.*
.*/node_modules/y-protocols/.*
.*/node_modules/y-indexeddb/.*
.*/node_modules/lib0/.*
.*/server/scripts/.*
*.test.js
-70
View File
@@ -1,70 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '28 15 * * 2'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
-1
View File
@@ -8,4 +8,3 @@ stats.json
.DS_Store
fakes3/*
.idea
*.pem
+3 -5
View File
@@ -1,3 +1,4 @@
# Architecture
Outline is composed of a backend and frontend codebase in this monorepo. As both are written in Javascript, they share some code where possible. We utilize the latest ES6 language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier formatting and ESLint are enforced by CI.
@@ -13,7 +14,6 @@ app
├── components - React components reusable across scenes
├── embeds - Embed definitions that represent rich interactive embeds in the editor
├── hooks - Reusable React hooks
├── actions - Reusable actions such as navigating, opening, creating entities
├── menus - Context menus, often appear in multiple places in the UI
├── models - State models using MobX observables
├── routes - Route definitions, note that chunks are async loaded with suspense
@@ -46,9 +46,7 @@ server
├── onboarding - Markdown templates for onboarding documents
├── policies - Authorization logic based on cancan
├── presenters - JSON presenters for database models, the interface between backend -> frontend
├── queues - Async queue definitions
│ └── processors - Processors perform async jobs, usually working on events from the event bus
├── services - Services start distinct portions of the application eg api, worker
├── services - Service definitions are triggered for events and perform async jobs
├── static - Static assets
├── test - Test helpers and fixtures, tests themselves are colocated
└── utils - Utility methods specific to the backend
@@ -66,4 +64,4 @@ shared
├── styles - Styles, colors and other global aesthetics
├── utils - Shared utility methods
└── constants - Shared constants
```
```
+12 -39
View File
@@ -1,50 +1,23 @@
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM node:14-alpine AS deps-common
FROM node:14-alpine
ENV APP_PATH /opt/outline
RUN mkdir -p $APP_PATH
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
# ---
FROM deps-common AS deps-dev
RUN yarn install --no-optional --frozen-lockfile && \
yarn cache clean
COPY package.json ./
COPY yarn.lock ./
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:14-alpine AS builder
ARG APP_PATH
WORKDIR $APP_PATH
RUN yarn --pure-lockfile
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
RUN yarn build
# ---
FROM node:14-alpine AS runner
RUN yarn build && \
yarn --production --ignore-scripts --prefer-offline && \
rm -rf shared && \
rm -rf app
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
COPY --from=builder $APP_PATH/build ./build
COPY --from=builder $APP_PATH/server ./server
COPY --from=builder $APP_PATH/public ./public
COPY --from=builder $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=deps-prod $APP_PATH/node_modules ./node_modules
COPY --from=builder $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs $APP_PATH/build
USER nodejs
CMD yarn start
EXPOSE 3000
CMD ["yarn", "start"]
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.60.1
Licensed Work: Outline 0.55.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: 2025-11-11
Change Date: 2024-04-22
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -2,7 +2,7 @@ up:
docker-compose up -d redis postgres s3
yarn install --pure-lockfile
yarn sequelize db:migrate
yarn dev:watch
yarn dev
build:
docker-compose build --pull outline
+1 -2
View File
@@ -1,2 +1 @@
web: yarn start --services=web,websockets
worker: yarn start --services=worker
web: node ./build/server/index.js
+29 -31
View File
@@ -1,3 +1,5 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
</p>
@@ -26,7 +28,8 @@ Outline requires the following dependencies:
- [Postgres](https://www.postgresql.org/download/) >=9.5
- [Redis](https://redis.io/) >= 4
- AWS S3 bucket or compatible API for file storage
- Slack, Google, Azure, or OIDC application for authentication
- Slack or Google developer application for authentication
## Self-Hosted Production
@@ -38,20 +41,16 @@ For a manual self-hosted production installation these are the recommended steps
1. Download the latest official Docker image, new releases are available around the middle of every month:
`docker pull outlinewiki/outline`
1. Using the [.env.sample](.env.sample) as a reference, set the required variables in your production environment. You can export the environment variables directly, or create a `.env` file and pass it to the docker image like so:
`docker run --env-file=.env outlinewiki/outline`
1. Setup the database with `yarn db:migrate`. Production assumes an SSL connection to the database by default, if
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
Postgres is on the same machine and is not SSL you can migrate with `yarn db:migrate --env=production-ssl-disabled`, for example:
`docker run --rm outlinewiki/outline yarn db:migrate`
1. Start the container:
`docker run outlinewiki/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
@@ -80,33 +79,28 @@ If you're running Outline by cloning this repository, run the following command
yarn run upgrade
```
## Local Development
For contributing features and fixes you can quickly get an environment running using Docker by following these steps:
1. Install these dependencies if you don't already have them
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. [Docker for Desktop](https://www.docker.com)
1. [Node.js](https://nodejs.org/) (v12 LTS preferred)
1. [Yarn](https://yarnpkg.com)
1. Clone this repo
1. Register a Slack app at https://api.slack.com/apps
1. Copy the file `.env.sample` to `.env`
1. Fill out the following fields:
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. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL and update the `URL` env var to match
1. Ensure that the bot token scope contains at least `users:read`
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. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
### Testing
The `Makefile` has other useful scripts, including some test automation.
1. To run the entire test suite, run `make test`
1. During development, it's often useful, to re-run some tests every time a file is changed. Use `make watch` to start the test daemon and follow the instructions in the console
# Contributing
@@ -116,22 +110,26 @@ Before submitting a pull request please let the core team know by creating or co
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
- [Translation](docs/TRANSLATION.md) into other languages
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
- Performance improvements, both on server and frontend
- Developer happiness and documentation
- Bugs and other issues listed on GitHub
* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
* Bugs and other issues listed on GitHub
## Architecture
If you're interested in contributing or learning more about the Outline codebase
please refer to the [architecture document](docs/ARCHITECTURE.md) first for a high level overview of how the application is put together.
please refer to the [architecture document](ARCHITECTURE.md) first for a high level overview of how the application is put together.
## Debugging
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available:
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
```
DEBUG=sql,cache,presenters,events,importer,exporter,emails,mailer
```
## Tests
@@ -147,7 +145,7 @@ make test
make watch
```
Once the test database is created with `make test` you may individually run
Once the test database is created with `make test` you may individually run
frontend and backend tests directly.
```shell
View File
-70
View File
@@ -1,70 +0,0 @@
// @flow
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import CollectionEdit from "scenes/CollectionEdit";
import CollectionNew from "scenes/CollectionNew";
import DynamicCollectionIcon from "components/CollectionIcon";
import { createAction } from "actions";
import { CollectionSection } from "actions/sections";
import history from "utils/history";
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
section: CollectionSection,
shortcut: ["o", "c"],
icon: <CollectionIcon />,
children: ({ stores }) => {
const collections = stores.collections.orderedData;
return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.url,
name: collection.name,
icon: <DynamicCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.url),
}));
},
});
export const createCollection = createAction({
name: ({ t }) => t("New collection"),
section: CollectionSection,
icon: <PlusIcon />,
keywords: "create",
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").createCollection,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create a collection"),
content: <CollectionNew onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const editCollection = createAction({
name: ({ t }) => t("Edit collection"),
section: CollectionSection,
icon: <EditIcon />,
visible: ({ stores, activeCollectionId }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId }) => {
stores.dialogs.openModal({
title: t("Edit collection"),
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={activeCollectionId}
/>
),
});
},
});
export const rootCollectionActions = [openCollection, createCollection];
-33
View File
@@ -1,33 +0,0 @@
// @flow
import { ToolsIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import { createAction } from "actions";
import { DebugSection } from "actions/sections";
import env from "env";
import { deleteAllDatabases } from "utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DebugSection,
perform: async ({ t }) => {
await deleteAllDatabases();
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const development = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DebugSection,
visible: ({ event }) =>
env.ENVIRONMENT === "development" ||
(event instanceof KeyboardEvent && event.altKey),
children: [clearIndexedDB],
});
export const rootDebugActions = [development];
-240
View File
@@ -1,240 +0,0 @@
// @flow
import invariant from "invariant";
import {
DownloadIcon,
DuplicateIcon,
StarredIcon,
PrintIcon,
UnstarredIcon,
DocumentIcon,
NewDocumentIcon,
ShapesIcon,
ImportIcon,
} from "outline-icons";
import * as React from "react";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import { createAction } from "actions";
import { DocumentSection } from "actions/sections";
import getDataTransferFiles from "utils/getDataTransferFiles";
import history from "utils/history";
import { newDocumentPath } from "utils/routeHelpers";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
section: DocumentSection,
shortcut: ["o", "d"],
keywords: "go to",
icon: <DocumentIcon />,
children: ({ stores }) => {
const paths = stores.collections.pathsToDocuments;
return paths
.filter((path) => path.type === "document")
.map((path) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: path.url,
name: path.title,
icon: () =>
stores.documents.get(path.id)?.isStarred ? (
<StarredIcon />
) : undefined,
section: DocumentSection,
perform: () => history.push(path.url),
}));
},
});
export const createDocument = createAction({
name: ({ t }) => t("New document"),
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId }) =>
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
section: DocumentSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!document?.isStarred && stores.policies.abilities(activeDocumentId).star
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
document?.star();
},
});
export const unstarDocument = createAction({
name: ({ t }) => t("Unstar"),
section: DocumentSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isStarred &&
stores.policies.abilities(activeDocumentId).unstar
);
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
document?.unstar();
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
section: DocumentSection,
icon: <DownloadIcon />,
keywords: "export",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
document?.download();
},
});
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
section: DocumentSection,
icon: <DuplicateIcon />,
keywords: "copy",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
invariant(document, "Document must exist");
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
stores.toasts.showToast(t("Document duplicated"), { type: "success" });
},
});
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
section: DocumentSection,
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: async () => {
window.print();
},
});
export const importDocument = createAction({
name: ({ t, activeDocumentId }) => t("Import document"),
section: DocumentSection,
icon: <ImportIcon />,
keywords: "upload",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (activeDocumentId) {
return !!stores.policies.abilities(activeDocumentId).createChildDocument;
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).update;
}
return false;
},
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
const { documents, toasts } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev: SyntheticEvent<>) => {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
const document = await documents.import(
file,
activeDocumentId,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toasts.showToast(err.message, {
type: "error",
});
throw err;
}
};
input.click();
},
});
export const createTemplate = createAction({
name: ({ t }) => t("Templatize"),
section: DocumentSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
if (!activeDocumentId) return false;
const document = stores.documents.get(activeDocumentId);
return (
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create template"),
content: (
<DocumentTemplatize
documentId={activeDocumentId}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const rootDocumentActions = [
openDocument,
createDocument,
createTemplate,
importDocument,
downloadDocument,
starDocument,
unstarDocument,
duplicateDocument,
printDocument,
];
-159
View File
@@ -1,159 +0,0 @@
// @flow
import {
HomeIcon,
SearchIcon,
ArchiveIcon,
TrashIcon,
EditIcon,
OpenIcon,
SettingsIcon,
ShapesIcon,
KeyboardIcon,
EmailIcon,
} from "outline-icons";
import * as React from "react";
import {
developersUrl,
changelogUrl,
mailToUrl,
githubIssuesUrl,
} from "shared/utils/routeHelpers";
import stores from "stores";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import { createAction } from "actions";
import { NavigationSection } from "actions/sections";
import history from "utils/history";
import {
settingsPath,
homePath,
searchUrl,
draftsPath,
templatesPath,
archivePath,
trashPath,
} from "utils/routeHelpers";
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
perform: () => history.push(homePath()),
visible: ({ location }) => location.pathname !== homePath(),
});
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
section: NavigationSection,
shortcut: ["/"],
icon: <SearchIcon />,
perform: () => history.push(searchUrl()),
visible: ({ location }) => location.pathname !== searchUrl(),
});
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
section: NavigationSection,
icon: <EditIcon />,
perform: () => history.push(draftsPath()),
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToTemplates = createAction({
name: ({ t }) => t("Templates"),
section: NavigationSection,
icon: <ShapesIcon />,
perform: () => history.push(templatesPath()),
visible: ({ location }) => location.pathname !== templatesPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
section: NavigationSection,
icon: <ArchiveIcon />,
perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(),
});
export const navigateToTrash = createAction({
name: ({ t }) => t("Trash"),
section: NavigationSection,
icon: <TrashIcon />,
perform: () => history.push(trashPath()),
visible: ({ location }) => location.pathname !== trashPath(),
});
export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
section: NavigationSection,
shortcut: ["g", "s"],
iconInContextMenu: false,
icon: <SettingsIcon />,
perform: () => history.push(settingsPath()),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(developersUrl()),
});
export const openFeedbackUrl = createAction({
name: ({ t }) => t("Send us feedback"),
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(mailToUrl()),
});
export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"),
section: NavigationSection,
perform: () => window.open(githubIssuesUrl()),
});
export const openChangelog = createAction({
name: ({ t }) => t("Changelog"),
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(changelogUrl()),
});
export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"),
section: NavigationSection,
shortcut: ["?"],
iconInContextMenu: false,
icon: <KeyboardIcon />,
perform: ({ t }) => {
stores.dialogs.openGuide({
title: t("Keyboard shortcuts"),
content: <KeyboardShortcuts />,
});
},
});
export const logout = createAction({
name: ({ t }) => t("Log out"),
section: NavigationSection,
perform: () => stores.auth.logout(),
});
export const rootNavigationActions = [
navigateToHome,
navigateToSearch,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
navigateToSettings,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
openChangelog,
openKeyboardShortcuts,
logout,
];
-48
View File
@@ -1,48 +0,0 @@
// @flow
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import { createAction } from "actions";
import { SettingsSection } from "actions/sections";
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
icon: <MoonIcon />,
iconInContextMenu: false,
keywords: "theme dark night",
section: SettingsSection,
selected: () => stores.ui.theme === "dark",
perform: () => stores.ui.setTheme("dark"),
});
export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"),
icon: <SunIcon />,
iconInContextMenu: false,
keywords: "theme light day",
section: SettingsSection,
selected: () => stores.ui.theme === "light",
perform: () => stores.ui.setTheme("light"),
});
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
icon: <BrowserIcon />,
iconInContextMenu: false,
keywords: "theme system default",
section: SettingsSection,
selected: () => stores.ui.theme === "system",
perform: () => stores.ui.setTheme("system"),
});
export const changeTheme = createAction({
name: ({ t }) => t("Change theme"),
placeholder: ({ t }) => t("Change theme to"),
icon: () =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
keywords: "appearance display",
section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
});
export const rootSettingsActions = [changeTheme];
-24
View File
@@ -1,24 +0,0 @@
// @flow
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "stores";
import Invite from "scenes/Invite";
import { createAction } from "actions";
import { UserSection } from "actions/sections";
export const inviteUser = createAction({
name: ({ t }) => `${t("Invite people")}`,
icon: <PlusIcon />,
keywords: "team member user",
section: UserSection,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite people"),
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const rootUserActions = [inviteUser];
-117
View File
@@ -1,117 +0,0 @@
// @flow
import { flattenDeep } from "lodash";
import * as React from "react";
import { v4 as uuidv4 } from "uuid";
import type {
Action,
ActionContext,
CommandBarAction,
MenuItemClickable,
MenuItemWithChildren,
} from "types";
export function createAction(
definition: $Diff<Action, { id?: string }>
): Action {
return {
id: uuidv4(),
...definition,
};
}
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemClickable | MenuItemWithChildren {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? React.cloneElement(resolvedIcon, { color: "currentColor" })
: undefined;
if (resolvedChildren) {
return {
title,
icon,
items: resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter((a) => !!a),
visible,
};
}
return {
title,
icon,
visible,
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
}
export function actionToKBar(
action: Action,
context: ActionContext
): CommandBarAction[] {
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const resolvedIcon = resolve<React.Element<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const resolvedSection = resolve<string>(action.section);
const resolvedName = resolve<string>(action.name);
const resolvedPlaceholder = resolve<string>(action.placeholder);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
)
: [];
return [
{
id: action.id,
name: resolvedName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: `${action.keywords || ""} ${children
.filter((c) => !!c.keywords)
.map((c) => c.keywords)
.join(" ")}`,
shortcut: action.shortcut,
icon: resolvedIcon
? React.cloneElement(resolvedIcon, { color: "currentColor" })
: undefined,
perform: action.perform
? () => action.perform && action.perform(context)
: undefined,
children: children.length ? children.map((a) => a.id) : undefined,
},
].concat(
children.map((child) => ({
...child,
parent: action.id,
}))
);
}
-16
View File
@@ -1,16 +0,0 @@
// @flow
import { rootCollectionActions } from "./definitions/collections";
import { rootDebugActions } from "./definitions/debug";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
import { rootUserActions } from "./definitions/users";
export default [
...rootCollectionActions,
...rootDocumentActions,
...rootUserActions,
...rootNavigationActions,
...rootSettingsActions,
...rootDebugActions,
];
-14
View File
@@ -1,14 +0,0 @@
// @flow
import { type ActionContext } from "types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const DebugSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SettingsSection = ({ t }: ActionContext) => t("Settings");
export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const UserSection = ({ t }: ActionContext) => t("People");
-1
View File
@@ -11,7 +11,6 @@ type Props = {|
size: number,
icon?: React.Node,
user?: User,
alt?: string,
onClick?: () => void,
className?: string,
|};
@@ -1,6 +1,7 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
@@ -64,6 +65,7 @@ class AvatarWithPresence extends React.Component<Props> {
: this.handleOpenProfile
}
size={32}
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
/>
</AvatarWrapper>
</Tooltip>
+5 -17
View File
@@ -35,7 +35,7 @@ const RealButton = styled.button`
border: 0;
}
&:hover:not(:disabled) {
&:hover {
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
}
@@ -43,10 +43,6 @@ const RealButton = styled.button`
cursor: default;
pointer-events: none;
color: ${(props) => props.theme.white50};
svg {
fill: ${(props) => props.theme.white50};
}
}
${(props) =>
@@ -69,12 +65,8 @@ const RealButton = styled.button`
}
&:hover:not(:disabled) {
background: ${
props.borderOnHover
? props.theme.buttonNeutralBackground
: darken(0.05, props.theme.buttonNeutralBackground)
};
&:hover {
background: ${darken(0.05, props.theme.buttonNeutralBackground)};
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
@@ -82,10 +74,6 @@ const RealButton = styled.button`
&:disabled {
color: ${props.theme.textTertiary};
svg {
fill: ${props.theme.textTertiary};
}
}
`} ${(props) =>
props.danger &&
@@ -136,11 +124,11 @@ export type Props = {|
fullwidth?: boolean,
autoFocus?: boolean,
style?: Object,
as?: React.ComponentType<any> | string,
as?: React.ComponentType<any>,
to?: string,
onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean,
href?: string,
"data-on"?: string,
"data-event-category"?: string,
"data-event-action"?: string,
+1 -1
View File
@@ -12,7 +12,7 @@ export type Props = {|
name?: string,
disabled?: boolean,
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
note?: React.Node,
note?: string,
short?: boolean,
small?: boolean,
|};
+12 -18
View File
@@ -1,5 +1,5 @@
// @flow
import { sortBy, filter, uniq, isEqual } from "lodash";
import { sortBy, filter, uniq } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -12,20 +12,17 @@ import DocumentViews from "components/DocumentViews";
import Facepile from "components/Facepile";
import NudeButton from "components/NudeButton";
import Popover from "components/Popover";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
currentUserId: string,
|};
function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
const { users, presence } = useStores();
const { document } = props;
const { document, currentUserId } = props;
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
@@ -52,21 +49,18 @@ function Collaborators(props: Props) {
[document.collaboratorIds, users.orderedData, presentIds]
);
// load any users we don't yet have in memory
// load any users we don't know about
React.useEffect(() => {
const userIdsToFetch = uniq([
...document.collaboratorIds,
...presentIds,
]).filter((userId) => !users.get(userId));
if (!isEqual(requestedUserIds, userIdsToFetch)) {
setRequestedUserIds(userIdsToFetch);
if (users.isFetching) {
return;
}
userIdsToFetch
.filter((userId) => requestedUserIds.includes(userId))
.forEach((userId) => users.fetch(userId));
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
uniq([...document.collaboratorIds, ...presentIds]).forEach((userId) => {
if (!users.get(userId)) {
return users.fetch(userId);
}
});
}, [document, users, presentIds, document.collaboratorIds]);
const popover = usePopoverState({
gutter: 0,
+1 -1
View File
@@ -20,7 +20,7 @@ function ResolvedCollectionIcon({ collection, expanded, size }: Props) {
// otherwise it will be impossible to see against the dark background.
const color =
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
? getLuminance(collection.color) > 0.09
? getLuminance(collection.color) > 0.12
? collection.color
: "currentColor"
: collection.color;
-97
View File
@@ -1,97 +0,0 @@
// @flow
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import CommandBarResults from "components/CommandBarResults";
import rootActions from "actions/root";
import useCommandBarActions from "hooks/useCommandBarActions";
export const CommandBarOptions = {
animations: {
enterMs: 250,
exitMs: 200,
},
};
function CommandBar() {
const { t } = useTranslation();
useCommandBarActions(rootActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.actions[state.currentRootActionId],
}));
return (
<KBarPortal>
<Positioner>
<Animator>
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
);
}
function KBarPortal({ children }: { children: React.Node }) {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
}
const Positioner = styled(KBarPositioner)`
z-index: ${(props) => props.theme.depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
padding: 16px 20px;
width: 100%;
outline: none;
border: none;
background: ${(props) => props.theme.menuBackground};
color: ${(props) => props.theme.text};
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
const Animator = styled(KBarAnimator)`
max-width: 600px;
max-height: 75vh;
width: 90vw;
background: ${(props) => props.theme.menuBackground};
color: ${(props) => props.theme.text};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
transition: max-width 0.2s ease-in-out;
${breakpoint("desktopLarge")`
max-width: 740px;
`};
@media print {
display: none;
}
`;
export default observer(CommandBar);
-71
View File
@@ -1,71 +0,0 @@
// @flow
import { BackIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import Flex from "components/Flex";
import Key from "components/Key";
import type { CommandBarAction } from "types";
type Props = {|
action: CommandBarAction,
active: Boolean,
|};
function CommandBarItem({ action, active }: Props, ref) {
return (
<Item active={active} ref={ref}>
<Text align="center" gap={8}>
<Icon>
{action.icon ? (
React.cloneElement(action.icon, { size: 22 })
) : (
<ForwardIcon color="currentColor" size={22} />
)}
</Icon>
{action.name}
{action.children?.length ? "…" : ""}
</Text>
{action.shortcut?.length ? (
<div style={{ display: "grid", gridAutoFlow: "column", gap: "4px" }}>
{action.shortcut.map((sc) => (
<Key key={sc}>{sc}</Key>
))}
</div>
) : null}
</Item>
);
}
const Icon = styled.div`
width: 22px;
height: 22px;
color: ${(props) => props.theme.textSecondary};
`;
const Text = styled(Flex)`
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
`;
const Item = styled.div`
font-size: 15px;
padding: 12px 16px;
background: ${(props) =>
props.active ? props.theme.menuItemSelected : "none"};
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
min-width: 0;
`;
const ForwardIcon = styled(BackIcon)`
transform: rotate(180deg);
`;
export default React.forwardRef<Props, HTMLDivElement>(CommandBarItem);
-44
View File
@@ -1,44 +0,0 @@
// @flow
import { useMatches, KBarResults, NO_GROUP } from "kbar";
import * as React from "react";
import styled from "styled-components";
import CommandBarItem from "components/CommandBarItem";
export default function CommandBarResults() {
const matches = useMatches();
const items = React.useMemo(
() =>
matches
.reduce((acc, curr) => {
const { actions, name } = curr;
acc.push(name);
acc.push(...actions);
return acc;
}, [])
.filter((i) => i !== NO_GROUP),
[matches]
);
return (
<KBarResults
items={items}
maxHeight={400}
onRender={({ item, active }) =>
typeof item === "string" ? (
<Header>{item}</Header>
) : (
<CommandBarItem action={item} active={active} />
)
}
/>
);
}
const Header = styled.h3`
font-size: 13px;
letter-spacing: 0.04em;
margin: 0;
padding: 16px 0 4px 20px;
color: ${(props) => props.theme.textTertiary};
height: 36px;
`;
-59
View File
@@ -1,59 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade";
import NudeButton from "components/NudeButton";
import Tooltip from "components/Tooltip";
import useStores from "hooks/useStores";
function ConnectionStatus() {
const { ui } = useStores();
const theme = useTheme();
const { t } = useTranslation();
return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
}
placement="bottom"
>
<Button>
<Fade>
<DisconnectedIcon color={theme.sidebarText} />
</Fade>
</Button>
</Tooltip>
) : null;
}
const Button = styled(NudeButton)`
display: none;
position: fixed;
bottom: 0;
right: 32px;
margin: 24px;
${breakpoint("tablet")`
display: block;
`};
@media print {
display: none;
}
`;
const Centered = styled.div`
text-align: center;
`;
export default observer(ConnectionStatus);
-107
View File
@@ -1,107 +0,0 @@
// @flow
import isPrintableKeyEvent from "is-printable-key-event";
import * as React from "react";
import styled from "styled-components";
type Props = {|
disabled?: boolean,
readOnly?: boolean,
onChange?: (text: string) => void,
onBlur?: (event: SyntheticInputEvent<>) => void,
onInput?: (event: SyntheticInputEvent<>) => void,
onKeyDown?: (event: SyntheticInputEvent<>) => void,
placeholder?: string,
maxLength?: number,
autoFocus?: boolean,
className?: string,
children?: React.Node,
value: string,
|};
/**
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
function ContentEditable({
disabled,
onChange,
onInput,
onBlur,
onKeyDown,
value,
children,
className,
maxLength,
autoFocus,
placeholder,
readOnly,
...rest
}: Props) {
const ref = React.useRef<?HTMLSpanElement>();
const [innerHTML, setInnerHTML] = React.useState<string>(value);
const lastValue = React.useRef("");
const wrappedEvent = (callback) => (
event: SyntheticInputEvent<HTMLInputElement>
) => {
const text = ref.current?.innerText || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event.preventDefault();
return false;
}
if (text !== lastValue.current) {
lastValue.current = text;
onChange && onChange(text);
}
callback && callback(event);
};
React.useLayoutEffect(() => {
if (autoFocus) {
ref.current?.focus();
}
});
React.useEffect(() => {
if (value !== ref.current?.innerText) {
setInnerHTML(value);
}
}, [value]);
return (
<div className={className}>
<Content
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
ref={ref}
data-placeholder={placeholder}
role="textbox"
dangerouslySetInnerHTML={{ __html: innerHTML }}
{...rest}
/>
{children}
</div>
);
}
const Content = styled.span`
&:empty {
display: inline-block;
}
&:empty::before {
display: inline-block;
color: ${(props) => props.theme.placeholder};
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
content: attr(data-placeholder);
pointer-events: none;
height: 0;
}
`;
export default React.memo<Props>(ContentEditable);
+5 -12
View File
@@ -2,9 +2,8 @@
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {|
onClick?: (SyntheticEvent<>) => void | Promise<void>,
@@ -17,7 +16,6 @@ type Props = {|
as?: string | React.ComponentType<*>,
hide?: () => void,
level?: number,
icon?: React.Node,
|};
const MenuItem = ({
@@ -27,7 +25,6 @@ const MenuItem = ({
disabled,
as,
hide,
icon,
...rest
}: Props) => {
const handleClick = React.useCallback(
@@ -74,7 +71,6 @@ const MenuItem = ({
&nbsp;
</>
)}
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{children}
</MenuAnchor>
)}
@@ -88,12 +84,12 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
export const MenuAnchorCSS = css`
export const MenuAnchor = styled.a`
display: flex;
margin: 0;
border: 0;
padding: 12px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
padding-left: ${(props) => 12 + props.level * 10}px;
width: 100%;
min-height: 32px;
background: none;
@@ -134,12 +130,9 @@ export const MenuAnchorCSS = css`
`};
${breakpoint("tablet")`
padding: 4px 12px;
font-size: 14px;
padding: ${(props) => (props.$toggleable ? "4px 12px" : "6px 12px")};
font-size: 15px;
`};
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
export default MenuItem;
+6 -61
View File
@@ -2,33 +2,21 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
import { Link } from "react-router-dom";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
} from "reakit/Menu";
import styled from "styled-components";
import Flex from "components/Flex";
import MenuIconWrapper from "components/MenuIconWrapper";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import Separator from "./Separator";
import ContextMenu from ".";
import { actionToMenuItem } from "actions";
import useStores from "hooks/useStores";
import type {
MenuItem as TMenuItem,
Action,
ActionContext,
MenuSeparator,
MenuHeading,
} from "types";
import { type MenuItem as TMenuItem } from "types";
type Props = {|
items: TMenuItem[],
actions: (Action | MenuSeparator | MenuHeading)[],
context?: $Shape<ActionContext>,
|};
const Disclosure = styled(ExpandedIcon)`
@@ -60,7 +48,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unnecessary separators
// 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;
@@ -78,39 +66,8 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return filtered;
}
function Template({ items, actions, context, ...menu }: Props): React.Node {
const { t } = useTranslation();
const location = useLocation();
const stores = useStores();
const { ui } = stores;
const ctx = {
t,
isCommandBar: false,
isContextMenu: true,
activeCollectionId: ui.activeCollectionId,
activeDocumentId: ui.activeDocumentId,
location,
stores,
...context,
};
const filteredTemplates = filterTemplateItems(
actions
? actions.map((action) =>
action.type ? action : actionToMenuItem(action, ctx)
)
: items
);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) => !item.type && !!item.icon
);
return filteredTemplates.map((item, index) => {
if (iconIsPresentInAnyMenuItem && !item.type) {
item.icon = item.icon || <MenuIconWrapper />;
}
function Template({ items, ...menu }: Props): React.Node {
return filterTemplateItems(items).map((item, index) => {
if (item.to) {
return (
<MenuItem
@@ -119,7 +76,6 @@ function Template({ items, actions, context, ...menu }: Props): React.Node {
key={index}
disabled={item.disabled}
selected={item.selected}
icon={item.icon}
{...menu}
>
{item.title}
@@ -136,7 +92,6 @@ function Template({ items, actions, context, ...menu }: Props): React.Node {
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={item.icon}
{...menu}
>
{item.title}
@@ -152,7 +107,6 @@ function Template({ items, actions, context, ...menu }: Props): React.Node {
disabled={item.disabled}
selected={item.selected}
key={index}
icon={item.icon}
{...menu}
>
{item.title}
@@ -166,7 +120,7 @@ function Template({ items, actions, context, ...menu }: Props): React.Node {
key={index}
as={Submenu}
templateItems={item.items}
title={<Title title={item.title} icon={item.icon} />}
title={item.title}
{...menu}
/>
);
@@ -185,13 +139,4 @@ function Template({ items, actions, context, ...menu }: Props): React.Node {
});
}
function Title({ title, icon }) {
return (
<Flex align="center">
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
+8 -17
View File
@@ -4,7 +4,6 @@ import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import useMenuHeight from "hooks/useMenuHeight";
import usePrevious from "hooks/usePrevious";
import {
fadeIn,
@@ -19,12 +18,8 @@ type Props = {|
placement?: string,
animating?: boolean,
children: React.Node,
unstable_disclosureRef?: {
current: null | React.ElementRef<"button">,
},
onOpen?: () => void,
onClose?: () => void,
hide?: () => void,
|};
export default function ContextMenu({
@@ -34,8 +29,6 @@ export default function ContextMenu({
...rest
}: Props) {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef();
React.useEffect(() => {
if (rest.visible && !previousVisible) {
@@ -50,8 +43,6 @@ export default function ContextMenu({
}
}, [onOpen, onClose, previousVisible, rest.visible]);
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
@@ -68,8 +59,6 @@ export default function ContextMenu({
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
style={maxHeight && topAnchor ? { maxHeight } : undefined}
>
{rest.visible || rest.animating ? children : null}
</Background>
@@ -79,14 +68,14 @@ export default function ContextMenu({
</Menu>
{(rest.visible || rest.animating) && (
<Portal>
<Backdrop onClick={rest.hide} />
<Backdrop />
</Portal>
)}
</>
);
}
export const Backdrop = styled.div`
const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
@@ -101,7 +90,7 @@ export const Backdrop = styled.div`
`};
`;
export const Position = styled.div`
const Position = styled.div`
position: absolute;
z-index: ${(props) => props.theme.depths.menu};
@@ -115,7 +104,7 @@ export const Position = styled.div`
`};
`;
export const Background = styled.div`
const Background = styled.div`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
@@ -123,7 +112,6 @@ export const Background = styled.div`
border-radius: 6px;
padding: 6px 0;
min-width: 180px;
min-height: 44px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
@@ -137,9 +125,12 @@ export const Background = styled.div`
${breakpoint("tablet")`
animation: ${(props) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props) => (props.rightAnchor ? "75%" : "25%")} 0;
transform-origin: ${(props) =>
props.rightAnchor === "bottom-end" ? "75%" : "25%"} 0;
max-width: 276px;
background: ${(props) => props.theme.menuBackground};
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`};
`;
-37
View File
@@ -1,37 +0,0 @@
// @flow
import { observer } from "mobx-react-lite";
import * as React from "react";
import Guide from "components/Guide";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
function Dialogs() {
const { dialogs } = useStores();
const { guide, modalStack } = dialogs;
return (
<>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{[...modalStack].map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
>
{modal.content}
</Modal>
))}
</>
);
}
export default observer(Dialogs);
-124
View File
@@ -1,124 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Event from "models/Event";
import Button from "components/Button";
import Empty from "components/Empty";
import Flex from "components/Flex";
import PaginatedEventList from "components/PaginatedEventList";
import Scrollable from "components/Scrollable";
import useStores from "hooks/useStores";
import { documentUrl } from "utils/routeHelpers";
const EMPTY_ARRAY = [];
function DocumentHistory() {
const { events, documents } = useStores();
const { t } = useTranslation();
const match = useRouteMatch();
const history = useHistory();
const document = documents.getByUrl(match.params.documentSlug);
const eventsInDocument = document
? events.inDocument(document.id)
: EMPTY_ARRAY;
const onCloseHistory = () => {
history.push(documentUrl(document));
};
const items = React.useMemo(() => {
if (
eventsInDocument[0] &&
document &&
eventsInDocument[0].createdAt !== document.updatedAt
) {
eventsInDocument.unshift(
new Event({
name: "documents.latest_version",
documentId: document.id,
createdAt: document.updatedAt,
actor: document.updatedBy,
})
);
}
return eventsInDocument;
}, [eventsInDocument, document]);
return (
<Sidebar>
{document ? (
<Position column>
<Header>
<Title>{t("History")}</Title>
<Button
icon={<CloseIcon />}
onClick={onCloseHistory}
borderOnHover
neutral
/>
</Header>
<Scrollable topShadow>
<PaginatedEventList
fetch={events.fetchPage}
events={items}
options={{ documentId: document.id }}
document={document}
empty={<Empty>{t("Oh weird, there's nothing here")}</Empty>}
/>
</Scrollable>
</Position>
) : null}
</Sidebar>
);
}
const Position = styled(Flex)`
position: fixed;
top: 0;
bottom: 0;
width: ${(props) => props.theme.sidebarWidth}px;
`;
const Sidebar = styled(Flex)`
display: none;
position: relative;
flex-shrink: 0;
background: ${(props) => props.theme.background};
width: ${(props) => props.theme.sidebarWidth}px;
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;
color: ${(props) => props.theme.text};
flex-shrink: 0;
`;
export default observer(DocumentHistory);
@@ -0,0 +1,199 @@
// @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 * as React from "react";
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DocumentsStore from "stores/DocumentsStore";
import RevisionsStore from "stores/RevisionsStore";
import Button from "components/Button";
import Flex from "components/Flex";
import PlaceholderList from "components/List/Placeholder";
import Revision from "./components/Revision";
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
type Props = {
match: Match,
documents: DocumentsStore,
revisions: RevisionsStore,
history: RouterHistory,
};
@observer
class DocumentHistory extends React.Component<Props> {
@observable isLoaded: boolean = false;
@observable isFetching: boolean = false;
@observable offset: number = 0;
@observable allowLoadMore: boolean = true;
@observable redirectTo: ?string;
async componentDidMount() {
await this.loadMoreResults();
this.selectFirstRevision();
}
fetchResults = async () => {
this.isFetching = true;
const limit = DEFAULT_PAGINATION_LIMIT;
const results = await this.props.revisions.fetchPage({
limit,
offset: this.offset,
documentId: this.props.match.params.documentSlug,
});
if (
results &&
(results.length === 0 || results.length < DEFAULT_PAGINATION_LIMIT)
) {
this.allowLoadMore = false;
} else {
this.offset += DEFAULT_PAGINATION_LIMIT;
}
this.isLoaded = true;
this.isFetching = false;
};
selectFirstRevision = () => {
if (this.revisions.length) {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return;
this.props.history.replace(
documentHistoryUrl(document, this.revisions[0].id)
);
}
};
@action
loadMoreResults = async () => {
// Don't paginate if there aren't more results or were in the middle of fetching
if (!this.allowLoadMore || this.isFetching) return;
await this.fetchResults();
};
get revisions() {
const document = this.props.documents.getByUrl(
this.props.match.params.documentSlug
);
if (!document) return [];
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>
<PlaceholderList count={5} />
</Loading>
) : (
<ArrowKeyNavigation
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{this.revisions.map((revision, index) => (
<Revision
key={revision.id}
revision={revision}
document={document}
showMenu={index !== 0}
selected={this.props.match.params.revisionId === revision.id}
/>
))}
</ArrowKeyNavigation>
)}
{this.allowLoadMore && (
<Waypoint key={this.offset} onEnter={this.loadMoreResults} />
)}
</Wrapper>
</Sidebar>
);
}
}
const Loading = styled.div`
margin: 0 16px;
`;
const Wrapper = styled(Flex)`
position: fixed;
top: 0;
right: 0;
z-index: 1;
min-width: ${(props) => props.theme.sidebarWidth}px;
height: 100%;
overflow-y: auto;
overscroll-behavior: none;
`;
const Sidebar = styled(Flex)`
display: none;
background: ${(props) => props.theme.background};
min-width: ${(props) => props.theme.sidebarWidth}px;
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);
@@ -0,0 +1,87 @@
// @flow
import { format } from "date-fns";
import * as React from "react";
import { NavLink } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Document from "models/Document";
import Revision from "models/Revision";
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,
showMenu: boolean,
selected: boolean,
document: Document,
revision: Revision,
};
class RevisionListItem extends React.Component<Props> {
render() {
const { revision, document, showMenu, selected, theme } = this.props;
return (
<StyledNavLink
to={documentHistoryUrl(document, revision.id)}
activeStyle={{ background: theme.primary, color: theme.white }}
>
<Author>
<StyledAvatar src={revision.createdBy.avatarUrl} />{" "}
{revision.createdBy.name}
</Author>
<Meta>
<Time dateTime={revision.createdAt} tooltipDelay={250}>
{format(Date.parse(revision.createdAt), "MMMM do, yyyy h:mm a")}
</Time>
</Meta>
{showMenu && (
<StyledRevisionMenu
document={document}
revision={revision}
iconColor={selected ? theme.white : theme.textTertiary}
/>
)}
</StyledNavLink>
);
}
}
const StyledAvatar = styled(Avatar)`
border-color: transparent;
margin-right: 4px;
`;
const StyledRevisionMenu = styled(RevisionMenu)`
position: absolute;
right: 16px;
top: 20px;
`;
const StyledNavLink = styled(NavLink)`
color: ${(props) => props.theme.text};
display: block;
padding: 8px 16px;
font-size: 15px;
position: relative;
`;
const Author = styled(Flex)`
font-weight: 500;
padding: 0;
margin: 0;
`;
const Meta = styled.p`
font-size: 14px;
opacity: 0.75;
margin: 0 0 2px;
padding: 0;
`;
export default withTheme(RevisionListItem);
+3
View File
@@ -0,0 +1,3 @@
// @flow
import DocumentHistory from "./DocumentHistory";
export default DocumentHistory;
+3 -5
View File
@@ -20,7 +20,7 @@ import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import DocumentMenu from "menus/DocumentMenu";
import { newDocumentPath } from "utils/routeHelpers";
import { newDocumentUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
@@ -66,7 +66,6 @@ function DocumentListItem(props: Props, ref) {
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = policies.abilities(currentTeam.id);
const canCollection = policies.abilities(document.collectionId);
return (
<DocumentLink
@@ -127,12 +126,11 @@ function DocumentListItem(props: Props, ref) {
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument &&
canCollection.update && (
can.createDocument && (
<>
<Button
as={Link}
to={newDocumentPath(document.collectionId, {
to={newDocumentUrl(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
+4 -63
View File
@@ -3,19 +3,18 @@ import { lighten } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom";
import { Extension } from "rich-markdown-editor";
import styled, { withTheme } from "styled-components";
import embeds from "shared/embeds";
import { light } from "shared/theme";
import UiStore from "stores/UiStore";
import ErrorBoundary from "components/ErrorBoundary";
import Tooltip from "components/Tooltip";
import embeds from "../embeds";
import useMediaQuery from "hooks/useMediaQuery";
import useToasts from "hooks/useToasts";
import { type Theme } from "types";
import { isModKey } from "utils/keyboard";
import { uploadFile } from "utils/uploadFile";
import { isInternalUrl, isHash } from "utils/urls";
import { isInternalUrl } from "utils/urls";
const RichMarkdownEditor = React.lazy(() =>
import(/* webpackChunkName: "rich-markdown-editor" */ "rich-markdown-editor")
@@ -31,8 +30,6 @@ export type Props = {|
grow?: boolean,
disableEmbeds?: boolean,
ui?: UiStore,
style?: Object,
extensions?: Extension[],
shareId?: ?string,
autoFocus?: boolean,
template?: boolean,
@@ -40,7 +37,6 @@ export type Props = {|
maxLength?: number,
scrollTo?: string,
theme?: Theme,
className?: string,
handleDOMEvents?: Object,
readOnlyWriteCheckboxes?: boolean,
onBlur?: (event: SyntheticEvent<>) => any,
@@ -79,7 +75,7 @@ function Editor(props: PropsWithRef) {
const onClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (isHash(href)) {
if (href[0] === "#") {
window.location.href = href;
return;
}
@@ -228,8 +224,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
visibility: hidden;
}
.heading-name:first-child,
.heading-name:first-child + .ProseMirror-yjs-cursor {
.heading-name:first-child {
& + h1,
& + h2,
& + h3,
@@ -251,60 +246,6 @@ const StyledEditor = styled(RichMarkdownEditor)`
}
}
}
.ProseMirror {
& > .ProseMirror-yjs-cursor {
display: none;
}
.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;
transition: opacity 100ms ease-in-out;
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;
}
}
}
}
&.show-cursor-names .ProseMirror-yjs-cursor > div {
opacity: 1;
}
`;
const EditorTooltip = ({ children, ...props }) => (
-164
View File
@@ -1,164 +0,0 @@
// @flow
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
CheckboxIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import Avatar from "components/Avatar";
import Item, { Actions } from "components/List/Item";
import Time from "components/Time";
import RevisionMenu from "menus/RevisionMenu";
import { documentHistoryUrl } from "utils/routeHelpers";
type Props = {|
document: Document,
event: Event,
latest?: boolean,
|};
const EventListItem = ({ event, latest, document }: Props) => {
const { t } = useTranslation();
const opts = { userName: event.actor.name };
const isRevision = event.name === "revisions.create";
let meta, icon, to;
switch (event.name) {
case "revisions.create":
case "documents.latest_version": {
if (latest) {
icon = <CheckboxIcon color="currentColor" size={16} checked />;
meta = t("Latest version");
to = documentHistoryUrl(document);
break;
} else {
icon = <EditIcon color="currentColor" size={16} />;
meta = t("{{userName}} edited", opts);
to = documentHistoryUrl(document, event.modelId || "");
break;
}
}
case "documents.archive":
icon = <ArchiveIcon color="currentColor" size={16} />;
meta = t("{{userName}} archived", opts);
break;
case "documents.unarchive":
meta = t("{{userName}} restored", opts);
break;
case "documents.delete":
icon = <TrashIcon color="currentColor" size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.restore":
meta = t("{{userName}} moved from trash", opts);
break;
case "documents.publish":
icon = <PublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} published", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
break;
default:
console.warn("Unhandled event: ", event.name);
}
if (!meta) {
return null;
}
return (
<ListItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
tooltipDelay={250}
format="MMMM do, h:mm a"
relative={false}
addSuffix
/>
}
image={<Avatar src={event.actor?.avatarUrl} size={32} />}
subtitle={
<Subtitle>
{icon}
{meta}
</Subtitle>
}
actions={
isRevision ? (
<RevisionMenu document={document} revisionId={event.modelId} />
) : undefined
}
/>
);
};
const Subtitle = styled.span`
svg {
margin: -3px;
margin-right: 2px;
}
`;
const ListItem = styled(Item)`
border: 0;
position: relative;
margin: 8px;
padding: 8px;
border-radius: 8px;
img {
border-color: transparent;
}
&::before {
content: "";
display: block;
position: absolute;
top: -4px;
left: 23px;
width: 2px;
height: calc(100% + 8px);
background: ${(props) => props.theme.textSecondary};
opacity: 0.25;
}
&:nth-child(2)::before {
height: 50%;
top: auto;
bottom: -4px;
}
&:last-child::before {
height: 50%;
}
&:first-child:last-child::before {
display: none;
}
${Actions} {
opacity: 0.25;
transition: opacity 100ms ease-in-out;
}
&:hover {
${Actions} {
opacity: 1;
}
}
`;
export default EventListItem;
+5 -9
View File
@@ -76,6 +76,11 @@ const FilterOptions = ({
);
};
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
`;
const Note = styled(HelpText)`
margin-top: 2px;
margin-bottom: 0;
@@ -85,15 +90,6 @@ const Note = styled(HelpText)`
color: ${(props) => props.theme.textTertiary};
`;
const LabelWithNote = styled.div`
font-weight: 500;
text-align: left;
&:hover ${Note} {
color: ${(props) => props.theme.white50};
}
`;
const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
+1 -2
View File
@@ -39,8 +39,7 @@ const Guide = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScroll
hideOnEsc
preventBodyScrollhideOnEsc
hide={onRequestClose}
>
{(props) => (
+2 -2
View File
@@ -36,7 +36,7 @@ function Header({ breadcrumb, title, actions }: Props) {
}, []);
return (
<Wrapper align="center" shrink={false}>
<Wrapper align="center" isCompact={isScrolled} shrink={false}>
{breadcrumb ? <Breadcrumbs>{breadcrumb}</Breadcrumbs> : null}
{isScrolled ? (
<Title align="center" justify="flex-start" onClick={handleClickTitle}>
@@ -95,7 +95,7 @@ const Wrapper = styled(Flex)`
}
${breakpoint("tablet")`
padding: 16px 16px 0;
padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)};
justify-content: "center";
`};
`;
+2
View File
@@ -162,6 +162,8 @@ const CardContent = styled.div`
const Card = styled.div`
backdrop-filter: blur(10px);
background: ${(props) => props.theme.background};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
+8 -89
View File
@@ -1,41 +1,28 @@
// @flow
import {
BookmarkedIcon,
CollectionIcon,
CoinsIcon,
AcademicCapIcon,
BeakerIcon,
BuildingBlocksIcon,
CameraIcon,
CloudIcon,
CodeIcon,
EditIcon,
EmailIcon,
EyeIcon,
GlobeIcon,
InfoIcon,
ImageIcon,
LeafIcon,
LightBulbIcon,
MathIcon,
MoonIcon,
NotepadIcon,
PadlockIcon,
PaletteIcon,
PromoteIcon,
QuestionMarkIcon,
SportIcon,
SunIcon,
TargetIcon,
ToolsIcon,
VehicleIcon,
WarningIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import ContextMenu from "components/ContextMenu";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
@@ -52,10 +39,6 @@ const TwitterPicker = React.lazy(() =>
);
export const icons = {
bookmark: {
component: BookmarkedIcon,
keywords: "bookmark",
},
collection: {
component: CollectionIcon,
keywords: "collection",
@@ -64,10 +47,6 @@ export const icons = {
component: CoinsIcon,
keywords: "coins money finance sales income revenue cash",
},
camera: {
component: CameraIcon,
keywords: "photo picture",
},
academicCap: {
component: AcademicCapIcon,
keywords: "learn teach lesson guide tutorial onboarding training",
@@ -88,26 +67,10 @@ export const icons = {
component: CodeIcon,
keywords: "developer api code development engineering programming",
},
email: {
component: EmailIcon,
keywords: "email at",
},
eye: {
component: EyeIcon,
keywords: "eye view",
},
globe: {
component: GlobeIcon,
keywords: "world translate",
},
info: {
component: InfoIcon,
keywords: "info information",
},
image: {
component: ImageIcon,
keywords: "image photo picture",
},
leaf: {
component: LeafIcon,
keywords: "leaf plant outdoors nature ecosystem climate",
@@ -116,10 +79,6 @@ export const icons = {
component: LightBulbIcon,
keywords: "lightbulb idea",
},
math: {
component: MathIcon,
keywords: "math formula",
},
moon: {
component: MoonIcon,
keywords: "night moon dark",
@@ -140,10 +99,6 @@ export const icons = {
component: EditIcon,
keywords: "copy writing post blog",
},
promote: {
component: PromoteIcon,
keywords: "marketing promotion",
},
question: {
component: QuestionMarkIcon,
keywords: "question help support faq",
@@ -152,26 +107,10 @@ export const icons = {
component: SunIcon,
keywords: "day sun weather",
},
sport: {
component: SportIcon,
keywords: "sport outdoor racket game",
},
target: {
component: TargetIcon,
keywords: "target goal sales",
},
tools: {
component: ToolsIcon,
keywords: "tool settings",
},
vehicle: {
component: VehicleIcon,
keywords: "truck car travel transport",
},
warning: {
component: WarningIcon,
keywords: "warning alert error",
},
};
const colors = [
@@ -189,18 +128,18 @@ const colors = [
type Props = {|
onOpen?: () => void,
onClose?: () => void,
onChange: (color: string, icon: string) => void,
icon: string,
color: string,
|};
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
function IconPicker({ onOpen, icon, color, onChange }: Props) {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
const Component = icons[icon || "collection"].component;
return (
<Wrapper>
@@ -210,22 +149,14 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
<MenuButton {...menu}>
{(props) => (
<Button aria-label={t("Show menu")} {...props}>
<Icon
as={icons[icon || "collection"].component}
color={color}
size={30}
/>
<Component color={color} size={30} />
</Button>
)}
</MenuButton>
<ContextMenu
{...menu}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Choose icon")}
>
<ContextMenu {...menu} onOpen={onOpen} aria-label={t("Choose icon")}>
<Icons>
{Object.keys(icons).map((name) => {
const Component = icons[name].component;
return (
<MenuItem
key={name}
@@ -234,7 +165,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
>
{(props) => (
<IconButton style={style} {...props}>
<Icon as={icons[name].component} color={color} size={30} />
<Component color={color} size={30} />
</IconButton>
)}
</MenuItem>
@@ -256,20 +187,13 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
);
}
const Icon = styled.svg`
transition: fill 150ms ease-in-out;
`;
const Label = styled.label`
display: block;
`;
const Icons = styled.div`
padding: 16px 8px 0 16px;
${breakpoint("tablet")`
width: 276px;
`};
padding: 15px 9px 9px 15px;
width: 276px;
`;
const Button = styled(NudeButton)`
@@ -292,11 +216,6 @@ const Loading = styled(HelpText)`
const ColorPicker = styled(TwitterPicker)`
box-shadow: none !important;
background: transparent !important;
width: auto !important;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Wrapper = styled("div")`
@@ -1,6 +1,6 @@
// @flow
import * as React from "react";
import { cdnPath } from "../../utils/urls";
import { cdnPath } from "utils/urls";
type Props = {|
alt: string,
+67 -68
View File
@@ -1,17 +1,20 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, type RouterHistory } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import Input from "./Input";
import useBoolean from "hooks/useBoolean";
import useKeyDown from "hooks/useKeyDown";
import { isModKey } from "utils/keyboard";
import { type Theme } from "types";
import { meta } from "utils/keyboard";
import { searchUrl } from "utils/routeHelpers";
type Props = {|
type Props = {
history: RouterHistory,
theme: Theme,
source: string,
placeholder?: string,
label?: string,
@@ -20,77 +23,73 @@ type Props = {|
value: string,
onChange: (event: SyntheticInputEvent<>) => mixed,
onKeyDown: (event: SyntheticKeyboardEvent<HTMLInputElement>) => mixed,
|};
t: TFunction,
};
function InputSearchPage({
onKeyDown,
value,
onChange,
placeholder,
label,
collectionId,
source,
}: Props) {
const inputRef = React.useRef();
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
const [isFocused, setFocused, setUnfocused] = useBoolean(false);
@observer
class InputSearchPage extends React.Component<Props> {
input: ?Input;
@observable focused: boolean = false;
const focus = React.useCallback(() => {
inputRef.current?.focus();
}, []);
@keydown(`${meta}+f`)
focus(ev: SyntheticEvent<>) {
ev.preventDefault();
useKeyDown("f", (ev: KeyboardEvent) => {
if (isModKey(ev)) {
ev.preventDefault();
focus();
if (this.input) {
this.input.focus();
}
});
}
const handleKeyDown = React.useCallback(
(ev: SyntheticKeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
history.push(
searchUrl(ev.currentTarget.value, {
collectionId,
ref: source,
})
);
}
handleSearchInput = (ev: SyntheticInputEvent<>) => {
ev.preventDefault();
this.props.history.push(
searchUrl(ev.target.value, {
collectionId: this.props.collectionId,
ref: this.props.source,
})
);
};
if (onKeyDown) {
onKeyDown(ev);
}
},
[history, collectionId, source, onKeyDown]
);
handleFocus = () => {
this.focused = true;
};
return (
<InputMaxWidth
ref={inputRef}
type="search"
placeholder={placeholder || `${t("Search")}`}
value={value}
onChange={onChange}
onKeyDown={handleKeyDown}
icon={
<SearchIcon
color={isFocused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
label={label}
onFocus={setFocused}
onBlur={setUnfocused}
margin={0}
labelHidden
/>
);
handleBlur = () => {
this.focused = false;
};
render() {
const { t, value, onChange, onKeyDown } = this.props;
const { theme, placeholder = `${t("Search")}` } = this.props;
return (
<InputMaxWidth
ref={(ref) => (this.input = ref)}
type="search"
placeholder={placeholder}
onInput={this.handleSearchInput}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
icon={
<SearchIcon
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
/>
}
label={this.props.label}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
margin={0}
labelHidden
/>
);
}
}
const InputMaxWidth = styled(Input)`
max-width: 30vw;
`;
export default observer(InputSearchPage);
export default withTranslation()<InputSearchPage>(
withTheme(withRouter(InputSearchPage))
);
+74 -259
View File
@@ -1,278 +1,93 @@
// @flow
import {
Select,
SelectOption,
useSelectState,
useSelectPopover,
SelectPopover,
} from "@renderlesskit/react";
import { CheckmarkIcon } from "outline-icons";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { css } from "styled-components";
import Button, { Inner } from "components/Button";
import HelpText from "components/HelpText";
import { Position, Background, Backdrop } from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import { LabelText } from "./Input";
import useMenuHeight from "hooks/useMenuHeight";
import styled from "styled-components";
import { Outline, LabelText } from "./Input";
export type Option = { label: string, value: string };
const Select = styled.select`
border: 0;
flex: 1;
padding: 4px 0;
margin: 0 12px;
outline: none;
background: none;
color: ${(props) => props.theme.text};
height: 30px;
export type Props = {
value?: string,
label?: string,
nude?: boolean,
ariaLabel: string,
short?: boolean,
disabled?: boolean,
className?: string,
labelHidden?: boolean,
icon?: React.Node,
options: Option[],
note?: React.Node,
onChange: (string) => Promise<void> | void,
};
const getOptionFromValue = (options: Option[], value) => {
return options.find((option) => option.value === value) || {};
};
const InputSelect = (props: Props) => {
const {
value,
label,
className,
labelHidden,
options,
short,
ariaLabel,
onChange,
disabled,
nude,
note,
icon,
} = props;
const select = useSelectState({
gutter: 0,
modal: true,
selectedValue: value,
animated: 200,
});
const popOver = useSelectPopover({
...select,
hideOnClickOutside: true,
preventBodyScroll: true,
disabled,
});
const previousValue = React.useRef(value);
const contentRef = React.useRef();
const selectedRef = React.useRef();
const buttonRef = React.useRef();
const [offset, setOffset] = React.useState(0);
const minWidth = buttonRef.current?.offsetWidth || 0;
const maxHeight = useMenuHeight(
select.visible,
select.unstable_disclosureRef
);
React.useEffect(() => {
if (previousValue.current === select.selectedValue) return;
previousValue.current = select.selectedValue;
async function load() {
await onChange(select.selectedValue);
}
load();
}, [onChange, select.selectedValue]);
const wrappedLabel = <LabelText>{label}</LabelText>;
const selectedValueIndex = options.findIndex(
(option) => option.value === select.selectedValue
);
// Ensure selected option is visible when opening the input
React.useEffect(() => {
if (!select.animating && selectedRef.current) {
scrollIntoView(selectedRef.current, {
scrollMode: "if-needed",
behavior: "instant",
block: "start",
});
}
}, [select.animating]);
React.useLayoutEffect(() => {
if (select.visible) {
const offset = Math.round(
(selectedRef.current?.getBoundingClientRect().top || 0) -
(contentRef.current?.getBoundingClientRect().top || 0)
);
setOffset(offset);
}
}, [select.visible]);
return (
<>
<Wrapper short={short}>
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
<Select {...select} disabled={disabled} ref={buttonRef}>
{(props) => (
<StyledButton
neutral
disclosure
className={className}
nude={nude}
icon={icon}
{...props}
>
{getOptionFromValue(options, select.selectedValue).label || (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popOver} aria-label={ariaLabel}>
{(props) => {
const topAnchor = props.style.top === "0";
const rightAnchor = props.placement === "bottom-end";
// offset top of select to place selected item under the cursor
if (selectedValueIndex !== -1) {
props.style.top = `-${offset + 32}px`;
}
return (
<Positioner {...props}>
<Background
dir="auto"
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
style={
maxHeight && topAnchor
? { maxHeight, minWidth }
: { minWidth }
}
>
{select.visible || select.animating
? options.map((option) => (
<StyledSelectOption
{...select}
value={option.value}
key={option.value}
animating={select.animating}
ref={
select.selectedValue === option.value
? selectedRef
: undefined
}
>
{select.selectedValue !== undefined && (
<>
{select.selectedValue === option.value ? (
<CheckmarkIcon color="currentColor" />
) : (
<Spacer />
)}
&nbsp;
</>
)}
{option.label}
</StyledSelectOption>
))
: null}
</Background>
</Positioner>
);
}}
</SelectPopover>
</Wrapper>
{note && <HelpText small>{note}</HelpText>}
{(select.visible || select.animating) && <Backdrop />}
</>
);
};
const Placeholder = styled.span`
color: ${(props) => props.theme.placeholder};
`;
const Spacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledButton = styled(Button)`
font-weight: normal;
text-transform: none;
margin-bottom: 16px;
display: block;
width: 100%;
${(props) =>
props.nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 16px;
padding-right: 8px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
}
svg {
justify-self: flex-end;
margin-left: auto;
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
}
`;
export const StyledSelectOption = styled(SelectOption)`
${MenuAnchorCSS}
${(props) =>
props.animating &&
css`
pointer-events: none;
`}
`;
const Wrapper = styled.label`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const Positioner = styled(Position)`
&.focus-visible {
${StyledSelectOption} {
&[aria-selected="true"] {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
box-shadow: none;
cursor: pointer;
export type Option = { label: string, value: string };
svg {
fill: ${(props) => props.theme.white};
}
}
}
export type Props = {
value?: string,
label?: string,
short?: boolean,
className?: string,
labelHidden?: boolean,
options: Option[],
onBlur?: () => void,
onFocus?: () => void,
};
@observer
class InputSelect extends React.Component<Props> {
@observable focused: boolean = false;
handleBlur = () => {
this.focused = false;
};
handleFocus = () => {
this.focused = true;
};
render() {
const {
label,
className,
labelHidden,
options,
short,
...rest
} = this.props;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
<Wrapper short={short}>
{label &&
(labelHidden ? (
<VisuallyHidden>{wrappedLabel}</VisuallyHidden>
) : (
wrappedLabel
))}
<Outline focused={this.focused} className={className}>
<Select onBlur={this.handleBlur} onFocus={this.handleFocus} {...rest}>
{options.map((option) => (
<option value={option.value} key={option.value}>
{option.label}
</option>
))}
</Select>
</Outline>
</Wrapper>
);
}
`;
}
export default InputSelect;
+3 -17
View File
@@ -4,33 +4,19 @@ import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "./InputSelect";
export default function InputSelectPermission(
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
props: $Rest<Props, { options: Array<Option> }>
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const handleChange = React.useCallback(
(value) => {
if (value === "no_access") {
value = "";
}
onChange(value);
},
[onChange]
);
return (
<InputSelect
label={t("Default access")}
options={[
{ label: t("View and edit"), value: "read_write" },
{ label: t("View only"), value: "read" },
{ label: t("No access"), value: "no_access" },
{ label: t("No access"), value: "" },
]}
ariaLabel={t("Default access")}
value={value || "no_access"}
onChange={handleChange}
{...rest}
{...props}
/>
);
}
-25
View File
@@ -1,25 +0,0 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import InputSelect, { type Props, type Option } from "components/InputSelect";
const InputSelectRole = (
props: $Rest<$Exact<Props>, {| options: Array<Option>, ariaLabel: string |}>
) => {
const { t } = useTranslation();
return (
<InputSelect
label={t("Role")}
options={[
{ label: t("Member"), value: "member" },
{ label: t("Viewer"), value: "viewer" },
{ label: t("Admin"), value: "admin" },
]}
ariaLabel={t("Role")}
{...props}
/>
);
};
export default InputSelectRole;
+62 -47
View File
@@ -4,10 +4,12 @@ import { observer, inject } from "mobx-react";
import { MenuIcon } from "outline-icons";
import * as React from "react";
import { Helmet } from "react-helmet";
import { withTranslation } from "react-i18next";
import { withTranslation, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import {
Switch,
Route,
Redirect,
withRouter,
type RouterHistory,
} from "react-router-dom";
@@ -18,35 +20,24 @@ import DocumentsStore from "stores/DocumentsStore";
import PoliciesStore from "stores/PoliciesStore";
import UiStore from "stores/UiStore";
import ErrorSuspended from "scenes/ErrorSuspended";
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
import Button from "components/Button";
import DocumentHistory from "components/DocumentHistory";
import Flex from "components/Flex";
import Guide from "components/Guide";
import { LoadingIndicatorBar } from "components/LoadingIndicator";
import RegisterKeyDown from "components/RegisterKeyDown";
import Sidebar from "components/Sidebar";
import SettingsSidebar from "components/Sidebar/Settings";
import SkipNavContent from "components/SkipNavContent";
import SkipNavLink from "components/SkipNavLink";
import { isModKey } from "utils/keyboard";
import { meta } from "utils/keyboard";
import {
homeUrl,
searchUrl,
matchDocumentSlug as slug,
newDocumentPath,
settingsPath,
newDocumentUrl,
} from "utils/routeHelpers";
const DocumentHistory = React.lazy(() =>
import(
/* webpackChunkName: "document-history" */ "components/DocumentHistory"
)
);
const CommandBar = React.lazy(() =>
import(
/* webpackChunkName: "command-bar" */
"components/CommandBar"
)
);
type Props = {
documents: DocumentsStore,
children?: ?React.Node,
@@ -57,50 +48,70 @@ type Props = {
history: RouterHistory,
policies: PoliciesStore,
notifications?: React.Node,
i18n: Object,
t: TFunction,
};
@observer
class Layout extends React.Component<Props> {
scrollable: ?HTMLDivElement;
@observable redirectTo: ?string;
@observable keyboardShortcutsOpen: boolean = false;
goToSearch = (ev: KeyboardEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.props.history.push(searchUrl());
componentDidUpdate() {
if (this.redirectTo) {
this.redirectTo = undefined;
}
}
@keydown(`${meta}+.`)
handleToggleSidebar() {
this.props.ui.toggleCollapsedSidebar();
}
@keydown("shift+/")
handleOpenKeyboardShortcuts() {
this.keyboardShortcutsOpen = true;
}
handleCloseKeyboardShortcuts = () => {
this.keyboardShortcutsOpen = false;
};
goToNewDocument = () => {
@keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) {
ev.preventDefault();
ev.stopPropagation();
this.redirectTo = searchUrl();
}
@keydown("d")
goToDashboard() {
this.redirectTo = homeUrl();
}
@keydown("n")
goToNewDocument() {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return;
const can = this.props.policies.abilities(activeCollectionId);
if (!can.update) return;
this.props.history.push(newDocumentPath(activeCollectionId));
};
this.props.history.push(newDocumentUrl(activeCollectionId));
}
render() {
const { auth, ui } = this.props;
const { auth, t, ui } = this.props;
const { user, team } = auth;
const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
return (
<Container column auto>
<RegisterKeyDown trigger="n" handler={this.goToNewDocument} />
<RegisterKeyDown trigger="t" handler={this.goToSearch} />
<RegisterKeyDown trigger="/" handler={this.goToSearch} />
<RegisterKeyDown
trigger="."
handler={(event) => {
if (isModKey(event)) {
ui.toggleCollapsedSidebar();
}
}}
/>
<Helmet>
<title>{team && team.name ? team.name : "Outline"}</title>
<meta
@@ -123,7 +134,7 @@ class Layout extends React.Component<Props> {
<Container auto>
{showSidebar && (
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route path="/settings" component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
)}
@@ -143,16 +154,20 @@ class Layout extends React.Component<Props> {
{this.props.children}
</Content>
<React.Suspense fallback={null}>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</React.Suspense>
<Switch>
<Route
path={`/doc/${slug}/history/:revisionId?`}
component={DocumentHistory}
/>
</Switch>
</Container>
<CommandBar />
<Guide
isOpen={this.keyboardShortcutsOpen}
onRequestClose={this.handleCloseKeyboardShortcuts}
title={t("Keyboard shortcuts")}
>
<KeyboardShortcuts />
</Guide>
</Container>
);
}
+24 -54
View File
@@ -1,64 +1,40 @@
// @flow
import * as React from "react";
import styled, { useTheme } from "styled-components";
import styled from "styled-components";
import Flex from "components/Flex";
import NavLink from "components/NavLink";
type Props = {|
type Props = {
image?: React.Node,
to?: string,
title: React.Node,
subtitle?: React.Node,
actions?: React.Node,
border?: boolean,
small?: boolean,
|};
};
const ListItem = (
{ image, title, subtitle, actions, small, border, to, ...rest }: Props,
ref
) => {
const theme = useTheme();
const ListItem = ({
image,
title,
subtitle,
actions,
small,
border,
}: Props) => {
const compact = !subtitle;
const content = (selected) => (
<>
{image && <Image>{image}</Image>}
<Content
justify={compact ? "center" : undefined}
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
</Subtitle>
)}
</Content>
{actions && (
<Actions $selected={selected} gap={4}>
{actions}
</Actions>
)}
</>
);
return (
<Wrapper
ref={ref}
$border={border}
activeStyle={{ background: theme.primary }}
{...rest}
as={to ? NavLink : undefined}
to={to}
>
{to ? content : content(false)}
<Wrapper compact={compact} $border={border}>
{image && <Image>{image}</Image>}
<Content align={compact ? "center" : undefined} column={!compact}>
<Heading $small={small}>{title}</Heading>
{subtitle && <Subtitle $small={small}>{subtitle}</Subtitle>}
</Content>
{actions && <Actions>{actions}</Actions>}
</Wrapper>
);
};
const Wrapper = styled.div`
const Wrapper = styled.li`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) => (props.$border === false ? "8px 0" : 0)};
@@ -81,34 +57,28 @@ const Image = styled(Flex)`
`;
const Heading = styled.p`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-size: ${(props) => (props.$small ? 15 : 16)}px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
line-height: 1.2;
margin: 0;
`;
const Content = styled(Flex)`
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
`;
const Subtitle = styled.p`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
color: ${(props) => props.theme.textTertiary};
margin-top: -2px;
`;
export const Actions = styled(Flex)`
const Actions = styled.div`
align-self: center;
justify-content: center;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;
export default React.forwardRef<Props, HTMLDivElement>(ListItem);
export default ListItem;
+41 -21
View File
@@ -1,9 +1,39 @@
// @flow
import { format as formatDate, formatDistanceToNow } from "date-fns";
import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
faIR,
fr,
es,
it,
ja,
ko,
ptBR,
pt,
zhCN,
zhTW,
ru,
} from "date-fns/locale";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
import { dateLocale } from "utils/i18n";
const locales = {
en_US: enUS,
de_DE: de,
es_ES: es,
fa_IR: faIR,
fr_FR: fr,
it_IT: it,
ja_JP: ja,
ko_KR: ko,
pt_BR: ptBR,
pt_PT: pt,
zh_CN: zhCN,
zh_TW: zhTW,
ru_RU: ru,
};
let callbacks = [];
@@ -27,8 +57,6 @@ type Props = {
tooltipDelay?: number,
addSuffix?: boolean,
shorten?: boolean,
relative?: boolean,
format?: string,
};
function LocaleTime({
@@ -36,8 +64,6 @@ function LocaleTime({
children,
dateTime,
shorten,
format,
relative,
tooltipDelay,
}: Props) {
const userLocale = useUserLocale();
@@ -56,31 +82,25 @@ function LocaleTime({
};
}, []);
const locale = dateLocale(userLocale);
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
let content = formatDistanceToNow(Date.parse(dateTime), {
addSuffix,
locale,
locale: userLocale ? locales[userLocale] : undefined,
});
if (shorten) {
relativeContent = relativeContent
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
const tooltipContent = formatDate(
Date.parse(dateTime),
format || "MMMM do, yyyy h:mm a",
{ locale }
);
const content =
children || relative !== false ? relativeContent : tooltipContent;
return (
<Tooltip tooltip={tooltipContent} delay={tooltipDelay} placement="bottom">
<time dateTime={dateTime}>{content}</time>
<Tooltip
tooltip={format(Date.parse(dateTime), "MMMM do, yyyy h:mm a")}
delay={tooltipDelay}
placement="bottom"
>
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
}
-11
View File
@@ -1,11 +0,0 @@
// @flow
import styled from "styled-components";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
`;
export default MenuIconWrapper;
+2 -5
View File
@@ -52,9 +52,7 @@ const Modal = ({
}
});
if (!isOpen && !wasOpen) {
return null;
}
if (!isOpen) return null;
return (
<DialogBackdrop {...dialog}>
@@ -63,8 +61,7 @@ const Modal = ({
<Dialog
{...dialog}
aria-label={title}
preventBodyScroll
hideOnEsc
preventBodyScrollhideOnEsc
hide={onRequestClose}
>
{(props) => (
-26
View File
@@ -1,26 +0,0 @@
// @flow
import * as React from "react";
import { NavLink, Route, type Match } from "react-router-dom";
type Props = {
children?: (match: Match) => React.Node,
exact?: boolean,
to: string,
};
export default function NavLinkWithChildrenFunc({
to,
exact = false,
children,
...rest
}: Props) {
return (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink {...rest} to={to} exact={exact}>
{children ? children(match) : null}
</NavLink>
)}
</Route>
);
}
-1
View File
@@ -12,7 +12,6 @@ const Button = styled.button`
padding: 0;
cursor: pointer;
user-select: none;
color: inherit;
`;
export default React.forwardRef<any, typeof Button>(
+1 -1
View File
@@ -2,8 +2,8 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import { cdnPath } from "../../shared/utils/urls";
import useStores from "hooks/useStores";
import { cdnPath } from "utils/urls";
type Props = {|
title: string,
-53
View File
@@ -1,53 +0,0 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Document from "models/Document";
import Event from "models/Event";
import PaginatedList from "components/PaginatedList";
import EventListItem from "./EventListItem";
type Props = {|
events: Event[],
document: Document,
fetch: (options: ?Object) => Promise<void>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
|};
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
events,
fetch,
options,
document,
...rest
}: Props) {
return (
<PaginatedList
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...rest}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
/>
);
});
const Heading = styled("h3")`
font-size: 14px;
padding: 0 12px;
`;
export default PaginatedEventList;
+6 -49
View File
@@ -2,26 +2,20 @@
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
import { isEqual } from "lodash";
import { observable, action } from "mobx";
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import { Waypoint } from "react-waypoint";
import AuthStore from "stores/AuthStore";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import DelayedMount from "components/DelayedMount";
import PlaceholderList from "components/List/Placeholder";
import { dateToHeading } from "utils/dates";
type Props = {
fetch?: (options: ?Object) => Promise<any>,
fetch?: (options: ?Object) => Promise<void>,
options?: Object,
heading?: React.Node,
empty?: React.Node,
items: any[],
auth: AuthStore,
renderItem: (any, index: number) => React.Node,
renderHeading?: (name: React.Element<any> | string) => React.Node,
t: TFunction,
renderItem: (any) => React.Node,
};
@observer
@@ -107,9 +101,8 @@ class PaginatedList extends React.Component<Props> {
};
render() {
const { items, heading, auth, empty, renderHeading } = this.props;
const { items, heading, empty } = this.props;
let previousHeading = "";
const showLoading =
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading;
@@ -126,41 +119,7 @@ class PaginatedList extends React.Component<Props> {
mode={ArrowKeyNavigation.mode.VERTICAL}
defaultActiveChildIndex={0}
>
{items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
if (!renderHeading) {
return children;
}
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
auth.user?.language
);
// If the heading is different to any previous heading then we
// should render it, otherwise the item can go under the previous
// heading
if (!previousHeading || currentHeading !== previousHeading) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
);
}
return children;
})}
{items.slice(0, this.renderCount).map(this.props.renderItem)}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
@@ -177,6 +136,4 @@ class PaginatedList extends React.Component<Props> {
}
}
export const Component = PaginatedList;
export default withTranslation()<PaginatedList>(inject("auth")(PaginatedList));
export default PaginatedList;
+3 -12
View File
@@ -2,21 +2,15 @@
import "../stores";
import { shallow } from "enzyme";
import * as React from "react";
import AuthStore from "stores/AuthStore";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import RootStore from "stores/RootStore";
import { runAllPromises } from "../test/support";
import { Component as PaginatedList } from "./PaginatedList";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
const rootStore = new RootStore();
const props = { auth: new AuthStore(rootStore), t: (string) => "test" };
it("with no items renders nothing", () => {
const list = shallow(
<PaginatedList items={[]} renderItem={render} {...props} />
);
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
expect(list).toEqual({});
});
@@ -26,7 +20,6 @@ describe("PaginatedList", () => {
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
{...props}
/>
);
expect(list.text()).toEqual("Sorry, no results");
@@ -42,7 +35,6 @@ describe("PaginatedList", () => {
fetch={fetch}
options={options}
renderItem={render}
{...props}
/>
);
expect(fetch).toHaveBeenCalledWith({
@@ -54,7 +46,7 @@ describe("PaginatedList", () => {
it("calls fetch when options prop changes", async () => {
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
const fetch = jest.fn().mockReturnValue(Promise.resolve(fetchedItems));
const fetch = jest.fn().mockReturnValue(fetchedItems);
const list = shallow(
<PaginatedList
@@ -62,7 +54,6 @@ describe("PaginatedList", () => {
fetch={fetch}
options={{ id: "one" }}
renderItem={render}
{...props}
/>
);
+10 -37
View File
@@ -6,45 +6,18 @@ import Fade from "components/Fade";
import Flex from "components/Flex";
import PlaceholderText from "components/PlaceholderText";
export default function PlaceholderDocument({
includeTitle,
delay,
}: {
includeTitle?: boolean,
delay?: number,
}) {
const content = (
<>
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</>
);
if (includeTitle === false) {
return (
<DelayedMount delay={delay}>
<Fade>
<Flex column auto>
{content}
</Flex>
</Fade>
</DelayedMount>
);
}
export default function PlaceholderDocument(props: Object) {
return (
<DelayedMount delay={delay}>
<DelayedMount>
<Wrapper>
<Fade>
<Flex column auto>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
{content}
</Flex>
</Fade>
<Flex column auto {...props}>
<PlaceholderText height={34} maxWidth={70} />
<PlaceholderText delay={0.2} maxWidth={40} />
<br />
<PlaceholderText delay={0.2} />
<PlaceholderText delay={0.4} />
<PlaceholderText delay={0.6} />
</Flex>
</Wrapper>
</DelayedMount>
);
+2
View File
@@ -27,6 +27,8 @@ const Contents = styled.div`
overflow-y: scroll;
width: ${(props) => props.width}px;
box-shadow: ${(props) => props.theme.menuShadow};
border: ${(props) =>
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
`;
export default Popover;
-17
View File
@@ -1,17 +0,0 @@
// @flow
import useKeyDown, { type KeyFilter } from "hooks/useKeyDown";
type Props = {
trigger: KeyFilter,
handler: (event: KeyboardEvent) => void,
};
/**
* This method is a wrapper around the useKeyDown hook to allow easier use in
* class components that have not yet been converted to functions. Do not use
* this method in functional components.
*/
export default function RegisterKeyDown({ trigger, handler }: Props) {
useKeyDown(trigger, handler);
return null;
}
+16 -14
View File
@@ -1,29 +1,31 @@
// @flow
// based on: https://reacttraining.com/react-router/web/guides/scroll-restoration
import * as React from "react";
import { useLocation } from "react-router-dom";
import usePrevious from "hooks/usePrevious";
import { withRouter } from "react-router-dom";
import type { Location } from "react-router-dom";
type Props = {|
type Props = {
location: Location,
children: React.Node,
|};
};
export default function ScrollToTop({ children }: Props) {
const location = useLocation();
const previousLocationPathname = usePrevious(location.pathname);
React.useEffect(() => {
if (location.pathname === previousLocationPathname) return;
class ScrollToTop extends React.Component<Props> {
componentDidUpdate(prevProps) {
if (this.props.location.pathname === prevProps.location.pathname) return;
// exception for when entering or exiting document edit, scroll position should not reset
if (
location.pathname.match(/\/edit\/?$/) ||
previousLocationPathname?.match(/\/edit\/?$/)
this.props.location.pathname.match(/\/edit\/?$/) ||
prevProps.location.pathname.match(/\/edit\/?$/)
)
return;
window.scrollTo(0, 0);
}, [location.pathname, previousLocationPathname]);
}
return children;
render() {
return this.props.children;
}
}
export default withRouter(ScrollToTop);
+6 -16
View File
@@ -11,17 +11,14 @@ type Props = {|
flex?: boolean,
|};
function Scrollable(
{ shadow, topShadow, bottomShadow, flex, ...rest }: Props,
ref: any
) {
const fallbackRef = React.useRef<?HTMLDivElement>();
function Scrollable({ shadow, topShadow, bottomShadow, flex, ...rest }: Props) {
const ref = React.useRef<?HTMLDivElement>();
const [topShadowVisible, setTopShadow] = React.useState(false);
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
const { height } = useWindowSize();
const updateShadows = React.useCallback(() => {
const c = (ref || fallbackRef).current;
const c = ref.current;
if (!c) return;
const scrollTop = c.scrollTop;
@@ -36,14 +33,7 @@ function Scrollable(
if (bsv !== bottomShadowVisible) {
setBottomShadow(bsv);
}
}, [
shadow,
topShadow,
bottomShadow,
ref,
topShadowVisible,
bottomShadowVisible,
]);
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
React.useEffect(() => {
updateShadows();
@@ -51,7 +41,7 @@ function Scrollable(
return (
<Wrapper
ref={ref || fallbackRef}
ref={ref}
onScroll={updateShadows}
$flex={flex}
$topShadowVisible={topShadowVisible}
@@ -85,4 +75,4 @@ const Wrapper = styled.div`
transition: all 100ms ease-in-out;
`;
export default observer(React.forwardRef(Scrollable));
export default observer(Scrollable);
+72 -25
View File
@@ -2,52 +2,73 @@
import { observer } from "mobx-react";
import {
EditIcon,
SearchIcon,
ShapesIcon,
HomeIcon,
PlusIcon,
SearchIcon,
SettingsIcon,
ShapesIcon,
StarredIcon,
} from "outline-icons";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Flex from "components/Flex";
import Modal from "components/Modal";
import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar";
import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarAction from "./components/SidebarAction";
import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import TeamButton from "./components/TeamButton";
import TrashLink from "./components/TrashLink";
import { inviteUser } from "actions/definitions/users";
import useCurrentTeam from "hooks/useCurrentTeam";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import AccountMenu from "menus/AccountMenu";
import {
homePath,
searchUrl,
draftsPath,
templatesPath,
settingsPath,
} from "utils/routeHelpers";
function MainSidebar() {
const { t } = useTranslation();
const { policies, documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
const [
createCollectionModalOpen,
setCreateCollectionModalOpen,
] = React.useState(false);
React.useEffect(() => {
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
const handleCreateCollectionModalOpen = React.useCallback(
(ev: SyntheticEvent<>) => {
ev.preventDefault();
setCreateCollectionModalOpen(true);
},
[]
);
const handleCreateCollectionModalClose = React.useCallback(() => {
setCreateCollectionModalOpen(false);
}, []);
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
setInviteModalOpen(true);
}, []);
const handleInviteModalClose = React.useCallback(() => {
setInviteModalOpen(false);
}, []);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
const html5Options = React.useMemo(() => ({ rootElement: dndArea }), [
@@ -74,23 +95,29 @@ function MainSidebar() {
<Scrollable flex topShadow>
<Section>
<SidebarLink
to={homePath()}
to="/home"
icon={<HomeIcon color="currentColor" />}
exact={false}
label={t("Home")}
/>
<SidebarLink
to={{
pathname: searchUrl(),
pathname: "/search",
state: { fromMenu: true },
}}
icon={<SearchIcon color="currentColor" />}
label={t("Search")}
exact={false}
/>
<SidebarLink
to="/starred"
icon={<StarredIcon color="currentColor" />}
exact={false}
label={t("Starred")}
/>
{can.createDocument && (
<SidebarLink
to={draftsPath()}
to="/drafts"
icon={<EditIcon color="currentColor" />}
label={
<Drafts align="center">
@@ -108,24 +135,21 @@ function MainSidebar() {
/>
)}
</Section>
<Starred />
<Section auto>
<Collections />
<Collections
onCreateCollection={handleCreateCollectionModalOpen}
/>
</Section>
<Section>
{can.createDocument && (
<>
<SidebarLink
to={templatesPath()}
to="/templates"
icon={<ShapesIcon color="currentColor" />}
exact={false}
label={t("Templates")}
active={
documents.active
? documents.active.isTemplate &&
!documents.active.isDeleted &&
!documents.active.isArchived
: undefined
documents.active ? documents.active.template : undefined
}
/>
<ArchiveLink documents={documents} />
@@ -133,14 +157,37 @@ function MainSidebar() {
</>
)}
<SidebarLink
to={settingsPath()}
to="/settings"
icon={<SettingsIcon color="currentColor" />}
exact={false}
label={t("Settings")}
/>
<SidebarAction action={inviteUser} />
{can.inviteUser && (
<SidebarLink
to="/settings/members"
onClick={handleInviteModalOpen}
icon={<PlusIcon color="currentColor" />}
label={`${t("Invite people")}`}
/>
)}
</Section>
</Scrollable>
{can.inviteUser && (
<Modal
title={t("Invite people")}
onRequestClose={handleInviteModalClose}
isOpen={inviteModalOpen}
>
<Invite onSubmit={handleInviteModalClose} />
</Modal>
)}
<Modal
title={t("Create a collection")}
onRequestClose={handleCreateCollectionModalClose}
isOpen={createCollectionModalOpen}
>
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
</Modal>
</DndProvider>
)}
</Sidebar>
+6 -16
View File
@@ -11,7 +11,6 @@ import {
LinkIcon,
TeamIcon,
ExpandedIcon,
BeakerIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -96,13 +95,6 @@ function SettingsSidebar() {
label={t("Security")}
/>
)}
{can.update && env.DEPLOYMENT !== "hosted" && (
<SidebarLink
to="/settings/features"
icon={<BeakerIcon color="currentColor" />}
label={t("Features")}
/>
)}
<SidebarLink
to="/settings/members"
icon={<UserIcon color="currentColor" />}
@@ -128,16 +120,14 @@ function SettingsSidebar() {
/>
)}
</Section>
{can.update && (env.SLACK_KEY || isHosted) && (
{can.update && (
<Section>
<Header>{t("Integrations")}</Header>
{env.SLACK_KEY && (
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
)}
<SidebarLink
to="/settings/integrations/slack"
icon={<SlackIcon color="currentColor" />}
label="Slack"
/>
{isHosted && (
<SidebarLink
to="/settings/integrations/zapier"
@@ -4,10 +4,9 @@ import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import { archivePath } from "utils/routeHelpers";
function ArchiveLink({ documents }) {
const { policies } = useStores();
@@ -30,7 +29,7 @@ function ArchiveLink({ documents }) {
return (
<div ref={dropToArchiveDocument}>
<SidebarLink
to={archivePath()}
to="/archive"
icon={<ArchiveIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Archive")}
@@ -3,14 +3,10 @@ import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useDrop, useDrag } from "react-dnd";
import { useTranslation } from "react-i18next";
import { useLocation, useHistory } from "react-router-dom";
import styled from "styled-components";
import Collection from "models/Collection";
import Document from "models/Document";
import DocumentReparent from "scenes/DocumentReparent";
import CollectionIcon from "components/CollectionIcon";
import Modal from "components/Modal";
import DocumentLink from "./DocumentLink";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
@@ -40,23 +36,13 @@ function CollectionLink({
isDraggingAnyCollection,
onChangeDragging,
}: Props) {
const history = useHistory();
const { t } = useTranslation();
const { search } = useLocation();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const [
permissionOpen,
handlePermissionOpen,
handlePermissionClose,
] = useBoolean();
const itemRef = React.useRef();
const handleTitleChange = React.useCallback(
async (name: string) => {
await collection.save({ name });
history.push(collection.url);
},
[collection, history]
[collection]
);
const { ui, documents, policies, collections } = useStores();
@@ -66,17 +52,12 @@ function CollectionLink({
);
React.useEffect(() => {
// If we're viewing a starred document through the starred menu then don't
// touch the expanded / collapsed state of the collections
if (search === "?starred") {
return;
}
if (isDraggingAnyCollection) {
setExpanded(false);
} else {
setExpanded(collection.id === ui.activeCollectionId);
}
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId, search]);
}, [isDraggingAnyCollection, collection.id, ui.activeCollectionId]);
const manualSort = collection.sort.field === "index";
const can = policies.abilities(collection.id);
@@ -86,22 +67,9 @@ function CollectionLink({
const [{ isOver, canDrop }, drop] = useDrop({
accept: "document",
drop: (item, monitor) => {
const { id, collectionId } = item;
if (monitor.didDrop()) return;
if (!collection) return;
if (collection.id === collectionId) return;
const prevCollection = collections.get(collectionId);
if (
prevCollection &&
prevCollection.permission === null &&
prevCollection.permission !== collection.permission
) {
itemRef.current = item;
handlePermissionOpen();
} else {
documents.move(id, collection.id);
}
documents.move(item.id, collection.id);
},
canDrop: (item, monitor) => {
return policies.abilities(collection.id).update;
@@ -179,6 +147,8 @@ function CollectionLink({
icon={
<CollectionIcon collection={collection} expanded={expanded} />
}
iconColor={collection.color}
expanded={expanded}
showActions={menuOpen || expanded}
isActiveDrop={isOver && canDrop}
label={
@@ -189,7 +159,6 @@ function CollectionLink({
/>
}
exact={false}
depth={0.5}
menu={
<>
{can.update && (
@@ -229,22 +198,10 @@ function CollectionLink({
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
canUpdate={canUpdate}
depth={2}
depth={1.5}
index={index}
/>
))}
<Modal
title={t("Move document")}
onRequestClose={handlePermissionClose}
isOpen={permissionOpen}
>
<DocumentReparent
item={itemRef.current}
collection={collection}
onSubmit={handlePermissionClose}
onCancel={handlePermissionClose}
/>
</Modal>
</>
);
}
@@ -1,31 +1,35 @@
// @flow
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Fade from "components/Fade";
import Flex from "components/Flex";
import useStores from "../../../hooks/useStores";
import CollectionLink from "./CollectionLink";
import DropCursor from "./DropCursor";
import Header from "./Header";
import PlaceholderCollections from "./PlaceholderCollections";
import SidebarAction from "./SidebarAction";
import SidebarLink from "./SidebarLink";
import { createCollection } from "actions/definitions/collections";
import useCurrentTeam from "hooks/useCurrentTeam";
import useToasts from "hooks/useToasts";
function Collections() {
type Props = {
onCreateCollection: () => void,
};
function Collections({ onCreateCollection }: Props) {
const [isFetching, setFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const { ui, policies, documents, collections } = useStores();
const { showToast } = useToasts();
const [expanded, setExpanded] = React.useState(true);
const isPreloaded: boolean = !!collections.orderedData.length;
const { t } = useTranslation();
const team = useCurrentTeam();
const orderedCollections = collections.orderedData;
const can = policies.abilities(team.id);
const [isDraggingAnyCollection, setIsDraggingAnyCollection] = React.useState(
false
);
@@ -88,17 +92,22 @@ function Collections() {
belowCollection={orderedCollections[index + 1]}
/>
))}
<SidebarAction action={createCollection} depth={0.5} />
{can.createCollection && (
<SidebarLink
to="/collections"
onClick={onCreateCollection}
icon={<PlusIcon color="currentColor" />}
label={`${t("New collection")}`}
exact
/>
)}
</>
);
if (!collections.isLoaded || fetchError) {
return (
<Flex column>
<SidebarLink
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
<Header>{t("Collections")}</Header>
<PlaceholderCollections />
</Flex>
);
@@ -106,19 +115,10 @@ function Collections() {
return (
<Flex column>
<SidebarLink
onClick={() => setExpanded((prev) => !prev)}
label={t("Collections")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (isPreloaded ? content : <Fade>{content}</Fade>)}
<Header>{t("Collections")}</Header>
{isPreloaded ? content : <Fade>{content}</Fade>}
</Flex>
);
}
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Collections);
@@ -1,13 +0,0 @@
// @flow
import { CollapsedIcon } from "outline-icons";
import styled from "styled-components";
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default Disclosure;
@@ -1,14 +1,13 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "shared/constants";
import Collection from "models/Collection";
import Document from "models/Document";
import Fade from "components/Fade";
import Disclosure from "./Disclosure";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle from "./EditableTitle";
@@ -129,12 +128,7 @@ function DocumentLink(
// Draggable
const [{ isDragging }, drag] = useDrag({
type: "document",
item: () => ({
...node,
depth,
active: isActiveDocument,
collectionId: collection?.id || "",
}),
item: () => ({ ...node, depth, active: isActiveDocument }),
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
@@ -216,7 +210,7 @@ function DocumentLink(
return (
<>
<Relative onDragLeave={resetHoverExpanding}>
<div style={{ position: "relative" }} onDragLeave={resetHoverExpanding}>
<Draggable
key={node.id}
ref={drag}
@@ -243,18 +237,13 @@ function DocumentLink(
title={node.title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
}
isActive={(match, location) =>
match && location.search !== "?starred"
}
isActiveDrop={isOverReparent && canDropToReparent}
depth={depth}
exact={false}
showActions={menuOpen}
scrollIntoViewIfNeeded={!document?.isStarred}
ref={ref}
menu={
document && !isMoving ? (
@@ -274,7 +263,7 @@ function DocumentLink(
{manualSort && (
<DropCursor isActiveDrop={isOverReorder} innerRef={dropToReorder} />
)}
</Relative>
</div>
{expanded && !isDragging && (
<>
{node.children.map((childNode, index) => (
@@ -296,15 +285,19 @@ function DocumentLink(
);
}
const Relative = styled.div`
position: relative;
`;
const Draggable = styled.div`
const Draggable = styled("div")`
opacity: ${(props) => (props.$isDragging || props.$isMoving ? 0.5 : 1)};
pointer-events: ${(props) => (props.$isMoving ? "none" : "all")};
`;
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
position: absolute;
left: -24px;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
export default ObservedDocumentLink;
@@ -27,7 +27,7 @@ const Cursor = styled("div")`
width: 100%;
height: 14px;
${(props) => (props.from === "collections" ? "top: 25px;" : "bottom: -7px;")}
${(props) => (props.from === "collections" ? "top: 15px;" : "bottom: -7px;")}
background: transparent;
::after {
@@ -14,6 +14,7 @@ type Props = {|
collectionId: string,
documentId?: string,
disabled: boolean,
staticContext: Object,
|};
function DropToImport({ disabled, children, collectionId, documentId }: Props) {
@@ -55,7 +56,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
}) => (
<DropzoneContainer
{...getRootProps()}
$isDragActive={isDragActive}
{...{ isDragActive }}
tabIndex="-1"
>
<input {...getInputProps()} />
@@ -70,8 +71,8 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const DropzoneContainer = styled.div`
border-radius: 4px;
${({ $isDragActive, theme }) =>
$isDragActive &&
${({ isDragActive, theme }) =>
isDragActive &&
css`
background: ${theme.slateDark};
a {
@@ -7,10 +7,9 @@ type Props = {|
onSubmit: (title: string) => Promise<void>,
title: string,
canUpdate: boolean,
maxLength?: number,
|};
function EditableTitle({ title, onSubmit, canUpdate, ...rest }: Props) {
function EditableTitle({ title, onSubmit, canUpdate }: Props) {
const [isEditing, setIsEditing] = React.useState(false);
const [originalValue, setOriginalValue] = React.useState(title);
const [value, setValue] = React.useState(title);
@@ -80,7 +79,6 @@ function EditableTitle({ title, onSubmit, canUpdate, ...rest }: Props) {
onChange={handleChange}
onBlur={handleSave}
autoFocus
{...rest}
/>
</form>
) : (
+1 -2
View File
@@ -5,11 +5,10 @@ import Flex from "components/Flex";
const Header = styled(Flex)`
font-size: 11px;
font-weight: 600;
user-select: none;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
margin: 4px 12px;
margin: 4px 16px;
`;
export default Header;
+3 -5
View File
@@ -27,11 +27,10 @@ const joinClassnames = (...classnames) => {
return classnames.filter((i) => i).join(" ");
};
export type Props = {|
type Props = {|
activeClassName?: String,
activeStyle?: Object,
className?: string,
scrollIntoViewIfNeeded?: boolean,
exact?: boolean,
isActive?: any,
location?: Location,
@@ -53,7 +52,6 @@ const NavLink = ({
location: locationProp,
strict,
style: styleProp,
scrollIntoViewIfNeeded,
to,
...rest
}: Props) => {
@@ -85,13 +83,13 @@ const NavLink = ({
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
React.useEffect(() => {
if (isActive && linkRef.current && scrollIntoViewIfNeeded !== false) {
if (isActive && linkRef.current) {
scrollIntoView(linkRef.current, {
scrollMode: "if-needed",
behavior: "instant",
});
}
}, [linkRef, scrollIntoViewIfNeeded, isActive]);
}, [linkRef, isActive]);
const props = {
"aria-current": (isActive && ariaCurrent) || null,
@@ -15,7 +15,6 @@ function PlaceholderCollections() {
const Wrapper = styled.div`
margin: 4px 16px;
margin-left: 40px;
width: 75%;
`;
+1 -1
View File
@@ -5,7 +5,7 @@ import Flex from "components/Flex";
const Section = styled(Flex)`
position: relative;
flex-direction: column;
margin: 0 8px 12px;
margin: 0 8px 20px;
min-width: ${(props) => props.theme.sidebarMinWidth}px;
flex-shrink: 0;
@@ -1,44 +0,0 @@
// @flow
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router";
import SidebarLink from "./SidebarLink";
import { actionToMenuItem } from "actions";
import useStores from "hooks/useStores";
import type { Action } from "types";
type Props = {|
action: Action,
|};
function SidebarAction({ action, ...rest }: Props) {
const stores = useStores();
const { t } = useTranslation();
const location = useLocation();
const context = {
isContextMenu: false,
isCommandBar: false,
activeCollectionId: undefined,
activeDocumentId: undefined,
location,
stores,
t,
};
const menuItem = actionToMenuItem(action, context);
invariant(menuItem.onClick, "passed action must have perform");
return (
<SidebarLink
onClick={menuItem.onClick}
icon={menuItem.icon}
label={menuItem.title}
{...rest}
/>
);
}
export default observer(SidebarAction);
@@ -1,31 +1,33 @@
// @flow
import { transparentize } from "polished";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "components/EventBoundary";
import NavLink, { type Props as NavLinkProps } from "./NavLink";
import NavLink from "./NavLink";
import { type Theme } from "types";
type Props = {|
...NavLinkProps,
type Props = {
to?: string | Object,
href?: string | Object,
innerRef?: (?HTMLElement) => void,
onClick?: (SyntheticEvent<>) => mixed,
onClick?: (SyntheticEvent<>) => void,
onMouseEnter?: (SyntheticEvent<>) => void,
className?: string,
children?: React.Node,
icon?: React.Node,
label?: React.Node,
menu?: React.Node,
showActions?: boolean,
iconColor?: string,
active?: boolean,
isActiveDrop?: boolean,
history: RouterHistory,
match: Match,
theme: Theme,
exact?: boolean,
depth?: number,
scrollIntoViewIfNeeded?: boolean,
|};
const activeDropStyle = {
fontWeight: 600,
};
function SidebarLink(
@@ -40,38 +42,37 @@ function SidebarLink(
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
scrollIntoViewIfNeeded,
...rest
}: Props,
ref
) {
const theme = useTheme();
const style = React.useMemo(
() => ({
paddingLeft: `${(depth || 0) * 16 + 12}px`,
}),
[depth]
);
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
};
}, [depth]);
const activeStyle = React.useMemo(
() => ({
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
...style,
}),
[theme, style]
);
const activeStyle = {
fontWeight: 600,
color: theme.text,
background: theme.sidebarItemBackground,
...style,
};
const activeDropStyle = {
fontWeight: 600,
};
return (
<>
<Link
$isActiveDrop={isActiveDrop}
scrollIntoViewIfNeeded={scrollIntoViewIfNeeded}
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
style={active ? activeStyle : style}
onClick={onClick}
@@ -82,7 +83,6 @@ function SidebarLink(
href={href}
className={className}
ref={ref}
{...rest}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -131,7 +131,6 @@ const Link = styled(NavLink)`
padding: 6px 16px;
border-radius: 4px;
transition: background 50ms, color 50ms;
user-select: none;
background: ${(props) =>
props.$isActiveDrop ? props.theme.slateDark : "inherit"};
color: ${(props) =>
@@ -157,11 +156,13 @@ const Link = styled(NavLink)`
`}
@media (hover: hover) {
&:hover + ${Actions}, &:active + ${Actions} {
display: inline-flex;
&:hover + ${Actions},
&:active + ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
svg {
opacity: 0.75;
}
}
}
@@ -182,4 +183,4 @@ const Label = styled.div`
}
`;
export default React.forwardRef<Props, HTMLAnchorElement>(SidebarLink);
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
@@ -1,171 +0,0 @@
// @flow
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "components/Flex";
import PlaceholderCollections from "./PlaceholderCollections";
import Section from "./Section";
import SidebarLink from "./SidebarLink";
import StarredLink from "./StarredLink";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
const STARRED_PAGINATION_LIMIT = 10;
const STARRED = "STARRED";
function Starred() {
const [isFetching, setIsFetching] = React.useState(false);
const [fetchError, setFetchError] = React.useState();
const [expanded, setExpanded] = React.useState(true);
const [show, setShow] = React.useState("Nothing");
const [offset, setOffset] = React.useState(0);
const [upperBound, setUpperBound] = React.useState(STARRED_PAGINATION_LIMIT);
const { showToast } = useToasts();
const { documents } = useStores();
const { t } = useTranslation();
const { fetchStarred, starred } = documents;
const fetchResults = React.useCallback(async () => {
try {
setIsFetching(true);
await fetchStarred({
limit: STARRED_PAGINATION_LIMIT,
offset,
});
} catch (error) {
showToast(t("Starred documents could not be loaded"), {
type: "error",
});
setFetchError(error);
} finally {
setIsFetching(false);
}
}, [fetchStarred, offset, showToast, t]);
useEffect(() => {
let stateInLocal;
try {
stateInLocal = localStorage.getItem(STARRED);
} catch (_) {
// no-op Safari private mode
}
if (!stateInLocal) {
localStorage.setItem(STARRED, expanded ? "true" : "false");
} else {
setExpanded(stateInLocal === "true");
}
}, [expanded]);
useEffect(() => {
setOffset(starred.length);
if (starred.length <= STARRED_PAGINATION_LIMIT) {
setShow("Nothing");
} else if (starred.length >= upperBound) {
setShow("More");
} else if (starred.length < upperBound) {
setShow("Less");
}
}, [starred, upperBound]);
useEffect(() => {
if (offset === 0) {
fetchResults();
}
}, [fetchResults, offset]);
const handleShowMore = React.useCallback(
async (ev) => {
setUpperBound(
(previousUpperBound) => previousUpperBound + STARRED_PAGINATION_LIMIT
);
await fetchResults();
},
[fetchResults]
);
const handleShowLess = React.useCallback((ev) => {
setUpperBound(STARRED_PAGINATION_LIMIT);
setShow("More");
}, []);
const handleExpandClick = React.useCallback(
(ev) => {
ev.preventDefault();
ev.stopPropagation();
try {
localStorage.setItem(STARRED, !expanded ? "true" : "false");
} catch (_) {
// no-op Safari private mode
}
setExpanded((prev) => !prev);
},
[expanded]
);
const content = starred.slice(0, upperBound).map((document, index) => {
return (
<StarredLink
key={document.id}
documentId={document.id}
collectionId={document.collectionId}
to={document.url}
title={document.title}
url={document.url}
depth={2}
/>
);
});
if (!starred.length) {
return null;
}
return (
<Section>
<Flex column>
<SidebarLink
onClick={handleExpandClick}
label={t("Starred")}
icon={<Disclosure expanded={expanded} color="currentColor" />}
/>
{expanded && (
<>
{content}
{show === "More" && !isFetching && (
<SidebarLink
onClick={handleShowMore}
label={`${t("Show more")}`}
depth={2}
/>
)}
{show === "Less" && !isFetching && (
<SidebarLink
onClick={handleShowLess}
label={`${t("Show less")}`}
depth={2}
/>
)}
{(isFetching || fetchError) && (
<Flex column>
<PlaceholderCollections />
</Flex>
)}
</>
)}
</Flex>
</Section>
);
}
const Disclosure = styled(CollapsedIcon)`
transition: transform 100ms ease, fill 50ms !important;
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
export default observer(Starred);
@@ -1,129 +0,0 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_TITLE_LENGTH } from "shared/constants";
import Fade from "components/Fade";
import useStores from "../../../hooks/useStores";
import Disclosure from "./Disclosure";
import EditableTitle from "./EditableTitle";
import SidebarLink from "./SidebarLink";
import useBoolean from "hooks/useBoolean";
import DocumentMenu from "menus/DocumentMenu";
type Props = {|
depth: number,
title: string,
to: string,
documentId: string,
collectionId: string,
|};
function StarredLink({ depth, title, to, documentId, collectionId }: Props) {
const { t } = useTranslation();
const { collections, documents, policies } = useStores();
const collection = collections.get(collectionId);
const document = documents.get(documentId);
const [expanded, setExpanded] = useState(false);
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const canUpdate = policies.abilities(documentId).update;
const childDocuments = collection
? collection.getDocumentChildren(documentId)
: [];
const hasChildDocuments = childDocuments.length > 0;
useEffect(() => {
async function load() {
if (!document) {
await documents.fetch(documentId);
}
}
load();
}, [collection, collectionId, collections, document, documentId, documents]);
const handleDisclosureClick = React.useCallback((ev: SyntheticEvent<>) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((prevExpanded) => !prevExpanded);
}, []);
const handleTitleChange = React.useCallback(
async (title: string) => {
if (!document) return;
await documents.update({
id: document.id,
lastRevision: document.revision,
text: document.text,
title,
});
},
[documents, document]
);
return (
<>
<Relative>
<SidebarLink
depth={depth}
to={`${to}?starred`}
isActive={(match, location) =>
match && location.search === "?starred"
}
label={
<>
{hasChildDocuments && (
<Disclosure
expanded={expanded}
onClick={handleDisclosureClick}
/>
)}
<EditableTitle
title={title || t("Untitled")}
onSubmit={handleTitleChange}
canUpdate={canUpdate}
maxLength={MAX_TITLE_LENGTH}
/>
</>
}
exact={false}
showActions={menuOpen}
menu={
document ? (
<Fade>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Fade>
) : undefined
}
/>
</Relative>
{expanded &&
childDocuments.map((childDocument) => (
<ObserveredStarredLink
key={childDocument.id}
depth={depth + 1}
title={childDocument.title}
to={childDocument.url}
documentId={childDocument.id}
collectionId={collectionId}
/>
))}
</>
);
}
const Relative = styled.div`
position: relative;
`;
const ObserveredStarredLink = observer(StarredLink);
export default ObserveredStarredLink;
@@ -1,5 +1,4 @@
// @flow
import { observer } from "mobx-react";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
@@ -85,4 +84,4 @@ const Header = styled.button`
}
`;
export default observer(TeamButton);
export default TeamButton;
+1 -9
View File
@@ -1,6 +1,5 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Arrow from "components/Arrow";
@@ -13,16 +12,9 @@ type Props = {
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
({ direction = "left", onClick, style }: Props, ref) => {
const { t } = useTranslation();
return (
<Positioner style={style}>
<ToggleButton
ref={ref}
$direction={direction}
onClick={onClick}
aria-label={t("Toggle sidebar")}
>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<Arrow />
</ToggleButton>
</Positioner>
@@ -7,9 +7,8 @@ import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "scenes/DocumentDelete";
import Modal from "components/Modal";
import useStores from "../../../hooks/useStores";
import SidebarLink from "./SidebarLink";
import useStores from "hooks/useStores";
import { trashPath } from "utils/routeHelpers";
function TrashLink({ documents }) {
const { policies } = useStores();
@@ -34,7 +33,7 @@ function TrashLink({ documents }) {
<>
<div ref={dropToTrashDocument}>
<SidebarLink
to={trashPath()}
to="/trash"
icon={<TrashIcon color="currentColor" open={isDocumentDropping} />}
exact={false}
label={t("Trash")}
+1 -24
View File
@@ -8,7 +8,6 @@ import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import DocumentPresenceStore from "stores/DocumentPresenceStore";
import DocumentsStore from "stores/DocumentsStore";
import FileOperationsStore from "stores/FileOperationsStore";
import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore";
@@ -29,7 +28,6 @@ type Props = {
views: ViewsStore,
auth: AuthStore,
toasts: ToastsStore,
fileOperations: FileOperationsStore,
};
@observer
@@ -82,7 +80,6 @@ class SocketProvider extends React.Component<Props> {
policies,
presence,
views,
fileOperations,
} = this.props;
if (!auth.token) return;
@@ -243,10 +240,6 @@ class SocketProvider extends React.Component<Props> {
}
}
}
if (event.teamIds) {
await auth.fetch();
}
});
this.socket.on("documents.star", (event) => {
@@ -294,21 +287,6 @@ class SocketProvider extends React.Component<Props> {
}
});
this.socket.on("fileOperations.update", async (event) => {
const user = auth.user;
let collection = null;
if (event.collectionId)
collection = await collections.fetch(event.collectionId);
if (user) {
fileOperations.add({
...event,
user,
collection,
});
}
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on("join", (event) => {
@@ -367,6 +345,5 @@ export default inject(
"memberships",
"presence",
"policies",
"views",
"fileOperations"
"views"
)(SocketProvider);
+17 -4
View File
@@ -1,13 +1,25 @@
// @flow
import { m } from "framer-motion";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import NavLinkWithChildrenFunc from "components/NavLink";
import { NavLink, Route } from "react-router-dom";
import styled, { withTheme } from "styled-components";
import { type Theme } from "types";
type Props = {
theme: Theme,
children: React.Node,
};
const NavLinkWithChildrenFunc = ({ to, exact = false, children, ...rest }) => (
<Route path={to} exact={exact}>
{({ match }) => (
<NavLink to={to} exact={exact} {...rest}>
{children(match)}
</NavLink>
)}
</Route>
);
const TabLink = styled(NavLinkWithChildrenFunc)`
position: relative;
display: inline-flex;
@@ -41,8 +53,7 @@ const transition = {
damping: 30,
};
export default function Tab({ children, ...rest }: Props) {
const theme = useTheme();
function Tab({ theme, children, ...rest }: Props) {
const activeStyle = {
color: theme.textSecondary,
};
@@ -64,3 +75,5 @@ export default function Tab({ children, ...rest }: Props) {
</TabLink>
);
}
export default withTheme(Tab);
+16 -55
View File
@@ -1,5 +1,4 @@
// @flow
import { isEqual } from "lodash";
import { observer } from "mobx-react";
import { CollapsedIcon } from "outline-icons";
import * as React from "react";
@@ -25,7 +24,6 @@ export type Props = {|
onChangePage: (index: number) => void,
onChangeSort: (sort: ?string, direction: "ASC" | "DESC") => void,
columns: any,
defaultSortDirection: "ASC" | "DESC",
|};
function Table({
@@ -41,7 +39,6 @@ function Table({
topRef,
onChangeSort,
onChangePage,
defaultSortDirection,
}: Props) {
const { t } = useTranslation();
const {
@@ -65,52 +62,32 @@ function Table({
autoResetPage: false,
pageCount: totalPages,
initialState: {
sortBy: [
{
id: defaultSort,
desc: defaultSortDirection === "DESC" ? true : false,
},
],
sortBy: [{ id: defaultSort, desc: false }],
pageSize,
pageIndex: page,
},
stateReducer: (newState, action, prevState) => {
if (!isEqual(newState.sortBy, prevState.sortBy)) {
return { ...newState, pageIndex: 0 };
}
return newState;
},
},
useSortBy,
usePagination
);
const prevSortBy = React.useRef(sortBy);
React.useEffect(() => {
onChangePage(pageIndex);
}, [pageIndex]);
React.useEffect(() => {
if (!isEqual(sortBy, prevSortBy.current)) {
prevSortBy.current = sortBy;
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
!sortBy.length ? defaultSortDirection : sortBy[0].desc ? "DESC" : "ASC"
);
}
}, [defaultSortDirection, onChangePage, onChangeSort, sortBy]);
const handleNextPage = () => {
nextPage();
onChangePage(pageIndex + 1);
};
const handlePreviousPage = () => {
previousPage();
onChangePage(pageIndex - 1);
};
onChangePage(0);
onChangeSort(
sortBy.length ? sortBy[0].id : undefined,
sortBy.length && sortBy[0].desc ? "DESC" : "ASC"
);
}, [sortBy]);
const isEmpty = !isLoading && data.length === 0;
const showPlaceholder = isLoading && data.length === 0;
console.log({ canNextPage, pageIndex, totalPages, rows, data });
return (
<>
<Anchor ref={topRef} />
@@ -165,12 +142,12 @@ function Table({
>
{/* Note: the page > 0 check shouldn't be needed here but is */}
{canPreviousPage && page > 0 && (
<Button onClick={handlePreviousPage} neutral>
<Button onClick={previousPage} neutral>
{t("Previous page")}
</Button>
)}
{canNextPage && (
<Button onClick={handleNextPage} neutral>
<Button onClick={nextPage} neutral>
{t("Next page")}
</Button>
)}
@@ -232,7 +209,7 @@ const SortWrapper = styled(Flex)`
`;
const Cell = styled.td`
padding: 6px;
padding: 8px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
font-size: 14px;
@@ -249,14 +226,6 @@ const Cell = styled.td`
`;
const Row = styled.tr`
${Cell} {
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
}
}
&:last-child {
${Cell} {
border-bottom: 0;
@@ -268,7 +237,7 @@ const Head = styled.th`
text-align: left;
position: sticky;
top: 54px;
padding: 6px;
padding: 6px 0;
border-bottom: 1px solid ${(props) => props.theme.divider};
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
@@ -276,14 +245,6 @@ const Head = styled.th`
color: ${(props) => props.theme.textSecondary};
font-weight: 500;
z-index: 1;
:first-child {
padding-left: 0;
}
:last-child {
padding-right: 0;
}
`;
export default observer(Table);
-1
View File
@@ -11,7 +11,6 @@ type Props = {
children?: React.Node,
tooltipDelay?: number,
addSuffix?: boolean,
format?: string,
shorten?: boolean,
};

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