Compare commits

..

2 Commits

Author SHA1 Message Date
Tom Moor 8e9beac59f test 2023-08-08 23:12:41 -04:00
Tom Moor a0f7c76405 Add support for SSL in development 2023-08-08 22:46:31 -04:00
1253 changed files with 30733 additions and 54650 deletions
+15 -30
View File
@@ -1,28 +1,29 @@
{
"presets": [
"@babel/preset-react",
"@babel/preset-env",
"@babel/preset-typescript"
"@babel/preset-typescript",
[
"@babel/preset-env",
{
"corejs": {
"version": "3",
"proposals": true
},
"useBuiltIns": "usage"
}
]
],
"plugins": [
"babel-plugin-transform-typescript-metadata",
"styled-components",
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
"@babel/plugin-proposal-class-properties",
[
"transform-inline-environment-variables",
{
"include": [
"SOURCE_COMMIT",
"SOURCE_VERSION"
]
}
],
"tsconfig-paths-module-resolver"
"@babel/plugin-transform-destructuring",
"@babel/plugin-transform-regenerator",
"transform-class-properties"
],
"env": {
"production": {
@@ -35,29 +36,13 @@
]
],
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"development": {
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"test": {
"presets": [
[
"@babel/preset-env",
{
"corejs": {
"version": "3",
"proposals": true
},
"useBuiltIns": "usage"
}
]
]
}
}
}
+20 -22
View File
@@ -3,7 +3,7 @@ version: 2.1
defaults: &defaults
working_directory: ~/outline
docker:
- image: cimg/node:20.10
- image: cimg/node:18.12
- image: cimg/redis:5.0
- image: cimg/postgres:14.2
environment:
@@ -13,8 +13,13 @@ defaults: &defaults
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
NODE_OPTIONS: --max-old-space-size=8000
executors:
@@ -31,12 +36,12 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: install-deps
command: yarn install --frozen-lockfile
- save_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
paths:
- ./node_modules
lint:
@@ -44,7 +49,7 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: lint
command: yarn lint
@@ -53,7 +58,7 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: typescript
command: yarn tsc
@@ -62,7 +67,7 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:app
@@ -71,25 +76,22 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: test
command: yarn test:shared
test-server:
<<: *defaults
parallelism: 3
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: migrate
command: ./node_modules/.bin/sequelize db:migrate
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
- run:
name: test
command: |
TESTFILES=$(circleci tests glob "server/**/*.test.ts" | circleci tests split)
yarn test --maxWorkers=2 $TESTFILES
command: yarn test:server --forceExit
bundle-size:
<<: *defaults
environment:
@@ -97,7 +99,7 @@ jobs:
steps:
- checkout
- restore_cache:
key: dependency-cache-v1-{{ checksum "package.json" }}
key: dependency-cache-{{ checksum "package.json" }}
- run:
name: build-vite
command: yarn vite:build
@@ -140,12 +142,7 @@ jobs:
command: docker push $BASE_IMAGE_NAME:latest
- run:
name: Build and push Docker image
command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else
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 .
fi
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 .
workflows:
version: 2
@@ -169,8 +166,9 @@ workflows:
- build
- bundle-size:
requires:
- build
- types
- test-app
- test-shared
- test-server
build-docker:
jobs:
+1
View File
@@ -13,4 +13,5 @@ app.json
crowdin.yml
build
docker-compose.yml
fakes3
node_modules
-10
View File
@@ -1,10 +0,0 @@
URL=https://local.outline.dev:3000
SMTP_FROM_EMAIL=hello@example.com
# Enable unsafe-inline in script-src CSP directive
# Setting it to true allows React dev tools add-on in Firefox to successfully detect the project
DEVELOPMENT_UNSAFE_INLINE_CSP=true
# Increase the log level to debug for development
LOG_LEVEL=debug
+20 -35
View File
@@ -13,6 +13,7 @@ UTILS_SECRET=generate_a_new_key
# For production point these at your databases, in development the default
# 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
@@ -29,44 +30,32 @@ REDIS_URL=redis://localhost:6379
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
URL=
URL=https://app.outline.dev:3000
PORT=3000
# See [documentation](docs/SERVICES.md) on running a separate collaboration
# server, for normal operation this does not need to be set.
COLLABORATION_URL=
# Specify what storage system to use. Possible value is one of "s3" or "local".
# For "local", the avatar images and document attachments will be saved on local disk.
FILE_STORAGE=local
# To support uploading of images for avatars and document attachments an
# s3-compatible storage must be provided. AWS S3 is recommended for redundancy
# however if you want to keep all file storage local an alternative such as
# minio (https://github.com/minio/minio) can be used.
# If "local" is configured for FILE_STORAGE above, then this sets the parent directory under
# which all attachments/images go. Make sure that the process has permissions to create
# this path and also to write files to it.
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
# Maximum allowed size for the uploaded attachment.
FILE_STORAGE_UPLOAD_MAX_SIZE=262144000
# Override the maximum size of document imports, generally this should be lower
# than the document attachment maximum size.
FILE_STORAGE_IMPORT_MAX_SIZE=
# Override the maximum size of workspace imports, these can be especially large
# and the files are temporary being automatically deleted after a period of time.
FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE=
# To support uploading of images for avatars and document attachments in a distributed
# architecture an s3-compatible storage can be configured if FILE_STORAGE=s3 above.
# A more detailed guide on setting up S3 is available here:
# => https://wiki.generaloutline.com/share/125de1cc-9ff6-424b-8415-0d58c809a40f
#
AWS_ACCESS_KEY_ID=get_a_key_from_aws
AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key
AWS_REGION=xx-xxxx-x
AWS_S3_ACCELERATE_URL=
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
AWS_S3_UPLOAD_MAX_SIZE=26214400
AWS_S3_FORCE_PATH_STYLE=true
AWS_S3_ACL=private
# –––––––––––––– AUTHENTICATION ––––––––––––––
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
@@ -104,7 +93,6 @@ OIDC_CLIENT_SECRET=
OIDC_AUTH_URI=
OIDC_TOKEN_URI=
OIDC_USERINFO_URI=
OIDC_LOGOUT_URI=
# Specify which claims to derive user information from
# Supports any valid JSON path with the JWT payload
@@ -116,16 +104,6 @@ OIDC_DISPLAY_NAME=OpenID Connect
# Space separated auth scopes.
OIDC_SCOPES=openid profile email
# To configure the GitHub integration, you'll need to create a GitHub App at
# => https://github.com/settings/apps
#
# When configuring the Client ID, add a redirect URL under "Permissions & events":
# https://<URL>/api/github.callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# –––––––––––––––– OPTIONAL ––––––––––––––––
@@ -153,6 +131,10 @@ ENABLE_UPDATES=true
# available memory by 512 for a rough estimate
WEB_CONCURRENCY=1
# Override the maximum size of document imports, could be required if you have
# especially large Word documents with embedded imagery
MAXIMUM_IMPORT_SIZE=5120000
# You can remove this line if your reverse proxy already logs incoming http
# requests and this ends up being duplicative
DEBUG=http
@@ -169,6 +151,9 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Optionally enable google analytics to track pageviews in the knowledge base
GOOGLE_ANALYTICS_ID=
# Optionally enable Sentry (sentry.io) to track errors and performance,
# and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI:
# https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option)
@@ -181,8 +166,8 @@ SMTP_HOST=
SMTP_PORT=
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_FROM_EMAIL=hello@example.com
SMTP_REPLY_EMAIL=hello@example.com
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
-31
View File
@@ -1,31 +0,0 @@
NODE_ENV=test
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
SMTP_HOST=smtp.example.com
SMTP_USERNAME=test
SMTP_FROM_EMAIL=hello@example.com
SMTP_REPLY_EMAIL=hello@example.com
GOOGLE_CLIENT_ID=123
GOOGLE_CLIENT_SECRET=123
SLACK_CLIENT_ID=123
SLACK_CLIENT_SECRET=123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
OIDC_CLIENT_ID=client-id
OIDC_CLIENT_SECRET=client-secret
OIDC_AUTH_URI=http://localhost/authorize
OIDC_TOKEN_URI=http://localhost/token
OIDC_USERINFO_URI=http://localhost/userinfo
IFRAMELY_API_KEY=123
RATE_LIMITER_ENABLED=false
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/tmp
+1 -11
View File
@@ -21,7 +21,7 @@
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-lodash"
"import"
],
"rules": {
"eqeqeq": 2,
@@ -32,20 +32,11 @@
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"no-shadow": "off",
"es/no-regexp-lookbehind-assertions": "error",
"react/self-closing-comp": ["error", {
"component": true,
"html": true
}],
"@typescript-eslint/no-shadow": [
"warn",
{
"hoist": "all",
"ignoreTypeValueShadow": true
}
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": [
@@ -64,7 +55,6 @@
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["warn", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+1 -3
View File
@@ -2,14 +2,12 @@ dist
build
node_modules/*
.env
.env.local
.env.production
.log
.vscode/*
npm-debug.log
stats.json
.DS_Store
data/*
fakes3/*
.idea
*.pem
*.key
+6 -5
View File
@@ -1,6 +1,5 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"projects": [
{
"displayName": "server",
@@ -9,11 +8,13 @@
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"<rootDir>/server/test/env.ts"
],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalSetup": "<rootDir>/server/test/globalSetup.js",
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
"testEnvironment": "node",
"runner": "@getoutline/jest-runner-serial"
},
{
"displayName": "app",
+2 -3
View File
@@ -1,6 +1,4 @@
require("dotenv").config({
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
});
require('dotenv').config({ silent: true });
var path = require('path');
@@ -8,4 +6,5 @@ module.exports = {
'config': path.resolve('server/config', 'database.json'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
'seeders-path': path.resolve('server/models', 'fixtures'),
}
+2 -11
View File
@@ -5,7 +5,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:20-alpine AS runner
FROM node:18-alpine AS runner
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
@@ -24,16 +24,7 @@ COPY --from=base $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR /var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
VOLUME /var/lib/outline/data
chown -R nodejs:nodejs $APP_PATH/build
USER nodejs
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:20-alpine AS deps
FROM node:18-alpine AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.71.0
Licensed Work: Outline 0.64.0
The Licensed Work is (c) 2020 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2027-08-18
Change Date: 2026-05-23
Change License: Apache License, Version 2.0
+12 -12
View File
@@ -1,28 +1,28 @@
up:
docker compose up -d redis postgres
docker-compose up -d redis postgres s3
yarn install-local-ssl
yarn install --pure-lockfile
yarn dev:watch
build:
docker compose build --pull outline
docker-compose build --pull outline
test:
docker compose up -d redis postgres
NODE_ENV=test yarn sequelize db:drop
NODE_ENV=test yarn sequelize db:create
NODE_ENV=test yarn sequelize db:migrate
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test
watch:
docker compose up -d redis postgres
NODE_ENV=test yarn sequelize db:drop
NODE_ENV=test yarn sequelize db:create
NODE_ENV=test yarn sequelize db:migrate
docker-compose up -d redis postgres s3
yarn sequelize db:drop --env=test
yarn sequelize db:create --env=test
yarn sequelize db:migrate --env=test
yarn test:watch
destroy:
docker compose stop
docker compose rm -f
docker-compose stop
docker-compose rm -f
.PHONY: up build destroy test watch # let's go to reserve rules names
+2 -6
View File
@@ -32,7 +32,7 @@ There is a short guide for [setting up a development environment](https://docs.g
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher liklihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
@@ -96,10 +96,6 @@ Or to run migrations on test database:
yarn sequelize db:migrate --env test
```
# Activity
![Alt](https://repobeats.axiom.co/api/embed/ff2e4e6918afff1acf9deb72d1ba6b071d586178.svg "Repobeats analytics image")
# License
## License
Outline is [BSL 1.1 licensed](LICENSE).
+5 -13
View File
@@ -81,14 +81,6 @@
"description": "",
"required": false
},
"OIDC_DISABLE_REDIRECT": {
"description": "Prevent the app from automatically redirecting to the OIDC login page",
"required": false
},
"OIDC_LOGOUT_URI": {
"description": "",
"required": false
},
"OIDC_USERNAME_CLAIM": {
"description": "Specify which claims to derive user information from. Supports any valid JSON path with the JWT payload",
"value": "preferred_username",
@@ -136,6 +128,11 @@
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
"required": false
},
"AWS_S3_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"AWS_S3_FORCE_PATH_STYLE": {
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
"value": "true",
@@ -151,11 +148,6 @@
"description": "S3 canned ACL for document attachments",
"required": false
},
"FILE_STORAGE_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"SMTP_HOST": {
"description": "smtp.example.com (optional)",
"required": false
+2 -2
View File
@@ -2,10 +2,10 @@
"extends": [
"../.eslintrc",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
"plugin:react-hooks/recommended",
],
"plugins": [
"eslint-plugin-react-hooks"
"eslint-plugin-react-hooks",
],
"env": {
"jest": true,
+6 -23
View File
@@ -3,7 +3,6 @@ import {
EditIcon,
PadlockIcon,
PlusIcon,
SearchIcon,
StarredIcon,
TrashIcon,
UnstarredIcon,
@@ -11,17 +10,14 @@ import {
import * as React from "react";
import stores from "~/stores";
import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionNew from "~/scenes/CollectionNew";
import CollectionPermissions from "~/scenes/CollectionPermissions";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions";
import { CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import { searchPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
@@ -38,11 +34,11 @@ export const openCollection = createAction({
return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
id: collection.url,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.path),
perform: () => history.push(collection.url),
}));
},
});
@@ -107,23 +103,11 @@ export const editCollectionPermissions = createAction({
stores.dialogs.openModal({
title: t("Collection permissions"),
fullscreen: true,
content: <CollectionPermissions collectionId={activeCollectionId} />,
});
},
});
export const searchInCollection = createAction({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: CollectionSection,
icon: <SearchIcon />,
visible: ({ activeCollectionId }) => !!activeCollectionId,
perform: ({ activeCollectionId }) => {
history.push(searchPath(undefined, { collectionId: activeCollectionId }));
},
});
export const starCollection = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
@@ -147,7 +131,6 @@ export const starCollection = createAction({
const collection = stores.collections.get(activeCollectionId);
await collection?.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
@@ -178,10 +161,9 @@ export const unstarCollection = createAction({
});
export const deleteCollection = createAction({
name: ({ t }) => `${t("Delete")}`,
name: ({ t }) => t("Delete"),
analyticsName: "Delete collection",
section: CollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
@@ -200,6 +182,7 @@ export const deleteCollection = createAction({
}
stores.dialogs.openModal({
isCentered: true,
title: t("Delete collection"),
content: (
<CollectionDeleteDialog
+13 -94
View File
@@ -1,113 +1,38 @@
import copy from "copy-to-clipboard";
import { CopyIcon, ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
import { homePath } from "~/utils/routeHelpers";
export const copyId = createAction({
name: ({ t }) => t("Copy ID"),
icon: <CopyIcon />,
keywords: "uuid",
section: DeveloperSection,
children: ({
currentTeamId,
currentUserId,
activeCollectionId,
activeDocumentId,
}) => {
function copyAndToast(text: string | null | undefined) {
if (text) {
copy(text);
toast.success("Copied to clipboard");
}
}
return [
createAction({
name: "Copy User ID",
section: DeveloperSection,
icon: <CopyIcon />,
visible: () => !!currentUserId,
perform: () => copyAndToast(currentUserId),
}),
createAction({
name: "Copy Team ID",
section: DeveloperSection,
icon: <CopyIcon />,
visible: () => !!currentTeamId,
perform: () => copyAndToast(currentTeamId),
}),
createAction({
name: "Copy Collection ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!activeCollectionId,
perform: () => copyAndToast(activeCollectionId),
}),
createAction({
name: "Copy Document ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!activeDocumentId,
perform: () => copyAndToast(activeDocumentId),
}),
createAction({
name: "Copy Team ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!currentTeamId,
perform: () => copyAndToast(currentTeamId),
}),
createAction({
name: "Copy Release ID",
icon: <CopyIcon />,
section: DeveloperSection,
visible: () => !!env.VERSION,
perform: () => copyAndToast(env.VERSION),
}),
];
},
});
export const clearIndexedDB = createAction({
name: ({ t }) => t("Clear IndexedDB cache"),
name: ({ t }) => t("Delete IndexedDB cache"),
icon: <TrashIcon />,
keywords: "cache clear database",
section: DeveloperSection,
perform: async ({ t }) => {
history.push(homePath());
await deleteAllDatabases();
toast.success(t("IndexedDB cache cleared"));
stores.toasts.showToast(t("IndexedDB cache deleted"));
},
});
export const createTestUsers = createAction({
name: "Create 10 test users",
name: "Create test users",
icon: <UserIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: async () => {
const count = 10;
await client.post("/developer.create_test_users", { count });
toast.message(`${count} test users created`);
},
});
export const createToast = createAction({
name: "Create toast",
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: async () => {
toast.message("Hello world", {
duration: 30000,
});
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" });
}
},
});
@@ -117,7 +42,7 @@ export const toggleDebugLogging = createAction({
section: DeveloperSection,
perform: async ({ t }) => {
Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled;
toast.message(
stores.toasts.showToast(
Logger.debugLoggingEnabled
? t("Debug logging enabled")
: t("Debug logging disabled")
@@ -131,13 +56,7 @@ export const developer = createAction({
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DeveloperSection,
children: [
copyId,
clearIndexedDB,
toggleDebugLogging,
createToast,
createTestUsers,
],
children: [clearIndexedDB, toggleDebugLogging, createTestUsers],
});
export const rootDeveloperActions = [developer];
+86 -286
View File
@@ -1,4 +1,3 @@
import copy from "copy-to-clipboard";
import invariant from "invariant";
import {
DownloadIcon,
@@ -20,32 +19,22 @@ import {
ArchiveIcon,
ShuffleIcon,
HistoryIcon,
GraphIcon,
LightBulbIcon,
UnpublishIcon,
PublishIcon,
CommentIcon,
GlobeIcon,
CopyIcon,
EyeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import { ExportContentType, TeamPreference } from "@shared/types";
import MarkdownHelper from "@shared/utils/MarkdownHelper";
import { getEventFiles } from "@shared/utils/files";
import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove";
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
import DocumentPublish from "~/scenes/DocumentPublish";
import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import DuplicateDialog from "~/components/DuplicateDialog";
import SharePopover from "~/components/Sharing";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import { createAction } from "~/actions";
import { DocumentSection, TrashSection } from "~/actions/sections";
import { DocumentSection } from "~/actions/sections";
import env from "~/env";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import {
documentInsightsPath,
@@ -53,9 +42,6 @@ import {
homePath,
newDocumentPath,
searchPath,
documentPath,
urlify,
trashPath,
} from "~/utils/routeHelpers";
export const openDocument = createAction({
@@ -92,66 +78,14 @@ export const createDocument = createAction({
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (
activeCollectionId &&
!stores.policies.abilities(activeCollectionId).createDocument
) {
return false;
}
return (
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
perform: ({ activeCollectionId, inStarredSection }) =>
history.push(newDocumentPath(activeCollectionId), {
starred: inStarredSection,
}),
});
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
!!stores.documents.get(activeDocumentId)?.template &&
stores.policies.abilities(currentTeamId).createDocument,
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
history.push(
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
{
starred: inStarredSection,
}
),
});
export const createNestedDocument = createAction({
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: DocumentSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, activeDocumentId, stores }) =>
!!currentTeamId &&
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) =>
history.push(
newDocumentPath(activeCollectionId, {
parentDocumentId: activeDocumentId,
}),
{
starred: inStarredSection,
}
),
});
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
@@ -174,7 +108,6 @@ export const starDocument = createAction({
const document = stores.documents.get(activeDocumentId);
await document?.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
@@ -215,7 +148,7 @@ export const publishDocument = createAction({
}
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isDraft && stores.policies.abilities(activeDocumentId).publish
!!document?.isDraft && stores.policies.abilities(activeDocumentId).update
);
},
perform: async ({ activeDocumentId, stores, t }) => {
@@ -232,14 +165,13 @@ export const publishDocument = createAction({
await document.save(undefined, {
publish: true,
});
toast.success(
t("Published {{ documentName }}", {
documentName: document.noun,
})
);
stores.toasts.showToast(t("Document published"), {
type: "success",
});
} else if (document) {
stores.dialogs.openModal({
title: t("Publish document"),
isCentered: true,
content: <DocumentPublish document={document} />,
});
}
@@ -263,17 +195,12 @@ export const unpublishDocument = createAction({
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.unpublish();
await document?.unpublish();
toast.success(
t("Unpublished {{ documentName }}", {
documentName: document.noun,
})
);
stores.toasts.showToast(t("Document unpublished"), {
type: "success",
});
},
});
@@ -300,8 +227,12 @@ export const subscribeDocument = createAction({
}
const document = stores.documents.get(activeDocumentId);
await document?.subscribe();
toast.success(t("Subscribed to document notifications"));
stores.toasts.showToast(t("Subscribed to document notifications"), {
type: "success",
});
},
});
@@ -331,38 +262,8 @@ export const unsubscribeDocument = createAction({
await document?.unsubscribe(currentUserId);
toast.success(t("Unsubscribed from document notifications"));
},
});
export const shareDocument = createAction({
name: ({ t }) => t("Share"),
analyticsName: "Share document",
section: DocumentSection,
icon: <GlobeIcon />,
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
if (!activeDocumentId || !currentUserId) {
return;
}
const document = stores.documents.get(activeDocumentId);
const share = stores.shares.getByDocumentId(activeDocumentId);
const sharedParent = stores.shares.getByDocumentParents(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Share this document"),
content: (
<SharePopover
document={document}
share={share}
sharedParent={sharedParent}
onRequestClose={stores.dialogs.closeAllModals}
visible
/>
),
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
type: "success",
});
},
});
@@ -402,11 +303,15 @@ export const downloadDocumentAsPDF = createAction({
return;
}
const id = toast.loading(`${t("Exporting")}`);
const id = stores.toasts.showToast(`${t("Exporting")}`, {
type: "loading",
timeout: 30 * 1000,
});
const document = stores.documents.get(activeDocumentId);
return document
document
?.download(ExportContentType.Pdf)
.finally(() => id && toast.dismiss(id));
.finally(() => id && stores.toasts.hideToast(id));
},
});
@@ -443,48 +348,6 @@ export const downloadDocument = createAction({
],
});
export const copyDocumentAsMarkdown = createAction({
name: ({ t }) => t("Copy as Markdown"),
section: DocumentSection,
keywords: "clipboard",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(MarkdownHelper.toMarkdown(document));
toast.success(t("Markdown copied to clipboard"));
}
},
});
export const copyDocumentLink = createAction({
name: ({ t }) => t("Copy link"),
section: DocumentSection,
keywords: "clipboard",
visible: ({ activeDocumentId }) => !!activeDocumentId,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(urlify(documentPath(document)));
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyDocument = createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy document",
section: DocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyDocumentLink, copyDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
@@ -493,7 +356,7 @@ export const duplicateDocument = createAction({
icon: <DuplicateIcon />,
keywords: "copy",
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).duplicate,
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) {
return;
@@ -501,18 +364,11 @@ export const duplicateDocument = createAction({
const document = stores.documents.get(activeDocumentId);
invariant(document, "Document must exist");
stores.dialogs.openModal({
title: t("Copy document"),
content: (
<DuplicateDialog
document={document}
onSubmit={(response) => {
stores.dialogs.closeAllModals();
history.push(documentPath(response[0]));
}}
/>
),
const duped = await document.duplicate();
// when duplicating, go straight to the duplicated document content
history.push(duped.url);
stores.toasts.showToast(t("Document duplicated"), {
type: "success",
});
},
});
@@ -551,13 +407,19 @@ export const pinDocumentToCollection = createAction({
return;
}
const document = stores.documents.get(activeDocumentId);
await document?.pin(document.collectionId);
try {
const document = stores.documents.get(activeDocumentId);
await document?.pin(document.collectionId);
const collection = stores.collections.get(activeCollectionId);
const collection = stores.collections.get(activeCollectionId);
if (!collection || !location.pathname.startsWith(collection?.url)) {
toast.success(t("Pinned to collection"));
if (!collection || !location.pathname.startsWith(collection?.url)) {
stores.toasts.showToast(t("Pinned to collection"));
}
} catch (err) {
stores.toasts.showToast(err.message, {
type: "error",
});
}
},
});
@@ -590,10 +452,16 @@ export const pinDocumentToHome = createAction({
}
const document = stores.documents.get(activeDocumentId);
await document?.pin();
try {
await document?.pin();
if (location.pathname !== homePath()) {
toast.success(t("Pinned to home"));
if (location.pathname !== homePath()) {
stores.toasts.showToast(t("Pinned to team home"));
}
} catch (err) {
stores.toasts.showToast(err.message, {
type: "error",
});
}
},
});
@@ -606,23 +474,6 @@ export const pinDocument = createAction({
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: DocumentSection,
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
return false;
}
const document = stores.documents.get(activeDocumentId);
return !!document?.isActive;
},
perform: ({ activeDocumentId }) => {
history.push(searchPath(undefined, { documentId: activeDocumentId }));
},
});
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
@@ -630,7 +481,7 @@ export const printDocument = createAction({
section: DocumentSection,
icon: <PrintIcon />,
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
perform: () => {
perform: async () => {
queueMicrotask(window.print);
},
});
@@ -653,7 +504,7 @@ export const importDocument = createAction({
return false;
},
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
const { documents } = stores;
const { documents, toasts } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypes.join(", ");
@@ -661,16 +512,23 @@ export const importDocument = createAction({
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const document = await documents.import(
file,
activeDocumentId,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
try {
const file = files[0];
const document = await documents.import(
file,
activeDocumentId,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toasts.showToast(err.message, {
type: "error",
});
throw err;
}
};
input.click();
@@ -692,7 +550,7 @@ export const createTemplate = createAction({
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate &&
!!document?.isActive
!document?.isDeleted
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
@@ -704,6 +562,7 @@ export const createTemplate = createAction({
stores.dialogs.openModal({
title: t("Create template"),
isCentered: true,
content: <DocumentTemplatizeDialog documentId={activeDocumentId} />,
});
},
@@ -762,6 +621,7 @@ export const moveDocument = createAction({
title: t("Move {{ documentType }}", {
documentType: document.noun,
}),
isCentered: true,
content: <DocumentMove document={document} />,
});
}
@@ -787,13 +647,15 @@ export const archiveDocument = createAction({
}
await document.archive();
toast.success(t("Document archived"));
stores.toasts.showToast(t("Document archived"), {
type: "success",
});
}
},
});
export const deleteDocument = createAction({
name: ({ t }) => `${t("Delete")}`,
name: ({ t }) => t("Delete"),
analyticsName: "Delete document",
section: DocumentSection,
icon: <TrashIcon />,
@@ -815,6 +677,7 @@ export const deleteDocument = createAction({
title: t("Delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentDelete
document={document}
@@ -849,6 +712,7 @@ export const permanentlyDeleteDocument = createAction({
title: t("Permanently delete {{ documentName }}", {
documentName: document.noun,
}),
isCentered: true,
content: (
<DocumentPermanentDelete
document={document}
@@ -860,27 +724,6 @@ export const permanentlyDeleteDocument = createAction({
},
});
export const permanentlyDeleteDocumentsInTrash = createAction({
name: ({ t }) => t("Empty trash"),
analyticsName: "Empty trash",
section: TrashSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ stores }) =>
stores.documents.deleted.length > 0 && !!stores.auth.user?.isAdmin,
perform: ({ stores, t, location }) => {
stores.dialogs.openModal({
title: t("Permanently delete documents in trash"),
content: (
<DeleteDocumentsInTrash
onSubmit={stores.dialogs.closeAllModals}
shouldRedirect={location.pathname === trashPath()}
/>
),
});
},
});
export const openDocumentComments = createAction({
name: ({ t }) => t("Comments"),
analyticsName: "Open comments",
@@ -890,7 +733,8 @@ export const openDocumentComments = createAction({
const can = stores.policies.abilities(activeDocumentId ?? "");
return (
!!activeDocumentId &&
can.comment &&
can.read &&
!can.restore &&
!!stores.auth.team?.getPreference(TeamPreference.Commenting)
);
},
@@ -910,7 +754,7 @@ export const openDocumentHistory = createAction({
icon: <HistoryIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return !!activeDocumentId && can.listRevisions;
return !!activeDocumentId && can.read && !can.restore;
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -928,19 +772,10 @@ export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: DocumentSection,
icon: <GraphIcon />,
icon: <LightBulbIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return (
!!activeDocumentId &&
can.listViews &&
!document?.isTemplate &&
!document?.isDeleted
);
return !!activeDocumentId && can.read;
},
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
@@ -954,37 +789,6 @@ export const openDocumentInsights = createAction({
},
});
export const toggleViewerInsights = createAction({
name: ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
return document?.insightsEnabled
? t("Disable viewer insights")
: t("Enable viewer insights");
},
analyticsName: "Toggle viewer insights",
section: DocumentSection,
icon: <EyeIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return can.updateInsights;
},
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
await document.save({
insightsEnabled: !document.insightsEnabled,
});
},
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
@@ -993,8 +797,6 @@ export const rootDocumentActions = [
deleteDocument,
importDocument,
downloadDocument,
copyDocumentLink,
copyDocumentAsMarkdown,
starDocument,
unstarDocument,
publishDocument,
@@ -1005,12 +807,10 @@ export const rootDocumentActions = [
moveDocument,
openRandomDocument,
permanentlyDeleteDocument,
permanentlyDeleteDocumentsInTrash,
printDocument,
pinDocumentToCollection,
pinDocumentToHome,
openDocumentComments,
openDocumentHistory,
openDocumentInsights,
shareDocument,
];
+28 -36
View File
@@ -6,29 +6,34 @@ import {
EditIcon,
OpenIcon,
SettingsIcon,
ShapesIcon,
KeyboardIcon,
EmailIcon,
LogoutIcon,
ProfileIcon,
BrowserIcon,
ShapesIcon,
} from "outline-icons";
import * as React from "react";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isMac } from "@shared/utils/browser";
import {
developersUrl,
changelogUrl,
feedbackUrl,
githubIssuesUrl,
} from "@shared/utils/urlHelpers";
import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { createAction } from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import env from "~/env";
import Desktop from "~/utils/Desktop";
import { isMac } from "~/utils/browser";
import history from "~/utils/history";
import isCloudHosted from "~/utils/isCloudHosted";
import {
homePath,
searchPath,
draftsPath,
templatesPath,
archivePath,
trashPath,
settingsPath,
@@ -62,6 +67,15 @@ export const navigateToDrafts = createAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToTemplates = createAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to templates",
section: NavigationSection,
icon: <ShapesIcon />,
perform: () => history.push(templatesPath()),
visible: ({ location }) => location.pathname !== templatesPath(),
});
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
@@ -87,8 +101,9 @@ export const navigateToSettings = createAction({
section: NavigationSection,
shortcut: ["g", "s"],
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(settingsPath()),
visible: ({ stores }) =>
stores.policies.abilities(stores.auth.team?.id || "").update,
perform: () => history.push(settingsPath("details")),
});
export const navigateToProfileSettings = createAction({
@@ -100,15 +115,6 @@ export const navigateToProfileSettings = createAction({
perform: () => history.push(settingsPath()),
});
export const navigateToTemplateSettings = createAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to template settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ShapesIcon />,
perform: () => history.push(settingsPath("templates")),
});
export const navigateToNotificationSettings = createAction({
name: ({ t }) => t("Notifications"),
analyticsName: "Navigate to notification settings",
@@ -127,22 +133,13 @@ export const navigateToAccountPreferences = createAction({
perform: () => history.push(settingsPath("preferences")),
});
export const openDocumentation = createAction({
name: ({ t }) => t("Documentation"),
analyticsName: "Open documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(UrlHelper.guide),
});
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(UrlHelper.developers),
perform: () => window.open(developersUrl()),
});
export const toggleSidebar = createAction({
@@ -150,7 +147,7 @@ export const toggleSidebar = createAction({
analyticsName: "Toggle sidebar",
keywords: "hide show navigation",
section: NavigationSection,
perform: () => stores.ui.toggleCollapsedSidebar(),
perform: ({ stores }) => stores.ui.toggleCollapsedSidebar(),
});
export const openFeedbackUrl = createAction({
@@ -159,14 +156,14 @@ export const openFeedbackUrl = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
perform: () => window.open(UrlHelper.contact),
perform: () => window.open(feedbackUrl()),
});
export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
perform: () => window.open(UrlHelper.github),
perform: () => window.open(githubIssuesUrl()),
});
export const openChangelog = createAction({
@@ -175,7 +172,7 @@ export const openChangelog = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
perform: () => window.open(UrlHelper.changelog),
perform: () => window.open(changelogUrl()),
});
export const openKeyboardShortcuts = createAction({
@@ -213,21 +210,16 @@ export const logout = createAction({
analyticsName: "Log out",
section: NavigationSection,
icon: <LogoutIcon />,
perform: async () => {
await stores.auth.logout();
if (env.OIDC_LOGOUT_URI) {
window.location.replace(env.OIDC_LOGOUT_URI);
}
},
perform: () => stores.auth.logout(),
});
export const rootNavigationActions = [
navigateToHome,
navigateToDrafts,
navigateToTemplates,
navigateToArchive,
navigateToTrash,
downloadApp,
openDocumentation,
openAPIDocumentation,
openFeedbackUrl,
openBugReportUrl,
+2 -15
View File
@@ -1,4 +1,4 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import { MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { createAction } from "..";
import { NotificationSection } from "../sections";
@@ -13,17 +13,4 @@ export const markNotificationsAsRead = createAction({
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const markNotificationsAsArchived = createAction({
name: ({ t }) => t("Archive all notifications"),
analyticsName: "Mark notifications as archived",
section: NotificationSection,
icon: <ArchiveIcon />,
iconInContextMenu: false,
perform: ({ stores }) => stores.notifications.markAllAsArchived(),
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
});
export const rootNotificationActions = [
markNotificationsAsRead,
markNotificationsAsArchived,
];
export const rootNotificationActions = [markNotificationsAsRead];
+3 -2
View File
@@ -2,7 +2,6 @@ import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections";
@@ -69,7 +68,9 @@ export const copyLinkToRevision = createAction({
copy(url, {
format: "text/plain",
onCopy: () => {
toast.message(t("Link copied"));
stores.toasts.showToast(t("Link copied"), {
type: "info",
});
},
});
},
+2 -23
View File
@@ -1,14 +1,12 @@
import { ArrowIcon, PlusIcon } from "outline-icons";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { stringToColor } from "@shared/utils/color";
import RootStore from "~/stores/RootStore";
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
import { createAction } from "~/actions";
import { ActionContext } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const createTeamsList = ({ stores }: { stores: RootStore }) =>
@@ -62,33 +60,14 @@ export const createTeam = createAction({
user &&
stores.dialogs.openModal({
title: t("Create a workspace"),
fullscreen: true,
content: <TeamNew user={user} />,
});
},
});
export const desktopLoginTeam = createAction({
name: ({ t }) => t("Login to workspace"),
analyticsName: "Login to workspace",
keywords: "change switch workspace organization team",
section: TeamSection,
icon: <ArrowIcon />,
visible: () => Desktop.isElectron(),
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Login to workspace"),
content: <LoginDialog />,
});
},
});
const StyledTeamLogo = styled(TeamLogo)`
border-radius: 2px;
border: 0;
`;
export const rootTeamActions = [switchTeam, createTeam, desktopLoginTeam];
export const rootTeamActions = [switchTeam, createTeam];
+3 -43
View File
@@ -1,14 +1,8 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import stores from "~/stores";
import User from "~/models/User";
import Invite from "~/scenes/Invite";
import {
UserChangeRoleDialog,
UserDeleteDialog,
} from "~/components/UserDialogs";
import { UserDeleteDialog } from "~/components/UserDialogs";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
@@ -22,47 +16,12 @@ export const inviteUser = createAction({
stores.policies.abilities(stores.auth.team?.id || "").inviteUser,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite to workspace"),
title: t("Invite people"),
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
createAction({
name: ({ t }) =>
UserRoleHelper.isRoleHigher(role, user!.role)
? `${t("Promote to {{ role }}", {
role: UserRoleHelper.displayName(role, t),
})}…`
: `${t("Demote to {{ role }}", {
role: UserRoleHelper.displayName(role, t),
})}…`,
analyticsName: "Update user role",
section: UserSection,
visible: ({ stores }) => {
const can = stores.policies.abilities(user.id);
return UserRoleHelper.isRoleHigher(role, user.role)
? can.promote
: UserRoleHelper.isRoleLower(role, user.role)
? can.demote
: false;
},
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Update role"),
content: (
<UserChangeRoleDialog
user={user}
role={role}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const deleteUserActionFactory = (userId: string) =>
createAction({
name: ({ t }) => `${t("Delete user")}`,
@@ -79,6 +38,7 @@ export const deleteUserActionFactory = (userId: string) =>
stores.dialogs.openModal({
title: t("Delete user"),
isCentered: true,
content: (
<UserDeleteDialog
user={user}
+12 -21
View File
@@ -1,6 +1,5 @@
import flattenDeep from "lodash/flattenDeep";
import { flattenDeep } from "lodash";
import * as React from "react";
import { toast } from "sonner";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
@@ -74,7 +73,15 @@ export function actionToMenuItem(
icon,
visible,
dangerous: action.dangerous,
onClick: () => performAction(action, context),
onClick: () => {
try {
action.perform?.(context);
} catch (err) {
context.stores.toasts.showToast(err.message, {
type: "error",
});
}
},
selected: action.selected?.(context),
};
}
@@ -108,24 +115,8 @@ export function actionToKBar(
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
perform: action.perform
? () => performAction(action, context)
: undefined,
perform: action.perform ? () => action.perform?.(context) : undefined,
},
].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
children.map((child) => ({ ...child, parent: child.parent ?? action.id }))
);
}
export async function performAction(action: Action, context: ActionContext) {
const result = action.perform?.(context);
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
].concat(children.map((child) => ({ ...child, parent: action.id })));
}
-2
View File
@@ -20,5 +20,3 @@ export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recent searches");
export const TrashSection = ({ t }: ActionContext) => t("Trash");
+2 -7
View File
@@ -1,8 +1,6 @@
/* eslint-disable react/prop-types */
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import { Action, ActionContext } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
@@ -26,7 +24,6 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
@@ -63,12 +60,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const response = performAction(action, actionContext);
const response = action.perform?.(actionContext);
if (response?.finally) {
setExecuting(true);
void response.finally(
() => isMounted() && setExecuting(false)
);
response.finally(() => setExecuting(false));
}
}
: rest.onClick
+1 -1
View File
@@ -1,6 +1,6 @@
/* eslint-disable prefer-rest-params */
/* global ga */
import escape from "lodash/escape";
import { escape } from "lodash";
import * as React from "react";
import { IntegrationService } from "@shared/types";
import env from "~/env";
+41
View File
@@ -0,0 +1,41 @@
import * as React from "react";
type Props = {
size?: number;
fill?: string;
className?: string;
};
function SlackLogo({ size = 34, fill = "#FFF", className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 34 34"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<g stroke="none" strokeWidth="1" fillRule="evenodd">
<g transform="translate(0.000000, 17.822581)">
<path d="M7.23870968,3.61935484 C7.23870968,5.56612903 5.6483871,7.15645161 3.7016129,7.15645161 C1.75483871,7.15645161 0.164516129,5.56612903 0.164516129,3.61935484 C0.164516129,1.67258065 1.75483871,0.0822580645 3.7016129,0.0822580645 L7.23870968,0.0822580645 L7.23870968,3.61935484 Z" />
<path d="M9.02096774,3.61935484 C9.02096774,1.67258065 10.6112903,0.0822580645 12.5580645,0.0822580645 C14.5048387,0.0822580645 16.0951613,1.67258065 16.0951613,3.61935484 L16.0951613,12.4758065 C16.0951613,14.4225806 14.5048387,16.0129032 12.5580645,16.0129032 C10.6112903,16.0129032 9.02096774,14.4225806 9.02096774,12.4758065 C9.02096774,12.4758065 9.02096774,3.61935484 9.02096774,3.61935484 Z" />
</g>
<g>
<path d="M12.5580645,7.23870968 C10.6112903,7.23870968 9.02096774,5.6483871 9.02096774,3.7016129 C9.02096774,1.75483871 10.6112903,0.164516129 12.5580645,0.164516129 C14.5048387,0.164516129 16.0951613,1.75483871 16.0951613,3.7016129 L16.0951613,7.23870968 L12.5580645,7.23870968 Z" />
<path d="M12.5580645,9.02096774 C14.5048387,9.02096774 16.0951613,10.6112903 16.0951613,12.5580645 C16.0951613,14.5048387 14.5048387,16.0951613 12.5580645,16.0951613 L3.7016129,16.0951613 C1.75483871,16.0951613 0.164516129,14.5048387 0.164516129,12.5580645 C0.164516129,10.6112903 1.75483871,9.02096774 3.7016129,9.02096774 C3.7016129,9.02096774 12.5580645,9.02096774 12.5580645,9.02096774 Z" />
</g>
<g transform="translate(17.822581, 0.000000)">
<path d="M8.93870968,12.5580645 C8.93870968,10.6112903 10.5290323,9.02096774 12.4758065,9.02096774 C14.4225806,9.02096774 16.0129032,10.6112903 16.0129032,12.5580645 C16.0129032,14.5048387 14.4225806,16.0951613 12.4758065,16.0951613 L8.93870968,16.0951613 L8.93870968,12.5580645 Z" />
<path d="M7.15645161,12.5580645 C7.15645161,14.5048387 5.56612903,16.0951613 3.61935484,16.0951613 C1.67258065,16.0951613 0.0822580645,14.5048387 0.0822580645,12.5580645 L0.0822580645,3.7016129 C0.0822580645,1.75483871 1.67258065,0.164516129 3.61935484,0.164516129 C5.56612903,0.164516129 7.15645161,1.75483871 7.15645161,3.7016129 L7.15645161,12.5580645 Z" />
</g>
<g transform="translate(17.822581, 17.822581)">
<path d="M3.61935484,8.93870968 C5.56612903,8.93870968 7.15645161,10.5290323 7.15645161,12.4758065 C7.15645161,14.4225806 5.56612903,16.0129032 3.61935484,16.0129032 C1.67258065,16.0129032 0.0822580645,14.4225806 0.0822580645,12.4758065 L0.0822580645,8.93870968 L3.61935484,8.93870968 Z" />
<path d="M3.61935484,7.15645161 C1.67258065,7.15645161 0.0822580645,5.56612903 0.0822580645,3.61935484 C0.0822580645,1.67258065 1.67258065,0.0822580645 3.61935484,0.0822580645 L12.4758065,0.0822580645 C14.4225806,0.0822580645 16.0129032,1.67258065 16.0129032,3.61935484 C16.0129032,5.56612903 14.4225806,7.15645161 12.4758065,7.15645161 L3.61935484,7.15645161 Z" />
</g>
</g>
</svg>
);
}
export default SlackLogo;
+3 -5
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import LoadingIndicator from "./LoadingIndicator";
@@ -14,11 +13,10 @@ type Props = {
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const language = user?.language;
const language = auth.user?.language;
// Watching for language changes here as this is the earliest point we might have the user
// available and means we can start loading translations faster
// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
void changeLanguage(language, i18n);
}, [i18n, language]);
+23 -35
View File
@@ -12,7 +12,6 @@ import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import type { Editor as TEditor } from "~/editor";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -26,7 +25,6 @@ import {
matchDocumentInsights,
} from "~/utils/routeHelpers";
import Fade from "./Fade";
import { PortalContext } from "./Portal";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
@@ -46,10 +44,8 @@ type Props = {
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const can = usePolicy(ui.activeCollectionId);
const { user, team } = auth;
const documentContext = useLocalStore<DocumentContextValue>(() => ({
editor: null,
setEditor: (editor: TEditor) => {
@@ -70,7 +66,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return;
}
const { activeCollectionId } = ui;
if (!activeCollectionId || !canCollection.createDocument) {
if (!activeCollectionId || !can.createDocument) {
return;
}
history.push(newDocumentPath(activeCollectionId));
@@ -80,30 +76,29 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const sidebar = (
const showSidebar = auth.authenticated && user && team;
const sidebar = showSidebar ? (
<Fade>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
);
) : undefined;
const showHistory =
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showInsights =
!!matchPath(location.pathname, {
path: matchDocumentInsights,
}) && can.listViews;
const showHistory = !!matchPath(location.pathname, {
path: matchDocumentHistory,
});
const showInsights = !!matchPath(location.pathname, {
path: matchDocumentInsights,
});
const showComments =
!showInsights &&
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded.includes(ui.activeDocumentId) &&
team.getPreference(TeamPreference.Commenting);
team?.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
@@ -126,22 +121,15 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return (
<DocumentContext.Provider value={documentContext}>
<PortalContext.Provider value={layoutRef.current}>
<Layout
title={team.name}
sidebar={sidebar}
sidebarRight={sidebarRight}
ref={layoutRef}
>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
</Layout>
</PortalContext.Provider>
<Layout title={team?.name} sidebar={sidebar} sidebarRight={sidebarRight}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
</Layout>
</DocumentContext.Provider>
);
};
-1
View File
@@ -5,7 +5,6 @@ import Initials from "./Initials";
export enum AvatarSize {
Small = 16,
Toast = 18,
Medium = 24,
Large = 32,
XLarge = 48,
+1 -1
View File
@@ -34,7 +34,7 @@ function AvatarWithPresence({
return (
<>
<Tooltip
content={
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
{status && (
+5 -6
View File
@@ -34,17 +34,16 @@ const Link = styled.a`
fill: ${s("text")};
}
&:hover {
background: ${s("sidebarBackground")};
}
${breakpoint("tablet")`
z-index: ${depths.sidebar + 1};
background: ${s("sidebarBackground")};
position: fixed;
bottom: 0;
right: 0;
left: 0;
padding: 16px;
&:hover {
background: ${s("sidebarControlHoverBackground")};
}
`};
`;
-2
View File
@@ -5,7 +5,6 @@ import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { MenuInternalLink } from "~/types";
type Props = {
@@ -76,7 +75,6 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
height: 24px;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
${undraggableOnDesktop()}
svg {
flex-shrink: 0;
+1 -1
View File
@@ -171,7 +171,7 @@ const Button = <T extends React.ElementType = "button">(
danger,
...rest
} = props;
const hasText = !!children || value !== undefined;
const hasText = children !== undefined || value !== undefined;
const ic = hideIcon ? undefined : action?.icon ?? icon;
const hasIcon = ic !== undefined;
+5 -12
View File
@@ -4,7 +4,6 @@ import breakpoint from "styled-components-breakpoint";
type Props = {
children?: React.ReactNode;
maxWidth?: string;
withStickyHeader?: boolean;
};
@@ -19,24 +18,18 @@ const Container = styled.div<Props>`
`};
`;
type ContentProps = { $maxWidth?: string };
const Content = styled.div<ContentProps>`
max-width: ${(props) => props.$maxWidth ?? "46em"};
const Content = styled.div`
max-width: 46em;
margin: 0 auto;
${breakpoint("desktopLarge")`
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
max-width: 52em;
`};
`;
const CenteredContent: React.FC<Props> = ({
children,
maxWidth,
...rest
}: Props) => (
const CenteredContent: React.FC<Props> = ({ children, ...rest }: Props) => (
<Container {...rest}>
<Content $maxWidth={maxWidth}>{children}</Content>
<Content>{children}</Content>
</Container>
);
-17
View File
@@ -1,17 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { changeLanguage } from "~/utils/language";
type Props = {
locale: string;
};
export default function ChangeLanguage({ locale }: Props) {
const { i18n } = useTranslation();
React.useEffect(() => {
void changeLanguage(locale, i18n);
}, [locale, i18n]);
return null;
}
+1 -4
View File
@@ -1,7 +1,4 @@
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import sortBy from "lodash/sortBy";
import uniq from "lodash/uniq";
import { sortBy, filter, uniq, isEqual } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -1,32 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { CollectionForm, FormData } from "./CollectionForm";
type Props = {
collectionId: string;
onSubmit: () => void;
};
export const CollectionEdit = observer(function CollectionEdit_({
collectionId,
onSubmit,
}: Props) {
const { collections } = useStores();
const collection = collections.get(collectionId);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await collection?.save(data);
onSubmit?.();
} catch (error) {
toast.error(error.message);
}
},
[collection, onSubmit]
);
return <CollectionForm collection={collection} handleSubmit={handleSubmit} />;
});
@@ -1,173 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import { randomElement } from "@shared/random";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import IconPicker from "~/components/IconPicker";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
export interface FormData {
name: string;
icon: string;
color: string;
sharing: boolean;
permission: CollectionPermission | undefined;
}
export const CollectionForm = observer(function CollectionForm_({
handleSubmit,
collection,
}: {
handleSubmit: (data: FormData) => void;
collection?: Collection;
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const {
register,
handleSubmit: formHandleSubmit,
formState,
watch,
control,
setValue,
setFocus,
} = useForm<FormData>({
mode: "all",
defaultValues: {
name: collection?.name ?? "",
icon: collection?.icon,
sharing: collection?.sharing ?? true,
permission: collection?.permission,
color: collection?.color ?? randomElement(colorPalette),
},
});
const values = watch();
React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
if (!hasOpenedIconPicker && !collection) {
setValue(
"icon",
IconLibrary.findIconByKeyword(values.name) ??
values.icon ??
"collection"
);
}
}, [values.name, collection]);
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconPickerChange = React.useCallback(
(color: string, icon: string) => {
if (icon !== values.icon) {
setFocus("name");
}
setValue("color", color);
setValue("icon", icon);
},
[setFocus, setValue, values.icon]
);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
<Trans>
Collections are used to group documents and choose permissions
</Trans>
.
</Text>
<Flex gap={8}>
<Input
type="text"
placeholder={t("Name")}
{...register("name", {
required: true,
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<StyledIconPicker
onOpen={setHasOpenedIconPicker}
onChange={handleIconPickerChange}
initial={values.name[0]}
color={values.color}
icon={values.icon}
/>
}
autoComplete="off"
autoFocus
flex
/>
</Flex>
{/* Following controls are available in create flow, but moved elsewhere for edit */}
{!collection && (
<Controller
control={control}
name="permission"
render={({ field }) => (
<InputSelectPermission
ref={field.ref}
value={field.value}
onChange={(value: CollectionPermission) => {
field.onChange(value);
}}
note={t(
"The default access for workspace members, you can share with more users or groups later."
)}
/>
)}
/>
)}
{team.sharing && !collection && (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
>
{collection
? formState.isSubmitting
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
);
});
const StyledIconPicker = styled(IconPicker)`
margin-left: 4px;
margin-right: 4px;
`;
@@ -1,32 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { toast } from "sonner";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { CollectionForm, FormData } from "./CollectionForm";
type Props = {
onSubmit: () => void;
};
export const CollectionNew = observer(function CollectionNew_({
onSubmit,
}: Props) {
const { collections } = useStores();
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const collection = new Collection(data, collections);
await collection.save();
onSubmit?.();
history.push(collection.path);
} catch (error) {
toast.error(error.message);
}
},
[collections, onSubmit]
);
return <CollectionForm handleSubmit={handleSubmit} />;
});
+4 -8
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
@@ -23,14 +22,11 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
const handleSubmit = async () => {
const redirect = collection.id === ui.activeCollectionId;
await collection.delete();
onSubmit();
if (redirect) {
history.push(homePath());
}
await collection.delete();
onSubmit();
toast.success(t("Collection deleted"));
};
return (
@@ -41,7 +37,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
danger
>
<>
<Text as="p" type="secondary">
<Text type="secondary">
<Trans
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash."
values={{
@@ -53,7 +49,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
/>
</Text>
{team.defaultCollectionId === collection.id ? (
<Text as="p" type="secondary">
<Text type="secondary">
<Trans
defaults="Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page."
values={{
+9 -16
View File
@@ -3,9 +3,7 @@ import { observer } from "mobx-react";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { richExtensions } from "@shared/editor/nodes";
import { s } from "@shared/styles";
import Collection from "~/models/Collection";
import Arrow from "~/components/Arrow";
@@ -13,18 +11,9 @@ import ButtonLink from "~/components/ButtonLink";
import Editor from "~/components/Editor";
import LoadingIndicator from "~/components/LoadingIndicator";
import NudeButton from "~/components/NudeButton";
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
const extensions = [
...richExtensions,
BlockMenuExtension,
EmojiMenuExtension,
HoverPreviewsExtension,
];
import useToasts from "~/hooks/useToasts";
type Props = {
collection: Collection;
@@ -32,6 +21,7 @@ type Props = {
function CollectionDescription({ collection }: Props) {
const { collections } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const [isExpanded, setExpanded] = React.useState(false);
const [isEditing, setEditing] = React.useState(false);
@@ -69,11 +59,15 @@ function CollectionDescription({ collection }: Props) {
});
setDirty(false);
} catch (err) {
toast.error(t("Sorry, an error occurred saving the collection"));
showToast(
t("Sorry, an error occurred saving the collection", {
type: "error",
})
);
throw err;
}
}, 1000),
[collection, t]
[collection, showToast, t]
);
const handleChange = React.useCallback(
@@ -115,7 +109,6 @@ function CollectionDescription({ collection }: Props) {
readOnly={!isEditing}
autoFocus={isEditing}
onBlur={handleStopEditing}
extensions={extensions}
maxLength={1000}
embedsDisabled
canUpdate
@@ -177,7 +170,7 @@ const MaxHeight = styled.div`
position: relative;
max-height: 25vh;
overflow: hidden;
margin: 8px -8px -8px;
margin: -12px -8px -8px;
padding: 8px;
&[data-editing="true"],
+18 -6
View File
@@ -11,26 +11,39 @@ import SearchActions from "~/components/SearchActions";
import rootActions from "~/actions/root";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useSettingsActions from "~/hooks/useSettingsActions";
import useTemplateActions from "~/hooks/useTemplateActions";
import { CommandBarAction } from "~/types";
function CommandBar() {
const { t } = useTranslation();
const settingsActions = useSettingsActions();
const templateActions = useTemplateActions();
const commandBarActions = React.useMemo(
() => [...rootActions, templateActions, settingsActions],
[settingsActions, templateActions]
() => [...rootActions, settingsActions],
[settingsActions]
);
useCommandBarActions(commandBarActions);
const { rootAction } = useKBar((state) => ({
rootAction: state.currentRootActionId
? (state.actions[
state.currentRootActionId
] as unknown as CommandBarAction)
: undefined,
}));
return (
<>
<KBarPortal>
<Positioner>
<Animator>
<SearchActions />
<SearchInput defaultPlaceholder={t("Type a command or search")} />
<SearchInput
placeholder={`${
rootAction?.placeholder ||
rootAction?.name ||
t("Type a command or search")
}`}
/>
<CommandBarResults />
</Animator>
</Positioner>
@@ -70,7 +83,6 @@ const SearchInput = styled(KBarSearch)`
&:disabled,
&::placeholder {
color: ${s("placeholder")};
opacity: 1;
}
`;
+1 -1
View File
@@ -62,7 +62,7 @@ function CommandBarItem(
{index > 0 ? (
<>
{" "}
<Text size="xsmall" type="secondary">
<Text size="xsmall" as="span" type="secondary">
then
</Text>{" "}
</>
+4 -3
View File
@@ -1,11 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Comment from "~/models/Comment";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type Props = {
comment: Comment;
@@ -14,6 +14,7 @@ type Props = {
function CommentDeleteDialog({ comment, onSubmit }: Props) {
const { comments } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const hasChildComments = comments.inThread(comment.id).length > 1;
@@ -22,7 +23,7 @@ function CommentDeleteDialog({ comment, onSubmit }: Props) {
await comment.delete();
onSubmit?.();
} catch (err) {
toast.error(err.message);
showToast(err.message, { type: "error" });
}
};
@@ -33,7 +34,7 @@ function CommentDeleteDialog({ comment, onSubmit }: Props) {
savingText={`${t("Deleting")}`}
danger
>
<Text as="p" type="secondary">
<Text type="secondary">
{hasChildComments ? (
<Trans>
Are you sure you want to permanently delete this entire comment
+18 -20
View File
@@ -1,11 +1,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
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 */
@@ -30,8 +29,8 @@ const ConfirmationDialog: React.FC<Props> = ({
disabled = false,
}: Props) => {
const [isSaving, setIsSaving] = React.useState(false);
const { t } = useTranslation();
const { dialogs } = useStores();
const { showToast } = useToasts();
const handleSubmit = React.useCallback(
async (ev: React.SyntheticEvent) => {
@@ -41,31 +40,30 @@ const ConfirmationDialog: React.FC<Props> = ({
await onSubmit();
dialogs.closeAllModals();
} catch (err) {
toast.error(err.message);
showToast(err.message, {
type: "error",
});
} finally {
setIsSaving(false);
}
},
[onSubmit, dialogs]
[onSubmit, dialogs, showToast]
);
return (
<form onSubmit={handleSubmit}>
<Flex gap={12} column>
<Flex column>
<form onSubmit={handleSubmit}>
<Text type="secondary">{children}</Text>
<Flex justify="flex-end">
<Button
type="submit"
disabled={isSaving || disabled}
danger={danger}
autoFocus
>
{isSaving && savingText ? savingText : submitText ?? t("Confirm")}
</Button>
</Flex>
</Flex>
</form>
<Button
type="submit"
disabled={isSaving || disabled}
danger={danger}
autoFocus
>
{isSaving && savingText ? savingText : submitText}
</Button>
</form>
</Flex>
);
};
+6 -39
View File
@@ -14,48 +14,15 @@ function ConnectionStatus() {
const theme = useTheme();
const { t } = useTranslation();
const codeToMessage = {
1009: {
title: t("Document is too large"),
body: t(
"This document has reached the maximum size and can no longer be edited"
),
},
4401: {
title: t("Authentication failed"),
body: t("Please try logging out and back in again"),
},
4403: {
title: t("Authorization failed"),
body: t("You may have lost access to this document, try reloading"),
},
4503: {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
};
const message = ui.multiplayerErrorCode
? codeToMessage[ui.multiplayerErrorCode]
: undefined;
return ui.multiplayerStatus === "connecting" ||
ui.multiplayerStatus === "disconnected" ? (
<Tooltip
content={
message ? (
<Centered>
<strong>{message.title}</strong>
<br />
{message.body}
</Centered>
) : (
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
)
tooltip={
<Centered>
<strong>{t("Server connection lost")}</strong>
<br />
{t("Edits you make will sync once youre online")}
</Centered>
}
placement="bottom"
>
+2 -5
View File
@@ -9,7 +9,6 @@ type Props = Omit<React.HTMLAttributes<HTMLSpanElement>, "ref" | "onChange"> & {
readOnly?: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>;
onChange?: (text: string) => void;
onFocus?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onBlur?: React.FocusEventHandler<HTMLSpanElement> | undefined;
onInput?: React.FormEventHandler<HTMLSpanElement> | undefined;
onKeyDown?: React.KeyboardEventHandler<HTMLSpanElement> | undefined;
@@ -36,7 +35,6 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
disabled,
onChange,
onInput,
onFocus,
onBlur,
onKeyDown,
value,
@@ -145,13 +143,11 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
);
return (
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
{children}
<div className={className} dir={dir} onClick={onClick}>
<Content
ref={contentRef}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onFocus={wrappedEvent(onFocus)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
@@ -162,6 +158,7 @@ const ContentEditable = React.forwardRef(function _ContentEditable(
>
{innerValue}
</Content>
{children}
</div>
);
});
+10 -11
View File
@@ -1,12 +1,11 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import MenuIconWrapper from "./MenuIconWrapper";
import MenuIconWrapper from "../MenuIconWrapper";
type Props = {
id?: string;
@@ -72,12 +71,13 @@ const MenuItem = (
])}
>
{selected !== undefined && (
<MenuIconWrapper aria-hidden>
<>
{selected ? <CheckmarkIcon /> : <Spacer />}
</MenuIconWrapper>
&nbsp;
</>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{children}
</MenuAnchor>
);
},
@@ -102,10 +102,6 @@ const Spacer = styled.svg`
flex-shrink: 0;
`;
const Title = styled.div`
${ellipsis()}
`;
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
@@ -134,6 +130,10 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
white-space: nowrap;
position: relative;
svg:not(:last-child) {
margin-right: 4px;
}
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
@@ -155,7 +155,6 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
}
+6 -6
View File
@@ -2,17 +2,17 @@ import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
type Positions = {
/** Sub-menu x */
/* Sub-menu x */
x: number;
/** Sub-menu y */
/* Sub-menu y */
y: number;
/** Sub-menu height */
/* Sub-menu height */
h: number;
/** Sub-menu width */
/* Sub-menu width */
w: number;
/** Mouse x */
/* Mouse x */
mouseX: number;
/** Mouse y */
/* Mouse y */
mouseY: number;
};
+4 -4
View File
@@ -9,8 +9,8 @@ import {
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import MenuIconWrapper from "~/components/MenuIconWrapper";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import {
@@ -126,7 +126,7 @@ function Template({ items, actions, context, ...menu }: Props) {
item.type !== "separator" &&
item.type !== "heading"
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
item.icon = item.icon || <MenuIconWrapper />;
}
if (item.type === "route") {
@@ -201,7 +201,7 @@ function Template({ items, actions, context, ...menu }: Props) {
}
if (item.type === "heading") {
return <Header key={index}>{item.title}</Header>;
return <Header>{item.title}</Header>;
}
const _exhaustiveCheck: never = item;
@@ -220,7 +220,7 @@ function Title({
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
{icon && <MenuIconWrapper>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
+62 -107
View File
@@ -38,8 +38,6 @@ export type Placement =
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
@@ -48,13 +46,10 @@ type Props = MenuStateReturn & {
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
children?: React.ReactNode;
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
@@ -62,6 +57,11 @@ const ContextMenu: React.FC<Props> = ({
...rest
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight({
visible: rest.visible,
elementRef: rest.unstable_disclosureRef,
});
const backgroundRef = React.useRef<HTMLDivElement>(null);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
@@ -99,6 +99,21 @@ 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 && !isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
scrollElement && !isSubMenu && enableBodyScroll(scrollElement);
};
}, [isSubMenu, rest.visible]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
@@ -108,104 +123,52 @@ const ContextMenu: React.FC<Props> = ({
// trigger and the bottom of the window
return (
<>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// eslint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
<Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...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
// positioning.
const topAnchor = props.style?.top === "0";
// @ts-expect-error ts-migrate(2339) FIXME: Property 'placement' does not exist on type 'Extra... Remove this comment to see the full error message
const rightAnchor = props.placement === "bottom-end";
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
rest.hide?.();
}}
/>
)}
<Position {...props}>
<Background
dir="auto"
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={
topAnchor
? {
maxHeight,
}
: undefined
}
>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
</>
);
}}
</Menu>
</>
);
};
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = 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
// positioning.
const topAnchor =
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// 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 (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
scrollElement && !props.isSubMenu && enableBodyScroll(scrollElement);
};
}, [props.isSubMenu, props.visible]);
const style =
topAnchor && !isMobile
? {
maxHeight,
}
: undefined;
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={style}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu;
export const Backdrop = styled.div`
@@ -223,12 +186,6 @@ export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
&.focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
}
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
@@ -246,7 +203,6 @@ export const Position = styled.div`
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
theme: DefaultTheme;
};
@@ -272,8 +228,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
max-height: 100vh;
max-width: 276px;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
+21 -30
View File
@@ -1,6 +1,5 @@
import copy from "copy-to-clipboard";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import env from "~/env";
type Props = {
@@ -10,40 +9,32 @@ type Props = {
onCopy?: () => void;
};
function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) {
const { text, onCopy, children, ...rest } = props;
class CopyToClipboard extends React.PureComponent<Props> {
onClick = (ev: React.SyntheticEvent) => {
const { text, onCopy, children } = this.props;
const elem = React.Children.only(children);
const onClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
const elem = React.Children.only(children);
copy(text, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
copy(text, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
onCopy?.();
onCopy?.();
if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
}
};
if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
}
},
[children, onCopy, text]
);
render() {
const { text, onCopy, children, ...rest } = this.props;
const elem = React.Children.only(children);
if (!elem) {
return null;
}
const elem = React.Children.only(children);
if (!elem) {
return null;
return React.cloneElement(elem, { ...rest, onClick: this.onClick });
}
return React.cloneElement(elem, {
...rest,
ref:
"ref" in elem
? mergeRefs([elem.ref as React.MutableRefObject<HTMLElement>, ref])
: ref,
onClick,
});
}
export default React.forwardRef(CopyToClipboard);
export default CopyToClipboard;
@@ -1,13 +1,13 @@
import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Optional } from "utility-types";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import InputSelect from "~/components/InputSelect";
import { IconWrapper } from "~/components/Sidebar/components/SidebarLink";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
type DefaultCollectionInputSelectProps = Optional<
React.ComponentProps<typeof InputSelect>
@@ -25,6 +25,7 @@ const DefaultCollectionInputSelect = ({
const { collections } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
const { showToast } = useToasts();
React.useEffect(() => {
async function fetchData() {
@@ -35,8 +36,11 @@ const DefaultCollectionInputSelect = ({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
showToast(
t("Collections could not be loaded, please reload the app"),
{
type: "error",
}
);
setFetchError(error);
} finally {
@@ -45,7 +49,7 @@ const DefaultCollectionInputSelect = ({
}
}
void fetchData();
}, [fetchError, t, fetching, collections]);
}, [showToast, fetchError, t, fetching, collections]);
const options = React.useMemo(
() =>
+7 -12
View File
@@ -1,10 +1,10 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import Desktop from "~/utils/Desktop";
export default function DesktopEventHandler() {
@@ -12,7 +12,7 @@ export default function DesktopEventHandler() {
const { t } = useTranslation();
const history = useHistory();
const { dialogs } = useStores();
const hasDisabledUpdateMessage = React.useRef(false);
const { showToast } = useToasts();
React.useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => {
@@ -24,16 +24,11 @@ export default function DesktopEventHandler() {
});
Desktop.bridge?.updateDownloaded(() => {
if (hasDisabledUpdateMessage.current) {
return;
}
hasDisabledUpdateMessage.current = true;
toast.message("An update is ready to install.", {
duration: Infinity,
dismissible: true,
showToast("An update is ready to install.", {
type: "info",
timeout: Infinity,
action: {
label: t("Install now"),
text: "Install now",
onClick: () => {
void Desktop.bridge?.restartAndInstall();
},
@@ -55,7 +50,7 @@ export default function DesktopEventHandler() {
content: <KeyboardShortcuts />,
});
});
}, [t, history, dialogs]);
}, [t, history, dialogs, showToast]);
return null;
}
+1 -1
View File
@@ -22,7 +22,7 @@ function Dialogs() {
<Modal
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
isCentered={modal.isCentered}
onRequestClose={() => dialogs.closeModal(id)}
title={modal.title}
>
+14 -21
View File
@@ -12,10 +12,9 @@ import { MenuInternalLink } from "~/types";
import {
archivePath,
collectionPath,
settingsPath,
templatesPath,
trashPath,
} from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
type Props = {
children?: React.ReactNode;
@@ -44,12 +43,12 @@ function useCategory(document: Document): MenuInternalLink | null {
};
}
if (document.template) {
if (document.isTemplate) {
return {
type: "route",
icon: <ShapesIcon />,
title: t("Templates"),
to: settingsPath("templates"),
to: templatesPath(),
};
}
@@ -68,10 +67,6 @@ const DocumentBreadcrumb: React.FC<Props> = ({
? collections.get(document.collectionId)
: undefined;
React.useEffect(() => {
void document.loadRelations();
}, [document]);
let collectionNode: MenuInternalLink | undefined;
if (collection) {
@@ -79,18 +74,22 @@ const DocumentBreadcrumb: React.FC<Props> = ({
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
to: collectionPath(collection.url),
};
} else if (document.isCollectionDeleted) {
} else if (document.collectionId && !collection) {
collectionNode = {
type: "route",
title: t("Deleted Collection"),
icon: undefined,
to: "",
to: collectionPath("deleted-collection"),
};
}
const path = document.pathTo;
const path = React.useMemo(
() => collection?.pathToDocument(document.id).slice(0, -1) || [],
// eslint-disable-next-line react-hooks/exhaustive-deps
[collection, document, document.collectionId, document.parentDocumentId]
);
const items = React.useMemo(() => {
const output = [];
@@ -103,16 +102,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({
output.push(collectionNode);
}
path.slice(0, -1).forEach((node: NavigationNode) => {
path.forEach((node: NavigationNode) => {
output.push({
type: "route",
title: node.emoji ? (
<>
<EmojiIcon emoji={node.emoji} /> {node.title}
</>
) : (
node.title
),
title: node.title,
to: node.url,
});
});
@@ -127,7 +120,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
return (
<>
{collection?.name}
{path.slice(0, -1).map((node: NavigationNode) => (
{path.map((node: NavigationNode) => (
<React.Fragment key={node.id}>
<SmallSlash />
{node.title}
+5 -6
View File
@@ -7,7 +7,6 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import { s, ellipsis } from "@shared/styles";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
@@ -18,6 +17,7 @@ import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import CollectionIcon from "./Icons/CollectionIcon";
import EmojiIcon from "./Icons/EmojiIcon";
import Squircle from "./Squircle";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -111,12 +111,11 @@ function DocumentCard(props: Props) {
{document.emoji ? (
<Squircle color={theme.slateLight}>
<EmojiIcon emoji={document.emoji} size={24} />
<EmojiIcon emoji={document.emoji} size={26} />
</Squircle>
) : (
<Squircle color={collection?.color}>
{collection?.icon &&
collection?.icon !== "letter" &&
collection?.icon !== "collection" &&
!pin?.collectionId ? (
<CollectionIcon collection={collection} color="white" />
@@ -145,7 +144,7 @@ function DocumentCard(props: Props) {
{canUpdatePin && (
<Actions dir={document.dir} gap={4}>
{!isDragging && pin && (
<Tooltip content={t("Unpin")}>
<Tooltip tooltip={t("Unpin")}>
<PinButton onClick={handleUnpin} aria-label={t("Unpin")}>
<CloseIcon />
</PinButton>
@@ -280,8 +279,8 @@ const Heading = styled.h3`
overflow: hidden;
color: ${s("text")};
font-family: ${s("fontFamily")};
font-weight: 500;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
export default observer(DocumentCard);
-18
View File
@@ -1,6 +1,5 @@
import * as React from "react";
import { Editor } from "~/editor";
import useIdle from "~/hooks/useIdle";
export type DocumentContextValue = {
/** The current editor instance for this document. */
@@ -17,21 +16,4 @@ const DocumentContext = React.createContext<DocumentContextValue>({
export const useDocumentContext = () => React.useContext(DocumentContext);
const activityEvents = [
"click",
"mousemove",
"DOMMouseScroll",
"mousewheel",
"mousedown",
"touchstart",
"touchmove",
"focus",
];
export const useEditingFocus = () => {
const { editor } = useDocumentContext();
const isIdle = useIdle(3000, activityEvents);
return isIdle && !!editor?.view.hasFocus();
};
export default DocumentContext;
+76 -84
View File
@@ -1,10 +1,5 @@
import FuzzySearch from "fuzzy-search";
import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import includes from "lodash/includes";
import map from "lodash/map";
import { includes, difference, concat, filter, map, fill } from "lodash";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
@@ -15,6 +10,7 @@ import scrollIntoView from "smooth-scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { NavigationNode } from "@shared/types";
import parseTitle from "@shared/utils/parseTitle";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
@@ -204,86 +200,84 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
}
};
const ListItem = observer(
({
index,
data,
style,
}: {
index: number;
data: NavigationNode[];
style: React.CSSProperties;
}) => {
const node = data[index];
const isCollection = node.type === "collection";
let icon, title: string, emoji: string | undefined, path;
const ListItem = ({
index,
data,
style,
}: {
index: number;
data: NavigationNode[];
style: React.CSSProperties;
}) => {
const node = data[index];
const isCollection = node.type === "collection";
let icon, title, path;
if (isCollection) {
const col = collections.get(node.collectionId as string);
icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
if (isCollection) {
const col = collections.get(node.collectionId as string);
icon = col && (
<CollectionIcon collection={col} expanded={isExpanded(index)} />
);
title = node.title;
} else {
const doc = documents.get(node.id);
const { strippedTitle, emoji } = parseTitle(node.title);
title = strippedTitle;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else {
const doc = documents.get(node.id);
emoji = doc?.emoji ?? node.emoji;
title = doc?.title ?? node.title;
if (emoji) {
icon = <EmojiIcon emoji={emoji} />;
} else if (doc?.isStarred) {
icon = <StarredIcon color={theme.yellow} />;
} else {
icon = <DocumentIcon color={theme.textSecondary} />;
}
path = ancestors(node)
.map((a) => a.title)
.join(" / ");
icon = <DocumentIcon color={theme.textSecondary} />;
}
return searchTerm ? (
<DocumentExplorerSearchResult
selected={isSelected(index)}
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
}}
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={icon}
title={title}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
);
path = ancestors(node)
.map((a) => parseTitle(a.title).strippedTitle)
.join(" / ");
}
);
return searchTerm ? (
<DocumentExplorerSearchResult
selected={isSelected(index)}
active={activeNode === index}
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
icon={icon}
title={title}
path={path}
/>
) : (
<DocumentExplorerNode
style={{
...style,
top: (style.top as number) + VERTICAL_PADDING,
left: (style.left as number) + HORIZONTAL_PADDING,
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
}}
selected={isSelected(index)}
active={activeNode === index}
expanded={isExpanded(index)}
icon={icon}
title={title}
depth={node.depth as number}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
);
};
const focusSearchInput = () => {
inputSearchRef.current?.focus();
@@ -389,9 +383,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
</AutoSizer>
) : (
<FlexContainer>
<Text as="p" type="secondary">
{t("No results found")}.
</Text>
<Text type="secondary">{t("No results found")}.</Text>
</FlexContainer>
)}
</ListContainer>
+37 -17
View File
@@ -1,4 +1,5 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -8,6 +9,7 @@ import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex";
@@ -16,11 +18,12 @@ import NudeButton from "~/components/NudeButton";
import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import DocumentMenu from "~/menus/DocumentMenu";
import { hover } from "~/styles";
import { documentPath } from "~/utils/routeHelpers";
import EmojiIcon from "./Icons/EmojiIcon";
import { newDocumentPath } from "~/utils/routeHelpers";
type Props = {
document: Document;
@@ -37,8 +40,9 @@ type Props = {
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX directly here as it causes an infinite loop
return tag.replace(new RegExp(SEARCH_RESULT_REGEX.source), "$1");
// 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(
@@ -47,6 +51,7 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const team = useCurrentTeam();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const {
@@ -66,6 +71,8 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar =
!document.isDraft && !document.isArchived && !document.isTemplate;
const can = usePolicy(team);
const canCollection = usePolicy(document.collectionId);
return (
<CompositeItem
@@ -76,7 +83,7 @@ function DocumentListItem(
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
pathname: document.url,
state: {
title: document.titleWithDefault,
},
@@ -85,18 +92,12 @@ function DocumentListItem(
>
<Content>
<Heading dir={document.dir}>
{document.emoji && (
<>
<EmojiIcon emoji={document.emoji} size={24} />
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
{document.isBadgedNew && document.createdBy.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{canStar && (
@@ -106,7 +107,7 @@ function DocumentListItem(
)}
{document.isDraft && showDraft && (
<Tooltip
content={t("Only visible to you")}
tooltip={t("Only visible to you")}
delay={500}
placement="top"
>
@@ -134,6 +135,25 @@ function DocumentListItem(
/>
</Content>
<Actions>
{document.isTemplate &&
!document.isArchived &&
!document.isDeleted &&
can.createDocument &&
canCollection.update && (
<>
<Button
as={Link}
to={newDocumentPath(document.collectionId, {
templateId: document.id,
})}
icon={<PlusIcon />}
neutral
>
{t("New doc")}
</Button>
&nbsp;
</>
)}
<DocumentMenu
document={document}
showPin={showPin}
@@ -242,8 +262,8 @@ const Heading = styled.h3<{ rtl?: boolean }>`
margin-bottom: 0.25em;
white-space: nowrap;
color: ${s("text")};
font-family: ${s("fontFamily")};
font-weight: 500;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
`;
const StarPositioner = styled(Flex)`
@@ -259,8 +279,8 @@ const Title = styled(Highlight)`
const ResultContext = styled(Highlight)`
display: block;
color: ${s("textSecondary")};
font-size: 15px;
color: ${s("textTertiary")};
font-size: 14px;
margin-top: -0.25em;
margin-bottom: 0.25em;
`;
+1 -22
View File
@@ -95,21 +95,6 @@ const DocumentMeta: React.FC<Props> = ({
<Time dateTime={archivedAt} addSuffix />
</span>
);
} else if (
document.sourceMetadata &&
document.sourceMetadata?.importedAt &&
document.sourceMetadata.importedAt >= updatedAt
) {
content = (
<span>
{document.sourceMetadata.createdByName
? t("{{ userName }} updated", {
userName: document.sourceMetadata.createdByName,
})
: t("Imported")}{" "}
<Time dateTime={createdAt} addSuffix />
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
@@ -177,13 +162,7 @@ const DocumentMeta: React.FC<Props> = ({
};
return (
<Container
align="center"
rtl={document.dir === "rtl"}
{...rest}
dir="ltr"
lang=""
>
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
+6 -3
View File
@@ -3,9 +3,9 @@ import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import { documentPath } from "~/utils/routeHelpers";
type Props = {
@@ -14,6 +14,7 @@ type Props = {
function DocumentTemplatizeDialog({ documentId }: Props) {
const history = useHistory();
const { showToast } = useToasts();
const { t } = useTranslation();
const { documents } = useStores();
const document = documents.get(documentId);
@@ -23,9 +24,11 @@ function DocumentTemplatizeDialog({ documentId }: Props) {
const template = await document?.templatize();
if (template) {
history.push(documentPath(template));
toast.success(t("Template created, go ahead and customize it"));
showToast(t("Template created, go ahead and customize it"), {
type: "info",
});
}
}, [document, history, t]);
}, [document, showToast, history, t]);
return (
<ConfirmationDialog
+7 -16
View File
@@ -1,15 +1,13 @@
import compact from "lodash/compact";
import sortBy from "lodash/sortBy";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { dateToRelative } from "@shared/utils/date";
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";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
type Props = {
@@ -20,9 +18,6 @@ type Props = {
function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation();
const { views, presence } = useStores();
const user = useCurrentUser();
const locale = dateLocale(user.language);
const documentPresence = presence.get(document.id);
const documentPresenceArray = documentPresence
? Array.from(documentPresence.values())
@@ -36,10 +31,10 @@ function DocumentViews({ document, isOpen }: Props) {
const documentViews = views.inDocument(document.id);
const sortedViews = sortBy(
documentViews,
(view) => !presentIds.includes(view.userId)
(view) => !presentIds.includes(view.user.id)
);
const users = React.useMemo(
() => compact(sortedViews.map((v) => v.user)),
() => sortedViews.map((v) => v.user),
[sortedViews]
);
@@ -50,20 +45,16 @@ function DocumentViews({ document, isOpen }: Props) {
aria-label={t("Viewers")}
items={users}
renderItem={(model: User) => {
const view = documentViews.find((v) => v.userId === model.id);
const view = documentViews.find((v) => v.user.id === model.id);
const isPresent = presentIds.includes(model.id);
const isEditing = editingIds.includes(model.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
: t("Currently viewing")
: t("Viewed {{ timeAgo }}", {
: t("Viewed {{ timeAgo }} ago", {
timeAgo: dateToRelative(
view ? Date.parse(view.lastViewedAt) : new Date(),
{
addSuffix: true,
locale,
}
view ? Date.parse(view.lastViewedAt) : new Date()
),
});
return (
-97
View File
@@ -1,97 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { DocumentValidation } from "@shared/validations";
import Document from "~/models/Document";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Input from "./Input";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
document: Document;
onSubmit: (documents: Document[]) => void;
};
function DuplicateDialog({ document, onSubmit }: Props) {
const { t } = useTranslation();
const defaultTitle = t(`Copy of {{ documentName }}`, {
documentName: document.title,
});
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [title, setTitle] = React.useState<string>(defaultTitle);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const handleTitleChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setTitle(ev.target.value);
},
[]
);
const handleSubmit = async () => {
const result = await document.duplicate({
publish,
recursive,
title,
});
onSubmit(result);
};
return (
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Duplicate")}>
<Input
autoFocus
autoSelect
name="title"
label={t("Title")}
onChange={handleTitleChange}
maxLength={DocumentValidation.maxTitleLength}
defaultValue={defaultTitle}
/>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Published")}
labelPosition="right"
checked={publish}
onChange={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</ConfirmationDialog>
);
}
export default observer(DuplicateDialog);
+82 -15
View File
@@ -1,11 +1,10 @@
import deburr from "lodash/deburr";
import difference from "lodash/difference";
import sortBy from "lodash/sortBy";
import { deburr, difference, 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 { useHistory } from "react-router-dom";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { AttachmentPreset } from "@shared/types";
@@ -15,18 +14,22 @@ import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
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 useCurrentUser from "~/hooks/useCurrentUser";
import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import useUserLocale from "~/hooks/useUserLocale";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import { isModKey } from "~/utils/keyboard";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { sharedDocumentPath } from "~/utils/routeHelpers";
import { isHash } from "~/utils/urls";
import DocumentBreadcrumb from "./DocumentBreadcrumb";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
@@ -38,13 +41,14 @@ export type Props = Optional<
| "onClickLink"
| "embeds"
| "dictionary"
| "onShowToast"
| "extensions"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
onHeadingsChange?: (headings: Heading[]) => void;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => void;
onPublish?: (event: React.MouseEvent) => any;
editorStyle?: React.CSSProperties;
};
@@ -59,14 +63,27 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
} = props;
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const { comments, documents } = useStores();
const { auth, comments, documents } = useStores();
const { showToast } = useToasts();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const history = useHistory();
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const preferences = auth.user?.preferences;
const previousHeadings = React.useRef<Heading[] | null>(null);
const [activeLinkElement, setActiveLink] =
React.useState<HTMLAnchorElement | null>(null);
const previousCommentIds = React.useRef<string[]>();
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
setActiveLink(element);
return false;
}, []);
const handleLinkInactive = React.useCallback(() => {
setActiveLink(null);
}, []);
const handleSearchLink = React.useCallback(
async (term: string) => {
if (isInternalUrl(term)) {
@@ -103,7 +120,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const results = await documents.searchTitles(term);
return sortBy(
results.map(({ document }) => ({
results.map((document: Document) => ({
title: document.title,
subtitle: <DocumentBreadcrumb document={document} onlyText />,
url: document.url,
@@ -116,7 +133,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
: 1
);
},
[locale, documents]
[documents]
);
const handleUploadFile = React.useCallback(
@@ -130,7 +147,47 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
[id]
);
const { handleClickLink } = useEditorClickHandlers({ shareId });
const handleClickLink = React.useCallback(
(href: string, event: MouseEvent) => {
// on page hash
if (isHash(href)) {
window.location.href = href;
return;
}
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
// relative
let navigateTo = href;
// probably absolute
if (href[0] !== "/") {
try {
const url = new URL(href);
navigateTo = url.pathname + url.hash;
} catch (err) {
navigateTo = href;
}
}
// Link to our own API should be opened in a new tab, not in the app
if (navigateTo.startsWith("/api/")) {
window.open(href, "_blank");
return;
}
// If we're navigating to an internal document link then prepend the
// share route to the URL so that the document is loaded in context
if (shareId && navigateTo.includes("/doc/")) {
navigateTo = sharedDocumentPath(shareId, navigateTo);
}
history.push(navigateTo);
} else if (href) {
window.open(href, "_blank");
}
},
[history, shareId]
);
const focusAtEnd = React.useCallback(() => {
localRef?.current?.focusAtEnd();
@@ -176,10 +233,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
);
return insertFiles(view, event, pos, files, {
insertFiles(view, event, pos, files, {
uploadFile: handleUploadFile,
onFileUploadStart: props.onFileUploadStart,
onFileUploadStop: props.onFileUploadStop,
onShowToast: showToast,
dictionary,
isAttachment,
});
@@ -190,6 +248,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
props.onFileUploadStop,
dictionary,
handleUploadFile,
showToast,
]
);
@@ -218,8 +277,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
}, [localRef, onHeadingsChange]);
const updateComments = React.useCallback(() => {
if (onCreateCommentMark && onDeleteCommentMark && localRef.current) {
const commentMarks = localRef.current.getComments();
if (onCreateCommentMark && onDeleteCommentMark) {
const commentMarks = localRef.current?.getComments();
const commentIds = comments.orderedData.map((c) => c.id);
const commentMarkIds = commentMarks?.map((c) => c.id);
const newCommentIds = difference(
@@ -229,7 +288,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
);
newCommentIds.forEach((commentId) => {
const mark = commentMarks.find((c) => c.id === commentId);
const mark = commentMarks?.find((c) => c.id === commentId);
if (mark) {
onCreateCommentMark(mark.id, mark.userId);
}
@@ -273,10 +332,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<LazyLoadedEditor
ref={mergeRefs([ref, localRef, handleRefChanged])}
uploadFile={handleUploadFile}
onShowToast={showToast}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onHoverLink={handleLinkActive}
onClickLink={handleClickLink}
onSearchLink={handleSearchLink}
onChange={handleChange}
@@ -291,6 +352,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
minHeight={props.editorStyle.paddingBottom}
/>
)}
{activeLinkElement && !shareId && (
<HoverPreview
element={activeLinkElement}
onClose={handleLinkInactive}
/>
)}
</>
</ErrorBoundary>
);
-23
View File
@@ -1,23 +0,0 @@
import styled from "styled-components";
import Button from "~/components/Button";
import { hover } from "~/styles";
import Flex from "../Flex";
export const EmojiButton = styled(Button)`
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
&: ${hover},
&:active,
&[aria-expanded= "true"] {
opacity: 1 !important;
}
`;
export const Emoji = styled(Flex)<{ size?: number }>`
line-height: 1.6;
${(props) => (props.size ? `font-size: ${props.size}px` : "")}
`;
-262
View File
@@ -1,262 +0,0 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import { toRGB } from "@shared/utils/color";
import Button from "~/components/Button";
import Popover from "~/components/Popover";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { Emoji, EmojiButton } from "./components";
/* Locales supported by emoji-mart */
const supportedLocales = [
"en",
"ar",
"be",
"cs",
"de",
"es",
"fa",
"fi",
"fr",
"hi",
"it",
"ja",
"ko",
"nl",
"pl",
"pt",
"ru",
"sa",
"tr",
"uk",
"vi",
"zh",
];
/**
* React hook to derive emoji picker's theme from UI theme
*
* @returns {string} Theme to use for emoji picker
*/
function usePickerTheme(): string {
const { ui } = useStores();
const { theme } = ui;
if (theme === "system") {
return "auto";
}
return theme;
}
type Props = {
/** The selected emoji, if any */
value?: string | null;
/** Callback when an emoji is selected */
onChange: (emoji: string | null) => void | Promise<void>;
/** Callback when the picker is opened */
onOpen?: () => void;
/** Callback when the picker is closed */
onClose?: () => void;
/** Callback when the picker is clicked outside of */
onClickOutside: () => void;
/** Whether to auto focus the search input on open */
autoFocus?: boolean;
/** Class name to apply to the trigger button */
className?: string;
};
function EmojiPicker({
value,
onOpen,
onClose,
onChange,
onClickOutside,
autoFocus,
className,
}: Props) {
const { t } = useTranslation();
const pickerTheme = usePickerTheme();
const theme = useTheme();
const locale = useUserLocale(true) ?? "en";
const popover = usePopoverState({
placement: "bottom-start",
modal: true,
unstable_offset: [0, 0],
});
const [emojisPerLine, setEmojisPerLine] = React.useState(9);
const pickerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
}
}, [popover.visible, onOpen, onClose]);
React.useEffect(() => {
if (popover.visible && pickerRef.current) {
// 28 is picker's observed width when perLine is set to 0
// and 36 is the default emojiButtonSize
// Ref: https://github.com/missive/emoji-mart#options--props
setEmojisPerLine(Math.floor((pickerRef.current.clientWidth - 28) / 36));
}
}, [popover.visible]);
const handleEmojiChange = React.useCallback(
async (emoji) => {
popover.hide();
await onChange(emoji ? emoji.native : null);
},
[popover, onChange]
);
const handleClick = React.useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
if (popover.visible) {
popover.hide();
} else {
popover.show();
}
},
[popover]
);
const handleClickOutside = React.useCallback(() => {
// It was observed that onClickOutside got triggered
// even when the picker wasn't open or opened at all.
// Hence, this guard here...
if (popover.visible) {
onClickOutside();
}
}, [popover.visible, onClickOutside]);
// Auto focus search input when picker is opened
React.useLayoutEffect(() => {
if (autoFocus && popover.visible) {
requestAnimationFrame(() => {
const searchInput = pickerRef.current
?.querySelector("em-emoji-picker")
?.shadowRoot?.querySelector(
"input[type=search]"
) as HTMLInputElement | null;
searchInput?.focus();
});
}
}, [autoFocus, popover.visible]);
return (
<>
<PopoverDisclosure {...popover}>
{(props) => (
<EmojiButton
{...props}
className={className}
onClick={handleClick}
icon={
value ? (
<Emoji size={32} align="center" justify="center">
{value}
</Emoji>
) : (
<StyledSmileyIcon size={32} color={theme.textTertiary} />
)
}
neutral
borderOnHover
/>
)}
</PopoverDisclosure>
<PickerPopover
{...popover}
tabIndex={0}
// This prevents picker from closing when any of its
// children are focused, e.g, clicking on search bar or
// a click on skin tone button
onClick={(e) => e.stopPropagation()}
width={352}
aria-label={t("Emoji Picker")}
>
{popover.visible && (
<>
{value && (
<RemoveButton neutral onClick={() => handleEmojiChange(null)}>
{t("Remove")}
</RemoveButton>
)}
<PickerStyles ref={pickerRef}>
<Picker
locale={supportedLocales.includes(locale) ? locale : "en"}
data={data}
onEmojiSelect={handleEmojiChange}
theme={pickerTheme}
previewPosition="none"
perLine={emojisPerLine}
onClickOutside={handleClickOutside}
/>
</PickerStyles>
</>
)}
</PickerPopover>
</>
);
}
const StyledSmileyIcon = styled(SmileyIcon)`
flex-shrink: 0;
@media print {
display: none;
}
`;
const RemoveButton = styled(Button)`
margin-left: -12px;
margin-bottom: 8px;
border-radius: 6px;
height: 24px;
font-size: 13px;
> :first-child {
min-height: unset;
line-height: unset;
}
`;
const PickerPopover = styled(Popover)`
z-index: ${depths.popover};
> :first-child {
padding-top: 8px;
padding-bottom: 0;
max-height: 488px;
overflow: unset;
}
`;
const PickerStyles = styled.div`
margin-left: -24px;
margin-right: -24px;
em-emoji-picker {
--shadow: none;
--font-family: ${s("fontFamily")};
--rgb-background: ${(props) => toRGB(props.theme.menuBackground)};
--rgb-accent: ${(props) => toRGB(props.theme.accent)};
--border-radius: 6px;
margin-left: auto;
margin-right: auto;
min-height: 443px;
}
`;
export default EmojiPicker;
+6 -6
View File
@@ -4,7 +4,7 @@ import * as React from "react";
import { withTranslation, Trans, WithTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { githubIssuesUrl, feedbackUrl } from "@shared/utils/urlHelpers";
import Button from "~/components/Button";
import CenteredContent from "~/components/CenteredContent";
import PageTitle from "~/components/PageTitle";
@@ -57,7 +57,7 @@ class ErrorBoundary extends React.Component<Props> {
};
handleReportBug = () => {
window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github);
window.open(isCloudHosted ? feedbackUrl() : githubIssuesUrl());
};
render() {
@@ -82,7 +82,7 @@ class ErrorBoundary extends React.Component<Props> {
</h1>
</>
)}
<Text as="p" type="secondary">
<Text type="secondary">
<Trans>
Sorry, part of the application failed to load. This may be
because it was updated since you opened the tab or because of a
@@ -106,7 +106,7 @@ class ErrorBoundary extends React.Component<Props> {
</h1>
</>
)}
<Text as="p" type="secondary">
<Text type="secondary">
<Trans
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
values={{
@@ -121,11 +121,11 @@ class ErrorBoundary extends React.Component<Props> {
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
<Trans>Report a bug</Trans>
<Trans>Report a Bug</Trans>
</Button>
) : (
<Button onClick={this.handleShowDetails} neutral>
<Trans>Show detail</Trans>
<Trans>Show Detail</Trans>
</Button>
)}
</p>
-12
View File
@@ -86,18 +86,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
icon = <TrashIcon size={16} />;
meta = t("{{userName}} deleted", opts);
break;
case "documents.add_user":
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: event.user?.name ?? t("a user"),
});
break;
case "documents.restore":
meta = t("{{userName}} moved from trash", opts);
+6 -18
View File
@@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { FileOperationFormat, NotificationEventType } from "@shared/types";
import Collection from "~/models/Collection";
@@ -11,8 +10,7 @@ import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { settingsPath } from "~/utils/routeHelpers";
import useToasts from "~/hooks/useToasts";
type Props = {
collection?: Collection;
@@ -26,6 +24,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
const [includeAttachments, setIncludeAttachments] =
React.useState<boolean>(true);
const user = useCurrentUser();
const { showToast } = useToasts();
const { collections } = useStores();
const { t } = useTranslation();
const appName = env.APP_NAME;
@@ -47,22 +46,11 @@ function ExportDialog({ collection, onSubmit }: Props) {
const handleSubmit = async () => {
if (collection) {
await collection.export(format, includeAttachments);
toast.success(t("Export started"), {
description: t(`Your file will be available in {{ location }} soon`, {
location: `"${t("Settings")} > ${t("Export")}"`,
}),
action: {
label: t("View"),
onClick: () => {
history.push(settingsPath("export"));
},
},
});
} else {
await collections.export(format, includeAttachments);
toast.success(t("Export started"));
}
onSubmit();
showToast(t("Export started"), { type: "success" });
};
const items = [
@@ -95,7 +83,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
return (
<ConfirmationDialog onSubmit={handleSubmit} submitText={t("Export")}>
{collection && (
<Text as="p">
<Text>
<Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take some time."
values={{
@@ -120,7 +108,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
onChange={handleFormatChange}
/>
<div>
<Text as="p" size="small" weight="bold">
<Text size="small" weight="bold">
{item.title}
</Text>
<Text size="small">{item.description}</Text>
@@ -137,7 +125,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
onChange={handleIncludeAttachmentsChange}
/>
<div>
<Text as="p" size="small" weight="bold">
<Text size="small" weight="bold">
{t("Include attachments")}
</Text>
<Text size="small">
+3 -6
View File
@@ -32,12 +32,9 @@ function Facepile({
</span>
</More>
)}
{users
.filter(Boolean)
.slice(0, limit)
.map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
{users.slice(0, limit).map((user) => (
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
))}
</Avatars>
);
}
+17 -18
View File
@@ -16,7 +16,7 @@ type TFilterOption = {
type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
activeKey: string | null | undefined;
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
@@ -25,7 +25,7 @@ type Props = {
const FilterOptions = ({
options,
selectedKeys = [],
activeKey = "",
defaultLabel = "Filter options",
selectedPrefix = "",
className,
@@ -34,22 +34,17 @@ const FilterOptions = ({
const menu = useMenuState({
modal: true,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
);
const selected =
options.find((option) => option.key === activeKey) || options[0];
const selectedLabel = selectedItems.length
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
: "";
const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : "";
return (
<div>
<Wrapper>
<MenuButton {...menu}>
{(props) => (
<StyledButton {...props} className={className} neutral disclosure>
{selectedItems.length ? selectedLabel : defaultLabel}
{activeKey ? selectedLabel : defaultLabel}
</StyledButton>
)}
</MenuButton>
@@ -61,7 +56,7 @@ const FilterOptions = ({
onSelect(option.key);
menu.hide();
}}
selected={selectedKeys.includes(option.key)}
selected={option.key === activeKey}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
@@ -76,16 +71,16 @@ const FilterOptions = ({
</MenuItem>
))}
</ContextMenu>
</div>
</Wrapper>
);
};
const Note = styled(Text)`
display: block;
margin: 2px 0;
margin-top: 2px;
margin-bottom: 0;
line-height: 1.2em;
font-size: 14px;
font-weight: 500;
font-weight: 400;
color: ${s("textTertiary")};
`;
@@ -98,7 +93,7 @@ const LabelWithNote = styled.div`
}
`;
export const StyledButton = styled(Button)`
const StyledButton = styled(Button)`
box-shadow: none;
text-transform: none;
border-color: transparent;
@@ -120,4 +115,8 @@ const Icon = styled.div`
height: 18px;
`;
const Wrapper = styled.div`
margin-right: 8px;
`;
export default FilterOptions;
+36 -1
View File
@@ -1,3 +1,38 @@
import Flex from "@shared/components/Flex";
import { CSSProperties } from "react";
import styled from "styled-components";
type JustifyValues = CSSProperties["justifyContent"];
type AlignValues = CSSProperties["alignItems"];
const Flex = styled.div<{
auto?: boolean;
column?: boolean;
align?: AlignValues;
justify?: JustifyValues;
wrap?: boolean;
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"};
align-items: ${({ align }) => align};
justify-content: ${({ justify }) => justify};
flex-wrap: ${({ wrap }) => (wrap ? "wrap" : "initial")};
flex-shrink: ${({ shrink }) =>
shrink === true ? 1 : shrink === false ? 0 : "initial"};
gap: ${({ gap }) => (gap ? `${gap}px` : "initial")};
min-height: 0;
min-width: 0;
`;
export default Flex;
+3 -5
View File
@@ -1,4 +1,4 @@
import throttle from "lodash/throttle";
import { throttle } from "lodash";
import { observer } from "mobx-react";
import { MenuIcon } from "outline-icons";
import { transparentize } from "polished";
@@ -6,7 +6,6 @@ import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
@@ -15,16 +14,16 @@ import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
import { supportsPassiveListener } from "~/utils/browser";
type Props = {
left?: React.ReactNode;
title: React.ReactNode;
actions?: React.ReactNode;
hasSidebar?: boolean;
className?: string;
};
function Header({ left, title, actions, hasSidebar, className }: Props) {
function Header({ left, title, actions, hasSidebar }: Props) {
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
@@ -55,7 +54,6 @@ function Header({ left, title, actions, hasSidebar, className }: Props) {
<Wrapper
align="center"
shrink={false}
className={className}
$passThrough={passThrough}
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
>
+1 -2
View File
@@ -1,10 +1,9 @@
import styled from "styled-components";
const Heading = styled.h1<{ as?: string; centered?: boolean }>`
const Heading = styled.h1<{ centered?: boolean }>`
display: flex;
align-items: center;
user-select: none;
${(props) => (props.as ? "" : "margin-top: 6vh; font-weight: 600;")}
${(props) => (props.centered ? "text-align: center;" : "")}
`;
+5 -4
View File
@@ -1,7 +1,8 @@
import escapeRegExp from "lodash/escapeRegExp";
import { escapeRegExp } from "lodash";
import * as React from "react";
import replace from "string-replace-to-array";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLSpanElement> & {
highlight: (string | null | undefined) | RegExp;
@@ -43,9 +44,9 @@ function Highlight({
}
export const Mark = styled.mark`
color: inherit;
background: transparent;
font-weight: 600;
background: ${s("searchHighlight")};
border-radius: 2px;
padding: 0 2px;
`;
export default Highlight;
+8 -25
View File
@@ -2,10 +2,9 @@ import { transparentize } from "polished";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import { getTextColor } from "@shared/utils/color";
import Text from "~/components/Text";
export const CARD_MARGIN = 10;
export const CARD_MARGIN = 16;
const NUMBER_OF_LINES = 10;
@@ -18,22 +17,21 @@ const StyledText = styled(Text)`
`;
export const Preview = styled(Link)`
cursor: ${(props: { as?: string }) =>
cursor: ${(props: any) =>
props.as === "div" ? "default" : "var(--pointer)"};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
position: absolute;
width: 375px;
min-width: 350px;
max-width: 375px;
`;
export const Title = styled(Text).attrs({ as: "h2", size: "large" })`
margin-bottom: 4px;
display: flex;
align-items: flex-start;
justify-content: flex-start;
gap: 4px;
export const Title = styled.h2`
font-size: 1.25em;
margin: 0;
color: ${s("text")};
`;
export const Info = styled(StyledText).attrs(() => ({
@@ -48,7 +46,6 @@ export const Description = styled(StyledText)`
margin-top: 0.5em;
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
overflow: hidden;
`;
export const Thumbnail = styled.img`
@@ -57,20 +54,6 @@ export const Thumbnail = styled.img`
background: ${s("menuBackground")};
`;
export const Label = styled(Text).attrs({ size: "xsmall", weight: "bold" })<{
color?: string;
}>`
background-color: ${(props) =>
props.color ?? props.theme.secondaryBackground};
color: ${(props) =>
props.color ? getTextColor(props.color) : props.theme.text};
width: fit-content;
border-radius: 2em;
padding: 0 8px;
margin-right: 0.5em;
margin-top: 0.5em;
`;
export const CardContent = styled.div`
overflow: hidden;
user-select: none;
+132 -179
View File
@@ -2,93 +2,146 @@ import { m } from "framer-motion";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { depths } from "@shared/styles";
import { UnfurlResourceType } from "@shared/types";
import { depths, s } from "@shared/styles";
import { UnfurlType } from "@shared/types";
import LoadingIndicator from "~/components/LoadingIndicator";
import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import LoadingIndicator from "../LoadingIndicator";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewIssue from "./HoverPreviewIssue";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 500;
const POINTER_HEIGHT = 22;
const POINTER_WIDTH = 22;
const DELAY_OPEN = 300;
const DELAY_CLOSE = 600;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
dataLoading: boolean;
/** A callback on close of the hover preview. */
/* The HTML element that is being hovered over */
element: HTMLAnchorElement;
/* A callback on close of the hover preview */
onClose: () => void;
};
enum Direction {
UP,
DOWN,
}
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
function HoverPreviewInternal({ element, onClose }: Props) {
const url = element.href || element.dataset.url;
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement>(null);
const stores = useStores();
const [cardLeft, setCardLeft] = React.useState(0);
const [cardTop, setCardTop] = React.useState(0);
const [pointerOffset, setPointerOffset] = React.useState(0);
React.useLayoutEffect(() => {
if (isVisible && cardRef.current) {
const elem = element.getBoundingClientRect();
const card = cardRef.current.getBoundingClientRect();
const top = elem.bottom + window.scrollY;
setCardTop(top);
let left = elem.left;
let pointerOffset = elem.width / 2;
if (left + card.width > window.innerWidth) {
// shift card leftwards by the amount it went out of screen
let shiftBy = left + card.width - window.innerWidth;
// shift a littler further to leave some margin between card and window boundary
shiftBy += CARD_MARGIN;
left -= shiftBy;
// shift pointer rightwards by same amount so as to position it back correctly
pointerOffset += shiftBy;
}
setCardLeft(left);
setPointerOffset(pointerOffset);
}
}, [isVisible, element]);
const { data, request, loading } = useRequest(
React.useCallback(
() =>
client.post("/urls.unfurl", {
url,
documentId: stores.ui.activeDocumentId,
}),
[url, stores.ui.activeDocumentId]
)
);
React.useEffect(() => {
if (url) {
stopOpenTimer();
setVisible(false);
void request();
}
}, [url, request]);
const stopOpenTimer = () => {
if (timerOpen.current) {
clearTimeout(timerOpen.current);
timerOpen.current = undefined;
}
};
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
if (isVisible) {
stopOpenTimer();
setVisible(false);
onClose();
}
}, []);
}, [isVisible, onClose]);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
const stopCloseTimer = () => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
};
const startOpenTimer = () => {
if (!timerOpen.current) {
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
}
};
const startCloseTimer = React.useCallback(() => {
stopOpenTimer();
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (data) {
startOpenTimer();
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
element.addEventListener("mouseout", startCloseTimer);
element.addEventListener("mouseover", stopCloseTimer);
element.addEventListener("mouseover", startOpenTimer);
}
return () => {
element.removeEventListener("mouseout", startCloseTimer);
element.removeEventListener("mouseover", stopCloseTimer);
element.removeEventListener("mouseover", startOpenTimer);
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
@@ -96,9 +149,9 @@ function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
}, [element, startCloseTimer, data]);
if (dataLoading) {
if (loading) {
return <LoadingIndicator />;
}
@@ -112,51 +165,24 @@ function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
animate={{ opacity: 1, y: 0, pointerEvents: "auto" }}
>
{data.type === UnfurlResourceType.Mention ? (
{data.type === UnfurlType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
url={data.thumbnailUrl}
title={data.title}
info={data.meta.info}
color={data.meta.color}
/>
) : data.type === UnfurlResourceType.Document ? (
) : data.type === UnfurlType.Document ? (
<HoverPreviewDocument
ref={cardRef}
id={data.meta.id}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
info={data.meta.info}
/>
) : (
<HoverPreviewLink
@@ -167,11 +193,7 @@ function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
<Pointer offset={pointerOffset} />
</Animate>
) : null}
</Position>
@@ -179,77 +201,13 @@ function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
);
}
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
function HoverPreview({ element, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
}
return (
<HoverPreviewDesktop
{...rest}
element={element}
data={data}
dataLoading={dataLoading}
/>
);
}
function useHoverPosition({
cardRef,
element,
isVisible,
}: {
cardRef: React.RefObject<HTMLDivElement>;
element: HTMLElement | null;
isVisible: boolean;
}) {
const [cardLeft, setCardLeft] = React.useState(0);
const [cardTop, setCardTop] = React.useState(0);
const [pointerLeft, setPointerLeft] = React.useState(0);
const [pointerTop, setPointerTop] = React.useState(0);
const [pointerDir, setPointerDir] = React.useState(Direction.UP);
React.useLayoutEffect(() => {
if (isVisible && element && cardRef.current) {
const elem = element.getBoundingClientRect();
const card = cardRef.current.getBoundingClientRect();
let cTop = elem.bottom + window.scrollY + CARD_MARGIN;
let pTop = -POINTER_HEIGHT;
let pDir = Direction.UP;
if (cTop + card.height > window.innerHeight + window.scrollY) {
// shift card upwards if it goes out of screen
const bottom = elem.top + window.scrollY;
cTop = bottom - card.height;
// shift a little further to leave some margin between card and element boundary
cTop -= CARD_MARGIN;
// pointer should be shifted downwards to align with card's bottom
pTop = card.height;
pDir = Direction.DOWN;
}
setCardTop(cTop);
setPointerTop(pTop);
setPointerDir(pDir);
let cLeft = elem.left;
let pLeft = elem.width / 2;
if (cLeft + card.width > window.innerWidth) {
// shift card leftwards by the amount it went out of screen
let shiftBy = cLeft + card.width - window.innerWidth;
// shift a little further to leave some margin between card and window boundary
shiftBy += CARD_MARGIN;
cLeft -= shiftBy;
// shift pointer rightwards by same amount so as to position it back correctly
pLeft += shiftBy;
}
setCardLeft(cLeft);
setPointerLeft(pLeft);
}
}, [isVisible, cardRef, element]);
return { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir };
return <HoverPreviewInternal {...rest} element={element} />;
}
const Animate = styled(m.div)`
@@ -259,6 +217,7 @@ const Animate = styled(m.div)`
`;
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
z-index: ${depths.hoverPreview};
display: flex;
@@ -268,11 +227,11 @@ const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
`;
const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
top: ${(props) => props.top}px;
left: ${(props) => props.left}px;
width: ${POINTER_WIDTH}px;
height: ${POINTER_HEIGHT}px;
const Pointer = styled.div<{ offset: number }>`
top: -22px;
left: ${(props) => props.offset}px;
width: 22px;
height: 22px;
position: absolute;
transform: translateX(-50%);
pointer-events: none;
@@ -282,26 +241,20 @@ const Pointer = styled.div<{ top: number; left: number; direction: Direction }>`
content: "";
display: inline-block;
position: absolute;
${({ direction }) => (direction === Direction.UP ? "bottom: 0" : "top: 0")};
${({ direction }) => (direction === Direction.UP ? "right: 0" : "left: 0")};
bottom: 0;
right: 0;
}
&:before {
border: 8px solid transparent;
${({ direction, theme }) =>
direction === Direction.UP
? `border-bottom-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`
: `border-top-color: ${theme.menuBorder || "rgba(0, 0, 0, 0.1)"}`};
${({ direction }) =>
direction === Direction.UP ? "right: -1px" : "left: -1px"};
border-bottom-color: ${(props) =>
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
right: -1px;
}
&:after {
border: 7px solid transparent;
${({ direction, theme }) =>
direction === Direction.UP
? `border-bottom-color: ${theme.menuBackground}`
: `border-top-color: ${theme.menuBackground}`};
border-bottom-color: ${s("menuBackground")};
}
`;
@@ -1,5 +1,4 @@
import * as React from "react";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import {
@@ -11,10 +10,21 @@ import {
Description,
} from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Document], "type">;
type Props = {
/** Document id associated with the editor, if any */
id?: string;
/** Document url */
url: string;
/** Title for the preview card */
title: string;
/** Info about last activity on the document */
info: string;
/** Text preview of document content */
description: string;
};
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
{ url, id, title, summary, lastActivityByViewer }: Props,
{ id, url, title, info, description }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
@@ -23,12 +33,12 @@ const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
<CardContent>
<Flex column gap={2}>
<Title>{title}</Title>
<Info>{lastActivityByViewer}</Info>
<Info>{info}</Info>
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
key={id}
defaultValue={summary}
defaultValue={description}
embedsDisabled
readOnly
/>
@@ -1,65 +0,0 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Flex from "~/components/Flex";
import Avatar from "../Avatar";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Description,
Card,
CardContent,
Label,
Info,
} from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Issue], "type">;
const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
{ url, id, title, description, author, labels, state, createdAt }: Props,
ref: React.Ref<HTMLDivElement>
) {
const authorName = author.name;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={2} column>
<Title>
<IssueStatusIcon status={state.name} color={state.color} />
<span>
{title}&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} created{" "}
<Time dateTime={createdAt} addSuffix />
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
<Flex wrap>
{labels.map((label, index) => (
<Label key={index} color={label.color}>
{label.name}
</Label>
))}
</Flex>
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
export default HoverPreviewIssue;
@@ -26,9 +26,9 @@ const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
) {
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Flex column>
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
<Card>
<Card ref={ref}>
<CardContent>
<Flex column>
<Title>{title}</Title>
@@ -1,14 +1,22 @@
import * as React from "react";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import Flex from "~/components/Flex";
import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.Mention], "type">;
type Props = {
/** Resource url, avatar url in case of user mention */
url: string;
/** Title for the preview card*/
title: string;
/** Info about mentioned user's recent activity */
info: string;
/** Used for avatar's background color in absence of avatar url */
color: string;
};
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
{ avatarUrl, name, lastActive, color }: Props,
{ url, title, info, color }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
@@ -18,15 +26,15 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
<Flex gap={12}>
<Avatar
model={{
avatarUrl,
initial: name ? name[0] : "?",
avatarUrl: url,
initial: title ? title[0] : "?",
color,
}}
size={AvatarSize.XLarge}
/>
<Flex column gap={2} justify="center">
<Title>{name}</Title>
<Info>{lastActive}</Info>
<Title>{title}</Title>
<Info>{info}</Info>
</Flex>
</Flex>
</CardContent>
@@ -1,58 +0,0 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Flex from "~/components/Flex";
import Avatar from "../Avatar";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
import {
Preview,
Title,
Description,
Card,
CardContent,
Info,
} from "./Components";
type Props = Omit<UnfurlResponse[UnfurlResourceType.PR], "type">;
const HoverPreviewPullRequest = React.forwardRef(
function _HoverPreviewPullRequest(
{ url, title, id, description, author, state, createdAt }: Props,
ref: React.Ref<HTMLDivElement>
) {
const authorName = author.name;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column ref={ref}>
<Card fadeOut={false}>
<CardContent>
<Flex gap={2} column>
<Title>
<PullRequestIcon status={state.name} color={state.color} />
<span>
{title}&nbsp;<Text type="tertiary">{id}</Text>
</span>
</Title>
<Flex align="center" gap={4}>
<Avatar src={author.avatarUrl} />
<Info>
<Trans>
{{ authorName }} opened{" "}
<Time dateTime={createdAt} addSuffix />
</Trans>
</Info>
</Flex>
<Description>{description}</Description>
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
}
);
export default HoverPreviewPullRequest;
+293 -155
View File
@@ -1,201 +1,334 @@
import {
BookmarkedIcon,
BicycleIcon,
CollectionIcon,
CoinsIcon,
AcademicCapIcon,
BeakerIcon,
BuildingBlocksIcon,
CameraIcon,
CloudIcon,
CodeIcon,
EditIcon,
EmailIcon,
EyeIcon,
GlobeIcon,
InfoIcon,
ImageIcon,
LeafIcon,
LightBulbIcon,
MathIcon,
MoonIcon,
NotepadIcon,
PadlockIcon,
PaletteIcon,
PromoteIcon,
QuestionMarkIcon,
SportIcon,
SunIcon,
TargetIcon,
TerminalIcon,
ToolsIcon,
VehicleIcon,
WarningIcon,
DatabaseIcon,
SmileyIcon,
LightningIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import { MenuItem } from "reakit/Menu";
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import { IconLibrary } from "@shared/utils/IconLibrary";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
import ContextMenu from "~/components/ContextMenu";
import Flex from "~/components/Flex";
import { LabelText } from "~/components/Input";
import NudeButton from "~/components/NudeButton";
import Text from "~/components/Text";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import lazyWithRetry from "~/utils/lazyWithRetry";
import DelayedMount from "./DelayedMount";
import InputSearch from "./InputSearch";
import Popover from "./Popover";
const icons = IconLibrary.mapping;
const style = {
width: 30,
height: 30,
};
const TwitterPicker = lazyWithRetry(
() => import("react-color/lib/components/twitter/Twitter")
);
export const icons = {
academicCap: {
component: AcademicCapIcon,
keywords: "learn teach lesson guide tutorial onboarding training",
},
bicycle: {
component: BicycleIcon,
keywords: "bicycle bike cycle",
},
beaker: {
component: BeakerIcon,
keywords: "lab research experiment test",
},
buildingBlocks: {
component: BuildingBlocksIcon,
keywords: "app blocks product prototype",
},
bookmark: {
component: BookmarkedIcon,
keywords: "bookmark",
},
collection: {
component: CollectionIcon,
keywords: "collection",
},
coins: {
component: CoinsIcon,
keywords: "coins money finance sales income revenue cash",
},
camera: {
component: CameraIcon,
keywords: "photo picture",
},
cloud: {
component: CloudIcon,
keywords: "cloud service aws infrastructure",
},
code: {
component: CodeIcon,
keywords: "developer api code development engineering programming",
},
database: {
component: DatabaseIcon,
keywords: "server ops database",
},
email: {
component: EmailIcon,
keywords: "email at",
},
eye: {
component: EyeIcon,
keywords: "eye view",
},
globe: {
component: GlobeIcon,
keywords: "world translate",
},
info: {
component: InfoIcon,
keywords: "info information",
},
image: {
component: ImageIcon,
keywords: "image photo picture",
},
leaf: {
component: LeafIcon,
keywords: "leaf plant outdoors nature ecosystem climate",
},
lightbulb: {
component: LightBulbIcon,
keywords: "lightbulb idea",
},
lightning: {
component: LightningIcon,
keywords: "lightning fast zap",
},
math: {
component: MathIcon,
keywords: "math formula",
},
moon: {
component: MoonIcon,
keywords: "night moon dark",
},
notepad: {
component: NotepadIcon,
keywords: "journal notepad write notes",
},
padlock: {
component: PadlockIcon,
keywords: "padlock private security authentication authorization auth",
},
palette: {
component: PaletteIcon,
keywords: "design palette art brand",
},
pencil: {
component: EditIcon,
keywords: "copy writing post blog",
},
promote: {
component: PromoteIcon,
keywords: "marketing promotion",
},
question: {
component: QuestionMarkIcon,
keywords: "question help support faq",
},
sun: {
component: SunIcon,
keywords: "day sun weather",
},
sport: {
component: SportIcon,
keywords: "sport outdoor racket game",
},
smiley: {
component: SmileyIcon,
keywords: "emoji smiley happy",
},
target: {
component: TargetIcon,
keywords: "target goal sales",
},
terminal: {
component: TerminalIcon,
keywords: "terminal code",
},
tools: {
component: ToolsIcon,
keywords: "tool settings",
},
vehicle: {
component: VehicleIcon,
keywords: "truck car travel transport",
},
warning: {
component: WarningIcon,
keywords: "warning alert error",
},
};
type Props = {
onOpen?: () => void;
onClose?: () => void;
onChange: (color: string, icon: string) => void;
initial: string;
icon: string;
color: string;
className?: string;
};
function IconPicker({
onOpen,
onClose,
icon,
initial,
color,
onChange,
className,
}: Props) {
const [query, setQuery] = React.useState("");
function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
const { t } = useTranslation();
const theme = useTheme();
const popover = usePopoverState({
gutter: 0,
placement: "right",
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
React.useEffect(() => {
if (popover.visible) {
onOpen?.();
} else {
onClose?.();
setQuery("");
}
}, [onOpen, onClose, popover.visible]);
const filteredIcons = IconLibrary.findIcons(query);
const handleFilter = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value.toLowerCase());
};
const styles = React.useMemo(
() => ({
default: {
body: {
padding: 0,
marginRight: -8,
},
hash: {
color: theme.text,
background: theme.inputBorder,
},
swatch: {
cursor: "var(--cursor-pointer)",
},
input: {
color: theme.text,
boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`,
background: "transparent",
},
},
}),
[theme]
);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
popover.unstable_popoverRef,
(event) => {
if (popover.visible) {
event.stopPropagation();
event.preventDefault();
popover.hide();
}
},
{ capture: true }
);
const iconNames = Object.keys(icons);
const delayPerIcon = 250 / iconNames.length;
return (
<>
<PopoverDisclosure {...popover}>
<Wrapper>
<Label>
<LabelText>{t("Icon")}</LabelText>
</Label>
<MenuButton {...menu}>
{(props) => (
<NudeButton
aria-label={t("Show menu")}
className={className}
{...props}
>
<Button aria-label={t("Show menu")} {...props}>
<Icon
as={IconLibrary.getComponent(icon || "collection")}
as={icons[icon || "collection"].component}
color={color}
>
{initial}
</Icon>
</NudeButton>
size={30}
/>
</Button>
)}
</PopoverDisclosure>
<Popover
{...popover}
width={552}
aria-label={t("Choose an icon")}
hideOnClickOutside={false}
</MenuButton>
<ContextMenu
{...menu}
onOpen={onOpen}
onClose={onClose}
aria-label={t("Choose icon")}
>
<Flex column gap={12}>
<Text size="large" weight="xbold">
{t("Choose an icon")}
</Text>
<InputSearch
value={query}
placeholder={`${t("Filter")}`}
onChange={handleFilter}
autoFocus
/>
<div>
{iconNames.map((name, index) => (
<MenuItem key={name} onClick={() => onChange(color, name)}>
{(props) => (
<IconButton
style={
{
opacity: query
? filteredIcons.includes(name)
? 1
: 0.3
: undefined,
"--delay": `${Math.round(index * delayPerIcon)}ms`,
} as React.CSSProperties
}
{...props}
>
<Icon
as={IconLibrary.getComponent(name)}
color={color}
size={30}
>
{initial}
</Icon>
</IconButton>
)}
</MenuItem>
))}
</div>
<Flex>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
<Icons>
{Object.keys(icons).map((name, index) => (
<MenuItem
key={name}
onClick={() => onChange(color, name)}
{...menu}
>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
triangle="hide"
styles={styles}
/>
</React.Suspense>
</Flex>
</Flex>
</Popover>
</>
{(props) => (
<IconButton
style={
{
...style,
"--delay": `${index * 8}ms`,
} as React.CSSProperties
}
{...props}
>
<Icon as={icons[name].component} color={color} size={30} />
</IconButton>
)}
</MenuItem>
))}
</Icons>
<Colors>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
triangle="hide"
styles={{
default: {
body: {
padding: 0,
marginRight: -8,
},
hash: {
color: theme.text,
background: theme.inputBorder,
},
input: {
color: theme.text,
boxShadow: `inset 0 0 0 1px ${theme.inputBorder}`,
background: "transparent",
},
},
}}
/>
</React.Suspense>
</Colors>
</ContextMenu>
</Wrapper>
);
}
const Icon = styled.svg`
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition: fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
const Colors = styled(Flex)`
padding: 8px;
`;
const Label = styled.label`
display: block;
`;
const Icons = styled.div`
padding: 8px;
${breakpoint("tablet")`
width: 276px;
`};
`;
const Button = styled(NudeButton)`
border: 1px solid ${s("inputBorder")};
width: 32px;
height: 32px;
`;
const IconButton = styled(NudeButton)`
vertical-align: top;
border-radius: 4px;
margin: 0px 6px 6px 0px;
width: 30px;
@@ -208,4 +341,9 @@ const ColorPicker = styled(TwitterPicker)`
width: 100% !important;
`;
const Wrapper = styled("div")`
display: inline-block;
position: relative;
`;
export default IconPicker;
+3 -7
View File
@@ -2,8 +2,8 @@ import { observer } from "mobx-react";
import { CollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import { IconLibrary } from "@shared/utils/IconLibrary";
import Collection from "~/models/Collection";
import { icons } from "~/components/IconPicker";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
@@ -38,12 +38,8 @@ function ResolvedCollectionIcon({
if (collection.icon && collection.icon !== "collection") {
try {
const Component = IconLibrary.getComponent(collection.icon);
return (
<Component color={color} size={size}>
{collection.initial}
</Component>
);
const Component = icons[collection.icon].component;
return <Component color={color} size={size} />;
} catch (error) {
Logger.warn("Failed to render custom icon", {
icon: collection.icon,
+3 -3
View File
@@ -2,9 +2,9 @@ import * as React from "react";
import styled from "styled-components";
type Props = {
/** The emoji to render */
/* The emoji to render */
emoji: string;
/** The size of the emoji, 24px is default to match standard icons */
/* The size of the emoji, 24px is default to match standard icons */
size?: number;
};
@@ -29,5 +29,5 @@ const Span = styled.span<{ $size: number }>`
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
text-indent: -0.15em;
font-size: ${(props) => props.$size - 10}px;
font-size: 14px;
`;
-74
View File
@@ -1,74 +0,0 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub issue status, but can be used for any git-style integration.
*/
export function IssueStatusIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l3.5-3.5Z" />
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
</svg>
);
case "canceled":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm9.78-2.22-5.5 5.5a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l5.5-5.5a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />
</svg>
);
default:
return null;
}
}
-32
View File
@@ -1,32 +0,0 @@
import * as React from "react";
export function LanguageIcon({ className }: { className?: string }) {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21 18H16L14 16V6C14 4.89543 14.8954 4 16 4H28C29.1046 4 30 4.89543 30 6V16C30 17.1046 29.1046 18 28 18H27L25.4142 19.5858C24.6332 20.3668 23.3668 20.3668 22.5858 19.5858L21 18ZM16 15.1716V6H28V16H27H26.1716L25.5858 16.5858L24 18.1716L22.4142 16.5858L21.8284 16H21H16.8284L16 15.1716Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16 13H4C2.89543 13 2 13.8954 2 15V25C2 26.1046 2.89543 27 4 27H5L6.58579 28.5858C7.36684 29.3668 8.63316 29.3668 9.41421 28.5858L11 27H16C17.1046 27 18 26.1046 18 25V15C18 13.8954 17.1046 13 16 13ZM9 17L6 16.9681C6 16.9681 5 17.016 5 18C5 18.984 6 19 6 19H8.5H10C10 19 9.57627 20.1885 8.38983 21.0831C7.20339 21.9777 5.7197 23 5.7197 23C5.7197 23 4.99153 23.6054 5.5 24.5C6.00847 25.3946 7 24.8403 7 24.8403L9.74576 22.8722L11.9492 24.6614C11.9492 24.6614 12.6271 25.3771 13.3051 24.4825C13.9831 23.5879 13.3051 23.0512 13.3051 23.0512L11.1017 21.262C11.1017 21.262 11.5 21 12 20L12.5 19H14C14 19 15 19.0319 15 18C15 16.9681 14 16.9681 14 16.9681L11 17V16C11 16 11.0169 15 10 15C8.98305 15 9 16 9 16V17Z"
fill="currentColor"
/>
<path
d="M23.6672 12.5221L23.5526 12.1816H23.1934H20.8818H20.5215L20.4075 12.5235L20.082 13.5H19.2196L21.2292 8.10156H21.8774L21.5587 9.06116L20.7633 11.4562L20.5449 12.1138H21.2378H22.8374H23.5327L23.3114 11.4546L22.5072 9.05959L22.1855 8.10156H22.768L24.7887 13.5H23.9964L23.6672 12.5221Z"
fill="currentColor"
stroke="currentColor"
/>
</svg>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ type Props = {
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
/** Whether the safe area should be removed and have graphic across full size */
/* Whether the safe area should be removed and have graphic across full size */
cover?: boolean;
};
-72
View File
@@ -1,72 +0,0 @@
import * as React from "react";
import styled from "styled-components";
type Props = {
status: string;
color: string;
size?: number;
className?: string;
};
/**
* Issue status icon based on GitHub pull requests, but can be used for any git-style integration.
*/
export function PullRequestIcon({ size, ...rest }: Props) {
return (
<Icon size={size}>
<BaseIcon {...rest} />
</Icon>
);
}
const Icon = styled.span<{ size?: number }>`
display: inline-flex;
flex-shrink: 0;
width: ${(props) => props.size ?? 24}px;
height: ${(props) => props.size ?? 24}px;
align-items: center;
justify-content: center;
`;
function BaseIcon(props: Props) {
switch (props.status) {
case "open":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
</svg>
);
case "merged":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />
</svg>
);
case "closed":
return (
<svg
viewBox="0 0 16 16"
width="16"
height="16"
fill={props.color}
className={props.className}
>
<path d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
</svg>
);
default:
return null;
}
}
+20 -51
View File
@@ -1,5 +1,4 @@
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -8,14 +7,10 @@ import Flex from "~/components/Flex";
import Text from "~/components/Text";
import { undraggableOnDesktop } from "~/styles";
export const NativeTextarea = styled.textarea<{
hasIcon?: boolean;
hasPrefix?: boolean;
}>`
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
border: 0;
flex: 1;
padding: 8px 12px 8px
${(props) => (props.hasPrefix ? 0 : props.hasIcon ? "8px" : "12px")};
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
outline: none;
background: none;
color: ${s("text")};
@@ -23,18 +18,13 @@ export const NativeTextarea = styled.textarea<{
&:disabled,
&::placeholder {
color: ${s("placeholder")};
opacity: 1;
}
`;
export const NativeInput = styled.input<{
hasIcon?: boolean;
hasPrefix?: boolean;
}>`
const RealInput = styled.input<{ hasIcon?: boolean }>`
border: 0;
flex: 1;
padding: 8px 12px 8px
${(props) => (props.hasPrefix ? 0 : props.hasIcon ? "8px" : "12px")};
padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")};
outline: none;
background: none;
color: ${s("text")};
@@ -48,7 +38,6 @@ export const NativeInput = styled.input<{
&:disabled,
&::placeholder {
color: ${s("placeholder")};
opacity: 1;
}
&:-webkit-autofill,
@@ -66,7 +55,7 @@ export const NativeInput = styled.input<{
`};
`;
export const Wrapper = styled.div<{
const Wrapper = styled.div<{
flex?: boolean;
short?: boolean;
minHeight?: number;
@@ -121,11 +110,9 @@ export const LabelText = styled.div`
display: inline-block;
`;
export interface Props
extends Omit<
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
export type Props = React.InputHTMLAttributes<
HTMLInputElement | HTMLTextAreaElement
> & {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
labelHidden?: boolean;
label?: string;
@@ -133,25 +120,19 @@ export interface Props
short?: boolean;
margin?: string | number;
error?: string;
/** Optional component that appears inside the input before the textarea and any icon */
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
icon?: React.ReactNode;
/** Like autoFocus, but also select any text in the input */
autoSelect?: boolean;
/** Callback is triggered with the CMD+Enter keyboard combo */
/* Callback is triggered with the CMD+Enter keyboard combo */
onRequestSubmit?: (
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
) => unknown;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
}
};
function Input(
props: Props,
ref: React.RefObject<HTMLInputElement | HTMLTextAreaElement>
) {
const internalRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>();
const [focused, setFocused] = React.useState(false);
const handleBlur = (ev: React.SyntheticEvent) => {
@@ -184,12 +165,6 @@ function Input(
}
};
React.useEffect(() => {
if (props.autoSelect && internalRef.current) {
internalRef.current.select();
}
}, [props.autoSelect, internalRef]);
const {
type = "text",
icon,
@@ -199,7 +174,6 @@ function Input(
className,
short,
flex,
prefix,
labelHidden,
onFocus,
onBlur,
@@ -220,32 +194,23 @@ function Input(
wrappedLabel
))}
<Outline focused={focused} margin={margin}>
{prefix}
{icon && <IconWrapper>{icon}</IconWrapper>}
{type === "textarea" ? (
<NativeTextarea
ref={mergeRefs([
internalRef,
ref as React.RefObject<HTMLTextAreaElement>,
])}
<RealTextarea
ref={ref as React.RefObject<HTMLTextAreaElement>}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
{...rest}
/>
) : (
<NativeInput
ref={mergeRefs([
internalRef,
ref as React.RefObject<HTMLInputElement>,
])}
<RealInput
ref={ref as React.RefObject<HTMLInputElement>}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
hasIcon={!!icon}
hasPrefix={!!prefix}
type={type}
{...rest}
/>
@@ -255,9 +220,9 @@ function Input(
</label>
{error && (
<TextWrapper>
<Text type="danger" size="xsmall">
<StyledText type="danger" size="xsmall">
{error}
</Text>
</StyledText>
</TextWrapper>
)}
</Wrapper>
@@ -270,4 +235,8 @@ export const TextWrapper = styled.span`
margin-top: -16px;
`;
export const StyledText = styled(Text)`
margin-bottom: 0;
`;
export default React.forwardRef(Input);
+63
View File
@@ -0,0 +1,63 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans } from "react-i18next";
import styled from "styled-components";
import Editor from "~/components/Editor";
import { LabelText, Outline } from "~/components/Input";
import Text from "~/components/Text";
type Props = {
label: string;
minHeight?: number;
maxHeight?: number;
readOnly?: boolean;
};
function InputRich({ label, minHeight, maxHeight, ...rest }: Props) {
const [focused, setFocused] = React.useState<boolean>(false);
const handleBlur = React.useCallback(() => {
setFocused(false);
}, []);
const handleFocus = React.useCallback(() => {
setFocused(true);
}, []);
return (
<>
<LabelText>{label}</LabelText>
<StyledOutline
maxHeight={maxHeight}
minHeight={minHeight}
focused={focused}
>
<React.Suspense
fallback={
<Text type="secondary">
<Trans>Loading editor</Trans>
</Text>
}
>
<Editor onBlur={handleBlur} onFocus={handleFocus} grow {...rest} />
</React.Suspense>
</StyledOutline>
</>
);
}
const StyledOutline = styled(Outline)<{
minHeight?: number;
maxHeight?: number;
focused?: boolean;
}>`
display: block;
padding: 8px 12px;
min-height: ${({ minHeight }) => (minHeight ? `${minHeight}px` : "0")};
max-height: ${({ maxHeight }) => (maxHeight ? `${maxHeight}px` : "auto")};
overflow-y: auto;
> * {
display: block;
}
`;
export default observer(InputRich);
+8 -24
View File
@@ -7,9 +7,7 @@ import Input, { Props as InputProps } from "~/components/Input";
type Props = InputProps & {
placeholder?: string;
value?: string;
onBlur?: (event: React.FocusEvent<HTMLInputElement>) => unknown;
onFocus?: (event: React.FocusEvent<HTMLInputElement>) => unknown;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => unknown;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => unknown;
};
@@ -20,29 +18,15 @@ function InputSearch(
const { t } = useTranslation();
const theme = useTheme();
const [isFocused, setIsFocused] = React.useState(false);
const {
placeholder = `${t("Search")}`,
onKeyDown,
onBlur,
onFocus,
...rest
} = props;
const handleFocus = React.useCallback(() => {
setIsFocused(true);
}, []);
const handleFocus = React.useCallback(
(event) => {
setIsFocused(true);
onFocus?.(event);
},
[onFocus]
);
const handleBlur = React.useCallback(() => {
setIsFocused(false);
}, []);
const handleBlur = React.useCallback(
(event) => {
setIsFocused(false);
onBlur?.(event);
},
[onBlur]
);
const { placeholder = `${t("Search")}`, onKeyDown, ...rest } = props;
return (
<Input
+29 -96
View File
@@ -14,7 +14,6 @@ import Button, { Inner } from "~/components/Button";
import Text from "~/components/Text";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import { fadeAndScaleIn } from "~/styles/animations";
import {
Position,
@@ -28,7 +27,6 @@ import { LabelText } from "./Input";
export type Option = {
label: string | JSX.Element;
value: string;
description?: string;
};
export type Props = {
@@ -44,25 +42,14 @@ export type Props = {
labelHidden?: boolean;
icon?: React.ReactNode;
options: Option[];
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
onChange?: (value: string | null) => void;
};
export interface InputSelectRef {
value: string | null;
focus: () => void;
blur: () => void;
}
interface InnerProps extends React.HTMLAttributes<HTMLDivElement> {
placement: Placement;
}
const getOptionFromValue = (options: Option[], value: string | null) =>
options.find((option) => option.value === value);
const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const InputSelect = (props: Props) => {
const {
value = null,
label,
@@ -75,7 +62,6 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
disabled,
note,
icon,
nude,
...rest
} = props;
@@ -85,9 +71,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
selectedValue: value,
});
const popover = useSelectPopover({
const popOver = useSelectPopover({
...select,
hideOnClickOutside: false,
hideOnClickOutside: true,
preventBodyScroll: true,
disabled,
});
@@ -113,45 +99,9 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const wrappedLabel = <LabelText>{label}</LabelText>;
const selectedValueIndex = options.findIndex(
(opt) => opt.value === select.selectedValue
(option) => option.value === select.selectedValue
);
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
// prevent event bubbling.
useOnClickOutside(
contentRef,
(event) => {
if (buttonRef.current?.contains(event.target as Node)) {
return;
}
if (select.visible) {
event.stopPropagation();
event.preventDefault();
select.hide();
}
},
{ capture: true }
);
React.useImperativeHandle(ref, () => ({
focus: () => {
buttonRef.current?.focus();
},
blur: () => {
buttonRef.current?.blur();
},
value: select.selectedValue,
}));
React.useEffect(() => {
previousValue.current = value;
// Update the selected value if it changes from the outside both of these lines are needed
// for correct functioning
select.selectedValue = value;
select.setSelectedValue(value);
}, [value]);
React.useEffect(() => {
if (previousValue.current === select.selectedValue) {
return;
@@ -171,24 +121,6 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
}
}, [select.visible, selectedValueIndex]);
function labelForOption(opt: Option) {
return (
<>
{opt.label}
{opt.description && (
<>
&nbsp;
<Text as="span" type="tertiary" size="small" ellipsis>
{opt.description}
</Text>
</>
)}
</>
);
}
const option = getOptionFromValue(options, select.selectedValue);
return (
<>
<Wrapper short={short}>
@@ -200,37 +132,37 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
))}
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
{(buttonProps) => (
{(props) => (
<StyledButton
neutral
disclosure
className={className}
icon={icon}
$nude={nude}
{...buttonProps}
{...props}
>
{option ? (
labelForOption(option)
) : (
{getOptionFromValue(options, select.selectedValue)?.label || (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover {...select} {...popover} aria-label={ariaLabel}>
{(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end";
<SelectPopover {...select} {...popOver} aria-label={ariaLabel}>
{(
props: React.HTMLAttributes<HTMLDivElement> & {
placement: Placement;
}
) => {
const topAnchor = props.style?.top === "0";
const rightAnchor = props.placement === "bottom-end";
return (
<Positioner {...popoverProps}>
<Positioner {...props}>
<Background
dir="auto"
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
hiddenScrollbars
maxWidth={400}
style={
maxHeight && topAnchor
? {
@@ -243,19 +175,20 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
}
>
{select.visible
? options.map((opt) => {
const isSelected = select.selectedValue === opt.value;
? options.map((option) => {
const isSelected =
select.selectedValue === option.value;
const Icon = isSelected ? CheckmarkIcon : Spacer;
return (
<StyledSelectOption
{...select}
value={opt.value}
key={opt.value}
value={option.value}
key={option.value}
ref={isSelected ? selectedRef : undefined}
>
<Icon />
&nbsp;
{labelForOption(opt)}
{option.label}
</StyledSelectOption>
);
})
@@ -267,7 +200,7 @@ const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
</SelectPopover>
</Wrapper>
{note && (
<Text as="p" type="secondary" size="small">
<Text type="secondary" size="small">
{note}
</Text>
)}
@@ -290,7 +223,7 @@ const Spacer = styled.div`
flex-shrink: 0;
`;
const StyledButton = styled(Button)<{ $nude?: boolean }>`
const StyledButton = styled(Button)<{ nude?: boolean }>`
font-weight: normal;
text-transform: none;
margin-bottom: 16px;
@@ -303,7 +236,7 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
}
${(props) =>
props.$nude &&
props.nude &&
css`
border-color: transparent;
box-shadow: none;
@@ -311,8 +244,8 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
${Inner} {
line-height: 28px;
padding-left: 12px;
padding-right: 4px;
padding-left: 16px;
padding-right: 8px;
}
svg {
@@ -334,7 +267,7 @@ const Wrapper = styled.label<{ short?: boolean }>`
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
export const Positioner = styled(Position)`
const Positioner = styled(Position)`
&.focus-visible {
${StyledSelectOption} {
&[aria-selected="true"] {
@@ -351,4 +284,4 @@ export const Positioner = styled(Position)`
}
`;
export default React.forwardRef(InputSelect);
export default InputSelect;
+12 -13
View File
@@ -2,35 +2,36 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import { CollectionPermission } from "@shared/types";
import { EmptySelectValue } from "~/types";
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
import InputSelect, { Props, Option } from "./InputSelect";
function InputSelectPermission(
export default function InputSelectPermission(
props: $Diff<
Props,
{
options: Array<Option>;
ariaLabel: string;
}
>,
ref: React.RefObject<InputSelectRef>
>
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const handleChange = React.useCallback(
(value) => {
onChange?.(value === EmptySelectValue ? null : value);
if (value === "no_access") {
value = "";
}
onChange?.(value);
},
[onChange]
);
return (
<InputSelect
ref={ref}
label={t("Permission")}
label={t("Default access")}
options={[
{
label: t("Can edit"),
label: t("View and edit"),
value: CollectionPermission.ReadWrite,
},
{
@@ -39,15 +40,13 @@ function InputSelectPermission(
},
{
label: t("No access"),
value: EmptySelectValue,
value: "no_access",
},
]}
ariaLabel={t("Default access")}
value={value || EmptySelectValue}
value={value || "no_access"}
onChange={handleChange}
{...rest}
/>
);
}
export default React.forwardRef(InputSelectPermission);
+39
View File
@@ -0,0 +1,39 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { $Diff } from "utility-types";
import InputSelect, { Props, Option } from "~/components/InputSelect";
const InputSelectRole = (
props: $Diff<
Props,
{
options: Array<Option>;
ariaLabel: string;
}
>
) => {
const { t } = useTranslation();
return (
<InputSelect
label={t("Role")}
options={[
{
label: t("Member"),
value: "member",
},
{
label: t("Viewer"),
value: "viewer",
},
{
label: t("Admin"),
value: "admin",
},
]}
ariaLabel={t("Role")}
{...props}
/>
);
};
export default InputSelectRole;

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