Compare commits

..

9 Commits

Author SHA1 Message Date
hmacr 1c802a0434 use maxStateLength for text 2025-06-11 16:35:48 +05:30
hmacr 6d76379821 remove validationBehavior related code 2025-06-11 16:31:54 +05:30
codegen-sh[bot] cd84eb7228 Applied automatic fixes 2025-06-11 03:31:01 +00:00
codegen-sh[bot] f686edbcf4 Remove validation behavior UI option, keep truncation as default
Based on feedback, removed the validation behavior selection from the import dialog.
The backend still supports all three behaviors (Skip, Truncate, Abort) but now
defaults to Truncate behavior without exposing the option in the UI.

This makes the import dialog cleaner and simpler for users while still providing
the improved validation handling behind the scenes.
2025-06-11 03:28:20 +00:00
Tom Moor cc849be25e Merge branch 'main' into codegen-bot/import-validation-behavior-options 2025-06-10 21:29:11 -04:00
hmacr ee285bc4d5 Merge branch 'main' into codegen-bot/import-validation-behavior-options 2025-06-07 16:11:00 +05:30
hmacr b23596600b additional skip behaviours 2025-06-07 16:05:04 +05:30
codegen-sh[bot] 7c087b125e Fix TypeScript errors in import validation behavior
- Fix InputSelect onChange prop type mismatch in DropToImport.tsx
- Add required ariaLabel prop to InputSelect component
- Remove incorrect 'new' keyword from ValidationError function call in ImportTask.ts
2025-05-31 15:03:18 +00:00
codegen-sh[bot] 933dde935d Add import validation behavior options
- Add ImportValidationBehavior enum with Skip, Truncate, and Abort options
- Update FileOperation options to include validationBehavior
- Add validation behavior selection to DropToImport UI component
- Update collections import API to accept validationBehavior parameter
- Implement validation logic in ImportTask with document processing
- Set Truncate as the new default behavior (was Abort)

