diff --git a/.babelrc b/.babelrc index c0fb394563ff..de6335c5e908 100644 --- a/.babelrc +++ b/.babelrc @@ -1,29 +1,28 @@ { "presets": [ "@babel/preset-react", - "@babel/preset-typescript", - [ - "@babel/preset-env", - { - "corejs": { - "version": "3", - "proposals": true - }, - "useBuiltIns": "usage" - } - ] + "@babel/preset-env", + "@babel/preset-typescript" ], "plugins": [ - "styled-components", + "babel-plugin-transform-typescript-metadata", [ "@babel/plugin-proposal-decorators", { "legacy": true } ], - "@babel/plugin-transform-destructuring", - "@babel/plugin-transform-regenerator", - "transform-class-properties" + "@babel/plugin-transform-class-properties", + [ + "transform-inline-environment-variables", + { + "include": [ + "SOURCE_COMMIT", + "SOURCE_VERSION" + ] + } + ], + "tsconfig-paths-module-resolver" ], "env": { "production": { @@ -36,13 +35,29 @@ ] ], "ignore": [ + "**/__mocks__", "**/*.test.ts" ] }, "development": { "ignore": [ + "**/__mocks__", "**/*.test.ts" ] + }, + "test": { + "presets": [ + [ + "@babel/preset-env", + { + "corejs": { + "version": "3", + "proposals": true + }, + "useBuiltIns": "usage" + } + ] + ] } } -} \ No newline at end of file +} diff --git a/.circleci/config.yml b/.circleci/config.yml index 6a7663e84a00..edba1a5a38b7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,13 +13,8 @@ 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: @@ -89,11 +84,11 @@ jobs: key: dependency-cache-v1-{{ checksum "package.json" }} - run: name: migrate - command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST + command: ./node_modules/.bin/sequelize db:migrate - run: name: test command: | - TESTFILES=$(circleci tests glob "server/**/*.test.ts" | circleci tests split) + TESTFILES=$(circleci tests glob "**/server/**/*.test.ts" | circleci tests split) yarn test --maxWorkers=2 $TESTFILES bundle-size: <<: *defaults @@ -113,8 +108,7 @@ jobs: executor: docker-publisher steps: - checkout - - setup_remote_docker: - version: 20.10.6 + - setup_remote_docker - run: name: Install Docker buildx command: | @@ -131,7 +125,7 @@ jobs: docker buildx install docker context create docker-multiarch docker run --rm --privileged multiarch/qemu-user-static --reset -p yes - docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch + docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch docker buildx inspect --builder docker-multiarch --bootstrap docker buildx use docker-multiarch - run: @@ -147,9 +141,9 @@ jobs: 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 . + docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,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 . + docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push . fi workflows: diff --git a/.env.development b/.env.development new file mode 100644 index 000000000000..2edaa9d124dc --- /dev/null +++ b/.env.development @@ -0,0 +1,10 @@ +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 diff --git a/.env.sample b/.env.sample index 8ebec5632599..eb57ad85c6f7 100644 --- a/.env.sample +++ b/.env.sample @@ -13,7 +13,6 @@ 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 @@ -30,30 +29,13 @@ 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=https://app.outline.dev:3000 +URL= PORT=3000 # See [documentation](docs/SERVICES.md) on running a separate collaboration # server, for normal operation this does not need to be set. COLLABORATION_URL= -# To support uploading of images for avatars and document attachments an -# s3-compatible storage must be provided. AWS S3 is recommended for redundancy -# however if you want to keep all file storage local an alternative such as -# minio (https://github.com/minio/minio) can be used. - -# A more detailed guide on setting up S3 is available here: -# => 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_FORCE_PATH_STYLE=true -AWS_S3_ACL=private - # 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 @@ -64,7 +46,26 @@ FILE_STORAGE=local FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data # Maximum allowed size for the uploaded attachment. -FILE_STORAGE_UPLOAD_MAX_SIZE=26214400 +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. +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_FORCE_PATH_STYLE=true +AWS_S3_ACL=private # –––––––––––––– AUTHENTICATION –––––––––––––– @@ -103,6 +104,7 @@ 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 @@ -114,6 +116,36 @@ 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:///api/github.callback +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_APP_NAME= +GITHUB_APP_ID= +GITHUB_APP_PRIVATE_KEY= + +# To configure Discord auth, you'll need to create a Discord Application at +# => https://discord.com/developers/applications/ +# +# When configuring the Client ID, add a redirect URL under "OAuth2": +# https:///auth/discord.callback +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= + +# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is +# integrated with. +# Used to verify that the user is a member of the server as well as server +# metadata such as nicknames, server icon and name. +DISCORD_SERVER_ID= + +# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are +# allowed to access Outline. If this is not set, all members of the server +# will be allowed to access Outline. +# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together. +DISCORD_SERVER_ROLES= # –––––––––––––––– OPTIONAL –––––––––––––––– @@ -141,10 +173,6 @@ 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 @@ -161,8 +189,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= +# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup +# and do not forget to whitelist your domain name in the app settings +DROPBOX_APP_KEY= # Optionally enable Sentry (sentry.io) to track errors and performance, # and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI: @@ -176,8 +205,8 @@ SMTP_HOST= SMTP_PORT= SMTP_USERNAME= SMTP_PASSWORD= -SMTP_FROM_EMAIL=hello@example.com -SMTP_REPLY_EMAIL=hello@example.com +SMTP_FROM_EMAIL= +SMTP_REPLY_EMAIL= SMTP_TLS_CIPHERS= SMTP_SECURE=true @@ -193,10 +222,5 @@ RATE_LIMITER_REQUESTS=1000 RATE_LIMITER_DURATION_WINDOW=60 # Iframely API config -# IFRAMELY_URL= -# IFRAMELY_API_KEY= - -# 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=false +IFRAMELY_URL= +IFRAMELY_API_KEY= diff --git a/.env.test b/.env.test new file mode 100644 index 000000000000..ceee6d86a5a6 --- /dev/null +++ b/.env.test @@ -0,0 +1,31 @@ +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 diff --git a/.eslintrc b/.eslintrc index e02b839b6da1..fcc8e7b8f7a9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -32,11 +32,21 @@ "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", + { + "allow": ["transaction"], + "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": [ @@ -130,4 +140,4 @@ "typescript": {} } } -} \ No newline at end of file +} diff --git a/.github/no-response.yml b/.github/no-response.yml index 1e45cc85a84a..a40b35c63d62 100644 --- a/.github/no-response.yml +++ b/.github/no-response.yml @@ -1,7 +1,7 @@ # Configuration for probot-no-response - https://github.com/probot/no-response # Number of days of inactivity before an Issue is closed for lack of response -daysUntilClose: 14 +daysUntilClose: 7 # Label requiring a response responseRequiredLabel: more information needed diff --git a/.gitignore b/.gitignore index 84aa962b3c36..83dc8ad390aa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ dist build node_modules/* .env +.env.local +.env.production .log .vscode/* npm-debug.log diff --git a/.jestconfig.json b/.jestconfig.json index 89207fab6b77..c45e43e75ea7 100644 --- a/.jestconfig.json +++ b/.jestconfig.json @@ -7,9 +7,10 @@ "roots": ["/server", "/plugins"], "moduleNameMapper": { "^@server/(.*)$": "/server/$1", - "^@shared/(.*)$": "/shared/$1" + "^@shared/(.*)$": "/shared/$1", + "react-medium-image-zoom": "/__mocks__/react-medium-image-zoom.js" }, - "setupFiles": ["/__mocks__/console.js", "/server/test/env.ts"], + "setupFiles": ["/__mocks__/console.js"], "setupFilesAfterEnv": ["/server/test/setup.ts"], "globalSetup": "/server/test/globalSetup.js", "globalTeardown": "/server/test/globalTeardown.js", @@ -22,7 +23,8 @@ "^~/(.*)$": "/app/$1", "^@shared/(.*)$": "/shared/$1", "^.*[.](gif|ttf|eot|svg)$": "/__test__/fileMock.js", - "^uuid$": "/node_modules/uuid/dist/index.js" + "^uuid$": "/node_modules/uuid/dist/index.js", + "react-medium-image-zoom": "/__mocks__/react-medium-image-zoom.js" }, "modulePaths": ["/app"], "setupFiles": ["/__mocks__/window.js"], @@ -37,7 +39,8 @@ "roots": ["/shared"], "moduleNameMapper": { "^@server/(.*)$": "/server/$1", - "^@shared/(.*)$": "/shared/$1" + "^@shared/(.*)$": "/shared/$1", + "react-medium-image-zoom": "/__mocks__/react-medium-image-zoom.js" }, "setupFiles": ["/__mocks__/console.js"], "setupFilesAfterEnv": ["/shared/test/setup.ts"], @@ -50,7 +53,8 @@ "^~/(.*)$": "/app/$1", "^@shared/(.*)$": "/shared/$1", "^.*[.](gif|ttf|eot|svg)$": "/__test__/fileMock.js", - "^uuid$": "/node_modules/uuid/dist/index.js" + "^uuid$": "/node_modules/uuid/dist/index.js", + "react-medium-image-zoom": "/__mocks__/react-medium-image-zoom.js" }, "setupFiles": ["/__mocks__/window.js"], "testEnvironment": "jsdom", diff --git a/.sequelizerc b/.sequelizerc index aa05bd0250ed..f8d239ac13f2 100644 --- a/.sequelizerc +++ b/.sequelizerc @@ -1,4 +1,6 @@ -require('dotenv').config({ silent: true }); +require("dotenv").config({ + path: process.env.NODE_ENV === "test" ? ".env.test" : ".env", +}); var path = require('path'); @@ -6,5 +8,4 @@ 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'), } diff --git a/Dockerfile b/Dockerfile index eaca276e8257..a0393b6b9397 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,17 @@ ARG APP_PATH=/opt/outline -FROM outlinewiki/outline-base as base +FROM outlinewiki/outline-base AS base ARG APP_PATH WORKDIR $APP_PATH # --- -FROM node:20-alpine AS runner - -RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates +FROM node:20-slim AS runner LABEL org.opencontainers.image.source="https://github.com/outline/outline" ARG APP_PATH WORKDIR $APP_PATH -ENV NODE_ENV production +ENV NODE_ENV=production COPY --from=base $APP_PATH/build ./build COPY --from=base $APP_PATH/server ./server @@ -22,13 +20,19 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc COPY --from=base $APP_PATH/node_modules ./node_modules COPY --from=base $APP_PATH/package.json ./package.json -RUN addgroup -g 1001 -S nodejs && \ - adduser -S nodejs -u 1001 && \ +# Install wget to healthcheck the server +RUN apt-get update \ + && apt-get install -y wget \ + && rm -rf /var/lib/apt/lists/* + +# Create a non-root user compatible with Debian and BusyBox based images +RUN addgroup --gid 1001 nodejs && \ + adduser --uid 1001 --ingroup nodejs nodejs && \ 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 +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" @@ -37,5 +41,7 @@ VOLUME /var/lib/outline/data USER nodejs +HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1 + EXPOSE 3000 CMD ["yarn", "start"] diff --git a/Dockerfile.base b/Dockerfile.base index 02318b1518c6..13378f1caa78 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -1,5 +1,5 @@ ARG APP_PATH=/opt/outline -FROM node:20-alpine AS deps +FROM node:20-slim AS deps ARG APP_PATH WORKDIR $APP_PATH @@ -17,3 +17,5 @@ RUN rm -rf node_modules RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \ yarn cache clean + +ENV PORT=3000 diff --git a/LICENSE b/LICENSE index 58a4fb6a7a79..cda0806cdde2 100644 --- a/LICENSE +++ b/LICENSE @@ -3,8 +3,8 @@ Business Source License 1.1 Parameters Licensor: General Outline, Inc. -Licensed Work: Outline 0.71.0 - The Licensed Work is (c) 2020 General Outline, Inc. +Licensed Work: Outline 0.80.2 + The Licensed Work is (c) 2024 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 Service. @@ -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: 2028-09-26 Change License: Apache License, Version 2.0 diff --git a/Makefile b/Makefile index 1b9fc94e2eb3..8d773aaa0823 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,28 @@ up: - docker-compose up -d redis postgres + docker compose up -d redis postgres 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 - yarn sequelize db:drop --env=test - yarn sequelize db:create --env=test - NODE_ENV=test yarn sequelize db:migrate --env=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 yarn test watch: - docker-compose up -d redis postgres - yarn sequelize db:drop --env=test - yarn sequelize db:create --env=test - NODE_ENV=test yarn sequelize db:migrate --env=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 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 diff --git a/README.md b/README.md index 761cecabd356..096d2d5182bf 100644 --- a/README.md +++ b/README.md @@ -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 liklihood 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 likelihood of your code being accepted. If you’re looking for ways to get started, here's a list of ways to help us improve Outline: diff --git a/__mocks__/react-medium-image-zoom.js b/__mocks__/react-medium-image-zoom.js new file mode 100644 index 000000000000..7646bbd17d04 --- /dev/null +++ b/__mocks__/react-medium-image-zoom.js @@ -0,0 +1 @@ +export default null; diff --git a/app.json b/app.json index 9169adf16679..32783bae0950 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,13 @@ "description": "Open source wiki and knowledge base for growing teams", "website": "https://www.getoutline.com/", "repository": "https://github.com/outline/outline", - "keywords": ["wiki", "team", "node", "markdown", "slack"], + "keywords": [ + "wiki", + "team", + "node", + "markdown", + "slack" + ], "success_url": "/", "formation": { "web": { @@ -33,6 +39,11 @@ "generator": "secret", "required": true }, + "UTILS_SECRET": { + "description": "A 32-character secret key, generate with openssl rand -hex 32", + "generator": "secret", + "required": true + }, "ENABLE_UPDATES": { "value": "true", "required": true @@ -81,6 +92,14 @@ "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", @@ -199,4 +218,4 @@ "required": false } } -} +} \ No newline at end of file diff --git a/app/actions/definitions/apiKeys.tsx b/app/actions/definitions/apiKeys.tsx new file mode 100644 index 000000000000..0bb9092e321b --- /dev/null +++ b/app/actions/definitions/apiKeys.tsx @@ -0,0 +1,25 @@ +import { PlusIcon } from "outline-icons"; +import * as React from "react"; +import stores from "~/stores"; +import ApiKeyNew from "~/scenes/ApiKeyNew"; +import { createAction } from ".."; +import { SettingsSection } from "../sections"; + +export const createApiKey = createAction({ + name: ({ t }) => t("New API key"), + analyticsName: "New API key", + section: SettingsSection, + icon: , + keywords: "create", + visible: () => + stores.policies.abilities(stores.auth.team?.id || "").createApiKey, + perform: ({ t, event }) => { + event?.preventDefault(); + event?.stopPropagation(); + + stores.dialogs.openModal({ + title: t("New API key"), + content: , + }); + }, +}); diff --git a/app/actions/definitions/collections.tsx b/app/actions/definitions/collections.tsx index bc58398d8d45..d0f4af58907b 100644 --- a/app/actions/definitions/collections.tsx +++ b/app/actions/definitions/collections.tsx @@ -1,23 +1,32 @@ import { + ArchiveIcon, CollectionIcon, EditIcon, PadlockIcon, PlusIcon, + RestoreIcon, + SearchIcon, + ShapesIcon, StarredIcon, TrashIcon, UnstarredIcon, } from "outline-icons"; import * as React from "react"; +import { toast } from "sonner"; 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 ConfirmationDialog from "~/components/ConfirmationDialog"; import DynamicCollectionIcon from "~/components/Icons/CollectionIcon"; +import SharePopover from "~/components/Sharing/Collection/SharePopover"; +import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header"; import { createAction } from "~/actions"; -import { CollectionSection } from "~/actions/sections"; +import { ActiveCollectionSection, CollectionSection } from "~/actions/sections"; +import { setPersistedState } from "~/hooks/usePersistedState"; import history from "~/utils/history"; +import { newTemplatePath, searchPath } from "~/utils/routeHelpers"; const ColorCollectionIcon = ({ collection }: { collection: Collection }) => ( @@ -34,11 +43,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.url, + id: collection.path, name: collection.name, icon: , section: CollectionSection, - perform: () => history.push(collection.url), + perform: () => history.push(collection.path), })); }, }); @@ -65,9 +74,9 @@ export const editCollection = createAction({ name: ({ t, isContextMenu }) => isContextMenu ? `${t("Edit")}…` : t("Edit collection"), analyticsName: "Edit collection", - section: CollectionSection, + section: ActiveCollectionSection, icon: , - visible: ({ stores, activeCollectionId }) => + visible: ({ activeCollectionId }) => !!activeCollectionId && stores.policies.abilities(activeCollectionId).update, perform: ({ t, activeCollectionId }) => { @@ -91,30 +100,65 @@ export const editCollectionPermissions = createAction({ name: ({ t, isContextMenu }) => isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"), analyticsName: "Collection permissions", - section: CollectionSection, + section: ActiveCollectionSection, icon: , - visible: ({ stores, activeCollectionId }) => + visible: ({ activeCollectionId }) => !!activeCollectionId && stores.policies.abilities(activeCollectionId).update, perform: ({ t, activeCollectionId }) => { if (!activeCollectionId) { return; } + const collection = stores.collections.get(activeCollectionId); + if (!collection) { + return; + } stores.dialogs.openModal({ - title: t("Collection permissions"), - content: , + title: t("Share this collection"), + style: { marginBottom: -12 }, + content: ( + + ), }); }, }); +export const searchInCollection = createAction({ + name: ({ t }) => t("Search in collection"), + analyticsName: "Search collection", + section: ActiveCollectionSection, + icon: , + visible: ({ activeCollectionId }) => { + if (!activeCollectionId) { + return false; + } + + const collection = stores.collections.get(activeCollectionId); + + if (!collection?.isActive) { + return false; + } + + return stores.policies.abilities(activeCollectionId).readDocument; + }, + + perform: ({ activeCollectionId }) => { + history.push(searchPath(undefined, { collectionId: activeCollectionId })); + }, +}); + export const starCollection = createAction({ name: ({ t }) => t("Star"), analyticsName: "Star collection", - section: CollectionSection, + section: ActiveCollectionSection, icon: , keywords: "favorite bookmark", - visible: ({ activeCollectionId, stores }) => { + visible: ({ activeCollectionId }) => { if (!activeCollectionId) { return false; } @@ -124,23 +168,24 @@ export const starCollection = createAction({ stores.policies.abilities(activeCollectionId).star ); }, - perform: async ({ activeCollectionId, stores }) => { + perform: async ({ activeCollectionId }) => { if (!activeCollectionId) { return; } const collection = stores.collections.get(activeCollectionId); await collection?.star(); + setPersistedState(getHeaderExpandedKey("starred"), true); }, }); export const unstarCollection = createAction({ name: ({ t }) => t("Unstar"), analyticsName: "Unstar collection", - section: CollectionSection, + section: ActiveCollectionSection, icon: , keywords: "unfavorite unbookmark", - visible: ({ activeCollectionId, stores }) => { + visible: ({ activeCollectionId }) => { if (!activeCollectionId) { return false; } @@ -150,7 +195,7 @@ export const unstarCollection = createAction({ stores.policies.abilities(activeCollectionId).unstar ); }, - perform: async ({ activeCollectionId, stores }) => { + perform: async ({ activeCollectionId }) => { if (!activeCollectionId) { return; } @@ -160,18 +205,85 @@ export const unstarCollection = createAction({ }, }); +export const archiveCollection = createAction({ + name: ({ t }) => `${t("Archive")}…`, + analyticsName: "Archive collection", + section: CollectionSection, + icon: , + visible: ({ activeCollectionId, stores }) => { + if (!activeCollectionId) { + return false; + } + return !!stores.policies.abilities(activeCollectionId).archive; + }, + perform: async ({ activeCollectionId, stores, t }) => { + const { dialogs, collections } = stores; + if (!activeCollectionId) { + return; + } + const collection = collections.get(activeCollectionId); + if (!collection) { + return; + } + + dialogs.openModal({ + title: t("Archive collection"), + content: ( + { + await collection.archive(); + toast.success(t("Collection archived")); + }} + submitText={t("Archive")} + savingText={`${t("Archiving")}…`} + > + {t( + "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results." + )} + + ), + }); + }, +}); + +export const restoreCollection = createAction({ + name: ({ t }) => t("Restore"), + analyticsName: "Restore collection", + section: CollectionSection, + icon: , + visible: ({ activeCollectionId, stores }) => { + if (!activeCollectionId) { + return false; + } + return !!stores.policies.abilities(activeCollectionId).restore; + }, + perform: async ({ activeCollectionId, stores, t }) => { + if (!activeCollectionId) { + return; + } + const collection = stores.collections.get(activeCollectionId); + if (!collection) { + return; + } + + await collection.restore(); + toast.success(t("Collection restored")); + }, +}); + export const deleteCollection = createAction({ - name: ({ t }) => t("Delete"), + name: ({ t }) => `${t("Delete")}…`, analyticsName: "Delete collection", - section: CollectionSection, + section: ActiveCollectionSection, + dangerous: true, icon: , - visible: ({ activeCollectionId, stores }) => { + visible: ({ activeCollectionId }) => { if (!activeCollectionId) { return false; } return stores.policies.abilities(activeCollectionId).delete; }, - perform: ({ activeCollectionId, stores, t }) => { + perform: ({ activeCollectionId, t }) => { if (!activeCollectionId) { return; } @@ -182,7 +294,6 @@ export const deleteCollection = createAction({ } stores.dialogs.openModal({ - isCentered: true, title: t("Delete collection"), content: ( t("New template"), + analyticsName: "New template", + section: ActiveCollectionSection, + icon: , + keywords: "new create template", + visible: ({ activeCollectionId }) => + !!( + !!activeCollectionId && + stores.policies.abilities(activeCollectionId).createDocument + ), + perform: ({ activeCollectionId, event }) => { + if (!activeCollectionId) { + return; + } + event?.preventDefault(); + event?.stopPropagation(); + history.push(newTemplatePath(activeCollectionId)); + }, +}); + export const rootCollectionActions = [ openCollection, createCollection, diff --git a/app/actions/definitions/comments.tsx b/app/actions/definitions/comments.tsx new file mode 100644 index 000000000000..0ed6206eb0e2 --- /dev/null +++ b/app/actions/definitions/comments.tsx @@ -0,0 +1,90 @@ +import { DoneIcon, TrashIcon } from "outline-icons"; +import * as React from "react"; +import { toast } from "sonner"; +import stores from "~/stores"; +import Comment from "~/models/Comment"; +import CommentDeleteDialog from "~/components/CommentDeleteDialog"; +import history from "~/utils/history"; +import { createAction } from ".."; +import { DocumentSection } from "../sections"; + +export const deleteCommentFactory = ({ + comment, + onDelete, +}: { + comment: Comment; + onDelete: () => void; +}) => + createAction({ + name: ({ t }) => `${t("Delete")}…`, + analyticsName: "Delete comment", + section: DocumentSection, + icon: , + keywords: "trash", + dangerous: true, + visible: () => stores.policies.abilities(comment.id).delete, + perform: ({ t, event }) => { + event?.preventDefault(); + event?.stopPropagation(); + + stores.dialogs.openModal({ + title: t("Delete comment"), + content: , + }); + }, + }); + +export const resolveCommentFactory = ({ + comment, + onResolve, +}: { + comment: Comment; + onResolve: () => void; +}) => + createAction({ + name: ({ t }) => t("Mark as resolved"), + analyticsName: "Resolve thread", + section: DocumentSection, + icon: , + visible: () => + stores.policies.abilities(comment.id).resolve && + stores.policies.abilities(comment.documentId).update, + perform: async ({ t }) => { + await comment.resolve(); + + history.replace({ + ...history.location, + state: null, + }); + + onResolve(); + toast.success(t("Thread resolved")); + }, + }); + +export const unresolveCommentFactory = ({ + comment, + onUnresolve, +}: { + comment: Comment; + onUnresolve: () => void; +}) => + createAction({ + name: ({ t }) => t("Mark as unresolved"), + analyticsName: "Unresolve thread", + section: DocumentSection, + icon: , + visible: () => + stores.policies.abilities(comment.id).unresolve && + stores.policies.abilities(comment.documentId).update, + perform: async () => { + await comment.unresolve(); + + history.replace({ + ...history.location, + state: null, + }); + + onUnresolve(); + }, + }); diff --git a/app/actions/definitions/developer.tsx b/app/actions/definitions/developer.tsx index 290202ee9268..e0d32e7594db 100644 --- a/app/actions/definitions/developer.tsx +++ b/app/actions/definitions/developer.tsx @@ -1,13 +1,22 @@ import copy from "copy-to-clipboard"; -import { CopyIcon, ToolsIcon, TrashIcon, UserIcon } from "outline-icons"; +import { + BeakerIcon, + CopyIcon, + ToolsIcon, + TrashIcon, + UserIcon, +} from "outline-icons"; import * as React from "react"; import { toast } from "sonner"; import { createAction } from "~/actions"; import { DeveloperSection } from "~/actions/sections"; import env from "~/env"; import { client } from "~/utils/ApiClient"; +import { Feature, FeatureFlags } from "~/utils/FeatureFlags"; 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"), @@ -67,38 +76,34 @@ export const copyId = createAction({ name: "Copy Release ID", icon: , section: DeveloperSection, - visible: () => !!env.RELEASE, - perform: () => copyAndToast(env.RELEASE), + visible: () => !!env.VERSION, + perform: () => copyAndToast(env.VERSION), }), ]; }, }); export const clearIndexedDB = createAction({ - name: ({ t }) => t("Delete IndexedDB cache"), + name: ({ t }) => t("Clear IndexedDB cache"), icon: , keywords: "cache clear database", section: DeveloperSection, perform: async ({ t }) => { + history.push(homePath()); await deleteAllDatabases(); - toast.message(t("IndexedDB cache deleted")); + toast.success(t("IndexedDB cache cleared")); }, }); export const createTestUsers = createAction({ - name: "Create test users", + name: "Create 10 test users", icon: , section: DeveloperSection, visible: () => env.ENVIRONMENT === "development", perform: async () => { const count = 10; - - try { - await client.post("/developer.create_test_users", { count }); - toast.message(`${count} test users created`); - } catch (err) { - toast.error(err.message); - } + await client.post("/developer.create_test_users", { count }); + toast.message(`${count} test users created`); }, }); @@ -106,7 +111,7 @@ export const createToast = createAction({ name: "Create toast", section: DeveloperSection, visible: () => env.ENVIRONMENT === "development", - perform: async () => { + perform: () => { toast.message("Hello world", { duration: 30000, }); @@ -117,7 +122,7 @@ export const toggleDebugLogging = createAction({ name: ({ t }) => t("Toggle debug logging"), icon: , section: DeveloperSection, - perform: async ({ t }) => { + perform: ({ t }) => { Logger.debugLoggingEnabled = !Logger.debugLoggingEnabled; toast.message( Logger.debugLoggingEnabled @@ -127,6 +132,30 @@ export const toggleDebugLogging = createAction({ }, }); +export const toggleFeatureFlag = createAction({ + name: "Toggle feature flag", + icon: , + section: DeveloperSection, + visible: () => env.ENVIRONMENT === "development", + children: Object.values(Feature).map((flag) => + createAction({ + id: `flag-${flag}`, + name: flag, + selected: () => FeatureFlags.isEnabled(flag), + section: DeveloperSection, + perform: () => { + if (FeatureFlags.isEnabled(flag)) { + FeatureFlags.disable(flag); + toast.success(`Disabled feature flag: ${flag}`); + } else { + FeatureFlags.enable(flag); + toast.success(`Enabled feature flag: ${flag}`); + } + }, + }) + ), +}); + export const developer = createAction({ name: ({ t }) => t("Development"), keywords: "debug", @@ -135,10 +164,11 @@ export const developer = createAction({ section: DeveloperSection, children: [ copyId, - clearIndexedDB, toggleDebugLogging, + toggleFeatureFlag, createToast, createTestUsers, + clearIndexedDB, ], }); diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index de46869840cd..99d289b6afb8 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -24,33 +24,50 @@ import { UnpublishIcon, PublishIcon, CommentIcon, - GlobeIcon, CopyIcon, + EyeIcon, + PadlockIcon, + GlobeIcon, } 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 { + ExportContentType, + TeamPreference, + NavigationNode, +} from "@shared/types"; import { getEventFiles } from "@shared/utils/files"; -import SharePopover from "~/scenes/Document/components/SharePopover"; import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentMove from "~/scenes/DocumentMove"; import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete"; import DocumentPublish from "~/scenes/DocumentPublish"; -import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; +import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInTrash"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; import DuplicateDialog from "~/components/DuplicateDialog"; +import Icon from "~/components/Icon"; +import MarkdownIcon from "~/components/Icons/MarkdownIcon"; +import SharePopover from "~/components/Sharing/Document"; +import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header"; +import DocumentTemplatizeDialog from "~/components/TemplatizeDialog"; import { createAction } from "~/actions"; -import { DocumentSection } from "~/actions/sections"; +import { + ActiveDocumentSection, + DocumentSection, + TrashSection, +} from "~/actions/sections"; import env from "~/env"; +import { setPersistedState } from "~/hooks/usePersistedState"; import history from "~/utils/history"; import { documentInsightsPath, documentHistoryPath, homePath, newDocumentPath, + newNestedDocumentPath, searchPath, documentPath, urlify, + trashPath, } from "~/utils/routeHelpers"; export const openDocument = createAction({ @@ -61,23 +78,24 @@ export const openDocument = createAction({ keywords: "go to", icon: , children: ({ stores }) => { - const paths = stores.collections.pathsToDocuments; - - return paths - .filter((path) => path.type === "document") - .map((path) => ({ - // Note: using url which includes the slug rather than id here to bust - // cache if the document is renamed - id: path.url, - name: path.title, - icon: function _Icon() { - return stores.documents.get(path.id)?.isStarred ? ( - - ) : null; - }, - section: DocumentSection, - perform: () => history.push(path.url), - })); + const nodes = stores.collections.navigationNodes.reduce( + (acc, node) => [...acc, ...node.children], + [] as NavigationNode[] + ); + + return nodes.map((item) => ({ + // Note: using url which includes the slug rather than id here to bust + // cache if the document is renamed + id: item.url, + name: item.title, + icon: item.icon ? ( + + ) : ( + + ), + section: DocumentSection, + perform: () => history.push(item.url), + })); }, }); @@ -87,11 +105,21 @@ export const createDocument = createAction({ section: DocumentSection, icon: , keywords: "create", - visible: ({ currentTeamId, stores }) => - !!currentTeamId && stores.policies.abilities(currentTeamId).createDocument, - perform: ({ activeCollectionId, inStarredSection }) => + visible: ({ currentTeamId, activeCollectionId, stores }) => { + if ( + activeCollectionId && + !stores.policies.abilities(activeCollectionId).createDocument + ) { + return false; + } + + return ( + !!currentTeamId && stores.policies.abilities(currentTeamId).createDocument + ); + }, + perform: ({ activeCollectionId, sidebarContext }) => history.push(newDocumentPath(activeCollectionId), { - starred: inStarredSection, + sidebarContext, }), }); @@ -106,11 +134,11 @@ export const createDocumentFromTemplate = createAction({ !!activeDocumentId && !!stores.documents.get(activeDocumentId)?.template && stores.policies.abilities(currentTeamId).createDocument, - perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) => + perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) => history.push( newDocumentPath(activeCollectionId, { templateId: activeDocumentId }), { - starred: inStarredSection, + sidebarContext, } ), }); @@ -118,7 +146,7 @@ export const createDocumentFromTemplate = createAction({ export const createNestedDocument = createAction({ name: ({ t }) => t("New nested document"), analyticsName: "New document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , keywords: "create", visible: ({ currentTeamId, activeDocumentId, stores }) => @@ -126,21 +154,16 @@ export const createNestedDocument = createAction({ !!activeDocumentId && stores.policies.abilities(currentTeamId).createDocument && stores.policies.abilities(activeDocumentId).createChildDocument, - perform: ({ activeCollectionId, activeDocumentId, inStarredSection }) => - history.push( - newDocumentPath(activeCollectionId, { - parentDocumentId: activeDocumentId, - }), - { - starred: inStarredSection, - } - ), + perform: ({ activeDocumentId, sidebarContext }) => + history.push(newNestedDocumentPath(activeDocumentId), { + sidebarContext, + }), }); export const starDocument = createAction({ name: ({ t }) => t("Star"), analyticsName: "Star document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , keywords: "favorite bookmark", visible: ({ activeDocumentId, stores }) => { @@ -159,13 +182,14 @@ export const starDocument = createAction({ const document = stores.documents.get(activeDocumentId); await document?.star(); + setPersistedState(getHeaderExpandedKey("starred"), true); }, }); export const unstarDocument = createAction({ name: ({ t }) => t("Unstar"), analyticsName: "Unstar document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , keywords: "unfavorite unbookmark", visible: ({ activeDocumentId, stores }) => { @@ -191,7 +215,7 @@ export const unstarDocument = createAction({ export const publishDocument = createAction({ name: ({ t }) => t("Publish"), analyticsName: "Publish document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { @@ -199,7 +223,7 @@ export const publishDocument = createAction({ } const document = stores.documents.get(activeDocumentId); return ( - !!document?.isDraft && stores.policies.abilities(activeDocumentId).update + !!document?.isDraft && stores.policies.abilities(activeDocumentId).publish ); }, perform: async ({ activeDocumentId, stores, t }) => { @@ -212,7 +236,7 @@ export const publishDocument = createAction({ return; } - if (document?.collectionId) { + if (document?.collectionId || document?.template) { await document.save(undefined, { publish: true, }); @@ -224,7 +248,6 @@ export const publishDocument = createAction({ } else if (document) { stores.dialogs.openModal({ title: t("Publish document"), - isCentered: true, content: , }); } @@ -234,7 +257,7 @@ export const publishDocument = createAction({ export const unpublishDocument = createAction({ name: ({ t }) => t("Unpublish"), analyticsName: "Unpublish document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { @@ -252,24 +275,20 @@ export const unpublishDocument = createAction({ return; } - try { - await document.unpublish(); + await document.unpublish(); - toast.success( - t("Unpublished {{ documentName }}", { - documentName: document.noun, - }) - ); - } catch (err) { - toast.error(err.message); - } + toast.success( + t("Unpublished {{ documentName }}", { + documentName: document.noun, + }) + ); }, }); export const subscribeDocument = createAction({ name: ({ t }) => t("Subscribe"), analyticsName: "Subscribe to document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { @@ -289,9 +308,7 @@ export const subscribeDocument = createAction({ } const document = stores.documents.get(activeDocumentId); - await document?.subscribe(); - toast.success(t("Subscribed to document notifications")); }, }); @@ -299,7 +316,7 @@ export const subscribeDocument = createAction({ export const unsubscribeDocument = createAction({ name: ({ t }) => t("Unsubscribe"), analyticsName: "Unsubscribe from document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { @@ -327,32 +344,31 @@ export const unsubscribeDocument = createAction({ }); export const shareDocument = createAction({ - name: ({ t }) => t("Share"), + name: ({ t }) => `${t("Permissions")}…`, analyticsName: "Share document", - section: DocumentSection, - icon: , + section: ActiveDocumentSection, + icon: , + visible: ({ stores, activeDocumentId }) => { + const can = stores.policies.abilities(activeDocumentId!); + return can.manageUsers || can.share; + }, 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({ + style: { marginBottom: -12 }, title: t("Share this document"), - isCentered: true, content: ( ), @@ -363,7 +379,7 @@ export const shareDocument = createAction({ export const downloadDocumentAsHTML = createAction({ name: ({ t }) => t("HTML"), analyticsName: "Download document as HTML", - section: DocumentSection, + section: ActiveDocumentSection, keywords: "html export", icon: , iconInContextMenu: false, @@ -382,7 +398,7 @@ export const downloadDocumentAsHTML = createAction({ export const downloadDocumentAsPDF = createAction({ name: ({ t }) => t("PDF"), analyticsName: "Download document as PDF", - section: DocumentSection, + section: ActiveDocumentSection, keywords: "export", icon: , iconInContextMenu: false, @@ -397,7 +413,7 @@ export const downloadDocumentAsPDF = createAction({ const id = toast.loading(`${t("Exporting")}…`); const document = stores.documents.get(activeDocumentId); - document + return document ?.download(ExportContentType.Pdf) .finally(() => id && toast.dismiss(id)); }, @@ -406,7 +422,7 @@ export const downloadDocumentAsPDF = createAction({ export const downloadDocumentAsMarkdown = createAction({ name: ({ t }) => t("Markdown"), analyticsName: "Download document as Markdown", - section: DocumentSection, + section: ActiveDocumentSection, keywords: "md markdown export", icon: , iconInContextMenu: false, @@ -426,9 +442,11 @@ export const downloadDocument = createAction({ name: ({ t, isContextMenu }) => isContextMenu ? t("Download") : t("Download document"), analyticsName: "Download document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , keywords: "export", + visible: ({ activeDocumentId, stores }) => + !!activeDocumentId && stores.policies.abilities(activeDocumentId).download, children: [ downloadDocumentAsHTML, downloadDocumentAsPDF, @@ -438,24 +456,50 @@ export const downloadDocument = createAction({ export const copyDocumentAsMarkdown = createAction({ name: ({ t }) => t("Copy as Markdown"), - section: DocumentSection, + section: ActiveDocumentSection, keywords: "clipboard", - visible: ({ activeDocumentId }) => !!activeDocumentId, + icon: , + iconInContextMenu: false, + 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)); + copy(document.toMarkdown()); toast.success(t("Markdown copied to clipboard")); } }, }); +export const copyDocumentShareLink = createAction({ + name: ({ t }) => t("Copy public link"), + section: ActiveDocumentSection, + keywords: "clipboard share", + icon: , + iconInContextMenu: false, + visible: ({ activeDocumentId, stores }) => + !!activeDocumentId && + !!stores.shares.getByDocumentId(activeDocumentId)?.published, + perform: ({ stores, activeDocumentId, t }) => { + if (!activeDocumentId) { + return; + } + const share = stores.shares.getByDocumentId(activeDocumentId); + if (share) { + copy(share.url); + toast.success(t("Link copied to clipboard")); + } + }, +}); + export const copyDocumentLink = createAction({ name: ({ t }) => t("Copy link"), - section: DocumentSection, + section: ActiveDocumentSection, keywords: "clipboard", + icon: , + iconInContextMenu: false, visible: ({ activeDocumentId }) => !!activeDocumentId, perform: ({ stores, activeDocumentId, t }) => { const document = activeDocumentId @@ -471,21 +515,21 @@ export const copyDocumentLink = createAction({ export const copyDocument = createAction({ name: ({ t }) => t("Copy"), analyticsName: "Copy document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , keywords: "clipboard", - children: [copyDocumentLink, copyDocumentAsMarkdown], + children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown], }); export const duplicateDocument = createAction({ name: ({ t, isContextMenu }) => isContextMenu ? t("Duplicate") : t("Duplicate document"), analyticsName: "Duplicate document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , keywords: "copy", visible: ({ activeDocumentId, stores }) => - !!activeDocumentId && stores.policies.abilities(activeDocumentId).update, + !!activeDocumentId && stores.policies.abilities(activeDocumentId).duplicate, perform: async ({ activeDocumentId, t, stores }) => { if (!activeDocumentId) { return; @@ -496,7 +540,6 @@ export const duplicateDocument = createAction({ stores.dialogs.openModal({ title: t("Copy document"), - isCentered: true, content: ( , iconInContextMenu: false, visible: ({ activeCollectionId, activeDocumentId, stores }) => { @@ -544,17 +587,13 @@ export const pinDocumentToCollection = createAction({ return; } - try { - const document = stores.documents.get(activeDocumentId); - await document?.pin(document.collectionId); + 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")); - } - } catch (err) { - toast.error(err.message); + if (!collection || !location.pathname.startsWith(collection?.url)) { + toast.success(t("Pinned to collection")); } }, }); @@ -566,7 +605,7 @@ export const pinDocumentToCollection = createAction({ export const pinDocumentToHome = createAction({ name: ({ t }) => t("Pin to home"), analyticsName: "Pin document to home", - section: DocumentSection, + section: ActiveDocumentSection, icon: , iconInContextMenu: false, visible: ({ activeDocumentId, currentTeamId, stores }) => { @@ -587,14 +626,10 @@ export const pinDocumentToHome = createAction({ } const document = stores.documents.get(activeDocumentId); - try { - await document?.pin(); + await document?.pin(); - if (location.pathname !== homePath()) { - toast.success(t("Pinned to home")); - } - } catch (err) { - toast.error(err.message); + if (location.pathname !== homePath()) { + toast.success(t("Pinned to home")); } }, }); @@ -602,19 +637,36 @@ export const pinDocumentToHome = createAction({ export const pinDocument = createAction({ name: ({ t }) => t("Pin"), analyticsName: "Pin document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , children: [pinDocumentToCollection, pinDocumentToHome], }); +export const searchInDocument = createAction({ + name: ({ t }) => t("Search in document"), + analyticsName: "Search document", + section: ActiveDocumentSection, + icon: , + 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"), analyticsName: "Print document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print), - perform: async () => { + perform: () => { queueMicrotask(window.print); }, }); @@ -645,8 +697,9 @@ export const importDocument = createAction({ input.onchange = async (ev) => { const files = getEventFiles(ev); + const file = files[0]; + try { - const file = files[0]; const document = await documents.import( file, activeDocumentId, @@ -666,22 +719,22 @@ export const importDocument = createAction({ }, }); -export const createTemplate = createAction({ +export const createTemplateFromDocument = createAction({ name: ({ t }) => t("Templatize"), analyticsName: "Templatize document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , keywords: "new create template", visible: ({ activeCollectionId, activeDocumentId, stores }) => { - if (!activeDocumentId) { + const document = activeDocumentId + ? stores.documents.get(activeDocumentId) + : undefined; + if (document?.isTemplate || !document?.isActive) { return false; } - const document = stores.documents.get(activeDocumentId); return !!( !!activeCollectionId && - stores.policies.abilities(activeCollectionId).update && - !document?.isTemplate && - !document?.isDeleted + stores.policies.abilities(activeCollectionId).updateDocument ); }, perform: ({ activeDocumentId, stores, t, event }) => { @@ -690,10 +743,8 @@ export const createTemplate = createAction({ } event?.preventDefault(); event?.stopPropagation(); - stores.dialogs.openModal({ title: t("Create template"), - isCentered: true, content: , }); }, @@ -706,14 +757,14 @@ export const openRandomDocument = createAction({ section: DocumentSection, icon: , perform: ({ stores, activeDocumentId }) => { - const documentPaths = stores.collections.pathsToDocuments.filter( - (path) => path.type === "document" && path.id !== activeDocumentId - ); - const documentPath = - documentPaths[Math.round(Math.random() * documentPaths.length)]; + const nodes = stores.collections.navigationNodes + .reduce((acc, node) => [...acc, ...node.children], [] as NavigationNode[]) + .filter((node) => node.id !== activeDocumentId); + + const random = nodes[Math.round(Math.random() * nodes.length)]; - if (documentPath) { - history.push(documentPath.url); + if (random) { + history.push(random.url); } }, }); @@ -730,11 +781,50 @@ export const searchDocumentsForQuery = (searchQuery: string) => visible: ({ location }) => location.pathname !== searchPath(), }); -export const moveDocument = createAction({ - name: ({ t }) => t("Move"), - analyticsName: "Move document", +export const moveTemplateToWorkspace = createAction({ + name: ({ t }) => t("Move to workspace"), + analyticsName: "Move template to workspace", section: DocumentSection, icon: , + iconInContextMenu: false, + visible: ({ activeDocumentId, stores }) => { + if (!activeDocumentId) { + return false; + } + const document = stores.documents.get(activeDocumentId); + if (!document || !document.template || document.isWorkspaceTemplate) { + return false; + } + return !!stores.policies.abilities(activeDocumentId).move; + }, + perform: async ({ activeDocumentId, stores }) => { + if (activeDocumentId) { + const document = stores.documents.get(activeDocumentId); + if (!document) { + return; + } + + await document.move({ + collectionId: null, + }); + } + }, +}); + +export const moveDocumentToCollection = createAction({ + name: ({ activeDocumentId, stores, t }) => { + if (!activeDocumentId) { + return t("Move"); + } + const document = stores.documents.get(activeDocumentId); + return document?.template && document?.collectionId + ? t("Move to collection") + : t("Move"); + }, + analyticsName: "Move document", + section: ActiveDocumentSection, + icon: , + iconInContextMenu: false, visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { return false; @@ -752,17 +842,54 @@ export const moveDocument = createAction({ title: t("Move {{ documentType }}", { documentType: document.noun, }), - isCentered: true, content: , }); } }, }); +export const moveDocument = createAction({ + name: ({ t }) => t("Move"), + analyticsName: "Move document", + section: ActiveDocumentSection, + icon: , + visible: ({ activeDocumentId, stores }) => { + if (!activeDocumentId) { + return false; + } + const document = stores.documents.get(activeDocumentId); + // Don't show the button if this is a non-workspace template. + if (!document || (document.template && !document.isWorkspaceTemplate)) { + return false; + } + return !!stores.policies.abilities(activeDocumentId).move; + }, + perform: moveDocumentToCollection.perform, +}); + +export const moveTemplate = createAction({ + name: ({ t }) => t("Move"), + analyticsName: "Move document", + section: ActiveDocumentSection, + icon: , + visible: ({ activeDocumentId, stores }) => { + if (!activeDocumentId) { + return false; + } + const document = stores.documents.get(activeDocumentId); + // Don't show the menu if this is not a template (or) a workspace template. + if (!document || !document.template || document.isWorkspaceTemplate) { + return false; + } + return !!stores.policies.abilities(activeDocumentId).move; + }, + children: [moveTemplateToWorkspace, moveDocumentToCollection], +}); + export const archiveDocument = createAction({ - name: ({ t }) => t("Archive"), + name: ({ t }) => `${t("Archive")}…`, analyticsName: "Archive document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { @@ -771,14 +898,30 @@ export const archiveDocument = createAction({ return !!stores.policies.abilities(activeDocumentId).archive; }, perform: async ({ activeDocumentId, stores, t }) => { + const { dialogs, documents } = stores; + if (activeDocumentId) { - const document = stores.documents.get(activeDocumentId); + const document = documents.get(activeDocumentId); if (!document) { return; } - await document.archive(); - toast.success(t("Document archived")); + dialogs.openModal({ + title: t("Are you sure you want to archive this document?"), + content: ( + { + await document.archive(); + toast.success(t("Document archived")); + }} + savingText={`${t("Archiving")}…`} + > + {t( + "Archiving this document will remove it from the collection and search results." + )} + + ), + }); } }, }); @@ -786,7 +929,7 @@ export const archiveDocument = createAction({ export const deleteDocument = createAction({ name: ({ t }) => `${t("Delete")}…`, analyticsName: "Delete document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , dangerous: true, visible: ({ activeDocumentId, stores }) => { @@ -806,7 +949,6 @@ export const deleteDocument = createAction({ title: t("Delete {{ documentName }}", { documentName: document.noun, }), - isCentered: true, content: ( t("Permanently delete"), analyticsName: "Permanently delete document", - section: DocumentSection, + section: ActiveDocumentSection, icon: , dangerous: true, visible: ({ activeDocumentId, stores }) => { @@ -841,7 +983,6 @@ export const permanentlyDeleteDocument = createAction({ title: t("Permanently delete {{ documentName }}", { documentName: document.noun, }), - isCentered: true, content: ( t("Empty trash"), + analyticsName: "Empty trash", + section: TrashSection, + icon: , + 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: ( + + ), + }); + }, +}); + export const openDocumentComments = createAction({ name: ({ t }) => t("Comments"), analyticsName: "Open comments", - section: DocumentSection, + section: ActiveDocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { const can = stores.policies.abilities(activeDocumentId ?? ""); @@ -878,11 +1040,11 @@ export const openDocumentComments = createAction({ export const openDocumentHistory = createAction({ name: ({ t }) => t("History"), analyticsName: "Open document history", - section: DocumentSection, + section: ActiveDocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { const can = stores.policies.abilities(activeDocumentId ?? ""); - return !!activeDocumentId && can.read && !can.restore; + return !!activeDocumentId && can.listRevisions; }, perform: ({ activeDocumentId, stores }) => { if (!activeDocumentId) { @@ -899,7 +1061,7 @@ export const openDocumentHistory = createAction({ export const openDocumentInsights = createAction({ name: ({ t }) => t("Insights"), analyticsName: "Open document insights", - section: DocumentSection, + section: ActiveDocumentSection, icon: , visible: ({ activeDocumentId, stores }) => { const can = stores.policies.abilities(activeDocumentId ?? ""); @@ -909,7 +1071,7 @@ export const openDocumentInsights = createAction({ return ( !!activeDocumentId && - can.read && + can.listViews && !document?.isTemplate && !document?.isDeleted ); @@ -926,15 +1088,47 @@ 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: ActiveDocumentSection, + icon: , + 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, createDocument, - createTemplate, + createTemplateFromDocument, deleteDocument, importDocument, downloadDocument, copyDocumentLink, + copyDocumentShareLink, copyDocumentAsMarkdown, starDocument, unstarDocument, @@ -943,13 +1137,16 @@ export const rootDocumentActions = [ subscribeDocument, unsubscribeDocument, duplicateDocument, - moveDocument, + moveTemplateToWorkspace, + moveDocumentToCollection, openRandomDocument, permanentlyDeleteDocument, + permanentlyDeleteDocumentsInTrash, printDocument, pinDocumentToCollection, pinDocumentToHome, openDocumentComments, openDocumentHistory, openDocumentInsights, + shareDocument, ]; diff --git a/app/actions/definitions/navigation.tsx b/app/actions/definitions/navigation.tsx index 14e5c86f4975..76d5da3ba910 100644 --- a/app/actions/definitions/navigation.tsx +++ b/app/actions/definitions/navigation.tsx @@ -3,7 +3,6 @@ import { SearchIcon, ArchiveIcon, TrashIcon, - EditIcon, OpenIcon, SettingsIcon, KeyboardIcon, @@ -12,20 +11,17 @@ import { ProfileIcon, BrowserIcon, ShapesIcon, + DraftsIcon, } 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 history from "~/utils/history"; import isCloudHosted from "~/utils/isCloudHosted"; @@ -61,7 +57,7 @@ export const navigateToDrafts = createAction({ name: ({ t }) => t("Drafts"), analyticsName: "Navigate to drafts", section: NavigationSection, - icon: , + icon: , perform: () => history.push(draftsPath()), visible: ({ location }) => location.pathname !== draftsPath(), }); @@ -91,8 +87,7 @@ export const navigateToSettings = createAction({ section: NavigationSection, shortcut: ["g", "s"], icon: , - visible: ({ stores }) => - stores.policies.abilities(stores.auth.team?.id || "").update, + visible: () => stores.policies.abilities(stores.auth.team?.id || "").update, perform: () => history.push(settingsPath()), }); @@ -132,13 +127,22 @@ 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: , + perform: () => window.open(UrlHelper.guide), +}); + export const openAPIDocumentation = createAction({ name: ({ t }) => t("API documentation"), analyticsName: "Open API documentation", section: NavigationSection, iconInContextMenu: false, icon: , - perform: () => window.open(developersUrl()), + perform: () => window.open(UrlHelper.developers), }); export const toggleSidebar = createAction({ @@ -146,7 +150,7 @@ export const toggleSidebar = createAction({ analyticsName: "Toggle sidebar", keywords: "hide show navigation", section: NavigationSection, - perform: ({ stores }) => stores.ui.toggleCollapsedSidebar(), + perform: () => stores.ui.toggleCollapsedSidebar(), }); export const openFeedbackUrl = createAction({ @@ -155,14 +159,14 @@ export const openFeedbackUrl = createAction({ section: NavigationSection, iconInContextMenu: false, icon: , - perform: () => window.open(feedbackUrl()), + perform: () => window.open(UrlHelper.contact), }); export const openBugReportUrl = createAction({ name: ({ t }) => t("Report a bug"), analyticsName: "Open bug report", section: NavigationSection, - perform: () => window.open(githubIssuesUrl()), + perform: () => window.open(UrlHelper.github), }); export const openChangelog = createAction({ @@ -171,7 +175,7 @@ export const openChangelog = createAction({ section: NavigationSection, iconInContextMenu: false, icon: , - perform: () => window.open(changelogUrl()), + perform: () => window.open(UrlHelper.changelog), }); export const openKeyboardShortcuts = createAction({ @@ -209,7 +213,14 @@ export const logout = createAction({ analyticsName: "Log out", section: NavigationSection, icon: , - perform: () => stores.auth.logout(), + perform: async () => { + await stores.auth.logout(); + if (env.OIDC_LOGOUT_URI) { + setTimeout(() => { + window.location.replace(env.OIDC_LOGOUT_URI); + }, 200); + } + }, }); export const rootNavigationActions = [ @@ -218,6 +229,7 @@ export const rootNavigationActions = [ navigateToArchive, navigateToTrash, downloadApp, + openDocumentation, openAPIDocumentation, openFeedbackUrl, openBugReportUrl, diff --git a/app/actions/definitions/notifications.tsx b/app/actions/definitions/notifications.tsx index ec77b7058c09..fa674d5f5ba7 100644 --- a/app/actions/definitions/notifications.tsx +++ b/app/actions/definitions/notifications.tsx @@ -1,4 +1,4 @@ -import { MarkAsReadIcon } from "outline-icons"; +import { ArchiveIcon, MarkAsReadIcon } from "outline-icons"; import * as React from "react"; import { createAction } from ".."; import { NotificationSection } from "../sections"; @@ -13,4 +13,17 @@ export const markNotificationsAsRead = createAction({ visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0, }); -export const rootNotificationActions = [markNotificationsAsRead]; +export const markNotificationsAsArchived = createAction({ + name: ({ t }) => t("Archive all notifications"), + analyticsName: "Mark notifications as archived", + section: NotificationSection, + icon: , + iconInContextMenu: false, + perform: ({ stores }) => stores.notifications.markAllAsArchived(), + visible: ({ stores }) => stores.notifications.orderedData.length > 0, +}); + +export const rootNotificationActions = [ + markNotificationsAsRead, + markNotificationsAsArchived, +]; diff --git a/app/actions/definitions/revisions.tsx b/app/actions/definitions/revisions.tsx index 08084ab46b39..9b91c007d860 100644 --- a/app/actions/definitions/revisions.tsx +++ b/app/actions/definitions/revisions.tsx @@ -17,7 +17,7 @@ export const restoreRevision = createAction({ analyticsName: "Restore revision", icon: , section: RevisionSection, - visible: ({ activeDocumentId, stores }) => + visible: ({ activeDocumentId }) => !!activeDocumentId && stores.policies.abilities(activeDocumentId).update, perform: async ({ event, location, activeDocumentId }) => { event?.preventDefault(); @@ -47,7 +47,7 @@ export const copyLinkToRevision = createAction({ analyticsName: "Copy link to revision", icon: , section: RevisionSection, - perform: async ({ activeDocumentId, stores, t }) => { + perform: async ({ activeDocumentId, t }) => { if (!activeDocumentId) { return; } diff --git a/app/actions/definitions/teams.tsx b/app/actions/definitions/teams.tsx index 4b2959b62107..a9847551d77b 100644 --- a/app/actions/definitions/teams.tsx +++ b/app/actions/definitions/teams.tsx @@ -1,12 +1,14 @@ -import { PlusIcon } from "outline-icons"; +import { ArrowIcon, 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 }) => @@ -60,14 +62,33 @@ export const createTeam = createAction({ user && stores.dialogs.openModal({ title: t("Create a workspace"), + fullscreen: true, content: , }); }, }); +export const desktopLoginTeam = createAction({ + name: ({ t }) => t("Login to workspace"), + analyticsName: "Login to workspace", + keywords: "change switch workspace organization team", + section: TeamSection, + icon: , + visible: () => Desktop.isElectron(), + perform: ({ t, event, stores }) => { + event?.preventDefault(); + event?.stopPropagation(); + + stores.dialogs.openModal({ + title: t("Login to workspace"), + content: , + }); + }, +}); + const StyledTeamLogo = styled(TeamLogo)` border-radius: 2px; border: 0; `; -export const rootTeamActions = [switchTeam, createTeam]; +export const rootTeamActions = [switchTeam, createTeam, desktopLoginTeam]; diff --git a/app/actions/definitions/users.tsx b/app/actions/definitions/users.tsx index c2ba44611894..abd83b89c0d7 100644 --- a/app/actions/definitions/users.tsx +++ b/app/actions/definitions/users.tsx @@ -1,8 +1,14 @@ 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 { UserDeleteDialog } from "~/components/UserDialogs"; +import { + UserChangeRoleDialog, + UserDeleteDialog, +} from "~/components/UserDialogs"; import { createAction } from "~/actions"; import { UserSection } from "~/actions/sections"; @@ -12,16 +18,51 @@ export const inviteUser = createAction({ icon: , keywords: "team member workspace user", section: UserSection, - visible: ({ stores }) => + visible: () => stores.policies.abilities(stores.auth.team?.id || "").inviteUser, perform: ({ t }) => { stores.dialogs.openModal({ - title: t("Invite people"), + title: t("Invite to workspace"), content: , }); }, }); +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: () => { + 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: ( + + ), + }); + }, + }); + export const deleteUserActionFactory = (userId: string) => createAction({ name: ({ t }) => `${t("Delete user")}…`, @@ -29,7 +70,7 @@ export const deleteUserActionFactory = (userId: string) => keywords: "leave", dangerous: true, section: UserSection, - visible: ({ stores }) => stores.policies.abilities(userId).delete, + visible: () => stores.policies.abilities(userId).delete, perform: ({ t }) => { const user = stores.users.get(userId); if (!user) { @@ -38,7 +79,6 @@ export const deleteUserActionFactory = (userId: string) => stores.dialogs.openModal({ title: t("Delete user"), - isCentered: true, content: ( { - try { - action.perform?.(context); - } catch (err) { - toast.error(err.message); - } - }, + onClick: () => performAction(action, context), selected: action.selected?.(context), }; } @@ -104,6 +98,11 @@ export function actionToKBar( ) : []; + const sectionPriority = + typeof action.section !== "string" && "priority" in action.section + ? (action.section.priority as number) ?? 0 + : 0; + return [ { id: action.id, @@ -114,10 +113,25 @@ export function actionToKBar( keywords: action.keywords ?? "", shortcut: action.shortcut || [], icon: resolvedIcon, - perform: action.perform ? () => action.perform?.(context) : undefined, + priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)), + perform: action.perform + ? () => performAction(action, 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; +} diff --git a/app/actions/sections.ts b/app/actions/sections.ts index 1541637d2a89..b434143c2998 100644 --- a/app/actions/sections.ts +++ b/app/actions/sections.ts @@ -2,10 +2,28 @@ import { ActionContext } from "~/types"; export const CollectionSection = ({ t }: ActionContext) => t("Collection"); +export const ActiveCollectionSection = ({ t, stores }: ActionContext) => { + const activeCollection = stores.collections.active; + return `${t("Collection")} · ${activeCollection?.name}`; +}; + +ActiveCollectionSection.priority = 0.8; + export const DeveloperSection = ({ t }: ActionContext) => t("Debug"); export const DocumentSection = ({ t }: ActionContext) => t("Document"); +export const ActiveDocumentSection = ({ t, stores }: ActionContext) => { + const activeDocument = stores.documents.active; + return `${t("Document")} · ${activeDocument?.titleWithDefault}`; +}; + +ActiveDocumentSection.priority = 0.9; + +export const RecentSection = ({ t }: ActionContext) => t("Recently viewed"); + +RecentSection.priority = 1; + export const RevisionSection = ({ t }: ActionContext) => t("Revision"); export const SettingsSection = ({ t }: ActionContext) => t("Settings"); @@ -20,3 +38,7 @@ export const TeamSection = ({ t }: ActionContext) => t("Workspace"); export const RecentSearchesSection = ({ t }: ActionContext) => t("Recent searches"); + +RecentSearchesSection.priority = -0.1; + +export const TrashSection = ({ t }: ActionContext) => t("Trash"); diff --git a/app/components/ActionButton.tsx b/app/components/ActionButton.tsx index d2b68618216f..8d94e0cc69c5 100644 --- a/app/components/ActionButton.tsx +++ b/app/components/ActionButton.tsx @@ -1,6 +1,8 @@ /* 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 & { @@ -24,6 +26,7 @@ const ActionButton = React.forwardRef( { action, context, tooltip, hideOnActionDisabled, ...rest }: Props, ref: React.Ref ) { + const isMounted = useIsMounted(); const [executing, setExecuting] = React.useState(false); const disabled = rest.disabled; @@ -60,10 +63,12 @@ const ActionButton = React.forwardRef( ? (ev) => { ev.preventDefault(); ev.stopPropagation(); - const response = action.perform?.(actionContext); + const response = performAction(action, actionContext); if (response?.finally) { setExecuting(true); - response.finally(() => setExecuting(false)); + void response.finally( + () => isMounted() && setExecuting(false) + ); } } : rest.onClick diff --git a/app/components/Analytics.tsx b/app/components/Analytics.tsx index 4d6f56cb6d37..70d51cfaf3da 100644 --- a/app/components/Analytics.tsx +++ b/app/components/Analytics.tsx @@ -2,13 +2,14 @@ /* global ga */ import escape from "lodash/escape"; import * as React from "react"; -import { IntegrationService } from "@shared/types"; +import { IntegrationService, PublicEnv } from "@shared/types"; import env from "~/env"; type Props = { children?: React.ReactNode; }; +// TODO: Refactor this component to allow injection from plugins const Analytics: React.FC = ({ children }: Props) => { // Google Analytics 3 React.useEffect(() => { @@ -43,12 +44,16 @@ const Analytics: React.FC = ({ children }: Props) => { React.useEffect(() => { const measurementIds = []; - if (env.analytics.service === IntegrationService.GoogleAnalytics) { - measurementIds.push(escape(env.analytics.settings?.measurementId)); - } if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) { measurementIds.push(env.GOOGLE_ANALYTICS_ID); } + + (env.analytics as PublicEnv["analytics"]).forEach((integration) => { + if (integration.service === IntegrationService.GoogleAnalytics) { + measurementIds.push(escape(integration.settings?.measurementId)); + } + }); + if (measurementIds.length === 0) { return; } @@ -75,6 +80,50 @@ const Analytics: React.FC = ({ children }: Props) => { document.getElementsByTagName("head")[0]?.appendChild(script); }, []); + // Matomo + React.useEffect(() => { + (env.analytics as PublicEnv["analytics"]).forEach((integration) => { + if (integration.service !== IntegrationService.Matomo) { + return; + } + + // @ts-expect-error - Matomo global variable + const _paq = (window._paq = window._paq || []); + _paq.push(["trackPageView"]); + _paq.push(["enableLinkTracking"]); + (function () { + const u = integration.settings?.instanceUrl; + _paq.push(["setTrackerUrl", u + "matomo.php"]); + _paq.push(["setSiteId", integration.settings?.measurementId]); + const d = document, + g = d.createElement("script"), + s = d.getElementsByTagName("script")[0]; + g.type = "text/javascript"; + g.async = true; + g.src = u + "matomo.js"; + s.parentNode?.insertBefore(g, s); + })(); + }); + }, []); + + // Umami + React.useEffect(() => { + (env.analytics as PublicEnv["analytics"]).forEach((integration) => { + if (integration.service !== IntegrationService.Umami) { + return; + } + + const script = document.createElement("script"); + script.defer = true; + script.src = `${integration.settings?.instanceUrl}${integration.settings?.scriptName}`; + script.setAttribute( + "data-website-id", + integration.settings?.measurementId + ); + document.getElementsByTagName("head")[0]?.appendChild(script); + }); + }, []); + return <>{children}; }; diff --git a/app/components/ArrowKeyNavigation.tsx b/app/components/ArrowKeyNavigation.tsx index f0f3e3d5f000..ffc8375aa223 100644 --- a/app/components/ArrowKeyNavigation.tsx +++ b/app/components/ArrowKeyNavigation.tsx @@ -1,54 +1,50 @@ +import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex"; import { observer } from "mobx-react"; import * as React from "react"; -import { - useCompositeState, - Composite, - CompositeStateReturn, -} from "reakit/Composite"; type Props = React.HTMLAttributes & { - children: (composite: CompositeStateReturn) => React.ReactNode; + children: () => React.ReactNode; onEscape?: (ev: React.KeyboardEvent) => void; + items: unknown[]; }; function ArrowKeyNavigation( - { children, onEscape, ...rest }: Props, + { children, onEscape, items, ...rest }: Props, ref: React.RefObject ) { - const composite = useCompositeState(); - const handleKeyDown = React.useCallback( - (ev) => { + (ev: React.KeyboardEvent) => { if (onEscape) { if (ev.nativeEvent.isComposing) { return; } - if (ev.key === "Escape") { + if (ev.key === "Escape" || ev.key === "Backspace") { + ev.preventDefault(); onEscape(ev); } if ( ev.key === "ArrowUp" && - composite.currentId === composite.items[0].id + // If the first item is focused and the user presses ArrowUp + ev.currentTarget.firstElementChild === document.activeElement ) { onEscape(ev); } } }, - [composite.currentId, composite.items, onEscape] + [onEscape] ); return ( - - {children(composite)} - +
+ {children()} +
+ ); } diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index 94567c9daefe..5aa9679b69ae 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -1,17 +1,14 @@ import { AnimatePresence } from "framer-motion"; -import { observer, useLocalStore } from "mobx-react"; +import { observer } from "mobx-react"; import * as React from "react"; import { Switch, Route, useLocation, matchPath } from "react-router-dom"; import { TeamPreference } from "@shared/types"; import ErrorSuspended from "~/scenes/ErrorSuspended"; -import DocumentContext from "~/components/DocumentContext"; -import type { DocumentContextValue } from "~/components/DocumentContext"; import Layout from "~/components/Layout"; import RegisterKeyDown from "~/components/RegisterKeyDown"; 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"; @@ -25,6 +22,7 @@ import { matchDocumentSlug as slug, matchDocumentInsights, } from "~/utils/routeHelpers"; +import { DocumentContextProvider } from "./DocumentContext"; import Fade from "./Fade"; import { PortalContext } from "./Portal"; @@ -47,14 +45,9 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { const { ui, auth } = useStores(); const location = useLocation(); const layoutRef = React.useRef(null); - const can = usePolicy(ui.activeCollectionId); + const can = usePolicy(ui.activeDocumentId); + const canCollection = usePolicy(ui.activeCollectionId); const team = useCurrentTeam(); - const documentContext = useLocalStore(() => ({ - editor: null, - setEditor: (editor: TEditor) => { - documentContext.editor = editor; - }, - })); const goToSearch = (ev: KeyboardEvent) => { if (!ev.metaKey && !ev.ctrlKey) { @@ -69,7 +62,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { return; } const { activeCollectionId } = ui; - if (!activeCollectionId || !can.createDocument) { + if (!activeCollectionId || !canCollection.createDocument) { return; } history.push(newDocumentPath(activeCollectionId)); @@ -88,15 +81,18 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { ); - const showHistory = !!matchPath(location.pathname, { - path: matchDocumentHistory, - }); - const showInsights = !!matchPath(location.pathname, { - path: matchDocumentInsights, - }); + const showHistory = + !!matchPath(location.pathname, { + path: matchDocumentHistory, + }) && can.listRevisions; + const showInsights = + !!matchPath(location.pathname, { + path: matchDocumentInsights, + }) && can.listViews; const showComments = !showInsights && !showHistory && + can.comment && ui.activeDocumentId && ui.commentsExpanded.includes(ui.activeDocumentId) && team.getPreference(TeamPreference.Commenting); @@ -121,7 +117,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { ); return ( - + { - + ); }; diff --git a/app/components/Avatar/AvatarWithPresence.tsx b/app/components/Avatar/AvatarWithPresence.tsx index abd02f543de3..106c1d6ce884 100644 --- a/app/components/Avatar/AvatarWithPresence.tsx +++ b/app/components/Avatar/AvatarWithPresence.tsx @@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next"; import styled, { css } from "styled-components"; import { s } from "@shared/styles"; import User from "~/models/User"; -import Avatar from "~/components/Avatar"; import Tooltip from "~/components/Tooltip"; +import Avatar from "./Avatar"; type Props = { user: User; @@ -34,7 +34,7 @@ function AvatarWithPresence({ return ( <> {user.name} {isCurrentUser && `(${t("You")})`} {status && ( diff --git a/app/components/Avatar/GroupAvatar.tsx b/app/components/Avatar/GroupAvatar.tsx new file mode 100644 index 000000000000..38aad5ada5c0 --- /dev/null +++ b/app/components/Avatar/GroupAvatar.tsx @@ -0,0 +1,35 @@ +import { GroupIcon } from "outline-icons"; +import * as React from "react"; +import { useTheme } from "styled-components"; +import Squircle from "@shared/components/Squircle"; +import Group from "~/models/Group"; +import { AvatarSize } from "../Avatar/Avatar"; + +type Props = { + /** The group to show an avatar for */ + group: Group; + /** The size of the icon, 24px is default to match standard avatars */ + size?: number; + /** The color of the avatar */ + color?: string; + /** The background color of the avatar */ + backgroundColor?: string; + className?: string; +}; + +export function GroupAvatar({ + color, + backgroundColor, + size = AvatarSize.Medium, + className, +}: Props) { + const theme = useTheme(); + return ( + + + + ); +} diff --git a/app/components/Avatar/Initials.tsx b/app/components/Avatar/Initials.tsx index bb46dfef76ef..296db0226729 100644 --- a/app/components/Avatar/Initials.tsx +++ b/app/components/Avatar/Initials.tsx @@ -1,4 +1,5 @@ import styled from "styled-components"; +import { s } from "@shared/styles"; import Flex from "~/components/Flex"; const Initials = styled(Flex)<{ @@ -11,7 +12,7 @@ const Initials = styled(Flex)<{ border-radius: 50%; width: 100%; height: 100%; - color: #fff; + color: ${s("white75")}; background-color: ${(props) => props.color}; width: ${(props) => props.size}px; height: ${(props) => props.size}px; diff --git a/app/components/Avatar/index.ts b/app/components/Avatar/index.ts index 4dc0cb18b797..be77236300e5 100644 --- a/app/components/Avatar/index.ts +++ b/app/components/Avatar/index.ts @@ -1,6 +1,7 @@ -import Avatar from "./Avatar"; +import Avatar, { IAvatar, AvatarSize } from "./Avatar"; import AvatarWithPresence from "./AvatarWithPresence"; +import { GroupAvatar } from "./GroupAvatar"; -export { AvatarWithPresence }; +export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence }; -export default Avatar; +export type { IAvatar }; diff --git a/app/components/Branding.tsx b/app/components/Branding.tsx index a5dff935233b..554086e8b983 100644 --- a/app/components/Branding.tsx +++ b/app/components/Branding.tsx @@ -34,16 +34,17 @@ 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; - left: 0; + right: 0; padding: 16px; + + &:hover { + background: ${s("sidebarControlHoverBackground")}; + } `}; `; diff --git a/app/components/Breadcrumb.tsx b/app/components/Breadcrumb.tsx index eacacade728d..d94ca8d49ec1 100644 --- a/app/components/Breadcrumb.tsx +++ b/app/components/Breadcrumb.tsx @@ -5,6 +5,7 @@ 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 = { @@ -75,6 +76,7 @@ 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; diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 4c4d9b96c981..63d60044f013 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -1,5 +1,5 @@ import { LocationDescriptor } from "history"; -import { ExpandedIcon } from "outline-icons"; +import { DisclosureIcon } from "outline-icons"; import { darken, lighten, transparentize } from "polished"; import * as React from "react"; import styled from "styled-components"; @@ -25,7 +25,7 @@ const RealButton = styled(ActionButton)` background: ${s("accent")}; color: ${s("accentText")}; box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px; - border-radius: 4px; + border-radius: 6px; font-size: 14px; font-weight: 500; height: 32px; @@ -49,8 +49,8 @@ const RealButton = styled(ActionButton)` &:disabled { cursor: default; pointer-events: none; - color: ${(props) => transparentize(0.5, props.theme.accentText)}; - background: ${(props) => lighten(0.2, props.theme.accent)}; + color: ${(props) => transparentize(0.3, props.theme.accentText)}; + background: ${(props) => transparentize(0.1, props.theme.accent)}; svg { fill: ${(props) => props.theme.white50}; @@ -105,7 +105,7 @@ const RealButton = styled(ActionButton)` background: ${lighten(0.05, props.theme.danger)}; } - &.focus-visible { + &:focus-visible { outline-color: ${darken(0.2, props.theme.danger)} !important; } `}; @@ -189,10 +189,14 @@ const Button = ( {hasIcon && ic} {hasText && } - {disclosure && } + {disclosure && } ); }; +const StyledDisclosureIcon = styled(DisclosureIcon)` + opacity: 0.8; +`; + export default React.forwardRef(Button); diff --git a/app/components/Collaborators.tsx b/app/components/Collaborators.tsx index b49aabd67dc2..0442410a4ec5 100644 --- a/app/components/Collaborators.tsx +++ b/app/components/Collaborators.tsx @@ -1,13 +1,13 @@ import filter from "lodash/filter"; import isEqual from "lodash/isEqual"; -import sortBy from "lodash/sortBy"; +import orderBy from "lodash/orderBy"; import uniq from "lodash/uniq"; import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { usePopoverState, PopoverDisclosure } from "reakit/Popover"; import Document from "~/models/Document"; -import AvatarWithPresence from "~/components/Avatar/AvatarWithPresence"; +import { AvatarWithPresence } from "~/components/Avatar"; import DocumentViews from "~/components/DocumentViews"; import Facepile from "~/components/Facepile"; import NudeButton from "~/components/NudeButton"; @@ -16,9 +16,14 @@ import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; type Props = { + /** The document to display live collaborators for */ document: Document; }; +/** + * Displays a list of live collaborators for a document, including their avatars + * and presence status. + */ function Collaborators(props: Props) { const { t } = useTranslation(); const user = useCurrentUser(); @@ -39,15 +44,16 @@ function Collaborators(props: Props) { // ensure currently present via websocket are always ordered first const collaborators = React.useMemo( () => - sortBy( + orderBy( filter( users.orderedData, - (user) => - (presentIds.includes(user.id) || - document.collaboratorIds.includes(user.id)) && - !user.isSuspended + (u) => + (presentIds.includes(u.id) || + document.collaboratorIds.includes(u.id)) && + !u.isSuspended ), - (user) => presentIds.includes(user.id) + [(u) => presentIds.includes(u.id), "id"], + ["asc", "asc"] ), [document.collaboratorIds, users.orderedData, presentIds] ); @@ -69,12 +75,19 @@ function Collaborators(props: Props) { placement: "bottom-end", }); + const limit = 8; + return ( <> - {(props) => ( - + {(popoverProps) => ( + { const isPresent = presentIds.includes(collaborator.id); diff --git a/app/components/Collection/CollectionEdit.tsx b/app/components/Collection/CollectionEdit.tsx new file mode 100644 index 000000000000..31dee8d7b44c --- /dev/null +++ b/app/components/Collection/CollectionEdit.tsx @@ -0,0 +1,32 @@ +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 ; +}); diff --git a/app/components/Collection/CollectionForm.tsx b/app/components/Collection/CollectionForm.tsx new file mode 100644 index 000000000000..28b31c31f629 --- /dev/null +++ b/app/components/Collection/CollectionForm.tsx @@ -0,0 +1,190 @@ +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 Icon from "~/components/Icon"; +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"; +import { EmptySelectValue } from "~/types"; + +const IconPicker = React.lazy(() => import("~/components/IconPicker")); + +export interface FormData { + name: string; + icon: string; + color: string | null; + 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 iconColor = React.useMemo( + () => collection?.color ?? randomElement(colorPalette), + [collection?.color] + ); + + const fallbackIcon = ; + + const { + register, + handleSubmit: formHandleSubmit, + formState, + watch, + control, + setValue, + setFocus, + } = useForm({ + mode: "all", + defaultValues: { + name: collection?.name ?? "", + icon: collection?.icon, + sharing: collection?.sharing ?? true, + permission: collection?.permission, + color: iconColor, + }, + }); + + 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" + ); + } + }, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]); + + React.useEffect(() => { + setTimeout(() => setFocus("name", { shouldSelect: true }), 100); + }, [setFocus]); + + const handleIconChange = React.useCallback( + (icon: string, color: string | null) => { + if (icon !== values.icon) { + setFocus("name"); + } + + setValue("icon", icon); + setValue("color", color); + }, + [setFocus, setValue, values.icon] + ); + + return ( +
+ + + Collections are used to group documents and choose permissions + + . + + + + + + } + autoComplete="off" + autoFocus + flex + /> + + + {/* Following controls are available in create flow, but moved elsewhere for edit */} + {!collection && ( + ( + { + field.onChange(value === EmptySelectValue ? null : value); + }} + note={t( + "The default access for workspace members, you can share with more users or groups later." + )} + /> + )} + /> + )} + + {team.sharing && ( + + )} + + + + + + ); +}); + +const StyledIconPicker = styled(IconPicker)` + margin-left: 4px; + margin-right: 4px; +`; diff --git a/app/components/Collection/CollectionNew.tsx b/app/components/Collection/CollectionNew.tsx new file mode 100644 index 000000000000..48535b6f023a --- /dev/null +++ b/app/components/Collection/CollectionNew.tsx @@ -0,0 +1,35 @@ +import { runInAction } from "mobx"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { toast } from "sonner"; +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 = await collections.save(data); + // Avoid flash of loading state for the new collection, we know it's empty. + runInAction(() => { + collection.documents = []; + }); + onSubmit?.(); + history.push(collection.path); + } catch (error) { + toast.error(error.message); + } + }, + [collections, onSubmit] + ); + + return ; +}); diff --git a/app/components/CollectionBreadcrumb.tsx b/app/components/CollectionBreadcrumb.tsx new file mode 100644 index 000000000000..bf8c59648f35 --- /dev/null +++ b/app/components/CollectionBreadcrumb.tsx @@ -0,0 +1,45 @@ +import { ArchiveIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import Collection from "~/models/Collection"; +import CollectionIcon from "~/components/Icons/CollectionIcon"; +import { MenuInternalLink } from "~/types"; +import { archivePath, collectionPath } from "~/utils/routeHelpers"; +import Breadcrumb from "./Breadcrumb"; + +type Props = { + collection: Collection; +}; + +export const CollectionBreadcrumb: React.FC = ({ collection }) => { + const { t } = useTranslation(); + + const items = React.useMemo(() => { + const collectionNode: MenuInternalLink = { + type: "route", + title: collection.name, + icon: , + to: collectionPath(collection.path), + }; + + const category: MenuInternalLink | undefined = collection.isArchived + ? { + type: "route", + icon: , + title: t("Archive"), + to: archivePath(), + } + : undefined; + + const output = []; + if (category) { + output.push(category); + } + + output.push(collectionNode); + + return output; + }, [collection, t]); + + return ; +}; diff --git a/app/components/CollectionDeleteDialog.tsx b/app/components/CollectionDeleteDialog.tsx index 78bcf9a982e9..7cab8ab978a5 100644 --- a/app/components/CollectionDeleteDialog.tsx +++ b/app/components/CollectionDeleteDialog.tsx @@ -41,7 +41,7 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) { danger > <> - + {team.defaultCollectionId === collection.id ? ( - + { try { await collection.save({ - description: getValue(), + data: getValue(false), }); setDirty(false); } catch (err) { @@ -109,7 +109,7 @@ function CollectionDescription({ collection }: Props) { > [...rootActions, templateActions, settingsActions], - [settingsActions, templateActions] + () => [ + ...recentDocumentActions, + ...rootActions, + templatesAction, + settingsAction, + ], + [recentDocumentActions, settingsAction, templatesAction] ); useCommandBarActions(commandBarActions); @@ -30,7 +37,9 @@ function CommandBar() { - + @@ -60,16 +69,23 @@ const Positioner = styled(KBarPositioner)` `; const SearchInput = styled(KBarSearch)` - padding: 16px 20px; - width: 100%; + position: relative; + padding: 16px 12px; + margin: 0 8px; + width: calc(100% - 16px); outline: none; border: none; background: ${s("menuBackground")}; color: ${s("text")}; + &:not(:last-child) { + border-bottom: 1px solid ${s("inputBorder")}; + } + &:disabled, &::placeholder { color: ${s("placeholder")}; + opacity: 1; } `; diff --git a/app/components/CommandBarItem.tsx b/app/components/CommandBar/CommandBarItem.tsx similarity index 94% rename from app/components/CommandBarItem.tsx rename to app/components/CommandBar/CommandBarItem.tsx index 11c094e34230..6f34115c54b6 100644 --- a/app/components/CommandBarItem.tsx +++ b/app/components/CommandBar/CommandBarItem.tsx @@ -5,7 +5,7 @@ import styled, { css, useTheme } from "styled-components"; import { s, ellipsis } from "@shared/styles"; import Flex from "~/components/Flex"; import Key from "~/components/Key"; -import Text from "./Text"; +import Text from "~/components/Text"; type Props = { action: ActionImpl; @@ -62,15 +62,15 @@ function CommandBarItem( {index > 0 ? ( <> {" "} - + then {" "} ) : ( "" )} - {sc.split("+").map((s) => ( - {s} + {sc.split("+").map((key) => ( + {key} ))} ))} diff --git a/app/components/CommandBarResults.tsx b/app/components/CommandBar/CommandBarResults.tsx similarity index 71% rename from app/components/CommandBarResults.tsx rename to app/components/CommandBar/CommandBarResults.tsx index c69e53d94c65..bf64e26af023 100644 --- a/app/components/CommandBarResults.tsx +++ b/app/components/CommandBar/CommandBarResults.tsx @@ -1,12 +1,16 @@ import { useMatches, KBarResults } from "kbar"; import * as React from "react"; import styled from "styled-components"; -import { s } from "@shared/styles"; -import CommandBarItem from "~/components/CommandBarItem"; +import Text from "~/components/Text"; +import CommandBarItem from "./CommandBarItem"; export default function CommandBarResults() { const { results, rootActionId } = useMatches(); + if (results.length === 0) { + return null; + } + return ( typeof item === "string" ? ( -
{item}
+
+ {item} +
) : ( { + const { documents, ui } = useStores(); + + return React.useMemo( + () => + documents.recentlyViewed + .filter((document) => document.id !== ui.activeDocumentId) + .slice(0, count) + .map((item) => + createAction({ + name: item.titleWithDefault, + analyticsName: "Recently viewed document", + section: RecentSection, + icon: item.icon ? ( + + ) : ( + + ), + perform: () => history.push(documentPath(item)), + }) + ), + [count, ui.activeDocumentId, documents.recentlyViewed] + ); +}; + +export default useRecentDocumentActions; diff --git a/app/hooks/useSettingsActions.tsx b/app/components/CommandBar/useSettingsAction.tsx similarity index 87% rename from app/hooks/useSettingsActions.tsx rename to app/components/CommandBar/useSettingsAction.tsx index 2cd777338086..1902daf44b4e 100644 --- a/app/hooks/useSettingsActions.tsx +++ b/app/components/CommandBar/useSettingsAction.tsx @@ -2,10 +2,10 @@ import { SettingsIcon } from "outline-icons"; import * as React from "react"; import { createAction } from "~/actions"; import { NavigationSection } from "~/actions/sections"; +import useSettingsConfig from "~/hooks/useSettingsConfig"; import history from "~/utils/history"; -import useSettingsConfig from "./useSettingsConfig"; -const useSettingsActions = () => { +const useSettingsAction = () => { const config = useSettingsConfig(); const actions = React.useMemo( () => @@ -38,4 +38,4 @@ const useSettingsActions = () => { return navigateToSettings; }; -export default useSettingsActions; +export default useSettingsAction; diff --git a/app/hooks/useTemplateActions.tsx b/app/components/CommandBar/useTemplatesAction.tsx similarity index 81% rename from app/hooks/useTemplateActions.tsx rename to app/components/CommandBar/useTemplatesAction.tsx index 0ee7e7fed1eb..32296531dbd5 100644 --- a/app/hooks/useTemplateActions.tsx +++ b/app/components/CommandBar/useTemplatesAction.tsx @@ -1,13 +1,13 @@ import { NewDocumentIcon, ShapesIcon } from "outline-icons"; import * as React from "react"; -import EmojiIcon from "~/components/Icons/EmojiIcon"; +import Icon from "~/components/Icon"; import { createAction } from "~/actions"; import { DocumentSection } from "~/actions/sections"; +import useStores from "~/hooks/useStores"; import history from "~/utils/history"; import { newDocumentPath } from "~/utils/routeHelpers"; -import useStores from "./useStores"; -const useTemplatesActions = () => { +const useTemplatesAction = () => { const { documents } = useStores(); React.useEffect(() => { @@ -21,19 +21,19 @@ const useTemplatesActions = () => { name: item.titleWithDefault, analyticsName: "New document", section: DocumentSection, - icon: item.emoji ? ( - + icon: item.icon ? ( + ) : ( ), keywords: "create", - perform: ({ activeCollectionId, inStarredSection }) => + perform: ({ activeCollectionId, sidebarContext }) => history.push( newDocumentPath(item.collectionId ?? activeCollectionId, { templateId: item.id, }), { - starred: inStarredSection, + sidebarContext, } ), }) @@ -60,4 +60,4 @@ const useTemplatesActions = () => { return newFromTemplate; }; -export default useTemplatesActions; +export default useTemplatesAction; diff --git a/app/components/CommentDeleteDialog.tsx b/app/components/CommentDeleteDialog.tsx index 5e1e90285921..eae67f31725e 100644 --- a/app/components/CommentDeleteDialog.tsx +++ b/app/components/CommentDeleteDialog.tsx @@ -33,7 +33,7 @@ function CommentDeleteDialog({ comment, onSubmit }: Props) { savingText={`${t("Deleting")}…`} danger > - + {hasChildComments ? ( Are you sure you want to permanently delete this entire comment diff --git a/app/components/ConfirmMoveDialog.tsx b/app/components/ConfirmMoveDialog.tsx new file mode 100644 index 000000000000..e7b3371700a3 --- /dev/null +++ b/app/components/ConfirmMoveDialog.tsx @@ -0,0 +1,64 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { CollectionPermission, NavigationNode } from "@shared/types"; +import type Collection from "~/models/Collection"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; +import useStores from "~/hooks/useStores"; + +type Props = { + /** The navigation node to move, must represent a document. */ + item: NavigationNode; + /** The collection to move the document to. */ + collection: Collection; + /** The parent document to move the document under. */ + parentDocumentId?: string | null; + /** The index to move the document to. */ + index?: number | null; +}; + +function ConfirmMoveDialog({ collection, item, ...rest }: Props) { + const { documents, dialogs, collections } = useStores(); + const { t } = useTranslation(); + const prevCollection = collections.get(item.collectionId!); + const accessMapping: Record | "null", string> = + { + [CollectionPermission.Admin]: t("manage access"), + [CollectionPermission.ReadWrite]: t("view and edit access"), + [CollectionPermission.Read]: t("view only access"), + null: t("no access"), + }; + + const handleSubmit = async () => { + await documents.move({ + documentId: item.id, + collectionId: collection.id, + ...rest, + }); + dialogs.closeAllModals(); + }; + + return ( + + , + }} + /> + + ); +} + +export default observer(ConfirmMoveDialog); diff --git a/app/components/ConfirmationDialog.tsx b/app/components/ConfirmationDialog.tsx index 074664fb4e90..299ebd396403 100644 --- a/app/components/ConfirmationDialog.tsx +++ b/app/components/ConfirmationDialog.tsx @@ -51,17 +51,19 @@ const ConfirmationDialog: React.FC = ({ return (
- {children} + + {children} - - + + + ); diff --git a/app/components/ConnectionStatus.tsx b/app/components/ConnectionStatus.tsx index b09018d7d220..6d799596335c 100644 --- a/app/components/ConnectionStatus.tsx +++ b/app/components/ConnectionStatus.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import { DisconnectedIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import styled, { useTheme } from "styled-components"; +import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import Fade from "~/components/Fade"; import NudeButton from "~/components/NudeButton"; @@ -11,7 +11,6 @@ import useStores from "~/hooks/useStores"; function ConnectionStatus() { const { ui } = useStores(); - const theme = useTheme(); const { t } = useTranslation(); const codeToMessage = { @@ -36,13 +35,13 @@ function ConnectionStatus() { }; const message = ui.multiplayerErrorCode - ? codeToMessage[ui.multiplayerErrorCode] + ? codeToMessage[ui.multiplayerErrorCode as keyof typeof codeToMessage] : undefined; return ui.multiplayerStatus === "connecting" || ui.multiplayerStatus === "disconnected" ? ( {message.title} @@ -61,7 +60,7 @@ function ConnectionStatus() { > @@ -72,7 +71,7 @@ const Button = styled(NudeButton)` display: none; position: fixed; bottom: 0; - margin: 24px; + margin: 20px; transform: translateX(-32px); ${breakpoint("tablet")` diff --git a/app/components/ContextMenu/MenuItem.tsx b/app/components/ContextMenu/MenuItem.tsx index 9fbf7c2bb875..2a3e76dd56de 100644 --- a/app/components/ContextMenu/MenuItem.tsx +++ b/app/components/ContextMenu/MenuItem.tsx @@ -1,15 +1,18 @@ import { LocationDescriptor } from "history"; import { CheckmarkIcon } from "outline-icons"; +import { ellipsis, transparentize } 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 { s } from "@shared/styles"; +import Text from "../Text"; import MenuIconWrapper from "./MenuIconWrapper"; type Props = { id?: string; - onClick?: (event: React.SyntheticEvent) => void | Promise; + onClick?: (event: React.MouseEvent) => void | Promise; active?: boolean; selected?: boolean; disabled?: boolean; @@ -41,43 +44,43 @@ const MenuItem = ( ) => { const content = React.useCallback( (props) => { + // Preventing default mousedown otherwise menu items do not work in Firefox, + // which triggers the hideOnClickOutside handler first via mousedown – hiding + // and un-rendering the menu contents. + const preventDefault = (ev: React.MouseEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + }; + const handleClick = async (ev: React.MouseEvent) => { hide?.(); if (onClick) { - ev.preventDefault(); + preventDefault(ev); await onClick(ev); } }; - // Preventing default mousedown otherwise menu items do not work in Firefox, - // which triggers the hideOnClickOutside handler first via mousedown – hiding - // and un-rendering the menu contents. - const handleMouseDown = (ev: React.MouseEvent) => { - ev.preventDefault(); - ev.stopPropagation(); - }; - return ( , ])} > {selected !== undefined && ( - <> + {selected ? : } -   - + )} - {icon && {icon}} - {children} + {icon && {icon}} + {children} ); }, @@ -102,6 +105,12 @@ const Spacer = styled.svg` flex-shrink: 0; `; +const Title = styled.div` + ${ellipsis()} + flex-grow: 1; + display: flex; +`; + type MenuAnchorProps = { level?: number; disabled?: boolean; @@ -130,10 +139,6 @@ export const MenuAnchorCSS = css` white-space: nowrap; position: relative; - svg:not(:last-child) { - margin-right: 4px; - } - svg { flex-shrink: 0; opacity: ${(props) => (props.disabled ? ".5" : 1)}; @@ -148,15 +153,20 @@ export const MenuAnchorCSS = css` @media (hover: hover) { &:hover, &:focus, - &.focus-visible { + &:focus-visible { color: ${props.theme.accentText}; background: ${props.dangerous ? props.theme.danger : props.theme.accent}; box-shadow: none; cursor: var(--pointer); svg { + color: ${props.theme.accentText}; fill: ${props.theme.accentText}; } + + ${Text} { + color: ${transparentize(0.5, props.theme.accentText)}; + } } } `} @@ -187,4 +197,13 @@ export const MenuAnchor = styled.a` ${MenuAnchorCSS} `; +const SelectedWrapper = styled.span` + width: 24px; + height: 24px; + margin-right: 4px; + margin-left: -8px; + flex-shrink: 0; + color: ${s("textSecondary")}; +`; + export default React.forwardRef(MenuItem); diff --git a/app/components/ContextMenu/Template.tsx b/app/components/ContextMenu/Template.tsx index 8c79f5b8dd54..cb4bf7c191ce 100644 --- a/app/components/ContextMenu/Template.tsx +++ b/app/components/ContextMenu/Template.tsx @@ -30,6 +30,7 @@ type Props = Omit & { actions?: (Action | MenuSeparator | MenuHeading)[]; context?: Partial; items?: TMenuItem[]; + showIcons?: boolean; }; const Disclosure = styled(ExpandedIcon)` @@ -98,7 +99,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] { }); } -function Template({ items, actions, context, ...menu }: Props) { +function Template({ items, actions, context, showIcons, ...menu }: Props) { const ctx = useActionContext({ isContextMenu: true, }); @@ -124,9 +125,10 @@ function Template({ items, actions, context, ...menu }: Props) { if ( iconIsPresentInAnyMenuItem && item.type !== "separator" && - item.type !== "heading" + item.type !== "heading" && + showIcons !== false ) { - item.icon = item.icon || ; + item.icon = item.icon || ; } if (item.type === "route") { @@ -138,7 +140,7 @@ function Template({ items, actions, context, ...menu }: Props) { key={index} disabled={item.disabled} selected={item.selected} - icon={item.icon} + icon={showIcons !== false ? item.icon : undefined} {...menu} > {item.title} @@ -156,7 +158,7 @@ function Template({ items, actions, context, ...menu }: Props) { selected={item.selected} level={item.level} target={item.href.startsWith("#") ? undefined : "_blank"} - icon={item.icon} + icon={showIcons !== false ? item.icon : undefined} {...menu} > {item.title} @@ -174,7 +176,7 @@ function Template({ items, actions, context, ...menu }: Props) { selected={item.selected} dangerous={item.dangerous} key={index} - icon={item.icon} + icon={showIcons !== false ? item.icon : undefined} {...menu} > {item.title} @@ -190,7 +192,12 @@ function Template({ items, actions, context, ...menu }: Props) { id={`${item.title}-${index}`} templateItems={item.items} parentMenuState={menu} - title={} + title={ + <Title + title={item.title} + icon={showIcons !== false ? item.icon : undefined} + /> + } {...menu} /> ); @@ -220,7 +227,7 @@ function Title({ }) { return ( <Flex align="center"> - {icon && <MenuIconWrapper>{icon}</MenuIconWrapper>} + {icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>} {title} </Flex> ); diff --git a/app/components/ContextMenu/index.tsx b/app/components/ContextMenu/index.tsx index ae3938d62ee5..35fe1ee265c3 100644 --- a/app/components/ContextMenu/index.tsx +++ b/app/components/ContextMenu/index.tsx @@ -6,6 +6,7 @@ import styled, { DefaultTheme } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { depths, s } from "@shared/styles"; import Scrollable from "~/components/Scrollable"; +import useEventListener from "~/hooks/useEventListener"; import useMenuContext from "~/hooks/useMenuContext"; import useMenuHeight from "~/hooks/useMenuHeight"; import useMobile from "~/hooks/useMobile"; @@ -38,6 +39,8 @@ 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,10 +51,13 @@ type Props = MenuStateReturn & { onClick?: (ev: React.MouseEvent) => void; /** The maximum width of the context menu. */ maxWidth?: number; + /** The minimum height of the context menu. */ + minHeight?: number; children?: React.ReactNode; }; const ContextMenu: React.FC<Props> = ({ + menuRef, children, onOpen, onClose, @@ -105,7 +111,12 @@ const ContextMenu: React.FC<Props> = ({ // trigger and the bottom of the window return ( <> - <Menu hideOnClickOutside={!isMobile} preventBodyScroll={false} {...rest}> + <Menu + ref={menuRef} + hideOnClickOutside={!isMobile} + preventBodyScroll={false} + {...rest} + > {(props) => ( <InnerContextMenu // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -126,6 +137,7 @@ type InnerContextMenuProps = MenuStateReturn & { menuProps: { style?: React.CSSProperties; placement: string }; children: React.ReactNode; maxWidth?: number; + minHeight?: number; }; /** @@ -163,6 +175,32 @@ const InnerContextMenu = (props: InnerContextMenuProps) => { }; }, [props.isSubMenu, props.visible]); + useEventListener( + "animationstart", + (event) => { + if (event.target instanceof HTMLElement) { + const parent = event.target.parentElement; + if (parent) { + parent.style.pointerEvents = "none"; + } + } + }, + backgroundRef.current + ); + + useEventListener( + "animationend", + (event) => { + if (event.target instanceof HTMLElement) { + const parent = event.target.parentElement; + if (parent) { + parent.style.pointerEvents = "auto"; + } + } + }, + backgroundRef.current + ); + const style = topAnchor && !isMobile ? { @@ -185,6 +223,7 @@ const InnerContextMenu = (props: InnerContextMenuProps) => { <Background dir="auto" maxWidth={props.maxWidth} + minHeight={props.minHeight} topAnchor={topAnchor} rightAnchor={rightAnchor} ref={backgroundRef} @@ -215,6 +254,32 @@ export const Position = styled.div` position: absolute; z-index: ${depths.menu}; + // Note: pointer events are re-enabled after the animation ends, see event listeners above + pointer-events: none; + + &:focus-visible { + transition-delay: 250ms; + transition-property: outline-width; + transition-duration: 0; + outline: none; + + &:after { + content: ""; + position: absolute; + top: 1px; + left: 1px; + right: 1px; + bottom: 1px; + pointer-events: none; + border-radius: 4px; + + outline-color: ${s("accent")}; + outline-width: initial; + outline-offset: -1px; + outline-style: solid; + } + } + /* * overrides make mobile-first coding style challenging * so we explicitly define mobile breakpoint here @@ -233,6 +298,7 @@ type BackgroundProps = { topAnchor?: boolean; rightAnchor?: boolean; maxWidth?: number; + minHeight?: number; theme: DefaultTheme; }; @@ -244,9 +310,8 @@ export const Background = styled(Scrollable)<BackgroundProps>` border-radius: 6px; padding: 6px; min-width: 180px; - min-height: 44px; + min-height: ${(props) => props.minHeight || 44}px; max-height: 75vh; - pointer-events: all; font-weight: normal; @media print { diff --git a/app/components/CopyToClipboard.ts b/app/components/CopyToClipboard.ts index 3f17db8eb243..2fcd7b49dd62 100644 --- a/app/components/CopyToClipboard.ts +++ b/app/components/CopyToClipboard.ts @@ -1,5 +1,6 @@ import copy from "copy-to-clipboard"; import * as React from "react"; +import { mergeRefs } from "react-merge-refs"; import env from "~/env"; type Props = { @@ -9,32 +10,43 @@ type Props = { onCopy?: () => void; }; -class CopyToClipboard extends React.PureComponent<Props> { - onClick = (ev: React.SyntheticEvent) => { - const { text, onCopy, children } = this.props; - const elem = React.Children.only(children); - - copy(text, { - debug: env.ENVIRONMENT !== "production", - format: "text/plain", - }); - - onCopy?.(); - - if (elem && elem.props && typeof elem.props.onClick === "function") { - elem.props.onClick(ev); - } - }; - - render() { - const { text, onCopy, children, ...rest } = this.props; - const elem = React.Children.only(children); - if (!elem) { - return null; - } - - return React.cloneElement(elem, { ...rest, onClick: this.onClick }); +function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) { + const { text, onCopy, children, ...rest } = props; + + const onClick = React.useCallback( + (ev: React.MouseEvent<HTMLElement>) => { + const elem = React.Children.only(children); + + copy(text, { + debug: env.ENVIRONMENT !== "production", + format: "text/plain", + }); + + onCopy?.(); + + if (elem && elem.props && typeof elem.props.onClick === "function") { + elem.props.onClick(ev); + } else { + ev.preventDefault(); + ev.stopPropagation(); + } + }, + [children, onCopy, text] + ); + + const elem = React.Children.only(children); + if (!elem) { + return null; } + + return React.cloneElement(elem, { + ...rest, + ref: + "ref" in elem + ? mergeRefs([elem.ref as React.MutableRefObject<HTMLElement>, ref]) + : ref, + onClick, + }); } -export default CopyToClipboard; +export default React.forwardRef(CopyToClipboard); diff --git a/app/components/DefaultCollectionInputSelect.tsx b/app/components/DefaultCollectionInputSelect.tsx index a20c044b329f..9dd7af5b37cc 100644 --- a/app/components/DefaultCollectionInputSelect.tsx +++ b/app/components/DefaultCollectionInputSelect.tsx @@ -49,7 +49,7 @@ const DefaultCollectionInputSelect = ({ const options = React.useMemo( () => - collections.publicCollections.reduce( + collections.nonPrivate.reduce( (acc, collection) => [ ...acc, { @@ -78,7 +78,7 @@ const DefaultCollectionInputSelect = ({ }, ] ), - [collections.publicCollections, t] + [collections.nonPrivate, t] ); if (fetching) { diff --git a/app/components/Dialogs.tsx b/app/components/Dialogs.tsx index afd6b611b5af..e7f4d0558928 100644 --- a/app/components/Dialogs.tsx +++ b/app/components/Dialogs.tsx @@ -22,9 +22,10 @@ function Dialogs() { <Modal key={id} isOpen={modal.isOpen} - isCentered={modal.isCentered} + fullscreen={modal.fullscreen ?? false} onRequestClose={() => dialogs.closeModal(id)} title={modal.title} + style={modal.style} > {modal.content} </Modal> diff --git a/app/components/DocumentBreadcrumb.tsx b/app/components/DocumentBreadcrumb.tsx index 6426f7733e45..9f87573aa2ac 100644 --- a/app/components/DocumentBreadcrumb.tsx +++ b/app/components/DocumentBreadcrumb.tsx @@ -6,7 +6,9 @@ import styled from "styled-components"; import type { NavigationNode } from "@shared/types"; import Document from "~/models/Document"; import Breadcrumb from "~/components/Breadcrumb"; +import Icon from "~/components/Icon"; import CollectionIcon from "~/components/Icons/CollectionIcon"; +import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { MenuInternalLink } from "~/types"; import { @@ -15,7 +17,6 @@ import { settingsPath, trashPath, } from "~/utils/routeHelpers"; -import EmojiIcon from "./Icons/EmojiIcon"; type Props = { children?: React.ReactNode; @@ -67,26 +68,27 @@ const DocumentBreadcrumb: React.FC<Props> = ({ const collection = document.collectionId ? collections.get(document.collectionId) : undefined; + const can = usePolicy(collection); React.useEffect(() => { - void document.loadRelations(); + void document.loadRelations({ withoutPolicies: true }); }, [document]); let collectionNode: MenuInternalLink | undefined; - if (collection) { + if (collection && can.readDocument) { collectionNode = { type: "route", title: collection.name, icon: <CollectionIcon collection={collection} expanded />, - to: collectionPath(collection.url), + to: collectionPath(collection.path), }; - } else if (document.collectionId && !collection) { + } else if (document.isCollectionDeleted) { collectionNode = { type: "route", title: t("Deleted Collection"), icon: undefined, - to: collectionPath("deleted-collection"), + to: "", }; } @@ -106,9 +108,9 @@ const DocumentBreadcrumb: React.FC<Props> = ({ path.slice(0, -1).forEach((node: NavigationNode) => { output.push({ type: "route", - title: node.emoji ? ( + title: node.icon ? ( <> - <EmojiIcon emoji={node.emoji} /> {node.title} + <StyledIcon value={node.icon} color={node.color} /> {node.title} </> ) : ( node.title @@ -144,6 +146,10 @@ const DocumentBreadcrumb: React.FC<Props> = ({ ); }; +const StyledIcon = styled(Icon)` + margin-right: 2px; +`; + const SmallSlash = styled(GoToIcon)` width: 12px; height: 12px; diff --git a/app/components/DocumentCard.tsx b/app/components/DocumentCard.tsx index 5da426d51f2d..fb9b802fa9de 100644 --- a/app/components/DocumentCard.tsx +++ b/app/components/DocumentCard.tsx @@ -7,17 +7,19 @@ 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 { IconType } from "@shared/types"; +import { determineIconType } from "@shared/utils/icon"; import Document from "~/models/Document"; import Pin from "~/models/Pin"; import Flex from "~/components/Flex"; +import Icon from "~/components/Icon"; import NudeButton from "~/components/NudeButton"; import Time from "~/components/Time"; 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"; @@ -52,6 +54,8 @@ function DocumentCard(props: Props) { disabled: !isDraggable || !canUpdatePin, }); + const hasEmojiInTitle = determineIconType(document.icon) === IconType.Emoji; + const style = { transform: CSS.Transform.toString(transform), transition, @@ -109,12 +113,18 @@ function DocumentCard(props: Props) { <path d="M19.5 19.5H6C2.96243 19.5 0.5 17.0376 0.5 14V0.5H0.792893L19.5 19.2071V19.5Z" /> </Fold> - {document.emoji ? ( - <Squircle color={theme.slateLight}> - <EmojiIcon emoji={document.emoji} size={24} /> - </Squircle> + {document.icon ? ( + <DocumentSquircle + icon={document.icon} + color={document.color ?? undefined} + /> ) : ( - <Squircle color={collection?.color}> + <Squircle + color={ + collection?.color ?? + (!pin?.collectionId ? theme.slateLight : theme.slateDark) + } + > {collection?.icon && collection?.icon !== "letter" && collection?.icon !== "collection" && @@ -127,8 +137,8 @@ function DocumentCard(props: Props) { )} <div> <Heading dir={document.dir}> - {document.emoji - ? document.titleWithDefault.replace(document.emoji, "") + {hasEmojiInTitle + ? document.titleWithDefault.replace(document.icon!, "") : document.titleWithDefault} </Heading> <DocumentMeta size="xsmall"> @@ -145,7 +155,7 @@ function DocumentCard(props: Props) { {canUpdatePin && ( <Actions dir={document.dir} gap={4}> {!isDragging && pin && ( - <Tooltip tooltip={t("Unpin")}> + <Tooltip content={t("Unpin")}> <PinButton onClick={handleUnpin} aria-label={t("Unpin")}> <CloseIcon /> </PinButton> @@ -159,6 +169,24 @@ function DocumentCard(props: Props) { ); } +const DocumentSquircle = ({ + icon, + color, +}: { + icon: string; + color?: string; +}) => { + const theme = useTheme(); + const iconType = determineIconType(icon)!; + const squircleColor = iconType === IconType.SVG ? color : theme.slateLight; + + return ( + <Squircle color={squircleColor}> + <Icon value={icon} color={theme.white} forceColor /> + </Squircle> + ); +}; + const Clock = styled(ClockIcon)` flex-shrink: 0; `; diff --git a/app/components/DocumentContext.ts b/app/components/DocumentContext.ts deleted file mode 100644 index 874d72e1475c..000000000000 --- a/app/components/DocumentContext.ts +++ /dev/null @@ -1,37 +0,0 @@ -import * as React from "react"; -import { Editor } from "~/editor"; -import useIdle from "~/hooks/useIdle"; - -export type DocumentContextValue = { - /** The current editor instance for this document. */ - editor: Editor | null; - /** Set the current editor instance for this document. */ - setEditor: (editor: Editor) => void; -}; - -const DocumentContext = React.createContext<DocumentContextValue>({ - editor: null, - // eslint-disable-next-line @typescript-eslint/no-empty-function - setEditor() {}, -}); - -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; diff --git a/app/components/DocumentContext.tsx b/app/components/DocumentContext.tsx new file mode 100644 index 000000000000..e7584da57731 --- /dev/null +++ b/app/components/DocumentContext.tsx @@ -0,0 +1,84 @@ +import { action, computed, observable } from "mobx"; +import React, { PropsWithChildren } from "react"; +import { Heading } from "@shared/utils/ProsemirrorHelper"; +import Document from "~/models/Document"; +import { Editor } from "~/editor"; + +class DocumentContext { + /** The current document */ + document?: Document; + + /** The editor instance for this document */ + editor?: Editor; + + @observable + isEditorInitialized: boolean = false; + + @observable + headings: Heading[] = []; + + @computed + get hasHeadings() { + return this.headings.length > 0; + } + + @action + setDocument = (document: Document) => { + this.document = document; + this.updateState(); + }; + + @action + setEditor = (editor: Editor) => { + this.editor = editor; + this.updateState(); + }; + + @action + setEditorInitialized = (initialized: boolean) => { + this.isEditorInitialized = initialized; + }; + + @action + updateState = () => { + this.updateHeadings(); + this.updateTasks(); + }; + + private updateHeadings() { + const currHeadings = this.editor?.getHeadings() ?? []; + const hasChanged = + currHeadings.map((h) => h.level + h.title).join("") !== + this.headings.map((h) => h.level + h.title).join(""); + + if (hasChanged) { + this.headings = currHeadings; + } + } + + private updateTasks() { + const tasks = this.editor?.getTasks() ?? []; + const total = tasks.length ?? 0; + const completed = tasks.filter((t) => t.completed).length ?? 0; + this.document?.updateTasks(total, completed); + } +} + +const Context = React.createContext<DocumentContext | null>(null); + +export const useDocumentContext = () => { + const ctx = React.useContext(Context); + if (!ctx) { + throw new Error( + "useDocumentContext must be used within DocumentContextProvider" + ); + } + return ctx; +}; + +export const DocumentContextProvider = ({ + children, +}: PropsWithChildren<unknown>) => { + const context = React.useMemo(() => new DocumentContext(), []); + return <Context.Provider value={context}>{children}</Context.Provider>; +}; diff --git a/app/components/DocumentExplorer.tsx b/app/components/DocumentExplorer.tsx index 5d6802f2680b..a09161d6df58 100644 --- a/app/components/DocumentExplorer.tsx +++ b/app/components/DocumentExplorer.tsx @@ -11,15 +11,15 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import AutoSizer from "react-virtualized-auto-sizer"; import { FixedSizeList as List } from "react-window"; -import scrollIntoView from "smooth-scroll-into-view-if-needed"; +import scrollIntoView from "scroll-into-view-if-needed"; import styled, { useTheme } from "styled-components"; import breakpoint from "styled-components-breakpoint"; import { NavigationNode } from "@shared/types"; import DocumentExplorerNode from "~/components/DocumentExplorerNode"; import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult"; import Flex from "~/components/Flex"; +import Icon from "~/components/Icon"; import CollectionIcon from "~/components/Icons/CollectionIcon"; -import EmojiIcon from "~/components/Icons/EmojiIcon"; import { Outline } from "~/components/Input"; import InputSearch from "~/components/InputSearch"; import Text from "~/components/Text"; @@ -216,25 +216,30 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) { }) => { const node = data[index]; const isCollection = node.type === "collection"; - let icon, title: string, emoji: string | undefined, path; + let renderedIcon, + title: string, + icon: string | undefined, + color: string | undefined, + path; if (isCollection) { const col = collections.get(node.collectionId as string); - icon = col && ( + renderedIcon = col && ( <CollectionIcon collection={col} expanded={isExpanded(index)} /> ); title = node.title; } else { const doc = documents.get(node.id); - emoji = doc?.emoji ?? node.emoji; + icon = doc?.icon ?? node.icon ?? node.emoji; + color = doc?.color ?? node.color; title = doc?.title ?? node.title; - if (emoji) { - icon = <EmojiIcon emoji={emoji} />; + if (icon) { + renderedIcon = <Icon value={icon} color={color} />; } else if (doc?.isStarred) { - icon = <StarredIcon color={theme.yellow} />; + renderedIcon = <StarredIcon color={theme.yellow} />; } else { - icon = <DocumentIcon color={theme.textSecondary} />; + renderedIcon = <DocumentIcon color={theme.textSecondary} />; } path = ancestors(node) @@ -254,7 +259,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) { }} onPointerMove={() => setActiveNode(index)} onClick={() => toggleSelect(index)} - icon={icon} + icon={renderedIcon} title={title} path={path} /> @@ -275,7 +280,7 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) { selected={isSelected(index)} active={activeNode === index} expanded={isExpanded(index)} - icon={icon} + icon={renderedIcon} title={title} depth={node.depth as number} hasChildren={hasChildren(index)} @@ -389,7 +394,9 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) { </AutoSizer> ) : ( <FlexContainer> - <Text type="secondary">{t("No results found")}.</Text> + <Text as="p" type="secondary"> + {t("No results found")}. + </Text> </FlexContainer> )} </ListContainer> diff --git a/app/components/DocumentExplorerNode.tsx b/app/components/DocumentExplorerNode.tsx index ca5bfdba4ced..2d1127d1d273 100644 --- a/app/components/DocumentExplorerNode.tsx +++ b/app/components/DocumentExplorerNode.tsx @@ -120,6 +120,7 @@ export const Node = styled.span<{ color: ${props.theme.white}; svg { + color: ${props.theme.white}; fill: ${props.theme.white}; } `} diff --git a/app/components/DocumentExplorerSearchResult.tsx b/app/components/DocumentExplorerSearchResult.tsx index d1c624c1ac29..a5d7e26a003c 100644 --- a/app/components/DocumentExplorerSearchResult.tsx +++ b/app/components/DocumentExplorerSearchResult.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import scrollIntoView from "smooth-scroll-into-view-if-needed"; +import scrollIntoView from "scroll-into-view-if-needed"; import styled from "styled-components"; import { ellipsis } from "@shared/styles"; import { Node as SearchResult } from "~/components/DocumentExplorerNode"; diff --git a/app/components/DocumentListItem.tsx b/app/components/DocumentListItem.tsx index 1b8862952451..6a17b5e50b99 100644 --- a/app/components/DocumentListItem.tsx +++ b/app/components/DocumentListItem.tsx @@ -1,17 +1,21 @@ +import { + useFocusEffect, + useRovingTabIndex, +} from "@getoutline/react-roving-tabindex"; import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import { CompositeStateReturn, CompositeItem } from "reakit/Composite"; import styled, { css } from "styled-components"; import breakpoint from "styled-components-breakpoint"; +import EventBoundary from "@shared/components/EventBoundary"; import { s } from "@shared/styles"; import Document from "~/models/Document"; import Badge from "~/components/Badge"; import DocumentMeta from "~/components/DocumentMeta"; -import EventBoundary from "~/components/EventBoundary"; import Flex from "~/components/Flex"; import Highlight from "~/components/Highlight"; +import Icon from "~/components/Icon"; import NudeButton from "~/components/NudeButton"; import StarButton, { AnimatedStar } from "~/components/Star"; import Tooltip from "~/components/Tooltip"; @@ -20,7 +24,6 @@ import useCurrentUser from "~/hooks/useCurrentUser"; import DocumentMenu from "~/menus/DocumentMenu"; import { hover } from "~/styles"; import { documentPath } from "~/utils/routeHelpers"; -import EmojiIcon from "./Icons/EmojiIcon"; type Props = { document: Document; @@ -32,14 +35,13 @@ type Props = { showPin?: boolean; showDraft?: boolean; showTemplate?: boolean; -} & CompositeStateReturn; +}; const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi; function replaceResultMarks(tag: string) { - // don't use SEARCH_RESULT_REGEX here as it causes - // an infinite loop to trigger a regex inside it's own callback - return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1"); + // don't use SEARCH_RESULT_REGEX directly here as it causes an infinite loop + return tag.replace(new RegExp(SEARCH_RESULT_REGEX.source), "$1"); } function DocumentListItem( @@ -50,6 +52,15 @@ function DocumentListItem( const user = useCurrentUser(); const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean(); + let itemRef: React.Ref<HTMLAnchorElement> = + React.useRef<HTMLAnchorElement>(null); + if (ref) { + itemRef = ref; + } + + const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false); + useFocusEffect(focused, itemRef); + const { document, showParentDocuments, @@ -65,13 +76,11 @@ function DocumentListItem( const queryIsInTitle = !!highlight && !!document.title.toLowerCase().includes(highlight.toLowerCase()); - const canStar = - !document.isDraft && !document.isArchived && !document.isTemplate; + const canStar = !document.isArchived && !document.isTemplate; return ( - <CompositeItem - as={DocumentLink} - ref={ref} + <DocumentLink + ref={itemRef} dir={document.dir} role="menuitem" $isStarred={document.isStarred} @@ -83,12 +92,13 @@ function DocumentListItem( }, }} {...rest} + {...rovingTabIndex} > <Content> <Heading dir={document.dir}> - {document.emoji && ( + {document.icon && ( <> - <EmojiIcon emoji={document.emoji} size={24} /> + <Icon value={document.icon} color={document.color ?? undefined} />   </> )} @@ -97,23 +107,23 @@ function DocumentListItem( 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 && ( - <StarPositioner> - <StarButton document={document} /> - </StarPositioner> - )} {document.isDraft && showDraft && ( <Tooltip - tooltip={t("Only visible to you")} + content={t("Only visible to you")} delay={500} placement="top" > <Badge>{t("Draft")}</Badge> </Tooltip> )} + {canStar && ( + <StarPositioner> + <StarButton document={document} /> + </StarPositioner> + )} {document.isTemplate && showTemplate && ( <Badge primary>{t("Template")}</Badge> )} @@ -143,7 +153,7 @@ function DocumentListItem( modal={false} /> </Actions> - </CompositeItem> + </DocumentLink> ); } @@ -264,6 +274,8 @@ const ResultContext = styled(Highlight)` font-size: 15px; margin-top: -0.25em; margin-bottom: 0.25em; + max-height: 90px; + overflow: hidden; `; export default observer(React.forwardRef(DocumentListItem)); diff --git a/app/components/DocumentMeta.tsx b/app/components/DocumentMeta.tsx index 03bd2a4738a0..bc4160461f05 100644 --- a/app/components/DocumentMeta.tsx +++ b/app/components/DocumentMeta.tsx @@ -95,6 +95,21 @@ 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> @@ -113,15 +128,6 @@ const DocumentMeta: React.FC<Props> = ({ <Time dateTime={publishedAt} addSuffix /> </span> ); - } else if (isDraft) { - content = ( - <span> - {lastUpdatedByCurrentUser - ? t("You saved") - : t("{{ userName }} saved", { userName })}{" "} - <Time dateTime={updatedAt} addSuffix /> - </span> - ); } else { content = ( <Modified highlight={modifiedSinceViewed && !lastUpdatedByCurrentUser}> @@ -134,7 +140,7 @@ const DocumentMeta: React.FC<Props> = ({ } const nestedDocumentsCount = collection - ? collection.getDocumentChildren(document.id).length + ? collection.getChildrenForDocument(document.id).length : 0; const canShowProgressBar = isTasks && !isTemplate; @@ -162,7 +168,13 @@ const DocumentMeta: React.FC<Props> = ({ }; return ( - <Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr"> + <Container + align="center" + rtl={document.dir === "rtl"} + {...rest} + dir="ltr" + lang="" + > {to ? ( <Link to={to} replace={replace}> {content} diff --git a/app/components/DocumentTemplatizeDialog.tsx b/app/components/DocumentTemplatizeDialog.tsx deleted file mode 100644 index 0750b305e361..000000000000 --- a/app/components/DocumentTemplatizeDialog.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import invariant from "invariant"; -import { observer } from "mobx-react"; -import * as React from "react"; -import { useTranslation, Trans } from "react-i18next"; -import { useHistory } from "react-router-dom"; -import { toast } from "sonner"; -import ConfirmationDialog from "~/components/ConfirmationDialog"; -import useStores from "~/hooks/useStores"; -import { documentPath } from "~/utils/routeHelpers"; - -type Props = { - documentId: string; -}; - -function DocumentTemplatizeDialog({ documentId }: Props) { - const history = useHistory(); - const { t } = useTranslation(); - const { documents } = useStores(); - const document = documents.get(documentId); - invariant(document, "Document must exist"); - - const handleSubmit = React.useCallback(async () => { - const template = await document?.templatize(); - if (template) { - history.push(documentPath(template)); - toast.success(t("Template created, go ahead and customize it")); - } - }, [document, history, t]); - - return ( - <ConfirmationDialog - onSubmit={handleSubmit} - submitText={t("Create template")} - savingText={`${t("Creating")}…`} - > - <Trans - defaults="Creating a template from <em>{{titleWithDefault}}</em> is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents." - values={{ - titleWithDefault: document.titleWithDefault, - }} - components={{ - em: <strong />, - }} - /> - </ConfirmationDialog> - ); -} - -export default observer(DocumentTemplatizeDialog); diff --git a/app/components/DocumentViews.tsx b/app/components/DocumentViews.tsx index 8d1e0f28dae7..e2ca6c0e09c3 100644 --- a/app/components/DocumentViews.tsx +++ b/app/components/DocumentViews.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import { dateLocale, dateToRelative } from "@shared/utils/date"; import Document from "~/models/Document"; import User from "~/models/User"; -import Avatar from "~/components/Avatar"; +import { Avatar } from "~/components/Avatar"; import ListItem from "~/components/List/Item"; import PaginatedList from "~/components/PaginatedList"; import useCurrentUser from "~/hooks/useCurrentUser"; diff --git a/app/components/DuplicateDialog.tsx b/app/components/DuplicateDialog.tsx index 534855c5dbd1..e84191533c18 100644 --- a/app/components/DuplicateDialog.tsx +++ b/app/components/DuplicateDialog.tsx @@ -5,6 +5,7 @@ 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 = { @@ -18,9 +19,17 @@ function DuplicateDialog({ document, onSubmit }: Props) { 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); @@ -37,6 +46,7 @@ function DuplicateDialog({ document, onSubmit }: Props) { const handleSubmit = async () => { const result = await document.duplicate({ + publish, recursive, title, }); @@ -54,18 +64,31 @@ function DuplicateDialog({ document, onSubmit }: Props) { maxLength={DocumentValidation.maxTitleLength} defaultValue={defaultTitle} /> - {document.publishedAt && !document.isTemplate && ( - <label> - <Text size="small"> - <input - type="checkbox" - name="recursive" - checked={recursive} - onChange={handleRecursiveChange} - />{" "} - {t("Include nested documents")} - </Text> - </label> + {!document.isTemplate && ( + <> + {document.collectionId && ( + <Text size="small"> + <Switch + name="publish" + label={t("Publish")} + labelPosition="right" + checked={publish} + onChange={handlePublishChange} + /> + </Text> + )} + {document.publishedAt && document.childDocuments.length > 0 && ( + <Text size="small"> + <Switch + name="recursive" + label={t("Include nested documents")} + labelPosition="right" + checked={recursive} + onChange={handleRecursiveChange} + /> + </Text> + )} + </> )} </ConfirmationDialog> ); diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 37eb539146c2..27b07849c5a0 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -9,7 +9,6 @@ import { mergeRefs } from "react-merge-refs"; import { Optional } from "utility-types"; import insertFiles from "@shared/editor/commands/insertFiles"; import { AttachmentPreset } from "@shared/types"; -import { Heading } from "@shared/utils/ProsemirrorHelper"; import { dateLocale, dateToRelative } from "@shared/utils/date"; import { getDataTransferFiles } from "@shared/utils/files"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; @@ -28,6 +27,7 @@ import { NotFoundError } from "~/utils/errors"; import { uploadFile } from "~/utils/files"; import lazyWithRetry from "~/utils/lazyWithRetry"; import DocumentBreadcrumb from "./DocumentBreadcrumb"; +import Icon from "./Icon"; const LazyLoadedEditor = lazyWithRetry(() => import("~/editor")); @@ -42,21 +42,14 @@ export type Props = Optional< > & { shareId?: string | undefined; embedsDisabled?: boolean; - onHeadingsChange?: (headings: Heading[]) => void; onSynced?: () => Promise<void>; onPublish?: (event: React.MouseEvent) => void; editorStyle?: React.CSSProperties; }; function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) { - const { - id, - shareId, - onChange, - onHeadingsChange, - onCreateCommentMark, - onDeleteCommentMark, - } = props; + const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } = + props; const userLocale = useUserLocale(); const locale = dateLocale(userLocale); const { comments, documents } = useStores(); @@ -64,7 +57,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) { const embeds = useEmbeds(!shareId); const localRef = React.useRef<SharedEditor>(); const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences; - const previousHeadings = React.useRef<Heading[] | null>(null); const previousCommentIds = React.useRef<string[]>(); const handleSearchLink = React.useCallback( @@ -89,6 +81,12 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) { title: document.title, subtitle: `Updated ${time}`, url: document.url, + icon: document.icon ? ( + <Icon + value={document.icon} + color={document.color ?? undefined} + /> + ) : undefined, }, ]; } catch (error) { @@ -107,6 +105,9 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) { title: document.title, subtitle: <DocumentBreadcrumb document={document} onlyText />, url: document.url, + icon: document.icon ? ( + <Icon value={document.icon} color={document.color ?? undefined} /> + ) : undefined, })), (document) => deburr(document.title) @@ -202,24 +203,9 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) { [] ); - // Calculate if headings have changed and trigger callback if so - const updateHeadings = React.useCallback(() => { - if (onHeadingsChange) { - const headings = localRef?.current?.getHeadings(); - if ( - headings && - headings.map((h) => h.level + h.title).join("") !== - previousHeadings.current?.map((h) => h.level + h.title).join("") - ) { - previousHeadings.current = headings; - onHeadingsChange(headings); - } - } - }, [localRef, onHeadingsChange]); - const updateComments = React.useCallback(() => { - if (onCreateCommentMark && onDeleteCommentMark) { - const commentMarks = localRef.current?.getComments(); + if (onCreateCommentMark && onDeleteCommentMark && localRef.current) { + 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 +215,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); } @@ -251,20 +237,18 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) { const handleChange = React.useCallback( (event) => { onChange?.(event); - updateHeadings(); updateComments(); }, - [onChange, updateComments, updateHeadings] + [onChange, updateComments] ); const handleRefChanged = React.useCallback( (node: SharedEditor | null) => { if (node) { - updateHeadings(); updateComments(); } }, - [updateComments, updateHeadings] + [updateComments] ); return ( diff --git a/app/components/EmojiPicker/components.tsx b/app/components/EmojiPicker/components.tsx deleted file mode 100644 index 4713666550b6..000000000000 --- a/app/components/EmojiPicker/components.tsx +++ /dev/null @@ -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` : "")} -`; diff --git a/app/components/EmojiPicker/index.tsx b/app/components/EmojiPicker/index.tsx deleted file mode 100644 index 7f817b5a6609..000000000000 --- a/app/components/EmojiPicker/index.tsx +++ /dev/null @@ -1,269 +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", - "kr", - "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 - // https://github.com/missive/emoji-mart/issues/800 - locale={ - locale === "ko" - ? "kr" - : 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; diff --git a/app/components/ErrorBoundary.tsx b/app/components/ErrorBoundary.tsx index ecf4766d765a..e2a0dcd1c971 100644 --- a/app/components/ErrorBoundary.tsx +++ b/app/components/ErrorBoundary.tsx @@ -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 { githubIssuesUrl, feedbackUrl } from "@shared/utils/urlHelpers"; +import { UrlHelper } from "@shared/utils/UrlHelper"; 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 ? feedbackUrl() : githubIssuesUrl()); + window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github); }; render() { @@ -82,7 +82,7 @@ class ErrorBoundary extends React.Component<Props> { </h1> </> )} - <Text type="secondary"> + <Text as="p" 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 type="secondary"> + <Text as="p" type="secondary"> <Trans defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch." values={{ diff --git a/app/components/EventListItem.tsx b/app/components/EventListItem.tsx index c8cdeb740570..6f7bbe76cb72 100644 --- a/app/components/EventListItem.tsx +++ b/app/components/EventListItem.tsx @@ -11,16 +11,13 @@ import { import * as React from "react"; import { useTranslation } from "react-i18next"; import { useLocation } from "react-router-dom"; -import { CompositeStateReturn } from "reakit/Composite"; import styled, { css } from "styled-components"; +import EventBoundary from "@shared/components/EventBoundary"; import { s } from "@shared/styles"; import Document from "~/models/Document"; import Event from "~/models/Event"; -import Avatar from "~/components/Avatar"; -import CompositeItem, { - Props as ItemProps, -} from "~/components/List/CompositeItem"; -import Item, { Actions } from "~/components/List/Item"; +import { Avatar } from "~/components/Avatar"; +import Item, { Actions, Props as ItemProps } from "~/components/List/Item"; import Time from "~/components/Time"; import useStores from "~/hooks/useStores"; import RevisionMenu from "~/menus/RevisionMenu"; @@ -32,7 +29,7 @@ type Props = { document: Document; event: Event; latest?: boolean; -} & CompositeStateReturn; +}; const EventListItem = ({ event, latest, document, ...rest }: Props) => { const { t } = useTranslation(); @@ -86,6 +83,18 @@ 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); @@ -150,7 +159,9 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => { } actions={ isRevision && isActive && event.modelId && !latest ? ( - <RevisionMenu document={document} revisionId={event.modelId} /> + <StyledEventBoundary> + <RevisionMenu document={document} revisionId={event.modelId} /> + </StyledEventBoundary> ) : undefined } onMouseEnter={prefetchRevision} @@ -164,13 +175,13 @@ const BaseItem = React.forwardRef(function _BaseItem( { to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement> ) { - if (to) { - return <CompositeListItem to={to} ref={ref} {...rest} />; - } - - return <ListItem ref={ref} {...rest} />; + return <ListItem to={to} ref={ref} {...rest} />; }); +const StyledEventBoundary = styled(EventBoundary)` + height: 24px; +`; + const Subtitle = styled.span` svg { margin: -3px; @@ -228,8 +239,4 @@ const ListItem = styled(Item)` ${ItemStyle} `; -const CompositeListItem = styled(CompositeItem)` - ${ItemStyle} -`; - export default observer(EventListItem); diff --git a/app/components/ExportDialog.tsx b/app/components/ExportDialog.tsx index 95eb48b66cca..aaef6ba84cab 100644 --- a/app/components/ExportDialog.tsx +++ b/app/components/ExportDialog.tsx @@ -95,7 +95,7 @@ function ExportDialog({ collection, onSubmit }: Props) { return ( <ConfirmationDialog onSubmit={handleSubmit} submitText={t("Export")}> {collection && ( - <Text> + <Text as="p"> <Trans defaults="Exporting the collection <em>{{collectionName}}</em> may take some time." values={{ @@ -120,7 +120,7 @@ function ExportDialog({ collection, onSubmit }: Props) { onChange={handleFormatChange} /> <div> - <Text size="small" weight="bold"> + <Text as="p" size="small" weight="bold"> {item.title} </Text> <Text size="small">{item.description}</Text> @@ -137,7 +137,7 @@ function ExportDialog({ collection, onSubmit }: Props) { onChange={handleIncludeAttachmentsChange} /> <div> - <Text size="small" weight="bold"> + <Text as="p" size="small" weight="bold"> {t("Include attachments")} </Text> <Text size="small"> diff --git a/app/components/Facepile.tsx b/app/components/Facepile.tsx index bd9097354bf2..914fa7e9a2ca 100644 --- a/app/components/Facepile.tsx +++ b/app/components/Facepile.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import styled from "styled-components"; import { s } from "@shared/styles"; import User from "~/models/User"; -import Avatar from "~/components/Avatar"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import Flex from "~/components/Flex"; type Props = { @@ -17,7 +17,7 @@ type Props = { function Facepile({ users, overflow = 0, - size = 32, + size = AvatarSize.Large, limit = 8, renderAvatar = DefaultAvatar, ...rest @@ -43,7 +43,7 @@ function Facepile({ } function DefaultAvatar(user: User) { - return <Avatar model={user} size={32} />; + return <Avatar model={user} size={AvatarSize.Large} />; } const AvatarWrapper = styled.div` @@ -62,11 +62,11 @@ const More = styled.div<{ size: number }>` min-width: ${(props) => props.size}px; height: ${(props) => props.size}px; border-radius: 100%; - background: ${(props) => props.theme.slate}; - color: ${s("text")}; + background: ${(props) => props.theme.textTertiary}; + color: ${s("white")}; border: 2px solid ${s("background")}; text-align: center; - font-size: 11px; + font-size: 12px; font-weight: 600; `; diff --git a/app/components/FilterOptions.tsx b/app/components/FilterOptions.tsx index be39e4150445..cbeb198cdaba 100644 --- a/app/components/FilterOptions.tsx +++ b/app/components/FilterOptions.tsx @@ -1,83 +1,246 @@ +import deburr from "lodash/deburr"; import * as React from "react"; +import { useTranslation } from "react-i18next"; import { useMenuState, MenuButton } from "reakit/Menu"; import styled from "styled-components"; import { s } from "@shared/styles"; +import type { FetchPageParams } from "~/stores/base/Store"; import Button, { Inner } from "~/components/Button"; import ContextMenu from "~/components/ContextMenu"; import MenuItem from "~/components/ContextMenu/MenuItem"; import Text from "~/components/Text"; +import Input, { NativeInput, Outline } from "./Input"; +import PaginatedList, { PaginatedItem } from "./PaginatedList"; -type TFilterOption = { +interface TFilterOption extends PaginatedItem { key: string; label: string; note?: string; icon?: React.ReactNode; -}; +} type Props = { options: TFilterOption[]; - activeKey: string | null | undefined; + selectedKeys: (string | null | undefined)[]; defaultLabel?: string; selectedPrefix?: string; className?: string; onSelect: (key: string | null | undefined) => void; + showFilter?: boolean; + fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>; + fetchQueryOptions?: Record<string, string>; }; const FilterOptions = ({ options, - activeKey = "", + selectedKeys = [], defaultLabel = "Filter options", selectedPrefix = "", className, onSelect, + showFilter, + fetchQuery, + fetchQueryOptions, }: Props) => { + const { t } = useTranslation(); + const searchInputRef = React.useRef<HTMLInputElement>(null); + const listRef = React.useRef<HTMLDivElement | null>(null); const menu = useMenuState({ modal: true, }); - const selected = - options.find((option) => option.key === activeKey) || options[0]; + const selectedItems = options.filter((option) => + selectedKeys.includes(option.key) + ); + const [query, setQuery] = React.useState(""); + + const selectedLabel = selectedItems.length + ? selectedItems + .map((selected) => `${selectedPrefix} ${selected.label}`) + .join(", ") + : ""; + + const renderItem = React.useCallback( + (option: TFilterOption) => ( + <MenuItem + key={option.key} + onClick={() => { + onSelect(option.key); + menu.hide(); + }} + selected={selectedKeys.includes(option.key)} + {...menu} + > + {option.icon && <Icon>{option.icon}</Icon>} + {option.note ? ( + <LabelWithNote> + {option.label} + <Note>{option.note}</Note> + </LabelWithNote> + ) : ( + option.label + )} + </MenuItem> + ), + [menu, onSelect, selectedKeys] + ); + + const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => { + setQuery(ev.target.value); + }; + + const filteredOptions = React.useMemo(() => { + const normalizedQuery = deburr(query.toLowerCase()); + + return query + ? options + .filter((option) => + deburr(option.label).toLowerCase().includes(normalizedQuery) + ) + // sort options starting with query first + .sort((a, b) => { + const aStartsWith = deburr(a.label) + .toLowerCase() + .startsWith(normalizedQuery); + const bStartsWith = deburr(b.label) + .toLowerCase() + .startsWith(normalizedQuery); - const selectedLabel = selected ? `${selectedPrefix} ${selected.label}` : ""; + if (aStartsWith && !bStartsWith) { + return -1; + } + if (!aStartsWith && bStartsWith) { + return 1; + } + return 0; + }) + : options; + }, [options, query]); + + const handleKeyDown = React.useCallback( + (ev: React.KeyboardEvent) => { + if (ev.nativeEvent.isComposing || ev.shiftKey) { + return; + } + + switch (ev.key) { + case "Escape": + menu.hide(); + break; + case "Enter": + if (filteredOptions.length === 1) { + ev.preventDefault(); + onSelect(filteredOptions[0].key); + menu.hide(); + } + break; + case "ArrowDown": + ev.preventDefault(); + (listRef.current?.firstElementChild as HTMLElement)?.focus(); + break; + default: + break; + } + }, + [filteredOptions, menu, onSelect] + ); + + const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => { + searchInputRef.current?.focus(); + + if (ev.key === "Backspace") { + setQuery((prev) => prev.slice(0, -1)); + } + }, []); + + React.useEffect(() => { + if (menu.visible) { + searchInputRef.current?.focus(); + } else { + setQuery(""); + } + }, [menu.visible]); + + const showFilterInput = showFilter || options.length > 10; return ( - <Wrapper> + <div> <MenuButton {...menu}> {(props) => ( <StyledButton {...props} className={className} neutral disclosure> - {activeKey ? selectedLabel : defaultLabel} + {selectedItems.length ? selectedLabel : defaultLabel} </StyledButton> )} </MenuButton> - <ContextMenu aria-label={defaultLabel} {...menu}> - {options.map((option) => ( - <MenuItem - key={option.key} - onClick={() => { - onSelect(option.key); - menu.hide(); - }} - selected={option.key === activeKey} - {...menu} - > - {option.icon && <Icon>{option.icon}</Icon>} - {option.note ? ( - <LabelWithNote> - {option.label} - <Note>{option.note}</Note> - </LabelWithNote> - ) : ( - option.label - )} - </MenuItem> - ))} + <ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}> + <PaginatedList + listRef={listRef} + options={{ query, ...fetchQueryOptions }} + items={filteredOptions} + fetch={fetchQuery} + renderItem={renderItem} + onEscape={handleEscapeFromList} + heading={showFilterInput ? <Spacer /> : undefined} + empty={<Empty />} + /> + {showFilterInput && ( + <SearchInput + ref={searchInputRef} + value={query} + onChange={handleFilter} + onKeyDown={handleKeyDown} + placeholder={`${t("Filter")}…`} + autoFocus + /> + )} </ContextMenu> - </Wrapper> + </div> ); }; +const Empty = () => { + const { t } = useTranslation(); + + return ( + <> + <Spacer /> + <Text size="small" type="tertiary" style={{ marginLeft: 6 }}> + {t("No results")} + </Text> + </> + ); +}; + +const Spacer = styled.div` + height: 30px; +`; + +const SearchInput = styled(Input)` + position: absolute; + width: 100%; + border: none; + border-top-left-radius: 6px; + border-top-right-radius: 6px; + overflow: hidden; + margin: 0; + top: 0; + left: 0; + right: 0; + + ${Outline} { + border: none; + border-radius: 0; + border-bottom: 1px solid ${s("inputBorder")}; + background: ${s("menuBackground")}; + } + + ${NativeInput} { + font-size: 14px; + } +`; + const Note = styled(Text)` - margin-top: 2px; - margin-bottom: 0; + display: block; + margin: 2px 0; line-height: 1.2em; font-size: 14px; font-weight: 500; @@ -93,7 +256,7 @@ const LabelWithNote = styled.div` } `; -const StyledButton = styled(Button)` +export const StyledButton = styled(Button)` box-shadow: none; text-transform: none; border-color: transparent; @@ -115,8 +278,4 @@ const Icon = styled.div` height: 18px; `; -const Wrapper = styled.div` - margin-right: 8px; -`; - export default FilterOptions; diff --git a/app/components/Flex.tsx b/app/components/Flex.tsx index eb3e86e9ae3d..4671600b60c8 100644 --- a/app/components/Flex.tsx +++ b/app/components/Flex.tsx @@ -1,38 +1,3 @@ -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; -`; +import Flex from "@shared/components/Flex"; export default Flex; diff --git a/app/components/GroupListItem.tsx b/app/components/GroupListItem.tsx index 35e9303e7ec7..ac18bc1b6fb8 100644 --- a/app/components/GroupListItem.tsx +++ b/app/components/GroupListItem.tsx @@ -5,36 +5,31 @@ import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { MAX_AVATAR_DISPLAY } from "@shared/constants"; import { s } from "@shared/styles"; -import CollectionGroupMembership from "~/models/CollectionGroupMembership"; import Group from "~/models/Group"; +import GroupMembership from "~/models/GroupMembership"; import GroupMembers from "~/scenes/GroupMembers"; import Facepile from "~/components/Facepile"; import Flex from "~/components/Flex"; import ListItem from "~/components/List/Item"; import Modal from "~/components/Modal"; import useBoolean from "~/hooks/useBoolean"; -import useStores from "~/hooks/useStores"; import { hover } from "~/styles"; import NudeButton from "./NudeButton"; type Props = { group: Group; - membership?: CollectionGroupMembership; + membership?: GroupMembership; showFacepile?: boolean; showAvatar?: boolean; renderActions: (params: { openMembersModal: () => void }) => React.ReactNode; }; function GroupListItem({ group, showFacepile, renderActions }: Props) { - const { groupMemberships } = useStores(); const { t } = useTranslation(); const [membersModalOpen, setMembersModalOpen, setMembersModalClosed] = useBoolean(); const memberCount = group.memberCount; - const membershipsInGroup = groupMemberships.inGroup(group.id); - const users = membershipsInGroup - .slice(0, MAX_AVATAR_DISPLAY) - .map((gm) => gm.user); + const users = group.users.slice(0, MAX_AVATAR_DISPLAY); const overflow = memberCount - users.length; return ( diff --git a/app/components/Highlight.tsx b/app/components/Highlight.tsx index d071dc6d7979..b38d75eda75d 100644 --- a/app/components/Highlight.tsx +++ b/app/components/Highlight.tsx @@ -2,6 +2,7 @@ import escapeRegExp from "lodash/escapeRegExp"; 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,7 +44,7 @@ function Highlight({ } export const Mark = styled.mark` - color: inherit; + color: ${s("text")}; background: transparent; font-weight: 600; `; diff --git a/app/components/HoverPreview/Components.tsx b/app/components/HoverPreview/Components.tsx index d2486f378947..8f2b1dffc97d 100644 --- a/app/components/HoverPreview/Components.tsx +++ b/app/components/HoverPreview/Components.tsx @@ -2,6 +2,7 @@ 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; @@ -24,14 +25,15 @@ export const Preview = styled(Link)` 0 0 1px 1px rgba(0, 0, 0, 0.05); overflow: hidden; position: absolute; - min-width: 350px; - max-width: 375px; + width: 375px; `; -export const Title = styled.h2` - font-size: 1.25em; - margin: 0; - color: ${s("text")}; +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 Info = styled(StyledText).attrs(() => ({ @@ -46,6 +48,7 @@ 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` @@ -54,6 +57,20 @@ 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; diff --git a/app/components/HoverPreview/HoverPreview.tsx b/app/components/HoverPreview/HoverPreview.tsx index 1e908daf84d4..c6a79d8f6e89 100644 --- a/app/components/HoverPreview/HoverPreview.tsx +++ b/app/components/HoverPreview/HoverPreview.tsx @@ -3,28 +3,30 @@ import * as React from "react"; import { Portal } from "react-portal"; import styled from "styled-components"; import { depths } from "@shared/styles"; -import { UnfurlType } from "@shared/types"; -import LoadingIndicator from "~/components/LoadingIndicator"; +import { UnfurlResourceType } from "@shared/types"; import useEventListener from "~/hooks/useEventListener"; import useKeyDown from "~/hooks/useKeyDown"; import useMobile from "~/hooks/useMobile"; import useOnClickOutside from "~/hooks/useOnClickOutside"; -import usePrevious from "~/hooks/usePrevious"; -import useRequest from "~/hooks/useRequest"; -import useStores from "~/hooks/useStores"; -import { client } from "~/utils/ApiClient"; +import LoadingIndicator from "../LoadingIndicator"; 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 = 600; +const DELAY_CLOSE = 500; const POINTER_HEIGHT = 22; const POINTER_WIDTH = 22; 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. */ onClose: () => void; }; @@ -34,12 +36,10 @@ enum Direction { DOWN, } -function HoverPreviewDesktop({ element, onClose }: Props) { - const url = element?.getAttribute("href") || element?.dataset.url; - const previousUrl = usePrevious(url, true); +function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) { const [isVisible, setVisible] = React.useState(false); const timerClose = React.useRef<ReturnType<typeof setTimeout>>(); - const cardRef = React.useRef<HTMLDivElement>(null); + const cardRef = React.useRef<HTMLDivElement | null>(null); const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } = useHoverPosition({ cardRef, @@ -65,12 +65,12 @@ function HoverPreviewDesktop({ element, onClose }: Props) { // Open and close the preview when the element changes. React.useEffect(() => { - if (element) { + if (element && data && !dataLoading) { setVisible(true); } else { startCloseTimer(); } - }, [startCloseTimer, element]); + }, [startCloseTimer, element, data, dataLoading]); // Close the preview on Escape, scroll, or click outside. useOnClickOutside(cardRef, closePreview); @@ -98,100 +98,102 @@ function HoverPreviewDesktop({ element, onClose }: Props) { }; }, [element, startCloseTimer, isVisible, stopCloseTimer]); - const displayUrl = url ?? previousUrl; + if (dataLoading) { + return <LoadingIndicator />; + } - if (!isVisible || !displayUrl) { + if (!data) { return null; } return ( <Portal> - <Position top={cardTop} left={cardLeft} ref={cardRef} aria-hidden> - <DataLoader url={displayUrl}> - {(data) => ( - <Animate - initial={{ opacity: 0, y: -20, pointerEvents: "none" }} - animate={{ opacity: 1, y: 0, pointerEvents: "auto" }} - > - {data.type === UnfurlType.Mention ? ( - <HoverPreviewMention - url={data.thumbnailUrl} - title={data.title} - info={data.meta.info} - color={data.meta.color} - /> - ) : data.type === UnfurlType.Document ? ( - <HoverPreviewDocument - id={data.meta.id} - url={data.url} - title={data.title} - description={data.description} - info={data.meta.info} - /> - ) : ( - <HoverPreviewLink - url={data.url} - thumbnailUrl={data.thumbnailUrl} - title={data.title} - description={data.description} - /> - )} - <Pointer - top={pointerTop} - left={pointerLeft} - direction={pointerDir} + <Position top={cardTop} left={cardLeft} aria-hidden> + {isVisible ? ( + <Animate + initial={{ opacity: 0, y: -20, pointerEvents: "none" }} + animate={{ + opacity: 1, + y: 0, + transitionEnd: { pointerEvents: "auto" }, + }} + > + {data.type === UnfurlResourceType.Mention ? ( + <HoverPreviewMention + ref={cardRef} + name={data.name} + avatarUrl={data.avatarUrl} + color={data.color} + lastActive={data.lastActive} + email={data.email} + /> + ) : data.type === UnfurlResourceType.Document ? ( + <HoverPreviewDocument + ref={cardRef} + 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} + /> + ) : ( + <HoverPreviewLink + ref={cardRef} + url={data.url} + thumbnailUrl={data.thumbnailUrl} + title={data.title} + description={data.description} /> - </Animate> - )} - </DataLoader> + )} + <Pointer + top={pointerTop} + left={pointerLeft} + direction={pointerDir} + /> + </Animate> + ) : null} </Position> </Portal> ); } -function DataLoader({ - url, - children, -}: { - url: string; - children: (data: any) => React.ReactNode; -}) { - const { ui } = useStores(); - const { data, request, loading } = useRequest( - React.useCallback( - () => - client.post("/urls.unfurl", { - url, - documentId: ui.activeDocumentId, - }), - [url, ui.activeDocumentId] - ) - ); - - React.useEffect(() => { - if (url) { - void request(); - } - }, [url, request]); - - if (loading) { - return <LoadingIndicator />; - } - - if (!data) { - return null; - } - - return <>{children(data)}</>; -} - -function HoverPreview({ element, ...rest }: Props) { +function HoverPreview({ element, data, dataLoading, ...rest }: Props) { const isMobile = useMobile(); if (isMobile) { return null; } - return <HoverPreviewDesktop {...rest} element={element} />; + return ( + <HoverPreviewDesktop + {...rest} + element={element} + data={data} + dataLoading={dataLoading} + /> + ); } function useHoverPosition({ diff --git a/app/components/HoverPreview/HoverPreviewDocument.tsx b/app/components/HoverPreview/HoverPreviewDocument.tsx index b8487190142a..1688eb629f70 100644 --- a/app/components/HoverPreview/HoverPreviewDocument.tsx +++ b/app/components/HoverPreview/HoverPreviewDocument.tsx @@ -1,6 +1,9 @@ import * as React from "react"; +import { richExtensions } from "@shared/editor/nodes"; +import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; import Editor from "~/components/Editor"; import Flex from "~/components/Flex"; +import ErrorBoundary from "../ErrorBoundary"; import { Preview, Title, @@ -10,41 +13,33 @@ import { Description, } from "./Components"; -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; -}; +type Props = Omit<UnfurlResponse[UnfurlResourceType.Document], "type">; const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument( - { id, url, title, info, description }: Props, + { url, id, title, summary, lastActivityByViewer }: Props, ref: React.Ref<HTMLDivElement> ) { return ( <Preview to={url}> <Card ref={ref}> <CardContent> - <Flex column gap={2}> - <Title>{title} - {info} - - }> - - - -
+ + + {title} + {lastActivityByViewer} + + }> + + + + + diff --git a/app/components/HoverPreview/HoverPreviewIssue.tsx b/app/components/HoverPreview/HoverPreviewIssue.tsx new file mode 100644 index 000000000000..032328ffa0be --- /dev/null +++ b/app/components/HoverPreview/HoverPreviewIssue.tsx @@ -0,0 +1,65 @@ +import * as React from "react"; +import { Trans } from "react-i18next"; +import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; +import { Avatar } from "~/components/Avatar"; +import Flex from "~/components/Flex"; +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; + +const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue( + { url, id, title, description, author, labels, state, createdAt }: Props, + ref: React.Ref +) { + const authorName = author.name; + + return ( + + + + + + + <IssueStatusIcon status={state.name} color={state.color} /> + <span> + {title} <Text type="tertiary">{id}</Text> + </span> + + + + + + {{ authorName }} created{" "} + + + + {description} + + + {labels.map((label, index) => ( + + ))} + + + + + + + ); +}); + +export default HoverPreviewIssue; diff --git a/app/components/HoverPreview/HoverPreviewMention.tsx b/app/components/HoverPreview/HoverPreviewMention.tsx index a79b209abcc5..4c44fdab3d85 100644 --- a/app/components/HoverPreview/HoverPreviewMention.tsx +++ b/app/components/HoverPreview/HoverPreviewMention.tsx @@ -1,22 +1,13 @@ import * as React from "react"; -import Avatar from "~/components/Avatar"; -import { AvatarSize } from "~/components/Avatar/Avatar"; +import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; +import { Avatar, AvatarSize } from "~/components/Avatar"; import Flex from "~/components/Flex"; import { Preview, Title, Info, Card, CardContent } from "./Components"; -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; -}; +type Props = Omit; const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention( - { url, title, info, color }: Props, + { avatarUrl, name, lastActive, color, email }: Props, ref: React.Ref ) { return ( @@ -26,15 +17,16 @@ const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention( - {title} - {info} + {name} + {email && {email}} + {lastActive} diff --git a/app/components/HoverPreview/HoverPreviewPullRequest.tsx b/app/components/HoverPreview/HoverPreviewPullRequest.tsx new file mode 100644 index 000000000000..6393695134d8 --- /dev/null +++ b/app/components/HoverPreview/HoverPreviewPullRequest.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { Trans } from "react-i18next"; +import { UnfurlResourceType, UnfurlResponse } from "@shared/types"; +import { Avatar } from "~/components/Avatar"; +import Flex from "~/components/Flex"; +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; + +const HoverPreviewPullRequest = React.forwardRef( + function _HoverPreviewPullRequest( + { url, title, id, description, author, state, createdAt }: Props, + ref: React.Ref + ) { + const authorName = author.name; + + return ( + + + + + + + <PullRequestIcon status={state.name} color={state.color} /> + <span> + {title} <Text type="tertiary">{id}</Text> + </span> + + + + + + {{ authorName }} opened{" "} + + + + {description} + + + + + + ); + } +); + +export default HoverPreviewPullRequest; diff --git a/app/components/Icon.tsx b/app/components/Icon.tsx new file mode 100644 index 000000000000..c0e8a8081f4a --- /dev/null +++ b/app/components/Icon.tsx @@ -0,0 +1,123 @@ +import { observer } from "mobx-react"; +import { getLuminance } from "polished"; +import * as React from "react"; +import styled from "styled-components"; +import { IconType } from "@shared/types"; +import { IconLibrary } from "@shared/utils/IconLibrary"; +import { colorPalette } from "@shared/utils/collections"; +import { determineIconType } from "@shared/utils/icon"; +import EmojiIcon from "~/components/Icons/EmojiIcon"; +import useStores from "~/hooks/useStores"; +import Logger from "~/utils/Logger"; +import Flex from "./Flex"; + +export type Props = { + /** The icon to render */ + value: string; + /** The color of the icon */ + color?: string; + /** The size of the icon */ + size?: number; + /** The initial to display if the icon is a letter icon */ + initial?: string; + /** Optional additional class name */ + className?: string; + /** + * Ensure the color does not change in response to theme and contrast. Should only be + * used in color picker UI. + */ + forceColor?: boolean; +}; + +const Icon = ({ + value: icon, + color, + size = 24, + initial, + forceColor, + className, +}: Props) => { + const iconType = determineIconType(icon); + + if (!iconType) { + Logger.warn("Failed to determine icon type", { + icon, + }); + return null; + } + + try { + if (iconType === IconType.SVG) { + return ( + + ); + } + + return ; + } catch (err) { + Logger.warn("Failed to render icon", { + icon, + }); + } + + return null; +}; + +const SVGIcon = observer( + ({ + value: icon, + color: inputColor, + initial, + size, + className, + forceColor, + }: Props) => { + const { ui } = useStores(); + + let color = inputColor ?? colorPalette[0]; + + // If the chosen icon color is very dark then we invert it in dark mode + if (!forceColor) { + if (ui.resolvedTheme === "dark" && color !== "currentColor") { + color = getLuminance(color) > 0.09 ? color : "currentColor"; + } + + // If the chosen icon color is very light then we invert it in light mode + if (ui.resolvedTheme === "light" && color !== "currentColor") { + color = getLuminance(color) < 0.9 ? color : "currentColor"; + } + } + + const Component = IconLibrary.getComponent(icon); + + return ( + + {initial} + + ); + } +); + +export const IconTitleWrapper = styled(Flex)<{ dir?: string }>` + align-items: center; + justify-content: center; + position: absolute; + top: 3px; + height: 40px; + width: 40px; + + // Always move above TOC + z-index: 1; + + ${(props: { dir?: string }) => + props.dir === "rtl" ? "right: -44px" : "left: -44px"}; +`; + +export default Icon; diff --git a/app/components/IconPicker.tsx b/app/components/IconPicker.tsx deleted file mode 100644 index 6fea6a2ebb34..000000000000 --- a/app/components/IconPicker.tsx +++ /dev/null @@ -1,362 +0,0 @@ -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 { useMenuState, MenuButton, MenuItem } from "reakit/Menu"; -import styled, { useTheme } from "styled-components"; -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 lazyWithRetry from "~/utils/lazyWithRetry"; -import DelayedMount from "./DelayedMount"; -import LetterIcon from "./Icons/LetterIcon"; - -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", - }, - letter: { - component: LetterIcon, - keywords: "letter", - }, - 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; -}; - -function IconPicker({ - onOpen, - onClose, - icon, - initial, - color, - onChange, -}: Props) { - const { t } = useTranslation(); - const theme = useTheme(); - const menu = useMenuState({ - modal: true, - placement: "bottom-end", - }); - - return ( - - - - {(props) => ( - - )} - - - - {Object.keys(icons).map((name, index) => ( - onChange(color, name)} - {...menu} - > - {(props) => ( - - - {initial} - - - )} - - ))} - - - - {t("Loading")}… - - } - > - 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", - }, - }, - }} - /> - - - - - ); -} - -const Icon = styled.svg` - 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: 304px; - `}; -`; - -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; - height: 30px; -`; - -const ColorPicker = styled(TwitterPicker)` - box-shadow: none !important; - background: transparent !important; - width: 100% !important; -`; - -const Wrapper = styled("div")` - display: inline-block; - position: relative; -`; - -export default IconPicker; diff --git a/app/components/IconPicker/components/ColorPicker.tsx b/app/components/IconPicker/components/ColorPicker.tsx new file mode 100644 index 000000000000..024db6765a85 --- /dev/null +++ b/app/components/IconPicker/components/ColorPicker.tsx @@ -0,0 +1,218 @@ +import { BackIcon } from "outline-icons"; +import React from "react"; +import styled from "styled-components"; +import { breakpoints, s } from "@shared/styles"; +import { colorPalette } from "@shared/utils/collections"; +import { validateColorHex } from "@shared/utils/color"; +import Flex from "~/components/Flex"; +import NudeButton from "~/components/NudeButton"; +import Text from "~/components/Text"; +import { hover } from "~/styles"; + +enum Panel { + Builtin, + Hex, +} + +type Props = { + width: number; + activeColor: string; + onSelect: (color: string) => void; +}; + +const ColorPicker = ({ width, activeColor, onSelect }: Props) => { + const [localValue, setLocalValue] = React.useState(activeColor); + + const [panel, setPanel] = React.useState( + colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex + ); + + const handleSwitcherClick = React.useCallback(() => { + setPanel(panel === Panel.Builtin ? Panel.Hex : Panel.Builtin); + }, [panel, setPanel]); + + const isLargeMobile = width > breakpoints.mobileLarge + 12; // 12px for the Container padding + + React.useEffect(() => { + setLocalValue(activeColor); + setPanel(colorPalette.includes(activeColor) ? Panel.Builtin : Panel.Hex); + }, [activeColor]); + + return isLargeMobile ? ( + + + + + ) : ( + + + + {panel === Panel.Builtin ? "#" : } + + + {panel === Panel.Builtin ? ( + + ) : ( + + )} + + ); +}; + +const BuiltinColors = ({ + activeColor, + onClick, + className, +}: { + activeColor: string; + onClick: (color: string) => void; + className?: string; +}) => ( + + {colorPalette.map((color) => ( + onClick(color)} + > + + + ))} + +); + +const CustomColor = ({ + value, + setLocalValue, + onValidHex, + className, +}: { + value: string; + setLocalValue: (value: string) => void; + onValidHex: (color: string) => void; + className?: string; +}) => { + const hasHexChars = React.useCallback( + (color: string) => /(^#[0-9A-F]{1,6}$)/i.test(color), + [] + ); + + const handleInputChange = React.useCallback( + (ev: React.ChangeEvent) => { + const val = ev.target.value; + + if (val === "" || val === "#") { + setLocalValue("#"); + return; + } + + const uppercasedVal = val.toUpperCase(); + + if (hasHexChars(uppercasedVal)) { + setLocalValue(uppercasedVal); + } + + if (validateColorHex(uppercasedVal)) { + onValidHex(uppercasedVal); + } + }, + [setLocalValue, hasHexChars, onValidHex] + ); + + return ( + + + HEX + + + + ); +}; + +const Container = styled(Flex)` + height: 48px; + padding: 8px 12px; + border-bottom: 1px solid ${s("inputBorder")}; +`; + +const Selected = styled.span` + width: 10px; + height: 5px; + border-left: 2px solid white; + border-bottom: 2px solid white; + transform: translateY(-25%) rotate(-45deg); +`; + +const ColorButton = styled(NudeButton)<{ $color: string; $active: boolean }>` + display: inline-flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: ${({ $color }) => $color}; + + &: ${hover} { + outline: 2px solid ${s("menuBackground")} !important; + box-shadow: ${({ $color }) => `0px 0px 3px 3px ${$color}`}; + } + + & ${Selected} { + display: ${({ $active }) => ($active ? "block" : "none")}; + } +`; + +const PanelSwitcher = styled(Flex)` + width: 40px; + border-right: 1px solid ${s("inputBorder")}; +`; + +const SwitcherButton = styled(NudeButton)<{ panel: Panel }>` + display: inline-flex; + justify-content: center; + align-items: center; + font-size: 14px; + border: 1px solid ${s("inputBorder")}; + transition: all 100ms ease-in-out; + + &: ${hover} { + border-color: ${s("inputBorderFocused")}; + } +`; + +const LargeMobileBuiltinColors = styled(BuiltinColors)` + max-width: 380px; + padding-right: 8px; +`; + +const LargeMobileCustomColor = styled(CustomColor)` + padding-left: 8px; + border-left: 1px solid ${s("inputBorder")}; + width: 120px; +`; + +const CustomColorInput = styled.input.attrs(() => ({ + type: "text", + autocomplete: "off", +}))` + font-size: 14px; + color: ${s("textSecondary")}; + background: transparent; + border: 0; + outline: 0; +`; + +export default ColorPicker; diff --git a/app/components/IconPicker/components/Emoji.tsx b/app/components/IconPicker/components/Emoji.tsx new file mode 100644 index 000000000000..2223147282f5 --- /dev/null +++ b/app/components/IconPicker/components/Emoji.tsx @@ -0,0 +1,8 @@ +import styled from "styled-components"; +import { s } from "@shared/styles"; + +export const Emoji = styled.span` + font-family: ${s("fontFamilyEmoji")}; + width: 24px; + height: 24px; +`; diff --git a/app/components/IconPicker/components/EmojiPanel.tsx b/app/components/IconPicker/components/EmojiPanel.tsx new file mode 100644 index 000000000000..1bc22a362a5b --- /dev/null +++ b/app/components/IconPicker/components/EmojiPanel.tsx @@ -0,0 +1,245 @@ +import concat from "lodash/concat"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types"; +import { getEmojis, getEmojisWithCategory, search } from "@shared/utils/emoji"; +import Flex from "~/components/Flex"; +import InputSearch from "~/components/InputSearch"; +import usePersistedState from "~/hooks/usePersistedState"; +import { + FREQUENTLY_USED_COUNT, + DisplayCategory, + emojiSkinToneKey, + emojisFreqKey, + lastEmojiKey, + sortFrequencies, +} from "../utils"; +import GridTemplate, { DataNode } from "./GridTemplate"; +import SkinTonePicker from "./SkinTonePicker"; + +/** + * This is needed as a constant for react-window. + * Calculated from the heights of TabPanel and InputSearch. + */ +const GRID_HEIGHT = 362; + +const useEmojiState = () => { + const [emojiSkinTone, setEmojiSkinTone] = usePersistedState( + emojiSkinToneKey, + EmojiSkinTone.Default + ); + const [emojisFreq, setEmojisFreq] = usePersistedState>( + emojisFreqKey, + {} + ); + const [lastEmoji, setLastEmoji] = usePersistedState( + lastEmojiKey, + undefined + ); + + const incrementEmojiCount = React.useCallback( + (emoji: string) => { + emojisFreq[emoji] = (emojisFreq[emoji] ?? 0) + 1; + setEmojisFreq({ ...emojisFreq }); + setLastEmoji(emoji); + }, + [emojisFreq, setEmojisFreq, setLastEmoji] + ); + + const getFreqEmojis = React.useCallback(() => { + const freqs = Object.entries(emojisFreq); + + if (freqs.length > FREQUENTLY_USED_COUNT.Track) { + sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track); + setEmojisFreq(Object.fromEntries(freqs)); + } + + const emojis = sortFrequencies(freqs) + .slice(0, FREQUENTLY_USED_COUNT.Get) + .map(([emoji, _]) => emoji); + + const isLastPresent = emojis.includes(lastEmoji ?? ""); + if (lastEmoji && !isLastPresent) { + emojis.pop(); + emojis.push(lastEmoji); + } + + return emojis; + }, [emojisFreq, setEmojisFreq, lastEmoji]); + + return { + emojiSkinTone, + setEmojiSkinTone, + incrementEmojiCount, + getFreqEmojis, + }; +}; + +type Props = { + panelWidth: number; + query: string; + panelActive: boolean; + onEmojiChange: (emoji: string) => void; + onQueryChange: (query: string) => void; +}; + +const EmojiPanel = ({ + panelWidth, + query, + panelActive, + onEmojiChange, + onQueryChange, +}: Props) => { + const { t } = useTranslation(); + + const searchRef = React.useRef(null); + const scrollableRef = React.useRef(null); + + const { + emojiSkinTone: skinTone, + setEmojiSkinTone, + incrementEmojiCount, + getFreqEmojis, + } = useEmojiState(); + + const freqEmojis = React.useMemo(() => getFreqEmojis(), [getFreqEmojis]); + + const handleFilter = React.useCallback( + (event: React.ChangeEvent) => { + onQueryChange(event.target.value); + }, + [onQueryChange] + ); + + const handleSkinChange = React.useCallback( + (emojiSkinTone: EmojiSkinTone) => { + setEmojiSkinTone(emojiSkinTone); + }, + [setEmojiSkinTone] + ); + + const handleEmojiSelection = React.useCallback( + ({ id, value }: { id: string; value: string }) => { + onEmojiChange(value); + incrementEmojiCount(id); + }, + [onEmojiChange, incrementEmojiCount] + ); + + const isSearch = query !== ""; + const templateData: DataNode[] = isSearch + ? getSearchResults({ + query, + skinTone, + }) + : getAllEmojis({ + skinTone, + freqEmojis, + }); + + React.useEffect(() => { + if (scrollableRef.current) { + scrollableRef.current.scrollTop = 0; + } + searchRef.current?.focus(); + }, [panelActive]); + + return ( + + + + + + + + ); +}; + +const getSearchResults = ({ + query, + skinTone, +}: { + query: string; + skinTone: EmojiSkinTone; +}): DataNode[] => { + const emojis = search({ query, skinTone }); + return [ + { + category: DisplayCategory.Search, + icons: emojis.map((emoji) => ({ + type: IconType.Emoji, + id: emoji.id, + value: emoji.value, + })), + }, + ]; +}; + +const getAllEmojis = ({ + skinTone, + freqEmojis, +}: { + skinTone: EmojiSkinTone; + freqEmojis: string[]; +}): DataNode[] => { + const emojisWithCategory = getEmojisWithCategory({ skinTone }); + + const getFrequentEmojis = (): DataNode => { + const emojis = getEmojis({ ids: freqEmojis, skinTone }); + return { + category: DisplayCategory.Frequent, + icons: emojis.map((emoji) => ({ + type: IconType.Emoji, + id: emoji.id, + value: emoji.value, + })), + }; + }; + + const getCategoryData = (emojiCategory: EmojiCategory): DataNode => { + const emojis = emojisWithCategory[emojiCategory] ?? []; + return { + category: emojiCategory, + icons: emojis.map((emoji) => ({ + type: IconType.Emoji, + id: emoji.id, + value: emoji.value, + })), + }; + }; + + return concat( + getFrequentEmojis(), + getCategoryData(EmojiCategory.People), + getCategoryData(EmojiCategory.Nature), + getCategoryData(EmojiCategory.Foods), + getCategoryData(EmojiCategory.Activity), + getCategoryData(EmojiCategory.Places), + getCategoryData(EmojiCategory.Objects), + getCategoryData(EmojiCategory.Symbols), + getCategoryData(EmojiCategory.Flags) + ); +}; + +const UserInputContainer = styled(Flex)` + height: 48px; + padding: 6px 12px 0px; +`; + +const StyledInputSearch = styled(InputSearch)` + flex-grow: 1; +`; + +export default EmojiPanel; diff --git a/app/components/IconPicker/components/Grid.tsx b/app/components/IconPicker/components/Grid.tsx new file mode 100644 index 000000000000..28842fd8151d --- /dev/null +++ b/app/components/IconPicker/components/Grid.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { FixedSizeList, ListChildComponentProps } from "react-window"; +import styled from "styled-components"; + +type Props = { + width: number; + height: number; + data: React.ReactNode[][]; + columns: number; + itemWidth: number; +}; + +const Grid = ( + { width, height, data, columns, itemWidth }: Props, + ref: React.Ref +) => ( + + {Row} + +); + +type RowProps = { + data: React.ReactNode[][]; + columns: number; +}; + +const Row = ({ index, style, data }: ListChildComponentProps) => { + const { data: rows, columns } = data; + const row = rows[index]; + + return ( + + {row} + + ); +}; + +const Container = styled(FixedSizeList)` + padding: 0px 12px; + overflow-x: hidden !important; + + // Needed for the absolutely positioned children + // to respect the VirtualList's padding + & > div { + position: relative; + } +`; + +const RowContainer = styled.div<{ columns: number }>` + display: grid; + grid-template-columns: ${({ columns }) => `repeat(${columns}, 1fr)`}; + align-content: center; +`; + +export default React.forwardRef(Grid); diff --git a/app/components/IconPicker/components/GridTemplate.tsx b/app/components/IconPicker/components/GridTemplate.tsx new file mode 100644 index 000000000000..c3abc3589c64 --- /dev/null +++ b/app/components/IconPicker/components/GridTemplate.tsx @@ -0,0 +1,120 @@ +import chunk from "lodash/chunk"; +import compact from "lodash/compact"; +import React from "react"; +import styled from "styled-components"; +import { IconType } from "@shared/types"; +import { IconLibrary } from "@shared/utils/IconLibrary"; +import Text from "~/components/Text"; +import { TRANSLATED_CATEGORIES } from "../utils"; +import { Emoji } from "./Emoji"; +import Grid from "./Grid"; +import { IconButton } from "./IconButton"; + +/** + * icon/emoji size is 24px; and we add 4px padding on all sides, + */ +const BUTTON_SIZE = 32; + +type OutlineNode = { + type: IconType.SVG; + name: string; + color: string; + initial: string; + delay: number; +}; + +type EmojiNode = { + type: IconType.Emoji; + id: string; + value: string; +}; + +export type DataNode = { + category: keyof typeof TRANSLATED_CATEGORIES; + icons: (OutlineNode | EmojiNode)[]; +}; + +type Props = { + width: number; + height: number; + data: DataNode[]; + onIconSelect: ({ id, value }: { id: string; value: string }) => void; +}; + +const GridTemplate = ( + { width, height, data, onIconSelect }: Props, + ref: React.Ref +) => { + // 24px padding for the Grid Container + const itemsPerRow = Math.floor((width - 24) / BUTTON_SIZE); + + const gridItems = compact( + data.flatMap((node) => { + if (node.icons.length === 0) { + return []; + } + + const category = ( + + {TRANSLATED_CATEGORIES[node.category]} + + ); + + const items = node.icons.map((item) => { + if (item.type === IconType.SVG) { + return ( + onIconSelect({ id: item.name, value: item.name })} + style={{ "--delay": `${item.delay}ms` } as React.CSSProperties} + > + + {item.initial} + + + ); + } + + return ( + onIconSelect({ id: item.id, value: item.value })} + > + {item.value} + + ); + }); + + const chunks = chunk(items, itemsPerRow); + return [[category], ...chunks]; + }) + ); + + return ( + + ); +}; + +const CategoryName = styled(Text)` + grid-column: 1 / -1; + padding-left: 6px; +`; + +const Icon = styled.svg` + transition: color 150ms ease-in-out, fill 150ms ease-in-out; + transition-delay: var(--delay); +`; + +export default React.forwardRef(GridTemplate); diff --git a/app/components/IconPicker/components/IconButton.tsx b/app/components/IconPicker/components/IconButton.tsx new file mode 100644 index 000000000000..0b521cfe2029 --- /dev/null +++ b/app/components/IconPicker/components/IconButton.tsx @@ -0,0 +1,14 @@ +import styled from "styled-components"; +import { s } from "@shared/styles"; +import NudeButton from "~/components/NudeButton"; +import { hover } from "~/styles"; + +export const IconButton = styled(NudeButton)<{ delay?: number }>` + width: 32px; + height: 32px; + padding: 4px; + + &: ${hover} { + background: ${s("listItemHoverBackground")}; + } +`; diff --git a/app/components/IconPicker/components/IconPanel.tsx b/app/components/IconPicker/components/IconPanel.tsx new file mode 100644 index 000000000000..22c8eccb8248 --- /dev/null +++ b/app/components/IconPicker/components/IconPanel.tsx @@ -0,0 +1,200 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import { IconType } from "@shared/types"; +import { IconLibrary } from "@shared/utils/IconLibrary"; +import Flex from "~/components/Flex"; +import InputSearch from "~/components/InputSearch"; +import usePersistedState from "~/hooks/usePersistedState"; +import { + FREQUENTLY_USED_COUNT, + DisplayCategory, + iconsFreqKey, + lastIconKey, + sortFrequencies, +} from "../utils"; +import ColorPicker from "./ColorPicker"; +import GridTemplate, { DataNode } from "./GridTemplate"; + +const IconNames = Object.keys(IconLibrary.mapping); +const TotalIcons = IconNames.length; + +/** + * This is needed as a constant for react-window. + * Calculated from the heights of TabPanel, ColorPicker and InputSearch. + */ +const GRID_HEIGHT = 314; + +const useIconState = () => { + const [iconsFreq, setIconsFreq] = usePersistedState>( + iconsFreqKey, + {} + ); + const [lastIcon, setLastIcon] = usePersistedState( + lastIconKey, + undefined + ); + + const incrementIconCount = React.useCallback( + (icon: string) => { + iconsFreq[icon] = (iconsFreq[icon] ?? 0) + 1; + setIconsFreq({ ...iconsFreq }); + setLastIcon(icon); + }, + [iconsFreq, setIconsFreq, setLastIcon] + ); + + const getFreqIcons = React.useCallback(() => { + const freqs = Object.entries(iconsFreq); + + if (freqs.length > FREQUENTLY_USED_COUNT.Track) { + sortFrequencies(freqs).splice(FREQUENTLY_USED_COUNT.Track); + setIconsFreq(Object.fromEntries(freqs)); + } + + const icons = sortFrequencies(freqs) + .slice(0, FREQUENTLY_USED_COUNT.Get) + .map(([icon, _]) => icon); + + const isLastPresent = icons.includes(lastIcon ?? ""); + if (lastIcon && !isLastPresent) { + icons.pop(); + icons.push(lastIcon); + } + + return icons; + }, [iconsFreq, setIconsFreq, lastIcon]); + + return { + incrementIconCount, + getFreqIcons, + }; +}; + +type Props = { + panelWidth: number; + initial: string; + color: string; + query: string; + panelActive: boolean; + onIconChange: (icon: string) => void; + onColorChange: (icon: string) => void; + onQueryChange: (query: string) => void; +}; + +const IconPanel = ({ + panelWidth, + initial, + color, + query, + panelActive, + onIconChange, + onColorChange, + onQueryChange, +}: Props) => { + const { t } = useTranslation(); + + const searchRef = React.useRef(null); + const scrollableRef = React.useRef(null); + + const { incrementIconCount, getFreqIcons } = useIconState(); + + const freqIcons = React.useMemo(() => getFreqIcons(), [getFreqIcons]); + const totalFreqIcons = freqIcons.length; + + const filteredIcons = React.useMemo( + () => IconLibrary.findIcons(query), + [query] + ); + + const isSearch = query !== ""; + const category = isSearch ? DisplayCategory.Search : DisplayCategory.All; + const delayPerIcon = 250 / (TotalIcons + totalFreqIcons); + + const handleFilter = React.useCallback( + (event: React.ChangeEvent) => { + onQueryChange(event.target.value); + }, + [onQueryChange] + ); + + const handleIconSelection = React.useCallback( + ({ id, value }: { id: string; value: string }) => { + onIconChange(value); + incrementIconCount(id); + }, + [onIconChange, incrementIconCount] + ); + + const baseIcons: DataNode = { + category, + icons: filteredIcons.map((name, index) => ({ + type: IconType.SVG, + name, + color, + initial, + delay: Math.round((index + totalFreqIcons) * delayPerIcon), + onClick: handleIconSelection, + })), + }; + + const templateData: DataNode[] = isSearch + ? [baseIcons] + : [ + { + category: DisplayCategory.Frequent, + icons: freqIcons.map((name, index) => ({ + type: IconType.SVG, + name, + color, + initial, + delay: Math.round((index + totalFreqIcons) * delayPerIcon), + onClick: handleIconSelection, + })), + }, + baseIcons, + ]; + + React.useEffect(() => { + if (scrollableRef.current) { + scrollableRef.current.scrollTop = 0; + } + searchRef.current?.focus(); + }, [panelActive]); + + return ( + + + + + + + + ); +}; + +const InputSearchContainer = styled(Flex)` + height: 48px; + padding: 6px 12px 0px; +`; + +const StyledInputSearch = styled(InputSearch)` + flex-grow: 1; +`; + +export default IconPanel; diff --git a/app/components/IconPicker/components/PopoverButton.tsx b/app/components/IconPicker/components/PopoverButton.tsx new file mode 100644 index 000000000000..e53bf482ee12 --- /dev/null +++ b/app/components/IconPicker/components/PopoverButton.tsx @@ -0,0 +1,20 @@ +import styled, { css } from "styled-components"; +import { s } from "@shared/styles"; +import NudeButton from "~/components/NudeButton"; +import { hover } from "~/styles"; + +export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>` + &: ${hover}, + &:active, + &[aria-expanded= "true"] { + opacity: 1 !important; + + ${({ $borderOnHover }) => + $borderOnHover && + css` + background: ${s("buttonNeutralBackground")}; + box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, + ${s("buttonNeutralBorder")} 0 0 0 1px inset; + `}; + } +`; diff --git a/app/components/IconPicker/components/SkinTonePicker.tsx b/app/components/IconPicker/components/SkinTonePicker.tsx new file mode 100644 index 000000000000..2061fd302d4a --- /dev/null +++ b/app/components/IconPicker/components/SkinTonePicker.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Menu, MenuButton, MenuItem, useMenuState } from "reakit"; +import styled from "styled-components"; +import { depths, s } from "@shared/styles"; +import { EmojiSkinTone } from "@shared/types"; +import { getEmojiVariants } from "@shared/utils/emoji"; +import Flex from "~/components/Flex"; +import NudeButton from "~/components/NudeButton"; +import { hover } from "~/styles"; +import { Emoji } from "./Emoji"; +import { IconButton } from "./IconButton"; + +const SkinTonePicker = ({ + skinTone, + onChange, +}: { + skinTone: EmojiSkinTone; + onChange: (skin: EmojiSkinTone) => void; +}) => { + const { t } = useTranslation(); + + const handEmojiVariants = React.useMemo( + () => getEmojiVariants({ id: "hand" }), + [] + ); + + const menu = useMenuState({ + placement: "bottom", + }); + + const handleSkinClick = React.useCallback( + (emojiSkin) => { + menu.hide(); + onChange(emojiSkin); + }, + [menu, onChange] + ); + + const menuItems = React.useMemo( + () => + Object.entries(handEmojiVariants).map(([eskin, emoji]) => ( + + {(menuprops) => ( + handleSkinClick(eskin)}> + {emoji.value} + + )} + + )), + [menu, handEmojiVariants, handleSkinClick] + ); + + return ( + <> + + {(props) => ( + + {handEmojiVariants[skinTone]!.value} + + )} + + + {(props) => {menuItems}} + + + ); +}; + +const MenuContainer = styled(Flex)` + z-index: ${depths.menu}; + padding: 4px; + border-radius: 4px; + background: ${s("menuBackground")}; + box-shadow: ${s("menuShadow")}; +`; + +const StyledMenuButton = styled(NudeButton)` + width: 32px; + height: 32px; + border: 1px solid ${s("inputBorder")}; + padding: 4px; + + &: ${hover} { + border: 1px solid ${s("inputBorderFocused")}; + } +`; + +export default SkinTonePicker; diff --git a/app/components/IconPicker/index.tsx b/app/components/IconPicker/index.tsx new file mode 100644 index 000000000000..a1261942eeae --- /dev/null +++ b/app/components/IconPicker/index.tsx @@ -0,0 +1,312 @@ +import { SmileyIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { + PopoverDisclosure, + Tab, + TabList, + TabPanel, + usePopoverState, + useTabState, +} from "reakit"; +import styled, { css } from "styled-components"; +import { s } from "@shared/styles"; +import theme from "@shared/styles/theme"; +import { IconType } from "@shared/types"; +import { determineIconType } from "@shared/utils/icon"; +import Flex from "~/components/Flex"; +import Icon from "~/components/Icon"; +import NudeButton from "~/components/NudeButton"; +import Popover from "~/components/Popover"; +import useMobile from "~/hooks/useMobile"; +import useOnClickOutside from "~/hooks/useOnClickOutside"; +import usePrevious from "~/hooks/usePrevious"; +import useWindowSize from "~/hooks/useWindowSize"; +import { hover } from "~/styles"; +import EmojiPanel from "./components/EmojiPanel"; +import IconPanel from "./components/IconPanel"; +import { PopoverButton } from "./components/PopoverButton"; + +const TAB_NAMES = { + Icon: "icon", + Emoji: "emoji", +} as const; + +const POPOVER_WIDTH = 408; + +type Props = { + icon: string | null; + color: string; + size?: number; + initial?: string; + className?: string; + popoverPosition: "bottom-start" | "right"; + allowDelete?: boolean; + borderOnHover?: boolean; + onChange: (icon: string | null, color: string | null) => void; + onOpen?: () => void; + onClose?: () => void; +}; + +const IconPicker = ({ + icon, + color, + size = 24, + initial, + className, + popoverPosition, + allowDelete, + onChange, + onOpen, + onClose, + borderOnHover, +}: Props) => { + const { t } = useTranslation(); + + const { width: windowWidth } = useWindowSize(); + const isMobile = useMobile(); + + const [query, setQuery] = React.useState(""); + const [chosenColor, setChosenColor] = React.useState(color); + const contentRef = React.useRef(null); + + const iconType = determineIconType(icon); + const defaultTab = React.useMemo( + () => + iconType === IconType.Emoji ? TAB_NAMES["Emoji"] : TAB_NAMES["Icon"], + [iconType] + ); + + const popover = usePopoverState({ + placement: popoverPosition, + modal: true, + unstable_offset: [0, 0], + }); + const tab = useTabState({ selectedId: defaultTab }); + const previouslyVisible = usePrevious(popover.visible); + + const popoverWidth = isMobile ? windowWidth : POPOVER_WIDTH; + // In mobile, popover is absolutely positioned to leave 8px on both sides. + const panelWidth = isMobile ? windowWidth - 16 : popoverWidth; + + const resetDefaultTab = React.useCallback(() => { + tab.select(defaultTab); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultTab]); + + const handleIconChange = React.useCallback( + (ic: string) => { + popover.hide(); + const icType = determineIconType(ic); + const finalColor = icType === IconType.SVG ? chosenColor : null; + onChange(ic, finalColor); + }, + [popover, onChange, chosenColor] + ); + + const handleIconColorChange = React.useCallback( + (c: string) => { + setChosenColor(c); + + const icType = determineIconType(icon); + // Outline icon set; propagate color change + if (icType === IconType.SVG) { + onChange(icon, c); + } + }, + [icon, onChange] + ); + + const handleIconRemove = React.useCallback(() => { + popover.hide(); + onChange(null, null); + }, [popover, onChange]); + + const handlePopoverButtonClick = React.useCallback( + (ev: React.MouseEvent) => { + ev.stopPropagation(); + if (popover.visible) { + popover.hide(); + } else { + popover.show(); + } + }, + [popover] + ); + + // Popover open effect + React.useEffect(() => { + if (popover.visible && !previouslyVisible) { + onOpen?.(); + } else if (!popover.visible && previouslyVisible) { + onClose?.(); + setQuery(""); + resetDefaultTab(); + } + }, [popover.visible, previouslyVisible, onOpen, onClose, resetDefaultTab]); + + // Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can + // prevent event bubbling. + useOnClickOutside( + contentRef, + (event) => { + if ( + popover.visible && + !popover.unstable_disclosureRef.current?.contains(event.target as Node) + ) { + event.stopPropagation(); + event.preventDefault(); + popover.hide(); + } + }, + { capture: true } + ); + + return ( + <> + + {(props) => ( + + {iconType && icon ? ( + + ) : ( + + )} + + )} + + e.stopPropagation()} + hideOnClickOutside={false} + > + <> + + + + {t("Icons")} + + + {t("Emojis")} + + + {allowDelete && icon && ( + + {t("Remove")} + + )} + + + + + + + + + + + ); +}; + +const StyledSmileyIcon = styled(SmileyIcon)` + flex-shrink: 0; + + @media print { + display: none; + } +`; + +const RemoveButton = styled(NudeButton)` + width: auto; + font-weight: 500; + font-size: 14px; + color: ${s("textTertiary")}; + padding: 8px 12px; + transition: color 100ms ease-in-out; + &: ${hover} { + color: ${s("textSecondary")}; + } +`; + +const TabActionsWrapper = styled(Flex)` + padding-left: 12px; + border-bottom: 1px solid ${s("inputBorder")}; +`; + +const StyledTab = styled(Tab)<{ $active: boolean }>` + position: relative; + font-weight: 500; + font-size: 14px; + cursor: var(--pointer); + background: none; + border: 0; + padding: 8px 12px; + user-select: none; + color: ${({ $active }) => ($active ? s("textSecondary") : s("textTertiary"))}; + transition: color 100ms ease-in-out; + + &: ${hover} { + color: ${s("textSecondary")}; + } + + ${({ $active }) => + $active && + css` + &:after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: ${s("textSecondary")}; + } + `} +`; + +const StyledTabPanel = styled(TabPanel)` + height: 410px; + overflow-y: auto; +`; + +export default React.memo(IconPicker); diff --git a/app/components/IconPicker/utils.ts b/app/components/IconPicker/utils.ts new file mode 100644 index 000000000000..6ef68a96905b --- /dev/null +++ b/app/components/IconPicker/utils.ts @@ -0,0 +1,50 @@ +import i18next from "i18next"; + +export enum DisplayCategory { + All = "All", + Frequent = "Frequent", + Search = "Search", +} + +export const TRANSLATED_CATEGORIES = { + All: i18next.t("All"), + Frequent: i18next.t("Frequently Used"), + Search: i18next.t("Search Results"), + People: i18next.t("Smileys & People"), + Nature: i18next.t("Animals & Nature"), + Foods: i18next.t("Food & Drink"), + Activity: i18next.t("Activity"), + Places: i18next.t("Travel & Places"), + Objects: i18next.t("Objects"), + Symbols: i18next.t("Symbols"), + Flags: i18next.t("Flags"), +}; + +export const FREQUENTLY_USED_COUNT = { + Get: 24, + Track: 30, +}; + +const STORAGE_KEYS = { + Base: "icon-state", + EmojiSkinTone: "emoji-skintone", + IconsFrequency: "icons-freq", + EmojisFrequency: "emojis-freq", + LastIcon: "last-icon", + LastEmoji: "last-emoji", +}; + +const getStorageKey = (key: string) => `${STORAGE_KEYS.Base}.${key}`; + +export const emojiSkinToneKey = getStorageKey(STORAGE_KEYS.EmojiSkinTone); + +export const iconsFreqKey = getStorageKey(STORAGE_KEYS.IconsFrequency); + +export const emojisFreqKey = getStorageKey(STORAGE_KEYS.EmojisFrequency); + +export const lastIconKey = getStorageKey(STORAGE_KEYS.LastIcon); + +export const lastEmojiKey = getStorageKey(STORAGE_KEYS.LastEmoji); + +export const sortFrequencies = (freqs: [string, number][]) => + freqs.sort((a, b) => (a[1] >= b[1] ? -1 : 1)); diff --git a/app/components/Icons/CircleIcon.tsx b/app/components/Icons/CircleIcon.tsx new file mode 100644 index 000000000000..9da634ce5673 --- /dev/null +++ b/app/components/Icons/CircleIcon.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; + +type Props = { + /** The size of the icon, 24px is default to match standard icons */ + size?: number; + /** The color of the icon, defaults to the current text color */ + color?: string; + /** If true, the icon will retain its color in selected menus and other places that attempt to override it */ + retainColor?: boolean; +}; + +export default function CircleIcon({ + size = 24, + color = "currentColor", + retainColor, + ...rest +}: Props) { + return ( + + + + ); +} diff --git a/app/components/Icons/CollectionIcon.tsx b/app/components/Icons/CollectionIcon.tsx index 52cddac24b8e..14cf595d5895 100644 --- a/app/components/Icons/CollectionIcon.tsx +++ b/app/components/Icons/CollectionIcon.tsx @@ -1,11 +1,11 @@ import { observer } from "mobx-react"; -import { CollectionIcon } from "outline-icons"; +import { CollectionIcon, PrivateCollectionIcon } from "outline-icons"; import { getLuminance } from "polished"; import * as React from "react"; +import { colorPalette } from "@shared/utils/collections"; import Collection from "~/models/Collection"; -import { icons } from "~/components/IconPicker"; +import Icon from "~/components/Icon"; import useStores from "~/hooks/useStores"; -import Logger from "~/utils/Logger"; type Props = { /** The collection to show an icon for */ @@ -16,6 +16,7 @@ type Props = { size?: number; /** The color of the icon, defaults to the collection color */ color?: string; + className?: string; }; function ResolvedCollectionIcon({ @@ -23,35 +24,45 @@ function ResolvedCollectionIcon({ color: inputColor, expanded, size, + className, }: Props) { const { ui } = useStores(); - // If the chosen icon color is very dark then we invert it in dark mode - // otherwise it will be impossible to see against the dark background. - const color = - inputColor || - (ui.resolvedTheme === "dark" && collection.color !== "currentColor" - ? getLuminance(collection.color) > 0.09 - ? collection.color - : "currentColor" - : collection.color); + if (!collection.icon || collection.icon === "collection") { + // If the chosen icon color is very dark then we invert it in dark mode + // otherwise it will be impossible to see against the dark background. + const collectionColor = collection.color ?? colorPalette[0]; + const color = + inputColor || + (ui.resolvedTheme === "dark" && collectionColor !== "currentColor" + ? getLuminance(collectionColor) > 0.09 + ? collectionColor + : "currentColor" + : collectionColor); - if (collection.icon && collection.icon !== "collection") { - try { - const Component = icons[collection.icon].component; - return ( - - {collection.initial} - - ); - } catch (error) { - Logger.warn("Failed to render custom icon", { - icon: collection.icon, - }); - } + const Component = collection.isPrivate + ? PrivateCollectionIcon + : CollectionIcon; + return ( + + ); } - return ; + return ( + + ); } export default observer(ResolvedCollectionIcon); diff --git a/app/components/Icons/EmojiIcon.tsx b/app/components/Icons/EmojiIcon.tsx index d4ce0e61f679..6512008279be 100644 --- a/app/components/Icons/EmojiIcon.tsx +++ b/app/components/Icons/EmojiIcon.tsx @@ -1,11 +1,13 @@ import * as React from "react"; import styled from "styled-components"; +import { s } from "@shared/styles"; type Props = { /** The emoji to render */ emoji: string; /** The size of the emoji, 24px is default to match standard icons */ size?: number; + className?: string; }; /** @@ -15,19 +17,28 @@ type Props = { export default function EmojiIcon({ size = 24, emoji, ...rest }: Props) { return ( - {emoji} + ); } const Span = styled.span<{ $size: number }>` - display: inline-flex; - align-items: center; - justify-content: center; - text-align: center; - flex-shrink: 0; + font-family: ${s("fontFamilyEmoji")}; + display: inline-block; width: ${(props) => props.$size}px; height: ${(props) => props.$size}px; - text-indent: -0.15em; - font-size: ${(props) => props.$size - 10}px; `; + +const SVG = ({ size, emoji }: { size: number; emoji: string }) => ( + + + {emoji} + + +); diff --git a/app/components/Icons/IssueStatusIcon.tsx b/app/components/Icons/IssueStatusIcon.tsx new file mode 100644 index 000000000000..0b3e77c3c398 --- /dev/null +++ b/app/components/Icons/IssueStatusIcon.tsx @@ -0,0 +1,74 @@ +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 ( + + + + ); +} + +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 ( + + + + + ); + case "closed": + return ( + + + + + ); + case "canceled": + return ( + + + + ); + default: + return null; + } +} diff --git a/app/components/Icons/LanguageIcon.tsx b/app/components/Icons/LanguageIcon.tsx new file mode 100644 index 000000000000..286b9459403d --- /dev/null +++ b/app/components/Icons/LanguageIcon.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +export function LanguageIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/app/components/Icons/PullRequestIcon.tsx b/app/components/Icons/PullRequestIcon.tsx new file mode 100644 index 000000000000..90c61e455613 --- /dev/null +++ b/app/components/Icons/PullRequestIcon.tsx @@ -0,0 +1,72 @@ +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 ( + + + + ); +} + +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 ( + + + + ); + case "merged": + return ( + + + + ); + case "closed": + return ( + + + + ); + default: + return null; + } +} diff --git a/app/components/Input.tsx b/app/components/Input.tsx index 2cd3264f7e1c..3def4e5ac345 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -8,10 +8,14 @@ import Flex from "~/components/Flex"; import Text from "~/components/Text"; import { undraggableOnDesktop } from "~/styles"; -const RealTextarea = styled.textarea<{ hasIcon?: boolean }>` +export const NativeTextarea = styled.textarea<{ + hasIcon?: boolean; + hasPrefix?: boolean; +}>` border: 0; flex: 1; - padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")}; + padding: 8px 12px 8px + ${(props) => (props.hasPrefix ? 0 : props.hasIcon ? "8px" : "12px")}; outline: none; background: none; color: ${s("text")}; @@ -19,13 +23,18 @@ const RealTextarea = styled.textarea<{ hasIcon?: boolean }>` &:disabled, &::placeholder { color: ${s("placeholder")}; + opacity: 1; } `; -const RealInput = styled.input<{ hasIcon?: boolean }>` +export const NativeInput = styled.input<{ + hasIcon?: boolean; + hasPrefix?: boolean; +}>` border: 0; flex: 1; - padding: 8px 12px 8px ${(props) => (props.hasIcon ? "8px" : "12px")}; + padding: 8px 12px 8px + ${(props) => (props.hasPrefix ? 0 : props.hasIcon ? "8px" : "12px")}; outline: none; background: none; color: ${s("text")}; @@ -39,6 +48,7 @@ const RealInput = styled.input<{ hasIcon?: boolean }>` &:disabled, &::placeholder { color: ${s("placeholder")}; + opacity: 1; } &:-webkit-autofill, @@ -56,7 +66,7 @@ const RealInput = styled.input<{ hasIcon?: boolean }>` `}; `; -const Wrapper = styled.div<{ +export const Wrapper = styled.div<{ flex?: boolean; short?: boolean; minHeight?: number; @@ -112,7 +122,10 @@ export const LabelText = styled.div` `; export interface Props - extends React.InputHTMLAttributes { + extends Omit< + React.InputHTMLAttributes, + "prefix" + > { type?: "text" | "email" | "checkbox" | "search" | "textarea"; labelHidden?: boolean; label?: string; @@ -120,6 +133,9 @@ 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; @@ -183,6 +199,7 @@ function Input( className, short, flex, + prefix, labelHidden, onFocus, onBlur, @@ -203,9 +220,10 @@ function Input( wrappedLabel ))} + {prefix} {icon && {icon}} {type === "textarea" ? ( - , @@ -214,10 +232,11 @@ function Input( onFocus={handleFocus} onKeyDown={handleKeyDown} hasIcon={!!icon} + hasPrefix={!!prefix} {...rest} /> ) : ( - , @@ -226,6 +245,7 @@ function Input( onFocus={handleFocus} onKeyDown={handleKeyDown} hasIcon={!!icon} + hasPrefix={!!prefix} type={type} {...rest} /> @@ -235,9 +255,9 @@ function Input( {error && ( - + {error} - +
)} @@ -250,8 +270,4 @@ export const TextWrapper = styled.span` margin-top: -16px; `; -export const StyledText = styled(Text)` - margin-bottom: 0; -`; - export default React.forwardRef(Input); diff --git a/app/scenes/CollectionPermissions/components/InputMemberPermissionSelect.tsx b/app/components/InputMemberPermissionSelect.tsx similarity index 58% rename from app/scenes/CollectionPermissions/components/InputMemberPermissionSelect.tsx rename to app/components/InputMemberPermissionSelect.tsx index 889e78a5248b..51086711029c 100644 --- a/app/scenes/CollectionPermissions/components/InputMemberPermissionSelect.tsx +++ b/app/components/InputMemberPermissionSelect.tsx @@ -2,35 +2,25 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { s } from "@shared/styles"; -import { CollectionPermission } from "@shared/types"; import InputSelect, { Props as SelectProps } from "~/components/InputSelect"; +import { EmptySelectValue, Permission } from "~/types"; export default function InputMemberPermissionSelect( - props: Partial + props: Partial & { permissions: Permission[] } ) { + const { value, onChange, ...rest } = props; const { t } = useTranslation(); return ( ) => unknown; + /** Event handler for when a key is pressed. */ onKeyDown?: (event: React.KeyboardEvent) => unknown; }; diff --git a/app/components/InputSelect.tsx b/app/components/InputSelect.tsx index 5e73300c87a5..6552c84b8af4 100644 --- a/app/components/InputSelect.tsx +++ b/app/components/InputSelect.tsx @@ -10,10 +10,11 @@ import * as React from "react"; import { VisuallyHidden } from "reakit/VisuallyHidden"; import styled, { css } from "styled-components"; import { s } from "@shared/styles"; -import Button, { Inner } from "~/components/Button"; +import Button, { Props as ButtonProps, 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, @@ -22,18 +23,21 @@ import { Placement, } from "./ContextMenu"; import { MenuAnchorCSS } from "./ContextMenu/MenuItem"; +import Separator from "./ContextMenu/Separator"; import { LabelText } from "./Input"; export type Option = { label: string | JSX.Element; value: string; + description?: string; + divider?: boolean; }; -export type Props = { +export type Props = Omit, "onChange"> & { id?: string; name?: string; value?: string | null; - label?: string; + label?: React.ReactNode; nude?: boolean; ariaLabel: string; short?: boolean; @@ -42,10 +46,23 @@ export type Props = { labelHidden?: boolean; icon?: React.ReactNode; options: Option[]; + /** @deprecated Removing soon, do not use. */ note?: React.ReactNode; onChange?: (value: string | null) => void; + style?: React.CSSProperties; + /** + * Set to true if this component is rendered inside a Modal. + * The Modal will take care of preventing body scroll behaviour. + */ + skipBodyScroll?: boolean; }; +export interface InputSelectRef { + value: string | null; + focus: () => void; + blur: () => void; +} + interface InnerProps extends React.HTMLAttributes { placement: Placement; } @@ -53,7 +70,7 @@ interface InnerProps extends React.HTMLAttributes { const getOptionFromValue = (options: Option[], value: string | null) => options.find((option) => option.value === value); -const InputSelect = (props: Props) => { +const InputSelect = (props: Props, ref: React.RefObject) => { const { value = null, label, @@ -66,6 +83,8 @@ const InputSelect = (props: Props) => { disabled, note, icon, + nude, + skipBodyScroll, ...rest } = props; @@ -75,10 +94,10 @@ const InputSelect = (props: Props) => { selectedValue: value, }); - const popOver = useSelectPopover({ + const popover = useSelectPopover({ ...select, - hideOnClickOutside: true, - preventBodyScroll: true, + hideOnClickOutside: false, + preventBodyScroll: skipBodyScroll ? false : true, disabled, }); @@ -103,9 +122,45 @@ const InputSelect = (props: Props) => { const wrappedLabel = {label}; const selectedValueIndex = options.findIndex( - (option) => option.value === select.selectedValue + (opt) => opt.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; @@ -125,6 +180,24 @@ const InputSelect = (props: Props) => { } }, [select.visible, selectedValueIndex]); + function labelForOption(opt: Option) { + return ( + <> + {opt.label} + {opt.description && ( + <> +   + + – {opt.description} + + + )} + + ); + } + + const option = getOptionFromValue(options, select.selectedValue); + return ( <> @@ -136,33 +209,42 @@ const InputSelect = (props: Props) => { ))} - - {(props: InnerProps) => { - const topAnchor = props.style?.top === "0"; - const rightAnchor = props.placement === "bottom-end"; + + {(popoverProps: InnerProps) => { + const topAnchor = popoverProps.style?.top === "0"; + const rightAnchor = popoverProps.placement === "bottom-end"; return ( - + { } > {select.visible - ? options.map((option) => { - const isSelected = - select.selectedValue === option.value; + ? options.map((opt) => { + const isSelected = select.selectedValue === opt.value; const Icon = isSelected ? CheckmarkIcon : Spacer; return ( - - -   - {option.label} - + + {opt.divider && } + + +   + {labelForOption(opt)} + + ); }) : null} @@ -200,7 +284,7 @@ const InputSelect = (props: Props) => { {note && ( - + {note} )} @@ -223,20 +307,20 @@ 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; display: block; width: 100%; - cursor: default; + cursor: var(--pointer); &:hover:not(:disabled) { background: ${s("buttonNeutralBackground")}; } ${(props) => - props.nude && + props.$nude && css` border-color: transparent; box-shadow: none; @@ -244,8 +328,8 @@ const StyledButton = styled(Button)<{ nude?: boolean }>` ${Inner} { line-height: 28px; - padding-left: 16px; - padding-right: 8px; + padding-left: 12px; + padding-right: 4px; } svg { @@ -267,8 +351,10 @@ const Wrapper = styled.label<{ short?: boolean }>` max-width: ${(props) => (props.short ? "350px" : "100%")}; `; -const Positioner = styled(Position)` - &.focus-visible { +export const Positioner = styled(Position)` + pointer-events: all; + + &:focus-visible { ${StyledSelectOption} { &[aria-selected="true"] { color: ${(props) => props.theme.white}; @@ -284,4 +370,4 @@ const Positioner = styled(Position)` } `; -export default InputSelect; +export default React.forwardRef(InputSelect); diff --git a/app/components/InputSelectPermission.tsx b/app/components/InputSelectPermission.tsx index 5f108bacc7c7..3286a5c07b38 100644 --- a/app/components/InputSelectPermission.tsx +++ b/app/components/InputSelectPermission.tsx @@ -1,52 +1,54 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; +import styled from "styled-components"; import { $Diff } from "utility-types"; +import { s } from "@shared/styles"; import { CollectionPermission } from "@shared/types"; -import InputSelect, { Props, Option } from "./InputSelect"; +import { EmptySelectValue } from "~/types"; +import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect"; -export default function InputSelectPermission( +function InputSelectPermission( props: $Diff< Props, { options: Array