Compare commits

..

7 Commits

Author SHA1 Message Date
Codegen Bot 0720a65de3 Update yarn.lock for React 18 dependencies 2025-05-20 03:33:22 +00:00
Codegen Bot 95d1f52c2e Update React and React DOM to version 18 and update related dependencies 2025-05-20 03:30:40 +00:00
Codegen Bot a52d9232a9 Update React types to match React 18 version and update testing library 2025-05-20 03:25:53 +00:00
Codegen Bot fe1672356a Update React types to match React 18 version and update testing library 2025-05-20 03:22:18 +00:00
codegen-sh[bot] 2a8d845cbc Update tsconfig.json for React 18 JSX transform 2025-05-20 03:09:58 +00:00
codegen-sh[bot] b593930338 Update ReactDOM.render to createRoot for React 18 2025-05-20 03:09:35 +00:00
codegen-sh[bot] 050200af36 Update React to version 18 2025-05-20 03:04:02 +00:00
1376 changed files with 27795 additions and 56516 deletions
+13 -9
View File
@@ -1,11 +1,6 @@
{
"presets": [
[
"@babel/preset-react",
{
"runtime": "automatic"
}
],
"@babel/preset-react",
"@babel/preset-env",
"@babel/preset-typescript"
],
@@ -21,7 +16,10 @@
[
"transform-inline-environment-variables",
{
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
"include": [
"SOURCE_COMMIT",
"SOURCE_VERSION"
]
}
],
"tsconfig-paths-module-resolver"
@@ -36,10 +34,16 @@
}
]
],
"ignore": ["**/__mocks__", "**/*.test.ts"]
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"development": {
"ignore": ["**/__mocks__", "**/*.test.ts"]
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"test": {
"presets": [
-1
View File
@@ -6,7 +6,6 @@ __mocks__
.DS_Store
.env*
.eslint*
.oxlintrc*
.log
Makefile
Procfile
-1
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=
-5
View File
@@ -1,10 +1,6 @@
NODE_ENV=test
DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test
SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET=test-utils-secret-key
REDIS_URL=redis://localhost:6379
URL=http://localhost:3000
COLLABORATION_URL=http://localhost:3001
SMTP_HOST=smtp.example.com
SMTP_USERNAME=test
@@ -16,7 +12,6 @@ GOOGLE_CLIENT_SECRET=123
SLACK_CLIENT_ID=123
SLACK_CLIENT_SECRET=123
SLACK_VERIFICATION_TOKEN=test-token-123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
+1
View File
@@ -0,0 +1 @@
server/migrations/*.js
+143
View File
@@ -0,0 +1,143 @@
{
"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",
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-lodash"
],
"rules": {
"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/self-closing-comp": ["error", {
"component": true,
"html": true
}],
"@typescript-eslint/no-shadow": [
"warn",
{
"allow": ["transaction"],
"hoist": "all",
"ignoreTypeValueShadow": true
}
],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": false
}
],
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"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
-3
View File
@@ -26,6 +26,3 @@ updates:
aws:
patterns:
- "@aws-sdk/*"
radix-ui:
patterns:
- "@radix-ui/*"
+2 -2
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:
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Close unsigned PRs
uses: actions/github-script@v8
uses: actions/github-script@v6
with:
script: |
const now = new Date();
@@ -40,7 +40,7 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout Branch
uses: actions/checkout@v5
uses: actions/checkout@v2
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@main
@@ -48,7 +48,6 @@ jobs:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
compressOnly: ${{ github.event_name != 'pull_request' }}
minPctChange: "10"
- name: Create Pull Request
# If it's not a Pull Request then commit any changes as a new PR.
if: |
+74 -67
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,57 +22,54 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
node-version: [20.x]
steps:
- uses: actions/checkout@v5
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v5
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@v5
- uses: actions/setup-node@v5
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint --quiet
- 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@v5
- uses: actions/setup-node@v5
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:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
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@v5
- uses: actions/setup-node@v5
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@v5
- uses: actions/setup-node@v5
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@v5
- uses: actions/setup-node@v5
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@v5
- 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
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -93,7 +93,7 @@ jobs:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -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 }}
+30
View File
@@ -0,0 +1,30 @@
name: Lint
on:
pull_request:
branches: [ main ]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
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'
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v5
with:
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
+11 -5
View File
@@ -1,17 +1,18 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"transformIgnorePatterns": ["node_modules/(?!(franc|trigram-utils)/)"],
"projects": [
{
"displayName": "server",
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
"^@shared/(.*)$": "<rootDir>/shared/$1",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
},
"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"
},
@@ -21,7 +22,9 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
@@ -36,7 +39,8 @@
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
"^@shared/(.*)$": "<rootDir>/shared/$1",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
@@ -48,7 +52,9 @@
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
-1
View File
@@ -1 +0,0 @@
22
-103
View File
@@ -1,103 +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
}
],
"react/rules-of-hooks": "error"
},
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
}
]
}
-4
View File
@@ -1,4 +0,0 @@
{
"printWidth": 80,
"trailingComma": "es5"
}
+2 -2
View File
@@ -1,11 +1,11 @@
require("@dotenvx/dotenvx").config({
require("dotenv").config({
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
});
var path = require('path');
module.exports = {
'config': path.resolve('server/config', 'database.js'),
'config': path.resolve('server/config', 'database.json'),
'migrations-path': path.resolve('server', 'migrations'),
'models-path': path.resolve('server', 'models'),
}
-192
View File
@@ -1,192 +0,0 @@
Outline is a fast, collaborative knowledge base built for teams. It's built with React and TypeScript in both frontend and backend, uses a real-time collaboration engine, and is designed for excellent performance and user experience. The backend is a Koa server with an RPC API and uses PostgreSQL and Redis. The application can be self-hosted or used as a cloud service.
There is a web client which is fully responsive and works on mobile devices.
**Monorepo Structure:**
- **`app/`** - React web application with MobX state management
- **`server/`** - Koa API server with Sequelize ORM and background workers
- **`shared/`** - Shared TypeScript types, utilities, and editor components
- **`plugins/`** - Plugin system for extending functionality
- **`public/`** - Static assets served directly
- **Various config files** - TypeScript, Vite, Jest, Prettier, Oxlint configurations
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
## Instructions
You're an expert in the following areas:
- TypeScript
- React and React Router
- MobX and MobX-React
- Node.js and Koa
- Sequelize ORM
- PostgreSQL
- Redis
- HTML, CSS and Styled Components
- Prosemirror (rich text editor)
- WebSockets and real-time collaboration
## General Guidelines
- Use early returns for readability.
- Emphasize type safety and static analysis.
- Follow consistent Prettier formatting.
- Do not replace smart quotes ("") or ('') with simple quotes ("").
## Dependencies and Upgrading
- Use yarn for all dependency management.
- After updating dependency versions, install to update lockfiles:
```bash
yarn install
```
## TypeScript Usage
- Use strict mode.
- Avoid "unknown" unless absolutely necessary.
- Never use "any".
- Prefer type definitions; avoid type assertions (as, !).
- Always use curly braces for if statements.
- Avoid # for private properties.
- Prefer interface over type for object shapes.
## Classes & Code Organization
### Class Member Order
1. Public static variables
2. Public static methods
3. Public variables
4. Public methods
6. Protected variables & methods
8. Private variables & methods
### Exports
- Exported members must appear at the top of the file.
- Prefer named exports for components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
- Use functional components with hooks.
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
- Use descriptive prop types with TypeScript interfaces.
- You do not need to import React unless it is used directly.
- Use styled-components for component styling.
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.
## MobX State Management
- Use MobX stores for global state management.
- Keep stores in `app/stores/`.
- Use `observable`, `action`, and `computed` decorators appropriately.
- Prefer computed values over manual calculations in render.
- Keep business logic in stores, not components.
## Database & ORM
- Use Sequelize models in `server/models/`.
- Generate migrations with Sequelize CLI:
```bash
yarn sequelize migration:create --name=add-field-to-table
```
- Run migrations with `yarn db:migrate`.
- Use transactions for multi-table operations.
- Add appropriate indexes for query performance.
- Always handle database errors gracefully.
## API Design
- RESTful endpoints under `/api/`.
- Authentication endpoints under `/auth/`.
- Use consistent error responses.
- Validate request data using the validation middleware and schemas
- Use presenters to format API responses.
- Keep API routes thin, use model methods for business logic, or commands if logic spans multiple models.
## Authentication & Authorization
- JWT tokens for authentication.
- Policies in `server/policies/` for authorization.
- Use cancan-style ability checks.
- Use authenticated middleware for protected routes.
- Always verify user permissions before data access.
## Real-time Collaboration
- WebSocket connections for real-time updates.
- Use Y.js for collaborative editing.
- Handle connection state changes gracefully.
## Documentation
- All public/exported functions & classes must have JSDoc.
- Include:
- Description
- @param and @return (start lowercase, end with period)
- @throws if applicable
- Add a newline between the description and the @ block.
- Use correct punctuation.
## Testing
- Run tests with Jest:
```bash
# Run all tests
yarn test
# Run specific test suites
yarn test:app # Frontend tests
yarn test:server # Backend tests
yarn test:shared # Shared code tests
# Run specific test file
yarn test path/to/test.spec.ts
```
- Write unit tests for utilities and business logic in a collocated .test.ts file.
- Mock external dependencies appropriately in __mocks__ folder.
- Aim for high code coverage but focus on critical paths.
## Code Quality
- Use Oxlint for linting: `yarn lint`
- Format code with Prettier: `yarn format`
- Check types with TypeScript: `yarn tsc`
- Pre-commit hooks run automatically via Husky.
- Fix linting issues before committing.
## Error Handling
- Use custom error classes in `server/errors.ts`.
- Always catch and handle errors appropriately.
- Log errors with appropriate context.
- Return user-friendly error messages.
- Never expose sensitive information in errors.
## Performance
- Use React.memo for expensive components.
- Implement pagination for large lists.
- Use database indexes effectively.
- Cache expensive computations.
- Monitor performance with appropriate tools.
- Lazy load routes and components where appropriate.
## Security
- Sanitize all user input.
- Use CSRF protection.
- Use rateLimiter middleware for sensitive endpoints.
- Follow OWASP guidelines.
- Never store sensitive data in plain text.
- Use environment variables for secrets.
+13 -12
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:22.21.0-slim AS runner
FROM node:20-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
@@ -14,13 +14,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
ENV NODE_ENV=production
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
COPY --from=base $APP_PATH/build ./build
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
@@ -29,13 +23,20 @@ COPY --from=base $APP_PATH/package.json ./package.json
# Install wget to healthcheck the server
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
VOLUME /var/lib/outline/data
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:22.21.0 AS deps
FROM node:20 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.1.0
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-11-16
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
+11 -12
View File
@@ -7,13 +7,14 @@
<img width="1640" alt="screenshot" src="https://user-images.githubusercontent.com/380914/110356468-26374600-7fef-11eb-9f6a-f2cc2c8c6590.png">
</p>
<p align="center">
<a href="https://circleci.com/gh/outline/outline" rel="nofollow"><img src="https://circleci.com/gh/outline/outline.svg?style=shield"></a>
<a href="http://www.typescriptlang.org" rel="nofollow"><img src="https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg" alt="TypeScript"></a>
<a href="https://github.com/prettier/prettier"><img src="https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat" alt="Prettier"></a>
<a href="https://github.com/styled-components/styled-components"><img src="https://img.shields.io/badge/style-%F0%9F%92%85%20styled--components-orange.svg" alt="Styled Components"></a>
<a href="https://translate.getoutline.com/project/outline" alt="Localized"><img src="https://badges.crowdin.net/outline/localized.svg"></a>
</p>
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, A hosted version of the app is offered at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
This is the source code that runs [**Outline**](https://www.getoutline.com) and all the associated services. If you want to use Outline then you don't need to run this code, we offer a hosted version of the app at [getoutline.com](https://www.getoutline.com). You can also find documentation on using Outline in [our guide](https://docs.getoutline.com/s/guide).
If you'd like to run your own copy of Outline or contribute to development then this is the place for you.
@@ -50,14 +51,13 @@ please refer to the [architecture document](docs/ARCHITECTURE.md) first for a hi
In development Outline outputs simple logging to the console, prefixed by categories. In production it outputs JSON logs, these can be easily parsed by your preferred log ingestion pipeline.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable. logging
can be enabled for all categories by setting `DEBUG=*` or for specific categories such as `DEBUG=database` and `LOG_LEVEL=debug`, or `LOG_LEVEL=silly` for very verbose logging.
HTTP logging is disabled by default, but can be enabled by setting the `DEBUG=http` environment variable.
## Tests
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code.
```shell
# To run all tests
@@ -68,14 +68,14 @@ make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly with jest:
frontend and backend tests directly.
```shell
# To run backend tests
yarn test:server
# To run a specific backend test in watch mode
yarn test path/to/file.test.ts --watch
# To run a specific backend test
yarn test:server myTestFile
# To run frontend tests
yarn test:app
@@ -86,15 +86,14 @@ yarn test:app
Sequelize is used to create and run migrations, for example:
```shell
yarn db:create-migration --name my-migration
yarn db:migrate
yarn db:rollback
yarn sequelize migration:generate --name my-migration
yarn sequelize db:migrate
```
Or, to run migrations on test database:
Or to run migrations on test database:
```shell
yarn db:migrate --env test
yarn sequelize db:migrate --env test
```
# Activity
+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 -7
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": {
@@ -194,11 +200,6 @@
"description": "Use a secure SMTP connection (optional)",
"required": false
},
"SMTP_DISABLE_STARTTLS": {
"value": "false",
"description": "Disable STARTTLS even if the server supports it (optional)",
"required": false
},
"SMTP_TLS_CIPHERS": {
"description": "Override SMTP cipher configuration (optional)",
"required": false
@@ -221,4 +222,4 @@
"required": false
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": [
"../.eslintrc",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"plugins": [
"eslint-plugin-react-hooks"
],
"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-globals": [
"error",
{
"name": "crypto",
"message": "Do not use, does not work in environments without SSL."
}
],
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["mime-types"],
"message": "Do not use the mime-types package in the browser."
}
]
}
]
},
"plugins": ["import"]
}
],
"env": {
"jest": true,
"browser": true
}
}
+2 -37
View File
@@ -1,8 +1,7 @@
import { PlusIcon, TrashIcon } from "outline-icons";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
import { createAction } from "..";
import { SettingsSection } from "../sections";
@@ -24,37 +23,3 @@ export const createApiKey = createAction({
});
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t, isMenu }) =>
isMenu
? 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}
/>
),
});
},
});
+30 -237
View File
@@ -1,13 +1,7 @@
import {
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
ManualSortIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
RestoreIcon,
@@ -19,6 +13,7 @@ import {
UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
@@ -26,32 +21,19 @@ import { CollectionNew } from "~/components/Collection/CollectionNew";
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import SharePopover from "~/components/Sharing/Collection/SharePopover";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} 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 { getEventFiles } from "@shared/utils/files";
import history from "~/utils/history";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
<DynamicCollectionIcon collection={collection} />
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
export const openCollection = createActionWithChildren({
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
analyticsName: "Open collection",
section: CollectionSection,
@@ -59,17 +41,15 @@ export const openCollection = createActionWithChildren({
icon: <CollectionIcon />,
children: ({ stores }) => {
const collections = stores.collections.orderedData;
return collections.map((collection) =>
createInternalLinkAction({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
to: collection.path,
})
);
return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
perform: () => history.push(collection.path),
}));
},
});
@@ -92,7 +72,8 @@ export const createCollection = createAction({
});
export const editCollection = createAction({
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}` : t("Edit collection")),
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Edit")}` : t("Edit collection"),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
@@ -117,8 +98,8 @@ export const editCollection = createAction({
});
export const editCollectionPermissions = createAction({
name: ({ t, isMenu }) =>
isMenu ? `${t("Permissions")}` : t("Collection permissions"),
name: ({ t, isContextMenu }) =>
isContextMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
@@ -148,130 +129,7 @@ export const editCollectionPermissions = createAction({
},
});
export const importDocument = createAction({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toast.error(err.message);
}
};
input.click();
},
});
export const sortCollection = createActionWithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
!!stores.policies.abilities(activeCollectionId).update,
icon: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
return sortAlphabetical ? (
sortDir === "asc" ? (
<AlphabeticalSortIcon />
) : (
<AlphabeticalReverseSortIcon />
)
) : (
<ManualSortIcon />
);
},
children: [
createAction({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "asc",
},
});
},
}),
createAction({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
direction: "desc",
},
});
},
}),
createAction({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.sort.field !== "title";
},
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "index",
direction: "asc",
},
});
},
}),
],
});
export const searchInCollection = createInternalLinkAction({
export const searchInCollection = createAction({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: ActiveCollectionSection,
@@ -289,16 +147,9 @@ export const searchInCollection = createInternalLinkAction({
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 }));
},
});
@@ -368,7 +219,6 @@ export const subscribeCollection = createAction({
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
@@ -399,7 +249,6 @@ export const unsubscribeCollection = createAction({
const collection = stores.collections.get(activeCollectionId);
return (
!!collection?.isActive &&
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
@@ -420,7 +269,7 @@ export const unsubscribeCollection = createAction({
export const archiveCollection = createAction({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
section: ActiveCollectionSection,
section: CollectionSection,
icon: <ArchiveIcon />,
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
@@ -517,62 +366,7 @@ export const deleteCollection = createAction({
},
});
export const exportCollection = createAction({
name: ({ t }) => `${t("Export")}`,
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ currentTeamId, activeCollectionId, stores }) => {
if (!currentTeamId || !activeCollectionId) {
return false;
}
return !!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 = createInternalLinkAction({
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 = createInternalLinkAction({
export const createTemplate = createAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
@@ -583,14 +377,13 @@ export const createTemplate = createInternalLinkAction({
!!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));
},
});
+34 -11
View File
@@ -1,10 +1,13 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import history from "~/utils/history";
import { createAction } from "..";
import { ActiveDocumentSection } from "../sections";
import { DocumentSection } from "../sections";
export const deleteCommentFactory = ({
comment,
@@ -16,12 +19,12 @@ export const deleteCommentFactory = ({
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();
@@ -42,13 +45,23 @@ export const resolveCommentFactory = ({
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 }) => {
await comment.resolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onResolve();
toast.success(t("Thread resolved"));
},
@@ -64,13 +77,23 @@ export const unresolveCommentFactory = ({
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 () => {
await comment.unresolve();
const locationState = history.location.state as Record<string, unknown>;
history.replace({
...history.location,
state: {
sidebarContext: locationState["sidebarContext"],
commentId: undefined,
},
});
onUnresolve();
},
});
@@ -83,12 +106,12 @@ export const viewCommentReactionsFactory = ({
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();
+7 -35
View File
@@ -1,4 +1,3 @@
import Storage from "@shared/utils/Storage";
import copy from "copy-to-clipboard";
import {
BeakerIcon,
@@ -8,8 +7,9 @@ import {
TrashIcon,
UserIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import { createAction, createActionWithChildren } from "~/actions";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
@@ -19,7 +19,7 @@ import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
import { homePath } from "~/utils/routeHelpers";
export const copyId = createActionWithChildren({
export const copyId = createAction({
name: ({ t }) => t("Copy ID"),
icon: <CopyIcon />,
keywords: "uuid",
@@ -107,8 +107,8 @@ export const startTyping = createAction({
}, 250);
window.addEventListener("keydown", (event) => {
if (event.key === "Escape" && intervalId) {
clearInterval(intervalId);
if (event.key === "Escape") {
intervalId && clearInterval(intervalId);
}
});
@@ -128,17 +128,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 />,
@@ -176,22 +165,7 @@ export const toggleDebugLogging = createAction({
},
});
export const toggleDebugSafeArea = createAction({
name: () => "Toggle menu safe area debugging",
icon: <ToolsIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: ({ stores }) => {
stores.ui.toggleDebugSafeArea();
toast.message(
stores.ui.debugSafeArea
? "Menu safe area debugging enabled"
: "Menu safe area debugging disabled"
);
},
});
export const toggleFeatureFlag = createActionWithChildren({
export const toggleFeatureFlag = createAction({
name: "Toggle feature flag",
icon: <BeakerIcon />,
section: DeveloperSection,
@@ -215,7 +189,7 @@ export const toggleFeatureFlag = createActionWithChildren({
),
});
export const developer = createActionWithChildren({
export const developer = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
@@ -224,12 +198,10 @@ export const developer = createActionWithChildren({
children: [
copyId,
toggleDebugLogging,
toggleDebugSafeArea,
toggleFeatureFlag,
createToast,
createTestUsers,
clearIndexedDB,
clearStorage,
startTyping,
],
});
+113 -332
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,13 +25,13 @@ import {
PublishIcon,
CommentIcon,
CopyIcon,
EyeIcon,
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
RestoreIcon,
EditIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import {
@@ -50,14 +49,10 @@ import DeleteDocumentsInTrash from "~/scenes/Trash/components/DeleteDocumentsInT
import ConfirmationDialog from "~/components/ConfirmationDialog";
import DocumentCopy from "~/components/DocumentCopy";
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,
createActionGroup,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import { createAction } from "~/actions";
import {
ActiveDocumentSection,
DocumentSection,
@@ -67,6 +62,7 @@ import env from "~/env";
import { setPersistedState } from "~/hooks/usePersistedState";
import history from "~/utils/history";
import {
documentInsightsPath,
documentHistoryPath,
homePath,
newDocumentPath,
@@ -75,21 +71,9 @@ import {
documentPath,
urlify,
trashPath,
documentEditPath,
} from "~/utils/routeHelpers";
import capitalize from "lodash/capitalize";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Action, ActionGroup, ActionSeparator } from "~/types";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Insights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Document/SharePopover")
);
export const openDocument = createActionWithChildren({
export const openDocument = createAction({
name: ({ t }) => t("Open document"),
analyticsName: "Open document",
section: DocumentSection,
@@ -101,63 +85,24 @@ export const openDocument = createActionWithChildren({
(acc, node) => [...acc, ...node.children],
[] as NavigationNode[]
);
const documents = stores.documents.orderedData;
return uniqBy([...documents, ...nodes], "id").map((item) =>
createInternalLinkAction({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
icon: item.icon ? (
<Icon
value={item.icon}
initial={item.title}
color={item.color ?? undefined}
/>
) : (
<DocumentIcon />
),
section: DocumentSection,
to: item.url,
})
);
return nodes.map((item) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the document is renamed
id: item.url,
name: item.title,
icon: item.icon ? (
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
section: DocumentSection,
perform: () => history.push(item.url),
}));
},
});
export const editDocument = createInternalLinkAction({
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 = createInternalLinkAction({
export const createDocument = createAction({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: DocumentSection,
@@ -175,18 +120,13 @@ export const createDocument = createInternalLinkAction({
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument
);
},
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(newDocumentPath(activeCollectionId), {
sidebarContext,
}),
});
export const createDraftDocument = createInternalLinkAction({
export const createDraftDocument = createAction({
name: ({ t }) => t("New draft"),
analyticsName: "New document",
section: DocumentSection,
@@ -194,13 +134,13 @@ export const createDraftDocument = createInternalLinkAction({
keywords: "create document",
visible: ({ currentTeamId, stores }) =>
!!currentTeamId && stores.policies.abilities(currentTeamId).createDocument,
to: ({ sidebarContext }) => ({
pathname: newDocumentPath(),
state: { sidebarContext },
}),
perform: ({ sidebarContext }) =>
history.push(newDocumentPath(), {
sidebarContext,
}),
});
export const createDocumentFromTemplate = createInternalLinkAction({
export const createDocumentFromTemplate = createAction({
name: ({ t }) => t("New from template"),
analyticsName: "New document",
section: DocumentSection,
@@ -230,24 +170,16 @@ export const createDocumentFromTemplate = createInternalLinkAction({
}
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 = createInternalLinkAction({
export const createNestedDocument = createAction({
name: ({ t }) => t("New nested document"),
analyticsName: "New document",
section: ActiveDocumentSection,
@@ -258,16 +190,10 @@ export const createNestedDocument = createInternalLinkAction({
!!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 = createAction({
@@ -400,22 +326,6 @@ export const subscribeDocument = createAction({
analyticsName: "Subscribe to document",
section: ActiveDocumentSection,
icon: <SubscribeIcon />,
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
return undefined;
}
return stores.collections.get(activeCollectionId)?.isSubscribed
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
return false;
}
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
},
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -424,7 +334,6 @@ export const subscribeDocument = createAction({
const document = stores.documents.get(activeDocumentId);
return (
!!document?.isActive &&
!document?.collection?.isSubscribed &&
!document?.isSubscribed &&
stores.policies.abilities(activeDocumentId).subscribe
@@ -446,22 +355,6 @@ export const unsubscribeDocument = createAction({
analyticsName: "Unsubscribe from document",
section: ActiveDocumentSection,
icon: <UnsubscribeIcon />,
tooltip: ({ activeCollectionId, isMenu, stores, t }) => {
if (!isMenu || !activeCollectionId) {
return undefined;
}
return stores.collections.get(activeCollectionId)?.isSubscribed
? t("Subscription inherited from collection")
: undefined;
},
disabled: ({ activeCollectionId, isMenu, stores }) => {
if (!isMenu || !activeCollectionId) {
return false;
}
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
},
visible: ({ activeDocumentId, stores }) => {
if (!activeDocumentId) {
return false;
@@ -470,10 +363,9 @@ export const unsubscribeDocument = createAction({
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 }) => {
@@ -549,11 +441,9 @@ export const downloadDocumentAsPDF = createAction({
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;
@@ -586,8 +476,9 @@ export const downloadDocumentAsMarkdown = createAction({
},
});
export const downloadDocument = createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download document")),
export const downloadDocument = createAction({
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Download") : t("Download document"),
analyticsName: "Download document",
section: ActiveDocumentSection,
icon: <DownloadIcon />,
@@ -609,15 +500,12 @@ export const copyDocumentAsMarkdown = createAction({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ stores, activeDocumentId, t }) => {
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toMarkdown(document));
copy(document.toMarkdown());
toast.success(t("Markdown copied to clipboard"));
}
},
@@ -631,15 +519,12 @@ export const copyDocumentAsPlainText = createAction({
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: async ({ stores, activeDocumentId, t }) => {
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
const { ProsemirrorHelper } = await import(
"~/models/helpers/ProsemirrorHelper"
);
copy(ProsemirrorHelper.toPlainText(document));
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
@@ -684,7 +569,7 @@ export const copyDocumentLink = createAction({
},
});
export const copyDocument = createActionWithChildren({
export const copyDocument = createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy document",
section: ActiveDocumentSection,
@@ -699,7 +584,8 @@ export const copyDocument = createActionWithChildren({
});
export const duplicateDocument = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Duplicate") : t("Duplicate document")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Duplicate") : t("Duplicate document"),
analyticsName: "Duplicate document",
section: ActiveDocumentSection,
icon: <DuplicateIcon />,
@@ -810,7 +696,7 @@ export const pinDocumentToHome = createAction({
},
});
export const pinDocument = createActionWithChildren({
export const pinDocument = createAction({
name: ({ t }) => t("Pin"),
analyticsName: "Pin document",
section: ActiveDocumentSection,
@@ -818,7 +704,7 @@ export const pinDocument = createActionWithChildren({
children: [pinDocumentToCollection, pinDocumentToHome],
});
export const searchInDocument = createInternalLinkAction({
export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
@@ -831,25 +717,14 @@ export const searchInDocument = createInternalLinkAction({
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 = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print document")),
name: ({ t, isContextMenu }) =>
isContextMenu ? t("Print") : t("Print document"),
analyticsName: "Print document",
section: ActiveDocumentSection,
icon: <PrintIcon />,
@@ -871,19 +746,20 @@ export const importDocument = createAction({
}
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
return !!stores.policies.abilities(activeCollectionId).update;
}
return false;
},
perform: ({ activeDocumentId, activeCollectionId, stores }) => {
perform: ({ activeCollectionId, activeDocumentId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
try {
@@ -956,14 +832,14 @@ export const openRandomDocument = createAction({
});
export const searchDocumentsForQuery = (query: string) =>
createInternalLinkAction({
createAction({
id: "search",
name: ({ t }) =>
t(`Search documents for "{{searchQuery}}"`, { searchQuery: query }),
analyticsName: "Search documents",
section: DocumentSection,
icon: <SearchIcon />,
to: searchPath({ query }),
perform: () => history.push(searchPath({ query })),
visible: ({ location }) => location.pathname !== searchPath(),
});
@@ -1053,7 +929,7 @@ export const moveDocument = createAction({
perform: moveDocumentToCollection.perform,
});
export const moveTemplate = createActionWithChildren({
export const moveTemplate = createAction({
name: ({ t }) => t("Move"),
analyticsName: "Move document",
section: ActiveDocumentSection,
@@ -1112,101 +988,6 @@ export const archiveDocument = createAction({
},
});
export const restoreDocument = createAction({
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 = createActionWithChildren({
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 createAction({
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 [createActionGroup({ name: t("Choose a collection"), actions })];
},
});
export const deleteDocument = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete document",
@@ -1303,7 +1084,6 @@ export const openDocumentComments = createAction({
icon: <CommentIcon />,
visible: ({ activeDocumentId, stores }) => {
const can = stores.policies.abilities(activeDocumentId ?? "");
return (
!!activeDocumentId &&
can.comment &&
@@ -1319,7 +1099,7 @@ export const openDocumentComments = createAction({
},
});
export const openDocumentHistory = createInternalLinkAction({
export const openDocumentHistory = createAction({
name: ({ t }) => t("History"),
analyticsName: "Open document history",
section: ActiveDocumentSection,
@@ -1328,21 +1108,15 @@ export const openDocumentHistory = createInternalLinkAction({
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));
},
});
@@ -1364,17 +1138,45 @@ export const openDocumentInsights = createAction({
!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,
});
},
});
@@ -1409,33 +1211,12 @@ export const leaveDocument = createAction({
} as UserMembership);
toast.success(t("You have left the shared document"));
} catch (_err) {
} catch (err) {
toast.error(t("Could not leave document"));
}
},
});
export const applyTemplateFactory = ({
actions,
}: {
actions: (Action | ActionGroup | ActionSeparator)[];
}) =>
createActionWithChildren({
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,
-21
View File
@@ -1,21 +0,0 @@
import { PlusIcon } from "outline-icons";
import { createAction } from "~/actions";
import { TeamSection } from "../sections";
import stores from "~/stores";
import { EmojiCreateDialog } from "~/components/EmojiCreateDialog";
export const createEmoji = createAction({
name: ({ t }) => `${t("New emoji")}`,
analyticsName: "Create emoji",
icon: <PlusIcon />,
keywords: "emoji custom upload image",
section: TeamSection,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Upload emoji"),
content: <EmojiCreateDialog onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
-47
View File
@@ -1,47 +0,0 @@
import { TrashIcon } from "outline-icons";
import stores from "~/stores";
import { createAction } from "..";
import { SettingsSection } from "../sections";
import Integration from "~/models/Integration";
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
import { IntegrationType } from "@shared/types";
import { settingsPath } from "@shared/utils/routeHelpers";
import history from "~/utils/history";
export const disconnectIntegrationFactory = (integration?: Integration) =>
createAction({
name: ({ t }) => t("Disconnect"),
analyticsName: "Disconnect integration",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "disconnect",
visible: () => !!integration,
perform: async ({ event }) => {
event?.preventDefault();
event?.stopPropagation();
await integration?.delete();
history.push(settingsPath("integrations"));
},
});
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!} />,
});
},
});
+49 -53
View File
@@ -12,20 +12,18 @@ import {
BrowserIcon,
ShapesIcon,
DraftsIcon,
BugIcon,
} from "outline-icons";
import * as React from "react";
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,
createExternalLinkAction,
createInternalLinkAction,
} 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,
@@ -36,136 +34,133 @@ import {
settingsPath,
} from "~/utils/routeHelpers";
export const navigateToHome = createInternalLinkAction({
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),
analyticsName: "Navigate to home",
section: NavigationSection,
shortcut: ["d"],
icon: <HomeIcon />,
to: homePath(),
perform: () => history.push(homePath()),
visible: ({ location }) => location.pathname !== homePath(),
});
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createInternalLinkAction({
createAction({
section: RecentSearchesSection,
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 = createInternalLinkAction({
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts",
section: NavigationSection,
icon: <DraftsIcon />,
to: draftsPath(),
perform: () => history.push(draftsPath()),
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createInternalLinkAction({
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
icon: <SearchIcon />,
to: searchPath(),
perform: () => history.push(searchPath()),
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createInternalLinkAction({
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
section: NavigationSection,
shortcut: ["g", "a"],
icon: <ArchiveIcon />,
to: archivePath(),
perform: () => history.push(archivePath()),
visible: ({ location }) => location.pathname !== archivePath(),
});
export const navigateToTrash = createInternalLinkAction({
export const navigateToTrash = createAction({
name: ({ t }) => t("Trash"),
analyticsName: "Navigate to trash",
section: NavigationSection,
icon: <TrashIcon />,
to: trashPath(),
perform: () => history.push(trashPath()),
visible: ({ location }) => location.pathname !== trashPath(),
});
export const navigateToSettings = createInternalLinkAction({
export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to settings",
section: NavigationSection,
shortcut: ["g", "s"],
icon: <SettingsIcon />,
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
to: settingsPath(),
perform: () => history.push(settingsPath()),
});
export const navigateToWorkspaceSettings = createInternalLinkAction({
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 = createInternalLinkAction({
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 = createInternalLinkAction({
export const navigateToTemplateSettings = createAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to template settings",
section: NavigationSection,
iconInContextMenu: false,
icon: <ShapesIcon />,
to: settingsPath("templates"),
perform: () => history.push(settingsPath("templates")),
});
export const navigateToNotificationSettings = createInternalLinkAction({
name: ({ t, isMenu }) =>
isMenu ? 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 = createInternalLinkAction({
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 = createExternalLinkAction({
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 = createExternalLinkAction({
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({
@@ -176,34 +171,29 @@ export const toggleSidebar = createAction({
perform: () => stores.ui.toggleCollapsedSidebar(),
});
export const openFeedbackUrl = createExternalLinkAction({
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 = createExternalLinkAction({
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 = createExternalLinkAction({
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 = createAction({
@@ -221,7 +211,7 @@ export const openKeyboardShortcuts = createAction({
},
});
export const downloadApp = createExternalLinkAction({
export const downloadApp = createAction({
name: ({ t }) =>
t("Download {{ platform }} app", {
platform: isMac() ? "macOS" : "Windows",
@@ -231,8 +221,9 @@ export const downloadApp = createExternalLinkAction({
iconInContextMenu: false,
icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
url: "https://desktop.getoutline.com",
target: "_blank",
perform: () => {
window.open("https://desktop.getoutline.com");
},
});
export const logout = createAction({
@@ -241,7 +232,12 @@ export const logout = createAction({
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);
}
},
});
@@ -1,4 +1,5 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { createAction } from "..";
import { NotificationSection } from "../sections";
+1
View File
@@ -1,4 +1,5 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
import { createAction } from "..";
+3 -34
View File
@@ -1,5 +1,6 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
@@ -12,7 +13,7 @@ import {
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
name: ({ t }) => t("Restore"),
name: ({ t }) => t("Restore revision"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
@@ -41,38 +42,6 @@ export const restoreRevision = createAction({
},
});
export const deleteRevision = createAction({
name: ({ t }) => t("Delete"),
analyticsName: "Delete revision",
icon: <TrashIcon />,
section: RevisionSection,
dangerous: true,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ t, event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
if (revisionId) {
const revision = stores.revisions.get(revisionId);
await revision?.delete();
toast.success(t("This version of the document was deleted"));
history.push(documentHistoryPath(document));
}
},
});
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
+15 -11
View File
@@ -1,6 +1,8 @@
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { Theme } from "~/stores/UiStore";
import { createAction, createActionWithChildren } from "~/actions";
import { createAction } from "~/actions";
import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createAction({
@@ -10,8 +12,8 @@ export const changeToDarkTheme = createAction({
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 = createAction({
@@ -21,8 +23,8 @@ export const changeToLightTheme = createAction({
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 = createAction({
@@ -32,16 +34,18 @@ export const changeToSystemTheme = createAction({
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 = createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
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 { createAction, createInternalLinkAction } 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 }) =>
createAction({
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 }) =>
createInternalLinkAction({
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>;
}) =>
createAction({
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 -25
View File
@@ -1,28 +1,25 @@
import { ArrowIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { stringToColor } from "@shared/utils/color";
import RootStore from "~/stores/RootStore";
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
import {
createAction,
createActionWithChildren,
createExternalLinkAction,
} from "~/actions";
import { ActionContext, ExternalLinkAction } 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<ExternalLinkAction>((session) =>
createExternalLinkAction({
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 +30,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 = createActionWithChildren({
export const switchTeam = createAction({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace",
@@ -63,14 +58,13 @@ export const createTeam = createAction({
perform: ({ t, event, stores }) => {
event?.preventDefault();
event?.stopPropagation();
const { user } = stores.auth;
if (user) {
user &&
stores.dialogs.openModal({
title: t("Create a workspace"),
fullscreen: true,
content: <TeamNew user={user} />,
});
}
},
});
+3 -3
View File
@@ -1,4 +1,5 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import stores from "~/stores";
@@ -22,7 +23,6 @@ export const inviteUser = createAction({
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite to workspace"),
width: "500px",
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
@@ -46,8 +46,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({
+81 -242
View File
@@ -1,285 +1,131 @@
import { LocationDescriptor } from "history";
import flattenDeep from "lodash/flattenDeep";
import * as React from "react";
import { toast } from "sonner";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import {
ActionContext,
Action,
ActionGroup,
ActionSeparator as TActionSeparator,
ActionVariant,
ActionWithChildren,
ExternalLinkAction,
InternalLinkAction,
MenuItem,
ActionContext,
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;
}
export const ActionSeparator: TActionSeparator = {
type: "action_separator",
};
export function createAction(
definition: Optional<Omit<Action, "type" | "variant">, "id">
): Action {
export function createAction(definition: Optional<Action, "id">): Action {
return {
...definition,
type: "action",
variant: "action",
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);
return definition.perform?.(context);
}
: () => {},
: undefined,
id: definition.id ?? uuidv4(),
};
}
export function createInternalLinkAction(
definition: Optional<Omit<InternalLinkAction, "type" | "variant">, "id">
): InternalLinkAction {
return {
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? uuidv4(),
};
}
export function createExternalLinkAction(
definition: Optional<Omit<ExternalLinkAction, "type" | "variant">, "id">
): ExternalLinkAction {
return {
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? uuidv4(),
};
}
export function createActionWithChildren(
definition: Optional<Omit<ActionWithChildren, "type" | "variant">, "id">
): ActionWithChildren {
return {
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? uuidv4(),
};
}
export function createActionGroup(
definition: Omit<ActionGroup, "type">
): ActionGroup {
return {
...definition,
type: "action_group",
};
}
export function createRootMenuAction(
actions: (ActionVariant | ActionGroup | TActionSeparator)[]
): ActionWithChildren {
return {
id: uuidv4(),
type: "action",
variant: "action_with_children",
name: "root_action",
section: "Root",
children: actions,
};
}
export function actionToMenuItem(
action: ActionVariant | ActionGroup | TActionSeparator,
action: Action,
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;
): 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;
const title = resolve<string>(action.name, context);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? resolvedIcon
: 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: () => performAction(action, context),
};
if (resolvedChildren) {
const items = resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter(Boolean)
.filter((a) => a.visible);
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<
(ActionVariant | ActionGroup | TActionSeparator)[]
>(action.children, context);
const subMenuItems = children.map((a) =>
actionToMenuItem(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) =>
actionToMenuItem(a, context)
);
return {
type: "group",
title: resolve<string>(action.name, context),
visible: hasVisibleItems(groupItems),
items: groupItems,
};
}
case "action_separator":
return { type: "separator" };
return {
type: "submenu",
title,
icon,
items,
visible: visible && items.length > 0,
};
}
return {
type: "button",
title,
icon,
visible,
dangerous: action.dangerous,
onClick: () => performAction(action, context),
selected: action.selected?.(context),
};
}
export function actionToKBar(
action: ActionVariant,
action: Action,
context: ActionContext
): KbarAction[] {
const visible = resolve<boolean>(action.visible, context);
if (visible === false) {
): CommandBarAction[] {
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const resolvedSection = resolve<string>(action.section, context);
const resolvedName = resolve<string>(action.name, context);
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
)
: [];
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? ((action.section.priority as number) ?? 0)
? (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: () => performAction(action, context),
},
];
}
case "action_with_children": {
const resolvedChildren = resolve<ActionVariant[]>(
action.children,
context
);
const children = resolvedChildren
.map((a) => actionToKBar(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");
}
return [
{
id: action.id,
name: resolvedName,
analyticsName: action.analyticsName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
perform: action.perform
? () => performAction(action, context)
: undefined,
},
].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
children.map((child) => ({ ...child, parent: child.parent ?? action.id }))
);
}
export async function performAction(
action: Exclude<ActionVariant, ActionWithChildren>,
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();
export async function performAction(action: Action, context: ActionContext) {
const result = action.perform?.(context);
if (result instanceof Promise) {
return result.catch((err: Error) => {
@@ -289,10 +135,3 @@ export async function performAction(
return result;
}
function hasVisibleItems(items: MenuItem[]) {
const applicableTypes = ["button", "link", "route", "group", "submenu"];
return items.some(
(item) => applicableTypes.includes(item.type) && item.visible
);
}
-6
View File
@@ -36,16 +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 EmojiSecion = ({ t }: ActionContext) => t("Emoji");
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) =>
+22 -20
View File
@@ -1,10 +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, resolve } from "~/actions";
import { performAction } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import useActionContext from "~/hooks/useActionContext";
import { ActionVariant, ActionWithChildren } from "~/types";
import { Action, ActionContext } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -12,7 +11,9 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Exclude<ActionVariant, ActionWithChildren>;
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 */
tooltip?: Omit<TooltipProps, "children">;
};
@@ -21,34 +22,35 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function ActionButton_(
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
function _ActionButton(
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
const actionContext = useActionContext({
isButton: true,
});
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (!actionContext || !action) {
if (action && !context) {
throw new Error("Context must be provided with action");
}
if (!context || !action) {
return <button {...rest} ref={ref} />;
}
const actionIsDisabled =
action.visible && !resolve<boolean>(action.visible, actionContext);
const actionContext = { ...context, isButton: true };
if (actionIsDisabled && hideOnActionDisabled) {
if (
action?.visible &&
!action.visible(actionContext) &&
hideOnActionDisabled
) {
return null;
}
const disabled = rest.disabled || actionIsDisabled;
const label =
rest["aria-label"] ??
(typeof action.name === "function"
typeof action.name === "function"
? action.name(actionContext)
: action.name);
: action.name;
const button = (
<button
@@ -57,7 +59,7 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
disabled={disabled || executing}
ref={ref}
onClick={
actionContext
action?.perform && actionContext
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
+2 -1
View File
@@ -6,6 +6,7 @@ import Flex from "~/components/Flex";
export const Action = styled(Flex)`
justify-content: center;
align-items: center;
padding: 0 0 0 12px;
height: 32px;
font-size: 15px;
flex-shrink: 0;
@@ -17,6 +18,7 @@ export const Action = styled(Flex)`
export const Separator = styled.div`
flex-shrink: 0;
margin-left: 12px;
width: 1px;
height: 28px;
background: ${s("divider")};
@@ -31,7 +33,6 @@ const Actions = styled(Flex)`
background: ${s("background")};
padding: 12px;
backdrop-filter: blur(20px);
gap: 12px;
@media print {
display: none;
+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";
+2
View File
@@ -1,3 +1,5 @@
import * as React from "react";
export default function Arrow() {
return (
<svg
+3 -8
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useEffect } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -19,7 +19,7 @@ const Authenticated = ({ children }: Props) => {
// Watching for language changes here as this is the earliest point we might have the user
// available and means we can start loading translations faster
useEffect(() => {
React.useEffect(() => {
void changeLanguage(language, i18n);
}, [i18n, language]);
@@ -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="/" />;
};
+23 -10
View File
@@ -13,6 +13,8 @@ import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
@@ -25,11 +27,11 @@ import {
settingsPath,
matchDocumentHistory,
matchDocumentSlug as slug,
matchDocumentInsights,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
@@ -37,9 +39,10 @@ const DocumentComments = lazyWithRetry(
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
const DocumentInsights = lazyWithRetry(
() => import("~/scenes/Document/components/Insights")
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
type Props = {
children?: React.ReactNode;
@@ -95,24 +98,32 @@ 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);
team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showComments) && (
{(showHistory || showInsights || showComments) && (
<Route path={`/doc/${slug}`}>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
<SidebarRight>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showInsights && <DocumentInsights />}
{showComments && <DocumentComments />}
</React.Suspense>
</SidebarRight>
</Route>
)}
</AnimatePresence>
@@ -131,7 +142,9 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<React.Suspense fallback={null}>
<CommandBar />
</React.Suspense>
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
+4 -30
View File
@@ -2,7 +2,6 @@ import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
import Initials from "./Initials";
import Tooltip from "../Tooltip";
export enum AvatarSize {
Small = 16,
@@ -23,7 +22,6 @@ export interface IAvatar {
avatarUrl: string | null;
color?: string;
initial?: string;
name?: string;
id?: string;
}
@@ -44,48 +42,26 @@ type Props = {
className?: string;
/** Optional style */
style?: React.CSSProperties;
/** Whether to show a tooltip */
showTooltip?: boolean;
};
function Avatar(props: Props) {
const {
model,
style,
variant = AvatarVariant.Round,
className,
showTooltip,
...rest
} = props;
const { model, style, variant = AvatarVariant.Round, ...rest } = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
const initial =
model?.initial || (model?.name ? model.name[0] : "").toUpperCase();
const content = (
<Relative
style={style}
$variant={variant}
$size={props.size}
className={className}
>
return (
<Relative style={style} $variant={variant} $size={props.size}>
{src && !error ? (
<Image onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} {...rest}>
{initial}
{model.initial}
</Initials>
) : (
<Initials {...rest} />
)}
</Relative>
);
return showTooltip ? (
<Tooltip content={props.alt || model?.name || ""}>{content}</Tooltip>
) : (
content
);
}
Avatar.defaultProps = {
@@ -99,8 +75,6 @@ const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>`
border-radius: ${(props) =>
props.$variant === AvatarVariant.Round ? "50%" : `${props.$size / 8}px`};
overflow: hidden;
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
`;
const Image = styled.img<{ size: number }>`
+1 -4
View File
@@ -25,8 +25,6 @@ type Props = {
onClick?: React.MouseEventHandler<HTMLImageElement>;
/** Size of the avatar, defaults to AvatarSize.Large */
size?: AvatarSize;
/** Optional alt text for the avatar image */
alt?: string;
/** Optional inline styles to apply to the avatar wrapper */
style?: React.CSSProperties;
};
@@ -55,7 +53,6 @@ function AvatarWithPresence({
isCurrentUser,
size = AvatarSize.Large,
style,
alt,
}: Props) {
const { t } = useTranslation();
const status = isPresent
@@ -86,7 +83,7 @@ function AvatarWithPresence({
$color={user.color}
style={style}
>
<Avatar model={user} onClick={onClick} size={size} alt={alt} />
<Avatar model={user} onClick={onClick} size={size} />
</AvatarPresence>
</Tooltip>
</>
+1 -1
View File
@@ -1,4 +1,5 @@
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import Group from "~/models/Group";
@@ -26,7 +27,6 @@ export function GroupAvatar({
return (
<Squircle color={color ?? theme.text} size={size} className={className}>
<GroupIcon
data-fixed-color
color={backgroundColor ?? theme.background}
size={size * 0.75}
/>
+2 -2
View File
@@ -1,7 +1,7 @@
import Avatar, { IAvatar, AvatarSize, AvatarVariant } from "./Avatar";
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
export { Avatar, GroupAvatar, AvatarSize, AvatarVariant, AvatarWithPresence };
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
export type { IAvatar };
+2 -3
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
@@ -21,7 +21,6 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
font-size: 12px;
font-weight: 500;
user-select: none;
white-space: nowrap;
`;
export default Badge;
+14 -11
View File
@@ -1,5 +1,6 @@
import * as React from "react";
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";
@@ -10,7 +11,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>
@@ -33,16 +34,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 React.memo(Branding);
export default Branding;
+31 -62
View File
@@ -6,86 +6,55 @@ import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import { InternalLinkAction, MenuInternalLink } from "~/types";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useComputed } from "~/hooks/useComputed";
type TopLevelAction =
| InternalLinkAction
| { type: "menu"; actions: InternalLinkAction[] };
import { MenuInternalLink } from "~/types";
type Props = React.PropsWithChildren<{
actions: InternalLinkAction[];
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({ isMenu: 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 InternalLinkAction[];
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 = actionToMenuItem(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 -5
View File
@@ -34,7 +34,6 @@ const RealButton = styled(ActionButton)<RealProps>`
cursor: var(--pointer);
user-select: none;
appearance: none !important;
transition: background 200ms ease-out;
${undraggableOnDesktop()}
&::-moz-focus-inner {
@@ -45,7 +44,6 @@ const RealButton = styled(ActionButton)<RealProps>`
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.accent)};
transition: background 0s;
}
&:disabled {
@@ -80,7 +78,6 @@ const RealButton = styled(ActionButton)<RealProps>`
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
transition: background 0s;
}
&:focus-visible {
@@ -106,7 +103,6 @@ const RealButton = styled(ActionButton)<RealProps>`
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${darken(0.05, props.theme.danger)};
transition: background 0s;
}
&:disabled {
@@ -180,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 (
+2 -2
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { changeLanguage } from "~/utils/language";
@@ -9,7 +9,7 @@ type Props = {
export default function ChangeLanguage({ locale }: Props) {
const { i18n } = useTranslation();
useEffect(() => {
React.useEffect(() => {
void changeLanguage(locale, i18n);
}, [locale, i18n]);
+1
View File
@@ -1,3 +1,4 @@
import React from "react";
import styled, { useTheme } from "styled-components";
const cleanPercentage = (percentage: number) => {
+64 -101
View File
@@ -3,18 +3,15 @@ import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useState, useMemo, useEffect, useCallback } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
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 Popover from "~/components/Popover";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -34,93 +31,58 @@ function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]);
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
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(
() => new Set(documentPresenceArray.map((p) => p.userId)),
[documentPresenceArray]
);
const editingIds = useMemo(
() =>
new Set(
documentPresenceArray.filter((p) => p.isEditing).map((p) => p.userId)
),
[documentPresenceArray]
);
const presentIds = documentPresenceArray.map((p) => p.userId);
const editingIds = documentPresenceArray
.filter((p) => p.isEditing)
.map((p) => p.userId);
// ensure currently present via websocket are always ordered first
// Memoize collaboratorIds as a Set for efficient lookup
const collaboratorIdsSet = useMemo(
() => new Set(document.collaboratorIds),
[document.collaboratorIds]
);
const collaborators = useMemo(
const collaborators = React.useMemo(
() =>
orderBy(
filter(
users.all,
(u) =>
(presentIds.has(u.id) || collaboratorIdsSet.has(u.id)) &&
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
!u.isSuspended
),
[(u) => presentIds.has(u.id), "id"],
[(u) => presentIds.includes(u.id), "id"],
["asc", "asc"]
),
[collaboratorIdsSet, users.all, presentIds]
[document.collaboratorIds, users.all, presentIds]
);
// load any users we don't yet have in memory
// Memoize ids to avoid unnecessary effect executions
const missingUserIds = useMemo(
() =>
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
.filter((userId) => !users.get(userId))
.sort(),
[document.collaboratorIds, presentIds, users]
);
React.useEffect(() => {
const ids = uniq([...document.collaboratorIds, ...presentIds])
.filter((userId) => !users.get(userId))
.sort();
useEffect(() => {
if (
!isEqual(requestedUserIds, missingUserIds) &&
missingUserIds.length > 0
) {
setRequestedUserIds(missingUserIds);
void users.fetchPage({ ids: missingUserIds, limit: 100 });
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
setRequestedUserIds(ids);
void users.fetchPage({ ids, limit: 100 });
}
}, [missingUserIds, requestedUserIds, users]);
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
// Memoize onClick handler to avoid inline function creation
const handleAvatarClick = useCallback(
(
collaboratorId: string,
isPresent: boolean,
isObserving: boolean,
isObservable: boolean
) =>
(ev: React.MouseEvent) => {
if (isObservable && isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(isObserving ? undefined : collaboratorId);
}
},
[ui]
);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
});
const renderAvatar = useCallback(
const renderAvatar = React.useCallback(
({ model: collaborator, ...rest }) => {
const isPresent = presentIds.has(collaborator.id);
const isEditing = editingIds.has(collaborator.id);
const isObserving = observingUserId === collaborator.id;
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isObservable = collaborator.id !== currentUserId;
return (
@@ -132,47 +94,48 @@ function Collaborators(props: Props) {
isEditing={isEditing}
isObserving={isObserving}
isCurrentUser={currentUserId === collaborator.id}
alt={t("Avatar of {{ name }}", { name: collaborator.name })}
onClick={
isObservable
? handleAvatarClick(
collaborator.id,
isPresent,
isObserving,
isObservable
)
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
: undefined
}
/>
);
},
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
[presentIds, ui, currentUserId, editingIds]
);
if (!document.insightsEnabled) {
return null;
}
return (
<Popover>
<PopoverTrigger>
<NudeButton
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
>
<Facepile
size={AvatarSize.Large}
limit={limit}
overflow={Math.max(0, collaborators.length - limit)}
users={collaborators}
renderAvatar={renderAvatar}
/>
</NudeButton>
</PopoverTrigger>
<PopoverContent aria-label={t("Viewers")} side="bottom" align="end">
<DocumentViews document={document} />
</PopoverContent>
</Popover>
<>
<PopoverDisclosure {...popover}>
{(popoverProps) => (
<NudeButton
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
height={AvatarSize.Large}
{...popoverProps}
>
<Facepile
size={AvatarSize.Large}
limit={limit}
overflow={Math.max(0, collaborators.length - limit)}
users={collaborators}
renderAvatar={renderAvatar}
/>
</NudeButton>
)}
</PopoverDisclosure>
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
</Popover>
</>
);
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useCallback } from "react";
import * as React from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { CollectionForm, FormData } from "./CollectionForm";
@@ -16,7 +16,7 @@ export const CollectionEdit = observer(function CollectionEdit_({
const { collections } = useStores();
const collection = collections.get(collectionId);
const handleSubmit = useCallback(
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
await collection?.save(data);
+23 -55
View File
@@ -1,12 +1,12 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useMemo, useEffect, useCallback, Suspense } from "react";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission, TeamPreference } from "@shared/types";
import { CollectionPermission } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
@@ -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";
@@ -31,7 +31,6 @@ export interface FormData {
color: string | null;
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
}
const useIconColor = (collection?: Collection) => {
@@ -41,7 +40,7 @@ const useIconColor = (collection?: Collection) => {
collections.orderedData.map((c) => c.color).filter(Boolean)
) as string[];
const iconColor = useMemo(
const iconColor = React.useMemo(
() =>
collection?.color ??
// If all the existing collections have the same color, use that color,
@@ -67,13 +66,7 @@ export const CollectionForm = observer(function CollectionForm_({
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const iconColor = useIconColor(collection);
const fallbackIcon = (
<Icon
value="collection"
initial={collection?.initial ?? "?"}
color={iconColor}
/>
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
register,
@@ -90,7 +83,6 @@ export const CollectionForm = observer(function CollectionForm_({
icon: collection?.icon,
sharing: collection?.sharing ?? true,
permission: collection?.permission,
commenting: collection?.commenting ?? true,
color: iconColor,
},
});
@@ -98,11 +90,11 @@ export const CollectionForm = observer(function CollectionForm_({
const values = watch();
// Preload the IconPicker component on mount
useEffect(() => {
React.useEffect(() => {
void IconPicker.preload();
}, []);
useEffect(() => {
React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
if (!hasOpenedIconPicker && !collection) {
@@ -115,12 +107,12 @@ export const CollectionForm = observer(function CollectionForm_({
}
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
useEffect(() => {
React.useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconChange = useCallback(
(icon: string, color: string) => {
const handleIconChange = React.useCallback(
(icon: string, color: string | null) => {
if (icon !== values.icon) {
setFocus("name");
}
@@ -131,14 +123,13 @@ 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">
<Trans>
Collections are used to group documents and choose permissions
</Trans>
.
</Text>
<Flex gap={8}>
<Input
@@ -149,16 +140,16 @@ export const CollectionForm = observer(function CollectionForm_({
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<Suspense fallback={fallbackIcon}>
<React.Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={initial}
initial={values.name[0]}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</Suspense>
</React.Suspense>
}
autoComplete="off"
autoFocus
@@ -180,7 +171,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."
)}
/>
@@ -189,36 +180,13 @@ 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}
/>
)}
/>
)}
{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="sharing"
label={t("Public document sharing")}
note={t(
"Allow documents within this collection to be shared publicly on the internet."
)}
{...register("sharing")}
/>
)}
@@ -232,8 +200,8 @@ export const CollectionForm = observer(function CollectionForm_({
? `${t("Saving")}`
: t("Save")
: formState.isSubmitting
? `${t("Creating")}`
: t("Create")}
? `${t("Creating")}`
: t("Create")}
</Button>
</Flex>
</form>
+2 -2
View File
@@ -1,6 +1,6 @@
import { runInAction } from "mobx";
import { observer } from "mobx-react";
import { useCallback } from "react";
import * as React from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -14,7 +14,7 @@ export const CollectionNew = observer(function CollectionNew_({
onSubmit,
}: Props) {
const { collections } = useStores();
const handleSubmit = useCallback(
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
const collection = await collections.save(data);
+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 { createInternalLinkAction } 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(
() => [
createInternalLinkAction({
name: t("Archive"),
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: collection.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection),
}),
],
[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 />;
};
@@ -1,4 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
-63
View File
@@ -1,63 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import NudeButton from "./NudeButton";
import { hover, s } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** The current color value in hex format. If no color is passed a radial gradient will be shown */
color?: string;
/** Whether the button is currently active/selected */
active?: boolean;
/** The size of the button in pixels */
size?: number;
};
export const ColorButton = React.forwardRef(
(
{ color, active = false, size = 24, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) => (
<ColorButtonInternal
$active={active}
$size={size}
{...rest}
style={{ "--color": color, ...rest.style } as React.CSSProperties}
ref={ref}
>
<Selected />
</ColorButtonInternal>
)
);
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButtonInternal = styled(NudeButton)<{
$active: boolean;
$size: number;
}>`
display: inline-flex;
justify-content: center;
align-items: center;
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
border-radius: 50%;
background: var(
--color,
linear-gradient(135deg, #ff5858 0%, #fbcc34 50%, #00c6ff 100%)
);
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: 0px 0px 3px 3px var(--color);
}
& ${Selected} {
display: ${({ $active }) => ($active ? "block" : "none")};
}
`;
@@ -1,4 +1,5 @@
import { useMatches, KBarResults } from "kbar";
import * as React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import CommandBarItem from "./CommandBarItem";
@@ -1,34 +1,31 @@
import { DocumentIcon } from "outline-icons";
import { useMemo } from "react";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { createInternalLinkAction } from "~/actions";
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) => {
const { documents, ui } = useStores();
return useMemo(
return React.useMemo(
() =>
documents.recentlyViewed
.filter((document) => document.id !== ui.activeDocumentId)
.slice(0, count)
.map((item) =>
createInternalLinkAction({
createAction({
name: item.titleWithDefault,
analyticsName: "Recently viewed document",
section: RecentSection,
icon: item.icon ? (
<Icon
value={item.icon}
initial={item.initial}
color={item.color ?? undefined}
/>
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon />
),
to: documentPath(item),
perform: () => history.push(documentPath(item)),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed]
@@ -1,29 +1,30 @@
import { SettingsIcon } from "outline-icons";
import { useMemo } from "react";
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
import * as React 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();
const actions = useMemo(
const actions = React.useMemo(
() =>
config.map((item) => {
const Icon = item.icon;
return createInternalLinkAction({
return {
id: item.path,
name: item.name,
icon: <Icon />,
section: NavigationSection,
to: item.path,
});
perform: () => history.push(item.path),
};
}),
[config]
);
const navigateToSettings = useMemo(
const navigateToSettings = React.useMemo(
() =>
createActionWithChildren({
createAction({
id: "settings",
name: ({ t }) => t("Settings"),
section: NavigationSection,
@@ -1,37 +1,34 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import { useEffect, useMemo } from "react";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
DocumentSection,
TeamSection,
} from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { documents } = useStores();
useEffect(() => {
React.useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
const actions = useMemo(
const actions = React.useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
createInternalLinkAction({
createAction({
name: template.titleWithDefault,
analyticsName: "New document",
section: template.isWorkspaceTemplate
? TeamSection
: ActiveCollectionSection,
icon: template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<NewDocumentIcon />
),
@@ -50,28 +47,23 @@ const useTemplatesAction = () => {
template.isWorkspaceTemplate
);
},
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(
template.collectionId ?? activeCollectionId,
{
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(
newDocumentPath(template.collectionId ?? activeCollectionId, {
templateId: template.id,
}),
{
sidebarContext,
}
).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
),
})
),
[documents.templatesAlphabetical]
);
const newFromTemplate = useMemo(
const newFromTemplate = React.useMemo(
() =>
createActionWithChildren({
createAction({
id: "templates",
name: ({ t }) => t("New from template"),
placeholder: ({ t }) => t("Choose a template"),
@@ -86,7 +78,7 @@ const useTemplatesAction = () => {
stores.policies.abilities(currentTeamId).createDocument
);
},
children: actions,
children: () => actions,
}),
[actions]
);
+1
View File
@@ -1,4 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Comment from "~/models/Comment";
+1
View File
@@ -1,4 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { CollectionPermission, NavigationNode } from "@shared/types";
+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,5 +1,6 @@
import { observer } from "mobx-react";
import { DisconnectedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -7,7 +8,6 @@ import {
AuthenticationFailed,
AuthorizationFailed,
DocumentTooLarge,
EditorUpdateError,
TooManyConnections,
} from "@shared/collaboration/CloseEvents";
import Fade from "~/components/Fade";
@@ -38,10 +38,6 @@ function ConnectionStatus() {
title: t("Too many users connected to document"),
body: t("Your edits will sync once other users leave the document"),
},
[EditorUpdateError.code]: {
title: t("New version available"),
body: t("Please reload the page to update to the latest version"),
},
};
const message = ui.multiplayerErrorCode
@@ -68,29 +64,24 @@ function ConnectionStatus() {
}
placement="bottom"
>
<Fade>
<Button width="auto">
{message?.title ?? t("Offline")}
<Button>
<Fade>
<DisconnectedIcon />
</Button>
</Fade>
</Fade>
</Button>
</Tooltip>
) : null;
}
const Button = styled(NudeButton)`
display: none;
background: ${(props) => props.theme.backgroundTertiary};
color: ${(props) => props.theme.textSecondary};
font-size: 14px;
font-weight: 500;
padding-left: 6px;
padding-right: 6px;
position: fixed;
bottom: 0;
margin: 20px;
transform: translateX(-32px);
${breakpoint("tablet")`
display: flex;
gap: 4px;
align-items: center;
display: block;
`};
@media print {
+3 -4
View File
@@ -31,7 +31,7 @@ export type RefHandle = {
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
const ContentEditable = React.forwardRef(function ContentEditable_(
const ContentEditable = React.forwardRef(function _ContentEditable(
{
disabled,
onChange,
@@ -143,14 +143,13 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
},
[]
);
const contentEditable = !disabled && !readOnly;
return (
<div className={className} dir={dir} onClick={onClick} tabIndex={-1}>
{children}
<Content
ref={contentRef}
contentEditable={contentEditable}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onFocus={wrappedEvent(onFocus)}
onBlur={wrappedEvent(onBlur)}
@@ -158,7 +157,7 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role={contentEditable ? "textbox" : undefined}
role="textbox"
{...rest}
>
{innerValue}
+13
View File
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Header = styled.h3`
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: ${s("sidebarText")};
letter-spacing: 0.04em;
margin: 1em 12px 0.5em;
`;
export default Header;
@@ -0,0 +1,13 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const MenuIconWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 6px;
margin-left: -4px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
export default MenuIconWrapper;
+217
View File
@@ -0,0 +1,217 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import { ellipsis, transparentize } from "polished";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
type Props = {
id?: string;
onClick?: (event: React.MouseEvent) => void | Promise<void>;
onPointerMove?: (event: React.MouseEvent) => void | Promise<void>;
active?: boolean;
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
href?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
const MenuItem = (
{
onClick,
onPointerMove,
children,
active,
selected,
disabled,
as,
hide,
icon,
...rest
}: Props,
ref: React.Ref<HTMLAnchorElement>
) => {
const content = React.useCallback(
(props) => {
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const preventDefault = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
};
const handleClick = async (ev: React.MouseEvent) => {
hide?.();
if (onClick) {
preventDefault(ev);
await onClick(ev);
}
};
return (
<MenuAnchor
{...props}
$active={active}
as={onClick ? "button" : as}
onClick={handleClick}
onPointerDown={preventDefault}
onMouseDown={preventDefault}
ref={mergeRefs([
ref,
props.ref as React.RefObject<HTMLAnchorElement>,
])}
>
{selected !== undefined && (
<SelectedWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</SelectedWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
</MenuAnchor>
);
},
[active, as, hide, icon, onClick, ref, children, selected]
);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
onPointerMove={disabled ? undefined : onPointerMove}
disabled={disabled}
hide={hide}
{...rest}
>
{content}
</BaseMenuItem>
);
};
const Spacer = styled.svg`
width: 24px;
height: 24px;
flex-shrink: 0;
`;
const Title = styled.div`
${ellipsis()}
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
`;
type MenuAnchorProps = {
level?: number;
disabled?: boolean;
dangerous?: boolean;
disclosure?: boolean;
$active?: boolean;
};
export const MenuAnchorCSS = css<MenuAnchorProps>`
display: flex;
margin: 0;
border: 0;
padding: 12px;
border-radius: 4px;
padding-left: ${(props) => 12 + (props.level || 0) * 10}px;
width: 100%;
min-height: 32px;
background: none;
color: ${(props) =>
props.disabled ? props.theme.textTertiary : props.theme.textSecondary};
justify-content: left;
align-items: center;
font-size: 16px;
cursor: default;
user-select: none;
white-space: nowrap;
position: relative;
svg {
flex-shrink: 0;
opacity: ${(props) => (props.disabled ? ".5" : 1)};
}
${(props) => props.disabled && "pointer-events: none;"}
${(props) =>
props.$active === undefined &&
!props.disabled &&
`
@media (hover: hover) {
&:hover,
&:focus,
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
svg {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
${Text} {
color: ${transparentize(0.5, props.theme.accentText)};
}
}
}
`}
${(props) =>
props.$active &&
!props.disabled &&
`
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
box-shadow: none;
cursor: var(--pointer);
svg {
fill: ${props.theme.accentText};
}
`}
${breakpoint("tablet")`
padding: 4px 12px;
padding-right: ${(props: MenuAnchorProps) =>
props.disclosure ? 32 : 12}px;
font-size: 14px;
`}
`;
export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
@@ -1,8 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useMousePosition } from "~/hooks/useMousePosition";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
type Positions = {
/** Sub-menu x */
@@ -24,7 +21,7 @@ type Positions = {
* allow moving cursor to lower parts of sub-menu without the sub-menu
* disappearing.
*/
export const MouseSafeArea = observer(function MouseSafeArea_(props: {
export default function MouseSafeArea(props: {
parentRef: React.RefObject<HTMLElement | null>;
}) {
const {
@@ -33,32 +30,15 @@ export const MouseSafeArea = observer(function MouseSafeArea_(props: {
height: h = 0,
width: w = 0,
} = props.parentRef.current?.getBoundingClientRect() || {};
const { ui } = useStores();
const [mouseX, mouseY] = useMousePosition();
const [isVisible, setIsVisible] = React.useState(true);
const positions = { x, y, h, w, mouseX, mouseY };
const distance = Math.abs(mouseX - x);
const prevDistance = usePrevious(distance) ?? distance;
// Hide the safe area if the mouse is moving _away_ from the menu
React.useEffect(() => {
if (distance > prevDistance) {
setIsVisible(false);
} else if (distance < prevDistance) {
setIsVisible(true);
}
}, [distance, prevDistance]);
if (!isVisible) {
return null;
}
return (
<div
style={{
position: "absolute",
top: 0,
backgroundColor: ui.debugSafeArea ? "rgba(255,0,0,0.2)" : undefined,
// backgroundColor: "rgba(255,0,0,0.1)", // Uncomment to debug
right: getRight(positions),
left: getLeft(positions),
height: h,
@@ -67,26 +47,24 @@ export const MouseSafeArea = observer(function MouseSafeArea_(props: {
}}
/>
);
});
const buffer = 10;
}
const getLeft = ({ x, mouseX }: Positions) =>
mouseX > x ? undefined : -Math.max(x - mouseX + buffer, buffer) + "px";
mouseX > x ? undefined : -Math.max(x - mouseX, 10) + "px";
const getRight = ({ x, w, mouseX }: Positions) =>
mouseX > x ? -Math.max(mouseX - (x + w) + buffer, buffer) + "px" : undefined;
mouseX > x ? -Math.max(mouseX - (x + w), 10) + "px" : undefined;
const getWidth = ({ x, w, mouseX }: Positions) =>
mouseX > x
? Math.max(mouseX - (x + w - buffer), buffer) + "px"
: Math.max(x - mouseX + buffer, buffer) + "px";
? Math.max(mouseX - (x + w), 10) + "px"
: Math.max(x - mouseX, 10) + "px";
const getClipPath = ({ x, y, h, mouseX, mouseY }: Positions) =>
mouseX > x
? `polygon(0% 0%, 0% 100%, 100% ${
? `polygon(0% 0%, 0% 100%, 100% ${(100 * (mouseY - y)) / h - 10}%, 100% ${
(100 * (mouseY - y)) / h + 5
}%, 100% ${(100 * (mouseY - y)) / h - buffer}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - buffer}%, 0% ${
}%)`
: `polygon(100% 0%, 0% ${(100 * (mouseY - y)) / h - 10}%, 0% ${
(100 * (mouseY - y)) / h + 5
}%, 100% 100%)`;
@@ -0,0 +1,20 @@
import { MoreIcon } from "outline-icons";
import * as React from "react";
import { MenuButton } from "reakit/Menu";
import NudeButton from "~/components/NudeButton";
type Props = React.ComponentProps<typeof MenuButton> & {
className?: string;
};
export default function OverflowMenuButton({ className, ...rest }: Props) {
return (
<MenuButton {...rest}>
{(props) => (
<NudeButton className={className} {...props}>
<MoreIcon />
</NudeButton>
)}
</MenuButton>
);
}
+15
View File
@@ -0,0 +1,15 @@
import * as React from "react";
import { MenuSeparator } from "reakit/Menu";
import styled from "styled-components";
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
return (
<MenuSeparator {...rest}>
{(props) => <HorizontalRule {...props} />}
</MenuSeparator>
);
}
const HorizontalRule = styled.hr`
margin: 6px 0;
`;
+254
View File
@@ -0,0 +1,254 @@
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import {
useMenuState,
MenuButton,
MenuItem as BaseMenuItem,
MenuStateReturn,
} from "reakit/Menu";
import styled, { useTheme } from "styled-components";
import MenuIconWrapper from "~/components/ContextMenu/MenuIconWrapper";
import Flex from "~/components/Flex";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import {
Action,
ActionContext,
MenuSeparator,
MenuHeading,
MenuItem as TMenuItem,
} from "~/types";
import Tooltip from "../Tooltip";
import Header from "./Header";
import MenuItem, { MenuAnchor } from "./MenuItem";
import MouseSafeArea from "./MouseSafeArea";
import Separator from "./Separator";
import ContextMenu from ".";
type Props = Omit<MenuStateReturn, "items"> & {
actions?: (Action | MenuSeparator | MenuHeading)[];
context?: Partial<ActionContext>;
items?: TMenuItem[];
showIcons?: boolean;
};
const Disclosure = styled(ExpandedIcon)`
transform: rotate(270deg);
position: absolute;
right: 8px;
`;
type SubMenuProps = MenuStateReturn & {
templateItems: TMenuItem[];
parentMenuState: Omit<MenuStateReturn, "items">;
title: React.ReactNode;
};
const SubMenu = React.forwardRef(function _Template(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState();
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
.filter((item) => item.visible !== false)
.reduce((acc, item) => {
// trim separator if the previous item was a separator
if (
item.type === "separator" &&
acc[acc.length - 1]?.type === "separator"
) {
return acc;
}
return [...acc, item];
}, [] as TMenuItem[])
.filter((item, index, arr) => {
if (
item.type === "separator" &&
(index === 0 || index === arr.length - 1)
) {
return false;
}
return true;
});
}
function Template({ items, actions, context, showIcons, ...menu }: Props) {
const ctx = useActionContext({
isContextMenu: true,
});
const templateItems = actions
? actions.map((item) =>
item.type === "separator" || item.type === "heading"
? item
: actionToMenuItem(item, ctx)
)
: items || [];
const filteredTemplates = filterTemplateItems(templateItems);
const iconIsPresentInAnyMenuItem = filteredTemplates.find(
(item) =>
item.type !== "separator" && item.type !== "heading" && !!item.icon
);
return (
<>
{filteredTemplates.map((item, index) => {
if (
iconIsPresentInAnyMenuItem &&
item.type !== "separator" &&
item.type !== "heading" &&
showIcons !== false
) {
item.icon = item.icon || <MenuIconWrapper aria-hidden />;
}
if (item.type === "route") {
return (
<MenuItem
as={Link}
id={`${item.title}-${index}`}
to={item.to}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "link") {
return (
<MenuItem
id={`${item.title}-${index}`}
href={item.href}
key={`${item.type}-${item.title}-${index}`}
disabled={item.disabled}
selected={item.selected}
level={item.level}
target={item.href.startsWith("#") ? undefined : "_blank"}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
}
if (item.type === "button") {
const menuItem = (
<MenuItem
as="button"
id={`${item.title}-${index}`}
onClick={item.onClick}
disabled={item.disabled}
selected={item.selected}
dangerous={item.dangerous}
key={`${item.type}-${item.title}-${index}`}
icon={showIcons !== false ? item.icon : undefined}
{...menu}
>
{item.title}
</MenuItem>
);
return item.tooltip ? (
<Tooltip
content={item.tooltip}
placement={"bottom"}
key={`tooltip-${item.title}-${index}`}
>
<div>{menuItem}</div>
</Tooltip>
) : (
<React.Fragment key={`${item.type}-${item.title}-${index}`}>
{menuItem}
</React.Fragment>
);
}
if (item.type === "submenu") {
// Skip rendering empty submenus
return item.items.length > 0 ? (
<BaseMenuItem
key={`${item.type}-${item.title}-${index}`}
as={SubMenu}
id={`${item.title}-${index}`}
templateItems={item.items}
parentMenuState={menu}
title={
<Title
title={item.title}
icon={showIcons !== false ? item.icon : undefined}
/>
}
{...menu}
/>
) : null;
}
if (item.type === "separator") {
return <Separator key={`separator-${index}`} />;
}
if (item.type === "heading") {
return (
<Header key={`heading-${item.title}-${index}`}>{item.title}</Header>
);
}
const _exhaustiveCheck: never = item;
return _exhaustiveCheck;
})}
</>
);
}
function Title({
title,
icon,
}: {
title: React.ReactNode;
icon?: React.ReactNode;
}) {
return (
<Flex align="center">
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
{title}
</Flex>
);
}
export default React.memo<Props>(Template);
+315
View File
@@ -0,0 +1,315 @@
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuStateReturn } from "reakit/Menu";
import styled, { DefaultTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import Scrollable from "~/components/Scrollable";
import useEventListener from "~/hooks/useEventListener";
import useMenuContext from "~/hooks/useMenuContext";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import useUnmount from "~/hooks/useUnmount";
import {
fadeIn,
fadeAndSlideUp,
fadeAndSlideDown,
mobileContextMenu,
} from "~/styles/animations";
export type Placement =
| "auto-start"
| "auto"
| "auto-end"
| "top-start"
| "top"
| "top-end"
| "right-start"
| "right"
| "right-end"
| "bottom-end"
| "bottom"
| "bottom-start"
| "left-end"
| "left"
| "left-start";
type Props = MenuStateReturn & {
"aria-label"?: string;
/** Reference to the rendered menu div element */
menuRef?: React.RefObject<HTMLDivElement>;
/** The parent menu state if this is a submenu. */
parentMenuState?: Omit<MenuStateReturn, "items">;
/** Called when the context menu is opened. */
onOpen?: () => void;
/** Called when the context menu is closed. */
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode;
};
const ContextMenu: React.FC<Props> = ({
menuRef,
children,
onOpen,
onClose,
parentMenuState,
...rest
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const { ui } = useStores();
const { t } = useTranslation();
const { setIsMenuOpen } = useMenuContext();
const isMobile = useMobile();
const isSubMenu = !!parentMenuState;
useUnmount(() => {
setIsMenuOpen(false);
});
React.useEffect(() => {
if (rest.visible && !previousVisible) {
onOpen?.();
if (!isSubMenu) {
setIsMenuOpen(true);
}
}
if (!rest.visible && previousVisible) {
onClose?.();
if (!isSubMenu) {
setIsMenuOpen(false);
}
}
}, [
onOpen,
onClose,
previousVisible,
rest.visible,
ui.sidebarCollapsed,
setIsMenuOpen,
isSubMenu,
t,
]);
// Perf win don't render anything until the menu has been opened
if (!rest.visible && !previousVisible) {
return null;
}
// sets the menu height based on the available space between the disclosure/
// trigger and the bottom of the window
return (
<>
<Menu
ref={menuRef}
hideOnClickOutside={!isMobile}
preventBodyScroll={false}
{...rest}
>
{(props) => (
<InnerContextMenu
// eslint-disable-next-line @typescript-eslint/no-explicit-any
menuProps={props as any}
{...rest}
isSubMenu={isSubMenu}
>
{children}
</InnerContextMenu>
)}
</Menu>
</>
);
};
type InnerContextMenuProps = MenuStateReturn & {
isSubMenu: boolean;
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
* Inner context menu allows deferring expensive window measurement hooks etc
* until the menu is actually opened.
*/
const InnerContextMenu = (props: InnerContextMenuProps) => {
const { menuProps } = props;
// kind of hacky, but this is an effective way of telling which way
// the menu will _actually_ be placed when taking into account screen
// positioning.
const topAnchor =
menuProps.style?.top === "0" || menuProps.style?.position === "fixed";
const rightAnchor = menuProps.placement === "bottom-end";
const backgroundRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMobile();
const maxHeight = useMenuHeight({
visible: props.visible,
elementRef: props.unstable_disclosureRef,
});
// We must manually manage scroll lock for iOS support so that the scrollable
// element can be passed into body-scroll-lock. See:
// https://github.com/ariakit/ariakit/issues/469
React.useEffect(() => {
const scrollElement = backgroundRef.current;
if (props.visible && scrollElement && !props.isSubMenu) {
disableBodyScroll(scrollElement, {
reserveScrollBarGap: true,
});
}
return () => {
scrollElement && !props.isSubMenu && enableBodyScroll(scrollElement);
};
}, [props.isSubMenu, props.visible]);
useEventListener(
"animationstart",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "none";
}
}
},
backgroundRef.current
);
useEventListener(
"animationend",
(event) => {
if (event.target instanceof HTMLElement) {
const parent = event.target.parentElement;
if (parent) {
parent.style.pointerEvents = "auto";
}
}
},
backgroundRef.current
);
const style =
topAnchor && !isMobile
? {
maxHeight,
}
: undefined;
return (
<>
{isMobile && (
<Backdrop
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
props.hide?.();
}}
/>
)}
<Position {...menuProps}>
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
hiddenScrollbars
style={style}
>
{props.visible || props.animating ? props.children : null}
</Background>
</Position>
</>
);
};
export default ContextMenu;
export const Backdrop = styled.div`
animation: ${fadeIn} 200ms ease-in-out;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${s("backdrop")};
z-index: ${depths.menu - 1};
`;
export const Position = styled.div`
position: absolute;
z-index: ${depths.menu};
// Note: pointer events are re-enabled after the animation ends, see event listeners above
pointer-events: none;
&:focus-visible {
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
}
/*
* overrides make mobile-first coding style challenging
* so we explicitly define mobile breakpoint here
*/
${breakpoint("mobile", "tablet")`
position: fixed !important;
transform: none !important;
top: auto !important;
right: 8px !important;
bottom: 16px !important;
left: 8px !important;
`};
`;
type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
export const Background = styled(Scrollable)<BackgroundProps>`
animation: ${mobileContextMenu} 200ms ease;
transform-origin: 50% 100%;
max-width: 100%;
background: ${s("menuBackground")};
border-radius: 6px;
padding: 6px;
min-width: 180px;
min-height: ${(props) => props.minHeight || 44}px;
max-height: 75vh;
font-weight: normal;
@media print {
display: none;
}
${breakpoint("tablet")`
animation: ${(props: BackgroundProps) =>
props.topAnchor ? fadeAndSlideDown : fadeAndSlideUp} 200ms ease;
transform-origin: ${(props: BackgroundProps) =>
props.rightAnchor ? "75%" : "25%"} 0;
max-width: ${(props: BackgroundProps) => props.maxWidth ?? 276}px;
max-height: 100vh;
background: ${(props: BackgroundProps) => props.theme.menuBackground};
box-shadow: ${(props: BackgroundProps) => props.theme.menuShadow};
`};
`;
+3 -7
View File
@@ -15,7 +15,7 @@ function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) {
const onClick = React.useCallback(
(ev: React.MouseEvent<HTMLElement>) => {
const childElem = React.Children.only(children);
const elem = React.Children.only(children);
copy(text, {
debug: env.ENVIRONMENT !== "production",
@@ -24,12 +24,8 @@ function CopyToClipboard(props: Props, ref: React.Ref<HTMLElement>) {
onCopy?.();
if (
childElem &&
childElem.props &&
typeof childElem.props.onClick === "function"
) {
childElem.props.onClick(ev);
if (elem && elem.props && typeof elem.props.onClick === "function") {
elem.props.onClick(ev);
} else {
ev.preventDefault();
ev.stopPropagation();
@@ -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
+3 -3
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import * as React from "react";
type Props = {
delay?: number;
@@ -6,9 +6,9 @@ type Props = {
};
export default function DelayedMount({ delay = 250, children }: Props) {
const [isShowing, setShowing] = useState(false);
const [isShowing, setShowing] = React.useState(false);
useEffect(() => {
React.useEffect(() => {
const timeout = setTimeout(() => setShowing(true), delay);
return () => {
clearTimeout(timeout);
+3 -3
View File
@@ -1,4 +1,4 @@
import { useRef, useEffect } from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
@@ -12,9 +12,9 @@ export default function DesktopEventHandler() {
const { t } = useTranslation();
const history = useHistory();
const { dialogs } = useStores();
const hasDisabledUpdateMessage = useRef(false);
const hasDisabledUpdateMessage = React.useRef(false);
useEffect(() => {
React.useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => {
if (replace) {
history.replace(path);
+7 -12
View File
@@ -1,18 +1,14 @@
import { observer } from "mobx-react";
import { Suspense } from "react";
import * as React from "react";
import Guide from "~/components/Guide";
import Modal from "~/components/Modal";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
const Guide = lazyWithRetry(() => import("~/components/Guide"));
const Modal = lazyWithRetry(() => import("~/components/Modal"));
function Dialogs() {
const { dialogs } = useStores();
const { guide, modalStack } = dialogs;
const modals = [...modalStack];
return (
<Suspense fallback={null}>
<>
{guide ? (
<Guide
isOpen={guide.isOpen}
@@ -22,23 +18,22 @@ 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);
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{modal.content}
</Modal>
))}
</Suspense>
</>
);
}
+90 -84
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 { createInternalLinkAction } 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,96 +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 = [
createInternalLinkAction({
name: t("Trash"),
section: ActiveDocumentSection,
icon: <TrashIcon />,
visible: document.isDeleted,
to: trashPath(),
}),
createInternalLinkAction({
name: t("Archive"),
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
name: t("Templates"),
section: ActiveDocumentSection,
icon: <ShapesIcon />,
visible: document.template,
to: settingsPath("templates"),
}),
createInternalLinkAction({
name: collection?.name,
section: ActiveDocumentSection,
icon: collection ? (
<CollectionIcon collection={collection} expanded />
) : undefined,
visible: !!(collection && can.readDocument),
to: collection
? {
pathname: collection.path,
state: { sidebarContext },
}
: "",
}),
createInternalLinkAction({
name: t("Deleted Collection"),
section: ActiveDocumentSection,
visible: document.isCollectionDeleted,
to: "",
}),
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkAction({
name: node.icon ? (
<>
<StyledIcon
value={node.icon}
color={node.color}
initial={node.title.charAt(0).toUpperCase()}
/>{" "}
{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;
@@ -170,7 +176,7 @@ function DocumentBreadcrumb(
}
return (
<Breadcrumb actions={actions} ref={ref} highlightFirstItem>
<Breadcrumb items={items} ref={ref} highlightFirstItem>
{children}
</Breadcrumb>
);
+27 -26
View File
@@ -3,8 +3,8 @@ import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { useRef, useCallback, Suspense } from "react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -19,12 +19,10 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
import lazyWithRetry from "~/utils/lazyWithRetry";
const ReadingTime = lazyWithRetry(() => import("./ReadingTime"));
type Props = {
/** The pin record */
@@ -42,7 +40,7 @@ function DocumentCard(props: Props) {
const { collections } = useStores();
const theme = useTheme();
const { document, pin, canUpdatePin, isDraggable } = props;
const pinnedToHome = useRef(!pin?.collectionId).current;
const pinnedToHome = React.useRef(!pin?.collectionId).current;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -65,7 +63,7 @@ function DocumentCard(props: Props) {
transition,
};
const handleUnpin = useCallback(
const handleUnpin = React.useCallback(
async (ev) => {
ev.preventDefault();
ev.stopPropagation();
@@ -78,13 +76,6 @@ function DocumentCard(props: Props) {
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
const updatedAt = (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
);
return (
<Reorderable
ref={setNodeRef}
@@ -132,7 +123,6 @@ function DocumentCard(props: Props) {
<DocumentSquircle
icon={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
) : (
<Squircle
@@ -159,11 +149,12 @@ function DocumentCard(props: Props) {
</Heading>
<DocumentMeta size="xsmall">
{isRecentlyUpdated ? (
updatedAt
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
) : (
<Suspense fallback={updatedAt}>
<ReadingTime document={document} />
</Suspense>
<ReadingTime document={document} />
)}
</DocumentMeta>
</div>
@@ -185,25 +176,35 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
initial,
color,
}: {
icon: string;
initial: string;
color?: 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>
);
};
+4 -15
View File
@@ -1,5 +1,5 @@
import { action, computed, observable } from "mobx";
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
import React, { PropsWithChildren } from "react";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import Document from "~/models/Document";
import { Editor } from "~/editor";
@@ -11,15 +11,9 @@ class DocumentContext {
/** The editor instance for this document */
editor?: Editor;
/** The ID of the currently focused comment, or null if no comment is focused */
@observable
focusedCommentId: string | null = null;
/** Whether the editor has been initialized */
@observable
isEditorInitialized: boolean = false;
/** The headings in the document */
@observable
headings: Heading[] = [];
@@ -45,11 +39,6 @@ class DocumentContext {
this.isEditorInitialized = initialized;
};
@action
setFocusedCommentId = (commentId: string | null) => {
this.focusedCommentId = commentId;
};
@action
updateState = () => {
this.updateHeadings();
@@ -75,10 +64,10 @@ class DocumentContext {
}
}
const Context = createContext<DocumentContext | null>(null);
const Context = React.createContext<DocumentContext | null>(null);
export const useDocumentContext = () => {
const ctx = useContext(Context);
const ctx = React.useContext(Context);
if (!ctx) {
throw new Error(
"useDocumentContext must be used within DocumentContextProvider"
@@ -90,6 +79,6 @@ export const useDocumentContext = () => {
export const DocumentContextProvider = ({
children,
}: PropsWithChildren<unknown>) => {
const context = useMemo(() => new DocumentContext(), []);
const context = React.useMemo(() => new DocumentContext(), []);
return <Context.Provider value={context}>{children}</Context.Provider>;
};
+49 -33
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
@@ -44,6 +46,20 @@ function DocumentCopy({ document, onSubmit }: Props) {
return nodes;
}, [policies, collectionTrees, document.isTemplate]);
const handlePublishChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setPublish(ev.target.checked);
},
[]
);
const handleRecursiveChange = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setRecursive(ev.target.checked);
},
[]
);
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
@@ -63,7 +79,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
toast.success(t("Document copied"));
onSubmit(result);
} catch (_err) {
} catch (err) {
toast.error(t("Couldnt copy the document, try again?"));
}
};
@@ -76,32 +92,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={handlePublishChange}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={handleRecursiveChange}
/>
</Text>
)}
</>
)}
</OptionsContainer>
<Footer justify="space-between" align="center" gap={8}>
<StyledText type="secondary">
{selectedPath ? (
@@ -123,11 +141,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);
+36 -38
View File
@@ -3,7 +3,6 @@ import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import flatten from "lodash/flatten";
import includes from "lodash/includes";
import map from "lodash/map";
import { observer } from "mobx-react";
@@ -16,9 +15,8 @@ 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 { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
@@ -28,6 +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 } from "~/utils/tree";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
@@ -49,13 +48,8 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [searchTerm, setSearchTerm] = React.useState<string>();
const [selectedNode, selectNode] = React.useState<NavigationNode | null>(
() => {
if (!defaultValue) {
return null;
}
// Search through all nodes in the tree, not just top-level items
const allNodes = flatten(items.map(flattenTree));
const node = allNodes.find((item) => item.id === defaultValue);
const node =
defaultValue && items.find((item) => item.id === defaultValue);
return node || null;
}
);
@@ -64,11 +58,9 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
// Search through all nodes in the tree, not just top-level items
const allNodes = flatten(items.map(flattenTree));
const node = allNodes.find((item) => item.id === defaultValue);
const node = items.find((item) => item.id === defaultValue);
if (node) {
return ancestors(node).map((ancestorNode) => ancestorNode.id);
return ancestors(node).map((node) => node.id);
}
}
return [];
@@ -86,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]
@@ -103,28 +99,14 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
}, [searchTerm]);
React.useEffect(() => {
setItemRefs((existingItemRefs) =>
setItemRefs((itemRefs) =>
map(
fill(Array(items.length), 0),
(_, i) => existingItemRefs[i] || React.createRef()
(_, i) => itemRefs[i] || React.createRef()
)
);
}, [items.length]);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
? [item, ...descendants(item, 1).flatMap(includeDescendants)]
: [item];
}
return searchTerm
? searchIndex.search(searchTerm)
: items.flatMap(includeDescendants);
}
const nodes = getNodes();
React.useEffect(() => {
onSelect(selectedNode);
}, [selectedNode, onSelect]);
@@ -136,12 +118,30 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
}, [defaultValue, selectedNode, nodes]);
}, []);
function getNodes() {
function includeDescendants(item: NavigationNode): NavigationNode[] {
return expandedNodes.includes(item.id)
? [item, ...descendants(item, 1).flatMap(includeDescendants)]
: [item];
}
return searchTerm
? searchIndex.search(searchTerm)
: items
.filter((item) => recentlyViewedItemIds.includes(item.id))
.concat(
items.filter((item) => item.type === NavigationNodeType.Collection)
)
.flatMap(includeDescendants);
}
const nodes = getNodes();
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const scrollNodeIntoView = React.useCallback(
(node: number) => {
@@ -180,7 +180,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
);
// remove children
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
const newNodes = filter(nodes, (node) => !includes(descendantIds, node.id));
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
@@ -268,9 +268,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
title = doc?.title ?? node.title;
if (icon) {
renderedIcon = (
<Icon value={icon} initial={node.title} color={color} />
);
renderedIcon = <Icon value={icon} color={color} />;
} else if (doc?.isStarred) {
renderedIcon = <StarredIcon color={theme.yellow} />;
} else {
@@ -317,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]}
/>
+73 -105
View File
@@ -25,10 +25,6 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
import useStores from "~/hooks/useStores";
type Props = {
document: Document;
@@ -37,6 +33,7 @@ type Props = {
showParentDocuments?: boolean;
showCollection?: boolean;
showPublished?: boolean;
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
@@ -54,7 +51,6 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const { userMemberships, groupMemberships } = useStores();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
@@ -72,6 +68,7 @@ function DocumentListItem(
showParentDocuments,
showCollection,
showPublished,
showPin,
showDraft = true,
showTemplate,
highlight,
@@ -83,110 +80,85 @@ function DocumentListItem(
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived && !document.isTemplate;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
groupMemberships.getByDocumentId(document.id)
);
const sidebarContext = determineSidebarContext({
document,
user,
currentContext: locationSidebarContext,
});
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
return (
<ActionContextProvider
value={{
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
<DocumentLink
ref={itemRef}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<ContextMenu
action={contextMenuAction}
ariaLabel={t("Document options")}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
>
<DocumentLink
ref={itemRef}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
state: {
title: document.titleWithDefault,
sidebarContext,
},
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon value={document.icon} color={document.color ?? undefined} />
&nbsp;
</>
)}
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
/>
</Actions>
</DocumentLink>
</ContextMenu>
</ActionContextProvider>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
showPin={showPin}
onOpen={handleMenuOpen}
onClose={handleMenuClose}
modal={false}
/>
</Actions>
</DocumentLink>
);
}
@@ -205,9 +177,7 @@ const Actions = styled(EventBoundary)`
color: ${s("textSecondary")};
${NudeButton} {
&:
${hover},
&[aria-expanded= "true"] {
&: ${hover}, &[aria-expanded= "true"] {
background: ${s("sidebarControlHoverBackground")};
}
}
@@ -280,7 +250,7 @@ const DocumentLink = styled(Link)<{
`}
`;
const Heading = styled.span<{ rtl?: boolean }>`
const Heading = styled.h3<{ rtl?: boolean }>`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
@@ -290,8 +260,6 @@ const Heading = styled.span<{ rtl?: boolean }>`
color: ${s("text")};
font-family: ${s("fontFamily")};
font-weight: 500;
font-size: 20px;
line-height: 1.2;
`;
const StarPositioner = styled(Flex)`

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