Addresses GitHub issue #9285
2025-05-31 14:49:54 +00:00
775 changed files with 16136 additions and 24327 deletions
+13 -4
View File
@@ -21,7 +21,10 @@
[
"transform-inline-environment-variables",
{
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
"include": [
"SOURCE_COMMIT",
"SOURCE_VERSION"
]
}
],
"tsconfig-paths-module-resolver"
@@ -36,10 +39,16 @@
}
]
],
"ignore": ["**/__mocks__", "**/*.test.ts"]
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"development": {
"ignore": ["**/__mocks__", "**/*.test.ts"]
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"test": {
"presets": [
@@ -56,4 +65,4 @@
]
}
}
}
}
-1
View File
@@ -6,7 +6,6 @@ __mocks__
.DS_Store
.env*
.eslint*
.oxlintrc*
.log
Makefile
Procfile
-5
View File
@@ -202,7 +202,6 @@ RATE_LIMITER_DURATION_WINDOW=60
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_WEBHOOK_SECRET=
GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
@@ -211,10 +210,6 @@ GITHUB_APP_PRIVATE_KEY=
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# The GitLab integration allows previewing issue and merge request links as rich mentions
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# For a complete Slack integration with search and posting to channels the
# following configs are also needed in addition to Slack authentication:
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
+1
View File
@@ -0,0 +1 @@
server/migrations/*.js
+190
View File
@@ -0,0 +1,190 @@
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"extraFileExtensions": [
".json"
],
"project": "./tsconfig.json",
"ecmaFeatures": {
"jsx": true
}
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:prettier/recommended"
],
"plugins": [
"es",
"react",
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-lodash"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "reakit/Menu",
"importNames": [
"useMenuState"
],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
],
"eqeqeq": 2,
"curly": 2,
"no-console": "error",
"arrow-body-style": [
"error",
"as-needed"
],
"spaced-comment": "error",
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"no-shadow": "off",
"es/no-regexp-lookbehind-assertions": "error",
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"@typescript-eslint/no-shadow": [
"warn",
{
"allow": [
"transaction"
],
"hoist": "all",
"ignoreTypeValueShadow": true
}
],
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": false
}
],
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"args": "after-used",
"ignoreRestSiblings": true
}
],
"padding-line-between-statements": [
"error",
{
"blankLine": "always",
"prev": "*",
"next": "export"
}
],
"lines-between-class-members": [
"error",
"always",
{
"exceptAfterSingleLine": true
}
],
"lodash/import-scope": [
"error",
"method"
],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
"import/order": [
"error",
{
"alphabetize": {
"order": "asc"
},
"pathGroups": [
{
"pattern": "@shared/**",
"group": "external",
"position": "after"
},
{
"pattern": "@server/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/stores",
"group": "external",
"position": "after"
},
{
"pattern": "~/stores/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/models/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/scenes/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/components/**",
"group": "external",
"position": "after"
},
{
"pattern": "~/**",
"group": "external",
"position": "after"
}
]
}
],
"prettier/prettier": [
"error",
{
"printWidth": 80,
"trailingComma": "es5"
}
]
},
"settings": {
"react": {
"createClass": "createReactClass",
"pragma": "React",
"version": "detect"
},
"import/parsers": {
"@typescript-eslint/parser": [
".ts",
".tsx"
]
},
"import/resolver": {
"typescript": {}
}
}
}
+56 -56
View File
@@ -2,62 +2,62 @@ name: Bug report
description: File a bug to help us improve
labels: ["bug"]
body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: checkboxes
attributes:
label: This is not related to configuring Outline
description: I understand that questions related to configuring self-hosted Outline should be asked in the [community forum](https://github.com/outline/outline/discussions/categories/self-hosting).
options:
- label: The issue is not related to self-hosting config
required: true
- type: textarea
attributes:
label: Current Behavior
description: A concise description of what you're experiencing.
validations:
required: false
- type: textarea
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: textarea
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. In this environment...
1. With this config...
1. Run '...'
1. See error...
validations:
required: false
- type: textarea
attributes:
label: Environment
description: |
examples:
- **Outline**: Outline 0.80.0
- **Browser**: Safari
value: |
- Outline:
- Browser:
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
render: markdown
validations:
required: false
- type: textarea
attributes:
label: Anything else?
description: |
Links? References? Anything that will give us more context about the issue you are encountering!
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
validations:
required: false
+2 -2
View File
@@ -2,9 +2,9 @@
addReviewers: true
# A list of reviewers to be added to pull requests (GitHub user name)
reviewers:
reviewers:
- tommoor
# A list of keywords to be skipped the process that add reviewers if pull requests include it
# A list of keywords to be skipped the process that add reviewers if pull requests include it
skipKeywords:
- wip
+1 -1
View File
@@ -2,7 +2,7 @@ name: Auto Close Unsigned PRs
on:
schedule:
- cron: "0 0 * * *" # Run daily at midnight UTC
- cron: '0 0 * * *' # Run daily at midnight UTC
jobs:
close-unsigned-prs:
+73 -66
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main]
branches: [ main ]
pull_request:
branches: [main]
branches: [ main ]
env:
NODE_ENV: test
@@ -22,46 +22,46 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
node-version: [20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
lint:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
types:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn tsc
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn tsc
changes:
runs-on: ubuntu-latest
outputs:
config: ${{ steps.filter.outputs.config }}
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
@@ -70,9 +70,6 @@ jobs:
id: filter
with:
filters: |
config:
- '.github/**'
- 'vite.config.ts'
server:
- 'server/**'
- 'shared/**'
@@ -86,23 +83,23 @@ jobs:
test:
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn test:${{ matrix.test-group }}
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
if: ${{ needs.changes.outputs.server == 'true' }}
runs-on: ubuntu-latest
services:
postgres:
@@ -118,41 +115,51 @@ jobs:
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:5.0
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
strategy:
matrix:
shard: [1, 2, 3, 4]
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn sequelize db:migrate
- name: Run server tests
run: |
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'yarn'
- run: yarn install --frozen-lockfile
- name: Set environment to production
run: echo "NODE_ENV=production" >> $GITHUB_ENV
- run: yarn vite:build
- name: Send bundle stats to RelativeCI
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
+29 -29
View File
@@ -13,12 +13,12 @@ name: "CodeQL"
on:
push:
branches: [main]
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [main]
branches: [ main ]
schedule:
- cron: "28 15 * * 2"
- cron: '28 15 * * 2'
jobs:
analyze:
@@ -32,39 +32,39 @@ jobs:
strategy:
fail-fast: false
matrix:
language: ["javascript"]
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
+1 -1
View File
@@ -209,4 +209,4 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+4 -4
View File
@@ -2,7 +2,7 @@ name: Lint
on:
pull_request:
branches: [main]
branches: [ main ]
jobs:
run-linters:
@@ -20,11 +20,11 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Applied automatic fixes"
commit_message: 'Applied automatic fixes'
+1
View File
@@ -12,6 +12,7 @@
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalSetup": "<rootDir>/server/test/globalSetup.js",
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
},
-1
View File
@@ -1 +0,0 @@
22
-102
View File
@@ -1,102 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
"build/**",
"node_modules/**",
"public/**",
"server/migrations/**",
"server/scripts/**",
"patches/**",
"*.d.ts"
],
"rules": {
"for-direction": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-explicit-any": "warn",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unexpected-multiline": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error"
},
"overrides": [
{
"files": ["**/*.{js,jsx,ts,tsx}"],
"rules": {
"eqeqeq": "error",
"curly": "error",
"no-console": "error",
"arrow-body-style": ["error", "as-needed"],
"no-useless-escape": "off",
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"@typescript-eslint/no-require-imports": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"args": "after-used",
"ignoreRestSiblings": true
}
]
},
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
}
]
}
-4
View File
@@ -1,4 +0,0 @@
{
"printWidth": 80,
"trailingComma": "es5"
}
+1 -1
View File
@@ -1,4 +1,4 @@
require("@dotenvx/dotenvx").config({
require("dotenv").config({
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
});
+1 -1
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:22-slim AS runner
FROM node:20-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.86.1
Licensed Work: Outline 0.84.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-08-09
Change Date: 2029-05-11
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -8,7 +8,7 @@ build:
docker compose build --pull outline
test:
docker compose up -d postgres
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
+1 -1
View File
@@ -1 +1 @@
export default "";
export default '';
+5 -5
View File
@@ -1,19 +1,19 @@
const storage = {};
export default {
setItem: function (key, value) {
storage[key] = value || "";
setItem: function(key, value) {
storage[key] = value || '';
},
getItem: function (key) {
getItem: function(key) {
return key in storage ? storage[key] : null;
},
removeItem: function (key) {
removeItem: function(key) {
delete storage[key];
},
get length() {
return Object.keys(storage).length;
},
key: function (i) {
key: function(i) {
var keys = Object.keys(storage);
return keys[i] || null;
},
+8 -2
View File
@@ -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": {
@@ -216,4 +222,4 @@
"required": false
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"extends": [
"../.eslintrc",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"plugins": [
"eslint-plugin-react-hooks"
],
"rules": {
"react/react-in-jsx-scope": "off"
},
"env": {
"jest": true,
"browser": true
}
}
-34
View File
@@ -1,34 +0,0 @@
{
"extends": ["../.oxlintrc.json"],
"plugins": ["oxc", "eslint", "typescript", "react"],
"overrides": [
{
"files": ["**/*.{jsx,tsx}"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
],
"paths": [
{
"name": "reakit/Menu",
"importNames": ["useMenuState"],
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
}
]
}
]
},
"plugins": ["import"]
}
],
"env": {
"jest": true,
"browser": true
}
}
+2 -38
View File
@@ -1,9 +1,7 @@
import { PlusIcon, TrashIcon } from "outline-icons";
import { PlusIcon } from "outline-icons";
import stores from "~/stores";
import ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
import { createAction, createActionV2 } from "..";
import { createAction } from "..";
import { SettingsSection } from "../sections";
export const createApiKey = createAction({
@@ -24,37 +22,3 @@ export const createApiKey = createAction({
});
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu
? apiKey.isExpired
? t("Delete")
: `${t("Revoke")}`
: t("Revoke API key"),
analyticsName: "Revoke API key",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "revoke delete remove",
dangerous: true,
perform: async ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
if (apiKey.isExpired) {
await apiKey.delete();
return;
}
stores.dialogs.openModal({
title: t("Revoke token"),
content: (
<ApiKeyRevokeDialog
onSubmit={stores.dialogs.closeAllModals}
apiKey={apiKey}
/>
),
});
},
});
+25 -103
View File
@@ -2,8 +2,6 @@ import {
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
RestoreIcon,
@@ -24,19 +22,11 @@ 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,
createActionV2,
createInternalLinkActionV2,
} from "~/actions";
import { createAction } from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
import {
newDocumentPath,
newTemplatePath,
searchPath,
} from "~/utils/routeHelpers";
import ExportDialog from "~/components/ExportDialog";
import history from "~/utils/history";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
@@ -57,7 +47,7 @@ export const openCollection = createAction({
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
to: collection.path,
perform: () => history.push(collection.path),
}));
},
});
@@ -80,7 +70,7 @@ export const createCollection = createAction({
},
});
export const editCollection = createActionV2({
export const editCollection = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection",
@@ -106,7 +96,7 @@ export const editCollection = createActionV2({
},
});
export const editCollectionPermissions = createActionV2({
export const editCollectionPermissions = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
@@ -138,7 +128,7 @@ export const editCollectionPermissions = createActionV2({
},
});
export const searchInCollection = createInternalLinkActionV2({
export const searchInCollection = createAction({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: ActiveCollectionSection,
@@ -156,20 +146,13 @@ export const searchInCollection = createInternalLinkActionV2({
return stores.policies.abilities(activeCollectionId).readDocument;
},
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = searchPath({
collectionId: activeCollectionId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
perform: ({ activeCollectionId }) => {
history.push(searchPath({ collectionId: activeCollectionId }));
},
});
export const starCollection = createActionV2({
export const starCollection = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: ActiveCollectionSection,
@@ -196,7 +179,7 @@ export const starCollection = createActionV2({
},
});
export const unstarCollection = createActionV2({
export const unstarCollection = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: ActiveCollectionSection,
@@ -222,7 +205,7 @@ export const unstarCollection = createActionV2({
},
});
export const subscribeCollection = createActionV2({
export const subscribeCollection = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
@@ -235,7 +218,6 @@ export const subscribeCollection = createActionV2({
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
@@ -253,7 +235,7 @@ export const subscribeCollection = createActionV2({
},
});
export const unsubscribeCollection = createActionV2({
export const unsubscribeCollection = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
@@ -266,7 +248,6 @@ export const unsubscribeCollection = createActionV2({
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
@@ -284,10 +265,10 @@ export const unsubscribeCollection = createActionV2({
},
});
export const archiveCollection = createActionV2({
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
section: ActiveCollectionSection,
section: CollectionSection,
icon: <ArchiveIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
@@ -325,7 +306,7 @@ export const archiveCollection = createActionV2({
},
});
export const restoreCollection = createActionV2({
export const restoreCollection = createAction({
name: ({ t }) => t("Restore"),
analyticsName: "Restore collection",
section: CollectionSection,
@@ -350,7 +331,7 @@ export const restoreCollection = createActionV2({
},
});
export const deleteCollection = createActionV2({
export const deleteCollection = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: ActiveCollectionSection,
@@ -384,65 +365,7 @@ export const deleteCollection = createActionV2({
},
});
export const exportCollection = createActionV2({
name: ({ t }) => `${t("Export")}`,
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (!currentTeamId || !activeCollectionId) {
return false;
}
return (
!!stores.policies.abilities(currentTeamId).createExport &&
!!stores.policies.abilities(activeCollectionId).export
);
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Export collection"),
content: (
<ExportDialog
collection={collection}
onSubmit={stores.dialogs.closeAllModals}
/>
),
});
},
});
export const createDocument = createInternalLinkActionV2({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const createTemplate = createInternalLinkActionV2({
export const createTemplate = createAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
@@ -453,14 +376,13 @@ export const createTemplate = createInternalLinkActionV2({
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
perform: ({ activeCollectionId, event }) => {
if (!activeCollectionId) {
return;
}
event?.preventDefault();
event?.stopPropagation();
history.push(newTemplatePath(activeCollectionId));
},
});
+17 -16
View File
@@ -1,11 +1,12 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
import { createAction } from "..";
import { DocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
@@ -14,15 +15,15 @@ export const deleteCommentFactory = ({
comment: Comment;
onDelete: () => void;
}) =>
createActionV2({
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <TrashIcon />,
keywords: "trash",
dangerous: true,
visible: ({ stores }) => stores.policies.abilities(comment.id).delete,
perform: ({ t, stores, event }) => {
visible: () => stores.policies.abilities(comment.id).delete,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
@@ -40,12 +41,12 @@ export const resolveCommentFactory = ({
comment: Comment;
onResolve: () => void;
}) =>
createActionV2({
createAction({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <DoneIcon outline />,
visible: ({ stores }) =>
visible: () =>
stores.policies.abilities(comment.id).resolve &&
stores.policies.abilities(comment.documentId).update,
perform: async ({ t }) => {
@@ -72,12 +73,12 @@ export const unresolveCommentFactory = ({
comment: Comment;
onUnresolve: () => void;
}) =>
createActionV2({
createAction({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <DoneIcon outline />,
visible: ({ stores }) =>
visible: () =>
stores.policies.abilities(comment.id).unresolve &&
stores.policies.abilities(comment.documentId).update,
perform: async () => {
@@ -101,15 +102,15 @@ export const viewCommentReactionsFactory = ({
}: {
comment: Comment;
}) =>
createActionV2({
createAction({
name: ({ t }) => `${t("View reactions")}`,
analyticsName: "View comment reactions",
section: ActiveDocumentSection,
section: DocumentSection,
icon: <SmileyIcon />,
visible: ({ stores }) =>
visible: () =>
stores.policies.abilities(comment.id).read &&
comment.reactions.length > 0,
perform: ({ t, stores, event }) => {
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
-13
View File
@@ -1,4 +1,3 @@
import Storage from "@shared/utils/Storage";
import copy from "copy-to-clipboard";
import {
BeakerIcon,
@@ -128,17 +127,6 @@ export const clearIndexedDB = createAction({
},
});
export const clearStorage = createAction({
name: ({ t }) => t("Clear local storage"),
icon: <TrashIcon />,
keywords: "cache clear localstorage",
section: DeveloperSection,
perform: ({ t }) => {
Storage.clear();
toast.success(t("Local storage cleared"));
},
});
export const createTestUsers = createAction({
name: "Create 10 test users",
icon: <UserIcon />,
@@ -213,7 +201,6 @@ export const developer = createAction({
createToast,
createTestUsers,
clearIndexedDB,
clearStorage,
startTyping,
],
});
+108 -305
View File
@@ -1,6 +1,5 @@
import copy from "copy-to-clipboard";
import invariant from "invariant";
import uniqBy from "lodash/uniqBy";
import {
DownloadIcon,
DuplicateIcon,
@@ -26,12 +25,11 @@ import {
PublishIcon,
CommentIcon,
CopyIcon,
EyeIcon,
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
RestoreIcon,
EditIcon,
} from "outline-icons";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
@@ -53,13 +51,7 @@ 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,
createActionV2,
createActionV2Group,
createActionV2WithChildren,
createInternalLinkActionV2,
} from "~/actions";
import { createAction } from "~/actions";
import {
ActiveDocumentSection,
DocumentSection,
@@ -69,6 +61,7 @@ import env from "~/env";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import {
documentInsightsPath,
documentHistoryPath,
homePath,
newDocumentPath,
@@ -77,12 +70,7 @@ import {
documentPath,
urlify,
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
import Insights from "~/scenes/Document/components/Insights";
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
@@ -96,9 +84,8 @@ export const openDocument = createAction({
(acc, node) => [...acc, ...node.children],
[] as NavigationNode[]
);
const documents = stores.documents.orderedData;
return uniqBy([...documents, ...nodes], "id").map((item) => ({
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,
@@ -109,43 +96,11 @@ export const openDocument = createAction({
<DocumentIcon />
),
section: DocumentSection,
to: item.url,
perform: () => history.push(item.url),
}));
},
});
export const editDocument = createInternalLinkActionV2({
name: ({ t }) => t("Edit"),
analyticsName: "Edit document",
section: ActiveDocumentSection,
keywords: "edit",
icon: <EditIcon />,
visible: ({ activeDocumentId, stores }) => {
const { auth, documents, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return (
!!can?.update && !!auth.user?.separateEditMode && !document?.template
);
},
to: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
}
return documentEditPath(document);
},
});
export const createDocument = createAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
@@ -184,7 +139,7 @@ export const createDraftDocument = createAction({
}),
});
export const createDocumentFromTemplate = createInternalLinkActionV2({
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
@@ -214,24 +169,16 @@ export const createDocumentFromTemplate = createInternalLinkActionV2({
}
return stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
if (!activeDocumentId || !activeCollectionId) {
return "";
}
const [pathname, search] = newDocumentPath(activeCollectionId, {
templateId: activeDocumentId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
history.push(
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
{
sidebarContext,
}
),
});
export const createNestedDocument = createInternalLinkActionV2({
export const createNestedDocument = createAction({
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
@@ -242,19 +189,13 @@ export const createNestedDocument = createInternalLinkActionV2({
!!activeDocumentId &&
stores.policies.abilities(currentTeamId).createDocument &&
stores.policies.abilities(activeDocumentId).createChildDocument,
to: ({ activeDocumentId, sidebarContext }) => {
const [pathname, search] =
newNestedDocumentPath(activeDocumentId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
perform: ({ activeDocumentId, sidebarContext }) =>
history.push(newNestedDocumentPath(activeDocumentId), {
sidebarContext,
}),
});
export const starDocument = createActionV2({
export const starDocument = createAction({
name: ({ t }) => t("Star"),
analyticsName: "Star document",
section: ActiveDocumentSection,
@@ -280,7 +221,7 @@ export const starDocument = createActionV2({
},
});
export const unstarDocument = createActionV2({
export const unstarDocument = createAction({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar document",
section: ActiveDocumentSection,
@@ -306,7 +247,7 @@ export const unstarDocument = createActionV2({
},
});
export const publishDocument = createActionV2({
export const publishDocument = createAction({
name: ({ t }) => t("Publish"),
analyticsName: "Publish document",
section: ActiveDocumentSection,
@@ -348,7 +289,7 @@ export const publishDocument = createActionV2({
},
});
export const unpublishDocument = createActionV2({
export const unpublishDocument = createAction({
name: ({ t }) => t("Unpublish"),
analyticsName: "Unpublish document",
section: ActiveDocumentSection,
@@ -379,27 +320,11 @@ export const unpublishDocument = createActionV2({
},
});
export const subscribeDocument = createActionV2({
export const subscribeDocument = createAction({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
icon: <SubscribeIcon />,
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
return undefined;
}
return stores.collections.get(activeCollectionId)?.isSubscribed
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
return false;
}
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
},
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -408,7 +333,6 @@ export const subscribeDocument = createActionV2({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isActive &&
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
@@ -425,27 +349,11 @@ export const subscribeDocument = createActionV2({
},
});
export const unsubscribeDocument = createActionV2({
export const unsubscribeDocument = createAction({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
icon: <UnsubscribeIcon />,
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
if (!isContextMenu || !activeCollectionId) {
return undefined;
}
return stores.collections.get(activeCollectionId)?.isSubscribed
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
if (!isContextMenu || !activeCollectionId) {
return false;
}
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
},
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -454,10 +362,9 @@ export const unsubscribeDocument = createActionV2({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isActive &&
(!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe))
!!document?.collection?.isSubscribed ||
(!!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).unsubscribe)
);
},
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
@@ -473,7 +380,7 @@ export const unsubscribeDocument = createActionV2({
},
});
export const shareDocument = createActionV2({
export const shareDocument = createAction({
name: ({ t }) => `${t("Permissions")}`,
analyticsName: "Share document",
section: ActiveDocumentSection,
@@ -506,7 +413,7 @@ export const shareDocument = createActionV2({
},
});
export const downloadDocumentAsHTML = createActionV2({
export const downloadDocumentAsHTML = createAction({
name: ({ t }) => t("HTML"),
analyticsName: "Download document as HTML",
section: ActiveDocumentSection,
@@ -525,7 +432,7 @@ export const downloadDocumentAsHTML = createActionV2({
},
});
export const downloadDocumentAsPDF = createActionV2({
export const downloadDocumentAsPDF = createAction({
name: ({ t }) => t("PDF"),
analyticsName: "Download document as PDF",
section: ActiveDocumentSection,
@@ -533,11 +440,9 @@ export const downloadDocumentAsPDF = createActionV2({
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!(
activeDocumentId &&
stores.policies.abilities(activeDocumentId).download &&
env.PDF_EXPORT_ENABLED
),
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download &&
env.PDF_EXPORT_ENABLED,
perform: ({ activeDocumentId, t, stores }) => {
if (!activeDocumentId) {
return;
@@ -551,7 +456,7 @@ export const downloadDocumentAsPDF = createActionV2({
},
});
export const downloadDocumentAsMarkdown = createActionV2({
export const downloadDocumentAsMarkdown = createAction({
name: ({ t }) => t("Markdown"),
analyticsName: "Download document as Markdown",
section: ActiveDocumentSection,
@@ -570,7 +475,7 @@ export const downloadDocumentAsMarkdown = createActionV2({
},
});
export const downloadDocument = createActionV2WithChildren({
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
analyticsName: "Download document",
@@ -586,7 +491,7 @@ export const downloadDocument = createActionV2WithChildren({
],
});
export const copyDocumentAsMarkdown = createActionV2({
export const copyDocumentAsMarkdown = createAction({
name: ({ t }) => t("Copy as Markdown"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -605,7 +510,7 @@ export const copyDocumentAsMarkdown = createActionV2({
},
});
export const copyDocumentAsPlainText = createActionV2({
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -624,7 +529,7 @@ export const copyDocumentAsPlainText = createActionV2({
},
});
export const copyDocumentShareLink = createActionV2({
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
keywords: "clipboard share",
@@ -645,7 +550,7 @@ export const copyDocumentShareLink = createActionV2({
},
});
export const copyDocumentLink = createActionV2({
export const copyDocumentLink = createAction({
name: ({ t }) => t("Copy link"),
section: ActiveDocumentSection,
keywords: "clipboard",
@@ -663,7 +568,7 @@ export const copyDocumentLink = createActionV2({
},
});
export const copyDocument = createActionV2WithChildren({
export const copyDocument = createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy document",
section: ActiveDocumentSection,
@@ -677,7 +582,7 @@ export const copyDocument = createActionV2WithChildren({
],
});
export const duplicateDocument = createActionV2({
export const duplicateDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
analyticsName: "Duplicate document",
@@ -713,7 +618,7 @@ export const duplicateDocument = createActionV2({
* Pin a document to a collection. Pinned documents will be displayed at the top
* of the collection for all collection members to see.
*/
export const pinDocumentToCollection = createActionV2({
export const pinDocumentToCollection = createAction({
name: ({ activeDocumentId = "", t, stores }) => {
const selectedDocument = stores.documents.get(activeDocumentId);
const collectionName = selectedDocument
@@ -758,7 +663,7 @@ export const pinDocumentToCollection = createActionV2({
* Pin a document to team home. Pinned documents will be displayed at the top
* of the home screen for all team members to see.
*/
export const pinDocumentToHome = createActionV2({
export const pinDocumentToHome = createAction({
name: ({ t }) => t("Pin to home"),
analyticsName: "Pin document to home",
section: ActiveDocumentSection,
@@ -790,7 +695,7 @@ export const pinDocumentToHome = createActionV2({
},
});
export const pinDocument = createActionV2WithChildren({
export const pinDocument = createAction({
name: ({ t }) => t("Pin"),
analyticsName: "Pin document",
section: ActiveDocumentSection,
@@ -798,7 +703,7 @@ export const pinDocument = createActionV2WithChildren({
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const searchInDocument = createInternalLinkActionV2({
export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
@@ -811,24 +716,12 @@ export const searchInDocument = createInternalLinkActionV2({
const document = stores.documents.get(activeDocumentId);
return !!document?.isActive;
},
to: ({ activeDocumentId, sidebarContext }) => {
if (!activeDocumentId) {
return "";
}
const [pathname, search] = searchPath({
documentId: activeDocumentId,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
perform: ({ activeDocumentId }) => {
history.push(searchPath({ documentId: activeDocumentId }));
},
});
export const printDocument = createActionV2({
export const printDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
analyticsName: "Print document",
@@ -840,7 +733,7 @@ export const printDocument = createActionV2({
},
});
export const importDocument = createActionV2({
export const importDocument = createAction({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: DocumentSection,
@@ -887,7 +780,7 @@ export const importDocument = createActionV2({
},
});
export const createTemplateFromDocument = createActionV2({
export const createTemplateFromDocument = createAction({
name: ({ t }) => t("Templatize"),
analyticsName: "Templatize document",
section: ActiveDocumentSection,
@@ -945,11 +838,11 @@ export const searchDocumentsForQuery = (query: string) =>
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
to: searchPath({ query }),
perform: () => history.push(searchPath({ query })),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const moveTemplateToWorkspace = createActionV2({
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: DocumentSection,
@@ -979,7 +872,7 @@ export const moveTemplateToWorkspace = createActionV2({
},
});
export const moveDocumentToCollection = createActionV2({
export const moveDocumentToCollection = createAction({
name: ({ activeDocumentId, stores, t }) => {
if (!activeDocumentId) {
return t("Move");
@@ -1016,7 +909,7 @@ export const moveDocumentToCollection = createActionV2({
},
});
export const moveDocument = createActionV2({
export const moveDocument = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
@@ -1035,7 +928,7 @@ export const moveDocument = createActionV2({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionV2WithChildren({
export const moveTemplate = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
@@ -1054,7 +947,7 @@ export const moveTemplate = createActionV2WithChildren({
children: [moveTemplateToWorkspace, moveDocumentToCollection],
});
export const archiveDocument = createActionV2({
export const archiveDocument = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive document",
section: ActiveDocumentSection,
@@ -1094,102 +987,7 @@ export const archiveDocument = createActionV2({
},
});
export const restoreDocument = createActionV2({
name: ({ t }) => `${t("Restore")}`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
icon: <RestoreIcon />,
visible: ({ activeDocumentId, stores }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return false;
}
const collection = document.collectionId
? stores.collections.get(document.collectionId)
: undefined;
const can = stores.policies.abilities(document.id);
return (
!!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
},
perform: async ({ t, stores, activeDocumentId }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return;
}
await document.restore();
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
},
});
export const restoreDocumentToCollection = createActionV2WithChildren({
name: ({ t }) => `${t("Restore")}`,
analyticsName: "Restore document",
section: ActiveDocumentSection,
icon: <RestoreIcon />,
visible: ({ stores, activeDocumentId }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return false;
}
const can = stores.policies.abilities(document.id);
const collection = document.collectionId
? stores.collections.get(document.collectionId)
: undefined;
return (
!(document.isWorkspaceTemplate || collection?.isActive) &&
!!(can.restore || can.unarchive)
);
},
children: ({ t, activeDocumentId, stores }) => {
const { collections, documents, policies } = stores;
const document = activeDocumentId
? documents.get(activeDocumentId)
: undefined;
if (!document) {
return [];
}
const actions = collections.orderedData.map((collection) => {
const can = policies.abilities(collection.id);
return createActionV2({
name: collection.name,
section: ActiveDocumentSection,
icon: <CollectionIcon collection={collection} />,
visible: can.createDocument,
perform: async () => {
await document.restore({ collectionId: collection.id });
toast.success(
t("{{ documentName }} restored", {
documentName: capitalize(document.noun),
})
);
},
});
});
return [createActionV2Group({ name: t("Choose a collection"), actions })];
},
});
export const deleteDocument = createActionV2({
export const deleteDocument = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete document",
section: ActiveDocumentSection,
@@ -1223,7 +1021,7 @@ export const deleteDocument = createActionV2({
},
});
export const permanentlyDeleteDocument = createActionV2({
export const permanentlyDeleteDocument = createAction({
name: ({ t }) => t("Permanently delete"),
analyticsName: "Permanently delete document",
section: ActiveDocumentSection,
@@ -1278,18 +1076,22 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
},
});
export const openDocumentComments = createActionV2({
export const openDocumentComments = createAction({
name: ({ t }) => t("Comments"),
analyticsName: "Open comments",
section: ActiveDocumentSection,
icon: <CommentIcon />,
visible: ({ activeDocumentId, stores }) => {
visible: ({ activeCollectionId, activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
const collection = activeCollectionId
? stores.collections.get(activeCollectionId)
: undefined;
return (
!!activeDocumentId &&
can.comment &&
!!stores.auth.team?.getPreference(TeamPreference.Commenting)
(collection?.canCreateComment ??
!!stores.auth.team?.getPreference(TeamPreference.Commenting))
);
},
perform: ({ activeDocumentId, stores }) => {
@@ -1301,7 +1103,7 @@ export const openDocumentComments = createActionV2({
},
});
export const openDocumentHistory = createInternalLinkActionV2({
export const openDocumentHistory = createAction({
name: ({ t }) => t("History"),
analyticsName: "Open document history",
section: ActiveDocumentSection,
@@ -1310,25 +1112,19 @@ export const openDocumentHistory = createInternalLinkActionV2({
const can = stores.policies.abilities(activeDocumentId ?? "");
return !!activeDocumentId && can.listRevisions;
},
to: ({ activeDocumentId, stores, sidebarContext }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (!document) {
return "";
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const [pathname, search] = documentHistoryPath(document).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
history.push(documentHistoryPath(document));
},
});
export const openDocumentInsights = createActionV2({
export const openDocumentInsights = createAction({
name: ({ t }) => t("Insights"),
analyticsName: "Open document insights",
section: ActiveDocumentSection,
@@ -1346,22 +1142,50 @@ export const openDocumentInsights = createActionV2({
!document?.isDeleted
);
},
perform: ({ activeDocumentId, stores, t }) => {
perform: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
history.push(documentInsightsPath(document));
},
});
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: <EyeIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return can.updateInsights;
},
perform: async ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
stores.dialogs.openModal({
title: t("Insights"),
content: <Insights document={document} />,
await document.save({
insightsEnabled: !document.insightsEnabled,
});
},
});
export const leaveDocument = createActionV2({
export const leaveDocument = createAction({
name: ({ t }) => t("Leave document"),
analyticsName: "Leave document",
section: ActiveDocumentSection,
@@ -1397,27 +1221,6 @@ export const leaveDocument = createActionV2({
},
});
export const applyTemplateFactory = ({
actions,
}: {
actions: (ActionV2 | ActionV2Group | ActionV2Separator)[];
}) =>
createActionV2WithChildren({
name: ({ t }) => t("Apply template"),
analyticsName: "Apply template",
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: ({ activeDocumentId, stores }) => {
const { policies } = stores;
const can = activeDocumentId
? policies.abilities(activeDocumentId)
: undefined;
return !!can?.update;
},
children: actions,
});
export const rootDocumentActions = [
openDocument,
archiveDocument,
-28
View File
@@ -1,28 +0,0 @@
import { TrashIcon } from "outline-icons";
import stores from "~/stores";
import { createAction } from "..";
import { SettingsSection } from "../sections";
import Integration from "~/models/Integration";
import { IntegrationType } from "@shared/types";
import { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
export const disconnectAnalyticsIntegrationFactory = (
integration?: Integration<IntegrationType.Analytics>
) =>
createAction({
name: ({ t }) => t("Disconnect analytics"),
analyticsName: "Disconnect analytics",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "disconnect",
visible: () => !!integration,
perform: ({ t, event }) => {
event?.preventDefault();
event?.stopPropagation();
stores.dialogs.openModal({
title: t("Disconnect analytics"),
content: <DisconnectAnalyticsDialog integration={integration!} />,
});
},
});
+40 -48
View File
@@ -12,21 +12,17 @@ import {
BrowserIcon,
ShapesIcon,
DraftsIcon,
BugIcon,
} from "outline-icons";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isMac } from "@shared/utils/browser";
import stores from "~/stores";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import {
createAction,
createActionV2,
createExternalLinkActionV2,
createInternalLinkActionV2,
} from "~/actions";
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";
import {
homePath,
@@ -43,7 +39,7 @@ export const navigateToHome = createAction({
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
to: homePath(),
perform: () => history.push(homePath()),
visible: ({ location }) => location.pathname !== homePath(),
});
@@ -53,7 +49,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
icon: <SearchIcon />,
to: searchPath({ query: searchQuery.query }),
perform: () => history.push(searchPath({ query: searchQuery.query })),
});
export const navigateToDrafts = createAction({
@@ -61,7 +57,7 @@ export const navigateToDrafts = createAction({
analyticsName: "Navigate to drafts",
section: NavigationSection,
icon: <DraftsIcon />,
to: draftsPath(),
perform: () => history.push(draftsPath()),
visible: ({ location }) => location.pathname !== draftsPath(),
});
@@ -70,7 +66,7 @@ export const navigateToSearch = createAction({
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
to: searchPath(),
perform: () => history.push(searchPath()),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -80,7 +76,7 @@ export const navigateToArchive = createAction({
section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />,
to: archivePath(),
perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(),
});
@@ -89,7 +85,7 @@ export const navigateToTrash = createAction({
analyticsName: "Navigate to trash",
section: NavigationSection,
icon: <TrashIcon />,
to: trashPath(),
perform: () => history.push(trashPath()),
visible: ({ location }) => location.pathname !== trashPath(),
});
@@ -100,25 +96,25 @@ export const navigateToSettings = createAction({
shortcut: ["g", "s"],
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
to: settingsPath(),
perform: () => history.push(settingsPath()),
});
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
export const navigateToWorkspaceSettings = createAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to workspace settings",
section: NavigationSection,
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
to: settingsPath("details"),
perform: () => history.push(settingsPath("details")),
});
export const navigateToProfileSettings = createInternalLinkActionV2({
export const navigateToProfileSettings = createAction({
name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ProfileIcon />,
to: settingsPath(),
perform: () => history.push(settingsPath()),
});
export const navigateToTemplateSettings = createAction({
@@ -127,46 +123,43 @@ export const navigateToTemplateSettings = createAction({
section: NavigationSection,
iconInContextMenu: false,
icon: <ShapesIcon />,
to: settingsPath("templates"),
perform: () => history.push(settingsPath("templates")),
});
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Notification settings") : t("Notifications"),
export const navigateToNotificationSettings = createAction({
name: ({ t }) => t("Notifications"),
analyticsName: "Navigate to notification settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
to: settingsPath("notifications"),
perform: () => history.push(settingsPath("notifications")),
});
export const navigateToAccountPreferences = createInternalLinkActionV2({
export const navigateToAccountPreferences = createAction({
name: ({ t }) => t("Preferences"),
analyticsName: "Navigate to account preferences",
section: NavigationSection,
iconInContextMenu: false,
icon: <SettingsIcon />,
to: settingsPath("preferences"),
perform: () => history.push(settingsPath("preferences")),
});
export const openDocumentation = createExternalLinkActionV2({
export const openDocumentation = createAction({
name: ({ t }) => t("Documentation"),
analyticsName: "Open documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
url: UrlHelper.guide,
target: "_blank",
perform: () => window.open(UrlHelper.guide),
});
export const openAPIDocumentation = createExternalLinkActionV2({
export const openAPIDocumentation = createAction({
name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
url: UrlHelper.developers,
target: "_blank",
perform: () => window.open(UrlHelper.developers),
});
export const toggleSidebar = createAction({
@@ -177,37 +170,32 @@ export const toggleSidebar = createAction({
perform: () => stores.ui.toggleCollapsedSidebar(),
});
export const openFeedbackUrl = createExternalLinkActionV2({
export const openFeedbackUrl = createAction({
name: ({ t }) => t("Send us feedback"),
analyticsName: "Open feedback",
section: NavigationSection,
iconInContextMenu: false,
icon: <EmailIcon />,
url: UrlHelper.contact,
target: "_blank",
perform: () => window.open(UrlHelper.contact),
});
export const openBugReportUrl = createExternalLinkActionV2({
export const openBugReportUrl = createAction({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
iconInContextMenu: false,
icon: <BugIcon />,
url: UrlHelper.github,
target: "_blank",
perform: () => window.open(UrlHelper.github),
});
export const openChangelog = createExternalLinkActionV2({
export const openChangelog = createAction({
name: ({ t }) => t("Changelog"),
analyticsName: "Open changelog",
section: NavigationSection,
iconInContextMenu: false,
icon: <OpenIcon />,
url: UrlHelper.changelog,
target: "_blank",
perform: () => window.open(UrlHelper.changelog),
});
export const openKeyboardShortcuts = createActionV2({
export const openKeyboardShortcuts = createAction({
name: ({ t }) => t("Keyboard shortcuts"),
analyticsName: "Open keyboard shortcuts",
section: NavigationSection,
@@ -232,19 +220,23 @@ export const downloadApp = createAction({
iconInContextMenu: false,
icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
to: {
url: "https://desktop.getoutline.com",
target: "_blank",
perform: () => {
window.open("https://desktop.getoutline.com");
},
});
export const logout = createActionV2({
export const logout = createAction({
name: ({ t }) => t("Log out"),
analyticsName: "Log out",
section: NavigationSection,
icon: <LogoutIcon />,
perform: async () => {
await stores.auth.logout({ userInitiated: true });
await stores.auth.logout();
if (env.OIDC_LOGOUT_URI) {
setTimeout(() => {
window.location.replace(env.OIDC_LOGOUT_URI);
}, 200);
}
},
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import { createAction, createActionV2 } from "..";
import { createAction } from "..";
import { NotificationSection } from "../sections";
export const markNotificationsAsRead = createAction({
@@ -12,7 +12,7 @@ export const markNotificationsAsRead = createAction({
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const markNotificationsAsArchived = createActionV2({
export const markNotificationsAsArchived = createAction({
name: ({ t }) => t("Archive all notifications"),
analyticsName: "Mark notifications as archived",
section: NotificationSection,
+3 -3
View File
@@ -3,7 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
import { createAction, createActionV2 } from "~/actions";
import { createAction } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import history from "~/utils/history";
import {
@@ -11,7 +11,7 @@ import {
matchDocumentHistory,
} from "~/utils/routeHelpers";
export const restoreRevision = createActionV2({
export const restoreRevision = createAction({
name: ({ t }) => t("Restore"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
@@ -73,7 +73,7 @@ export const deleteRevision = createAction({
},
});
export const copyLinkToRevision = createActionV2({
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
+15 -13
View File
@@ -1,48 +1,50 @@
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import stores from "~/stores";
import { Theme } from "~/stores/UiStore";
import { createActionV2, createActionV2WithChildren } from "~/actions";
import { createAction } from "~/actions";
import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createActionV2({
export const changeToDarkTheme = createAction({
name: ({ t }) => t("Dark"),
analyticsName: "Change to dark theme",
icon: <MoonIcon />,
iconInContextMenu: false,
keywords: "theme dark night",
section: SettingsSection,
selected: ({ stores }) => stores.ui.theme === "dark",
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
selected: () => stores.ui.theme === "dark",
perform: () => stores.ui.setTheme(Theme.Dark),
});
export const changeToLightTheme = createActionV2({
export const changeToLightTheme = createAction({
name: ({ t }) => t("Light"),
analyticsName: "Change to light theme",
icon: <SunIcon />,
iconInContextMenu: false,
keywords: "theme light day",
section: SettingsSection,
selected: ({ stores }) => stores.ui.theme === "light",
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
selected: () => stores.ui.theme === "light",
perform: () => stores.ui.setTheme(Theme.Light),
});
export const changeToSystemTheme = createActionV2({
export const changeToSystemTheme = createAction({
name: ({ t }) => t("System"),
analyticsName: "Change to system theme",
icon: <BrowserIcon />,
iconInContextMenu: false,
keywords: "theme system default",
section: SettingsSection,
selected: ({ stores }) => stores.ui.theme === "system",
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
selected: () => stores.ui.theme === "system",
perform: () => stores.ui.setTheme(Theme.System),
});
export const changeTheme = createActionV2WithChildren({
export const changeTheme = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Appearance") : t("Change theme"),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: ({ stores }) =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
icon: function _Icon() {
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
},
keywords: "appearance display",
section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
-59
View File
@@ -1,59 +0,0 @@
import copy from "copy-to-clipboard";
import Share from "~/models/Share";
import { createActionV2, createInternalLinkActionV2 } from "..";
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
import { ShareSection } from "../sections";
import env from "~/env";
import { toast } from "sonner";
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
createActionV2({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy share link",
section: ShareSection,
icon: <CopyIcon />,
perform: ({ t }) => {
copy(share.url, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
toast.success(t("Share link copied"));
},
});
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
createInternalLinkActionV2({
name: ({ t }) =>
share.collectionId ? t("Go to collection") : t("Go to document"),
analyticsName: "Go to share source",
section: ShareSection,
icon: <ArrowIcon />,
to: {
pathname: share.sourcePathWithFallback,
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
},
});
export const revokeShareFactory = ({
share,
can,
}: {
share: Share;
can: Record<string, boolean>;
}) =>
createActionV2({
name: ({ t }) => t("Revoke link"),
analyticsName: "Revoke share",
section: ShareSection,
icon: <TrashIcon />,
dangerous: true,
visible: !!can.revoke,
perform: async ({ t, stores }) => {
try {
await stores.shares.revoke(share);
toast.message(t("Share link revoked"));
} catch (err) {
toast.error(err.message);
}
},
});
+19 -24
View File
@@ -5,24 +5,20 @@ import RootStore from "~/stores/RootStore";
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
import {
createActionV2,
createActionV2WithChildren,
createExternalLinkActionV2,
} from "~/actions";
import { ActionContext, ExternalLinkActionV2 } from "~/types";
import { createAction } from "~/actions";
import { ActionContext } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
createExternalLinkActionV2({
id: `switch-${session.id}`,
name: session.name,
analyticsName: "Switch workspace",
section: TeamSection,
keywords: "change switch workspace organization team",
icon: (
stores.auth.availableTeams?.map((session) => ({
id: `switch-${session.id}`,
name: session.name,
analyticsName: "Switch workspace",
section: TeamSection,
keywords: "change switch workspace organization team",
icon: function _Icon() {
return (
<StyledTeamLogo
alt={session.name}
model={{
@@ -33,15 +29,13 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
}}
size={24}
/>
),
visible: ({ currentTeamId }: ActionContext) =>
currentTeamId !== session.id,
url: session.url,
target: "_self",
})
) ?? [];
);
},
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
perform: () => (window.location.href = session.url),
})) ?? [];
export const switchTeam = createActionV2WithChildren({
export const switchTeam = createAction({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace",
@@ -52,7 +46,7 @@ export const switchTeam = createActionV2WithChildren({
children: switchTeamsList,
});
export const createTeam = createActionV2({
export const createTeam = createAction({
name: ({ t }) => `${t("New workspace")}`,
analyticsName: "New workspace",
keywords: "create change switch workspace organization team",
@@ -68,13 +62,14 @@ export const createTeam = createActionV2({
if (user) {
stores.dialogs.openModal({
title: t("Create a workspace"),
fullscreen: true,
content: <TeamNew user={user} />,
});
}
},
});
export const desktopLoginTeam = createActionV2({
export const desktopLoginTeam = createAction({
name: ({ t }) => t("Login to workspace"),
analyticsName: "Login to workspace",
keywords: "change switch workspace organization team",
+5 -5
View File
@@ -8,7 +8,7 @@ import {
UserChangeRoleDialog,
UserDeleteDialog,
} from "~/components/UserDialogs";
import { createAction, createActionV2 } from "~/actions";
import { createAction } from "~/actions";
import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
@@ -28,7 +28,7 @@ export const inviteUser = createAction({
});
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
createActionV2({
createAction({
name: ({ t }) =>
UserRoleHelper.isRoleHigher(role, user!.role)
? `${t("Promote to {{ role }}", {
@@ -45,8 +45,8 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
return UserRoleHelper.isRoleHigher(role, user.role)
? can.promote
: UserRoleHelper.isRoleLower(role, user.role)
? can.demote
: false;
? can.demote
: false;
},
perform: ({ t }) => {
stores.dialogs.openModal({
@@ -63,7 +63,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
});
export const deleteUserActionFactory = (userId: string) =>
createActionV2({
createAction({
name: ({ t }) => `${t("Delete user")}`,
analyticsName: "Delete user",
keywords: "leave",
+13 -329
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import { Optional } from "utility-types";
@@ -6,24 +5,13 @@ import { v4 as uuidv4 } from "uuid";
import {
Action,
ActionContext,
ActionV2,
ActionV2Group,
ActionV2Separator as TActionV2Separator,
ActionV2Variant,
ActionV2WithChildren,
ExternalLinkActionV2,
InternalLinkActionV2,
MenuExternalLink,
MenuInternalLink,
MenuItem,
CommandBarAction,
MenuItemButton,
MenuItemWithChildren,
} from "~/types";
import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
import { Action as KbarAction } from "kbar";
export function resolve<T>(value: any, context: ActionContext): T {
function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
}
@@ -32,17 +20,18 @@ export function createAction(definition: Optional<Action, "id">): Action {
...definition,
perform: definition.perform
? (context) => {
// We must use the specific analytics name here as the action name is
// We muse use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
? "commandbar"
: "contextmenu",
});
}
return definition.perform?.(context);
}
: undefined,
@@ -53,7 +42,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemButton | MenuExternalLink | MenuInternalLink | MenuItemWithChildren {
): MenuItemButton | MenuItemWithChildren {
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const visible = action.visible ? action.visible(context) : true;
@@ -78,26 +67,6 @@ export function actionToMenuItem(
};
}
if (action.to) {
return typeof action.to === "string"
? {
type: "route",
title,
icon,
visible,
to: action.to,
selected: action.selected?.(context),
}
: {
type: "link",
title,
icon,
visible,
href: action.to,
selected: action.selected?.(context),
};
}
return {
type: "button",
title,
@@ -112,7 +81,7 @@ export function actionToMenuItem(
export function actionToKBar(
action: Action,
context: ActionContext
): KbarAction[] {
): CommandBarAction[] {
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
@@ -130,7 +99,7 @@ export function actionToKBar(
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? ((action.section.priority as number) ?? 0)
? (action.section.priority as number) ?? 0
: 0;
return [
@@ -144,10 +113,9 @@ export function actionToKBar(
shortcut: action.shortcut || [],
icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
perform:
action.perform || action.to
? () => performAction(action, context)
: undefined,
perform: action.perform
? () => performAction(action, context)
: undefined,
},
].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
@@ -156,13 +124,7 @@ export function actionToKBar(
}
export async function performAction(action: Action, context: ActionContext) {
const result = action.perform
? action.perform(context)
: action.to
? typeof action.to === "string"
? history.push(action.to)
: window.open(action.to.url, action.to.target)
: undefined;
const result = action.perform?.(context);
if (result instanceof Promise) {
return result.catch((err: Error) => {
@@ -172,281 +134,3 @@ export async function performAction(action: Action, context: ActionContext) {
return result;
}
/** Actions V2 */
export const ActionV2Separator: TActionV2Separator = {
type: "action_separator",
};
export function createActionV2(
definition: Optional<Omit<ActionV2, "type" | "variant">, "id">
): ActionV2 {
return {
...definition,
type: "action",
variant: "action",
perform: definition.perform
? (context) => {
// We must use the specific analytics name here as the action name is
// translated and potentially contains user strings.
if (definition.analyticsName) {
Analytics.track("perform_action", definition.analyticsName, {
context: context.isButton
? "button"
: context.isCommandBar
? "commandbar"
: "contextmenu",
});
}
return definition.perform(context);
}
: () => {},
id: definition.id ?? uuidv4(),
};
}
export function createInternalLinkActionV2(
definition: Optional<Omit<InternalLinkActionV2, "type" | "variant">, "id">
): InternalLinkActionV2 {
return {
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? uuidv4(),
};
}
export function createExternalLinkActionV2(
definition: Optional<Omit<ExternalLinkActionV2, "type" | "variant">, "id">
): ExternalLinkActionV2 {
return {
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? uuidv4(),
};
}
export function createActionV2WithChildren(
definition: Optional<Omit<ActionV2WithChildren, "type" | "variant">, "id">
): ActionV2WithChildren {
return {
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? uuidv4(),
};
}
export function createActionV2Group(
definition: Omit<ActionV2Group, "type">
): ActionV2Group {
return {
...definition,
type: "action_group",
};
}
export function createRootMenuAction(
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: uuidv4(),
type: "action",
variant: "action_with_children",
name: "root_action",
section: "Root",
children: actions,
};
}
export function actionV2ToMenuItem(
action: ActionV2Variant | ActionV2Group | TActionV2Separator,
context: ActionContext
): MenuItem {
switch (action.type) {
case "action": {
const title = resolve<string>(action.name, context);
const visible = resolve<boolean>(action.visible, context) ?? true;
const disabled = resolve<boolean>(action.disabled, context);
const icon =
!!action.icon && action.iconInContextMenu !== false
? resolve<React.ReactNode>(action.icon, context)
: undefined;
switch (action.variant) {
case "action":
return {
type: "button",
title,
icon,
visible,
disabled,
tooltip: resolve<React.ReactChild>(action.tooltip, context),
selected: resolve<boolean>(action.selected, context),
dangerous: action.dangerous,
onClick: () => performActionV2(action, context),
};
case "internal_link": {
const to = resolve<LocationDescriptor>(action.to, context);
return {
type: "route",
title,
icon,
visible,
disabled,
to,
};
}
case "external_link":
return {
type: "link",
title,
icon,
visible,
disabled,
href: action.target
? { url: action.url, target: action.target }
: action.url,
};
case "action_with_children": {
const children = resolve<
(ActionV2Variant | ActionV2Group | TActionV2Separator)[]
>(action.children, context);
const subMenuItems = children.map((a) =>
actionV2ToMenuItem(a, context)
);
return {
type: "submenu",
title,
icon,
items: subMenuItems,
disabled,
visible: visible && hasVisibleItems(subMenuItems),
};
}
default:
throw Error("invalid action variant");
}
}
case "action_group": {
const groupItems = action.actions.map((a) =>
actionV2ToMenuItem(a, context)
);
return {
type: "group",
title: resolve<string>(action.name, context),
visible: hasVisibleItems(groupItems),
items: groupItems,
};
}
case "action_separator":
return { type: "separator" };
}
}
export function actionV2ToKBar(
action: ActionV2Variant,
context: ActionContext
): KbarAction[] {
const visible = resolve<boolean>(action.visible, context);
if (visible === false) {
return [];
}
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? ((action.section.priority as number) ?? 0)
: 0;
const priority = (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0));
switch (action.variant) {
case "action":
case "internal_link":
case "external_link": {
return [
{
id: action.id,
name,
section,
keywords: action.keywords,
shortcut: action.shortcut,
icon,
priority,
perform: () => performActionV2(action, context),
},
];
}
case "action_with_children": {
const resolvedChildren = resolve<ActionV2Variant[]>(
action.children,
context
);
const children = resolvedChildren
.map((a) => actionV2ToKBar(a, context))
.flat()
.filter(Boolean);
return [
{
id: action.id,
name,
section,
keywords: action.keywords,
shortcut: action.shortcut,
icon,
priority,
},
...children.map((child) => ({
...child,
parent: child.parent ?? action.id,
})),
];
}
default:
throw Error("invalid action variant");
}
}
export async function performActionV2(
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
context: ActionContext
) {
const perform =
action.variant === "action"
? () => action.perform(context)
: action.variant === "internal_link"
? () => history.push(resolve<LocationDescriptor>(action.to, context))
: () => window.open(action.url, action.target);
const result = perform();
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
}
function hasVisibleItems(items: MenuItem[]) {
const applicableTypes = ["button", "link", "route", "group", "submenu"];
return items.some(
(item) => applicableTypes.includes(item.type) && item.visible
);
}
-4
View File
@@ -36,14 +36,10 @@ export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const GroupSection = ({ t }: ActionContext) => t("Groups");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
+8 -16
View File
@@ -1,14 +1,9 @@
/* oxlint-disable react/prop-types */
/* eslint-disable react/prop-types */
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction, performActionV2, resolve } from "~/actions";
import { performAction } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import {
Action,
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
} from "~/types";
import { Action, ActionContext } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -16,7 +11,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
action?: Action;
/** Context of action, must be provided with action */
context?: ActionContext;
/** If tooltip props are provided the button will be wrapped in a tooltip */
@@ -45,8 +40,8 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
const actionContext = { ...context, isButton: true };
if (
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
action?.visible &&
!action.visible(actionContext) &&
hideOnActionDisabled
) {
return null;
@@ -64,14 +59,11 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
disabled={disabled || executing}
ref={ref}
onClick={
actionContext
action?.perform && actionContext
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const response =
"variant" in action
? performActionV2(action, actionContext)
: performAction(action, actionContext);
const response = performAction(action, actionContext);
if (response?.finally) {
setExecuting(true);
void response.finally(
+1 -1
View File
@@ -1,4 +1,4 @@
/* oxlint-disable prefer-rest-params */
/* eslint-disable prefer-rest-params */
/* global ga */
import escape from "lodash/escape";
import * as React from "react";
+1 -6
View File
@@ -31,12 +31,7 @@ const Authenticated = ({ children }: Props) => {
return <LoadingIndicator />;
}
void auth.logout({ savePath: true });
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
return null;
}
void auth.logout(true);
return <Redirect to="/" />;
};
+15 -4
View File
@@ -27,6 +27,7 @@ import {
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
@@ -38,7 +39,9 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const DocumentInsights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
type Props = {
@@ -46,7 +49,7 @@ type Props = {
};
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const { ui, auth, collections } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const can = usePolicy(ui.activeDocumentId);
@@ -95,23 +98,31 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
!!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 &&
!!team.getPreference(TeamPreference.Commenting);
(ui.activeCollectionId
? collections.get(ui.activeCollectionId)?.canCreateComment
: !!team.getPreference(TeamPreference.Commenting));
const sidebarRight = (
<AnimatePresence
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showComments) && (
{(showHistory || showInsights || showComments) && (
<Route path={`/doc/${slug}`}>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
+2 -2
View File
@@ -10,8 +10,8 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
primary
? theme.accentText
: yellow
? theme.almostBlack
: theme.textTertiary};
? theme.almostBlack
: theme.textTertiary};
border: 1px solid
${({ primary, yellow, theme }) =>
primary || yellow
+13 -10
View File
@@ -1,4 +1,5 @@
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import env from "~/env";
import OutlineIcon from "./Icons/OutlineIcon";
@@ -9,7 +10,7 @@ type Props = {
function Branding({ href = env.URL }: Props) {
return (
<Link href={href} target="_blank">
<Link href={href}>
<OutlineIcon size={20} />
&nbsp;{env.APP_NAME}
</Link>
@@ -32,16 +33,18 @@ const Link = styled.a`
fill: ${s("text")};
}
z-index: ${depths.sidebar + 1};
background: ${s("sidebarBackground")};
position: fixed;
bottom: 0;
right: 0;
padding: 16px;
${breakpoint("tablet")`
z-index: ${depths.sidebar + 1};
background: ${s("sidebarBackground")};
position: fixed;
bottom: 0;
right: 0;
padding: 16px;
&:hover {
background: ${s("sidebarControlHoverBackground")};
}
&:hover {
background: ${s("sidebarControlHoverBackground")};
}
`};
`;
export default Branding;
+31 -65
View File
@@ -6,89 +6,55 @@ import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useComputed } from "~/hooks/useComputed";
type TopLevelAction =
| InternalLinkActionV2
| { type: "menu"; actions: InternalLinkActionV2[] };
import { MenuInternalLink } from "~/types";
type Props = React.PropsWithChildren<{
actions: InternalLinkActionV2[];
items: MenuInternalLink[];
max?: number;
highlightFirstItem?: boolean;
}>;
function Breadcrumb(
{ actions, highlightFirstItem, children, max = 2 }: Props,
{ items, highlightFirstItem, children, max = 2 }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const actionContext = useActionContext({ isContextMenu: true });
const visibleActions = useComputed(
() =>
actions.filter((action) =>
typeof action.visible === "function"
? action.visible(actionContext)
: (action.visible ?? true)
),
[actions, actionContext]
);
const totalVisibleActions = visibleActions.length;
const topLevelActions: TopLevelAction[] = [...visibleActions];
const totalItems = items.length;
const topLevelItems: MenuInternalLink[] = [...items];
let overflowItems;
// chop middle breadcrumbs and present a "..." menu instead
if (totalVisibleActions > max) {
if (totalItems > max) {
const halfMax = Math.floor(max / 2);
const menuActions = topLevelActions.splice(
halfMax,
totalVisibleActions - max
) as InternalLinkActionV2[];
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
topLevelActions.splice(halfMax, 0, {
type: "menu",
actions: menuActions,
topLevelItems.splice(halfMax, 0, {
to: "",
type: "route",
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
});
}
const toBreadcrumb = React.useCallback(
(action: TopLevelAction, index: number) => {
if (action.type === "menu") {
return <BreadcrumbMenu key="menu" actions={action.actions} />;
}
const item = actionV2ToMenuItem(
action,
actionContext
) as MenuInternalLink;
return (
<>
{item.icon}
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
{item.title}
</Item>
</>
);
},
[actionContext, highlightFirstItem]
);
return (
<Flex justify="flex-start" align="center" ref={ref}>
{topLevelActions.map((action, index) => (
<React.Fragment key={action.type === "menu" ? "menu" : `item-${index}`}>
{toBreadcrumb(action, index)}
{index !== topLevelActions.length - 1 || !!children ? (
<Slash />
) : null}
{topLevelItems.map((item, index) => (
<React.Fragment
key={
(typeof item.to === "string" ? item.to : item.to.pathname) || index
}
>
{item.icon}
{item.to ? (
<Item
to={item.to}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
{item.title}
</Item>
) : (
item.title
)}
{index !== topLevelItems.length - 1 || !!children ? <Slash /> : null}
</React.Fragment>
))}
{children}
+1 -1
View File
@@ -176,7 +176,7 @@ const Button = <T extends React.ElementType = "button">(
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const ic = hideIcon ? undefined : action?.icon ?? icon;
const hasIcon = ic !== undefined;
return (
+44 -26
View File
@@ -1,3 +1,4 @@
import * as Popover from "@radix-ui/react-popover";
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
@@ -5,18 +6,16 @@ import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import Document from "~/models/Document";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
import NudeButton from "~/components/NudeButton";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { fadeAndScaleIn } from "~/styles/animations";
type Props = {
/** The document to display live collaborators for */
@@ -25,6 +24,21 @@ type Props = {
limit?: number;
};
// Styled components to match the original Popover styling
const StyledPopoverContent = styled(Popover.Content)`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: 75% 0;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 12px 24px;
max-height: 75vh;
box-shadow: ${s("menuShadow")};
z-index: ${depths.modal};
overflow-x: hidden;
overflow-y: auto;
outline: none;
`;
/**
* Displays a list of live collaborators for a document, including their avatars
* and presence status.
@@ -35,14 +49,14 @@ function Collaborators(props: Props) {
const user = useCurrentUser();
const currentUserId = user?.id;
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]);
const [popoverOpen, setPopoverOpen] = useState(false);
const { users, presence, ui } = useStores();
const { document } = props;
const { observingUserId } = ui;
const documentPresence = presence.get(document.id);
const documentPresenceArray = useMemo(
() => (documentPresence ? Array.from(documentPresence.values()) : []),
[documentPresence]
);
const documentPresenceArray = documentPresence
? Array.from(documentPresence.values())
: [];
// Use Set for O(1) lookups and stable references
const presentIds = useMemo(
@@ -101,11 +115,11 @@ function Collaborators(props: Props) {
// Memoize onClick handler to avoid inline function creation
const handleAvatarClick = useCallback(
(
collaboratorId: string,
isPresent: boolean,
isObserving: boolean,
isObservable: boolean
) =>
collaboratorId: string,
isPresent: boolean,
isObserving: boolean,
isObservable: boolean
) =>
(ev: React.MouseEvent) => {
if (isObservable && isPresent) {
ev.preventDefault();
@@ -125,8 +139,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
key={collaborator.id}
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
@@ -148,13 +162,9 @@ function Collaborators(props: Props) {
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
);
if (!document.insightsEnabled) {
return null;
}
return (
<Popover>
<PopoverTrigger>
<Popover.Root open={popoverOpen} onOpenChange={setPopoverOpen}>
<Popover.Trigger asChild>
<NudeButton
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
@@ -167,11 +177,19 @@ function Collaborators(props: Props) {
renderAvatar={renderAvatar}
/>
</NudeButton>
</PopoverTrigger>
<PopoverContent aria-label={t("Viewers")} side="bottom" align="end">
<DocumentViews document={document} />
</PopoverContent>
</Popover>
</Popover.Trigger>
<Popover.Portal>
<StyledPopoverContent
side="bottom"
align="end"
sideOffset={0}
aria-label={t("Viewers")}
style={{ width: 300 }}
>
<DocumentViews document={document} />
</StyledPopoverContent>
</Popover.Portal>
</Popover.Root>
);
}
+17 -32
View File
@@ -14,7 +14,7 @@ import Collection from "~/models/Collection";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import InputSelectPermission from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -22,6 +22,7 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
import { createSwitchRegister } from "~/utils/forms";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
@@ -125,8 +126,6 @@ export const CollectionForm = observer(function CollectionForm_({
[setFocus, setValue, values.icon]
);
const initial = values.name.charAt(0).toUpperCase();
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
@@ -147,7 +146,7 @@ export const CollectionForm = observer(function CollectionForm_({
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={initial}
initial={values.name[0]}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
@@ -174,7 +173,7 @@ export const CollectionForm = observer(function CollectionForm_({
) => {
field.onChange(value === EmptySelectValue ? null : value);
}}
help={t(
note={t(
"The default access for workspace members, you can share with more users or groups later."
)}
/>
@@ -183,36 +182,22 @@ export const CollectionForm = observer(function CollectionForm_({
)}
{team.sharing && (
<Controller
control={control}
name="sharing"
render={({ field }) => (
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
checked={field.value}
onChange={field.onChange}
/>
<Switch
id="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...createSwitchRegister(register, "sharing")}
/>
)}
{team.getPreference(TeamPreference.Commenting) && (
<Controller
control={control}
name="commenting"
render={({ field }) => (
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
checked={!!field.value}
onChange={field.onChange}
/>
)}
<Switch
id="commenting"
label={t("Commenting")}
note={t("Allow commenting on documents within this collection.")}
{...createSwitchRegister(register, "commenting")}
/>
)}
@@ -226,8 +211,8 @@ export const CollectionForm = observer(function CollectionForm_({
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
+28 -21
View File
@@ -3,10 +3,9 @@ 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";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveCollectionSection } from "~/actions/sections";
type Props = {
collection: Collection;
@@ -15,24 +14,32 @@ type Props = {
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const { t } = useTranslation();
const actions = React.useMemo(
() => [
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: collection.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
}),
],
[collection, t]
);
const items = React.useMemo(() => {
const collectionNode: MenuInternalLink = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection.path),
};
return <Breadcrumb actions={actions} highlightFirstItem />;
const category: MenuInternalLink | undefined = collection.isArchived
? {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
}
: undefined;
const output = [];
if (category) {
output.push(category);
}
output.push(collectionNode);
return output;
}, [collection, t]);
return <Breadcrumb items={items} highlightFirstItem />;
};
@@ -4,6 +4,7 @@ import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
@@ -24,7 +25,7 @@ const useRecentDocumentActions = (count = 6) => {
) : (
<DocumentIcon />
),
to: documentPath(item),
perform: () => history.push(documentPath(item)),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed]
@@ -3,6 +3,7 @@ import { useMemo } from "react";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import history from "~/utils/history";
const useSettingsAction = () => {
const config = useSettingsConfig();
@@ -15,7 +16,7 @@ const useSettingsAction = () => {
name: item.name,
icon: <Icon />,
section: NavigationSection,
to: item.path,
perform: () => history.push(item.path),
};
}),
[config]
+1 -1
View File
@@ -64,7 +64,7 @@ const ConfirmationDialog: React.FC<Props> = ({
danger={danger}
autoFocus
>
{isSaving && savingText ? savingText : (submitText ?? t("Confirm"))}
{isSaving && savingText ? savingText : submitText ?? t("Confirm")}
</Button>
</Flex>
</Flex>
+1 -1
View File
@@ -20,7 +20,7 @@ type Props = {
dangerous?: boolean;
to?: LocationDescriptor;
href?: string;
target?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
+2 -10
View File
@@ -155,14 +155,12 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
return (
<MenuItem
id={`${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
href={item.href}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={
typeof item.href === "string" ? undefined : item.href.target
}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
@@ -233,12 +231,6 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
);
}
// This should never be reached for Reakit dropdown menu.
// Added for exhaustiveness check.
if (item.type === "group") {
return null;
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
+1 -1
View File
@@ -119,7 +119,7 @@ const ContextMenu: React.FC<Props> = ({
>
{(props) => (
<InnerContextMenu
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
@@ -3,7 +3,7 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { InputSelect, Option } from "~/components/InputSelect";
import { InputSelectNew, Option } from "~/components/InputSelectNew";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = {
@@ -70,10 +70,11 @@ const DefaultCollectionInputSelect = ({
}
return (
<InputSelect
<InputSelectNew
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
ariaLabel={t("Default collection")}
label={t("Start view")}
hideLabel
short
+2 -3
View File
@@ -6,8 +6,6 @@ import useStores from "~/hooks/useStores";
function Dialogs() {
const { dialogs } = useStores();
const { guide, modalStack } = dialogs;
const modals = [...modalStack];
return (
<>
{guide ? (
@@ -19,10 +17,11 @@ function Dialogs() {
{guide.content}
</Guide>
) : undefined}
{modals.map(([id, modal]) => (
{[...modalStack].map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
@@ -1,50 +0,0 @@
import { Trans, useTranslation } from "react-i18next";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import { IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import useStores from "~/hooks/useStores";
import { useHistory } from "react-router-dom";
import { settingsPath } from "~/utils/routeHelpers";
import { observer } from "mobx-react";
import capitalize from "lodash/capitalize";
type Props = {
integration: Integration<IntegrationType.Analytics>;
};
export const DisconnectAnalyticsDialog = observer(({ integration }: Props) => {
const { t } = useTranslation();
const { dialogs } = useStores();
const history = useHistory();
const handleSubmit = async () => {
await integration.delete();
history.push(settingsPath("integrations"));
dialogs.closeAllModals();
};
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={t("Disconnect")}
savingText={`${t("Disconnecting")}`}
danger
>
<Text as="p" type="secondary">
<Trans
defaults="Are you sure you want to disconnect the <em>{{ service }}</em> integration?"
values={{
service: capitalize(integration.service),
}}
components={{
em: <strong />,
}}
/>
</Text>
<Text as="p" type="secondary">
<Trans defaults="This will stop sending analytics events to the configured instance." />
</Text>
</ConfirmationDialog>
);
});
+90 -79
View File
@@ -11,9 +11,8 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { MenuInternalLink } from "~/types";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
type Props = {
children?: React.ReactNode;
@@ -28,12 +27,46 @@ type Props = {
maxDepth?: number;
};
function useCategory(document: Document): MenuInternalLink | null {
const { t } = useTranslation();
if (document.isDeleted) {
return {
type: "route",
icon: <TrashIcon />,
title: t("Trash"),
to: trashPath(),
};
}
if (document.isArchived) {
return {
type: "route",
icon: <ArchiveIcon />,
title: t("Archive"),
to: archivePath(),
};
}
if (document.template) {
return {
type: "route",
icon: <ShapesIcon />,
title: t("Templates"),
to: settingsPath("templates"),
};
}
return null;
}
function DocumentBreadcrumb(
{ document, children, onlyText, reverse = false, maxDepth }: Props,
ref: React.RefObject<HTMLDivElement> | null
) {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
const sidebarContext = useLocationSidebarContext();
const collection = document.collectionId
? collections.get(document.collectionId)
@@ -45,91 +78,69 @@ function DocumentBreadcrumb(
void document.loadRelations({ withoutPolicies: true });
}, [document]);
let collectionNode: MenuInternalLink | undefined;
if (collection && can.readDocument) {
collectionNode = {
type: "route",
title: collection.name,
icon: <CollectionIcon collection={collection} expanded />,
to: {
pathname: collection.path,
state: { sidebarContext },
},
};
} else if (document.isCollectionDeleted) {
collectionNode = {
type: "route",
title: t("Deleted Collection"),
icon: undefined,
to: "",
};
}
const path = document.pathTo.slice(0, -1);
const actions = React.useMemo(() => {
const items = React.useMemo(() => {
const output: MenuInternalLink[] = [];
if (depth === 0) {
return [];
return output;
}
const outputActions = [
createInternalLinkActionV2({
name: t("Trash"),
section: ActiveDocumentSection,
icon: <TrashIcon />,
visible: document.isDeleted,
to: trashPath(),
}),
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkActionV2({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkActionV2({
name: collection?.name,
section: ActiveDocumentSection,
icon: collection ? (
<CollectionIcon collection={collection} expanded />
) : undefined,
visible: !!(collection && can.readDocument),
to: collection
? {
pathname: collection.path,
state: { sidebarContext },
}
: "",
}),
createInternalLinkActionV2({
name: t("Deleted Collection"),
section: ActiveDocumentSection,
visible: document.isCollectionDeleted,
to: "",
}),
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkActionV2({
name: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
title
),
section: ActiveDocumentSection,
to: {
pathname: node.url,
state: { sidebarContext },
},
});
}),
];
if (category) {
output.push(category);
}
if (collectionNode) {
output.push(collectionNode);
}
path.forEach((node: NavigationNode) => {
const title = node.title || t("Untitled");
output.push({
type: "route",
title: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
title
),
to: {
pathname: node.url,
state: { sidebarContext },
},
});
});
return reverse
? depth !== undefined
? outputActions.slice(-depth)
: outputActions
? output.slice(-depth)
: output
: depth !== undefined
? outputActions.slice(0, depth)
: outputActions;
}, [
t,
document,
collection,
can.readDocument,
sidebarContext,
path,
reverse,
depth,
]);
? output.slice(0, depth)
: output;
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
if (!collections.isLoaded) {
return null;
@@ -165,7 +176,7 @@ function DocumentBreadcrumb(
}
return (
<Breadcrumb actions={actions} ref={ref} highlightFirstItem>
<Breadcrumb items={items} ref={ref} highlightFirstItem>
{children}
</Breadcrumb>
);
+2 -8
View File
@@ -123,7 +123,6 @@ function DocumentCard(props: Props) {
<DocumentSquircle
icon={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
) : (
<Squircle
@@ -195,22 +194,17 @@ const ReadingTime = ({ document }: { document: Document }) => {
const DocumentSquircle = ({
icon,
color,
initial,
}: {
icon: string;
color?: string;
initial?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
const squircleColor = iconType === IconType.SVG ? color : theme.slateLight;
const style = {
"--background": squircleColor,
} as React.CSSProperties;
return (
<Squircle color={squircleColor} style={style}>
<Icon value={icon} color={theme.white} initial={initial} forceColor />
<Squircle color={squircleColor}>
<Icon value={icon} color={theme.white} forceColor />
</Squircle>
);
};
+34 -32
View File
@@ -1,3 +1,4 @@
import flatten from "lodash/flatten";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -10,6 +11,7 @@ import Button from "~/components/Button";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { flattenTree } from "~/utils/tree";
import Switch from "./Switch";
import Text from "./Text";
@@ -30,7 +32,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
);
const items = React.useMemo(() => {
const nodes = collectionTrees.filter((node) =>
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
@@ -76,32 +78,34 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<OptionsContainer>
{!document.isTemplate && (
<>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
@@ -123,11 +127,9 @@ function DocumentCopy({ document, onSubmit }: Props) {
}
const OptionsContainer = styled.div`
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding: 16px 24px 0;
margin-bottom: -1px;
background: ${(props) => props.theme.modalBackground};
z-index: 1;
margin: 16px 0 8px 0;
padding-left: 24px;
padding-right: 24px;
`;
export default observer(DocumentCopy);
+14 -7
View File
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import { NavigationNode } from "@shared/types";
import { NavigationNode, NavigationNodeType } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
@@ -26,8 +26,7 @@ import InputSearch from "~/components/InputSearch";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { ancestors, descendants, flattenTree } from "~/utils/tree";
import flatten from "lodash/flatten";
import { ancestors, descendants } from "~/utils/tree";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
@@ -79,9 +78,13 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const recentlyViewedItemIds = documents.recentlyViewed
.slice(0, 5)
.map((item) => item.id);
const searchIndex = React.useMemo(
() =>
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
new FuzzySearch(items, ["title"], {
caseSensitive: false,
}),
[items]
@@ -126,7 +129,12 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
return searchTerm
? searchIndex.search(searchTerm)
: items.flatMap(includeDescendants);
: items
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.flatMap(includeDescendants);
}
const nodes = getNodes();
@@ -134,7 +142,6 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -308,7 +315,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
expanded={isExpanded(index)}
icon={renderedIcon}
title={title}
depth={(node.depth ?? 0) - normalizedBaseDepth}
depth={(node.depth ?? 0) - baseDepth}
hasChildren={hasChildren(index)}
ref={itemRefs[index]}
/>
+6 -8
View File
@@ -33,6 +33,7 @@ type Props = {
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
@@ -67,6 +68,7 @@ function DocumentListItem(
showParentDocuments,
showCollection,
showPublished,
showPin,
showDraft = true,
showTemplate,
highlight,
@@ -105,11 +107,7 @@ function DocumentListItem(
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
<Icon value={document.icon} color={document.color ?? undefined} />
&nbsp;
</>
)}
@@ -154,8 +152,10 @@ function DocumentListItem(
<Actions>
<DocumentMenu
document={document}
showPin={showPin}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
modal={false}
/>
</Actions>
</DocumentLink>
@@ -177,9 +177,7 @@ const Actions = styled(EventBoundary)`
color: ${s("textSecondary")};
${NudeButton} {
&:
${hover},
&[aria-expanded= "true"] {
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("sidebarControlHoverBackground")};
}
}
-1
View File
@@ -201,7 +201,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
commenting={!!props.onClickCommentMark}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
+15 -89
View File
@@ -13,9 +13,6 @@ import Text from "~/components/Text";
import env from "~/env";
import Logger from "~/utils/Logger";
import isCloudHosted from "~/utils/isCloudHosted";
import Storage from "@shared/utils/Storage";
import { deleteAllDatabases } from "~/utils/developer";
import Flex from "./Flex";
type Props = WithTranslation & {
/** Whether to reload the page if a chunk fails to load. */
@@ -26,9 +23,6 @@ type Props = WithTranslation & {
component?: React.ComponentType | string;
};
const ERROR_TRACKING_KEY = "error-boundary-tracking";
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
@observer
class ErrorBoundary extends React.Component<Props> {
@observable
@@ -37,13 +31,6 @@ class ErrorBoundary extends React.Component<Props> {
@observable
showDetails = false;
@observable
isRepeatedError = false;
componentDidMount() {
this.checkForPreviousErrors();
}
componentDidCatch(error: Error) {
this.error = error;
@@ -59,47 +46,9 @@ class ErrorBoundary extends React.Component<Props> {
return;
}
this.trackError();
Logger.error("ErrorBoundary", error);
}
private checkForPreviousErrors = () => {
try {
const stored = Storage.get(ERROR_TRACKING_KEY);
if (!stored) {
return;
}
const errors: number[] = JSON.parse(stored);
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
const recentErrors = errors.filter((timestamp) => timestamp > cutoff);
this.isRepeatedError = recentErrors.length > 0;
} catch (err) {
Logger.warn("Failed to parse stored errors for error boundary", { err });
}
};
private trackError = () => {
try {
const stored = Storage.get(ERROR_TRACKING_KEY);
const errors: number[] = stored ? JSON.parse(stored) : [];
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
// Filter out old errors and add current one
const updatedErrors = [
...errors.filter((timestamp) => timestamp > cutoff),
Date.now(),
];
Storage.set(ERROR_TRACKING_KEY, JSON.stringify(updatedErrors));
this.isRepeatedError = updatedErrors.length > 1;
} catch (err) {
Logger.warn("Failed to track error in error boundary", { err });
}
};
handleReload = () => {
window.location.reload();
};
@@ -112,12 +61,6 @@ class ErrorBoundary extends React.Component<Props> {
window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github);
};
handleClearCache = async () => {
await deleteAllDatabases();
Storage.clear();
window.location.reload();
};
render() {
const { t, component: Component = CenteredContent, showTitle } = this.props;
@@ -164,46 +107,29 @@ class ErrorBoundary extends React.Component<Props> {
</Heading>
</>
)}
{this.isRepeatedError ? (
<Text as="p" type="secondary">
<Trans>
An error has occurred multiple times recently. If it continues
please try clearing the cache or using a different browser.
</Trans>
</Text>
) : (
<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={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
</Text>
)}
{this.showDetails && <Pre>{error.stack}</Pre>}
<Flex gap={8} wrap>
{this.isRepeatedError && (
<Button onClick={this.handleClearCache}>
<Trans>Clear cache + reload</Trans>
</Button>
)}
<Button onClick={this.handleReload} neutral={this.isRepeatedError}>
{t("Reload")}
</Button>
<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={{
notified: isReported
? ` ${t("our engineers have been notified")}`
: undefined,
}}
/>
</Text>
{this.showDetails && <Pre>{error.toString()}</Pre>}
<p>
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
{this.showDetails ? (
<Button onClick={this.handleReportBug} neutral>
<Trans>Report a bug</Trans>
<Trans>Report a bug</Trans>
</Button>
) : (
<Button onClick={this.handleShowDetails} neutral>
<Trans>Show detail</Trans>
</Button>
)}
</Flex>
</p>
</Component>
);
}
+174 -15
View File
@@ -1,7 +1,9 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import {
TrashIcon,
ArchiveIcon,
EditIcon,
PublishIcon,
MoveIcon,
UnpublishIcon,
@@ -9,28 +11,120 @@ import {
UserIcon,
CrossIcon,
} from "outline-icons";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import EventBoundary from "@shared/components/EventBoundary";
import { s, hover } from "@shared/styles";
import { RevisionHelper } from "@shared/utils/RevisionHelper";
import Document from "~/models/Document";
import Event from "~/models/Event";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Item, { Actions } from "~/components/List/Item";
import Time from "~/components/Time";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
import Logger from "~/utils/Logger";
import { documentHistoryPath } from "~/utils/routeHelpers";
import Text from "./Text";
export type RevisionEvent = {
name: "revisions.create";
latest: boolean;
};
export type DocumentEvent = {
name:
| "documents.publish"
| "documents.unpublish"
| "documents.archive"
| "documents.unarchive"
| "documents.delete"
| "documents.restore"
| "documents.add_user"
| "documents.remove_user"
| "documents.move";
userId: string;
};
export type Event = {
id: string;
actorId: string;
createdAt: string;
deletedAt?: string;
} & (RevisionEvent | DocumentEvent);
type Props = {
document: Document;
item: Event<Document>;
event: Event;
};
const EventListItem = ({ item }: Props) => {
const EventListItem = ({ event, document, ...rest }: Props) => {
const { t } = useTranslation();
const { revisions, users } = useStores();
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
const user = "userId" in event ? users.get(event.userId) : undefined;
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = useRef(false);
const opts = {
userName: item.actor?.name,
userName: actor?.name,
};
let meta, icon;
const isRevision = event.name === "revisions.create";
const isDerivedFromDocument =
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = () => {
ref.current?.focus();
};
const prefetchRevision = async () => {
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!event.deletedAt &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
await revisions.fetch(event.id, { force: true });
revisionLoadedRef.current = true;
}
};
switch (event.name) {
case "revisions.create":
{
if (event.deletedAt) {
icon = <TrashIcon />;
meta = t("Revision deleted");
} else {
icon = <EditIcon size={16} />;
meta = event.latest ? (
<>
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
},
};
}
}
break;
switch (item.name) {
case "documents.archive":
icon = <ArchiveIcon />;
meta = t("{{userName}} archived", opts);
@@ -49,14 +143,14 @@ const EventListItem = ({ item }: Props) => {
icon = <UserIcon />;
meta = t("{{userName}} added {{addedUserName}}", {
...opts,
addedUserName: item.user?.name ?? t("a user"),
addedUserName: user?.name ?? t("a user"),
});
break;
case "documents.remove_user":
icon = <CrossIcon />;
meta = t("{{userName}} removed {{removedUserName}}", {
...opts,
removedUserName: item.user?.name ?? t("a user"),
removedUserName: user?.name ?? t("a user"),
});
break;
@@ -81,27 +175,71 @@ const EventListItem = ({ item }: Props) => {
break;
default:
Logger.warn("Unhandled item", { item });
Logger.warn("Unhandled event", { event });
}
if (!meta) {
return null;
}
return (
const isActive =
typeof to === "string"
? location.pathname === to
: location.pathname === to?.pathname;
if (document.isDeleted) {
to = undefined;
}
return event.name === "revisions.create" && !event.deletedAt ? (
<RevisionItem
small
exact
to={to}
title={
<Time
dateTime={event.createdAt}
format={{
en_US: "MMM do, h:mm a",
fr_FR: "'Le 'd MMMM 'à' H:mm",
}}
relative={false}
addSuffix
onClick={handleTimeClick}
/>
}
image={<Avatar model={actor} size={AvatarSize.Large} />}
subtitle={meta}
actions={
isRevision && isActive && !event.latest ? (
<StyledEventBoundary>
<RevisionMenu document={document} revisionId={event.id} />
</StyledEventBoundary>
) : undefined
}
onMouseEnter={prefetchRevision}
ref={ref}
{...rest}
/>
) : (
<EventItem>
<IconWrapper size="xsmall" type="secondary">
{icon}
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={item.createdAt} relative shorten addSuffix />
<Time
dateTime={event.deletedAt ?? event.createdAt}
relative
shorten
addSuffix
/>
</Text>
</EventItem>
);
};
export const lineStyle = css`
const lineStyle = css`
&::before {
content: "";
display: block;
@@ -147,10 +285,9 @@ export const lineStyle = css`
const IconWrapper = styled(Text)`
height: 24px;
min-width: 24px;
`;
export const EventItem = styled.li`
const EventItem = styled.li`
display: flex;
align-items: center;
gap: 8px;
@@ -171,4 +308,26 @@ export const EventItem = styled.li`
${lineStyle}
`;
const StyledEventBoundary = styled(EventBoundary)`
height: 24px;
`;
const RevisionItem = styled(Item)`
border: 0;
position: relative;
margin: 8px 0;
padding: 8px;
border-radius: 8px;
${lineStyle}
${Actions} {
opacity: 0.5;
&: ${hover} {
opacity: 1;
}
}
`;
export default observer(EventListItem);
+8 -11
View File
@@ -1,10 +1,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Initials from "./Avatar/Initials";
type Props = {
/** The users to display */
@@ -31,22 +31,19 @@ function Facepile({
renderAvatar = Avatar,
...rest
}: Props) {
const { t } = useTranslation();
const filtered = users.filter(Boolean).slice(-limit);
const Component = renderAvatar;
if (overflow > 0) {
filtered.unshift({
id: "overflow",
initial: `${users.length ? "+" : ""}${overflow}`,
name: t(`{{count}} more user`, { count: overflow }),
} as User);
}
return (
<Avatars {...rest}>
{overflow > 0 && (
<Initials size={size} content={String(overflow)}>
{users.length ? "+" : ""}
{overflow}
</Initials>
)}
{filtered.map((model, index) => {
const lastChild = index === 0;
const lastChild = index === 0 && overflow <= 0;
return (
<Component
key={model.id}
+57 -42
View File
@@ -1,14 +1,9 @@
import * as React from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import {
fadeIn,
fadeOut,
fadeInAndSlideLeft,
fadeOutAndSlideRight,
} from "~/styles/animations";
import usePrevious from "~/hooks/usePrevious";
type Props = {
children?: React.ReactNode;
@@ -23,33 +18,55 @@ const Guide: React.FC<Props> = ({
title = "Untitled",
onRequestClose,
...rest
}: Props) => (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onRequestClose()}>
<Dialog.Portal>
<StyledOverlay>
<Scene
onEscapeKeyDown={onRequestClose}
onPointerDownOutside={onRequestClose}
aria-describedby={undefined}
{...rest}
>
<Content>
{title && <Header>{title}</Header>}
{children}
</Content>
</Scene>
</StyledOverlay>
</Dialog.Portal>
</Dialog.Root>
);
}: Props) => {
const dialog = useDialogState({
animated: 250,
});
const wasOpen = usePrevious(isOpen);
const Header = styled(Dialog.Title)`
React.useEffect(() => {
if (!wasOpen && isOpen) {
dialog.show();
}
if (wasOpen && !isOpen) {
dialog.hide();
}
}, [dialog, wasOpen, isOpen]);
return (
<DialogBackdrop {...dialog}>
{(backdropProps) => (
<Backdrop {...backdropProps}>
<Dialog
{...dialog}
aria-label={title}
preventBodyScroll
hideOnEsc
hide={onRequestClose}
>
{(dialogProps) => (
<Scene {...dialogProps} {...rest}>
<Content>
{title && <Header>{title}</Header>}
{children}
</Content>
</Scene>
)}
</Dialog>
</Backdrop>
)}
</DialogBackdrop>
);
};
const Header = styled.h1`
font-size: 18px;
margin-top: 0;
margin-bottom: 1em;
`;
const StyledOverlay = styled(Dialog.Overlay)`
const Backdrop = styled.div`
position: fixed;
top: 0;
left: 0;
@@ -57,37 +74,35 @@ const StyledOverlay = styled(Dialog.Overlay)`
bottom: 0;
background-color: ${s("backdrop")} !important;
z-index: ${depths.overlay};
transition: opacity 200ms ease-in-out;
opacity: 0;
&[data-state="open"] {
animation: ${fadeIn} 200ms ease;
}
&[data-state="closed"] {
animation: ${fadeOut} 200ms ease;
&[data-enter] {
opacity: 1;
}
`;
const Scene = styled(Dialog.Content)`
const Scene = styled.div`
position: absolute;
top: 0;
right: 0;
bottom: 0;
margin: 12px;
display: flex;
z-index: ${depths.modal};
display: flex;
justify-content: center;
align-items: flex-start;
width: 350px;
background: ${s("background")};
border-radius: 8px;
outline: none;
opacity: 0;
transform: translateX(16px);
transition: transform 250ms ease, opacity 250ms ease;
&[data-state="open"] {
animation: ${fadeInAndSlideLeft} 200ms ease;
}
&[data-state="closed"] {
animation: ${fadeOutAndSlideRight} 200ms ease;
&[data-enter] {
opacity: 1;
transform: translateX(0px);
}
`;
+9 -12
View File
@@ -6,7 +6,7 @@ import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import useMeasure from "react-use-measure";
import { useComponentSize } from "@shared/hooks/useComponentSize";
import { depths, s } from "@shared/styles";
import { supportsPassiveListener } from "@shared/utils/browser";
import Button from "~/components/Button";
@@ -38,8 +38,8 @@ function Header(
const { ui } = useStores();
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const [internalMeasureRef, size] = useMeasure();
const [breadcrumbsMeasureRef, breadcrumbsSize] = useMeasure();
const internalRef = React.useRef<HTMLDivElement | null>(null);
const breadcrumbsRef = React.useRef<HTMLDivElement | null>(null);
const passThrough = !actions && !left && !title;
const [isScrolled, setScrolled] = React.useState(false);
@@ -62,22 +62,19 @@ function Header(
});
}, []);
const setBreadcrumbRef = React.useCallback(
(node: HTMLDivElement | null) => {
if (node?.firstElementChild) {
breadcrumbsMeasureRef(node.firstElementChild as HTMLDivElement);
}
},
[breadcrumbsMeasureRef]
);
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement | null) => {
breadcrumbsRef.current = node?.firstElementChild as HTMLDivElement;
}, []);
const size = useComponentSize(internalRef);
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
return (
<TooltipProvider>
<Wrapper
ref={mergeRefs([ref, internalMeasureRef])}
ref={mergeRefs([ref, internalRef])}
align="center"
shrink={false}
className={className}
+1 -2
View File
@@ -20,8 +20,7 @@ export const Preview = styled(Link)`
cursor: ${(props: { as?: string }) =>
props.as === "div" ? "default" : "var(--pointer)"};
border-radius: 4px;
box-shadow:
0 30px 90px -20px rgba(0, 0, 0, 0.3),
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
position: absolute;
@@ -30,15 +30,10 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
) {
const authorName = author.name;
const urlObj = new URL(url);
let service;
if (urlObj.hostname === "github.com") {
service = IntegrationService.GitHub;
} else if (urlObj.hostname === "gitlab.com") {
service = IntegrationService.GitLab;
} else {
service = IntegrationService.Linear;
}
const service =
urlObj.hostname === "github.com"
? IntegrationService.GitHub
: IntegrationService.Linear;
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
@@ -136,7 +136,6 @@ const CustomColor = ({
maxLength={7}
value={value}
onChange={handleInputChange}
autoFocus
/>
</Flex>
);
@@ -136,12 +136,11 @@ const EmojiPanel = ({
freqEmojis,
});
React.useLayoutEffect(() => {
if (!panelActive) {
return;
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
scrollableRef.current?.scroll({ top: 0 });
requestAnimationFrame(() => searchRef.current?.focus());
searchRef.current?.focus();
}, [panelActive]);
return (
@@ -115,9 +115,7 @@ const CategoryName = styled(Text)`
`;
const Icon = styled.svg`
transition:
color 150ms ease-in-out,
fill 150ms ease-in-out;
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
transition-delay: var(--delay);
`;
@@ -155,12 +155,11 @@ const IconPanel = ({
baseIcons,
];
React.useLayoutEffect(() => {
if (!panelActive) {
return;
React.useEffect(() => {
if (scrollableRef.current) {
scrollableRef.current.scrollTop = 0;
}
scrollableRef.current?.scroll({ top: 0 });
requestAnimationFrame(() => searchRef.current?.focus());
searchRef.current?.focus();
}, [panelActive]);
return (
@@ -12,8 +12,7 @@ export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
$borderOnHover &&
css`
background: ${s("buttonNeutralBackground")};
box-shadow:
rgba(0, 0, 0, 0.07) 0px 1px 2px,
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px,
${s("buttonNeutralBorder")} 0 0 0 1px inset;
`};
}
@@ -1,17 +1,14 @@
import { useMemo, useCallback, useState } from "react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem } from "reakit";
import styled from "styled-components";
import { s, hover } from "@shared/styles";
import { depths, s, hover } from "@shared/styles";
import { EmojiSkinTone } from "@shared/types";
import { getEmojiVariants } from "@shared/utils/emoji";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/primitives/Popover";
import { useMenuState } from "~/hooks/useMenuState";
import { IconButton } from "./IconButton";
const SkinTonePicker = ({
@@ -22,61 +19,62 @@ const SkinTonePicker = ({
onChange: (skin: EmojiSkinTone) => void;
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const handEmojiVariants = useMemo(() => getEmojiVariants({ id: "hand" }), []);
const menu = useMenuState({
placement: "bottom-end",
});
const handleSkinClick = useCallback(
(emojiSkin: EmojiSkinTone) => {
setOpen(false);
(emojiSkin) => {
menu.hide();
onChange(emojiSkin);
},
[onChange]
[menu, onChange]
);
const menuItems = useMemo(
() =>
Object.values(EmojiSkinTone)
.map((skinTone) => {
const emoji = handEmojiVariants[skinTone];
return emoji ? (
<IconButton
key={emoji.value}
onClick={() => handleSkinClick(skinTone)}
>
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
<MenuItem {...menu} key={emoji.value}>
{(menuprops) => (
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
<Emoji width={24} height={24}>
{emoji.value}
</Emoji>
</IconButton>
) : null;
})
.filter(Boolean),
[handEmojiVariants, handleSkinClick]
)}
</MenuItem>
)),
[menu, handEmojiVariants, handleSkinClick]
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<StyledMenuButton aria-label={t("Choose default skin tone")}>
{handEmojiVariants[skinTone]?.value}
</StyledMenuButton>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="end"
aria-label={t("Choose default skin tone")}
width={208}
scrollable={false}
shrink
>
<Emojis>{menuItems}</Emojis>
</PopoverContent>
</Popover>
<>
<MenuButton {...menu}>
{(props) => (
<StyledMenuButton
{...props}
aria-label={t("Choose default skin tone")}
>
{handEmojiVariants[skinTone]!.value}
</StyledMenuButton>
)}
</MenuButton>
<Menu {...menu} aria-label={t("Choose default skin tone")}>
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
</Menu>
</>
);
};
const Emojis = styled(Flex)`
padding: 0 8px;
const MenuContainer = styled(Flex)`
z-index: ${depths.menu};
padding: 4px;
border-radius: 4px;
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
`;
const StyledMenuButton = styled(NudeButton)`
+74 -39
View File
@@ -1,22 +1,19 @@
import * as Popover from "@radix-ui/react-popover";
import * as Tabs from "@radix-ui/react-tabs";
import { SmileyIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import { s, hover, depths } 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 NudeButton from "~/components/NudeButton";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useMobile from "~/hooks/useMobile";
import useWindowSize from "~/hooks/useWindowSize";
import { fadeAndScaleIn } from "~/styles/animations";
import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
import EmojiPanel from "./components/EmojiPanel";
import IconPanel from "./components/IconPanel";
@@ -129,24 +126,7 @@ const IconPicker = ({
onChange(null, null);
}, [setOpen, onChange]);
const pickerTrigger = (
<PopoverButton
aria-label={t("Show menu")}
className={className}
size={size}
$borderOnHover={borderOnHover}
>
{children ? (
children
) : iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
)}
</PopoverButton>
);
const pickerContent = (
const PickerContent = (
<Content
open={open}
activeTab={activeTab}
@@ -171,28 +151,60 @@ const IconPicker = ({
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
<DrawerTrigger asChild>
<PopoverButton
aria-label={t("Show menu")}
className={className}
size={size}
$borderOnHover={borderOnHover}
>
{children ? (
children
) : iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
)}
</PopoverButton>
</DrawerTrigger>
<DrawerContent aria-label={t("Icon Picker")}>
{pickerContent}
{PickerContent}
</DrawerContent>
</Drawer>
);
}
return (
<Popover open={open} onOpenChange={handleOpenChange} modal={true}>
<PopoverTrigger>{pickerTrigger}</PopoverTrigger>
<PopoverContent
aria-label={t("Icon Picker")}
width={popoverWidth}
side={popoverPosition === "right" ? "right" : "bottom"}
align={popoverPosition === "bottom-start" ? "start" : "center"}
scrollable={false}
shrink
>
{pickerContent}
</PopoverContent>
</Popover>
<Popover.Root open={open} onOpenChange={handleOpenChange} modal={true}>
<Popover.Trigger asChild>
<PopoverButton
aria-label={t("Show menu")}
className={className}
size={size}
$borderOnHover={borderOnHover}
>
{children ? (
children
) : iconType && icon ? (
<Icon value={icon} color={color} size={size} initial={initial} />
) : (
<StyledSmileyIcon color={theme.placeholder} size={size} />
)}
</PopoverButton>
</Popover.Trigger>
<Popover.Portal>
<StyledPopoverContent
side={popoverPosition === "right" ? "right" : "bottom"}
align={popoverPosition === "bottom-start" ? "start" : "center"}
sideOffset={0}
width={popoverWidth}
aria-label={t("Icon Picker")}
onClick={(e) => e.stopPropagation()}
>
{PickerContent}
</StyledPopoverContent>
</Popover.Portal>
</Popover.Root>
);
};
@@ -336,4 +348,27 @@ const StyledTabContent = styled(Tabs.Content)`
overflow-y: auto;
`;
const StyledPopoverContent = styled(Popover.Content)<{ width: number }>`
animation: ${fadeAndScaleIn} 200ms ease;
transform-origin: var(--radix-popover-content-transform-origin);
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px 0;
max-height: 75vh;
box-shadow: ${s("menuShadow")};
z-index: ${depths.modal};
width: ${(props) => props.width}px;
overflow: hidden;
outline: none;
@media (max-width: 768px) {
position: fixed;
z-index: ${depths.menu};
top: 50px;
left: 8px;
right: 8px;
width: auto;
}
`;
export default React.memo(IconPicker);
+2 -2
View File
@@ -107,8 +107,8 @@ export const Outline = styled(Flex)<{
props.hasError
? props.theme.danger
: props.focused
? props.theme.inputBorderFocused
: props.theme.inputBorder};
? props.theme.inputBorderFocused
: props.theme.inputBorder};
border-radius: 4px;
font-weight: normal;
align-items: center;
+31 -26
View File
@@ -1,14 +1,16 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import { useMenuState } from "~/hooks/useMenuState";
import lazyWithRetry from "~/utils/lazyWithRetry";
import ContextMenu from "./ContextMenu";
import DelayedMount from "./DelayedMount";
import Input, { Props as InputProps } from "./Input";
import NudeButton from "./NudeButton";
import Relative from "./Sidebar/components/Relative";
import Text from "./Text";
import { Popover, PopoverContent, PopoverTrigger } from "./primitives/Popover";
type Props = Omit<InputProps, "onChange"> & {
value: string | undefined;
@@ -17,6 +19,10 @@ type Props = Omit<InputProps, "onChange"> & {
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
return (
<Relative>
@@ -27,26 +33,30 @@ const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
maxLength={7}
{...rest}
/>
<Popover modal={true}>
<PopoverTrigger>
<SwatchButton aria-label={t("Show menu")} $background={value} />
</PopoverTrigger>
<StyledContent aria-label={t("Select a color")} align="end">
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={value}
onChange={(color) => onChange(color.hex)}
/>
</React.Suspense>
</StyledContent>
</Popover>
<MenuButton {...menu}>
{(props) => (
<SwatchButton
aria-label={t("Show menu")}
{...props}
$background={value}
/>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Select a color")}>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={value}
onChange={(color) => onChange(color.hex)}
/>
</React.Suspense>
</ContextMenu>
</Relative>
);
};
@@ -60,11 +70,6 @@ const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
right: 6px;
`;
const StyledContent = styled(PopoverContent)`
width: auto;
padding: 8px;
`;
const ColorPicker = lazyWithRetry(
() => import("react-color/lib/components/chrome/Chrome")
);
+16 -27
View File
@@ -2,42 +2,23 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { InputSelect, Option } from "~/components/InputSelect";
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
import { EmptySelectValue, Permission } from "~/types";
type Props = Pick<
React.ComponentProps<typeof InputSelect>,
"value" | "onChange" | "disabled" | "className"
>;
export default function InputMemberPermissionSelect(
props: Props & { permissions: Permission[] }
props: Partial<SelectProps> & { permissions: Permission[] }
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
const options = React.useMemo<Option[]>(
() =>
props.permissions.reduce((acc, permission) => {
if (permission.divider) {
acc.push({ type: "separator" });
}
acc.push({
...permission,
type: "item",
});
return acc;
}, [] as Option[]),
[props.permissions]
);
return (
<Select
options={options}
value={value || EmptySelectValue}
onChange={onChange}
label={t("Permissions")}
hideLabel
options={props.permissions}
ariaLabel={t("Permissions")}
onChange={onChange}
value={value || EmptySelectValue}
labelHidden
nude
{...rest}
/>
@@ -45,5 +26,13 @@ export default function InputMemberPermissionSelect(
}
const Select = styled(InputSelect)`
margin: 0;
font-size: 14px;
border-color: transparent;
box-shadow: none;
color: ${s("textSecondary")};
`;
select {
margin: 0;
}
` as React.ComponentType<SelectProps>;
+350 -369
View File
@@ -1,402 +1,383 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { QuestionMarkIcon } from "outline-icons";
import { transparentize } from "polished";
import {
Select,
SelectOption,
useSelectState,
useSelectPopover,
SelectPopover,
} from "@renderlesskit/react";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
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,
Background as ContextMenuBackground,
Backdrop,
Placement,
} from "./ContextMenu";
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import NudeButton from "./NudeButton";
import Scrollable from "./Scrollable";
import Tooltip from "./Tooltip";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import {
InputSelectRoot,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./primitives/components/InputSelect";
type Separator = {
/* Denotes a horizontal divider line to be rendered in the menu, */
type: "separator";
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
/* Representative text shown in the menu for this option. */
label: string;
/* Actual value of this option. */
export type Option = {
label: string | JSX.Element;
value: string;
/* Additional info shown alongside the label. */
description?: string;
/* An icon shown alongside the label. */
icon?: React.ReactElement;
divider?: boolean;
};
export type Option = Item | Separator;
type Props = {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
export type Props = Omit<ButtonProps<any>, "onChange"> & {
id?: string;
name?: string;
value?: string | null;
/* Callback when an option is selected. */
onChange: (value: string) => void;
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
label?: React.ReactNode;
nude?: boolean;
ariaLabel: string;
short?: boolean;
/** Display a tooltip with the descriptive help text about the select menu. */
help?: string;
} & TriggerButtonProps;
export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
(props, ref) => {
const {
options,
value,
onChange,
label,
hideLabel,
short,
help,
...triggerProps
} = props;
const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const isMobile = useMobile();
const placeholder = `Select a ${label.toLowerCase()}`;
const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon
);
const renderOption = React.useCallback(
(option: Option, idx: number) => {
if (option.type === "separator") {
return <InputSelectSeparator key={`separator-${idx}`} />;
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
</InputSelectItem>
);
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return (
<MobileSelect
ref={ref}
{...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} help={help} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue ?? undefined}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={ref}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={label}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
);
InputSelect.displayName = "InputSelect";
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
disabled?: boolean;
className?: string;
labelHidden?: boolean;
icon?: React.ReactNode;
options: Option[];
/** @deprecated Removing soon, do not use. */
note?: React.ReactNode;
/** Callback function that is called when the value changes. Return false to cancel the change. */
onChange?: (value: string | null) => void | Promise<boolean | 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;
};
const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
(props, ref) => {
const {
options,
value,
onChange,
label,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
...triggerProps
} = props;
const [open, setOpen] = React.useState(false);
const contentRef =
React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const selectedOption = React.useMemo(
() =>
value
? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined,
[value, options]
);
const handleSelect = React.useCallback(
async (val: string) => {
setOpen(false);
onChange(val);
},
[onChange]
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <Separator />;
}
const isSelected = option === selectedOption;
return (
<SelectItemWrapper
key={option.value}
onClick={() => handleSelect(option.value)}
data-state={isSelected ? "checked" : "unchecked"}
>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
{isSelected && <SelectItemIndicator />}
</SelectItemWrapper>
);
},
[handleSelect, selectedOption, optionsHaveIcon]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
ref={ref}
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={label}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
);
MobileSelect.displayName = "InputSelect";
function Label({
text,
hidden,
help,
}: {
text: string;
hidden: boolean;
help?: string;
}) {
const content = (
<Flex align="center" gap={2} style={{ marginBottom: "4px" }}>
<LabelText style={{ paddingBottom: 0 }}>{text}</LabelText>
{help ? (
<Tooltip content={help}>
<TooltipButton size={18}>
<QuestionMarkIcon size={18} />
</TooltipButton>
</Tooltip>
) : null}
</Flex>
);
return hidden ? (
<VisuallyHidden.Root>{content}</VisuallyHidden.Root>
) : (
content
);
export interface InputSelectRef {
value: string | null;
focus: () => void;
blur: () => void;
}
function Option({
option,
optionsHaveIcon,
}: {
option: Item;
optionsHaveIcon: boolean;
}) {
const icon = optionsHaveIcon ? (
option.icon ? (
<IconWrapper>{option.icon}</IconWrapper>
) : (
<IconSpacer />
)
) : null;
interface InnerProps extends React.HTMLAttributes<HTMLDivElement> {
placement: Placement;
}
const getOptionFromValue = (options: Option[], value: string | null) =>
options.find((option) => option.value === value);
const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
const {
value = null,
label,
className,
labelHidden,
options,
short,
ariaLabel,
onChange,
disabled,
note,
icon,
nude,
skipBodyScroll,
...rest
} = props;
const select = useSelectState({
gutter: 0,
modal: true,
selectedValue: value,
});
const popover = useSelectPopover({
...select,
hideOnClickOutside: false,
preventBodyScroll: skipBodyScroll ? false : true,
disabled,
});
const isMobile = useMobile();
const previousValue = React.useRef<string | null>(value);
const selectedRef = React.useRef<HTMLDivElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const minWidth = buttonRef.current?.offsetWidth || 0;
const margin = 8;
const menuMaxHeight = useMenuHeight({
visible: select.visible,
elementRef: select.unstable_disclosureRef,
margin,
});
const maxHeight = Math.min(
menuMaxHeight ?? 0,
window.innerHeight -
(buttonRef.current?.getBoundingClientRect().bottom ?? 0) -
margin
);
const wrappedLabel = <LabelText>{label}</LabelText>;
const selectedValueIndex = options.findIndex(
(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;
}
const previous = previousValue.current;
previousValue.current = select.selectedValue;
const response = onChange?.(select.selectedValue);
if (response && response instanceof Promise) {
void response.then((success) => {
if (success === false) {
select.selectedValue = previous;
select.setSelectedValue(previous);
}
});
}
}, [onChange, select.selectedValue]);
React.useLayoutEffect(() => {
if (select.visible) {
requestAnimationFrame(() => {
if (contentRef.current) {
contentRef.current.scrollTop = selectedValueIndex * 32;
}
});
}
}, [select.visible, selectedValueIndex]);
function labelForOption(opt: Option) {
return (
<>
{opt.label}
{opt.description && (
<>
&nbsp;
<Text as="span" type="tertiary" size="small" ellipsis>
{opt.description}
</Text>
</>
)}
</>
);
}
const option = getOptionFromValue(options, select.selectedValue);
return (
<OptionContainer align="center">
{icon}
{option.label}
{option.description && (
<>
&nbsp;
<Description type="tertiary" size="small" ellipsis>
{option.description}
</Description>
</>
<>
<Wrapper short={short}>
{label &&
(labelHidden ? (
<VisuallyHidden.Root>{wrappedLabel}</VisuallyHidden.Root>
) : (
wrappedLabel
))}
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
{(buttonProps) => (
<StyledButton
neutral
disclosure
className={className}
icon={icon}
$nude={nude}
{...buttonProps}
>
{option ? (
labelForOption(option)
) : (
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
)}
</StyledButton>
)}
</Select>
<SelectPopover
{...select}
{...popover}
aria-label={ariaLabel}
preventBodyScroll={skipBodyScroll ? false : true}
>
{(popoverProps: InnerProps) => {
const topAnchor = popoverProps.style?.top === "0";
const rightAnchor = popoverProps.placement === "bottom-end";
return (
<Positioner {...popoverProps}>
<Background
dir="auto"
ref={contentRef}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
hiddenScrollbars
maxWidth={400}
style={
maxHeight && topAnchor
? {
maxHeight,
minWidth,
}
: {
minWidth,
}
}
>
{select.visible
? options.map((opt) => {
const isSelected = select.selectedValue === opt.value;
const Icon = isSelected ? CheckmarkIcon : Spacer;
return (
<React.Fragment key={opt.value}>
{opt.divider && <Separator />}
<StyledSelectOption
{...select}
value={opt.value}
key={opt.value}
ref={isSelected ? selectedRef : undefined}
>
<Icon />
&nbsp;
{labelForOption(opt)}
</StyledSelectOption>
</React.Fragment>
);
})
: null}
</Background>
</Positioner>
);
}}
</SelectPopover>
</Wrapper>
{note && (
<Text as="p" type="secondary" size="small">
{note}
</Text>
)}
</OptionContainer>
{select.visible && isMobile && <Backdrop />}
</>
);
}
};
const Background = styled(ContextMenuBackground)`
animation: ${fadeAndScaleIn} 200ms ease;
`;
const Placeholder = styled.span`
color: ${s("placeholder")};
`;
const Spacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledButton = styled(Button)<{ $nude?: boolean }>`
font-weight: normal;
text-transform: none;
margin-bottom: 16px;
display: block;
width: 100%;
cursor: var(--pointer);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
}
${(props) =>
props.$nude &&
css`
border-color: transparent;
box-shadow: none;
`}
${Inner} {
line-height: 28px;
padding-left: 12px;
padding-right: 4px;
}
svg {
justify-self: flex-end;
margin-left: auto;
}
`;
export const StyledSelectOption = styled(SelectOption)`
${MenuAnchorCSS}
/* overriding the styles from MenuAnchorCSS because we use &nbsp; here */
svg:not(:last-child) {
margin-right: 0px;
}
`;
const Wrapper = styled.label<{ short?: boolean }>`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const OptionContainer = styled(Flex)`
min-height: 24px;
`;
export const Positioner = styled(Position)`
pointer-events: all;
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
&:focus-visible {
${StyledSelectOption} {
&[aria-selected="true"] {
color: ${(props) => props.theme.white};
background: ${s("accent")};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${(props) => props.theme.white};
}
}
}
}
`;
const IconWrapper = styled.span`
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
margin-left: -4px;
margin-right: 4px;
overflow: hidden;
flex-shrink: 0;
`;
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
const TooltipButton = styled(NudeButton)`
color: ${s("textSecondary")};
&:hover,
&[aria-expanded="true"] {
background: none !important;
}
`;
export default React.forwardRef(InputSelect);
+354
View File
@@ -0,0 +1,354 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import Separator from "./ContextMenu/Separator";
import Flex from "./Flex";
import { LabelText } from "./Input";
import Scrollable from "./Scrollable";
import { IconWrapper } from "./Sidebar/components/SidebarLink";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "./primitives/Drawer";
import {
InputSelectRoot,
InputSelectContent,
InputSelectItem,
InputSelectSeparator,
InputSelectTrigger,
type TriggerButtonProps,
} from "./primitives/InputSelect";
import {
SelectItemIndicator,
SelectItem as SelectItemWrapper,
SelectButton,
} from "./primitives/components/InputSelect";
type Separator = {
/* Denotes a horizontal divider line to be rendered in the menu, */
type: "separator";
};
export type Item = {
/* Denotes a selectable option in the menu. */
type: "item";
/* Representative text shown in the menu for this option. */
label: string;
/* Actual value of this option. */
value: string;
/* Additional info shown alongside the label. */
description?: string;
/* An icon shown alongside the label. */
icon?: React.ReactElement;
};
export type Option = Item | Separator;
type Props = {
/* Options to display in the select menu. */
options: Option[];
/* Current chosen value. */
value?: string;
/* Callback when an option is selected. */
onChange: (value: string) => void;
/* ARIA label for accessibility. */
ariaLabel: string;
/* Label for the select menu. */
label: string;
/* When true, label is hidden in an accessible manner. */
hideLabel?: boolean;
/* When true, menu is disabled. */
disabled?: boolean;
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
short?: boolean;
} & TriggerButtonProps;
export function InputSelectNew(props: Props) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
...triggerProps
} = props;
const [localValue, setLocalValue] = React.useState(value);
const [open, setOpen] = React.useState(false);
const triggerRef =
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
const contentRef =
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
const isMobile = useMobile();
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
const optionsHaveIcon = options.some(
(opt) => opt.type === "item" && !!opt.icon
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <InputSelectSeparator />;
}
return (
<InputSelectItem key={option.value} value={option.value}>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
</InputSelectItem>
);
},
[optionsHaveIcon]
);
const onValueChange = React.useCallback(
async (val: string) => {
setLocalValue(val);
onChange(val);
},
[onChange, setLocalValue]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
React.useEffect(() => {
setLocalValue(value);
}, [value]);
if (isMobile) {
return (
<MobileSelect
{...props}
value={localValue}
onChange={onValueChange}
placeholder={placeholder}
optionsHaveIcon={optionsHaveIcon}
/>
);
}
return (
<Wrapper short={short}>
<Label text={label} hidden={hideLabel ?? false} />
<InputSelectRoot
open={open}
onOpenChange={setOpen}
value={localValue}
onValueChange={onValueChange}
>
<InputSelectTrigger
ref={triggerRef}
placeholder={placeholder}
{...triggerProps}
/>
<InputSelectContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
{options.map(renderOption)}
</InputSelectContent>
</InputSelectRoot>
</Wrapper>
);
}
type MobileSelectProps = Props & {
placeholder: string;
optionsHaveIcon: boolean;
};
function MobileSelect(props: MobileSelectProps) {
const {
options,
value,
onChange,
ariaLabel,
label,
hideLabel,
disabled,
short,
placeholder,
optionsHaveIcon,
...triggerProps
} = props;
const [open, setOpen] = React.useState(false);
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const selectedOption = React.useMemo(
() =>
value
? options.find((opt) => opt.type === "item" && opt.value === value)
: undefined,
[value, options]
);
const handleSelect = React.useCallback(
async (val: string) => {
setOpen(false);
onChange(val);
},
[onChange]
);
const renderOption = React.useCallback(
(option: Option) => {
if (option.type === "separator") {
return <Separator />;
}
const isSelected = option === selectedOption;
return (
<SelectItemWrapper
key={option.value}
onClick={() => handleSelect(option.value)}
data-state={isSelected ? "checked" : "unchecked"}
>
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
{isSelected && <SelectItemIndicator />}
</SelectItemWrapper>
);
},
[handleSelect, selectedOption, optionsHaveIcon]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
return (
<Wrapper>
<Label text={label} hidden={hideLabel ?? false} />
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<SelectButton
{...triggerProps}
neutral
disclosure
data-placeholder={selectedOption ? false : ""}
>
{selectedOption ? (
<Option
option={selectedOption as Item}
optionsHaveIcon={optionsHaveIcon}
/>
) : (
<>{placeholder}</>
)}
</SelectButton>
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{options.map(renderOption)}
</StyledScrollable>
</DrawerContent>
</Drawer>
</Wrapper>
);
}
function Label({ text, hidden }: { text: string; hidden: boolean }) {
const labelText = <LabelText>{text}</LabelText>;
return hidden ? (
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
) : (
labelText
);
}
function Option({
option,
optionsHaveIcon,
}: {
option: Item;
optionsHaveIcon: boolean;
}) {
const icon = optionsHaveIcon ? (
option.icon ? (
<IconWrapper>{option.icon}</IconWrapper>
) : (
<IconSpacer />
)
) : null;
return (
<OptionContainer align="center">
{icon}
{option.label}
{option.description && (
<>
&nbsp;
<Description type="tertiary" size="small" ellipsis>
{option.description}
</Description>
</>
)}
</OptionContainer>
);
}
const Wrapper = styled.label<{ short?: boolean }>`
display: block;
max-width: ${(props) => (props.short ? "350px" : "100%")};
`;
const OptionContainer = styled(Flex)`
min-height: 24px;
`;
const Description = styled(Text)`
@media (hover: hover) {
&:hover,
&:focus {
color: ${(props) => transparentize(0.5, props.theme.accentText)};
}
}
`;
const IconSpacer = styled.div`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
+31 -40
View File
@@ -1,63 +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, Option } from "~/components/InputSelect";
import { EmptySelectValue } from "~/types";
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
type Props = {
shrink?: boolean;
} & Pick<
React.ComponentProps<typeof InputSelect>,
"value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
>;
function InputSelectPermission(
props: $Diff<
Props,
{
options: Array<Option>;
ariaLabel: string;
}
>,
ref: React.RefObject<InputSelectRef>
) {
const { value, onChange, ...rest } = props;
const { t } = useTranslation();
export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
(props, ref) => {
const { value, onChange, shrink, ...rest } = props;
const { t } = useTranslation();
const options = React.useMemo<Option[]>(
() => [
return (
<Select
ref={ref}
label={t("Permission")}
options={[
{
type: "item",
label: t("View only"),
value: CollectionPermission.Read,
},
{
type: "item",
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
type: "separator",
},
{
type: "item",
divider: true,
label: t("No access"),
value: EmptySelectValue,
},
],
[t]
);
]}
ariaLabel={t("Default access")}
value={value || EmptySelectValue}
onChange={onChange}
{...rest}
/>
);
}
return (
<Select
ref={ref}
options={options}
value={value || EmptySelectValue}
onChange={onChange}
label={t("Permission")}
$shrink={shrink}
{...rest}
/>
);
}
);
InputSelectPermission.displayName = "InputSelectPermission";
const Select = styled(InputSelect)<{ $shrink?: boolean }>`
const Select = styled(InputSelect)`
color: ${s("textSecondary")};
${({ $shrink }) => !$shrink && "margin-bottom: 16px;"}
`;
export default React.forwardRef(InputSelectPermission);
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<unknown>> {
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
@@ -34,7 +34,7 @@ interface LazyLoadOptions {
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<unknown>>(
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
-1
View File
@@ -231,7 +231,6 @@ const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${s("textTertiary")};
margin-top: -2px;
overflow-wrap: break-word;
`;
export const Actions = styled(Flex)<{ $selected?: boolean }>`
-236
View File
@@ -1,236 +0,0 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import styled from "styled-components";
import Scrollable from "~/components/Scrollable";
import {
Drawer,
DrawerContent,
DrawerTitle,
DrawerTrigger,
} from "~/components/primitives/Drawer";
import {
DropdownMenu as DropdownMenuRoot,
DropdownMenuTrigger,
DropdownMenuContent,
} from "~/components/primitives/DropdownMenu";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import useMobile from "~/hooks/useMobile";
import {
ActionContext,
ActionV2Variant,
ActionV2WithChildren,
MenuItem,
MenuItemWithChildren,
} from "~/types";
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
import { observer } from "mobx-react";
import { useComputed } from "~/hooks/useComputed";
type Props = {
/** Root action with children representing the menu items */
action: ActionV2WithChildren;
/** Action context to use - new context will be created if not provided */
context?: ActionContext;
/** Trigger for the menu */
children: React.ReactNode;
/** Alignment w.r.t trigger - defaults to start */
align?: "start" | "end";
/** ARIA label for the menu */
ariaLabel: string;
/** Additional component to display at the bottom of the top-level menu */
append?: React.ReactNode;
/** Callback when menu is opened */
onOpen?: () => void;
/** Callback when menu is closed */
onClose?: () => void;
// TODO: Invert the dependency chain by forwarding dropdown ref and props to Tooltip component
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>;
export const DropdownMenu = observer(
React.forwardRef<React.ElementRef<typeof TooltipPrimitive.Trigger>, Props>(
(
{
action,
context,
children,
align = "start",
ariaLabel,
append,
onOpen,
onClose,
...rest
},
ref
) => {
const [open, setOpen] = React.useState(false);
const isMobile = useMobile();
const contentRef =
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
const actionContext =
context ??
useActionContext({
isContextMenu: true,
});
const menuItems = useComputed(() => {
if (!open) {
return [];
}
return (action.children as ActionV2Variant[]).map((childAction) =>
actionV2ToMenuItem(childAction, actionContext)
);
}, [open, action.children, actionContext]);
const handleOpenChange = React.useCallback(
(open: boolean) => {
setOpen(open);
if (open) {
onOpen?.();
} else {
onClose?.();
}
},
[onOpen, onClose]
);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
const handleCloseAutoFocus = React.useCallback(
(e: Event) => e.preventDefault(),
[]
);
if (isMobile) {
return (
<MobileDropdown
open={open}
onOpenChange={handleOpenChange}
items={menuItems}
trigger={children}
ariaLabel={ariaLabel}
append={append}
/>
);
}
const content = toDropdownMenuItems(menuItems);
return (
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={ariaLabel}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
onCloseAutoFocus={handleCloseAutoFocus}
>
{content}
{append}
</DropdownMenuContent>
</DropdownMenuRoot>
);
}
)
);
type MobileDropdownProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
items: MenuItem[];
trigger: React.ReactNode;
} & Pick<Props, "ariaLabel" | "append">;
function MobileDropdown({
open,
onOpenChange,
items,
trigger,
ariaLabel,
append,
}: MobileDropdownProps) {
const [submenuName, setSubmenuName] = React.useState<string>();
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
const enablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "auto";
}
}, []);
const disablePointerEvents = React.useCallback(() => {
if (contentRef.current) {
contentRef.current.style.pointerEvents = "none";
}
}, []);
const closeDrawer = React.useCallback(() => {
onOpenChange(false);
setTimeout(() => setSubmenuName(undefined), 500); // needed for a Vaul bug where 'onAnimationEnd' is not called for controlled state.
}, [onOpenChange]);
const resetSubmenu = React.useCallback((isOpen: boolean) => {
if (!isOpen) {
setSubmenuName(undefined);
}
}, []);
const menuItems = React.useMemo(() => {
if (!items.length || !submenuName) {
return items;
}
const submenu = items.find(
(item) =>
item.type === "submenu" && (item.title as string) === submenuName
)! as MenuItemWithChildren;
return submenu.items;
}, [items, submenuName]);
const content = toMobileMenuItems(menuItems, closeDrawer, setSubmenuName);
return (
<Drawer
open={open}
onOpenChange={onOpenChange}
onAnimationEnd={resetSubmenu}
>
<DrawerTrigger aria-label={ariaLabel} asChild>
{trigger}
</DrawerTrigger>
<DrawerContent
ref={contentRef}
aria-label={ariaLabel}
aria-describedby={undefined}
onAnimationStart={disablePointerEvents}
onAnimationEnd={enablePointerEvents}
>
<DrawerTitle>{ariaLabel}</DrawerTitle>
<StyledScrollable hiddenScrollbars>
{content}
{!submenuName ? append : null}
</StyledScrollable>
</DrawerContent>
</Drawer>
);
}
const StyledScrollable = styled(Scrollable)`
max-height: 75vh;
`;
@@ -1,24 +0,0 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { MoreIcon } from "outline-icons";
import * as React from "react";
import Button from "~/components/Button";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Trigger
> & {
neutral?: boolean;
className?: string;
};
export const OverflowMenuButton = React.forwardRef<HTMLButtonElement, Props>(
({ neutral, className, ...rest }, ref) =>
neutral ? (
<Button ref={ref} icon={<MoreIcon />} neutral borderOnHover {...rest} />
) : (
<NudeButton ref={ref} className={className} {...rest}>
<MoreIcon />
</NudeButton>
)
);
OverflowMenuButton.displayName = "OverflowMenuButton";
-285
View File
@@ -1,285 +0,0 @@
import { CheckmarkIcon } from "outline-icons";
import {
DropdownMenuButton,
DropdownMenuExternalLink,
DropdownMenuGroup,
DropdownMenuInternalLink,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
} from "~/components/primitives/DropdownMenu";
import {
MenuButton,
MenuIconWrapper,
MenuInternalLink,
MenuExternalLink,
MenuLabel,
MenuSeparator,
MenuDisclosure,
SelectedIconWrapper,
} from "~/components/primitives/components/Menu";
import { MenuItem } from "~/types";
export function toDropdownMenuItems(items: MenuItem[]) {
const filteredItems = filterMenuItems(items);
if (!filteredItems.length) {
return null;
}
const showIcon = filteredItems.find(
(item) =>
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
!!item.icon
);
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<DropdownMenuButton
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
tooltip={item.tooltip}
selected={item.selected}
dangerous={item.dangerous}
onClick={item.onClick}
/>
);
case "route":
return (
<DropdownMenuInternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
to={item.to}
/>
);
case "link":
return (
<DropdownMenuExternalLink
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
icon={icon}
disabled={item.disabled}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
typeof item.href === "string" ? undefined : item.href.target
}
/>
);
case "submenu": {
const submenuItems = toDropdownMenuItems(item.items);
if (!submenuItems?.length) {
return null;
}
return (
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
<DropdownSubMenuTrigger
label={item.title as string}
icon={icon}
disabled={item.disabled}
/>
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
</DropdownSubMenu>
);
}
case "group": {
const groupItems = toDropdownMenuItems(item.items);
if (!groupItems?.length) {
return null;
}
return (
<DropdownMenuGroup
key={`${item.type}-${item.title}-${index}`}
label={item.title as string}
items={groupItems}
/>
);
}
case "separator":
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
}
});
}
export function toMobileMenuItems(
items: MenuItem[],
closeMenu: () => void,
openSubmenu: (submenuName: string) => void
) {
const filteredItems = filterMenuItems(items);
if (!filteredItems.length) {
return null;
}
const showIcon = filteredItems.find(
(item) =>
item.type !== "separator" &&
item.type !== "heading" &&
item.type !== "group" &&
!!item.icon
);
return filteredItems.map((item, index) => {
const icon = showIcon ? (
<MenuIconWrapper aria-hidden>
{"icon" in item ? item.icon : null}
</MenuIconWrapper>
) : undefined;
switch (item.type) {
case "button":
return (
<MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
$dangerous={item.dangerous}
onClick={(e) => {
closeMenu();
item.onClick(e);
}}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
{item.selected !== undefined && (
<SelectedIconWrapper aria-hidden>
{item.selected ? <CheckmarkIcon /> : null}
</SelectedIconWrapper>
)}
</MenuButton>
);
case "route":
return (
<MenuInternalLink
key={`${item.type}-${item.title}-${index}`}
to={item.to}
disabled={item.disabled}
onClick={closeMenu}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
</MenuInternalLink>
);
case "link":
return (
<MenuExternalLink
key={`${item.type}-${item.title}-${index}`}
href={typeof item.href === "string" ? item.href : item.href.url}
target={
typeof item.href === "string" ? undefined : item.href.target
}
disabled={item.disabled}
onClick={closeMenu}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
</MenuExternalLink>
);
case "submenu": {
const submenuItems = toMobileMenuItems(
item.items,
closeMenu,
openSubmenu
);
if (!submenuItems?.length) {
return null;
}
return (
<MenuButton
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
onClick={() => {
openSubmenu(item.title as string);
}}
>
{icon}
<MenuLabel>{item.title}</MenuLabel>
<MenuDisclosure />
</MenuButton>
);
}
case "group": {
const groupItems = toMobileMenuItems(
item.items,
closeMenu,
openSubmenu
);
if (!groupItems?.length) {
return null;
}
return (
<div key={`${item.type}-${item.title}-${index}`}>
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
{groupItems}
</div>
);
}
case "separator":
return <MenuSeparator key={`${item.type}-${index}`} />;
default:
return null;
}
});
}
function filterMenuItems(items: MenuItem[]): MenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator when the previous item is also a separator.
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as MenuItem[])
.filter((item, index, arr) => {
// trim when first or last item is a separator.
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
+111 -59
View File
@@ -1,9 +1,10 @@
import * as Dialog from "@radix-ui/react-dialog";
import { observer } from "mobx-react";
import { CloseIcon, BackIcon } from "outline-icons";
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Flex from "~/components/Flex";
@@ -12,14 +13,17 @@ import Scrollable from "~/components/Scrollable";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
import useUnmount from "~/hooks/useUnmount";
import { fadeAndScaleIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import ErrorBoundary from "./ErrorBoundary";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
let openModals = 0;
type Props = {
children?: React.ReactNode;
isOpen: boolean;
fullscreen?: boolean;
title?: React.ReactNode;
style?: React.CSSProperties;
onRequestClose: () => void;
@@ -28,14 +32,32 @@ type Props = {
const Modal: React.FC<Props> = ({
children,
isOpen,
fullscreen = true,
title = "Untitled",
style,
onRequestClose,
}: Props) => {
const [depth, setDepth] = React.useState(0);
const wasOpen = usePrevious(isOpen);
const isMobile = useMobile();
const { t } = useTranslation();
React.useEffect(() => {
if (!wasOpen && isOpen) {
setDepth(openModals++);
}
if (wasOpen && !isOpen) {
setDepth(openModals--);
}
}, [wasOpen, isOpen]);
useUnmount(() => {
if (isOpen) {
openModals--;
}
});
if (!isOpen && !wasOpen) {
return null;
}
@@ -46,71 +68,86 @@ const Modal: React.FC<Props> = ({
onOpenChange={(open) => !open && onRequestClose()}
>
<Dialog.Portal>
<StyledOverlay />
<Dialog.Title asChild>
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
</Dialog.Title>
<StyledContent
onEscapeKeyDown={onRequestClose}
onPointerDownOutside={onRequestClose}
aria-describedby={undefined}
>
{isMobile ? (
<Mobile>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && (
<Text size="xlarge" weight="bold">
{title}
</Text>
)}
<ErrorBoundary>{children}</ErrorBoundary>
</Centered>
</Content>
<Close onClick={onRequestClose}>
<CloseIcon size={32} />
</Close>
<Back onClick={onRequestClose}>
<BackIcon size={32} />
<Text>{t("Back")} </Text>
</Back>
</Mobile>
) : (
<Small>
<Centered
onClick={(ev) => ev.stopPropagation()}
// maxHeight needed for proper overflow behavior in Safari
style={{ maxHeight: "65vh" }}
column
reverse
<StyledOverlay $fullscreen={fullscreen}>
<StyledContent
onEscapeKeyDown={onRequestClose}
onPointerDownOutside={fullscreen ? undefined : onRequestClose}
aria-describedby={undefined}
>
{fullscreen || isMobile ? (
<Fullscreen
$nested={!!depth}
style={
isMobile
? undefined
: {
marginLeft: `${depth * 12}px`,
}
}
>
<SmallContent style={style} shadow>
<ErrorBoundary component="div">{children}</ErrorBoundary>
</SmallContent>
<Header>
{title && <Text size="large">{title}</Text>}
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Header>
</Centered>
</Small>
)}
</StyledContent>
<Content>
<Centered onClick={(ev) => ev.stopPropagation()} column>
{title && (
<Text size="xlarge" weight="bold">
{title}
</Text>
)}
<ErrorBoundary>{children}</ErrorBoundary>
</Centered>
</Content>
<Close onClick={onRequestClose}>
<CloseIcon size={32} />
</Close>
<Back onClick={onRequestClose}>
<BackIcon size={32} />
<Text>{t("Back")} </Text>
</Back>
</Fullscreen>
) : (
<Small>
<Centered
onClick={(ev) => ev.stopPropagation()}
// maxHeight needed for proper overflow behavior in Safari
style={{ maxHeight: "65vh" }}
column
reverse
>
<SmallContent style={style} shadow>
<ErrorBoundary component="div">{children}</ErrorBoundary>
</SmallContent>
<Header>
{title && <Text size="large">{title}</Text>}
<NudeButton onClick={onRequestClose}>
<CloseIcon />
</NudeButton>
</Header>
</Centered>
</Small>
)}
</StyledContent>
</StyledOverlay>
</Dialog.Portal>
</Dialog.Root>
);
};
const StyledOverlay = styled(Dialog.Overlay)`
const StyledOverlay = styled(Dialog.Overlay)<{ $fullscreen?: boolean }>`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: ${(props) => props.theme.modalBackdrop} !important;
background-color: ${(props) =>
props.$fullscreen
? transparentize(0.25, props.theme.background)
: props.theme.modalBackdrop} !important;
z-index: ${depths.overlay};
animation: ${fadeIn} 200ms ease;
transition: opacity 50ms ease-in-out;
opacity: 0;
&[data-state="open"] {
opacity: 1;
}
`;
const StyledContent = styled(Dialog.Content)`
@@ -126,7 +163,12 @@ const StyledContent = styled(Dialog.Content)`
outline: none;
`;
const Mobile = styled.div`
type FullscreenProps = {
$nested: boolean;
theme: DefaultTheme;
};
const Fullscreen = styled.div<FullscreenProps>`
animation: ${fadeAndScaleIn} 250ms ease;
position: absolute;
@@ -140,6 +182,16 @@ const Mobile = styled.div`
align-items: flex-start;
background: ${s("background")};
outline: none;
${breakpoint("tablet")`
${(props: FullscreenProps) =>
props.$nested &&
`
box-shadow: 0 -2px 10px ${props.theme.shadow};
border-radius: 8px 0 0 8px;
overflow: hidden;
`}
`}
`;
const Content = styled(Scrollable)`
@@ -204,7 +256,7 @@ const Header = styled(Flex)`
align-items: center;
justify-content: space-between;
font-weight: 600;
padding: 24px 24px 12px;
padding: 24px 24px 4px;
`;
const Small = styled.div`
@@ -238,7 +290,7 @@ const Small = styled.div`
`;
const SmallContent = styled(Scrollable)`
padding: 8px 24px 24px;
padding: 12px 24px 24px;
`;
export default observer(Modal);
@@ -35,7 +35,7 @@ function Notifications(
const context = useActionContext();
const { notifications } = useStores();
const { t } = useTranslation();
const isEmpty = notifications.active.length === 0;
const isEmpty = notifications.orderedData.length === 0;
// Update the notification count in the dock icon, if possible.
React.useEffect(() => {
@@ -80,7 +80,7 @@ function Notifications(
<PaginatedList<Notification>
fetch={notifications.fetchPage}
options={{ archived: false }}
items={notifications.active}
items={notifications.orderedData}
renderItem={(item) => (
<NotificationListItem
key={item.id}
@@ -1,12 +1,11 @@
import * as Popover from "@radix-ui/react-popover";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/primitives/Popover";
import useStores from "~/hooks/useStores";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { fadeAndSlideUp } from "~/styles/animations";
import Notifications from "./Notifications";
type Props = {
@@ -15,16 +14,13 @@ type Props = {
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const { notifications } = useStores();
const [open, setOpen] = React.useState(false);
const scrollableRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
void notifications.fetchPage({ archived: false });
}, [notifications]);
const closeRef = React.useRef<HTMLButtonElement>(null);
const handleRequestClose = React.useCallback(() => {
setOpen(false);
if (closeRef.current) {
closeRef.current.click();
}
}, []);
const handleAutoFocus = React.useCallback((event: Event) => {
@@ -39,22 +35,51 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
}, []);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>{children}</PopoverTrigger>
<PopoverContent
aria-label={t("Notifications")}
side="top"
align="start"
onOpenAutoFocus={handleAutoFocus}
shrink
>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
</PopoverContent>
</Popover>
<Popover.Root>
<Popover.Trigger asChild>{children}</Popover.Trigger>
<Popover.Portal>
<StyledContent
side="top"
align="start"
sideOffset={0}
avoidCollisions={true}
aria-label={t("Notifications")}
onOpenAutoFocus={handleAutoFocus}
>
<Notifications
onRequestClose={handleRequestClose}
ref={scrollableRef}
/>
<VisuallyHidden>
<Popover.Close ref={closeRef} />
</VisuallyHidden>
</StyledContent>
</Popover.Portal>
</Popover.Root>
);
};
const StyledContent = styled(Popover.Content)`
z-index: ${depths.menu};
display: flex;
animation: ${fadeAndSlideUp} 200ms ease;
transform-origin: 75% 0;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px 0;
max-height: 75vh;
box-shadow: ${s("menuShadow")};
width: 380px;
overflow: hidden;
@media (max-width: 768px) {
position: fixed;
z-index: ${depths.menu};
top: 50px;
left: 8px;
right: 8px;
width: auto;
}
`;
export default observer(NotificationsPopover);
+8 -14
View File
@@ -8,6 +8,7 @@ import ImageInput from "~/scenes/Settings/components/ImageInput";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input, { LabelText } from "~/components/Input";
import { createSwitchRegister } from "~/utils/forms";
import isCloudHosted from "~/utils/isCloudHosted";
import Switch from "../Switch";
@@ -56,7 +57,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<>
<label style={{ marginBottom: "1em", display: "block" }}>
<label style={{ marginBottom: "1em" }}>
<LabelText>{t("Icon")}</LabelText>
<Controller
control={control}
@@ -115,17 +116,10 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
)}
/>
{isCloudHosted && (
<Controller
control={control}
name="published"
render={({ field }) => (
<Switch
label={t("Published")}
note={t("Allow this app to be installed by other workspaces")}
checked={field.value}
onChange={field.onChange}
/>
)}
<Switch
{...createSwitchRegister(register, "published")}
label={t("Published")}
note={t("Allow this app to be installed by other workspaces")}
/>
)}
</>
@@ -140,8 +134,8 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
-59
View File
@@ -1,59 +0,0 @@
import * as OneTimePasswordField from "@radix-ui/react-one-time-password-field";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
/** The length of the OTP */
length?: number;
};
export const OneTimePasswordInput = React.forwardRef(
function _OneTimePasswordInput(
{ length = 6, ...rest }: Props,
ref: React.RefObject<HTMLInputElement>
) {
return (
<OneTimePasswordRoot {...rest}>
{Array.from({ length }, (_, i) => (
<OneTimePasswordInputField key={i} />
))}
<OneTimePasswordField.HiddenInput ref={ref} />
</OneTimePasswordRoot>
);
}
);
const OneTimePasswordRoot = styled(OneTimePasswordField.Root)`
display: flex;
gap: 0.5rem;
flex-wrap: nowrap;
justify-content: space-between;
`;
const OneTimePasswordInputField = styled(OneTimePasswordField.Input)`
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
border-radius: 4px;
font-size: 15px;
color: ${s("text")};
background: ${s("background")};
box-shadow: 0 0 0 1px ${s("inputBorder")};
padding: 0;
height: 38px;
width: 38px;
line-height: 1;
transition: box-shadow 0.1s ease-in-out;
&:focus {
box-shadow: 0 0 0 2px ${s("inputBorderFocused")};
}
&::selection {
background-color: ${s("background")};
color: ${s("text")};
}
`;
+10 -22
View File
@@ -8,18 +8,11 @@ type Props = {
children: React.ReactNode;
};
const StableWrapper = styled.div<{ $shouldApplyMobileStyles: boolean }>`
${({ $shouldApplyMobileStyles }) =>
$shouldApplyMobileStyles
? `
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
`
: `
display: contents;
`}
const MobileWrapper = styled.div`
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
`;
/**
@@ -34,17 +27,12 @@ const PageScroll = ({ children }: Props) => {
const isPrinting = useMediaQuery("print");
const ref = React.useRef<HTMLDivElement>(null);
const shouldApplyMobileStyles = isMobile && !isPrinting;
return (
<ScrollContext.Provider value={shouldApplyMobileStyles ? ref : undefined}>
<StableWrapper
ref={ref}
$shouldApplyMobileStyles={shouldApplyMobileStyles}
>
{children}
</StableWrapper>
return isMobile && !isPrinting ? (
<ScrollContext.Provider value={ref}>
<MobileWrapper ref={ref}>{children}</MobileWrapper>
</ScrollContext.Provider>
) : (
<>{children}</>
);
};
+1
View File
@@ -46,6 +46,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
<DocumentListItem
key={item.id}
document={item}
showPin={!!options?.collectionId}
showParentDocuments={showParentDocuments}
showCollection={showCollection}
showPublished={showPublished}
+8 -17
View File
@@ -1,18 +1,13 @@
import * as React from "react";
import styled from "styled-components";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Revision from "~/models/Revision";
import PaginatedList from "~/components/PaginatedList";
import EventListItem from "./EventListItem";
import RevisionListItem from "./RevisionListItem";
type Item = Revision | Event<Document>;
import EventListItem, { type Event } from "./EventListItem";
type Props = {
items: Item[];
events: Event[];
document: Document;
fetch: (options: Record<string, any> | undefined) => Promise<Item[]>;
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
options?: Record<string, any>;
heading?: React.ReactNode;
empty?: JSX.Element;
@@ -21,7 +16,7 @@ type Props = {
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
empty,
heading,
items,
events,
fetch,
options,
document,
@@ -29,18 +24,14 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
}: Props) {
return (
<StyledPaginatedList
items={items}
items={events}
empty={empty}
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Item) =>
item instanceof Revision ? (
<RevisionListItem key={item.id} item={item} document={document} />
) : (
<EventListItem key={item.id} item={item} document={document} />
)
}
renderItem={(item: Event) => (
<EventListItem key={item.id} event={item} document={document} />
)}
renderHeading={(name) => <Heading>{name}</Heading>}
{...rest}
/>
+1 -1
View File
@@ -14,7 +14,7 @@ describe("PaginatedList", () => {
i18n,
tReady: true,
t: ((key: string) => key) as TFunction,
} as unknown;
} as any;
it("with no items renders nothing", () => {
const result = render(

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