Compare commits

..

6 Commits

Author SHA1 Message Date
Saumya Pandey 83361f4dbb fix: remove currentColor 2022-03-16 00:40:21 +05:30
Saumya Pandey b5364bdc60 yarn install 2022-03-16 00:25:40 +05:30
Saumya Pandey 455998074c add tests for collectionImporter command 2022-03-16 00:24:05 +05:30
Saumya Pandey e5e9790c59 fix: use appropriate variable name 2022-03-15 23:29:04 +05:30
Saumya Pandey 6d795e5d73 fix: update relative links with document url 2022-03-15 23:29:04 +05:30
Saumya Pandey 713e27c14a generate relative links on export 2022-03-15 23:29:04 +05:30
906 changed files with 19032 additions and 39021 deletions
-8
View File
@@ -35,14 +35,6 @@
"displayName": false
}
]
],
"ignore": [
"**/*.test.ts"
]
},
"development": {
"ignore": [
"**/*.test.ts"
]
}
}
+83 -122
View File
@@ -1,102 +1,51 @@
version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:14.19
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: circle_test
resource_class: large
environment:
NODE_ENV: test
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
DATABASE_URL_TEST: postgres://postgres:password@localhost:5432/circle_test
DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test
URL: http://localhost:3000
SMTP_FROM_EMAIL: hello@example.com
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
executors:
docker-publisher:
environment:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
docker:
- image: circleci/buildpack-deps:stretch
jobs:
build:
<<: *defaults
working_directory: ~/outline
docker:
- image: circleci/node:14
- image: circleci/redis:latest
- image: circleci/postgres:9.6.5-alpine-ram
environment:
NODE_ENV: test
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
DATABASE_URL_TEST: postgres://root@localhost:5432/circle_test
DATABASE_URL: postgres://root@localhost:5432/circle_test
URL: http://localhost:3000
SMTP_FROM_EMAIL: hello@example.com
AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com
AWS_S3_UPLOAD_BUCKET_NAME: outline-circle
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
command: yarn install --pure-lockfile
- save_cache:
key: dependency-cache-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
types:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
test-app:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
test-shared:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: lint
command: yarn lint
- run:
name: typescript
command: yarn tsc
- run:
name: test
command: yarn test:server --forceExit
bundle-size:
<<: *defaults
steps:
- checkout
- restore_cache:
key: dependency-cache-{{ checksum "package.json" }}
command: yarn test
- run:
name: build-webpack
command: yarn build:webpack
@@ -107,63 +56,59 @@ jobs:
- setup_remote_docker:
version: 20.10.6
- run:
name: Install Docker buildx
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: |
mkdir -p ~/.docker/cli-plugins
url="https://github.com/docker/buildx/releases/download/v0.8.0/buildx-v0.8.0.linux-amd64"
curl -sSL -o ~/.docker/cli-plugins/docker-buildx $url
chmod a+x ~/.docker/cli-plugins/docker-buildx
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: Enable Docker buildx
command: export DOCKER_CLI_EXPERIMENTAL=enabled
name: Load archived Docker image
command: docker load -i /tmp/workspace/image.tar
- run:
name: Initialize Docker buildx
name: Publish Docker Image to Docker Hub
command: |
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
name: Build base image
command: docker build -f Dockerfile.base -t $BASE_IMAGE_NAME:latest --load .
- run:
name: Login to Docker Hub
command: echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- run:
name: Publish base Docker Image to Docker Hub
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
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
all:
build-and-test:
jobs:
- build
- lint:
requires:
- build
- test-server:
requires:
- build
- test-shared:
requires:
- build
- test-app:
requires:
- build
- types:
requires:
- build
- bundle-size:
requires:
- test-app
- test-shared
- test-server
- build:
filters:
tags:
ignore: /^v.*/
build-docker:
jobs:
- build-image:
@@ -172,3 +117,19 @@ workflows:
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: /.*/
+7 -22
View File
@@ -1,7 +1,5 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
NODE_ENV=production
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
@@ -14,19 +12,9 @@ UTILS_SECRET=generate_a_new_key
# should work out of the box.
DATABASE_URL=postgres://user:pass@localhost:5432/outline
DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test
DATABASE_CONNECTION_POOL_MIN=
DATABASE_CONNECTION_POOL_MAX=
# Uncomment this to disable SSL for connecting to Postgres
# PGSSLMODE=disable
# For redis you can either specify an ioredis compatible url like this
REDIS_URL=redis://localhost:6379
# or alternatively, if you would like to provide addtional connection options,
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
# for a list of available options.
# Example: Use Redis Sentinel for high availability
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
@@ -67,8 +55,8 @@ AWS_S3_ACL=private
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/auth/slack.callback
SLACK_CLIENT_ID=get_a_key_from_slack
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
SLACK_KEY=get_a_key_from_slack
SLACK_SECRET=get_the_secret_of_above_key
# To configure Google auth, you'll need to create an OAuth Client ID at
# => https://console.cloud.google.com/apis/credentials
@@ -102,7 +90,7 @@ OIDC_USERNAME_CLAIM=preferred_username
OIDC_DISPLAY_NAME=OpenID
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
OIDC_SCOPES="openid profile email"
# –––––––––––––––– OPTIONAL ––––––––––––––––
@@ -139,6 +127,10 @@ MAXIMUM_IMPORT_SIZE=5120000
# requests and this ends up being duplicative
DEBUG=http
# 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
# following configs are also needed, some more details
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
@@ -170,10 +162,3 @@ SMTP_SECURE=true
# The default interface language. See translate.getoutline.com for a list of
# available language codes and their rough percentage translated.
DEFAULT_LANGUAGE=en_US
# Optionally enable rate limiter at application web server
RATE_LIMITER_ENABLED=true
# Configure default throttling parameters for rate limiter
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
+2 -3
View File
@@ -12,23 +12,22 @@
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended"
],
"plugins": [
"es",
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-react-hooks",
"import"
],
"rules": {
"eqeqeq": 2,
"curly": 2,
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"es/no-regexp-lookbehind-assertions": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
-11
View File
@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+22
View File
@@ -0,0 +1,22 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 120
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 14
# Issues with these labels will never be considered stale
exemptLabels:
- security
- pinned
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
Hey! The issue has been automatically marked as stale because it has not had
recent activity. It will be closed soon if no further activity occurs. Please
reply here if you wish for the issue to be kept open.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
@@ -1,56 +0,0 @@
# Image Actions will run in the following scenarios:
# - on Pull Requests containing images (not including forks)
# - on pushing of images to `main` (for forks)
# - on demand (https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/)
# - at 11 PM every Sunday in anything gets missed with any of the above scenarios
# For Pull Requests, the images are added to the PR.
# For other scenarios, a new PR will be opened if any images are compressed.
name: Compress images
on:
pull_request:
paths:
- "**.jpg"
- "**.jpeg"
- "**.png"
- "**.webp"
push:
branches:
- main
paths:
- "**.jpg"
- "**.jpeg"
- "**.png"
- "**.webp"
workflow_dispatch:
schedule:
- cron: "00 20 * * 0"
jobs:
build:
name: calibreapp/image-actions
runs-on: ubuntu-latest
# Only run on main repo on and PRs that match the main repo.
if: |
github.repository == 'outline/outline' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout Branch
uses: actions/checkout@v2
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@main
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
compressOnly: ${{ github.event_name != 'pull_request' }}
- name: Create Pull Request
# If it's not a Pull Request then commit any changes as a new PR.
if: |
github.event_name != 'pull_request' &&
steps.calibre.outputs.markdown != ''
uses: peter-evans/create-pull-request@v3
with:
title: "chore: Auto Compress Images"
branch-suffix: timestamp
commit-message: "chore: Compressed inefficient images automatically"
body: ${{ steps.calibre.outputs.markdown }}
+3 -3
View File
@@ -42,7 +42,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
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.
@@ -53,7 +53,7 @@ jobs:
# 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@v2
uses: github/codeql-action/autobuild@v1
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -67,4 +67,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v1
-29
View File
@@ -1,29 +0,0 @@
name: "Close Stale PRs"
on:
workflow_dispatch:
schedule:
- cron: "30 1 * * *"
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v5
with:
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
close-pr-message: "Automatically closed due to inactivity"
close-issue-message: "Automatically closed due to inactivity"
days-before-issue-stale: 120
days-before-pr-stale: 60
days-before-close: 5
operations-per-run: 60
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: "security,pinned"
- name: Print outputs
run: echo ${{ join(steps.stale.outputs.*, ',') }}
-1
View File
@@ -3,7 +3,6 @@ build
node_modules/*
.env
.log
.vscode/*
npm-debug.log
stats.json
.DS_Store
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
-88
View File
@@ -1,88 +0,0 @@
{
"projects": [
{
"displayName": "server",
"verbose": false,
"roots": [
"<rootDir>/server"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/env.ts"
],
"setupFilesAfterEnv": [
"<rootDir>/server/test/setup.ts"
],
"testEnvironment": "node",
"runner": "@getoutline/jest-runner-serial"
},
{
"displayName": "app",
"verbose": false,
"roots": [
"<rootDir>/app"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"<rootDir>/app/test/setup.ts"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": [
"<rootDir>/__mocks__/console.js"
],
"setupFilesAfterEnv": [
"<rootDir>/shared/test/setup.ts"
],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"verbose": false,
"roots": [
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
+31 -8
View File
@@ -1,22 +1,45 @@
# syntax=docker/dockerfile:1.2
ARG APP_PATH=/opt/outline
FROM outlinewiki/outline-base as base
FROM node:16-alpine AS deps-common
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
# ---
FROM deps-common AS deps-prod
RUN yarn install --production=true --frozen-lockfile && \
yarn cache clean
# ---
FROM node:16-alpine AS builder
ARG APP_PATH
WORKDIR $APP_PATH
COPY . .
COPY --from=deps-dev $APP_PATH/node_modules ./node_modules
ARG CDN_URL
RUN yarn build
# ---
FROM node:16.14.2-alpine3.15 AS runner
FROM node:16-alpine AS runner
ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV production
COPY --from=base $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json
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 && \
-18
View File
@@ -1,18 +0,0 @@
ARG APP_PATH=/opt/outline
FROM node:16.14.2-alpine3.15 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
COPY . .
ARG CDN_URL
RUN yarn build
RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.64.0
Licensed Work: Outline 0.60.1
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: 2026-05-23
Change Date: 2025-11-11
Change License: Apache License, Version 2.0
+17
View File
@@ -0,0 +1,17 @@
export default class Queue {
name;
constructor(name) {
this.name = name;
}
process = (fn) => {
console.log(`Registered function ${this.name}`);
this.processFn = fn;
};
add = (data) => {
console.log(`Running ${this.name}`);
return this.processFn({ data });
};
}
+1
View File
@@ -1 +1,2 @@
// Mock for node-uuid
global.console.warn = () => {};
+7 -3
View File
@@ -43,6 +43,10 @@
"value": "true",
"required": true
},
"ALLOWED_DOMAINS": {
"description": "Comma separated list of domains to be allowed (optional). If not set, all domains are allowed by default when using Google OAuth to signin. Consider putting {your app name}.herokuapp.com and any domain you are binding on in this list.",
"required": false
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
@@ -102,11 +106,11 @@
"value": "openid profile email",
"required": false
},
"SLACK_CLIENT_ID": {
"SLACK_KEY": {
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
"required": false
},
"SLACK_CLIENT_SECRET": {
"SLACK_SECRET": {
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
"required": false
},
@@ -205,4 +209,4 @@
"required": false
}
}
}
}
+1 -5
View File
@@ -1,10 +1,6 @@
{
"extends": [
"../.eslintrc",
"plugin:react-hooks/recommended",
],
"plugins": [
"eslint-plugin-react-hooks",
"../.eslintrc"
],
"env": {
"jest": true,
+27
View File
@@ -0,0 +1,27 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.ts"
],
"testEnvironment": "jsdom"
}
+2 -63
View File
@@ -1,10 +1,4 @@
import {
CollectionIcon,
EditIcon,
PlusIcon,
StarredIcon,
UnstarredIcon,
} from "outline-icons";
import { CollectionIcon, EditIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import Collection from "~/models/Collection";
@@ -79,59 +73,4 @@ export const editCollection = createAction({
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
section: CollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).star
);
},
perform: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
collection?.star();
},
});
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
section: CollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
collection?.unstar();
},
});
export const rootCollectionActions = [
openCollection,
createCollection,
starCollection,
unstarCollection,
];
export const rootCollectionActions = [openCollection, createCollection];
+32
View File
@@ -0,0 +1,32 @@
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];
-50
View File
@@ -1,50 +0,0 @@
import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import { deleteAllDatabases } from "~/utils/developer";
export const clearIndexedDB = createAction({
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DeveloperSection,
perform: async ({ t }) => {
await deleteAllDatabases();
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const createTestUsers = createAction({
name: "Create test users",
icon: <UserIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: async () => {
const count = 10;
try {
await client.post("/developer.create_test_users", { count });
stores.toasts.showToast(`${count} test users created`);
} catch (err) {
stores.toasts.showToast(err.message, { type: "error" });
}
},
});
export const developer = createAction({
name: ({ t }) => t("Developer"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DeveloperSection,
visible: ({ event }) =>
env.ENVIRONMENT === "development" ||
(event instanceof KeyboardEvent && event.altKey),
children: [clearIndexedDB, createTestUsers],
});
export const rootDeveloperActions = [developer];
+25 -253
View File
@@ -11,19 +11,10 @@ import {
ImportIcon,
PinIcon,
SearchIcon,
UnsubscribeIcon,
SubscribeIcon,
MoveIcon,
TrashIcon,
CrossIcon,
ArchiveIcon,
} from "outline-icons";
import * as React from "react";
import { getEventFiles } from "@shared/utils/files";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import DocumentTemplatize from "~/scenes/DocumentTemplatize";
import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections";
import history from "~/utils/history";
@@ -61,11 +52,8 @@ export const createDocument = createAction({
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ activeCollectionId, inStarredSection }) =>
activeCollectionId &&
history.push(newDocumentPath(activeCollectionId), {
starred: inStarredSection,
}),
perform: ({ activeCollectionId }) =>
activeCollectionId && history.push(newDocumentPath(activeCollectionId)),
});
export const starDocument = createAction({
@@ -117,111 +105,22 @@ export const unstarDocument = createAction({
},
});
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
section: DocumentSection,
icon: <SubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
);
},
perform: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.subscribe();
stores.toasts.showToast(t("Subscribed to document notifications"), {
type: "success",
});
},
});
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
section: DocumentSection,
icon: <UnsubscribeIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe
);
},
perform: ({ activeDocumentId, stores, currentUserId, t }) => {
if (!activeDocumentId || !currentUserId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.unsubscribe(currentUserId);
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
type: "success",
});
},
});
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
section: DocumentSection,
keywords: "html export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download("text/html");
},
});
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
section: DocumentSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download("text/markdown");
},
});
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
section: DocumentSection,
icon: <DownloadIcon />,
keywords: "export",
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
document?.download();
},
});
export const duplicateDocument = createAction({
@@ -358,8 +257,8 @@ export const importDocument = createAction({
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev) => {
const files = getEventFiles(ev);
input.onchange = async (ev: Event) => {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
@@ -394,24 +293,28 @@ export const createTemplate = createAction({
return false;
}
const document = stores.documents.get(activeDocumentId);
return !!(
return (
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate &&
!document?.isDeleted
!document?.isTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
if (!activeDocumentId) {
return;
}
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Create template"),
isCentered: true,
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
content: (
<DocumentTemplatize
documentId={activeDocumentId}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
@@ -427,146 +330,15 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
section: DocumentSection,
icon: <MoveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).move;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Move {{ documentName }}", {
documentName: document.noun,
}),
content: (
<DocumentMove
document={document}
onRequestClose={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const archiveDocument = createAction({
name: ({ t }) => t("Archive"),
section: DocumentSection,
icon: <ArchiveIcon />,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).archive;
},
perform: async ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.archive();
stores.toasts.showToast(t("Document archived"), {
type: "success",
});
}
},
});
export const deleteDocument = createAction({
name: ({ t }) => t("Delete"),
section: DocumentSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).delete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
section: DocumentSection,
icon: <CrossIcon />,
dangerous: true,
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
}
return !!stores.policies.abilities(activeDocumentId).permanentDelete;
},
perform: ({ activeDocumentId, stores, t }) => {
if (activeDocumentId) {
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Permanently delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentPermanentDelete
document={document}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
}
},
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
createDocument,
createTemplate,
deleteDocument,
importDocument,
downloadDocument,
starDocument,
unstarDocument,
subscribeDocument,
unsubscribeDocument,
duplicateDocument,
moveDocument,
permanentlyDeleteDocument,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
+2 -4
View File
@@ -16,7 +16,7 @@ import * as React from "react";
import {
developersUrl,
changelogUrl,
feedbackUrl,
mailToUrl,
githubIssuesUrl,
} from "@shared/utils/urlHelpers";
import stores from "~/stores";
@@ -91,8 +91,6 @@ export const navigateToSettings = createAction({
section: NavigationSection,
shortcut: ["g", "s"],
icon: <SettingsIcon />,
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(organizationSettingsPath()),
});
@@ -117,7 +115,7 @@ export const openFeedbackUrl = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(feedbackUrl()),
perform: () => window.open(mailToUrl()),
});
export const openBugReportUrl = createAction({
-40
View File
@@ -1,40 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import { createAction } from "~/actions";
import { loadSessionsFromCookie } from "~/hooks/useSessions";
export const changeTeam = createAction({
name: ({ t }) => t("Switch team"),
placeholder: ({ t }) => t("Select a team"),
keywords: "change workspace organization",
section: "Account",
visible: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.length > 0;
},
children: ({ currentTeamId }) => {
const sessions = loadSessionsFromCookie();
const otherSessions = sessions.filter(
(session) => session.teamId !== currentTeamId
);
return otherSessions.map((session) => ({
id: session.url,
name: session.name,
section: "Account",
icon: <Logo alt={session.name} src={session.logoUrl} />,
perform: () => (window.location.href = session.url),
}));
},
});
const Logo = styled("img")`
border-radius: 2px;
width: 24px;
height: 24px;
`;
export const rootTeamActions = [changeTeam];
+27 -14
View File
@@ -10,10 +10,6 @@ import {
MenuItemWithChildren,
} from "~/types";
function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
}
export function createAction(definition: Optional<Action, "id">): Action {
return {
...definition,
@@ -25,10 +21,18 @@ export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemButton | MenuItemWithChildren {
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
function resolve<T>(value: any): T {
if (typeof value === "function") {
return value(context);
}
return value;
}
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon);
const resolvedChildren = resolve<Action[]>(action.children);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name, context);
const title = resolve<string>(action.name);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? React.cloneElement(resolvedIcon, {
@@ -56,7 +60,6 @@ export function actionToMenuItem(
title,
icon,
visible,
dangerous: action.dangerous,
onClick: () => action.perform && action.perform(context),
selected: action.selected ? action.selected(context) : undefined,
};
@@ -66,15 +69,23 @@ 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.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const resolvedSection = resolve<string>(action.section, context);
const resolvedName = resolve<string>(action.name, context);
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
const resolvedIcon = resolve<React.ReactElement<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
@@ -90,7 +101,9 @@ export function actionToKBar(
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
perform: action.perform ? () => action?.perform?.(context) : undefined,
perform: action.perform
? () => action.perform && action.perform(context)
: undefined,
},
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
].concat(children.map((child) => ({ ...child, parent: action.id })));
+2 -4
View File
@@ -1,9 +1,8 @@
import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDebugActions } from "./definitions/debug";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
import { rootTeamActions } from "./definitions/teams";
import { rootUserActions } from "./definitions/users";
export default [
@@ -12,6 +11,5 @@ export default [
...rootUserActions,
...rootNavigationActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootTeamActions,
...rootDebugActions,
];
+1 -1
View File
@@ -2,7 +2,7 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DebugSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
+5 -1
View File
@@ -2,7 +2,11 @@
import * as React from "react";
import env from "~/env";
export default class Analytics extends React.Component {
type Props = {
children?: React.ReactNode;
};
export default class Analytics extends React.Component<Props> {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return;
+21 -1
View File
@@ -2,7 +2,9 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "@shared/utils/domains";
import LoadingIndicator from "~/components/LoadingIndicator";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
@@ -13,7 +15,7 @@ type Props = {
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const language = auth.user?.language;
const language = auth.user && auth.user.language;
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
@@ -23,11 +25,29 @@ const Authenticated = ({ children }: Props) => {
if (auth.authenticated) {
const { user, team } = auth;
const { hostname } = window.location;
if (!team || !user) {
return <LoadingIndicator />;
}
// If we're authenticated but viewing a domain that doesn't match the
// current team then kick the user to the teams correct domain.
if (team.domain) {
if (team.domain !== hostname) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
} else if (
env.SUBDOMAINS_ENABLED &&
team.subdomain &&
isCustomSubdomain(hostname) &&
!hostname.startsWith(`${team.subdomain}.`)
) {
window.location.href = `${team.url}${window.location.pathname}`;
return <LoadingIndicator />;
}
return children;
}
+5 -6
View File
@@ -34,7 +34,10 @@ const CommandBar = React.lazy(
)
);
type Props = WithTranslation & RootStore;
type Props = WithTranslation &
RootStore & {
children?: React.ReactNode;
};
@observer
class AuthenticatedLayout extends React.Component<Props> {
@@ -51,11 +54,7 @@ class AuthenticatedLayout extends React.Component<Props> {
}
};
goToNewDocument = (event: KeyboardEvent) => {
if (event.metaKey || event.altKey) {
return;
}
goToNewDocument = () => {
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) {
return;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 B

After

Width:  |  Height:  |  Size: 976 B

+1 -2
View File
@@ -1,7 +1,6 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import env from "~/env";
import OutlineLogo from "./OutlineLogo";
@@ -39,7 +38,7 @@ const Link = styled.a`
}
${breakpoint("tablet")`
z-index: ${depths.sidebar + 1};
z-index: ${(props: any) => props.theme.depths.sidebar + 1};
position: fixed;
bottom: 0;
left: 0;
+3 -7
View File
@@ -9,15 +9,11 @@ import { MenuInternalLink } from "~/types";
type Props = {
items: MenuInternalLink[];
max?: number;
children?: React.ReactNode;
highlightFirstItem?: boolean;
};
function Breadcrumb({
items,
highlightFirstItem,
children,
max = 2,
}: React.PropsWithChildren<Props>) {
function Breadcrumb({ items, highlightFirstItem, children, max = 2 }: Props) {
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
@@ -37,7 +33,7 @@ function Breadcrumb({
return (
<Flex justify="flex-start" align="center">
{topLevelItems.map((item, index) => (
<React.Fragment key={String(item.to) || index}>
<React.Fragment key={item.to || index}>
{item.icon}
{item.to ? (
<Item
+2 -7
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import * as React from "react";
@@ -112,11 +111,7 @@ const RealButton = styled.button<{
}
&:disabled {
background: ${lighten(0.05, props.theme.danger)};
}
&.focus-visible {
outline-color: ${darken(0.2, props.theme.danger)} !important;
background: none;
}
`};
`;
@@ -156,7 +151,7 @@ export type Props<T> = {
primary?: boolean;
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
to?: string;
borderOnHover?: boolean;
href?: string;
"data-on"?: string;
+13 -1
View File
@@ -1,6 +1,18 @@
import * as React from "react";
import styled from "styled-components";
const ButtonLink = styled.button`
type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
children: React.ReactNode;
};
const ButtonLink = React.forwardRef(
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
return <Button {...props} ref={ref} />;
}
);
const Button = styled.button`
margin: 0;
padding: 0;
border: 0;
+5 -4
View File
@@ -3,17 +3,18 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
children?: React.ReactNode;
withStickyHeader?: boolean;
};
const Container = styled.div<Props>`
const Container = styled.div<{ withStickyHeader?: boolean }>`
width: 100%;
max-width: 100vw;
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
${breakpoint("tablet")`
padding: ${(props: Props) =>
props.withStickyHeader ? "4px 44px 60px" : "60px 44px"};
padding: ${(props: any) =>
props.withStickyHeader ? "4px 60px 60px" : "60px"};
`};
`;
@@ -26,7 +27,7 @@ const Content = styled.div`
`};
`;
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => {
const CenteredContent = ({ children, ...rest }: Props) => {
return (
<Container {...rest}>
<Content>{children}</Content>
+12 -9
View File
@@ -42,9 +42,8 @@ function Collaborators(props: Props) {
filter(
users.orderedData,
(user) =>
(presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)) &&
!user.isSuspended
presentIds.includes(user.id) ||
document.collaboratorIds.includes(user.id)
),
(user) => presentIds.includes(user.id)
),
@@ -53,14 +52,18 @@ function Collaborators(props: Props) {
// load any users we don't yet have in memory
React.useEffect(() => {
const ids = uniq([...document.collaboratorIds, ...presentIds])
.filter((userId) => !users.get(userId))
.sort();
const userIdsToFetch = uniq([
...document.collaboratorIds,
...presentIds,
]).filter((userId) => !users.get(userId));
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
setRequestedUserIds(ids);
users.fetchPage({ ids, limit: 100 });
if (!isEqual(requestedUserIds, userIdsToFetch)) {
setRequestedUserIds(userIdsToFetch);
}
userIdsToFetch
.filter((userId) => requestedUserIds.includes(userId))
.forEach((userId) => users.fetch(userId));
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
const popover = usePopoverState({
+18 -21
View File
@@ -1,4 +1,3 @@
import debounce from "lodash/debounce";
import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
@@ -10,6 +9,7 @@ import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import useDebouncedCallback from "~/hooks/useDebouncedCallback";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
@@ -25,7 +25,7 @@ function CollectionDescription({ collection }: Props) {
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
const [isDirty, setDirty] = React.useState(false);
const can = usePolicy(collection);
const can = usePolicy(collection.id);
const handleStartEditing = React.useCallback(() => {
setEditing(true);
@@ -49,25 +49,21 @@ function CollectionDescription({ collection }: Props) {
[isExpanded]
);
const handleSave = React.useMemo(
() =>
debounce(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000),
[collection, showToast, t]
);
const handleSave = useDebouncedCallback(async (getValue) => {
try {
await collection.save({
description: getValue(),
});
setDirty(false);
} catch (err) {
showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000);
const handleChange = React.useCallback(
(getValue) => {
@@ -111,6 +107,7 @@ function CollectionDescription({ collection }: Props) {
maxLength={1000}
embedsDisabled
readOnlyWriteCheckboxes
grow
/>
</React.Suspense>
) : (
+1 -4
View File
@@ -5,7 +5,6 @@ import * as React from "react";
import Collection from "~/models/Collection";
import { icons } from "~/components/IconPicker";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
type Props = {
collection: Collection;
@@ -37,9 +36,7 @@ function ResolvedCollectionIcon({
const Component = icons[collection.icon].component;
return <Component color={color} size={size} />;
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
});
console.warn("Failed to render custom icon " + collection.icon);
}
}
+3 -4
View File
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import CommandBarResults from "~/components/CommandBarResults";
import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
@@ -68,7 +67,7 @@ function CommandBar() {
);
}
const KBarPortal: React.FC = ({ children }) => {
function KBarPortal({ children }: { children: React.ReactNode }) {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
@@ -78,7 +77,7 @@ const KBarPortal: React.FC = ({ children }) => {
}
return <Portal>{children}</Portal>;
};
}
const Hint = styled(Text)`
display: flex;
@@ -91,7 +90,7 @@ const Hint = styled(Text)`
`;
const Positioner = styled(KBarPositioner)`
z-index: ${depths.commandBar};
z-index: ${(props) => props.theme.depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
-61
View File
@@ -1,61 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {
/** Callback when the dialog is submitted */
onSubmit: () => Promise<void> | void;
/** Text to display on the submit button */
submitText?: string;
/** Text to display while the form is saving */
savingText?: string;
/** If true, the submit button will be a dangerous red */
danger?: boolean;
};
const ConfirmationDialog: React.FC<Props> = ({
onSubmit,
children,
submitText,
savingText,
danger,
}) => {
const [isSaving, setIsSaving] = React.useState(false);
const { dialogs } = useStores();
const { showToast } = useToasts();
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await onSubmit();
dialogs.closeAllModals();
} catch (err) {
showToast(err.message, {
type: "error",
});
} finally {
setIsSaving(false);
}
},
[onSubmit, dialogs, showToast]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">{children}</Text>
<Button type="submit" disabled={isSaving} danger={danger} autoFocus>
{isSaving ? savingText : submitText}
</Button>
</form>
</Flex>
);
};
export default observer(ConfirmationDialog);
+10 -54
View File
@@ -18,13 +18,6 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
value: string;
};
export type RefHandle = {
focus: () => void;
focusAtStart: () => void;
focusAtEnd: () => void;
getComputedDirection: () => string;
};
/**
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
@@ -48,36 +41,13 @@ const ContentEditable = React.forwardRef(
onClick,
...rest
}: Props,
ref: React.RefObject<RefHandle>
forwardedRef: React.RefObject<HTMLSpanElement>
) => {
const contentRef = React.useRef<HTMLSpanElement>(null);
const innerRef = React.useRef<HTMLSpanElement>(null);
const ref = forwardedRef || innerRef;
const [innerValue, setInnerValue] = React.useState<string>(value);
const lastValue = React.useRef("");
React.useImperativeHandle(ref, () => ({
focus: () => {
contentRef.current?.focus();
},
focusAtStart: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, true);
}
},
focusAtEnd: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, false);
}
},
getComputedDirection: () => {
if (contentRef.current) {
return window.getComputedStyle(contentRef.current).direction;
}
return "ltr";
},
}));
const wrappedEvent = (
callback:
| React.FocusEventHandler<HTMLSpanElement>
@@ -85,7 +55,7 @@ const ContentEditable = React.forwardRef(
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) => (event: any) => {
const text = contentRef.current?.innerText || "";
const text = ref.current?.innerText || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event?.preventDefault();
@@ -104,19 +74,19 @@ const ContentEditable = React.forwardRef(
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(contentRef);
const isVisible = useOnScreen(ref);
React.useEffect(() => {
if (autoFocus && isVisible && !disabled && !readOnly) {
contentRef.current?.focus();
ref.current?.focus();
}
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
}, [autoFocus, disabled, isVisible, readOnly, ref]);
React.useEffect(() => {
if (value !== contentRef.current?.innerText) {
if (value !== ref.current?.innerText) {
setInnerValue(value);
}
}, [value, contentRef]);
}, [value, ref]);
// Ensure only plain text can be pasted into title when pasting from another
// rich text editor
@@ -132,7 +102,7 @@ const ContentEditable = React.forwardRef(
return (
<div className={className} dir={dir} onClick={onClick}>
<Content
ref={contentRef}
ref={ref}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
@@ -151,20 +121,6 @@ const ContentEditable = React.forwardRef(
}
);
function placeCaret(element: HTMLElement, atStart: boolean) {
if (
typeof window.getSelection !== "undefined" &&
typeof document.createRange !== "undefined"
) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(atStart);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
const Content = styled.span`
background: ${(props) => props.theme.background};
transition: ${(props) => props.theme.backgroundTransition};
+15 -16
View File
@@ -1,17 +1,18 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { hover } from "~/styles";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {
onClick?: (event: React.SyntheticEvent) => void | Promise<void>;
onClick?: (arg0: React.SyntheticEvent) => void | Promise<void>;
children?: React.ReactNode;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
to?: string;
href?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
@@ -20,7 +21,7 @@ type Props = {
icon?: React.ReactElement;
};
const MenuItem: React.FC<Props> = ({
const MenuItem = ({
onClick,
children,
selected,
@@ -29,7 +30,7 @@ const MenuItem: React.FC<Props> = ({
hide,
icon,
...rest
}) => {
}: Props) => {
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
@@ -132,18 +133,16 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
? "pointer-events: none;"
: `
@media (hover: hover) {
&:hover,
&:focus,
&.focus-visible {
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: pointer;
&:${hover},
&:focus,
&.focus-visible {
color: ${props.theme.white};
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
box-shadow: none;
cursor: pointer;
svg {
fill: ${props.theme.white};
}
svg {
fill: ${props.theme.white};
}
}
`};
@@ -1,66 +0,0 @@
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = {
/* Sub-menu x */
x: number;
/* Sub-menu y */
y: number;
/* Sub-menu height */
h: number;
/* Sub-menu width */
w: number;
/* Mouse x */
mouseX: number;
/* Mouse y */
mouseY: number;
};
/**
* Component to cover the area between the mouse cursor and the sub-menu, to
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const { x = 0, y = 0, height: h = 0, width: w = 0 } =
props.parentRef.current?.getBoundingClientRect() || {};
const [mouseX, mouseY] = useMousePosition();
const positions = { x, y, h, w, mouseX, mouseY };
return (
<div
style={{
position: "absolute",
top: 0,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
width: getWidth(positions),
clipPath: getClipPath(positions),
}}
/>
);
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
+1 -1
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
export default function Separator(rest: any) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
+23 -23
View File
@@ -21,7 +21,6 @@ import {
} from "~/types";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
@@ -60,7 +59,6 @@ const Submenu = React.forwardRef(
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Submenu")}>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
@@ -69,27 +67,29 @@ const Submenu = React.forwardRef(
);
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unnecessary separators
filtered = filtered.reduce((acc, item, index) => {
// trim separators from start / end
if (item.type === "separator" && index === 0) {
return acc;
}
if (item.type === "separator" && index === filtered.length - 1) {
return acc;
}
// trim double separators looking ahead / behind
const prev = filtered[index - 1];
if (prev && prev.type === "separator" && item.type === "separator") {
return acc;
}
// otherwise, continue
return [...acc, item];
}, []);
return filtered;
}
function Template({ items, actions, context, ...menu }: Props) {
+18 -40
View File
@@ -1,12 +1,9 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import { Menu } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import usePrevious from "~/hooks/usePrevious";
@@ -41,18 +38,19 @@ type Props = {
visible?: boolean;
placement?: Placement;
animating?: boolean;
children: React.ReactNode;
unstable_disclosureRef?: React.RefObject<HTMLElement | null>;
onOpen?: () => void;
onClose?: () => void;
hide?: () => void;
};
const ContextMenu: React.FC<Props> = ({
export default function ContextMenu({
children,
onOpen,
onClose,
...rest
}) => {
}: Props) {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight(rest.visible, rest.unstable_disclosureRef);
const backgroundRef = React.useRef<HTMLDivElement>(null);
@@ -93,19 +91,6 @@ const ContextMenu: React.FC<Props> = ({
t,
]);
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (rest.visible && scrollElement) {
disableBodyScroll(scrollElement);
}
return () => {
scrollElement && enableBodyScroll(scrollElement);
};
}, [rest.visible]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
@@ -115,7 +100,7 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => {
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
@@ -131,7 +116,6 @@ const ContextMenu: React.FC<Props> = ({
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
maxHeight && topAnchor
? {
@@ -153,9 +137,7 @@ const ContextMenu: React.FC<Props> = ({
)}
</>
);
};
export default ContextMenu;
}
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
@@ -165,7 +147,7 @@ export const Backdrop = styled.div`
right: 0;
bottom: 0;
background: ${(props) => props.theme.backdrop};
z-index: ${depths.menu - 1};
z-index: ${(props) => props.theme.depths.menu - 1};
${breakpoint("tablet")`
display: none;
@@ -174,12 +156,10 @@ export const Backdrop = styled.div`
export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
z-index: ${(props) => props.theme.depths.menu};
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
// overrides make mobile-first coding style challenging
// so we explicitly define mobile breakpoint here
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
@@ -190,13 +170,10 @@ export const Position = styled.div`
`};
`;
type BackgroundProps = {
export const Background = styled.div<{
topAnchor?: boolean;
rightAnchor?: boolean;
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
}>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
@@ -205,6 +182,8 @@ export const Background = styled(Scrollable)<BackgroundProps>`
padding: 6px 0;
min-width: 180px;
min-height: 44px;
overflow: hidden;
overflow-y: auto;
max-height: 75vh;
pointer-events: all;
font-weight: normal;
@@ -214,12 +193,11 @@ export const Background = styled(Scrollable)<BackgroundProps>`
}
${breakpoint("tablet")`
animation: ${(props: BackgroundProps) =>
animation: ${(props: any) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
transform-origin: ${(props: any) => (props.rightAnchor ? "75%" : "25%")} 0;
max-width: 276px;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
background: ${(props: any) => props.theme.menuBackground};
box-shadow: ${(props: any) => props.theme.menuShadow};
`};
`;
+5 -5
View File
@@ -1,12 +1,11 @@
import copy from "copy-to-clipboard";
import * as React from "react";
import env from "~/env";
type Props = {
text: string;
children?: React.ReactElement;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
onCopy?: () => void;
onCopy: () => void;
};
class CopyToClipboard extends React.PureComponent<Props> {
@@ -15,11 +14,12 @@ class CopyToClipboard extends React.PureComponent<Props> {
const elem = React.Children.only(children);
copy(text, {
debug: env.ENVIRONMENT !== "production",
debug: process.env.NODE_ENV !== "production",
format: "text/plain",
});
onCopy?.();
if (onCopy) {
onCopy();
}
if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
-1
View File
@@ -22,7 +22,6 @@ function Dialogs() {
<Modal
key={id}
isOpen={modal.isOpen}
isCentered={modal.isCentered}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
>
+2 -5
View File
@@ -12,6 +12,7 @@ import { collectionUrl } from "~/utils/routeHelpers";
type Props = {
document: Document;
children?: React.ReactNode;
onlyText?: boolean;
};
@@ -48,11 +49,7 @@ function useCategory(document: Document): MenuInternalLink | null {
return null;
}
const DocumentBreadcrumb: React.FC<Props> = ({
document,
children,
onlyText,
}) => {
const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
+1 -1
View File
@@ -108,7 +108,7 @@ function DocumentCard(props: Props) {
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<PinButton onClick={handleUnpin}>
<CloseIcon color="currentColor" />
</PinButton>
</Tooltip>
+4 -5
View File
@@ -49,8 +49,8 @@ function DocumentListItem(
ref: React.RefObject<HTMLAnchorElement>
) {
const { t } = useTranslation();
const user = useCurrentUser();
const team = useCurrentTeam();
const currentUser = useCurrentUser();
const currentTeam = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const {
@@ -70,7 +70,7 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = usePolicy(team);
const can = usePolicy(currentTeam.id);
const canCollection = usePolicy(document.collectionId);
return (
@@ -78,7 +78,6 @@ function DocumentListItem(
as={DocumentLink}
ref={ref}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
@@ -96,7 +95,7 @@ function DocumentListItem(
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy.id !== user.id && (
{document.isBadgedNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
+12 -30
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -36,10 +35,11 @@ type Props = {
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
to?: LocationDescriptor;
children?: React.ReactNode;
to?: string;
};
const DocumentMeta: React.FC<Props> = ({
function DocumentMeta({
showPublished,
showCollection,
showLastViewed,
@@ -48,7 +48,7 @@ const DocumentMeta: React.FC<Props> = ({
children,
to,
...rest
}) => {
}: Props) {
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
@@ -74,61 +74,42 @@ const DocumentMeta: React.FC<Props> = ({
const collection = collections.get(document.collectionId);
const lastUpdatedByCurrentUser = user.id === updatedBy.id;
const userName = updatedBy.name;
let content;
if (deletedAt) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You deleted")
: t("{{ userName }} deleted", { userName })}{" "}
<Time dateTime={deletedAt} addSuffix />
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
</span>
);
} else if (archivedAt) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You archived")
: t("{{ userName }} archived", { userName })}{" "}
<Time dateTime={archivedAt} addSuffix />
{t("archived")} <Time dateTime={archivedAt} addSuffix />
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You created")
: t("{{ userName }} created", { userName })}{" "}
<Time dateTime={updatedAt} addSuffix />
{t("created")} <Time dateTime={updatedAt} addSuffix />
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You published")
: t("{{ userName }} published", { userName })}{" "}
<Time dateTime={publishedAt} addSuffix />
{t("published")} <Time dateTime={publishedAt} addSuffix />
</span>
);
} else if (isDraft) {
content = (
<span>
{lastUpdatedByCurrentUser
? t("You saved")
: t("{{ userName }} saved", { userName })}{" "}
<Time dateTime={updatedAt} addSuffix />
{t("saved")} <Time dateTime={updatedAt} addSuffix />
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}>
{lastUpdatedByCurrentUser
? t("You updated")
: t("{{ userName }} updated", { userName })}{" "}
<Time dateTime={updatedAt} addSuffix />
{t("updated")} <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
@@ -163,6 +144,7 @@ const DocumentMeta: React.FC<Props> = ({
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
@@ -190,6 +172,6 @@ const DocumentMeta: React.FC<Props> = ({
{children}
</Container>
);
};
}
export default observer(DocumentMeta);
+3 -4
View File
@@ -1,5 +1,4 @@
import { LocationDescriptor } from "history";
import { observer, useObserver } from "mobx-react";
import { useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
@@ -13,7 +12,7 @@ import useStores from "~/hooks/useStores";
type Props = {
document: Document;
isDraft: boolean;
to?: LocationDescriptor;
to?: string;
rtl?: boolean;
};
@@ -84,4 +83,4 @@ const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
}
`;
export default observer(DocumentMetaWithViews);
export default DocumentMetaWithViews;
+1 -2
View File
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, TFunction } from "react-i18next";
@@ -61,4 +60,4 @@ const Done = styled(DoneIcon)<{ $animated: boolean }>`
transform-origin: center center;
`;
export default observer(DocumentTasks);
export default DocumentTasks;
@@ -1,52 +0,0 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { documentUrl } from "~/utils/routeHelpers";
type Props = {
documentId: string;
};
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { showToast } = useToasts();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
invariant(document, "Document must exist");
const handleSubmit = React.useCallback(async () => {
const template = await document?.templatize();
if (template) {
history.push(documentUrl(template));
showToast(t("Template created, go ahead and customize it"), {
type: "info",
});
}
}, [document, showToast, history, t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Create template")}
savingText={`${t("Creating")}`}
>
<Trans
defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents."
values={{
titleWithDefault: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
);
}
export default observer(DocumentTemplatizeDialog);
+1 -2
View File
@@ -4,7 +4,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
@@ -43,7 +42,7 @@ function DocumentViews({ document, isOpen }: Props) {
<PaginatedList
aria-label={t("Viewers")}
items={users}
renderItem={(item: User) => {
renderItem={(item) => {
const view = documentViews.find((v) => v.user.id === item.id);
const isPresent = presentIds.includes(item.id);
const isEditing = editingIds.includes(item.id);
+21 -225
View File
@@ -1,34 +1,17 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { Heading } from "@shared/editor/lib/getHeadings";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import embeds from "@shared/editor/embeds";
import { isInternalUrl } from "@shared/utils/urls";
import { AttachmentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
import HoverPreview from "~/components/HoverPreview";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import { Props as EditorProps } from "~/editor";
import useDictionary from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import history from "~/utils/history";
import { isModKey } from "~/utils/keyboard";
import { isHash } from "~/utils/urls";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const LazyLoadedEditor = React.lazy(
const SharedEditor = React.lazy(
() =>
import(
/* webpackChunkName: "shared-editor" */
@@ -44,88 +27,18 @@ export type Props = Optional<
| "embeds"
| "dictionary"
| "onShowToast"
| "extensions"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
grow?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => any;
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { id, shareId, onChange, onHeadingsChange } = props;
const { documents } = useStores();
function Editor(props: Props, ref: React.Ref<any>) {
const { id, shareId } = props;
const { showToast } = useToasts();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const [
activeLinkEvent,
setActiveLinkEvent,
] = React.useState<MouseEvent | null>(null);
const previousHeadings = React.useRef<Heading[] | null>(null);
const handleLinkActive = React.useCallback((event: MouseEvent) => {
setActiveLinkEvent(event);
return false;
}, []);
const handleLinkInactive = React.useCallback(() => {
setActiveLinkEvent(null);
}, []);
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
// search for exact internal document
const slug = parseDocumentSlug(term);
if (!slug) {
return [];
}
try {
const document = await documents.fetch(slug);
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
addSuffix: true,
});
return [
{
title: document.title,
subtitle: `Updated ${time}`,
url: document.url,
},
];
} catch (error) {
// NotFoundError could not find document for slug
if (!(error instanceof NotFoundError)) {
throw error;
}
}
}
// default search for anything that doesn't look like a URL
const results = await documents.searchTitles(term);
return sortBy(
results.map((document: Document) => {
return {
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
};
}),
(document) =>
deburr(document.title)
.toLowerCase()
.startsWith(deburr(term).toLowerCase())
? -1
: 1
);
},
[documents]
);
const onUploadFile = React.useCallback(
async (file: File) => {
@@ -171,145 +84,28 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[shareId]
);
const focusAtEnd = React.useCallback(() => {
ref?.current?.focusAtEnd();
}, [ref]);
const handleDrop = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const files = getDataTransferFiles(event);
const view = ref?.current?.view;
if (!view) {
return;
}
// Find a valid position at the end of the document to insert our content
const pos = TextSelection.near(
view.state.doc.resolve(view.state.doc.nodeSize - 2)
).from;
// If there are no files in the drop event attempt to parse the html
// as a fragment and insert it at the end of the document
if (files.length === 0) {
const text =
event.dataTransfer.getData("text/html") ||
event.dataTransfer.getData("text/plain");
const dom = new DOMParser().parseFromString(text, "text/html");
view.dispatch(
view.state.tr.insert(
pos,
ProsemirrorDOMParser.fromSchema(view.state.schema).parse(dom)
)
);
return;
}
// Insert all files as attachments if any of the files are not images.
const isAttachment = files.some(
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
);
insertFiles(view, event, pos, files, {
uploadFile: onUploadFile,
onFileUploadStart: props.onFileUploadStart,
onFileUploadStop: props.onFileUploadStop,
onShowToast: showToast,
dictionary,
isAttachment,
});
const onShowToast = React.useCallback(
(message: string) => {
showToast(message);
},
[
ref,
props.onFileUploadStart,
props.onFileUploadStop,
dictionary,
onUploadFile,
showToast,
]
);
// see: https://stackoverflow.com/a/50233827/192065
const handleDragOver = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
},
[]
);
// Calculate if headings have changed and trigger callback if so
const updateHeadings = React.useCallback(() => {
if (onHeadingsChange) {
const headings = ref?.current?.getHeadings();
if (
headings &&
headings.map((h) => h.level + h.title).join("") !==
previousHeadings.current?.map((h) => h.level + h.title).join("")
) {
previousHeadings.current = headings;
onHeadingsChange(headings);
}
}
}, [ref, onHeadingsChange]);
const handleChange = React.useCallback(
(event) => {
onChange?.(event);
updateHeadings();
},
[onChange, updateHeadings]
);
const handleRefChanged = React.useCallback(
(node: SharedEditor | null) => {
if (node && !previousHeadings.current) {
updateHeadings();
}
},
[updateHeadings]
[showToast]
);
return (
<ErrorBoundary reloadOnChunkMissing>
<>
<LazyLoadedEditor
ref={mergeRefs([ref, handleRefChanged])}
uploadFile={onUploadFile}
onShowToast={showToast}
embeds={embeds}
dictionary={dictionary}
{...props}
onHoverLink={handleLinkActive}
onClickLink={onClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
{props.grow && !props.readOnly && (
<ClickablePadding
onClick={focusAtEnd}
onDrop={handleDrop}
onDragOver={handleDragOver}
grow
/>
)}
{activeLinkEvent && !shareId && (
<HoverPreview
node={activeLinkEvent.target as HTMLAnchorElement}
event={activeLinkEvent}
onClose={handleLinkInactive}
/>
)}
</>
<SharedEditor
ref={ref}
uploadFile={onUploadFile}
onShowToast={onShowToast}
embeds={embeds}
dictionary={dictionary}
{...props}
onClickLink={onClickLink}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>
</ErrorBoundary>
);
}
export default observer(React.forwardRef(Editor));
export default React.forwardRef(Editor);
-1
View File
@@ -2,7 +2,6 @@ import styled from "styled-components";
const Empty = styled.p`
color: ${(props) => props.theme.textTertiary};
user-select: none;
`;
export default Empty;
+7 -4
View File
@@ -1,3 +1,4 @@
import * as Sentry from "@sentry/react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
@@ -9,10 +10,9 @@ import CenteredContent from "~/components/CenteredContent";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import env from "~/env";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
type Props = WithTranslation & {
children: React.ReactNode;
reloadOnChunkMissing?: boolean;
};
@@ -26,6 +26,7 @@ class ErrorBoundary extends React.Component<Props> {
componentDidCatch(error: Error) {
this.error = error;
console.error(error);
if (
this.props.reloadOnChunkMissing &&
@@ -39,7 +40,9 @@ class ErrorBoundary extends React.Component<Props> {
return;
}
Logger.error("ErrorBoundary", error);
if (env.SENTRY_DSN) {
Sentry.captureException(error);
}
}
handleReload = () => {
@@ -59,7 +62,7 @@ class ErrorBoundary extends React.Component<Props> {
if (this.error) {
const error = this.error;
const isReported = !!env.SENTRY_DSN && isCloudHosted;
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
const isChunkError = this.error.message.match(/chunk/);
if (isChunkError) {
+3 -4
View File
@@ -1,10 +1,11 @@
import * as React from "react";
type Props = {
children: React.ReactNode;
className?: string;
};
const EventBoundary: React.FC<Props> = ({ children, className }) => {
export default function EventBoundary({ children, className }: Props) {
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
@@ -15,6 +16,4 @@ const EventBoundary: React.FC<Props> = ({ children, className }) => {
{children}
</span>
);
};
export default EventBoundary;
}
+2 -11
View File
@@ -5,7 +5,6 @@ import {
PublishIcon,
MoveIcon,
CheckboxIcon,
UnpublishIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -33,7 +32,7 @@ type Props = {
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
const location = useLocation();
const can = usePolicy(document);
const can = usePolicy(document.id);
const opts = {
userName: event.actor.name,
};
@@ -86,11 +85,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
meta = t("{{userName}} published", opts);
break;
case "documents.unpublish":
icon = <UnpublishIcon color="currentColor" size={16} />;
meta = t("{{userName}} unpublished", opts);
break;
case "documents.move":
icon = <MoveIcon color="currentColor" size={16} />;
meta = t("{{userName}} moved", opts);
@@ -119,10 +113,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
<Time
dateTime={event.createdAt}
tooltipDelay={500}
format={{
en_US: "MMM do, h:mm a",
fr_FR: "'Le 'd MMMM 'à' H:mm",
}}
format="MMM do, h:mm a"
relative={false}
addSuffix
onClick={handleTimeClick}
+13 -12
View File
@@ -1,9 +1,18 @@
import { CSSProperties } from "react";
import styled from "styled-components";
type JustifyValues = CSSProperties["justifyContent"];
type JustifyValues =
| "center"
| "space-around"
| "space-between"
| "flex-start"
| "flex-end";
type AlignValues = CSSProperties["alignItems"];
type AlignValues =
| "stretch"
| "center"
| "baseline"
| "flex-start"
| "flex-end";
const Flex = styled.div<{
auto?: boolean;
@@ -11,19 +20,11 @@ const Flex = styled.div<{
align?: AlignValues;
justify?: JustifyValues;
shrink?: boolean;
reverse?: boolean;
gap?: number;
}>`
display: flex;
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
flex-direction: ${({ column, reverse }) =>
reverse
? column
? "column-reverse"
: "row-reverse"
: column
? "column"
: "row"};
flex-direction: ${({ column }) => (column ? "column" : "row")};
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
+1 -1
View File
@@ -19,7 +19,7 @@ type Props = RootStore & {
membership?: CollectionGroupMembership;
showFacepile?: boolean;
showAvatar?: boolean;
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
renderActions: (arg0: { openMembersModal: () => void }) => React.ReactNode;
};
@observer
+7 -6
View File
@@ -1,23 +1,24 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import { depths } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import usePrevious from "~/hooks/usePrevious";
type Props = {
children?: React.ReactNode;
isOpen: boolean;
title?: string;
onRequestClose: () => void;
};
const Guide: React.FC<Props> = ({
const Guide = ({
children,
isOpen,
title = "Untitled",
onRequestClose,
...rest
}) => {
}: Props) => {
const dialog = useDialogState({
animated: 250,
});
@@ -72,7 +73,7 @@ const Backdrop = styled.div`
right: 0;
bottom: 0;
background-color: ${(props) => props.theme.backdrop} !important;
z-index: ${depths.modalOverlay};
z-index: ${(props) => props.theme.depths.modalOverlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
@@ -87,7 +88,7 @@ const Scene = styled.div`
right: 0;
bottom: 0;
margin: 12px;
z-index: ${depths.modal};
z-index: ${(props) => props.theme.depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
@@ -111,4 +112,4 @@ const Content = styled(Scrollable)`
padding: 16px;
`;
export default Guide;
export default observer(Guide);
+11 -11
View File
@@ -5,11 +5,9 @@ import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { supportsPassiveListener } from "~/utils/browser";
@@ -30,17 +28,19 @@ function Header({ breadcrumb, title, actions, hasSidebar }: Props) {
const passThrough = !actions && !breadcrumb && !title;
const [isScrolled, setScrolled] = React.useState(false);
const handleScroll = React.useMemo(
() => throttle(() => setScrolled(window.scrollY > 75), 50),
const handleScroll = React.useCallback(
throttle(() => setScrolled(window.scrollY > 75), 50),
[]
);
useEventListener(
"scroll",
handleScroll,
window,
supportsPassiveListener ? { passive: true } : { capture: false }
);
React.useEffect(() => {
window.addEventListener(
"scroll",
handleScroll,
supportsPassiveListener ? { passive: true } : false
);
return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
const handleClickTitle = React.useCallback(() => {
window.scrollTo({
@@ -100,7 +100,7 @@ const Actions = styled(Flex)`
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
top: 0;
z-index: ${depths.header};
z-index: ${(props) => props.theme.depths.header};
position: sticky;
background: ${(props) => props.theme.background};
+3 -4
View File
@@ -1,4 +1,3 @@
import { escapeRegExp } from "lodash";
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
@@ -24,7 +23,7 @@ function Highlight({
regex = highlight;
} else {
regex = new RegExp(
escapeRegExp(highlight || ""),
(highlight || "").replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"),
caseSensitive ? "g" : "gi"
);
}
@@ -42,10 +41,10 @@ function Highlight({
);
}
export const Mark = styled.mark`
const Mark = styled.mark`
background: ${(props) => props.theme.searchHighlight};
border-radius: 2px;
padding: 0 2px;
padding: 0 4px;
`;
export default Highlight;
+4 -11
View File
@@ -2,11 +2,9 @@ import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { depths } from "@shared/styles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isExternalUrl } from "@shared/utils/urls";
import { isInternalUrl } from "@shared/utils/urls";
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { fadeAndSlideDown } from "~/styles/animations";
@@ -125,13 +123,8 @@ function HoverPreviewInternal({ node, onClose }: Props) {
}
function HoverPreview({ node, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
}
// previews only work for internal doc links for now
if (isExternalUrl(node.href)) {
if (!isInternalUrl(node.href)) {
return null;
}
@@ -157,7 +150,7 @@ const Margin = styled.div`
const CardContent = styled.div`
overflow: hidden;
max-height: 20em;
max-height: 350px;
user-select: none;
`;
@@ -202,7 +195,7 @@ const Card = styled.div`
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
z-index: ${depths.hoverPreview};
z-index: ${(props) => props.theme.depths.hoverPreview};
display: flex;
max-height: 75%;
+13 -3
View File
@@ -40,7 +40,6 @@ import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { colorPalette } from "@shared/utils/collections";
import ContextMenu from "~/components/ContextMenu";
import Flex from "~/components/Flex";
import { LabelText } from "~/components/Input";
@@ -201,7 +200,18 @@ export const icons = {
keywords: "warning alert error",
},
};
const colors = [
"#4E5C6E",
"#0366d6",
"#9E5CF7",
"#FF825C",
"#FF5C80",
"#FFBE0B",
"#42DED1",
"#00D084",
"#FF4DFA",
"#2F362F",
];
type Props = {
onOpen?: () => void;
onClose?: () => void;
@@ -262,7 +272,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
colors={colors}
triangle="hide"
styles={{
default: {
+33 -37
View File
@@ -38,13 +38,6 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
color: ${(props) => props.theme.placeholder};
}
&:-webkit-autofill,
&:-webkit-autofill:hover,
&:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0px 1000px ${(props) => props.theme.background}
inset;
}
&::-webkit-search-cancel-button {
-webkit-appearance: none;
}
@@ -104,24 +97,36 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = React.InputHTMLAttributes<
HTMLInputElement | HTMLTextAreaElement
> & {
export type Props = React.HTMLAttributes<HTMLInputElement> & {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
labelHidden?: boolean;
value?: string;
label?: string;
className?: string;
labelHidden?: boolean;
flex?: boolean;
short?: boolean;
margin?: string | number;
icon?: React.ReactNode;
innerRef?: React.Ref<any>;
name?: string;
minLength?: number;
maxLength?: number;
autoFocus?: boolean;
autoComplete?: boolean | string;
readOnly?: boolean;
required?: boolean;
disabled?: boolean;
placeholder?: string;
onChange?: (
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
};
@observer
class Input extends React.Component<Props> {
input = this.props.innerRef;
input = React.createRef<HTMLInputElement | HTMLTextAreaElement>();
@observable
focused = false;
@@ -142,6 +147,10 @@ class Input extends React.Component<Props> {
}
};
focus() {
this.input.current?.focus();
}
render() {
const {
type = "text",
@@ -157,6 +166,8 @@ class Input extends React.Component<Props> {
...rest
} = this.props;
const InputComponent: React.ComponentType =
type === "textarea" ? RealTextarea : RealInput;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
@@ -170,24 +181,15 @@ class Input extends React.Component<Props> {
))}
<Outline focused={this.focused} margin={margin}>
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
<RealTextarea
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
{...rest}
/>
) : (
<RealInput
ref={this.props.innerRef}
onBlur={this.props.onBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type}
{...rest}
/>
)}
<InputComponent
// @ts-expect-error no idea why this is not working
ref={this.input}
onBlur={this.handleBlur}
onFocus={this.handleFocus}
hasIcon={!!icon}
type={type === "textarea" ? undefined : type}
{...rest}
/>
</Outline>
</label>
</Wrapper>
@@ -195,10 +197,4 @@ class Input extends React.Component<Props> {
}
}
export const ReactHookWrappedInput = React.forwardRef(
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
return <Input {...{ ...props, innerRef: ref }} />;
}
);
export default Input;
+2 -8
View File
@@ -2,7 +2,7 @@ import { SearchIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useTheme } from "styled-components";
import Input, { Props as InputProps } from "~/components/Input";
import Input, { Props as InputProps } from "./Input";
type Props = InputProps & {
placeholder?: string;
@@ -11,10 +11,7 @@ type Props = InputProps & {
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
};
function InputSearch(
props: Props,
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
) {
export default function InputSearch(props: Props) {
const { t } = useTranslation();
const theme = useTheme();
const [isFocused, setIsFocused] = React.useState(false);
@@ -42,10 +39,7 @@ function InputSearch(
onBlur={handleBlur}
margin={0}
labelHidden
innerRef={ref}
{...rest}
/>
);
}
export default React.forwardRef(InputSearch);
+3 -7
View File
@@ -8,7 +8,7 @@ import useBoolean from "~/hooks/useBoolean";
import useKeyDown from "~/hooks/useKeyDown";
import { isModKey } from "~/utils/keyboard";
import { searchPath } from "~/utils/routeHelpers";
import Input, { Outline } from "./Input";
import Input from "./Input";
type Props = {
source: string;
@@ -30,7 +30,7 @@ function InputSearchPage({
collectionId,
source,
}: Props) {
const inputRef = React.useRef<HTMLInputElement>(null);
const inputRef = React.useRef<Input>(null);
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
@@ -67,7 +67,7 @@ function InputSearchPage({
return (
<InputMaxWidth
innerRef={inputRef}
ref={inputRef}
type="search"
placeholder={placeholder || `${t("Search")}`}
value={value}
@@ -89,10 +89,6 @@ function InputSearchPage({
const InputMaxWidth = styled(Input)`
max-width: 30vw;
${Outline} {
border-radius: 16px;
}
`;
export default observer(InputSearchPage);
+2 -3
View File
@@ -1,7 +1,6 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import { CollectionPermission } from "@shared/types";
import InputSelect, { Props, Option } from "./InputSelect";
export default function InputSelectPermission(
@@ -32,11 +31,11 @@ export default function InputSelectPermission(
options={[
{
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
value: "read_write",
},
{
label: t("View only"),
value: CollectionPermission.Read,
value: "read",
},
{
label: t("No access"),
+2 -1
View File
@@ -5,9 +5,10 @@ import Flex from "~/components/Flex";
type Props = {
label: React.ReactNode | string;
children?: React.ReactNode;
};
const Labeled: React.FC<Props> = ({ label, children, ...props }) => (
const Labeled = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
+2 -2
View File
@@ -10,7 +10,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { detectLanguage } from "~/utils/language";
function Icon({ className }: { className?: string }) {
function Icon(props: any) {
return (
<svg
width="32"
@@ -18,7 +18,7 @@ function Icon({ className }: { className?: string }) {
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
{...props}
>
<path
fill-rule="evenodd"
+7 -9
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Helmet } from "react-helmet";
import styled, { DefaultTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "~/components/Flex";
import { LoadingIndicatorBar } from "~/components/LoadingIndicator";
@@ -14,11 +14,12 @@ import { isModKey } from "~/utils/keyboard";
type Props = {
title?: string;
children?: React.ReactNode;
sidebar?: React.ReactNode;
rightRail?: React.ReactNode;
};
const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
function Layout({ title, children, sidebar, rightRail }: Props) {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.isEditing || ui.sidebarCollapsed;
@@ -64,7 +65,7 @@ const Layout: React.FC<Props> = ({ title, children, sidebar, rightRail }) => {
</Container>
</Container>
);
};
}
const Container = styled(Flex)`
background: ${(props) => props.theme.background};
@@ -74,14 +75,11 @@ const Container = styled(Flex)`
min-height: 100%;
`;
type ContentProps = {
const Content = styled(Flex)<{
$isResizing?: boolean;
$sidebarCollapsed?: boolean;
$hasSidebar?: boolean;
theme: DefaultTheme;
};
const Content = styled(Flex)<ContentProps>`
}>`
margin: 0;
transition: ${(props) =>
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
@@ -95,7 +93,7 @@ const Content = styled(Flex)<ContentProps>`
`}
${breakpoint("tablet")`
${(props: ContentProps) =>
${(props: any) =>
props.$hasSidebar &&
props.$sidebarCollapsed &&
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
-23
View File
@@ -1,23 +0,0 @@
import * as React from "react";
import { loadPolyfills } from "~/utils/polyfills";
/**
* Asyncronously load required polyfills. Should wrap the React tree.
*/
export const LazyPolyfill: React.FC = ({ children }) => {
const [isLoaded, setIsLoaded] = React.useState(false);
React.useEffect(() => {
loadPolyfills().then(() => {
setIsLoaded(true);
});
}, []);
if (!isLoaded) {
return null;
}
return <>{children}</>;
};
export default LazyPolyfill;
-53
View File
@@ -1,53 +0,0 @@
import { DisconnectedIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Empty from "~/components/Empty";
import useEventListener from "~/hooks/useEventListener";
import { OfflineError } from "~/utils/errors";
import ButtonLink from "../ButtonLink";
import Flex from "../Flex";
type Props = {
error: Error;
retry: () => void;
};
export default function LoadingError({ error, retry, ...rest }: Props) {
const { t } = useTranslation();
useEventListener("online", retry);
const message =
error instanceof OfflineError ? (
<>
<DisconnectedIcon color="currentColor" /> {t("Youre offline.")}
</>
) : (
<>
<WarningIcon color="currentColor" /> {t("Sorry, an error occurred.")}
</>
);
return (
<Content {...rest}>
<Flex align="center" gap={4}>
{message}{" "}
<ButtonLink onClick={() => retry()}>{t("Click to retry")}</ButtonLink>
</Flex>
</Content>
);
}
const Content = styled(Empty)`
padding: 8px 0;
white-space: nowrap;
${ButtonLink} {
color: ${(props) => props.theme.textTertiary};
&:hover {
color: ${(props) => props.theme.textSecondary};
text-decoration: underline;
}
}
`;
+1 -6
View File
@@ -69,11 +69,7 @@ const ListItem = (
);
};
const Wrapper = styled.a<{
$small?: boolean;
$border?: boolean;
to?: string;
}>`
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
display: flex;
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
margin: ${(props) =>
@@ -96,7 +92,6 @@ const Image = styled(Flex)`
user-select: none;
flex-shrink: 0;
align-self: center;
color: ${(props) => props.theme.text};
`;
const Heading = styled.p<{ $small?: boolean }>`
+5 -10
View File
@@ -3,24 +3,19 @@ import * as React from "react";
import styled from "styled-components";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import PlaceholderText, {
Props as PlaceholderTextProps,
} from "~/components/PlaceholderText";
import PlaceholderText from "~/components/PlaceholderText";
type Props = {
count?: number;
className?: string;
header?: PlaceholderTextProps;
body?: PlaceholderTextProps;
};
const ListPlaceHolder = ({ count, className, header, body }: Props) => {
const ListPlaceHolder = ({ count }: Props) => {
return (
<Fade>
{times(count || 2, (index) => (
<Item key={index} className={className} column auto>
<PlaceholderText {...header} header delay={0.2 * index} />
<PlaceholderText {...body} delay={0.2 * index} />
<Item key={index} column auto>
<PlaceholderText header delay={0.2 * index} />
<PlaceholderText delay={0.2 * index} />
</Item>
))}
</Fade>
@@ -1,6 +1,5 @@
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { depths } from "@shared/styles";
const LoadingIndicatorBar = () => {
return (
@@ -18,7 +17,7 @@ const loadingFrame = keyframes`
const Container = styled.div`
position: fixed;
top: 0;
z-index: ${depths.loadingIndicatorBar};
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
width: 100%;
animation: ${loadingFrame} 4s ease-in-out infinite;
animation-delay: 250ms;
+15 -16
View File
@@ -2,7 +2,7 @@ import { format as formatDate, formatDistanceToNow } from "date-fns";
import * as React from "react";
import Tooltip from "~/components/Tooltip";
import useUserLocale from "~/hooks/useUserLocale";
import { dateLocale, locales } from "~/utils/i18n";
import { dateLocale } from "~/utils/i18n";
let callbacks: (() => void)[] = [];
@@ -22,14 +22,15 @@ function eachMinute(fn: () => void) {
type Props = {
dateTime: string;
children?: React.ReactNode;
tooltipDelay?: number;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
format?: Partial<Record<keyof typeof locales, string>>;
format?: string;
};
const LocaleTime: React.FC<Props> = ({
function LocaleTime({
addSuffix,
children,
dateTime,
@@ -37,14 +38,8 @@ const LocaleTime: React.FC<Props> = ({
format,
relative,
tooltipDelay,
}) => {
const userLocale: string = useUserLocale() || "";
const dateFormatLong = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
}: Props) {
const userLocale = useUserLocale();
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
@@ -72,13 +67,17 @@ const LocaleTime: React.FC<Props> = ({
.replace("minute", "min");
}
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
locale,
});
const tooltipContent = formatDate(
Date.parse(dateTime),
"MMMM do, yyyy h:mm a",
{
locale,
}
);
const content =
relative !== false
? relativeContent
: formatDate(Date.parse(dateTime), formatLocale, {
: formatDate(Date.parse(dateTime), format || "MMMM do, yyyy h:mm a", {
locale,
});
@@ -87,6 +86,6 @@ const LocaleTime: React.FC<Props> = ({
<time dateTime={dateTime}>{children || content}</time>
</Tooltip>
);
};
}
export default LocaleTime;
+43 -119
View File
@@ -4,39 +4,34 @@ import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled, { DefaultTheme } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useUnmount from "~/hooks/useUnmount";
import { fadeAndScaleIn } from "~/styles/animations";
let openModals = 0;
type Props = {
children?: React.ReactNode;
isOpen: boolean;
isCentered?: boolean;
title?: React.ReactNode;
onRequestClose: () => void;
};
const Modal: React.FC<Props> = ({
const Modal = ({
children,
isOpen,
isCentered,
title = "Untitled",
onRequestClose,
}) => {
}: Props) => {
const dialog = useDialogState({
animated: 250,
});
const [depth, setDepth] = React.useState(0);
const wasOpen = usePrevious(isOpen);
const isMobile = useMobile();
const { t } = useTranslation();
React.useEffect(() => {
@@ -64,66 +59,37 @@ const Modal: React.FC<Props> = ({
return (
<DialogBackdrop {...dialog}>
{(props) => (
<Backdrop $isCentered={isCentered} {...props}>
<Backdrop {...props}>
<Dialog
{...dialog}
aria-label={typeof title === "string" ? title : undefined}
preventBodyScroll
hideOnEsc
hideOnClickOutside={!!isCentered}
hideOnClickOutside={false}
hide={onRequestClose}
>
{(props) =>
isCentered && !isMobile ? (
<Small {...props}>
<Centered
onClick={(ev) => ev.stopPropagation()}
column
reverse
>
<SmallContent shadow>{children}</SmallContent>
<Header>
{title && (
<Text as="span" size="large">
{title}
</Text>
)}
<Text as="span" size="large">
<NudeButton onClick={onRequestClose}>
<CloseIcon color="currentColor" />
</NudeButton>
</Text>
</Header>
{(props) => (
<Scene
$nested={!!depth}
style={{
marginLeft: `${depth * 12}px`,
}}
{...props}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
</Small>
) : (
<Fullscreen
$nested={!!depth}
style={
isMobile
? undefined
: {
marginLeft: `${depth * 12}px`,
}
}
{...props}
>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && <h1>{title}</h1>}
{children}
</Centered>
</Content>
<Close onClick={onRequestClose}>
<CloseIcon size={32} color="currentColor" />
</Close>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
<Text as="span">{t("Back")} </Text>
</Back>
</Fullscreen>
)
}
</Content>
<Back onClick={onRequestClose}>
<BackIcon size={32} color="currentColor" />
<Text>{t("Back")}</Text>
</Back>
<Close onClick={onRequestClose}>
<CloseIcon size={32} color="currentColor" />
</Close>
</Scene>
)}
</Dialog>
</Backdrop>
)}
@@ -131,17 +97,15 @@ const Modal: React.FC<Props> = ({
);
};
const Backdrop = styled(Flex)<{ $isCentered?: boolean }>`
const Backdrop = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${(props) =>
props.$isCentered
? props.theme.modalBackdrop
: transparentize(0.25, props.theme.background)} !important;
z-index: ${depths.modalOverlay};
transparentize(0.25, props.theme.background)} !important;
z-index: ${(props) => props.theme.depths.modalOverlay};
transition: opacity 50ms ease-in-out;
opacity: 0;
@@ -150,12 +114,7 @@ const Backdrop = styled(Flex)<{ $isCentered?: boolean }>`
}
`;
type FullscreenProps = {
$nested: boolean;
theme: DefaultTheme;
};
const Fullscreen = styled.div<FullscreenProps>`
const Scene = styled.div<{ $nested: boolean }>`
animation: ${fadeAndScaleIn} 250ms ease;
position: absolute;
@@ -163,7 +122,7 @@ const Fullscreen = styled.div<FullscreenProps>`
left: 0;
right: 0;
bottom: 0;
z-index: ${depths.modal};
z-index: ${(props) => props.theme.depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
@@ -172,7 +131,7 @@ const Fullscreen = styled.div<FullscreenProps>`
outline: none;
${breakpoint("tablet")`
${(props: FullscreenProps) =>
${(props: any) =>
props.$nested &&
`
box-shadow: 0 -2px 10px ${props.theme.shadow};
@@ -184,10 +143,10 @@ const Fullscreen = styled.div<FullscreenProps>`
const Content = styled(Scrollable)`
width: 100%;
padding: 8vh 32px;
padding: 8vh 2rem 2rem;
${breakpoint("tablet")`
padding: 13vh 2rem 2rem;
padding-top: 13vh;
`};
`;
@@ -198,6 +157,13 @@ const Centered = styled(Flex)`
margin: 0 auto;
`;
const Text = styled.span`
font-size: 16px;
font-weight: 500;
padding-right: 12px;
user-select: none;
`;
const Close = styled(NudeButton)`
position: absolute;
display: block;
@@ -226,7 +192,6 @@ const Back = styled(NudeButton)`
left: 2rem;
opacity: 0.75;
color: ${(props) => props.theme.text};
font-weight: 500;
width: auto;
height: auto;
@@ -239,45 +204,4 @@ const Back = styled(NudeButton)`
`};
`;
const Header = styled(Flex)`
color: ${(props) => props.theme.textSecondary};
align-items: center;
justify-content: space-between;
font-weight: 600;
padding: 24px 24px 4px;
`;
const Small = styled.div`
animation: ${fadeAndScaleIn} 250ms ease;
margin: auto auto;
min-width: 350px;
max-width: 30vw;
z-index: ${depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
background: ${(props) => props.theme.modalBackground};
transition: ${(props) => props.theme.backgroundTransition};
box-shadow: ${(props) => props.theme.modalShadow};
border-radius: 8px;
outline: none;
${NudeButton} {
&:hover,
&[aria-expanded="true"] {
background: ${(props) => props.theme.sidebarControlHoverBackground};
}
vertical-align: middle;
}
${Header} {
align-items: start;
}
`;
const SmallContent = styled(Scrollable)`
padding: 12px 24px 24px;
`;
export default observer(Modal);
+4 -13
View File
@@ -1,15 +1,8 @@
import * as React from "react";
import { match, NavLink, Route } from "react-router-dom";
import { NavLink, Route } from "react-router-dom";
type Props = React.ComponentProps<typeof NavLink> & {
children?: (
match:
| match<{
[x: string]: string | undefined;
}>
| boolean
| null
) => React.ReactNode;
children?: (match: any) => React.ReactNode;
exact?: boolean;
activeStyle?: React.CSSProperties;
to: string;
@@ -21,11 +14,9 @@ function NavLinkWithChildrenFunc(
) {
return (
<Route path={to} exact={exact}>
{({ match, location }) => (
{({ match }) => (
<NavLink {...rest} to={to} exact={exact} ref={ref}>
{children
? children(rest.isActive ? rest.isActive(match, location) : match)
: null}
{children ? children(match) : null}
</NavLink>
)}
</Route>
+2 -1
View File
@@ -4,11 +4,12 @@ import Flex from "./Flex";
import Text from "./Text";
type Props = {
children: React.ReactNode;
icon?: JSX.Element;
description?: JSX.Element;
};
const Notice: React.FC<Props> = ({ children, icon, description }) => {
const Notice = ({ children, icon, description }: Props) => {
return (
<Container>
<Flex as="span" gap={8}>
+6 -4
View File
@@ -1,7 +1,11 @@
import * as React from "react";
import Notice from "~/components/Notice";
const AlertNotice: React.FC = ({ children }) => {
export default function AlertNotice({
children,
}: {
children: React.ReactNode;
}) {
return (
<Notice>
<svg
@@ -24,6 +28,4 @@ const AlertNotice: React.FC = ({ children }) => {
{children}
</Notice>
);
};
export default AlertNotice;
}
+1 -1
View File
@@ -16,7 +16,7 @@ const PageTitle = ({ title, favicon }: Props) => {
return (
<Helmet>
<title>
{team?.name ? `${title} - ${team.name}` : `${title} - Outline`}
{team && team.name ? `${title} - ${team.name}` : `${title} - Outline`}
</title>
{favicon ? (
<link rel="shortcut icon" href={favicon} />
+2 -4
View File
@@ -2,12 +2,11 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import Document from "~/models/Document";
import DocumentListItem from "~/components/DocumentListItem";
import Error from "~/components/List/Error";
import PaginatedList from "~/components/PaginatedList";
type Props = {
documents: Document[];
fetch: (options: any) => Promise<Document[] | undefined>;
fetch: (options: any) => Promise<void>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -41,8 +40,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
heading={heading}
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index, compositeProps) => (
renderItem={(item, _index, compositeProps) => (
<DocumentListItem
key={item.id}
document={item}
+2 -2
View File
@@ -8,7 +8,7 @@ import EventListItem from "./EventListItem";
type Props = {
events: Event[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
fetch: (options: Record<string, any> | null | undefined) => Promise<void>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
@@ -29,7 +29,7 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event, index, compositeProps) => {
renderItem={(item, index, compositeProps) => {
return (
<EventListItem
key={item.id}
+86 -127
View File
@@ -13,39 +13,27 @@ import PlaceholderList from "~/components/List/Placeholder";
import withStores from "~/components/withStores";
import { dateToHeading } from "~/utils/dates";
export interface PaginatedItem {
id: string;
createdAt?: string;
updatedAt?: string;
}
type Props<T> = WithTranslation &
type Props = WithTranslation &
RootStore & {
fetch?: (
options: Record<string, any> | undefined
) => Promise<T[] | undefined> | undefined;
fetch?: (options: Record<string, any> | null | undefined) => Promise<any>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: React.ReactNode;
loading?: React.ReactElement;
items?: T[];
items: any[];
renderItem: (
item: T,
item: any,
index: number,
compositeProps: CompositeStateReturn
composite: CompositeStateReturn
) => React.ReactNode;
renderError?: (options: {
error: Error;
retry: () => void;
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
@observer
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
class PaginatedList extends React.Component<Props> {
isInitiallyLoaded = this.props.items.length > 0;
@observable
error?: Error;
isLoaded = false;
@observable
isFetchingMore = false;
@@ -53,9 +41,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
@observable
isFetching = false;
@observable
fetchCounter = 0;
@observable
renderCount: number = DEFAULT_PAGINATION_LIMIT;
@@ -69,7 +54,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.fetchResults();
}
componentDidUpdate(prevProps: Props<T>) {
componentDidUpdate(prevProps: Props) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
@@ -85,41 +70,31 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.renderCount = DEFAULT_PAGINATION_LIMIT;
this.isFetching = false;
this.isFetchingMore = false;
this.isLoaded = false;
};
@action
fetchResults = async () => {
if (!this.props.fetch) {
return;
}
this.isFetching = true;
const counter = ++this.fetchCounter;
const limit = DEFAULT_PAGINATION_LIMIT;
this.error = undefined;
const results = await this.props.fetch({
limit,
offset: this.offset,
...this.props.options,
});
try {
const results = await this.props.fetch({
limit,
offset: this.offset,
...this.props.options,
});
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
} catch (err) {
this.error = err;
} finally {
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
if (results && (results.length === 0 || results.length < limit)) {
this.allowLoadMore = false;
} else {
this.offset += limit;
}
this.renderCount += limit;
this.isLoaded = true;
this.isFetching = false;
this.isFetchingMore = false;
};
@action
@@ -130,9 +105,9 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
}
// If there are already cached results that we haven't yet rendered because
// of lazy rendering then show another page.
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
const leftToRender = this.props.items.length - this.renderCount;
if (leftToRender > 0) {
if (leftToRender > 1) {
this.renderCount += DEFAULT_PAGINATION_LIMIT;
}
@@ -145,86 +120,70 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
};
render() {
const {
items = [],
heading,
auth,
empty = null,
renderHeading,
renderError,
onEscape,
} = this.props;
const { items, heading, auth, empty, renderHeading } = this.props;
let previousHeading = "";
const showLoading =
this.isFetching &&
!this.isFetchingMore &&
(!items?.length || this.fetchCounter === 0);
this.isFetching && !this.isFetchingMore && !this.isInitiallyLoaded;
const showEmpty = !items.length && !showLoading;
const showList =
(this.isLoaded || this.isInitiallyLoaded) && !showLoading && !showEmpty;
return (
<>
{showEmpty && empty}
{showList && (
<>
{heading}
<ArrowKeyNavigation aria-label={this.props["aria-label"]}>
{(composite: CompositeStateReturn) =>
items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(
item,
index,
composite
);
if (showLoading) {
return (
this.props.loading || (
// 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;
})
}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
)}
{showLoading && (
<DelayedMount>
<PlaceholderList count={5} />
</DelayedMount>
)
);
}
if (items?.length === 0) {
if (this.error && renderError) {
return renderError({ error: this.error, retry: this.fetchResults });
}
return empty;
}
return (
<>
{heading}
<ArrowKeyNavigation
aria-label={this.props["aria-label"]}
onEscape={onEscape}
>
{(composite: CompositeStateReturn) => {
let previousHeading = "";
return items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index, composite);
// 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;
});
}}
</ArrowKeyNavigation>
{this.allowLoadMore && (
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
)}
</>
);
+1 -1
View File
@@ -14,7 +14,7 @@ type Props = {
collection: Collection | null | undefined;
onSuccess?: () => void;
style?: React.CSSProperties;
ref?: (element: React.ElementRef<"div"> | null | undefined) => void;
ref?: (arg0: React.ElementRef<"div"> | null | undefined) => void;
};
@observer
+1 -1
View File
@@ -4,7 +4,7 @@ import { randomInteger } from "@shared/random";
import Flex from "~/components/Flex";
import { pulsate } from "~/styles/animations";
export type Props = {
type Props = {
header?: boolean;
height?: number;
minWidth?: number;
+10 -19
View File
@@ -1,50 +1,41 @@
import * as React from "react";
import { Dialog } from "reakit/Dialog";
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
import { Popover as ReakitPopover } from "reakit/Popover";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths } from "@shared/styles";
import useMobile from "~/hooks/useMobile";
import { fadeAndScaleIn } from "~/styles/animations";
type Props = PopoverProps & {
type Props = {
children: React.ReactNode;
width?: number;
shrink?: boolean;
tabIndex?: number;
width?: number;
};
const Popover: React.FC<Props> = ({
children,
shrink,
width = 380,
...rest
}) => {
function Popover({ children, width = 380, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return (
<Dialog {...rest} modal>
<Contents $shrink={shrink}>{children}</Contents>
<Contents>{children}</Contents>
</Dialog>
);
}
return (
<ReakitPopover {...rest}>
<Contents $shrink={shrink} $width={width}>
{children}
</Contents>
<Contents $width={width}>{children}</Contents>
</ReakitPopover>
);
};
}
const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
const Contents = styled.div<{ $width?: number }>`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${(props) => props.theme.menuBackground};
border-radius: 6px;
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
padding: 12px 24px;
max-height: 50vh;
overflow-y: scroll;
box-shadow: ${(props) => props.theme.menuShadow};
@@ -52,7 +43,7 @@ const Contents = styled.div<{ $shrink?: boolean; $width?: number }>`
${breakpoint("mobile", "tablet")`
position: fixed;
z-index: ${depths.menu};
z-index: ${(props: any) => props.theme.depths.menu};
// 50 is a magic number that positions us nicely under the top bar
top: 50px;
+4 -3
View File
@@ -8,12 +8,13 @@ type Props = {
icon?: React.ReactNode;
title?: React.ReactNode;
textTitle?: string;
children: React.ReactNode;
breadcrumb?: React.ReactNode;
actions?: React.ReactNode;
centered?: boolean;
};
const Scene: React.FC<Props> = ({
function Scene({
title,
icon,
textTitle,
@@ -21,7 +22,7 @@ const Scene: React.FC<Props> = ({
breadcrumb,
children,
centered,
}) => {
}: Props) {
return (
<FillWidth>
<PageTitle title={textTitle || title} />
@@ -46,7 +47,7 @@ const Scene: React.FC<Props> = ({
)}
</FillWidth>
);
};
}
const FillWidth = styled.div`
width: 100%;
+2 -16
View File
@@ -3,17 +3,16 @@ import * as React from "react";
import styled from "styled-components";
import useWindowSize from "~/hooks/useWindowSize";
type Props = React.HTMLAttributes<HTMLDivElement> & {
type Props = {
shadow?: boolean;
topShadow?: boolean;
bottomShadow?: boolean;
hiddenScrollbars?: boolean;
flex?: boolean;
children: React.ReactNode;
};
function Scrollable(
{ shadow, topShadow, bottomShadow, hiddenScrollbars, flex, ...rest }: Props,
{ shadow, topShadow, bottomShadow, flex, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const fallbackRef = React.useRef<HTMLDivElement>();
@@ -56,7 +55,6 @@ function Scrollable(
ref={ref || fallbackRef}
onScroll={updateShadows}
$flex={flex}
$hiddenScrollbars={hiddenScrollbars}
$topShadowVisible={topShadowVisible}
$bottomShadowVisible={bottomShadowVisible}
{...rest}
@@ -68,7 +66,6 @@ const Wrapper = styled.div<{
$flex?: boolean;
$topShadowVisible?: boolean;
$bottomShadowVisible?: boolean;
$hiddenScrollbars?: boolean;
}>`
display: ${(props) => (props.$flex ? "flex" : "block")};
flex-direction: column;
@@ -93,17 +90,6 @@ const Wrapper = styled.div<{
return "none";
}};
transition: all 100ms ease-in-out;
${(props) =>
props.$hiddenScrollbars &&
`
-ms-overflow-style: none;
overflow: -moz-scrollbars-none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`}
`;
export default observer(React.forwardRef(Scrollable));
-148
View File
@@ -1,148 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Document from "~/models/Document";
import Highlight, { Mark } from "~/components/Highlight";
import { hover } from "~/styles";
type Props = {
document: Document;
highlight: string;
context: string | undefined;
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
shareId?: string;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(
props: Props,
ref: React.RefObject<HTMLAnchorElement>
) {
const { document, highlight, context, shareId, ...rest } = props;
return (
<CompositeItem
as={DocumentLink}
ref={ref}
dir={document.dir}
to={{
pathname: shareId ? `/share/${shareId}${document.url}` : document.url,
state: {
title: document.titleWithDefault,
},
}}
{...rest}
>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Heading>
{
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
}
</Content>
</CompositeItem>
);
}
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
`;
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
align-items: center;
padding: 6px 12px;
max-height: 50vh;
&:not(:last-child) {
margin-bottom: 4px;
}
&:focus-visible {
outline: none;
}
${breakpoint("tablet")`
width: auto;
`};
&:${hover},
&:active,
&:focus,
&:focus-within {
background: ${(props) => props.theme.listItemHoverBackground};
}
${(props) =>
props.$menuOpen &&
css`
background: ${(props) => props.theme.listItemHoverBackground};
`}
`;
const Heading = styled.h4<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 18px;
margin-top: 0;
margin-bottom: 0.25em;
overflow: hidden;
white-space: nowrap;
color: ${(props) => props.theme.text};
`;
const Title = styled(Highlight)`
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
${Mark} {
padding: 0;
}
`;
const ResultContext = styled(Highlight)`
display: block;
color: ${(props) => props.theme.textTertiary};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
${Mark} {
padding: 0;
}
`;
export default observer(React.forwardRef(DocumentListItem));
-227
View File
@@ -1,227 +0,0 @@
import { debounce } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { depths } from "@shared/styles";
import Empty from "~/components/Empty";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Placeholder from "~/components/List/Placeholder";
import PaginatedList, { PaginatedItem } from "~/components/PaginatedList";
import Popover from "~/components/Popover";
import { id as bodyContentId } from "~/components/SkipNavContent";
import useKeyDown from "~/hooks/useKeyDown";
import useStores from "~/hooks/useStores";
import { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
type Props = { shareId: string };
function SearchPopover({ shareId }: Props) {
const { t } = useTranslation();
const { documents } = useStores();
const focusRef = React.useRef<HTMLElement | null>(null);
const popover = usePopoverState({
placement: "bottom-start",
unstable_offset: [-24, 0],
modal: true,
});
const [query, setQuery] = React.useState("");
const searchResults = documents.searchResults(query);
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
PaginatedItem[] | undefined
>(searchResults);
React.useEffect(() => {
if (searchResults) {
setCachedQuery(query);
setCachedSearchResults(searchResults);
popover.show();
}
}, [searchResults, query, popover.show]);
const performSearch = React.useCallback(
async ({ query, ...options }) => {
if (query?.length > 0) {
return await documents.search(query, { shareId, ...options });
}
return undefined;
},
[documents, shareId]
);
const handleSearchInputChange = React.useMemo(
() =>
debounce(async (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
setQuery(value.trim());
// covers edge case: user manually dismisses popover then
// quickly edits input resulting in no change in query
// the useEffect that normally shows the popover will miss it
if (value === cachedQuery) {
popover.show();
}
if (!value.length) {
popover.hide();
}
}, 300),
[popover, cachedQuery]
);
const searchInputRef = popover.unstable_referenceRef as React.RefObject<
HTMLInputElement
>;
const firstSearchItem = React.useRef<HTMLAnchorElement>(null);
const handleEscapeList = React.useCallback(
() => searchInputRef?.current?.focus(),
[searchInputRef]
);
const handleSearchInputFocus = React.useCallback(() => {
focusRef.current = searchInputRef.current;
}, []);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
if (searchResults) {
popover.show();
}
}
if (ev.key === "ArrowDown" && !ev.shiftKey) {
if (ev.currentTarget.value.length) {
if (
ev.currentTarget.value.length === ev.currentTarget.selectionStart
) {
popover.show();
}
firstSearchItem.current?.focus();
}
}
if (ev.key === "ArrowUp") {
if (popover.visible) {
popover.hide();
if (!ev.shiftKey) {
ev.preventDefault();
}
}
if (ev.currentTarget.value) {
if (ev.currentTarget.selectionEnd === 0) {
ev.currentTarget.selectionStart = 0;
ev.currentTarget.selectionEnd = ev.currentTarget.value.length;
ev.preventDefault();
}
}
}
if (ev.key === "Escape") {
if (popover.visible) {
popover.hide();
ev.preventDefault();
}
}
},
[popover, searchResults]
);
const handleSearchItemClick = React.useCallback(() => {
popover.hide();
if (searchInputRef.current) {
searchInputRef.current.value = "";
focusRef.current = document.getElementById(bodyContentId);
}
}, [popover.hide]);
useKeyDown("/", (ev) => {
if (
searchInputRef.current &&
searchInputRef.current !== document.activeElement
) {
searchInputRef.current.focus();
ev.preventDefault();
}
});
return (
<>
<PopoverDisclosure {...popover}>
{(props) => {
// props assumes the disclosure is a button, but we want a type-ahead
// so we take the aria props, and ref and ignore the event handlers
return (
<StyledInputSearch
aria-controls={props["aria-controls"]}
aria-expanded={props["aria-expanded"]}
aria-haspopup={props["aria-haspopup"]}
ref={props.ref}
onChange={handleSearchInputChange}
onFocus={handleSearchInputFocus}
onKeyDown={handleKeyDown}
/>
);
}}
</PopoverDisclosure>
<Popover
{...popover}
aria-label={t("Results")}
unstable_autoFocusOnShow={false}
unstable_finalFocusRef={focusRef}
style={{ zIndex: depths.sidebar + 1 }}
shrink
>
<PaginatedList
options={{ query, snippetMinWords: 10, snippetMaxWords: 11 }}
items={cachedSearchResults}
fetch={performSearch}
onEscape={handleEscapeList}
empty={
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item: SearchResult, index, compositeProps) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
ref={index === 0 ? firstSearchItem : undefined}
document={item.document}
context={item.context}
highlight={cachedQuery}
onClick={handleSearchItemClick}
{...compositeProps}
/>
)}
/>
</Popover>
</>
);
}
const NoResults = styled(Empty)`
padding: 0 12px;
margin: 6px 0;
`;
const PlaceholderList = styled(Placeholder)`
padding: 6px 12px;
`;
const StyledInputSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
`;
export default observer(SearchPopover);
+7 -17
View File
@@ -10,7 +10,6 @@ import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import { inviteUser } from "~/actions/definitions/users";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
@@ -26,7 +25,7 @@ import ArchiveLink from "./components/ArchiveLink";
import Collections from "./components/Collections";
import Section from "./components/Section";
import SidebarAction from "./components/SidebarAction";
import SidebarButton, { SidebarButtonProps } from "./components/SidebarButton";
import SidebarButton from "./components/SidebarButton";
import SidebarLink from "./components/SidebarLink";
import Starred from "./components/Starred";
import TrashLink from "./components/TrashLink";
@@ -35,15 +34,12 @@ function AppSidebar() {
const { t } = useTranslation();
const { documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team);
const can = usePolicy(team.id);
React.useEffect(() => {
if (!user.isViewer) {
documents.fetchDrafts();
documents.fetchTemplates();
}
}, [documents, user.isViewer]);
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
@@ -59,17 +55,12 @@ function AppSidebar() {
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
<OrganizationMenu>
{(props: SidebarButtonProps) => (
{(props) => (
<SidebarButton
{...props}
title={team.name}
image={
<StyledTeamLogo
src={team.avatarUrl}
width={32}
height={32}
alt={t("Logo")}
/>
<StyledTeamLogo src={team.avatarUrl} width={32} height={32} />
}
showDisclosure
/>
@@ -141,7 +132,6 @@ function AppSidebar() {
const StyledTeamLogo = styled(TeamLogo)`
margin-right: 4px;
background: white;
`;
const Drafts = styled(Text)`

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