mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3187619328 | |||
| 4a8b8d5fa7 | |||
| 391fc5fdee | |||
| cbcf7d6a8e | |||
| 94eb1aa07d | |||
| ca66a6b2fa | |||
| 404a5991b3 | |||
| bd5de2e185 | |||
| b8eefe4b78 | |||
| cc8a3d8b5e | |||
| dd061790a8 | |||
| 7fddd99c28 | |||
| 7ab7e6efb7 | |||
| 4464d3c8b4 | |||
| e0f40f9bc1 | |||
| c83a6b4f41 | |||
| 82994c7b7b | |||
| e891de7f49 | |||
| fc2648becf | |||
| ff548eae5c | |||
| 866a7f264b | |||
| 8fcb629bdf | |||
| 99655c65d4 | |||
| 5e176415ab | |||
| 34555bce86 | |||
| dcd7a050bd | |||
| dc0df7c7e9 | |||
| e2c8ee7b54 | |||
| d2a0ddab12 | |||
| 31b254ff09 | |||
| 025af4f9fd | |||
| 221169db51 | |||
| d2a50256b0 | |||
| 6bc80720c9 | |||
| 23106bfce8 | |||
| e8046f0d2f | |||
| ba8ade0244 | |||
| 119eb92f27 | |||
| c26a75af27 | |||
| 10f508a8dd | |||
| d872293551 | |||
| e419aa6c3a | |||
| ba60d4bc0a | |||
| b1dffc3486 | |||
| 4fc6ac1f15 | |||
| 289302fd2e | |||
| 00db4010d0 | |||
| bc14699994 | |||
| 013a7a6d39 | |||
| c3f93a3e9d | |||
| c5cd4d9335 | |||
| c2f84466df | |||
| 00d7239601 | |||
| 5c070d0428 | |||
| 4822261187 | |||
| cda503e7af | |||
| 45cc5ee20f | |||
| ecf8783af0 | |||
| 7b7af55826 | |||
| 5c0fc87504 | |||
| 93cda9327a | |||
| 888add7920 | |||
| 7dfc4fb57d | |||
| 6ef03d3aa7 | |||
| d5510f4454 | |||
| 5b19f7c711 | |||
| f28f41eb76 | |||
| 80b9fb1daa | |||
| 80c547c1fc | |||
| 04c3d81b1f | |||
| f41f93d6c9 | |||
| 74acf9601b | |||
| 5bdabc13a6 | |||
| 6a6e77b52a | |||
| a97908518a | |||
| a48aabdd66 | |||
| 84f45b7fb7 | |||
| 9bb802c35c | |||
| 8ad097aef2 | |||
| 0cfd968f1e | |||
| c6fc6230ff | |||
| ca2258ecc7 | |||
| bf87f2dfee | |||
| b4757dfc6c | |||
| b028b541a8 | |||
| 8dc6bcba22 | |||
| 2f418e7c2e | |||
| 02e5c7663b | |||
| ca0c7d256b | |||
| 2a16d76cb3 | |||
| 3c251adf96 | |||
| 129f8f0520 | |||
| 6cde7fed51 | |||
| 34059ce0ef | |||
| 08a0b72706 | |||
| 6c2313919b | |||
| 73f079f32b | |||
| d3eb3db7ba | |||
| f7826c7d91 | |||
| 09e835d935 | |||
| a139d599b7 | |||
| 114e240958 | |||
| ce3bba9f33 | |||
| 6ee275acc7 | |||
| bbe16f26e4 | |||
| a0308cf809 | |||
| e2d6c89549 | |||
| 1d0c2225d0 | |||
| 6efd23ad41 | |||
| da86f38e6f | |||
| 86e76dbe9b | |||
| a4d59dfc88 | |||
| 481adc6ecb | |||
| 3f4950031b | |||
| 737e7ba4b4 | |||
| af041a5a35 | |||
| d4d91c33f3 | |||
| 0439a2ac27 | |||
| 69516f7ab9 | |||
| b5165f231e | |||
| 7e11b23f87 | |||
| 520a2e363e | |||
| 32d894bf84 | |||
| 64550ae790 | |||
| f5d795dbce | |||
| cdd4cc56c2 | |||
| 48fcb4e1ca | |||
| f8e6acc0bd | |||
| cc591ebc66 | |||
| 27d0fe86d1 | |||
| 807b26c762 | |||
| c678701bd0 | |||
| 8c8f7774af | |||
| eaf9e08184 | |||
| bcb4d7c7da | |||
| 0125e2b05d | |||
| 80090f12be | |||
| 9c564ba557 | |||
| 980220d2ac | |||
| e1fe955a76 | |||
| 46ae6d8e6d | |||
| 6bf83f2517 | |||
| b3e91a2acc | |||
| df28641677 | |||
| 877073b538 | |||
| cf2f13193f | |||
| b4836cd922 | |||
| 3466909666 | |||
| 3b317c105b | |||
| 4f3e8ef4af | |||
| 11ee274e5b | |||
| 875d0c1d55 | |||
| bdf89e5de8 | |||
| f23559d1b8 | |||
| 5902563ea6 | |||
| f6c15fc795 | |||
| 33038a6450 | |||
| d188b7b53b | |||
| 8e4dfa65f0 | |||
| 6e33b90f62 | |||
| 5db3df35f5 | |||
| 69a4dc0950 | |||
| 41df837435 | |||
| e6683b84da | |||
| da8fa90e2d | |||
| 58d3fad84f | |||
| 697bca9932 | |||
| ccac9379ca | |||
| a83adc4ecf | |||
| 6f663e5de2 | |||
| d78b0440df | |||
| d3115e3557 | |||
| 417216cb16 | |||
| 3449c7b8ea | |||
| 0d03a4ac13 | |||
| f5db27396a | |||
| 4c1caf6025 | |||
| c7231ffd8b | |||
| c4a7692724 | |||
| bf447ec72a | |||
| f058dc7f53 | |||
| 7d315288dd | |||
| cd83f41294 | |||
| a9e40b8709 | |||
| 8a3a4f3a30 | |||
| 42f23d9582 | |||
| 4354c791f1 | |||
| 35b3c59136 | |||
| b8ab063cc9 | |||
| f3c09ef797 | |||
| 91c3c0e9ff | |||
| 7176a7b8e8 | |||
| 772eb2f1d4 | |||
| 40e6a87d15 | |||
| a57d90fdf1 | |||
| ad9674faa7 | |||
| bc7a0cc18b | |||
| 3c513b319c | |||
| 932f3333f1 | |||
| 2dc214393c | |||
| 928ec064ee | |||
| 066b6dab43 | |||
| 66964ad5a1 | |||
| 9193e606b9 | |||
| ff75a70c74 | |||
| e469e5771e | |||
| 2e31f87b34 | |||
| cda88daf3f | |||
| d4cdf4202f | |||
| 7dd0616b8c | |||
| 210e960a98 | |||
| 473bed604b | |||
| cabae7a91e | |||
| ecf53866b5 | |||
| aee117ba0b | |||
| 45b865604d | |||
| 6dfc75f090 | |||
| 97f8d0f265 | |||
| 68f87f7254 | |||
| a60634139e | |||
| d5504be686 | |||
| a6b0fcff48 | |||
| ad13e28ce9 | |||
| 466033c6b8 | |||
| ac56515527 | |||
| fb052ec8b7 | |||
| 29c07d6dee | |||
| b0a2a02166 | |||
| 127b7fe986 | |||
| 31d790d7d9 | |||
| d36f98bc9e | |||
| b03880d121 | |||
| 12d084e8fa | |||
| 0589f62bde | |||
| 58bfb1b79b | |||
| ebb06a7c34 | |||
| 064adb72e1 | |||
| f6d28811e5 | |||
| e21bd23bd8 | |||
| 3fd09ca0bf | |||
| bc5270c220 | |||
| d57c991d06 | |||
| 71459e648f | |||
| 5de549c882 | |||
| f0a7cbd193 | |||
| 453ec0e3e9 | |||
| 3199aad7cf | |||
| 54914aa312 | |||
| 5c60053802 | |||
| 50fe0bb746 | |||
| 8c7c47ff95 | |||
| c4d798d70b | |||
| ba20eb4040 | |||
| c97e5fd181 | |||
| 8e56f58102 | |||
| 3347101c84 | |||
| 6d387c61c3 | |||
| 2f06ae9e48 | |||
| 2a962efe57 | |||
| 879c568a2c | |||
| 76b54fc234 | |||
| 2e53145532 | |||
| f6f831f3f6 | |||
| 3700342b35 | |||
| 0244ac2a84 | |||
| bf54e639a7 | |||
| 083e5bb7c4 |
@@ -21,10 +21,7 @@
|
||||
[
|
||||
"transform-inline-environment-variables",
|
||||
{
|
||||
"include": [
|
||||
"SOURCE_COMMIT",
|
||||
"SOURCE_VERSION"
|
||||
]
|
||||
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
|
||||
}
|
||||
],
|
||||
"tsconfig-paths-module-resolver"
|
||||
@@ -39,16 +36,10 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"ignore": [
|
||||
"**/__mocks__",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
"ignore": ["**/__mocks__", "**/*.test.ts"]
|
||||
},
|
||||
"development": {
|
||||
"ignore": [
|
||||
"**/__mocks__",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
"ignore": ["**/__mocks__", "**/*.test.ts"]
|
||||
},
|
||||
"test": {
|
||||
"presets": [
|
||||
@@ -65,4 +56,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ __mocks__
|
||||
.DS_Store
|
||||
.env*
|
||||
.eslint*
|
||||
.oxlintrc*
|
||||
.log
|
||||
Makefile
|
||||
Procfile
|
||||
|
||||
@@ -202,6 +202,7 @@ 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=
|
||||
@@ -210,6 +211,10 @@ GITHUB_APP_PRIVATE_KEY=
|
||||
LINEAR_CLIENT_ID=
|
||||
LINEAR_CLIENT_SECRET=
|
||||
|
||||
# The GitLab integration allows previewing issue and merge request links as rich mentions
|
||||
GITLAB_CLIENT_ID=
|
||||
GITLAB_CLIENT_SECRET=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed in addition to Slack authentication:
|
||||
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-G2mc8DOJHk
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
server/migrations/*.js
|
||||
@@ -1,190 +0,0 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"extraFileExtensions": [
|
||||
".json"
|
||||
],
|
||||
"project": "./tsconfig.json",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"es",
|
||||
"react",
|
||||
"@typescript-eslint",
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"eslint-plugin-lodash"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
"importNames": [
|
||||
"useMenuState"
|
||||
],
|
||||
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"eqeqeq": 2,
|
||||
"curly": 2,
|
||||
"no-console": "error",
|
||||
"arrow-body-style": [
|
||||
"error",
|
||||
"as-needed"
|
||||
],
|
||||
"spaced-comment": "error",
|
||||
"object-shorthand": "error",
|
||||
"no-mixed-operators": "off",
|
||||
"no-useless-escape": "off",
|
||||
"no-shadow": "off",
|
||||
"es/no-regexp-lookbehind-assertions": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/self-closing-comp": [
|
||||
"error",
|
||||
{
|
||||
"component": true,
|
||||
"html": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-shadow": [
|
||||
"warn",
|
||||
{
|
||||
"allow": [
|
||||
"transaction"
|
||||
],
|
||||
"hoist": "all",
|
||||
"ignoreTypeValueShadow": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"error",
|
||||
{
|
||||
"checksVoidReturn": false
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
],
|
||||
"padding-line-between-statements": [
|
||||
"error",
|
||||
{
|
||||
"blankLine": "always",
|
||||
"prev": "*",
|
||||
"next": "export"
|
||||
}
|
||||
],
|
||||
"lines-between-class-members": [
|
||||
"error",
|
||||
"always",
|
||||
{
|
||||
"exceptAfterSingleLine": true
|
||||
}
|
||||
],
|
||||
"lodash/import-scope": [
|
||||
"error",
|
||||
"method"
|
||||
],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"alphabetize": {
|
||||
"order": "asc"
|
||||
},
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "@shared/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "@server/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/stores",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/stores/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/models/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/scenes/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/components/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "~/**",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"printWidth": 80,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"createClass": "createReactClass",
|
||||
"pragma": "React",
|
||||
"version": "detect"
|
||||
},
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [
|
||||
".ts",
|
||||
".tsx"
|
||||
]
|
||||
},
|
||||
"import/resolver": {
|
||||
"typescript": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+66
-73
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NODE_ENV: test
|
||||
@@ -22,46 +22,46 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
node-version: [20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --prefer-offline
|
||||
|
||||
lint:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint
|
||||
|
||||
types:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn tsc
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn tsc
|
||||
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
config: ${{ steps.filter.outputs.config }}
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
steps:
|
||||
@@ -70,6 +70,9 @@ jobs:
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
config:
|
||||
- '.github/**'
|
||||
- 'vite.config.ts'
|
||||
server:
|
||||
- 'server/**'
|
||||
- 'shared/**'
|
||||
@@ -83,23 +86,23 @@ jobs:
|
||||
|
||||
test:
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
test-group: [app, shared]
|
||||
steps:
|
||||
- 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 }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn test:${{ matrix.test-group }}
|
||||
|
||||
test-server:
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.server == 'true' }}
|
||||
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
@@ -115,51 +118,41 @@ 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]
|
||||
|
||||
shard: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- 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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn sequelize db:migrate
|
||||
- name: Run server tests
|
||||
run: |
|
||||
TESTFILES=$(find . -name "*.test.ts" -path "*/server/*" | sort | split -n -d -l $(($(find . -name "*.test.ts" -path "*/server/*" | wc -l)/${{ matrix.shard }})) - | sed -n "${{ matrix.shard }}p")
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- name: Set environment to production
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
webpackStatsFile: ./build/app/webpack-stats.json
|
||||
|
||||
@@ -13,12 +13,12 @@ name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '28 15 * * 2'
|
||||
- cron: "28 15 * * 2"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -32,39 +32,39 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
language: ["javascript"]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
@@ -20,11 +20,11 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
cache: "yarn"
|
||||
- run: yarn install --frozen-lockfile --prefer-offline
|
||||
- run: yarn lint --fix
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: 'Applied automatic fixes'
|
||||
commit_message: "Applied automatic fixes"
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"ignorePatterns": [
|
||||
"build/**",
|
||||
"node_modules/**",
|
||||
"public/**",
|
||||
"server/migrations/**",
|
||||
"server/scripts/**",
|
||||
"patches/**",
|
||||
"*.d.ts"
|
||||
],
|
||||
"rules": {
|
||||
"for-direction": "error",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-case-declarations": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-compare-neg-zero": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-binary-expression": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-control-regex": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-else-if": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-empty-static-block": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-explicit-any": "warn",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-fallthrough": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-global-assign": "error",
|
||||
"no-import-assign": "error",
|
||||
"no-invalid-regexp": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-loss-of-precision": "error",
|
||||
"no-new-native-nonconstructor": "error",
|
||||
"no-nonoctal-decimal-escape": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-prototype-builtins": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-setter-return": "error",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-sparse-arrays": "error",
|
||||
"no-this-before-super": "error",
|
||||
"no-unexpected-multiline": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-unused-labels": "error",
|
||||
"no-unused-private-class-members": "error",
|
||||
"no-unused-vars": "error",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-catch": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-with": "error",
|
||||
"require-yield": "error",
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.{js,jsx,ts,tsx}"],
|
||||
"rules": {
|
||||
"eqeqeq": "error",
|
||||
"curly": "error",
|
||||
"no-console": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"no-useless-escape": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/self-closing-comp": [
|
||||
"error",
|
||||
{
|
||||
"component": true,
|
||||
"html": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
require("dotenv").config({
|
||||
require("@dotenvx/dotenvx").config({
|
||||
path: process.env.NODE_ENV === "test" ? ".env.test" : ".env",
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
|
||||
# ---
|
||||
FROM node:20-slim AS runner
|
||||
FROM node:22-slim AS runner
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.84.0
|
||||
Licensed Work: Outline 0.86.1
|
||||
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-05-11
|
||||
Change Date: 2029-08-09
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ build:
|
||||
docker compose build --pull outline
|
||||
|
||||
test:
|
||||
docker compose up -d redis postgres
|
||||
docker compose up -d postgres
|
||||
NODE_ENV=test yarn sequelize db:drop
|
||||
NODE_ENV=test yarn sequelize db:create
|
||||
NODE_ENV=test yarn sequelize db:migrate
|
||||
|
||||
@@ -1 +1 @@
|
||||
export default '';
|
||||
export default "";
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
"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": {
|
||||
@@ -222,4 +216,4 @@
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"eslint-plugin-react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off"
|
||||
},
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"plugins": ["oxc", "eslint", "typescript", "react"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.{jsx,tsx}"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["mime-types"],
|
||||
"message": "Do not use the mime-types package in the browser."
|
||||
}
|
||||
],
|
||||
"paths": [
|
||||
{
|
||||
"name": "reakit/Menu",
|
||||
"importNames": ["useMenuState"],
|
||||
"message": "Do not use useMenuState from reakit/Menu. Use useMenuState instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": ["import"]
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import { PlusIcon, TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import ApiKey from "~/models/ApiKey";
|
||||
import ApiKeyNew from "~/scenes/ApiKeyNew";
|
||||
import { createAction } from "..";
|
||||
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
|
||||
import { createAction, createActionV2 } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
|
||||
export const createApiKey = createAction({
|
||||
@@ -22,3 +24,37 @@ export const createApiKey = createAction({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
|
||||
createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu
|
||||
? apiKey.isExpired
|
||||
? t("Delete")
|
||||
: `${t("Revoke")}…`
|
||||
: t("Revoke API key"),
|
||||
analyticsName: "Revoke API key",
|
||||
section: SettingsSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "revoke delete remove",
|
||||
dangerous: true,
|
||||
perform: async ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
if (apiKey.isExpired) {
|
||||
await apiKey.delete();
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Revoke token"),
|
||||
content: (
|
||||
<ApiKeyRevokeDialog
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
ArchiveIcon,
|
||||
CollectionIcon,
|
||||
EditIcon,
|
||||
ExportIcon,
|
||||
NewDocumentIcon,
|
||||
PadlockIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
@@ -22,11 +24,19 @@ import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import SharePopover from "~/components/Sharing/Collection/SharePopover";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import { newTemplatePath, searchPath } from "~/utils/routeHelpers";
|
||||
import {
|
||||
newDocumentPath,
|
||||
newTemplatePath,
|
||||
searchPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import ExportDialog from "~/components/ExportDialog";
|
||||
|
||||
const ColorCollectionIcon = ({ collection }: { collection: Collection }) => (
|
||||
<DynamicCollectionIcon collection={collection} />
|
||||
@@ -47,7 +57,7 @@ export const openCollection = createAction({
|
||||
name: collection.name,
|
||||
icon: <ColorCollectionIcon collection={collection} />,
|
||||
section: CollectionSection,
|
||||
perform: () => history.push(collection.path),
|
||||
to: collection.path,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -70,7 +80,7 @@ export const createCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollection = createAction({
|
||||
export const editCollection = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Edit")}…` : t("Edit collection"),
|
||||
analyticsName: "Edit collection",
|
||||
@@ -96,7 +106,7 @@ export const editCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const editCollectionPermissions = createAction({
|
||||
export const editCollectionPermissions = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? `${t("Permissions")}…` : t("Collection permissions"),
|
||||
analyticsName: "Collection permissions",
|
||||
@@ -128,7 +138,7 @@ export const editCollectionPermissions = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const searchInCollection = createAction({
|
||||
export const searchInCollection = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in collection"),
|
||||
analyticsName: "Search collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -146,13 +156,20 @@ export const searchInCollection = createAction({
|
||||
|
||||
return stores.policies.abilities(activeCollectionId).readDocument;
|
||||
},
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = searchPath({
|
||||
collectionId: activeCollectionId,
|
||||
}).split("?");
|
||||
|
||||
perform: ({ activeCollectionId }) => {
|
||||
history.push(searchPath({ collectionId: activeCollectionId }));
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const starCollection = createAction({
|
||||
export const starCollection = createActionV2({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -179,7 +196,7 @@ export const starCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarCollection = createAction({
|
||||
export const unstarCollection = createActionV2({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -205,7 +222,7 @@ export const unstarCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeCollection = createAction({
|
||||
export const subscribeCollection = createActionV2({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -218,6 +235,7 @@ export const subscribeCollection = createAction({
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isActive &&
|
||||
!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).subscribe
|
||||
);
|
||||
@@ -235,7 +253,7 @@ export const subscribeCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeCollection = createAction({
|
||||
export const unsubscribeCollection = createActionV2({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -248,6 +266,7 @@ export const unsubscribeCollection = createAction({
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
|
||||
return (
|
||||
!!collection?.isActive &&
|
||||
!!collection?.isSubscribed &&
|
||||
stores.policies.abilities(activeCollectionId).unsubscribe
|
||||
);
|
||||
@@ -265,10 +284,10 @@ export const unsubscribeCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveCollection = createAction({
|
||||
export const archiveCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive collection",
|
||||
section: CollectionSection,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeCollectionId, stores }) => {
|
||||
if (!activeCollectionId) {
|
||||
@@ -306,7 +325,7 @@ export const archiveCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreCollection = createAction({
|
||||
export const restoreCollection = createActionV2({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore collection",
|
||||
section: CollectionSection,
|
||||
@@ -331,7 +350,7 @@ export const restoreCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteCollection = createAction({
|
||||
export const deleteCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete collection",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -365,7 +384,65 @@ export const deleteCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createAction({
|
||||
export const exportCollection = createActionV2({
|
||||
name: ({ t }) => `${t("Export")}…`,
|
||||
analyticsName: "Export collection",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ExportIcon />,
|
||||
visible: ({ currentTeamId, activeCollectionId, stores }) => {
|
||||
if (!currentTeamId || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!stores.policies.abilities(currentTeamId).createExport &&
|
||||
!!stores.policies.abilities(activeCollectionId).export
|
||||
);
|
||||
},
|
||||
perform: async ({ activeCollectionId, stores, t }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
const collection = stores.collections.get(activeCollectionId);
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Export collection"),
|
||||
content: (
|
||||
<ExportDialog
|
||||
collection={collection}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveCollectionSection,
|
||||
icon: <NewDocumentIcon />,
|
||||
keywords: "new create document",
|
||||
visible: ({ activeCollectionId, stores }) =>
|
||||
!!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplate = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New template"),
|
||||
analyticsName: "New template",
|
||||
section: ActiveCollectionSection,
|
||||
@@ -376,13 +453,14 @@ export const createTemplate = createAction({
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).createDocument
|
||||
),
|
||||
perform: ({ activeCollectionId, event }) => {
|
||||
if (!activeCollectionId) {
|
||||
return;
|
||||
}
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
history.push(newTemplatePath(activeCollectionId));
|
||||
to: ({ activeCollectionId, sidebarContext }) => {
|
||||
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
|
||||
import history from "~/utils/history";
|
||||
import { createAction } from "..";
|
||||
import { DocumentSection } from "../sections";
|
||||
import { createActionV2 } from "..";
|
||||
import { ActiveDocumentSection } from "../sections";
|
||||
|
||||
export const deleteCommentFactory = ({
|
||||
comment,
|
||||
@@ -15,15 +14,15 @@ export const deleteCommentFactory = ({
|
||||
comment: Comment;
|
||||
onDelete: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete comment",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "trash",
|
||||
dangerous: true,
|
||||
visible: () => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, event }) => {
|
||||
visible: ({ stores }) => stores.policies.abilities(comment.id).delete,
|
||||
perform: ({ t, stores, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
@@ -41,12 +40,12 @@ export const resolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onResolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Mark as resolved"),
|
||||
analyticsName: "Resolve thread",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).resolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async ({ t }) => {
|
||||
@@ -73,12 +72,12 @@ export const unresolveCommentFactory = ({
|
||||
comment: Comment;
|
||||
onUnresolve: () => void;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Mark as unresolved"),
|
||||
analyticsName: "Unresolve thread",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <DoneIcon outline />,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).unresolve &&
|
||||
stores.policies.abilities(comment.documentId).update,
|
||||
perform: async () => {
|
||||
@@ -102,15 +101,15 @@ export const viewCommentReactionsFactory = ({
|
||||
}: {
|
||||
comment: Comment;
|
||||
}) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("View reactions")}`,
|
||||
analyticsName: "View comment reactions",
|
||||
section: DocumentSection,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SmileyIcon />,
|
||||
visible: () =>
|
||||
visible: ({ stores }) =>
|
||||
stores.policies.abilities(comment.id).read &&
|
||||
comment.reactions.length > 0,
|
||||
perform: ({ t, event }) => {
|
||||
perform: ({ t, stores, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
BeakerIcon,
|
||||
@@ -127,6 +128,17 @@ 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 />,
|
||||
@@ -201,6 +213,7 @@ export const developer = createAction({
|
||||
createToast,
|
||||
createTestUsers,
|
||||
clearIndexedDB,
|
||||
clearStorage,
|
||||
startTyping,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -26,11 +26,12 @@ import {
|
||||
PublishIcon,
|
||||
CommentIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
CaseSensitiveIcon,
|
||||
RestoreIcon,
|
||||
EditIcon,
|
||||
} from "outline-icons";
|
||||
import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
@@ -52,7 +53,13 @@ import MarkdownIcon from "~/components/Icons/MarkdownIcon";
|
||||
import SharePopover from "~/components/Sharing/Document";
|
||||
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
|
||||
import DocumentTemplatizeDialog from "~/components/TemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createActionV2Group,
|
||||
createActionV2WithChildren,
|
||||
createInternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import {
|
||||
ActiveDocumentSection,
|
||||
DocumentSection,
|
||||
@@ -62,7 +69,6 @@ import env from "~/env";
|
||||
import { setPersistedState } from "~/hooks/usePersistedState";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
documentInsightsPath,
|
||||
documentHistoryPath,
|
||||
homePath,
|
||||
newDocumentPath,
|
||||
@@ -71,7 +77,12 @@ import {
|
||||
documentPath,
|
||||
urlify,
|
||||
trashPath,
|
||||
documentEditPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { ActionV2, ActionV2Group, ActionV2Separator } from "~/types";
|
||||
import Insights from "~/scenes/Document/components/Insights";
|
||||
|
||||
export const openDocument = createAction({
|
||||
name: ({ t }) => t("Open document"),
|
||||
@@ -98,11 +109,43 @@ export const openDocument = createAction({
|
||||
<DocumentIcon />
|
||||
),
|
||||
section: DocumentSection,
|
||||
perform: () => history.push(item.url),
|
||||
to: item.url,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const editDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Edit"),
|
||||
analyticsName: "Edit document",
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "edit",
|
||||
icon: <EditIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { auth, documents, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!!can?.update && !!auth.user?.separateEditMode && !document?.template
|
||||
);
|
||||
},
|
||||
to: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return documentEditPath(document);
|
||||
},
|
||||
});
|
||||
|
||||
export const createDocument = createAction({
|
||||
name: ({ t }) => t("New document"),
|
||||
analyticsName: "New document",
|
||||
@@ -141,7 +184,7 @@ export const createDraftDocument = createAction({
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDocumentFromTemplate = createAction({
|
||||
export const createDocumentFromTemplate = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New from template"),
|
||||
analyticsName: "New document",
|
||||
section: DocumentSection,
|
||||
@@ -171,16 +214,24 @@ export const createDocumentFromTemplate = createAction({
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
},
|
||||
perform: ({ activeCollectionId, activeDocumentId, sidebarContext }) =>
|
||||
history.push(
|
||||
newDocumentPath(activeCollectionId, { templateId: activeDocumentId }),
|
||||
{
|
||||
sidebarContext,
|
||||
}
|
||||
),
|
||||
to: ({ activeDocumentId, activeCollectionId, sidebarContext }) => {
|
||||
if (!activeDocumentId || !activeCollectionId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = newDocumentPath(activeCollectionId, {
|
||||
templateId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const createNestedDocument = createAction({
|
||||
export const createNestedDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("New nested document"),
|
||||
analyticsName: "New document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -191,13 +242,19 @@ export const createNestedDocument = createAction({
|
||||
!!activeDocumentId &&
|
||||
stores.policies.abilities(currentTeamId).createDocument &&
|
||||
stores.policies.abilities(activeDocumentId).createChildDocument,
|
||||
perform: ({ activeDocumentId, sidebarContext }) =>
|
||||
history.push(newNestedDocumentPath(activeDocumentId), {
|
||||
sidebarContext,
|
||||
}),
|
||||
to: ({ activeDocumentId, sidebarContext }) => {
|
||||
const [pathname, search] =
|
||||
newNestedDocumentPath(activeDocumentId).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const starDocument = createAction({
|
||||
export const starDocument = createActionV2({
|
||||
name: ({ t }) => t("Star"),
|
||||
analyticsName: "Star document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -223,7 +280,7 @@ export const starDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unstarDocument = createAction({
|
||||
export const unstarDocument = createActionV2({
|
||||
name: ({ t }) => t("Unstar"),
|
||||
analyticsName: "Unstar document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -249,7 +306,7 @@ export const unstarDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const publishDocument = createAction({
|
||||
export const publishDocument = createActionV2({
|
||||
name: ({ t }) => t("Publish"),
|
||||
analyticsName: "Publish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -291,7 +348,7 @@ export const publishDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unpublishDocument = createAction({
|
||||
export const unpublishDocument = createActionV2({
|
||||
name: ({ t }) => t("Unpublish"),
|
||||
analyticsName: "Unpublish document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -322,11 +379,27 @@ export const unpublishDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeDocument = createAction({
|
||||
export const subscribeDocument = createActionV2({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
analyticsName: "Subscribe to document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return stores.collections.get(activeCollectionId)?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
|
||||
},
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
@@ -335,6 +408,7 @@ export const subscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.isActive &&
|
||||
!document?.collection?.isSubscribed &&
|
||||
!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
@@ -351,11 +425,27 @@ export const subscribeDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeDocument = createAction({
|
||||
export const unsubscribeDocument = createActionV2({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
analyticsName: "Unsubscribe from document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
tooltip: ({ activeCollectionId, isContextMenu, stores, t }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return stores.collections.get(activeCollectionId)?.isSubscribed
|
||||
? t("Subscription inherited from collection")
|
||||
: undefined;
|
||||
},
|
||||
disabled: ({ activeCollectionId, isContextMenu, stores }) => {
|
||||
if (!isContextMenu || !activeCollectionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!stores.collections.get(activeCollectionId)?.isSubscribed;
|
||||
},
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
@@ -364,9 +454,10 @@ export const unsubscribeDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.collection?.isSubscribed ||
|
||||
(!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe)
|
||||
!!document?.isActive &&
|
||||
(!!document?.collection?.isSubscribed ||
|
||||
(!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe))
|
||||
);
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
@@ -382,7 +473,7 @@ export const unsubscribeDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const shareDocument = createAction({
|
||||
export const shareDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Permissions")}…`,
|
||||
analyticsName: "Share document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -415,7 +506,7 @@ export const shareDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsHTML = createAction({
|
||||
export const downloadDocumentAsHTML = createActionV2({
|
||||
name: ({ t }) => t("HTML"),
|
||||
analyticsName: "Download document as HTML",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -434,7 +525,7 @@ export const downloadDocumentAsHTML = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsPDF = createAction({
|
||||
export const downloadDocumentAsPDF = createActionV2({
|
||||
name: ({ t }) => t("PDF"),
|
||||
analyticsName: "Download document as PDF",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -442,9 +533,11 @@ 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;
|
||||
@@ -458,7 +551,7 @@ export const downloadDocumentAsPDF = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
export const downloadDocumentAsMarkdown = createActionV2({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
analyticsName: "Download document as Markdown",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -477,7 +570,7 @@ export const downloadDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
export const downloadDocument = createActionV2WithChildren({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
analyticsName: "Download document",
|
||||
@@ -493,7 +586,7 @@ export const downloadDocument = createAction({
|
||||
],
|
||||
});
|
||||
|
||||
export const copyDocumentAsMarkdown = createAction({
|
||||
export const copyDocumentAsMarkdown = createActionV2({
|
||||
name: ({ t }) => t("Copy as Markdown"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -512,7 +605,7 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentAsPlainText = createAction({
|
||||
export const copyDocumentAsPlainText = createActionV2({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -531,7 +624,7 @@ export const copyDocumentAsPlainText = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentShareLink = createAction({
|
||||
export const copyDocumentShareLink = createActionV2({
|
||||
name: ({ t }) => t("Copy public link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard share",
|
||||
@@ -552,7 +645,7 @@ export const copyDocumentShareLink = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentLink = createAction({
|
||||
export const copyDocumentLink = createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
@@ -570,7 +663,7 @@ export const copyDocumentLink = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocument = createAction({
|
||||
export const copyDocument = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Copy"),
|
||||
analyticsName: "Copy document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -584,7 +677,7 @@ export const copyDocument = createAction({
|
||||
],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
export const duplicateDocument = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
analyticsName: "Duplicate document",
|
||||
@@ -620,7 +713,7 @@ export const duplicateDocument = createAction({
|
||||
* Pin a document to a collection. Pinned documents will be displayed at the top
|
||||
* of the collection for all collection members to see.
|
||||
*/
|
||||
export const pinDocumentToCollection = createAction({
|
||||
export const pinDocumentToCollection = createActionV2({
|
||||
name: ({ activeDocumentId = "", t, stores }) => {
|
||||
const selectedDocument = stores.documents.get(activeDocumentId);
|
||||
const collectionName = selectedDocument
|
||||
@@ -665,7 +758,7 @@ export const pinDocumentToCollection = createAction({
|
||||
* Pin a document to team home. Pinned documents will be displayed at the top
|
||||
* of the home screen for all team members to see.
|
||||
*/
|
||||
export const pinDocumentToHome = createAction({
|
||||
export const pinDocumentToHome = createActionV2({
|
||||
name: ({ t }) => t("Pin to home"),
|
||||
analyticsName: "Pin document to home",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -697,7 +790,7 @@ export const pinDocumentToHome = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const pinDocument = createAction({
|
||||
export const pinDocument = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Pin"),
|
||||
analyticsName: "Pin document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -705,7 +798,7 @@ export const pinDocument = createAction({
|
||||
children: [pinDocumentToCollection, pinDocumentToHome],
|
||||
});
|
||||
|
||||
export const searchInDocument = createAction({
|
||||
export const searchInDocument = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Search in document"),
|
||||
analyticsName: "Search document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -718,12 +811,24 @@ export const searchInDocument = createAction({
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return !!document?.isActive;
|
||||
},
|
||||
perform: ({ activeDocumentId }) => {
|
||||
history.push(searchPath({ documentId: activeDocumentId }));
|
||||
to: ({ activeDocumentId, sidebarContext }) => {
|
||||
if (!activeDocumentId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [pathname, search] = searchPath({
|
||||
documentId: activeDocumentId,
|
||||
}).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const printDocument = createAction({
|
||||
export const printDocument = createActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Print") : t("Print document"),
|
||||
analyticsName: "Print document",
|
||||
@@ -735,7 +840,7 @@ export const printDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const importDocument = createAction({
|
||||
export const importDocument = createActionV2({
|
||||
name: ({ t }) => t("Import document"),
|
||||
analyticsName: "Import document",
|
||||
section: DocumentSection,
|
||||
@@ -782,7 +887,7 @@ export const importDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const createTemplateFromDocument = createAction({
|
||||
export const createTemplateFromDocument = createActionV2({
|
||||
name: ({ t }) => t("Templatize"),
|
||||
analyticsName: "Templatize document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -840,11 +945,11 @@ export const searchDocumentsForQuery = (query: string) =>
|
||||
analyticsName: "Search documents",
|
||||
section: DocumentSection,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath({ query })),
|
||||
to: searchPath({ query }),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveTemplateToWorkspace = createAction({
|
||||
export const moveTemplateToWorkspace = createActionV2({
|
||||
name: ({ t }) => t("Move to workspace"),
|
||||
analyticsName: "Move template to workspace",
|
||||
section: DocumentSection,
|
||||
@@ -874,7 +979,7 @@ export const moveTemplateToWorkspace = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocumentToCollection = createAction({
|
||||
export const moveDocumentToCollection = createActionV2({
|
||||
name: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return t("Move");
|
||||
@@ -911,7 +1016,7 @@ export const moveDocumentToCollection = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const moveDocument = createAction({
|
||||
export const moveDocument = createActionV2({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -930,7 +1035,7 @@ export const moveDocument = createAction({
|
||||
perform: moveDocumentToCollection.perform,
|
||||
});
|
||||
|
||||
export const moveTemplate = createAction({
|
||||
export const moveTemplate = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -949,7 +1054,7 @@ export const moveTemplate = createAction({
|
||||
children: [moveTemplateToWorkspace, moveDocumentToCollection],
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
export const archiveDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Archive")}…`,
|
||||
analyticsName: "Archive document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -989,7 +1094,102 @@ export const archiveDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createAction({
|
||||
export const restoreDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Restore")}`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = document.collectionId
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
const can = stores.policies.abilities(document.id);
|
||||
|
||||
return (
|
||||
!!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
perform: async ({ t, stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.restore();
|
||||
toast.success(
|
||||
t("{{ documentName }} restored", {
|
||||
documentName: capitalize(document.noun),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const restoreDocumentToCollection = createActionV2WithChildren({
|
||||
name: ({ t }) => `${t("Restore")}…`,
|
||||
analyticsName: "Restore document",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <RestoreIcon />,
|
||||
visible: ({ stores, activeDocumentId }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const can = stores.policies.abilities(document.id);
|
||||
const collection = document.collectionId
|
||||
? stores.collections.get(document.collectionId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
!(document.isWorkspaceTemplate || collection?.isActive) &&
|
||||
!!(can.restore || can.unarchive)
|
||||
);
|
||||
},
|
||||
children: ({ t, activeDocumentId, stores }) => {
|
||||
const { collections, documents, policies } = stores;
|
||||
|
||||
const document = activeDocumentId
|
||||
? documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const actions = collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
return createActionV2({
|
||||
name: collection.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CollectionIcon collection={collection} />,
|
||||
visible: can.createDocument,
|
||||
perform: async () => {
|
||||
await document.restore({ collectionId: collection.id });
|
||||
toast.success(
|
||||
t("{{ documentName }} restored", {
|
||||
documentName: capitalize(document.noun),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return [createActionV2Group({ name: t("Choose a collection"), actions })];
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createActionV2({
|
||||
name: ({ t }) => `${t("Delete")}…`,
|
||||
analyticsName: "Delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1023,7 +1223,7 @@ export const deleteDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
export const permanentlyDeleteDocument = createActionV2({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
analyticsName: "Permanently delete document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1078,7 +1278,7 @@ export const permanentlyDeleteDocumentsInTrash = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentComments = createAction({
|
||||
export const openDocumentComments = createActionV2({
|
||||
name: ({ t }) => t("Comments"),
|
||||
analyticsName: "Open comments",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1101,7 +1301,7 @@ export const openDocumentComments = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentHistory = createAction({
|
||||
export const openDocumentHistory = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("History"),
|
||||
analyticsName: "Open document history",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1110,19 +1310,25 @@ export const openDocumentHistory = createAction({
|
||||
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||
return !!activeDocumentId && can.listRevisions;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (!document) {
|
||||
return;
|
||||
return "";
|
||||
}
|
||||
history.push(documentHistoryPath(document));
|
||||
|
||||
const [pathname, search] = documentHistoryPath(document).split("?");
|
||||
|
||||
return {
|
||||
pathname,
|
||||
search,
|
||||
state: { sidebarContext },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const openDocumentInsights = createAction({
|
||||
export const openDocumentInsights = createActionV2({
|
||||
name: ({ t }) => t("Insights"),
|
||||
analyticsName: "Open document insights",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1140,50 +1346,22 @@ export const openDocumentInsights = createAction({
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
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 }) => {
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
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;
|
||||
}
|
||||
|
||||
await document.save({
|
||||
insightsEnabled: !document.insightsEnabled,
|
||||
stores.dialogs.openModal({
|
||||
title: t("Insights"),
|
||||
content: <Insights document={document} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const leaveDocument = createAction({
|
||||
export const leaveDocument = createActionV2({
|
||||
name: ({ t }) => t("Leave document"),
|
||||
analyticsName: "Leave document",
|
||||
section: ActiveDocumentSection,
|
||||
@@ -1219,6 +1397,27 @@ export const leaveDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const applyTemplateFactory = ({
|
||||
actions,
|
||||
}: {
|
||||
actions: (ActionV2 | ActionV2Group | ActionV2Separator)[];
|
||||
}) =>
|
||||
createActionV2WithChildren({
|
||||
name: ({ t }) => t("Apply template"),
|
||||
analyticsName: "Apply template",
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
const { policies } = stores;
|
||||
const can = activeDocumentId
|
||||
? policies.abilities(activeDocumentId)
|
||||
: undefined;
|
||||
|
||||
return !!can?.update;
|
||||
},
|
||||
children: actions,
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "..";
|
||||
import { SettingsSection } from "../sections";
|
||||
import Integration from "~/models/Integration";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
|
||||
|
||||
export const disconnectAnalyticsIntegrationFactory = (
|
||||
integration?: Integration<IntegrationType.Analytics>
|
||||
) =>
|
||||
createAction({
|
||||
name: ({ t }) => t("Disconnect analytics"),
|
||||
analyticsName: "Disconnect analytics",
|
||||
section: SettingsSection,
|
||||
icon: <TrashIcon />,
|
||||
keywords: "disconnect",
|
||||
visible: () => !!integration,
|
||||
perform: ({ t, event }) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Disconnect analytics"),
|
||||
content: <DisconnectAnalyticsDialog integration={integration!} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -12,17 +12,21 @@ import {
|
||||
BrowserIcon,
|
||||
ShapesIcon,
|
||||
DraftsIcon,
|
||||
BugIcon,
|
||||
} from "outline-icons";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
import { isMac } from "@shared/utils/browser";
|
||||
import stores from "~/stores";
|
||||
import SearchQuery from "~/models/SearchQuery";
|
||||
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
|
||||
import { createAction } from "~/actions";
|
||||
import {
|
||||
createAction,
|
||||
createActionV2,
|
||||
createExternalLinkActionV2,
|
||||
createInternalLinkActionV2,
|
||||
} 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,
|
||||
@@ -39,7 +43,7 @@ export const navigateToHome = createAction({
|
||||
section: NavigationSection,
|
||||
shortcut: ["d"],
|
||||
icon: <HomeIcon />,
|
||||
perform: () => history.push(homePath()),
|
||||
to: homePath(),
|
||||
visible: ({ location }) => location.pathname !== homePath(),
|
||||
});
|
||||
|
||||
@@ -49,7 +53,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
|
||||
name: searchQuery.query,
|
||||
analyticsName: "Navigate to recent search query",
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath({ query: searchQuery.query })),
|
||||
to: searchPath({ query: searchQuery.query }),
|
||||
});
|
||||
|
||||
export const navigateToDrafts = createAction({
|
||||
@@ -57,7 +61,7 @@ export const navigateToDrafts = createAction({
|
||||
analyticsName: "Navigate to drafts",
|
||||
section: NavigationSection,
|
||||
icon: <DraftsIcon />,
|
||||
perform: () => history.push(draftsPath()),
|
||||
to: draftsPath(),
|
||||
visible: ({ location }) => location.pathname !== draftsPath(),
|
||||
});
|
||||
|
||||
@@ -66,7 +70,7 @@ export const navigateToSearch = createAction({
|
||||
analyticsName: "Navigate to search",
|
||||
section: NavigationSection,
|
||||
icon: <SearchIcon />,
|
||||
perform: () => history.push(searchPath()),
|
||||
to: searchPath(),
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
@@ -76,7 +80,7 @@ export const navigateToArchive = createAction({
|
||||
section: NavigationSection,
|
||||
shortcut: ["g", "a"],
|
||||
icon: <ArchiveIcon />,
|
||||
perform: () => history.push(archivePath()),
|
||||
to: archivePath(),
|
||||
visible: ({ location }) => location.pathname !== archivePath(),
|
||||
});
|
||||
|
||||
@@ -85,7 +89,7 @@ export const navigateToTrash = createAction({
|
||||
analyticsName: "Navigate to trash",
|
||||
section: NavigationSection,
|
||||
icon: <TrashIcon />,
|
||||
perform: () => history.push(trashPath()),
|
||||
to: trashPath(),
|
||||
visible: ({ location }) => location.pathname !== trashPath(),
|
||||
});
|
||||
|
||||
@@ -96,25 +100,25 @@ export const navigateToSettings = createAction({
|
||||
shortcut: ["g", "s"],
|
||||
icon: <SettingsIcon />,
|
||||
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(settingsPath()),
|
||||
to: settingsPath(),
|
||||
});
|
||||
|
||||
export const navigateToWorkspaceSettings = createAction({
|
||||
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Settings"),
|
||||
analyticsName: "Navigate to workspace settings",
|
||||
section: NavigationSection,
|
||||
icon: <SettingsIcon />,
|
||||
visible: () => stores.policies.abilities(stores.auth.team?.id || "").update,
|
||||
perform: () => history.push(settingsPath("details")),
|
||||
to: settingsPath("details"),
|
||||
});
|
||||
|
||||
export const navigateToProfileSettings = createAction({
|
||||
export const navigateToProfileSettings = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Profile"),
|
||||
analyticsName: "Navigate to profile settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ProfileIcon />,
|
||||
perform: () => history.push(settingsPath()),
|
||||
to: settingsPath(),
|
||||
});
|
||||
|
||||
export const navigateToTemplateSettings = createAction({
|
||||
@@ -123,43 +127,46 @@ export const navigateToTemplateSettings = createAction({
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <ShapesIcon />,
|
||||
perform: () => history.push(settingsPath("templates")),
|
||||
to: settingsPath("templates"),
|
||||
});
|
||||
|
||||
export const navigateToNotificationSettings = createAction({
|
||||
name: ({ t }) => t("Notifications"),
|
||||
export const navigateToNotificationSettings = createInternalLinkActionV2({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Notification settings") : t("Notifications"),
|
||||
analyticsName: "Navigate to notification settings",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
perform: () => history.push(settingsPath("notifications")),
|
||||
to: settingsPath("notifications"),
|
||||
});
|
||||
|
||||
export const navigateToAccountPreferences = createAction({
|
||||
export const navigateToAccountPreferences = createInternalLinkActionV2({
|
||||
name: ({ t }) => t("Preferences"),
|
||||
analyticsName: "Navigate to account preferences",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <SettingsIcon />,
|
||||
perform: () => history.push(settingsPath("preferences")),
|
||||
to: settingsPath("preferences"),
|
||||
});
|
||||
|
||||
export const openDocumentation = createAction({
|
||||
export const openDocumentation = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Documentation"),
|
||||
analyticsName: "Open documentation",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
perform: () => window.open(UrlHelper.guide),
|
||||
url: UrlHelper.guide,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openAPIDocumentation = createAction({
|
||||
export const openAPIDocumentation = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("API documentation"),
|
||||
analyticsName: "Open API documentation",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
perform: () => window.open(UrlHelper.developers),
|
||||
url: UrlHelper.developers,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const toggleSidebar = createAction({
|
||||
@@ -170,32 +177,37 @@ export const toggleSidebar = createAction({
|
||||
perform: () => stores.ui.toggleCollapsedSidebar(),
|
||||
});
|
||||
|
||||
export const openFeedbackUrl = createAction({
|
||||
export const openFeedbackUrl = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Send us feedback"),
|
||||
analyticsName: "Open feedback",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <EmailIcon />,
|
||||
perform: () => window.open(UrlHelper.contact),
|
||||
url: UrlHelper.contact,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openBugReportUrl = createAction({
|
||||
export const openBugReportUrl = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Report a bug"),
|
||||
analyticsName: "Open bug report",
|
||||
section: NavigationSection,
|
||||
perform: () => window.open(UrlHelper.github),
|
||||
iconInContextMenu: false,
|
||||
icon: <BugIcon />,
|
||||
url: UrlHelper.github,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openChangelog = createAction({
|
||||
export const openChangelog = createExternalLinkActionV2({
|
||||
name: ({ t }) => t("Changelog"),
|
||||
analyticsName: "Open changelog",
|
||||
section: NavigationSection,
|
||||
iconInContextMenu: false,
|
||||
icon: <OpenIcon />,
|
||||
perform: () => window.open(UrlHelper.changelog),
|
||||
url: UrlHelper.changelog,
|
||||
target: "_blank",
|
||||
});
|
||||
|
||||
export const openKeyboardShortcuts = createAction({
|
||||
export const openKeyboardShortcuts = createActionV2({
|
||||
name: ({ t }) => t("Keyboard shortcuts"),
|
||||
analyticsName: "Open keyboard shortcuts",
|
||||
section: NavigationSection,
|
||||
@@ -220,23 +232,19 @@ export const downloadApp = createAction({
|
||||
iconInContextMenu: false,
|
||||
icon: <BrowserIcon />,
|
||||
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
|
||||
perform: () => {
|
||||
window.open("https://desktop.getoutline.com");
|
||||
to: {
|
||||
url: "https://desktop.getoutline.com",
|
||||
target: "_blank",
|
||||
},
|
||||
});
|
||||
|
||||
export const logout = createAction({
|
||||
export const logout = createActionV2({
|
||||
name: ({ t }) => t("Log out"),
|
||||
analyticsName: "Log out",
|
||||
section: NavigationSection,
|
||||
icon: <LogoutIcon />,
|
||||
perform: async () => {
|
||||
await stores.auth.logout();
|
||||
if (env.OIDC_LOGOUT_URI) {
|
||||
setTimeout(() => {
|
||||
window.location.replace(env.OIDC_LOGOUT_URI);
|
||||
}, 200);
|
||||
}
|
||||
await stores.auth.logout({ userInitiated: true });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
|
||||
import { createAction } from "..";
|
||||
import { createAction, createActionV2 } from "..";
|
||||
import { NotificationSection } from "../sections";
|
||||
|
||||
export const markNotificationsAsRead = createAction({
|
||||
@@ -12,7 +12,7 @@ export const markNotificationsAsRead = createAction({
|
||||
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
|
||||
});
|
||||
|
||||
export const markNotificationsAsArchived = createAction({
|
||||
export const markNotificationsAsArchived = createActionV2({
|
||||
name: ({ t }) => t("Archive all notifications"),
|
||||
analyticsName: "Mark notifications as archived",
|
||||
section: NotificationSection,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
|
||||
import { matchPath } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { createAction, createActionV2 } from "~/actions";
|
||||
import { RevisionSection } from "~/actions/sections";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
matchDocumentHistory,
|
||||
} from "~/utils/routeHelpers";
|
||||
|
||||
export const restoreRevision = createAction({
|
||||
export const restoreRevision = createActionV2({
|
||||
name: ({ t }) => t("Restore"),
|
||||
analyticsName: "Restore revision",
|
||||
icon: <RestoreIcon />,
|
||||
@@ -73,7 +73,7 @@ export const deleteRevision = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyLinkToRevision = createAction({
|
||||
export const copyLinkToRevision = createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy link to revision",
|
||||
icon: <LinkIcon />,
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
|
||||
import stores from "~/stores";
|
||||
import { Theme } from "~/stores/UiStore";
|
||||
import { createAction } from "~/actions";
|
||||
import { createActionV2, createActionV2WithChildren } from "~/actions";
|
||||
import { SettingsSection } from "~/actions/sections";
|
||||
|
||||
export const changeToDarkTheme = createAction({
|
||||
export const changeToDarkTheme = createActionV2({
|
||||
name: ({ t }) => t("Dark"),
|
||||
analyticsName: "Change to dark theme",
|
||||
icon: <MoonIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme dark night",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "dark",
|
||||
perform: () => stores.ui.setTheme(Theme.Dark),
|
||||
selected: ({ stores }) => stores.ui.theme === "dark",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
|
||||
});
|
||||
|
||||
export const changeToLightTheme = createAction({
|
||||
export const changeToLightTheme = createActionV2({
|
||||
name: ({ t }) => t("Light"),
|
||||
analyticsName: "Change to light theme",
|
||||
icon: <SunIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme light day",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "light",
|
||||
perform: () => stores.ui.setTheme(Theme.Light),
|
||||
selected: ({ stores }) => stores.ui.theme === "light",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
|
||||
});
|
||||
|
||||
export const changeToSystemTheme = createAction({
|
||||
export const changeToSystemTheme = createActionV2({
|
||||
name: ({ t }) => t("System"),
|
||||
analyticsName: "Change to system theme",
|
||||
icon: <BrowserIcon />,
|
||||
iconInContextMenu: false,
|
||||
keywords: "theme system default",
|
||||
section: SettingsSection,
|
||||
selected: () => stores.ui.theme === "system",
|
||||
perform: () => stores.ui.setTheme(Theme.System),
|
||||
selected: ({ stores }) => stores.ui.theme === "system",
|
||||
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
|
||||
});
|
||||
|
||||
export const changeTheme = createAction({
|
||||
export const changeTheme = createActionV2WithChildren({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Appearance") : t("Change theme"),
|
||||
analyticsName: "Change theme",
|
||||
placeholder: ({ t }) => t("Change theme to"),
|
||||
icon: function _Icon() {
|
||||
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
|
||||
},
|
||||
icon: ({ stores }) =>
|
||||
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
|
||||
keywords: "appearance display",
|
||||
section: SettingsSection,
|
||||
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import Share from "~/models/Share";
|
||||
import { createActionV2, createInternalLinkActionV2 } from "..";
|
||||
import { ArrowIcon, CopyIcon, TrashIcon } from "outline-icons";
|
||||
import { ShareSection } from "../sections";
|
||||
import env from "~/env";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const copyShareUrlFactory = ({ share }: { share: Share }) =>
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Copy link"),
|
||||
analyticsName: "Copy share link",
|
||||
section: ShareSection,
|
||||
icon: <CopyIcon />,
|
||||
perform: ({ t }) => {
|
||||
copy(share.url, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
toast.success(t("Share link copied"));
|
||||
},
|
||||
});
|
||||
|
||||
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
|
||||
createInternalLinkActionV2({
|
||||
name: ({ t }) =>
|
||||
share.collectionId ? t("Go to collection") : t("Go to document"),
|
||||
analyticsName: "Go to share source",
|
||||
section: ShareSection,
|
||||
icon: <ArrowIcon />,
|
||||
to: {
|
||||
pathname: share.sourcePathWithFallback,
|
||||
state: { sidebarContext: "collections" }, // optimistic preference of "collections"
|
||||
},
|
||||
});
|
||||
|
||||
export const revokeShareFactory = ({
|
||||
share,
|
||||
can,
|
||||
}: {
|
||||
share: Share;
|
||||
can: Record<string, boolean>;
|
||||
}) =>
|
||||
createActionV2({
|
||||
name: ({ t }) => t("Revoke link"),
|
||||
analyticsName: "Revoke share",
|
||||
section: ShareSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: !!can.revoke,
|
||||
perform: async ({ t, stores }) => {
|
||||
try {
|
||||
await stores.shares.revoke(share);
|
||||
toast.message(t("Share link revoked"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -5,20 +5,24 @@ import RootStore from "~/stores/RootStore";
|
||||
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
|
||||
import TeamNew from "~/scenes/TeamNew";
|
||||
import TeamLogo from "~/components/TeamLogo";
|
||||
import { createAction } from "~/actions";
|
||||
import { ActionContext } from "~/types";
|
||||
import {
|
||||
createActionV2,
|
||||
createActionV2WithChildren,
|
||||
createExternalLinkActionV2,
|
||||
} from "~/actions";
|
||||
import { ActionContext, ExternalLinkActionV2 } from "~/types";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import { TeamSection } from "../sections";
|
||||
|
||||
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
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 (
|
||||
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
|
||||
createExternalLinkActionV2({
|
||||
id: `switch-${session.id}`,
|
||||
name: session.name,
|
||||
analyticsName: "Switch workspace",
|
||||
section: TeamSection,
|
||||
keywords: "change switch workspace organization team",
|
||||
icon: (
|
||||
<StyledTeamLogo
|
||||
alt={session.name}
|
||||
model={{
|
||||
@@ -29,13 +33,15 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
|
||||
}}
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
},
|
||||
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})) ?? [];
|
||||
),
|
||||
visible: ({ currentTeamId }: ActionContext) =>
|
||||
currentTeamId !== session.id,
|
||||
url: session.url,
|
||||
target: "_self",
|
||||
})
|
||||
) ?? [];
|
||||
|
||||
export const switchTeam = createAction({
|
||||
export const switchTeam = createActionV2WithChildren({
|
||||
name: ({ t }) => t("Switch workspace"),
|
||||
placeholder: ({ t }) => t("Select a workspace"),
|
||||
analyticsName: "Switch workspace",
|
||||
@@ -46,7 +52,7 @@ export const switchTeam = createAction({
|
||||
children: switchTeamsList,
|
||||
});
|
||||
|
||||
export const createTeam = createAction({
|
||||
export const createTeam = createActionV2({
|
||||
name: ({ t }) => `${t("New workspace")}…`,
|
||||
analyticsName: "New workspace",
|
||||
keywords: "create change switch workspace organization team",
|
||||
@@ -62,14 +68,13 @@ export const createTeam = createAction({
|
||||
if (user) {
|
||||
stores.dialogs.openModal({
|
||||
title: t("Create a workspace"),
|
||||
fullscreen: true,
|
||||
content: <TeamNew user={user} />,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const desktopLoginTeam = createAction({
|
||||
export const desktopLoginTeam = createActionV2({
|
||||
name: ({ t }) => t("Login to workspace"),
|
||||
analyticsName: "Login to workspace",
|
||||
keywords: "change switch workspace organization team",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UserChangeRoleDialog,
|
||||
UserDeleteDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
import { createAction } from "~/actions";
|
||||
import { createAction, createActionV2 } from "~/actions";
|
||||
import { UserSection } from "~/actions/sections";
|
||||
|
||||
export const inviteUser = createAction({
|
||||
@@ -28,7 +28,7 @@ export const inviteUser = createAction({
|
||||
});
|
||||
|
||||
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) =>
|
||||
UserRoleHelper.isRoleHigher(role, user!.role)
|
||||
? `${t("Promote to {{ role }}", {
|
||||
@@ -45,8 +45,8 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
return UserRoleHelper.isRoleHigher(role, user.role)
|
||||
? can.promote
|
||||
: UserRoleHelper.isRoleLower(role, user.role)
|
||||
? can.demote
|
||||
: false;
|
||||
? can.demote
|
||||
: false;
|
||||
},
|
||||
perform: ({ t }) => {
|
||||
stores.dialogs.openModal({
|
||||
@@ -63,7 +63,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
|
||||
});
|
||||
|
||||
export const deleteUserActionFactory = (userId: string) =>
|
||||
createAction({
|
||||
createActionV2({
|
||||
name: ({ t }) => `${t("Delete user")}…`,
|
||||
analyticsName: "Delete user",
|
||||
keywords: "leave",
|
||||
|
||||
+329
-13
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import flattenDeep from "lodash/flattenDeep";
|
||||
import { toast } from "sonner";
|
||||
import { Optional } from "utility-types";
|
||||
@@ -5,13 +6,24 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
CommandBarAction,
|
||||
ActionV2,
|
||||
ActionV2Group,
|
||||
ActionV2Separator as TActionV2Separator,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
ExternalLinkActionV2,
|
||||
InternalLinkActionV2,
|
||||
MenuExternalLink,
|
||||
MenuInternalLink,
|
||||
MenuItem,
|
||||
MenuItemButton,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import Analytics from "~/utils/Analytics";
|
||||
import history from "~/utils/history";
|
||||
import { Action as KbarAction } from "kbar";
|
||||
|
||||
function resolve<T>(value: any, context: ActionContext): T {
|
||||
export function resolve<T>(value: any, context: ActionContext): T {
|
||||
return typeof value === "function" ? value(context) : value;
|
||||
}
|
||||
|
||||
@@ -20,18 +32,17 @@ export function createAction(definition: Optional<Action, "id">): Action {
|
||||
...definition,
|
||||
perform: definition.perform
|
||||
? (context) => {
|
||||
// We muse use the specific analytics name here as the action name is
|
||||
// We must use the specific analytics name here as the action name is
|
||||
// translated and potentially contains user strings.
|
||||
if (definition.analyticsName) {
|
||||
Analytics.track("perform_action", definition.analyticsName, {
|
||||
context: context.isButton
|
||||
? "button"
|
||||
: context.isCommandBar
|
||||
? "commandbar"
|
||||
: "contextmenu",
|
||||
? "commandbar"
|
||||
: "contextmenu",
|
||||
});
|
||||
}
|
||||
|
||||
return definition.perform?.(context);
|
||||
}
|
||||
: undefined,
|
||||
@@ -42,7 +53,7 @@ export function createAction(definition: Optional<Action, "id">): Action {
|
||||
export function actionToMenuItem(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): MenuItemButton | MenuItemWithChildren {
|
||||
): MenuItemButton | MenuExternalLink | MenuInternalLink | 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;
|
||||
@@ -67,6 +78,26 @@ export function actionToMenuItem(
|
||||
};
|
||||
}
|
||||
|
||||
if (action.to) {
|
||||
return typeof action.to === "string"
|
||||
? {
|
||||
type: "route",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
to: action.to,
|
||||
selected: action.selected?.(context),
|
||||
}
|
||||
: {
|
||||
type: "link",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
href: action.to,
|
||||
selected: action.selected?.(context),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "button",
|
||||
title,
|
||||
@@ -81,7 +112,7 @@ export function actionToMenuItem(
|
||||
export function actionToKBar(
|
||||
action: Action,
|
||||
context: ActionContext
|
||||
): CommandBarAction[] {
|
||||
): KbarAction[] {
|
||||
if (typeof action.visible === "function" && !action.visible(context)) {
|
||||
return [];
|
||||
}
|
||||
@@ -99,7 +130,7 @@ export function actionToKBar(
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? (action.section.priority as number) ?? 0
|
||||
? ((action.section.priority as number) ?? 0)
|
||||
: 0;
|
||||
|
||||
return [
|
||||
@@ -113,9 +144,10 @@ export function actionToKBar(
|
||||
shortcut: action.shortcut || [],
|
||||
icon: resolvedIcon,
|
||||
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
|
||||
perform: action.perform
|
||||
? () => performAction(action, context)
|
||||
: undefined,
|
||||
perform:
|
||||
action.perform || action.to
|
||||
? () => performAction(action, context)
|
||||
: undefined,
|
||||
},
|
||||
].concat(
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
@@ -124,7 +156,13 @@ export function actionToKBar(
|
||||
}
|
||||
|
||||
export async function performAction(action: Action, context: ActionContext) {
|
||||
const result = action.perform?.(context);
|
||||
const result = action.perform
|
||||
? action.perform(context)
|
||||
: action.to
|
||||
? typeof action.to === "string"
|
||||
? history.push(action.to)
|
||||
: window.open(action.to.url, action.to.target)
|
||||
: undefined;
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch((err: Error) => {
|
||||
@@ -134,3 +172,281 @@ export async function performAction(action: Action, context: ActionContext) {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Actions V2 */
|
||||
|
||||
export const ActionV2Separator: TActionV2Separator = {
|
||||
type: "action_separator",
|
||||
};
|
||||
|
||||
export function createActionV2(
|
||||
definition: Optional<Omit<ActionV2, "type" | "variant">, "id">
|
||||
): ActionV2 {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "action",
|
||||
perform: definition.perform
|
||||
? (context) => {
|
||||
// We must use the specific analytics name here as the action name is
|
||||
// translated and potentially contains user strings.
|
||||
if (definition.analyticsName) {
|
||||
Analytics.track("perform_action", definition.analyticsName, {
|
||||
context: context.isButton
|
||||
? "button"
|
||||
: context.isCommandBar
|
||||
? "commandbar"
|
||||
: "contextmenu",
|
||||
});
|
||||
}
|
||||
return definition.perform(context);
|
||||
}
|
||||
: () => {},
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createInternalLinkActionV2(
|
||||
definition: Optional<Omit<InternalLinkActionV2, "type" | "variant">, "id">
|
||||
): InternalLinkActionV2 {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "internal_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createExternalLinkActionV2(
|
||||
definition: Optional<Omit<ExternalLinkActionV2, "type" | "variant">, "id">
|
||||
): ExternalLinkActionV2 {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "external_link",
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionV2WithChildren(
|
||||
definition: Optional<Omit<ActionV2WithChildren, "type" | "variant">, "id">
|
||||
): ActionV2WithChildren {
|
||||
return {
|
||||
...definition,
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
id: definition.id ?? uuidv4(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createActionV2Group(
|
||||
definition: Omit<ActionV2Group, "type">
|
||||
): ActionV2Group {
|
||||
return {
|
||||
...definition,
|
||||
type: "action_group",
|
||||
};
|
||||
}
|
||||
|
||||
export function createRootMenuAction(
|
||||
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
|
||||
): ActionV2WithChildren {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
type: "action",
|
||||
variant: "action_with_children",
|
||||
name: "root_action",
|
||||
section: "Root",
|
||||
children: actions,
|
||||
};
|
||||
}
|
||||
|
||||
export function actionV2ToMenuItem(
|
||||
action: ActionV2Variant | ActionV2Group | TActionV2Separator,
|
||||
context: ActionContext
|
||||
): MenuItem {
|
||||
switch (action.type) {
|
||||
case "action": {
|
||||
const title = resolve<string>(action.name, context);
|
||||
const visible = resolve<boolean>(action.visible, context) ?? true;
|
||||
const disabled = resolve<boolean>(action.disabled, context);
|
||||
const icon =
|
||||
!!action.icon && action.iconInContextMenu !== false
|
||||
? resolve<React.ReactNode>(action.icon, context)
|
||||
: undefined;
|
||||
|
||||
switch (action.variant) {
|
||||
case "action":
|
||||
return {
|
||||
type: "button",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
tooltip: resolve<React.ReactChild>(action.tooltip, context),
|
||||
selected: resolve<boolean>(action.selected, context),
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => performActionV2(action, context),
|
||||
};
|
||||
|
||||
case "internal_link": {
|
||||
const to = resolve<LocationDescriptor>(action.to, context);
|
||||
return {
|
||||
type: "route",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
case "external_link":
|
||||
return {
|
||||
type: "link",
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
disabled,
|
||||
href: action.target
|
||||
? { url: action.url, target: action.target }
|
||||
: action.url,
|
||||
};
|
||||
|
||||
case "action_with_children": {
|
||||
const children = resolve<
|
||||
(ActionV2Variant | ActionV2Group | TActionV2Separator)[]
|
||||
>(action.children, context);
|
||||
const subMenuItems = children.map((a) =>
|
||||
actionV2ToMenuItem(a, context)
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title,
|
||||
icon,
|
||||
items: subMenuItems,
|
||||
disabled,
|
||||
visible: visible && hasVisibleItems(subMenuItems),
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw Error("invalid action variant");
|
||||
}
|
||||
}
|
||||
|
||||
case "action_group": {
|
||||
const groupItems = action.actions.map((a) =>
|
||||
actionV2ToMenuItem(a, context)
|
||||
);
|
||||
return {
|
||||
type: "group",
|
||||
title: resolve<string>(action.name, context),
|
||||
visible: hasVisibleItems(groupItems),
|
||||
items: groupItems,
|
||||
};
|
||||
}
|
||||
|
||||
case "action_separator":
|
||||
return { type: "separator" };
|
||||
}
|
||||
}
|
||||
|
||||
export function actionV2ToKBar(
|
||||
action: ActionV2Variant,
|
||||
context: ActionContext
|
||||
): KbarAction[] {
|
||||
const visible = resolve<boolean>(action.visible, context);
|
||||
if (visible === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const name = resolve<string>(action.name, context);
|
||||
const icon = resolve<React.ReactElement>(action.icon, context);
|
||||
const section = resolve<string>(action.section, context);
|
||||
|
||||
const sectionPriority =
|
||||
typeof action.section !== "string" && "priority" in action.section
|
||||
? ((action.section.priority as number) ?? 0)
|
||||
: 0;
|
||||
|
||||
const priority = (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0));
|
||||
|
||||
switch (action.variant) {
|
||||
case "action":
|
||||
case "internal_link":
|
||||
case "external_link": {
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
perform: () => performActionV2(action, context),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case "action_with_children": {
|
||||
const resolvedChildren = resolve<ActionV2Variant[]>(
|
||||
action.children,
|
||||
context
|
||||
);
|
||||
const children = resolvedChildren
|
||||
.map((a) => actionV2ToKBar(a, context))
|
||||
.flat()
|
||||
.filter(Boolean);
|
||||
|
||||
return [
|
||||
{
|
||||
id: action.id,
|
||||
name,
|
||||
section,
|
||||
keywords: action.keywords,
|
||||
shortcut: action.shortcut,
|
||||
icon,
|
||||
priority,
|
||||
},
|
||||
...children.map((child) => ({
|
||||
...child,
|
||||
parent: child.parent ?? action.id,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
default:
|
||||
throw Error("invalid action variant");
|
||||
}
|
||||
}
|
||||
|
||||
export async function performActionV2(
|
||||
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
|
||||
context: ActionContext
|
||||
) {
|
||||
const perform =
|
||||
action.variant === "action"
|
||||
? () => action.perform(context)
|
||||
: action.variant === "internal_link"
|
||||
? () => history.push(resolve<LocationDescriptor>(action.to, context))
|
||||
: () => window.open(action.url, action.target);
|
||||
|
||||
const result = perform();
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch((err: Error) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasVisibleItems(items: MenuItem[]) {
|
||||
const applicableTypes = ["button", "link", "route", "group", "submenu"];
|
||||
return items.some(
|
||||
(item) => applicableTypes.includes(item.type) && item.visible
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,10 +36,14 @@ export const NavigationSection = ({ t }: ActionContext) => t("Navigation");
|
||||
|
||||
export const NotificationSection = ({ t }: ActionContext) => t("Notification");
|
||||
|
||||
export const GroupSection = ({ t }: ActionContext) => t("Groups");
|
||||
|
||||
export const UserSection = ({ t }: ActionContext) => t("People");
|
||||
|
||||
UserSection.priority = 0.5;
|
||||
|
||||
export const ShareSection = ({ t }: ActionContext) => t("Share");
|
||||
|
||||
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
|
||||
|
||||
export const RecentSearchesSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
/* oxlint-disable react/prop-types */
|
||||
import * as React from "react";
|
||||
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
|
||||
import { performAction } from "~/actions";
|
||||
import { performAction, performActionV2, resolve } from "~/actions";
|
||||
import useIsMounted from "~/hooks/useIsMounted";
|
||||
import { Action, ActionContext } from "~/types";
|
||||
import {
|
||||
Action,
|
||||
ActionContext,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
} from "~/types";
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Show the button in a disabled state */
|
||||
@@ -11,7 +16,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
|
||||
/** Hide the button entirely if action is not applicable */
|
||||
hideOnActionDisabled?: boolean;
|
||||
/** Action to use on button */
|
||||
action?: Action;
|
||||
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
|
||||
/** Context of action, must be provided with action */
|
||||
context?: ActionContext;
|
||||
/** If tooltip props are provided the button will be wrapped in a tooltip */
|
||||
@@ -40,8 +45,8 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
const actionContext = { ...context, isButton: true };
|
||||
|
||||
if (
|
||||
action?.visible &&
|
||||
!action.visible(actionContext) &&
|
||||
action.visible &&
|
||||
!resolve<boolean>(action.visible, actionContext) &&
|
||||
hideOnActionDisabled
|
||||
) {
|
||||
return null;
|
||||
@@ -59,11 +64,14 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
disabled={disabled || executing}
|
||||
ref={ref}
|
||||
onClick={
|
||||
action?.perform && actionContext
|
||||
actionContext
|
||||
? (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const response = performAction(action, actionContext);
|
||||
const response =
|
||||
"variant" in action
|
||||
? performActionV2(action, actionContext)
|
||||
: performAction(action, actionContext);
|
||||
if (response?.finally) {
|
||||
setExecuting(true);
|
||||
void response.finally(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable prefer-rest-params */
|
||||
/* oxlint-disable prefer-rest-params */
|
||||
/* global ga */
|
||||
import escape from "lodash/escape";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -31,7 +31,12 @@ const Authenticated = ({ children }: Props) => {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
void auth.logout({ savePath: true });
|
||||
|
||||
if (auth.logoutRedirectUri) {
|
||||
window.location.href = auth.logoutRedirectUri;
|
||||
return null;
|
||||
}
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { DocumentContextProvider } from "./DocumentContext";
|
||||
import Fade from "./Fade";
|
||||
@@ -39,9 +38,7 @@ const DocumentComments = lazyWithRetry(
|
||||
const DocumentHistory = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
const DocumentInsights = lazyWithRetry(
|
||||
() => import("~/scenes/Document/components/Insights")
|
||||
);
|
||||
|
||||
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
|
||||
|
||||
type Props = {
|
||||
@@ -98,12 +95,7 @@ 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 &&
|
||||
@@ -115,12 +107,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
initial={false}
|
||||
key={ui.activeDocumentId ? "active" : "inactive"}
|
||||
>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
{(showHistory || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showInsights && <DocumentInsights />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-13
@@ -1,5 +1,4 @@
|
||||
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 +9,7 @@ type Props = {
|
||||
|
||||
function Branding({ href = env.URL }: Props) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<Link href={href} target="_blank">
|
||||
<OutlineIcon size={20} />
|
||||
{env.APP_NAME}
|
||||
</Link>
|
||||
@@ -33,18 +32,16 @@ const Link = styled.a`
|
||||
fill: ${s("text")};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
z-index: ${depths.sidebar + 1};
|
||||
background: ${s("sidebarBackground")};
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
z-index: ${depths.sidebar + 1};
|
||||
background: ${s("sidebarBackground")};
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
padding: 16px;
|
||||
|
||||
&:hover {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`};
|
||||
&:hover {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Branding;
|
||||
|
||||
@@ -6,55 +6,89 @@ import { s, ellipsis } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
|
||||
import { undraggableOnDesktop } from "~/styles";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
type TopLevelAction =
|
||||
| InternalLinkActionV2
|
||||
| { type: "menu"; actions: InternalLinkActionV2[] };
|
||||
|
||||
type Props = React.PropsWithChildren<{
|
||||
items: MenuInternalLink[];
|
||||
actions: InternalLinkActionV2[];
|
||||
max?: number;
|
||||
highlightFirstItem?: boolean;
|
||||
}>;
|
||||
|
||||
function Breadcrumb(
|
||||
{ items, highlightFirstItem, children, max = 2 }: Props,
|
||||
{ actions, highlightFirstItem, children, max = 2 }: Props,
|
||||
ref: React.RefObject<HTMLDivElement> | null
|
||||
) {
|
||||
const totalItems = items.length;
|
||||
const topLevelItems: MenuInternalLink[] = [...items];
|
||||
let overflowItems;
|
||||
const actionContext = useActionContext({ isContextMenu: true });
|
||||
|
||||
const visibleActions = useComputed(
|
||||
() =>
|
||||
actions.filter((action) =>
|
||||
typeof action.visible === "function"
|
||||
? action.visible(actionContext)
|
||||
: (action.visible ?? true)
|
||||
),
|
||||
[actions, actionContext]
|
||||
);
|
||||
const totalVisibleActions = visibleActions.length;
|
||||
|
||||
const topLevelActions: TopLevelAction[] = [...visibleActions];
|
||||
|
||||
// chop middle breadcrumbs and present a "..." menu instead
|
||||
if (totalItems > max) {
|
||||
if (totalVisibleActions > max) {
|
||||
const halfMax = Math.floor(max / 2);
|
||||
overflowItems = topLevelItems.splice(halfMax, totalItems - max);
|
||||
const menuActions = topLevelActions.splice(
|
||||
halfMax,
|
||||
totalVisibleActions - max
|
||||
) as InternalLinkActionV2[];
|
||||
|
||||
topLevelItems.splice(halfMax, 0, {
|
||||
to: "",
|
||||
type: "route",
|
||||
title: <BreadcrumbMenu items={overflowItems as MenuInternalLink[]} />,
|
||||
topLevelActions.splice(halfMax, 0, {
|
||||
type: "menu",
|
||||
actions: menuActions,
|
||||
});
|
||||
}
|
||||
|
||||
const toBreadcrumb = React.useCallback(
|
||||
(action: TopLevelAction, index: number) => {
|
||||
if (action.type === "menu") {
|
||||
return <BreadcrumbMenu key="menu" actions={action.actions} />;
|
||||
}
|
||||
|
||||
const item = actionV2ToMenuItem(
|
||||
action,
|
||||
actionContext
|
||||
) as MenuInternalLink;
|
||||
|
||||
return (
|
||||
<>
|
||||
{item.icon}
|
||||
<Item
|
||||
to={item.to}
|
||||
$withIcon={!!item.icon}
|
||||
$highlight={!!highlightFirstItem && index === 0}
|
||||
>
|
||||
{item.title}
|
||||
</Item>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[actionContext, highlightFirstItem]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex justify="flex-start" align="center" ref={ref}>
|
||||
{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}
|
||||
{topLevelActions.map((action, index) => (
|
||||
<React.Fragment key={action.type === "menu" ? "menu" : `item-${index}`}>
|
||||
{toBreadcrumb(action, index)}
|
||||
{index !== topLevelActions.length - 1 || !!children ? (
|
||||
<Slash />
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{children}
|
||||
|
||||
@@ -176,7 +176,7 @@ const Button = <T extends React.ElementType = "button">(
|
||||
...rest
|
||||
} = props;
|
||||
const hasText = !!children || value !== undefined;
|
||||
const ic = hideIcon ? undefined : action?.icon ?? icon;
|
||||
const ic = hideIcon ? undefined : (action?.icon ?? icon);
|
||||
const hasIcon = ic !== undefined;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import filter from "lodash/filter";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import orderBy from "lodash/orderBy";
|
||||
@@ -6,16 +5,18 @@ import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
|
||||
import DocumentViews from "~/components/DocumentViews";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
/** The document to display live collaborators for */
|
||||
@@ -24,21 +25,6 @@ type Props = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
// Styled components to match the original Popover styling
|
||||
const StyledPopoverContent = styled(Popover.Content)`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 12px 24px;
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
z-index: ${depths.modal};
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Displays a list of live collaborators for a document, including their avatars
|
||||
* and presence status.
|
||||
@@ -49,7 +35,6 @@ function Collaborators(props: Props) {
|
||||
const user = useCurrentUser();
|
||||
const currentUserId = user?.id;
|
||||
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const { users, presence, ui } = useStores();
|
||||
const { document } = props;
|
||||
const { observingUserId } = ui;
|
||||
@@ -116,11 +101,11 @@ function Collaborators(props: Props) {
|
||||
// Memoize onClick handler to avoid inline function creation
|
||||
const handleAvatarClick = useCallback(
|
||||
(
|
||||
collaboratorId: string,
|
||||
isPresent: boolean,
|
||||
isObserving: boolean,
|
||||
isObservable: boolean
|
||||
) =>
|
||||
collaboratorId: string,
|
||||
isPresent: boolean,
|
||||
isObserving: boolean,
|
||||
isObservable: boolean
|
||||
) =>
|
||||
(ev: React.MouseEvent) => {
|
||||
if (isObservable && isPresent) {
|
||||
ev.preventDefault();
|
||||
@@ -163,9 +148,13 @@ function Collaborators(props: Props) {
|
||||
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
|
||||
);
|
||||
|
||||
if (!document.insightsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<NudeButton
|
||||
width={Math.min(collaborators.length, limit) * AvatarSize.Large}
|
||||
height={AvatarSize.Large}
|
||||
@@ -178,19 +167,11 @@ function Collaborators(props: Props) {
|
||||
renderAvatar={renderAvatar}
|
||||
/>
|
||||
</NudeButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<StyledPopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={0}
|
||||
aria-label={t("Viewers")}
|
||||
style={{ width: 300 }}
|
||||
>
|
||||
<DocumentViews document={document} />
|
||||
</StyledPopoverContent>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent aria-label={t("Viewers")} side="bottom" align="end">
|
||||
<DocumentViews document={document} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -125,6 +125,8 @@ 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">
|
||||
@@ -145,7 +147,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
<StyledIconPicker
|
||||
icon={values.icon}
|
||||
color={values.color ?? iconColor}
|
||||
initial={values.name[0]}
|
||||
initial={initial}
|
||||
popoverPosition="right"
|
||||
onOpen={setHasOpenedIconPicker}
|
||||
onChange={handleIconChange}
|
||||
@@ -172,7 +174,7 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
) => {
|
||||
field.onChange(value === EmptySelectValue ? null : value);
|
||||
}}
|
||||
note={t(
|
||||
help={t(
|
||||
"The default access for workspace members, you can share with more users or groups later."
|
||||
)}
|
||||
/>
|
||||
@@ -224,8 +226,8 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
? `${t("Saving")}…`
|
||||
: t("Save")
|
||||
: formState.isSubmitting
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
@@ -3,9 +3,10 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, collectionPath } from "~/utils/routeHelpers";
|
||||
import Breadcrumb from "./Breadcrumb";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveCollectionSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
@@ -14,32 +15,24 @@ type Props = {
|
||||
export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const collectionNode: MenuInternalLink = {
|
||||
type: "route",
|
||||
title: collection.name,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
};
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
createInternalLinkActionV2({
|
||||
name: t("Archive"),
|
||||
section: ActiveCollectionSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: collection.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: collection.name,
|
||||
section: ActiveCollectionSection,
|
||||
icon: <CollectionIcon collection={collection} expanded />,
|
||||
to: collectionPath(collection.path),
|
||||
}),
|
||||
],
|
||||
[collection, t]
|
||||
);
|
||||
|
||||
const 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 />;
|
||||
return <Breadcrumb actions={actions} highlightFirstItem />;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import Icon from "@shared/components/Icon";
|
||||
import { createAction } from "~/actions";
|
||||
import { RecentSection } from "~/actions/sections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
const useRecentDocumentActions = (count = 6) => {
|
||||
@@ -25,7 +24,7 @@ const useRecentDocumentActions = (count = 6) => {
|
||||
) : (
|
||||
<DocumentIcon />
|
||||
),
|
||||
perform: () => history.push(documentPath(item)),
|
||||
to: documentPath(item),
|
||||
})
|
||||
),
|
||||
[count, ui.activeDocumentId, documents.recentlyViewed]
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useMemo } from "react";
|
||||
import { createAction } from "~/actions";
|
||||
import { NavigationSection } from "~/actions/sections";
|
||||
import useSettingsConfig from "~/hooks/useSettingsConfig";
|
||||
import history from "~/utils/history";
|
||||
|
||||
const useSettingsAction = () => {
|
||||
const config = useSettingsConfig();
|
||||
@@ -16,7 +15,7 @@ const useSettingsAction = () => {
|
||||
name: item.name,
|
||||
icon: <Icon />,
|
||||
section: NavigationSection,
|
||||
perform: () => history.push(item.path),
|
||||
to: item.path,
|
||||
};
|
||||
}),
|
||||
[config]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
dangerous?: boolean;
|
||||
to?: LocationDescriptor;
|
||||
href?: string;
|
||||
target?: "_blank";
|
||||
target?: string;
|
||||
as?: string | React.ComponentType<any>;
|
||||
hide?: () => void;
|
||||
level?: number;
|
||||
|
||||
@@ -155,12 +155,14 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
return (
|
||||
<MenuItem
|
||||
id={`${item.title}-${index}`}
|
||||
href={item.href}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
selected={item.selected}
|
||||
level={item.level}
|
||||
target={item.href.startsWith("#") ? undefined : "_blank"}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
}
|
||||
icon={showIcons !== false ? item.icon : undefined}
|
||||
{...menu}
|
||||
>
|
||||
@@ -231,6 +233,12 @@ function Template({ items, actions, context, showIcons, ...menu }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// This should never be reached for Reakit dropdown menu.
|
||||
// Added for exhaustiveness check.
|
||||
if (item.type === "group") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const _exhaustiveCheck: never = item;
|
||||
return _exhaustiveCheck;
|
||||
})}
|
||||
|
||||
@@ -119,7 +119,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
>
|
||||
{(props) => (
|
||||
<InnerContextMenu
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
menuProps={props as any}
|
||||
{...rest}
|
||||
isSubMenu={isSubMenu}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { InputSelectNew, Option } from "~/components/InputSelectNew";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
type DefaultCollectionInputSelectProps = {
|
||||
@@ -70,11 +70,10 @@ const DefaultCollectionInputSelect = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelectNew
|
||||
<InputSelect
|
||||
options={options}
|
||||
value={defaultCollectionId ?? "home"}
|
||||
onChange={onSelectCollection}
|
||||
ariaLabel={t("Default collection")}
|
||||
label={t("Start view")}
|
||||
hideLabel
|
||||
short
|
||||
|
||||
@@ -6,6 +6,8 @@ import useStores from "~/hooks/useStores";
|
||||
function Dialogs() {
|
||||
const { dialogs } = useStores();
|
||||
const { guide, modalStack } = dialogs;
|
||||
const modals = [...modalStack];
|
||||
|
||||
return (
|
||||
<>
|
||||
{guide ? (
|
||||
@@ -17,11 +19,10 @@ function Dialogs() {
|
||||
{guide.content}
|
||||
</Guide>
|
||||
) : undefined}
|
||||
{[...modalStack].map(([id, modal]) => (
|
||||
{modals.map(([id, modal]) => (
|
||||
<Modal
|
||||
key={id}
|
||||
isOpen={modal.isOpen}
|
||||
fullscreen={modal.fullscreen ?? false}
|
||||
onRequestClose={() => {
|
||||
modal.onClose?.();
|
||||
dialogs.closeModal(id);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import { observer } from "mobx-react";
|
||||
import capitalize from "lodash/capitalize";
|
||||
|
||||
type Props = {
|
||||
integration: Integration<IntegrationType.Analytics>;
|
||||
};
|
||||
|
||||
export const DisconnectAnalyticsDialog = observer(({ integration }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const history = useHistory();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await integration.delete();
|
||||
history.push(settingsPath("integrations"));
|
||||
dialogs.closeAllModals();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Disconnect")}
|
||||
savingText={`${t("Disconnecting")}…`}
|
||||
danger
|
||||
>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Are you sure you want to disconnect the <em>{{ service }}</em> integration?"
|
||||
values={{
|
||||
service: capitalize(integration.service),
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans defaults="This will stop sending analytics events to the configured instance." />
|
||||
</Text>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
});
|
||||
@@ -11,8 +11,9 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { MenuInternalLink } from "~/types";
|
||||
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
|
||||
import { createInternalLinkActionV2 } from "~/actions";
|
||||
import { ActiveDocumentSection } from "~/actions/sections";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -27,46 +28,12 @@ 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)
|
||||
@@ -78,69 +45,91 @@ 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 items = React.useMemo(() => {
|
||||
const output: MenuInternalLink[] = [];
|
||||
|
||||
const actions = React.useMemo(() => {
|
||||
if (depth === 0) {
|
||||
return output;
|
||||
return [];
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
});
|
||||
});
|
||||
const outputActions = [
|
||||
createInternalLinkActionV2({
|
||||
name: t("Trash"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
visible: document.isDeleted,
|
||||
to: trashPath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Archive"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: document.isArchived,
|
||||
to: archivePath(),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Templates"),
|
||||
section: ActiveDocumentSection,
|
||||
icon: <ShapesIcon />,
|
||||
visible: document.template,
|
||||
to: settingsPath("templates"),
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: collection?.name,
|
||||
section: ActiveDocumentSection,
|
||||
icon: collection ? (
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
) : undefined,
|
||||
visible: !!(collection && can.readDocument),
|
||||
to: collection
|
||||
? {
|
||||
pathname: collection.path,
|
||||
state: { sidebarContext },
|
||||
}
|
||||
: "",
|
||||
}),
|
||||
createInternalLinkActionV2({
|
||||
name: t("Deleted Collection"),
|
||||
section: ActiveDocumentSection,
|
||||
visible: document.isCollectionDeleted,
|
||||
to: "",
|
||||
}),
|
||||
...path.map((node) => {
|
||||
const title = node.title || t("Untitled");
|
||||
return createInternalLinkActionV2({
|
||||
name: node.icon ? (
|
||||
<>
|
||||
<StyledIcon value={node.icon} color={node.color} /> {title}
|
||||
</>
|
||||
) : (
|
||||
title
|
||||
),
|
||||
section: ActiveDocumentSection,
|
||||
to: {
|
||||
pathname: node.url,
|
||||
state: { sidebarContext },
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
return reverse
|
||||
? depth !== undefined
|
||||
? output.slice(-depth)
|
||||
: output
|
||||
? outputActions.slice(-depth)
|
||||
: outputActions
|
||||
: depth !== undefined
|
||||
? output.slice(0, depth)
|
||||
: output;
|
||||
}, [t, path, category, sidebarContext, collectionNode, reverse, depth]);
|
||||
? outputActions.slice(0, depth)
|
||||
: outputActions;
|
||||
}, [
|
||||
t,
|
||||
document,
|
||||
collection,
|
||||
can.readDocument,
|
||||
sidebarContext,
|
||||
path,
|
||||
reverse,
|
||||
depth,
|
||||
]);
|
||||
|
||||
if (!collections.isLoaded) {
|
||||
return null;
|
||||
@@ -176,7 +165,7 @@ function DocumentBreadcrumb(
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb items={items} ref={ref} highlightFirstItem>
|
||||
<Breadcrumb actions={actions} ref={ref} highlightFirstItem>
|
||||
{children}
|
||||
</Breadcrumb>
|
||||
);
|
||||
|
||||
@@ -123,6 +123,7 @@ function DocumentCard(props: Props) {
|
||||
<DocumentSquircle
|
||||
icon={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
) : (
|
||||
<Squircle
|
||||
@@ -194,17 +195,22 @@ const ReadingTime = ({ document }: { document: Document }) => {
|
||||
const DocumentSquircle = ({
|
||||
icon,
|
||||
color,
|
||||
initial,
|
||||
}: {
|
||||
icon: string;
|
||||
color?: string;
|
||||
initial?: string;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const iconType = determineIconType(icon)!;
|
||||
const squircleColor = iconType === IconType.SVG ? color : theme.slateLight;
|
||||
const style = {
|
||||
"--background": squircleColor,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<Squircle color={squircleColor}>
|
||||
<Icon value={icon} color={theme.white} forceColor />
|
||||
<Squircle color={squircleColor} style={style}>
|
||||
<Icon value={icon} color={theme.white} initial={initial} forceColor />
|
||||
</Squircle>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import flatten from "lodash/flatten";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -11,7 +10,6 @@ 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";
|
||||
|
||||
@@ -32,7 +30,7 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
);
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
const nodes = flatten(collectionTrees.map(flattenTree)).filter((node) =>
|
||||
const nodes = collectionTrees.filter((node) =>
|
||||
node.collectionId
|
||||
? policies.get(node.collectionId)?.abilities.createDocument
|
||||
: true
|
||||
@@ -78,34 +76,32 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
onSelect={selectPath}
|
||||
defaultValue={document.parentDocumentId || document.collectionId || ""}
|
||||
/>
|
||||
<OptionsContainer>
|
||||
{!document.isTemplate && (
|
||||
<>
|
||||
{document.collectionId && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="publish"
|
||||
label={t("Publish")}
|
||||
labelPosition="right"
|
||||
checked={publish}
|
||||
onChange={setPublish}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{document.publishedAt && document.childDocuments.length > 0 && (
|
||||
<Text size="small">
|
||||
<Switch
|
||||
name="recursive"
|
||||
label={t("Include nested documents")}
|
||||
labelPosition="right"
|
||||
checked={recursive}
|
||||
onChange={setRecursive}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OptionsContainer>
|
||||
{!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>
|
||||
)}
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
{selectedPath ? (
|
||||
@@ -127,9 +123,11 @@ function DocumentCopy({ document, onSubmit }: Props) {
|
||||
}
|
||||
|
||||
const OptionsContainer = styled.div`
|
||||
margin: 16px 0 8px 0;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding: 16px 24px 0;
|
||||
margin-bottom: -1px;
|
||||
background: ${(props) => props.theme.modalBackground};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export default observer(DocumentCopy);
|
||||
|
||||
@@ -15,7 +15,7 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { NavigationNode, NavigationNodeType } from "@shared/types";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
|
||||
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
|
||||
@@ -26,7 +26,8 @@ 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";
|
||||
import { ancestors, descendants, flattenTree } from "~/utils/tree";
|
||||
import flatten from "lodash/flatten";
|
||||
|
||||
type Props = {
|
||||
/** Action taken upon submission of selected item, could be publish, move etc. */
|
||||
@@ -78,13 +79,9 @@ 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(items, ["title"], {
|
||||
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
|
||||
caseSensitive: false,
|
||||
}),
|
||||
[items]
|
||||
@@ -129,12 +126,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
|
||||
return searchTerm
|
||||
? searchIndex.search(searchTerm)
|
||||
: items
|
||||
.filter((item) => recentlyViewedItemIds.includes(item.id))
|
||||
.concat(
|
||||
items.filter((item) => item.type === NavigationNodeType.Collection)
|
||||
)
|
||||
.flatMap(includeDescendants);
|
||||
: items.flatMap(includeDescendants);
|
||||
}
|
||||
|
||||
const nodes = getNodes();
|
||||
@@ -142,6 +134,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
|
||||
Infinity
|
||||
);
|
||||
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
|
||||
|
||||
const scrollNodeIntoView = React.useCallback(
|
||||
(node: number) => {
|
||||
@@ -315,7 +308,7 @@ function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
|
||||
expanded={isExpanded(index)}
|
||||
icon={renderedIcon}
|
||||
title={title}
|
||||
depth={(node.depth ?? 0) - baseDepth}
|
||||
depth={(node.depth ?? 0) - normalizedBaseDepth}
|
||||
hasChildren={hasChildren(index)}
|
||||
ref={itemRefs[index]}
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,6 @@ type Props = {
|
||||
showParentDocuments?: boolean;
|
||||
showCollection?: boolean;
|
||||
showPublished?: boolean;
|
||||
showPin?: boolean;
|
||||
showDraft?: boolean;
|
||||
showTemplate?: boolean;
|
||||
};
|
||||
@@ -68,7 +67,6 @@ function DocumentListItem(
|
||||
showParentDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
showDraft = true,
|
||||
showTemplate,
|
||||
highlight,
|
||||
@@ -107,7 +105,11 @@ function DocumentListItem(
|
||||
<Heading dir={document.dir}>
|
||||
{document.icon && (
|
||||
<>
|
||||
<Icon value={document.icon} color={document.color ?? undefined} />
|
||||
<Icon
|
||||
value={document.icon}
|
||||
color={document.color ?? undefined}
|
||||
initial={document.initial}
|
||||
/>
|
||||
|
||||
</>
|
||||
)}
|
||||
@@ -152,10 +154,8 @@ function DocumentListItem(
|
||||
<Actions>
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
showPin={showPin}
|
||||
onOpen={handleMenuOpen}
|
||||
onClose={handleMenuClose}
|
||||
modal={false}
|
||||
/>
|
||||
</Actions>
|
||||
</DocumentLink>
|
||||
@@ -177,7 +177,9 @@ const Actions = styled(EventBoundary)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
${NudeButton} {
|
||||
&: ${hover}, &[aria-expanded= "true"] {
|
||||
&:
|
||||
${hover},
|
||||
&[aria-expanded= "true"] {
|
||||
background: ${s("sidebarControlHoverBackground")};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
grow={props.grow}
|
||||
style={props.style}
|
||||
editorStyle={props.editorStyle}
|
||||
commenting={!!props.onClickCommentMark}
|
||||
>
|
||||
<div className="ProseMirror">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
|
||||
@@ -13,6 +13,9 @@ import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
import Flex from "./Flex";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
/** Whether to reload the page if a chunk fails to load. */
|
||||
@@ -23,6 +26,9 @@ type Props = WithTranslation & {
|
||||
component?: React.ComponentType | string;
|
||||
};
|
||||
|
||||
const ERROR_TRACKING_KEY = "error-boundary-tracking";
|
||||
const ERROR_TRACKING_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
@observer
|
||||
class ErrorBoundary extends React.Component<Props> {
|
||||
@observable
|
||||
@@ -31,6 +37,13 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
@observable
|
||||
showDetails = false;
|
||||
|
||||
@observable
|
||||
isRepeatedError = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.checkForPreviousErrors();
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
this.error = error;
|
||||
|
||||
@@ -46,9 +59,47 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackError();
|
||||
Logger.error("ErrorBoundary", error);
|
||||
}
|
||||
|
||||
private checkForPreviousErrors = () => {
|
||||
try {
|
||||
const stored = Storage.get(ERROR_TRACKING_KEY);
|
||||
if (!stored) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: number[] = JSON.parse(stored);
|
||||
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
|
||||
const recentErrors = errors.filter((timestamp) => timestamp > cutoff);
|
||||
|
||||
this.isRepeatedError = recentErrors.length > 0;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to parse stored errors for error boundary", { err });
|
||||
}
|
||||
};
|
||||
|
||||
private trackError = () => {
|
||||
try {
|
||||
const stored = Storage.get(ERROR_TRACKING_KEY);
|
||||
const errors: number[] = stored ? JSON.parse(stored) : [];
|
||||
const cutoff = Date.now() - ERROR_TRACKING_WINDOW_MS;
|
||||
|
||||
// Filter out old errors and add current one
|
||||
const updatedErrors = [
|
||||
...errors.filter((timestamp) => timestamp > cutoff),
|
||||
Date.now(),
|
||||
];
|
||||
|
||||
Storage.set(ERROR_TRACKING_KEY, JSON.stringify(updatedErrors));
|
||||
|
||||
this.isRepeatedError = updatedErrors.length > 1;
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to track error in error boundary", { err });
|
||||
}
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
@@ -61,6 +112,12 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
window.open(isCloudHosted ? UrlHelper.contact : UrlHelper.github);
|
||||
};
|
||||
|
||||
handleClearCache = async () => {
|
||||
await deleteAllDatabases();
|
||||
Storage.clear();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { t, component: Component = CenteredContent, showTitle } = this.props;
|
||||
|
||||
@@ -107,29 +164,46 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
values={{
|
||||
notified: isReported
|
||||
? ` – ${t("our engineers have been notified")}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{this.showDetails && <Pre>{error.toString()}</Pre>}
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>{t("Reload")}</Button>{" "}
|
||||
|
||||
{this.isRepeatedError ? (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
An error has occurred multiple times recently. If it continues
|
||||
please try clearing the cache or using a different browser.
|
||||
</Trans>
|
||||
</Text>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Sorry, an unrecoverable error occurred{{notified}}. Please try reloading the page, it may have been a temporary glitch."
|
||||
values={{
|
||||
notified: isReported
|
||||
? ` – ${t("our engineers have been notified")}`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{this.showDetails && <Pre>{error.stack}</Pre>}
|
||||
<Flex gap={8} wrap>
|
||||
{this.isRepeatedError && (
|
||||
<Button onClick={this.handleClearCache}>
|
||||
<Trans>Clear cache + reload</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={this.handleReload} neutral={this.isRepeatedError}>
|
||||
{t("Reload")}
|
||||
</Button>
|
||||
{this.showDetails ? (
|
||||
<Button onClick={this.handleReportBug} neutral>
|
||||
<Trans>Report a bug</Trans>…
|
||||
<Trans>Report a bug</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={this.handleShowDetails} neutral>
|
||||
<Trans>Show detail</Trans>…
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
</Flex>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
TrashIcon,
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
UnpublishIcon,
|
||||
@@ -11,120 +9,28 @@ import {
|
||||
UserIcon,
|
||||
CrossIcon,
|
||||
} from "outline-icons";
|
||||
import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import styled, { css } from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { RevisionHelper } from "@shared/utils/RevisionHelper";
|
||||
import { s } from "@shared/styles";
|
||||
import Document from "~/models/Document";
|
||||
import { Avatar, AvatarSize } from "~/components/Avatar";
|
||||
import Item, { Actions } from "~/components/List/Item";
|
||||
import Event from "~/models/Event";
|
||||
import Time from "~/components/Time";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import RevisionMenu from "~/menus/RevisionMenu";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { documentHistoryPath } from "~/utils/routeHelpers";
|
||||
import Text from "./Text";
|
||||
|
||||
export type RevisionEvent = {
|
||||
name: "revisions.create";
|
||||
latest: boolean;
|
||||
};
|
||||
|
||||
export type DocumentEvent = {
|
||||
name:
|
||||
| "documents.publish"
|
||||
| "documents.unpublish"
|
||||
| "documents.archive"
|
||||
| "documents.unarchive"
|
||||
| "documents.delete"
|
||||
| "documents.restore"
|
||||
| "documents.add_user"
|
||||
| "documents.remove_user"
|
||||
| "documents.move";
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type Event = {
|
||||
id: string;
|
||||
actorId: string;
|
||||
createdAt: string;
|
||||
deletedAt?: string;
|
||||
} & (RevisionEvent | DocumentEvent);
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
event: Event;
|
||||
item: Event<Document>;
|
||||
};
|
||||
|
||||
const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
const EventListItem = ({ item }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { revisions, users } = useStores();
|
||||
const actor = "actorId" in event ? users.get(event.actorId) : undefined;
|
||||
const user = "userId" in event ? users.get(event.userId) : undefined;
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const revisionLoadedRef = useRef(false);
|
||||
const opts = {
|
||||
userName: actor?.name,
|
||||
userName: item.actor?.name,
|
||||
};
|
||||
const isRevision = event.name === "revisions.create";
|
||||
const isDerivedFromDocument =
|
||||
event.id === RevisionHelper.latestId(document.id);
|
||||
let meta, icon, to: LocationDescriptor | undefined;
|
||||
|
||||
const ref = useRef<HTMLAnchorElement>(null);
|
||||
// the time component tends to steal focus when clicked
|
||||
// ...so forward the focus back to the parent item
|
||||
const handleTimeClick = () => {
|
||||
ref.current?.focus();
|
||||
};
|
||||
|
||||
const prefetchRevision = async () => {
|
||||
if (
|
||||
!document.isDeleted &&
|
||||
event.name === "revisions.create" &&
|
||||
!event.deletedAt &&
|
||||
!isDerivedFromDocument &&
|
||||
!revisionLoadedRef.current
|
||||
) {
|
||||
await revisions.fetch(event.id, { force: true });
|
||||
revisionLoadedRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
switch (event.name) {
|
||||
case "revisions.create":
|
||||
{
|
||||
if (event.deletedAt) {
|
||||
icon = <TrashIcon />;
|
||||
meta = t("Revision deleted");
|
||||
} else {
|
||||
icon = <EditIcon size={16} />;
|
||||
meta = event.latest ? (
|
||||
<>
|
||||
{t("Current version")} · {actor?.name}
|
||||
</>
|
||||
) : (
|
||||
t("{{userName}} edited", opts)
|
||||
);
|
||||
to = {
|
||||
pathname: documentHistoryPath(
|
||||
document,
|
||||
isDerivedFromDocument ? "latest" : event.id
|
||||
),
|
||||
state: {
|
||||
sidebarContext,
|
||||
retainScrollPosition: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
break;
|
||||
let meta, icon;
|
||||
|
||||
switch (item.name) {
|
||||
case "documents.archive":
|
||||
icon = <ArchiveIcon />;
|
||||
meta = t("{{userName}} archived", opts);
|
||||
@@ -143,14 +49,14 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
icon = <UserIcon />;
|
||||
meta = t("{{userName}} added {{addedUserName}}", {
|
||||
...opts,
|
||||
addedUserName: user?.name ?? t("a user"),
|
||||
addedUserName: item.user?.name ?? t("a user"),
|
||||
});
|
||||
break;
|
||||
case "documents.remove_user":
|
||||
icon = <CrossIcon />;
|
||||
meta = t("{{userName}} removed {{removedUserName}}", {
|
||||
...opts,
|
||||
removedUserName: user?.name ?? t("a user"),
|
||||
removedUserName: item.user?.name ?? t("a user"),
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -175,71 +81,27 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
|
||||
break;
|
||||
|
||||
default:
|
||||
Logger.warn("Unhandled event", { event });
|
||||
Logger.warn("Unhandled item", { item });
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive =
|
||||
typeof to === "string"
|
||||
? location.pathname === to
|
||||
: location.pathname === to?.pathname;
|
||||
|
||||
if (document.isDeleted) {
|
||||
to = undefined;
|
||||
}
|
||||
|
||||
return event.name === "revisions.create" && !event.deletedAt ? (
|
||||
<RevisionItem
|
||||
small
|
||||
exact
|
||||
to={to}
|
||||
title={
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
format={{
|
||||
en_US: "MMM do, h:mm a",
|
||||
fr_FR: "'Le 'd MMMM 'à' H:mm",
|
||||
}}
|
||||
relative={false}
|
||||
addSuffix
|
||||
onClick={handleTimeClick}
|
||||
/>
|
||||
}
|
||||
image={<Avatar model={actor} size={AvatarSize.Large} />}
|
||||
subtitle={meta}
|
||||
actions={
|
||||
isRevision && isActive && !event.latest ? (
|
||||
<StyledEventBoundary>
|
||||
<RevisionMenu document={document} revisionId={event.id} />
|
||||
</StyledEventBoundary>
|
||||
) : undefined
|
||||
}
|
||||
onMouseEnter={prefetchRevision}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<EventItem>
|
||||
<IconWrapper size="xsmall" type="secondary">
|
||||
{icon}
|
||||
</IconWrapper>
|
||||
<Text size="xsmall" type="secondary">
|
||||
{meta} ·{" "}
|
||||
<Time
|
||||
dateTime={event.deletedAt ?? event.createdAt}
|
||||
relative
|
||||
shorten
|
||||
addSuffix
|
||||
/>
|
||||
<Time dateTime={item.createdAt} relative shorten addSuffix />
|
||||
</Text>
|
||||
</EventItem>
|
||||
);
|
||||
};
|
||||
|
||||
const lineStyle = css`
|
||||
export const lineStyle = css`
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
@@ -285,9 +147,10 @@ const lineStyle = css`
|
||||
|
||||
const IconWrapper = styled(Text)`
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
`;
|
||||
|
||||
const EventItem = styled.li`
|
||||
export const EventItem = styled.li`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -308,26 +171,4 @@ const EventItem = styled.li`
|
||||
${lineStyle}
|
||||
`;
|
||||
|
||||
const StyledEventBoundary = styled(EventBoundary)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
const RevisionItem = styled(Item)`
|
||||
border: 0;
|
||||
position: relative;
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
${lineStyle}
|
||||
|
||||
${Actions} {
|
||||
opacity: 0.5;
|
||||
|
||||
&: ${hover} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(EventListItem);
|
||||
|
||||
+42
-57
@@ -1,9 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { Dialog, DialogBackdrop, useDialogState } from "reakit/Dialog";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import {
|
||||
fadeIn,
|
||||
fadeOut,
|
||||
fadeInAndSlideLeft,
|
||||
fadeOutAndSlideRight,
|
||||
} from "~/styles/animations";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -18,55 +23,33 @@ const Guide: React.FC<Props> = ({
|
||||
title = "Untitled",
|
||||
onRequestClose,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const dialog = useDialogState({
|
||||
animated: 250,
|
||||
});
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
}: Props) => (
|
||||
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onRequestClose()}>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay>
|
||||
<Scene
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={onRequestClose}
|
||||
aria-describedby={undefined}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
{title && <Header>{title}</Header>}
|
||||
{children}
|
||||
</Content>
|
||||
</Scene>
|
||||
</StyledOverlay>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!wasOpen && isOpen) {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
if (wasOpen && !isOpen) {
|
||||
dialog.hide();
|
||||
}
|
||||
}, [dialog, wasOpen, isOpen]);
|
||||
|
||||
return (
|
||||
<DialogBackdrop {...dialog}>
|
||||
{(backdropProps) => (
|
||||
<Backdrop {...backdropProps}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={title}
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hide={onRequestClose}
|
||||
>
|
||||
{(dialogProps) => (
|
||||
<Scene {...dialogProps} {...rest}>
|
||||
<Content>
|
||||
{title && <Header>{title}</Header>}
|
||||
{children}
|
||||
</Content>
|
||||
</Scene>
|
||||
)}
|
||||
</Dialog>
|
||||
</Backdrop>
|
||||
)}
|
||||
</DialogBackdrop>
|
||||
);
|
||||
};
|
||||
|
||||
const Header = styled.h1`
|
||||
const Header = styled(Dialog.Title)`
|
||||
font-size: 18px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1em;
|
||||
`;
|
||||
|
||||
const Backdrop = styled.div`
|
||||
const StyledOverlay = styled(Dialog.Overlay)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -74,35 +57,37 @@ const Backdrop = styled.div`
|
||||
bottom: 0;
|
||||
background-color: ${s("backdrop")} !important;
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 200ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeIn} 200ms ease;
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
animation: ${fadeOut} 200ms ease;
|
||||
}
|
||||
`;
|
||||
|
||||
const Scene = styled.div`
|
||||
const Scene = styled(Dialog.Content)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 12px;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
z-index: ${depths.modal};
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 350px;
|
||||
background: ${s("background")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0;
|
||||
transform: translateX(16px);
|
||||
transition: transform 250ms ease, opacity 250ms ease;
|
||||
|
||||
&[data-enter] {
|
||||
opacity: 1;
|
||||
transform: translateX(0px);
|
||||
&[data-state="open"] {
|
||||
animation: ${fadeInAndSlideLeft} 200ms ease;
|
||||
}
|
||||
|
||||
&[data-state="closed"] {
|
||||
animation: ${fadeOutAndSlideRight} 200ms ease;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { useComponentSize } from "@shared/hooks/useComponentSize";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { supportsPassiveListener } from "@shared/utils/browser";
|
||||
import Button from "~/components/Button";
|
||||
@@ -38,8 +38,8 @@ function Header(
|
||||
const { ui } = useStores();
|
||||
const isMobile = useMobile();
|
||||
const hasMobileSidebar = hasSidebar && isMobile;
|
||||
const internalRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const breadcrumbsRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [internalMeasureRef, size] = useMeasure();
|
||||
const [breadcrumbsMeasureRef, breadcrumbsSize] = useMeasure();
|
||||
const passThrough = !actions && !left && !title;
|
||||
|
||||
const [isScrolled, setScrolled] = React.useState(false);
|
||||
@@ -62,19 +62,22 @@ function Header(
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setBreadcrumbRef = React.useCallback((node: HTMLDivElement | null) => {
|
||||
breadcrumbsRef.current = node?.firstElementChild as HTMLDivElement;
|
||||
}, []);
|
||||
const setBreadcrumbRef = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
if (node?.firstElementChild) {
|
||||
breadcrumbsMeasureRef(node.firstElementChild as HTMLDivElement);
|
||||
}
|
||||
},
|
||||
[breadcrumbsMeasureRef]
|
||||
);
|
||||
|
||||
const size = useComponentSize(internalRef);
|
||||
const breadcrumbsSize = useComponentSize(breadcrumbsRef);
|
||||
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
|
||||
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Wrapper
|
||||
ref={mergeRefs([ref, internalRef])}
|
||||
ref={mergeRefs([ref, internalMeasureRef])}
|
||||
align="center"
|
||||
shrink={false}
|
||||
className={className}
|
||||
|
||||
@@ -20,7 +20,8 @@ export const Preview = styled(Link)`
|
||||
cursor: ${(props: { as?: string }) =>
|
||||
props.as === "div" ? "default" : "var(--pointer)"};
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
box-shadow:
|
||||
0 30px 90px -20px rgba(0, 0, 0, 0.3),
|
||||
0 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
|
||||
@@ -30,10 +30,15 @@ const HoverPreviewIssue = React.forwardRef(function _HoverPreviewIssue(
|
||||
) {
|
||||
const authorName = author.name;
|
||||
const urlObj = new URL(url);
|
||||
const service =
|
||||
urlObj.hostname === "github.com"
|
||||
? IntegrationService.GitHub
|
||||
: IntegrationService.Linear;
|
||||
let service;
|
||||
|
||||
if (urlObj.hostname === "github.com") {
|
||||
service = IntegrationService.GitHub;
|
||||
} else if (urlObj.hostname === "gitlab.com") {
|
||||
service = IntegrationService.GitLab;
|
||||
} else {
|
||||
service = IntegrationService.Linear;
|
||||
}
|
||||
|
||||
return (
|
||||
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -136,6 +136,7 @@ const CustomColor = ({
|
||||
maxLength={7}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
autoFocus
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -136,11 +136,12 @@ const EmojiPanel = ({
|
||||
freqEmojis,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scrollableRef.current) {
|
||||
scrollableRef.current.scrollTop = 0;
|
||||
React.useLayoutEffect(() => {
|
||||
if (!panelActive) {
|
||||
return;
|
||||
}
|
||||
searchRef.current?.focus();
|
||||
scrollableRef.current?.scroll({ top: 0 });
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}, [panelActive]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -115,7 +115,9 @@ const CategoryName = styled(Text)`
|
||||
`;
|
||||
|
||||
const Icon = styled.svg`
|
||||
transition: color 150ms ease-in-out, fill 150ms ease-in-out;
|
||||
transition:
|
||||
color 150ms ease-in-out,
|
||||
fill 150ms ease-in-out;
|
||||
transition-delay: var(--delay);
|
||||
`;
|
||||
|
||||
|
||||
@@ -155,11 +155,12 @@ const IconPanel = ({
|
||||
baseIcons,
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scrollableRef.current) {
|
||||
scrollableRef.current.scrollTop = 0;
|
||||
React.useLayoutEffect(() => {
|
||||
if (!panelActive) {
|
||||
return;
|
||||
}
|
||||
searchRef.current?.focus();
|
||||
scrollableRef.current?.scroll({ top: 0 });
|
||||
requestAnimationFrame(() => searchRef.current?.focus());
|
||||
}, [panelActive]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,8 @@ export const PopoverButton = styled(NudeButton)<{ $borderOnHover?: boolean }>`
|
||||
$borderOnHover &&
|
||||
css`
|
||||
background: ${s("buttonNeutralBackground")};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px,
|
||||
box-shadow:
|
||||
rgba(0, 0, 0, 0.07) 0px 1px 2px,
|
||||
${s("buttonNeutralBorder")} 0 0 0 1px inset;
|
||||
`};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu, MenuButton, MenuItem } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import { depths, s, hover } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import { EmojiSkinTone } from "@shared/types";
|
||||
import { getEmojiVariants } from "@shared/utils/emoji";
|
||||
import { Emoji } from "~/components/Emoji";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/primitives/Popover";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
const SkinTonePicker = ({
|
||||
@@ -19,62 +22,61 @@ const SkinTonePicker = ({
|
||||
onChange: (skin: EmojiSkinTone) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handEmojiVariants = useMemo(() => getEmojiVariants({ id: "hand" }), []);
|
||||
|
||||
const menu = useMenuState({
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
const handleSkinClick = useCallback(
|
||||
(emojiSkin) => {
|
||||
menu.hide();
|
||||
(emojiSkin: EmojiSkinTone) => {
|
||||
setOpen(false);
|
||||
onChange(emojiSkin);
|
||||
},
|
||||
[menu, onChange]
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const menuItems = useMemo(
|
||||
() =>
|
||||
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
|
||||
<MenuItem {...menu} key={emoji.value}>
|
||||
{(menuprops) => (
|
||||
<IconButton {...menuprops} onClick={() => handleSkinClick(eskin)}>
|
||||
Object.values(EmojiSkinTone)
|
||||
.map((skinTone) => {
|
||||
const emoji = handEmojiVariants[skinTone];
|
||||
return emoji ? (
|
||||
<IconButton
|
||||
key={emoji.value}
|
||||
onClick={() => handleSkinClick(skinTone)}
|
||||
>
|
||||
<Emoji width={24} height={24}>
|
||||
{emoji.value}
|
||||
</Emoji>
|
||||
</IconButton>
|
||||
)}
|
||||
</MenuItem>
|
||||
)),
|
||||
[menu, handEmojiVariants, handleSkinClick]
|
||||
) : null;
|
||||
})
|
||||
.filter(Boolean),
|
||||
[handEmojiVariants, handleSkinClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<StyledMenuButton
|
||||
{...props}
|
||||
aria-label={t("Choose default skin tone")}
|
||||
>
|
||||
{handEmojiVariants[skinTone]!.value}
|
||||
</StyledMenuButton>
|
||||
)}
|
||||
</MenuButton>
|
||||
<Menu {...menu} aria-label={t("Choose default skin tone")}>
|
||||
{(props) => <MenuContainer {...props}>{menuItems}</MenuContainer>}
|
||||
</Menu>
|
||||
</>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<StyledMenuButton aria-label={t("Choose default skin tone")}>
|
||||
{handEmojiVariants[skinTone]?.value}
|
||||
</StyledMenuButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
aria-label={t("Choose default skin tone")}
|
||||
width={208}
|
||||
scrollable={false}
|
||||
shrink
|
||||
>
|
||||
<Emojis>{menuItems}</Emojis>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const MenuContainer = styled(Flex)`
|
||||
z-index: ${depths.menu};
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
const Emojis = styled(Flex)`
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
const StyledMenuButton = styled(NudeButton)`
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { SmileyIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import { s, hover, depths } from "@shared/styles";
|
||||
import { s, hover } from "@shared/styles";
|
||||
import theme from "@shared/styles/theme";
|
||||
import { IconType } from "@shared/types";
|
||||
import { determineIconType } from "@shared/utils/icon";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../primitives/Drawer";
|
||||
import EmojiPanel from "./components/EmojiPanel";
|
||||
import IconPanel from "./components/IconPanel";
|
||||
@@ -126,7 +129,24 @@ const IconPicker = ({
|
||||
onChange(null, null);
|
||||
}, [setOpen, onChange]);
|
||||
|
||||
const PickerContent = (
|
||||
const pickerTrigger = (
|
||||
<PopoverButton
|
||||
aria-label={t("Show menu")}
|
||||
className={className}
|
||||
size={size}
|
||||
$borderOnHover={borderOnHover}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : iconType && icon ? (
|
||||
<Icon value={icon} color={color} size={size} initial={initial} />
|
||||
) : (
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
)}
|
||||
</PopoverButton>
|
||||
);
|
||||
|
||||
const pickerContent = (
|
||||
<Content
|
||||
open={open}
|
||||
activeTab={activeTab}
|
||||
@@ -151,60 +171,28 @@ const IconPicker = ({
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<PopoverButton
|
||||
aria-label={t("Show menu")}
|
||||
className={className}
|
||||
size={size}
|
||||
$borderOnHover={borderOnHover}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : iconType && icon ? (
|
||||
<Icon value={icon} color={color} size={size} initial={initial} />
|
||||
) : (
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
)}
|
||||
</PopoverButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerTrigger asChild>{pickerTrigger}</DrawerTrigger>
|
||||
<DrawerContent aria-label={t("Icon Picker")}>
|
||||
{PickerContent}
|
||||
{pickerContent}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={handleOpenChange} modal={true}>
|
||||
<Popover.Trigger asChild>
|
||||
<PopoverButton
|
||||
aria-label={t("Show menu")}
|
||||
className={className}
|
||||
size={size}
|
||||
$borderOnHover={borderOnHover}
|
||||
>
|
||||
{children ? (
|
||||
children
|
||||
) : iconType && icon ? (
|
||||
<Icon value={icon} color={color} size={size} initial={initial} />
|
||||
) : (
|
||||
<StyledSmileyIcon color={theme.placeholder} size={size} />
|
||||
)}
|
||||
</PopoverButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<StyledPopoverContent
|
||||
side={popoverPosition === "right" ? "right" : "bottom"}
|
||||
align={popoverPosition === "bottom-start" ? "start" : "center"}
|
||||
sideOffset={0}
|
||||
width={popoverWidth}
|
||||
aria-label={t("Icon Picker")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{PickerContent}
|
||||
</StyledPopoverContent>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<Popover open={open} onOpenChange={handleOpenChange} modal={true}>
|
||||
<PopoverTrigger>{pickerTrigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
aria-label={t("Icon Picker")}
|
||||
width={popoverWidth}
|
||||
side={popoverPosition === "right" ? "right" : "bottom"}
|
||||
align={popoverPosition === "bottom-start" ? "start" : "center"}
|
||||
scrollable={false}
|
||||
shrink
|
||||
>
|
||||
{pickerContent}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -348,27 +336,4 @@ const StyledTabContent = styled(Tabs.Content)`
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const StyledPopoverContent = styled(Popover.Content)<{ width: number }>`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
z-index: ${depths.modal};
|
||||
width: ${(props) => props.width}px;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
position: fixed;
|
||||
z-index: ${depths.menu};
|
||||
top: 50px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.memo(IconPicker);
|
||||
|
||||
@@ -107,8 +107,8 @@ export const Outline = styled(Flex)<{
|
||||
props.hasError
|
||||
? props.theme.danger
|
||||
: props.focused
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
? props.theme.inputBorderFocused
|
||||
: props.theme.inputBorder};
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { useMenuState } from "~/hooks/useMenuState";
|
||||
import lazyWithRetry from "~/utils/lazyWithRetry";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import DelayedMount from "./DelayedMount";
|
||||
import Input, { Props as InputProps } from "./Input";
|
||||
import NudeButton from "./NudeButton";
|
||||
import Relative from "./Sidebar/components/Relative";
|
||||
import Text from "./Text";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./primitives/Popover";
|
||||
|
||||
type Props = Omit<InputProps, "onChange"> & {
|
||||
value: string | undefined;
|
||||
@@ -19,10 +17,6 @@ type Props = Omit<InputProps, "onChange"> & {
|
||||
|
||||
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
placement: "bottom-end",
|
||||
});
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
@@ -33,30 +27,26 @@ const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
|
||||
maxLength={7}
|
||||
{...rest}
|
||||
/>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<SwatchButton
|
||||
aria-label={t("Show menu")}
|
||||
{...props}
|
||||
$background={value}
|
||||
/>
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Select a color")}>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<StyledColorPicker
|
||||
disableAlpha
|
||||
color={value}
|
||||
onChange={(color) => onChange(color.hex)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</ContextMenu>
|
||||
<Popover modal={true}>
|
||||
<PopoverTrigger>
|
||||
<SwatchButton aria-label={t("Show menu")} $background={value} />
|
||||
</PopoverTrigger>
|
||||
<StyledContent aria-label={t("Select a color")} align="end">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount>
|
||||
<Text>{t("Loading")}…</Text>
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<StyledColorPicker
|
||||
disableAlpha
|
||||
color={value}
|
||||
onChange={(color) => onChange(color.hex)}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</StyledContent>
|
||||
</Popover>
|
||||
</Relative>
|
||||
);
|
||||
};
|
||||
@@ -70,6 +60,11 @@ const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>`
|
||||
right: 6px;
|
||||
`;
|
||||
|
||||
const StyledContent = styled(PopoverContent)`
|
||||
width: auto;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const ColorPicker = lazyWithRetry(
|
||||
() => import("react-color/lib/components/chrome/Chrome")
|
||||
);
|
||||
|
||||
@@ -2,23 +2,42 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import InputSelect, { Props as SelectProps } from "~/components/InputSelect";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import { EmptySelectValue, Permission } from "~/types";
|
||||
|
||||
type Props = Pick<
|
||||
React.ComponentProps<typeof InputSelect>,
|
||||
"value" | "onChange" | "disabled" | "className"
|
||||
>;
|
||||
|
||||
export default function InputMemberPermissionSelect(
|
||||
props: Partial<SelectProps> & { permissions: Permission[] }
|
||||
props: Props & { permissions: Permission[] }
|
||||
) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = React.useMemo<Option[]>(
|
||||
() =>
|
||||
props.permissions.reduce((acc, permission) => {
|
||||
if (permission.divider) {
|
||||
acc.push({ type: "separator" });
|
||||
}
|
||||
acc.push({
|
||||
...permission,
|
||||
type: "item",
|
||||
});
|
||||
return acc;
|
||||
}, [] as Option[]),
|
||||
[props.permissions]
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={t("Permissions")}
|
||||
options={props.permissions}
|
||||
ariaLabel={t("Permissions")}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
value={value || EmptySelectValue}
|
||||
labelHidden
|
||||
onChange={onChange}
|
||||
label={t("Permissions")}
|
||||
hideLabel
|
||||
nude
|
||||
{...rest}
|
||||
/>
|
||||
@@ -26,13 +45,5 @@ export default function InputMemberPermissionSelect(
|
||||
}
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
select {
|
||||
margin: 0;
|
||||
}
|
||||
` as React.ComponentType<SelectProps>;
|
||||
`;
|
||||
|
||||
+354
-335
@@ -1,383 +1,402 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import {
|
||||
Select,
|
||||
SelectOption,
|
||||
useSelectState,
|
||||
useSelectPopover,
|
||||
SelectPopover,
|
||||
} from "@renderlesskit/react";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import { QuestionMarkIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
|
||||
import Text from "~/components/Text";
|
||||
import useMenuHeight from "~/hooks/useMenuHeight";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import {
|
||||
Position,
|
||||
Background as ContextMenuBackground,
|
||||
Backdrop,
|
||||
Placement,
|
||||
} from "./ContextMenu";
|
||||
import { MenuAnchorCSS } from "./ContextMenu/MenuItem";
|
||||
import Separator from "./ContextMenu/Separator";
|
||||
import Flex from "./Flex";
|
||||
import { LabelText } from "./Input";
|
||||
import NudeButton from "./NudeButton";
|
||||
import Scrollable from "./Scrollable";
|
||||
import Tooltip from "./Tooltip";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import {
|
||||
InputSelectRoot,
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
InputSelectTrigger,
|
||||
type TriggerButtonProps,
|
||||
} from "./primitives/InputSelect";
|
||||
import {
|
||||
SelectItemIndicator,
|
||||
SelectItem as SelectItemWrapper,
|
||||
SelectButton,
|
||||
} from "./primitives/components/InputSelect";
|
||||
|
||||
export type Option = {
|
||||
label: string | JSX.Element;
|
||||
type Separator = {
|
||||
/* Denotes a horizontal divider line to be rendered in the menu, */
|
||||
type: "separator";
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
/* Denotes a selectable option in the menu. */
|
||||
type: "item";
|
||||
/* Representative text shown in the menu for this option. */
|
||||
label: string;
|
||||
/* Actual value of this option. */
|
||||
value: string;
|
||||
/* Additional info shown alongside the label. */
|
||||
description?: string;
|
||||
divider?: boolean;
|
||||
/* An icon shown alongside the label. */
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type Props = Omit<ButtonProps<any>, "onChange"> & {
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string | null;
|
||||
label?: React.ReactNode;
|
||||
nude?: boolean;
|
||||
ariaLabel: string;
|
||||
short?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
labelHidden?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
export type Option = Item | Separator;
|
||||
|
||||
type Props = {
|
||||
/* Options to display in the select menu. */
|
||||
options: Option[];
|
||||
/** @deprecated Removing soon, do not use. */
|
||||
note?: React.ReactNode;
|
||||
/** Callback function that is called when the value changes. Return false to cancel the change. */
|
||||
onChange?: (value: string | null) => void | Promise<boolean | void>;
|
||||
style?: React.CSSProperties;
|
||||
/**
|
||||
* Set to true if this component is rendered inside a Modal.
|
||||
* The Modal will take care of preventing body scroll behaviour.
|
||||
*/
|
||||
skipBodyScroll?: boolean;
|
||||
};
|
||||
/* Current chosen value. */
|
||||
value?: string | null;
|
||||
/* Callback when an option is selected. */
|
||||
onChange: (value: string) => void;
|
||||
/* Label for the select menu. */
|
||||
label: string;
|
||||
/* When true, label is hidden in an accessible manner. */
|
||||
hideLabel?: boolean;
|
||||
/* When true, menu is disabled. */
|
||||
disabled?: boolean;
|
||||
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
|
||||
short?: boolean;
|
||||
/** Display a tooltip with the descriptive help text about the select menu. */
|
||||
help?: string;
|
||||
} & TriggerButtonProps;
|
||||
|
||||
export interface InputSelectRef {
|
||||
value: string | null;
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
}
|
||||
export const InputSelect = React.forwardRef<HTMLButtonElement, Props>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
hideLabel,
|
||||
short,
|
||||
help,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
interface InnerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
placement: Placement;
|
||||
}
|
||||
const [localValue, setLocalValue] = React.useState(value);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const getOptionFromValue = (options: Option[], value: string | null) =>
|
||||
options.find((option) => option.value === value);
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
|
||||
|
||||
const InputSelect = (props: Props, ref: React.RefObject<InputSelectRef>) => {
|
||||
const {
|
||||
value = null,
|
||||
label,
|
||||
className,
|
||||
labelHidden,
|
||||
options,
|
||||
short,
|
||||
ariaLabel,
|
||||
onChange,
|
||||
disabled,
|
||||
note,
|
||||
icon,
|
||||
nude,
|
||||
skipBodyScroll,
|
||||
...rest
|
||||
} = props;
|
||||
const isMobile = useMobile();
|
||||
|
||||
const select = useSelectState({
|
||||
gutter: 0,
|
||||
modal: true,
|
||||
selectedValue: value,
|
||||
});
|
||||
const placeholder = `Select a ${label.toLowerCase()}`;
|
||||
const optionsHaveIcon = options.some(
|
||||
(opt) => opt.type === "item" && !!opt.icon
|
||||
);
|
||||
|
||||
const popover = useSelectPopover({
|
||||
...select,
|
||||
hideOnClickOutside: false,
|
||||
preventBodyScroll: skipBodyScroll ? false : true,
|
||||
disabled,
|
||||
});
|
||||
|
||||
const isMobile = useMobile();
|
||||
const previousValue = React.useRef<string | null>(value);
|
||||
const selectedRef = React.useRef<HTMLDivElement>(null);
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
const minWidth = buttonRef.current?.offsetWidth || 0;
|
||||
const margin = 8;
|
||||
const menuMaxHeight = useMenuHeight({
|
||||
visible: select.visible,
|
||||
elementRef: select.unstable_disclosureRef,
|
||||
margin,
|
||||
});
|
||||
const maxHeight = Math.min(
|
||||
menuMaxHeight ?? 0,
|
||||
window.innerHeight -
|
||||
(buttonRef.current?.getBoundingClientRect().bottom ?? 0) -
|
||||
margin
|
||||
);
|
||||
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
const selectedValueIndex = options.findIndex(
|
||||
(opt) => opt.value === select.selectedValue
|
||||
);
|
||||
|
||||
// Custom click outside handling rather than using `hideOnClickOutside` from reakit so that we can
|
||||
// prevent event bubbling.
|
||||
useOnClickOutside(
|
||||
contentRef,
|
||||
(event) => {
|
||||
if (buttonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if (select.visible) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
select.hide();
|
||||
}
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
buttonRef.current?.focus();
|
||||
},
|
||||
blur: () => {
|
||||
buttonRef.current?.blur();
|
||||
},
|
||||
value: select.selectedValue,
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
previousValue.current = value;
|
||||
|
||||
// Update the selected value if it changes from the outside – both of these lines are needed
|
||||
// for correct functioning
|
||||
select.selectedValue = value;
|
||||
select.setSelectedValue(value);
|
||||
}, [value]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (previousValue.current === select.selectedValue) {
|
||||
return;
|
||||
}
|
||||
const previous = previousValue.current;
|
||||
previousValue.current = select.selectedValue;
|
||||
|
||||
const response = onChange?.(select.selectedValue);
|
||||
if (response && response instanceof Promise) {
|
||||
void response.then((success) => {
|
||||
if (success === false) {
|
||||
select.selectedValue = previous;
|
||||
select.setSelectedValue(previous);
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option, idx: number) => {
|
||||
if (option.type === "separator") {
|
||||
return <InputSelectSeparator key={`separator-${idx}`} />;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [onChange, select.selectedValue]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (select.visible) {
|
||||
requestAnimationFrame(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.scrollTop = selectedValueIndex * 32;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [select.visible, selectedValueIndex]);
|
||||
return (
|
||||
<InputSelectItem key={option.value} value={option.value}>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
</InputSelectItem>
|
||||
);
|
||||
},
|
||||
[optionsHaveIcon]
|
||||
);
|
||||
|
||||
const onValueChange = React.useCallback(
|
||||
async (val: string) => {
|
||||
setLocalValue(val);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange, setLocalValue]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileSelect
|
||||
ref={ref}
|
||||
{...props}
|
||||
value={localValue}
|
||||
onChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function labelForOption(opt: Option) {
|
||||
return (
|
||||
<>
|
||||
{opt.label}
|
||||
{opt.description && (
|
||||
<>
|
||||
|
||||
<Text as="span" type="tertiary" size="small" ellipsis>
|
||||
– {opt.description}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<Wrapper short={short}>
|
||||
<Label text={label} hidden={hideLabel ?? false} help={help} />
|
||||
<InputSelectRoot
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
value={localValue ?? undefined}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<InputSelectTrigger
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
{...triggerProps}
|
||||
/>
|
||||
<InputSelectContent
|
||||
ref={contentRef}
|
||||
aria-label={label}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
{options.map(renderOption)}
|
||||
</InputSelectContent>
|
||||
</InputSelectRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
InputSelect.displayName = "InputSelect";
|
||||
|
||||
const option = getOptionFromValue(options, select.selectedValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Wrapper short={short}>
|
||||
{label &&
|
||||
(labelHidden ? (
|
||||
<VisuallyHidden.Root>{wrappedLabel}</VisuallyHidden.Root>
|
||||
) : (
|
||||
wrappedLabel
|
||||
))}
|
||||
|
||||
<Select {...select} disabled={disabled} {...rest} ref={buttonRef}>
|
||||
{(buttonProps) => (
|
||||
<StyledButton
|
||||
neutral
|
||||
disclosure
|
||||
className={className}
|
||||
icon={icon}
|
||||
$nude={nude}
|
||||
{...buttonProps}
|
||||
>
|
||||
{option ? (
|
||||
labelForOption(option)
|
||||
) : (
|
||||
<Placeholder>Select a {ariaLabel.toLowerCase()}</Placeholder>
|
||||
)}
|
||||
</StyledButton>
|
||||
)}
|
||||
</Select>
|
||||
<SelectPopover
|
||||
{...select}
|
||||
{...popover}
|
||||
aria-label={ariaLabel}
|
||||
preventBodyScroll={skipBodyScroll ? false : true}
|
||||
>
|
||||
{(popoverProps: InnerProps) => {
|
||||
const topAnchor = popoverProps.style?.top === "0";
|
||||
const rightAnchor = popoverProps.placement === "bottom-end";
|
||||
|
||||
return (
|
||||
<Positioner {...popoverProps}>
|
||||
<Background
|
||||
dir="auto"
|
||||
ref={contentRef}
|
||||
topAnchor={topAnchor}
|
||||
rightAnchor={rightAnchor}
|
||||
hiddenScrollbars
|
||||
maxWidth={400}
|
||||
style={
|
||||
maxHeight && topAnchor
|
||||
? {
|
||||
maxHeight,
|
||||
minWidth,
|
||||
}
|
||||
: {
|
||||
minWidth,
|
||||
}
|
||||
}
|
||||
>
|
||||
{select.visible
|
||||
? options.map((opt) => {
|
||||
const isSelected = select.selectedValue === opt.value;
|
||||
const Icon = isSelected ? CheckmarkIcon : Spacer;
|
||||
return (
|
||||
<React.Fragment key={opt.value}>
|
||||
{opt.divider && <Separator />}
|
||||
<StyledSelectOption
|
||||
{...select}
|
||||
value={opt.value}
|
||||
key={opt.value}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
>
|
||||
<Icon />
|
||||
|
||||
{labelForOption(opt)}
|
||||
</StyledSelectOption>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</Background>
|
||||
</Positioner>
|
||||
);
|
||||
}}
|
||||
</SelectPopover>
|
||||
</Wrapper>
|
||||
{note && (
|
||||
<Text as="p" type="secondary" size="small">
|
||||
{note}
|
||||
</Text>
|
||||
)}
|
||||
{select.visible && isMobile && <Backdrop />}
|
||||
</>
|
||||
);
|
||||
type MobileSelectProps = Props & {
|
||||
placeholder: string;
|
||||
optionsHaveIcon: boolean;
|
||||
};
|
||||
|
||||
const Background = styled(ContextMenuBackground)`
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
`;
|
||||
const MobileSelect = React.forwardRef<HTMLButtonElement, MobileSelectProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
placeholder,
|
||||
optionsHaveIcon,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
const Placeholder = styled.span`
|
||||
color: ${s("placeholder")};
|
||||
`;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
const Spacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
const selectedOption = React.useMemo(
|
||||
() =>
|
||||
value
|
||||
? options.find((opt) => opt.type === "item" && opt.value === value)
|
||||
: undefined,
|
||||
[value, options]
|
||||
);
|
||||
|
||||
const StyledButton = styled(Button)<{ $nude?: boolean }>`
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
margin-bottom: 16px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
cursor: var(--pointer);
|
||||
const handleSelect = React.useCallback(
|
||||
async (val: string) => {
|
||||
setOpen(false);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: ${s("buttonNeutralBackground")};
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <Separator />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
return (
|
||||
<SelectItemWrapper
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
data-state={isSelected ? "checked" : "unchecked"}
|
||||
>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
{isSelected && <SelectItemIndicator />}
|
||||
</SelectItemWrapper>
|
||||
);
|
||||
},
|
||||
[handleSelect, selectedOption, optionsHaveIcon]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<SelectButton
|
||||
ref={ref}
|
||||
{...triggerProps}
|
||||
neutral
|
||||
disclosure
|
||||
data-placeholder={selectedOption ? false : ""}
|
||||
>
|
||||
{selectedOption ? (
|
||||
<Option
|
||||
option={selectedOption as Item}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
) : (
|
||||
<>{placeholder}</>
|
||||
)}
|
||||
</SelectButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
ref={contentRef}
|
||||
aria-label={label}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
<DrawerTitle hidden={!label}>{label}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>
|
||||
{options.map(renderOption)}
|
||||
</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
MobileSelect.displayName = "InputSelect";
|
||||
|
||||
${(props) =>
|
||||
props.$nude &&
|
||||
css`
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
function Label({
|
||||
text,
|
||||
hidden,
|
||||
help,
|
||||
}: {
|
||||
text: string;
|
||||
hidden: boolean;
|
||||
help?: string;
|
||||
}) {
|
||||
const content = (
|
||||
<Flex align="center" gap={2} style={{ marginBottom: "4px" }}>
|
||||
<LabelText style={{ paddingBottom: 0 }}>{text}</LabelText>
|
||||
{help ? (
|
||||
<Tooltip content={help}>
|
||||
<TooltipButton size={18}>
|
||||
<QuestionMarkIcon size={18} />
|
||||
</TooltipButton>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
${Inner} {
|
||||
line-height: 28px;
|
||||
padding-left: 12px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
return hidden ? (
|
||||
<VisuallyHidden.Root>{content}</VisuallyHidden.Root>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
}
|
||||
|
||||
svg {
|
||||
justify-self: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
`;
|
||||
function Option({
|
||||
option,
|
||||
optionsHaveIcon,
|
||||
}: {
|
||||
option: Item;
|
||||
optionsHaveIcon: boolean;
|
||||
}) {
|
||||
const icon = optionsHaveIcon ? (
|
||||
option.icon ? (
|
||||
<IconWrapper>{option.icon}</IconWrapper>
|
||||
) : (
|
||||
<IconSpacer />
|
||||
)
|
||||
) : null;
|
||||
|
||||
export const StyledSelectOption = styled(SelectOption)`
|
||||
${MenuAnchorCSS}
|
||||
/* overriding the styles from MenuAnchorCSS because we use here */
|
||||
svg:not(:last-child) {
|
||||
margin-right: 0px;
|
||||
}
|
||||
`;
|
||||
return (
|
||||
<OptionContainer align="center">
|
||||
{icon}
|
||||
{option.label}
|
||||
{option.description && (
|
||||
<>
|
||||
|
||||
<Description type="tertiary" size="small" ellipsis>
|
||||
– {option.description}
|
||||
</Description>
|
||||
</>
|
||||
)}
|
||||
</OptionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.label<{ short?: boolean }>`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
export const Positioner = styled(Position)`
|
||||
pointer-events: all;
|
||||
const OptionContainer = styled(Flex)`
|
||||
min-height: 24px;
|
||||
`;
|
||||
|
||||
&:focus-visible {
|
||||
${StyledSelectOption} {
|
||||
&[aria-selected="true"] {
|
||||
color: ${(props) => props.theme.white};
|
||||
background: ${s("accent")};
|
||||
box-shadow: none;
|
||||
cursor: var(--pointer);
|
||||
|
||||
svg {
|
||||
fill: ${(props) => props.theme.white};
|
||||
}
|
||||
}
|
||||
const Description = styled(Text)`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.forwardRef(InputSelect);
|
||||
const IconWrapper = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-left: -4px;
|
||||
margin-right: 4px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const IconSpacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
const TooltipButton = styled(NudeButton)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
background: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Separator from "./ContextMenu/Separator";
|
||||
import Flex from "./Flex";
|
||||
import { LabelText } from "./Input";
|
||||
import Scrollable from "./Scrollable";
|
||||
import { IconWrapper } from "./Sidebar/components/SidebarLink";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import {
|
||||
InputSelectRoot,
|
||||
InputSelectContent,
|
||||
InputSelectItem,
|
||||
InputSelectSeparator,
|
||||
InputSelectTrigger,
|
||||
type TriggerButtonProps,
|
||||
} from "./primitives/InputSelect";
|
||||
import {
|
||||
SelectItemIndicator,
|
||||
SelectItem as SelectItemWrapper,
|
||||
SelectButton,
|
||||
} from "./primitives/components/InputSelect";
|
||||
|
||||
type Separator = {
|
||||
/* Denotes a horizontal divider line to be rendered in the menu, */
|
||||
type: "separator";
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
/* Denotes a selectable option in the menu. */
|
||||
type: "item";
|
||||
/* Representative text shown in the menu for this option. */
|
||||
label: string;
|
||||
/* Actual value of this option. */
|
||||
value: string;
|
||||
/* Additional info shown alongside the label. */
|
||||
description?: string;
|
||||
/* An icon shown alongside the label. */
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export type Option = Item | Separator;
|
||||
|
||||
type Props = {
|
||||
/* Options to display in the select menu. */
|
||||
options: Option[];
|
||||
/* Current chosen value. */
|
||||
value?: string;
|
||||
/* Callback when an option is selected. */
|
||||
onChange: (value: string) => void;
|
||||
/* ARIA label for accessibility. */
|
||||
ariaLabel: string;
|
||||
/* Label for the select menu. */
|
||||
label: string;
|
||||
/* When true, label is hidden in an accessible manner. */
|
||||
hideLabel?: boolean;
|
||||
/* When true, menu is disabled. */
|
||||
disabled?: boolean;
|
||||
/* When true, width of the menu trigger is restricted. Otherwise, takes up the full width of parent. */
|
||||
short?: boolean;
|
||||
} & TriggerButtonProps;
|
||||
|
||||
export function InputSelectNew(props: Props) {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
const [localValue, setLocalValue] = React.useState(value);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const triggerRef =
|
||||
React.useRef<React.ElementRef<typeof InputSelectTrigger>>(null);
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof InputSelectContent>>(null);
|
||||
|
||||
const isMobile = useMobile();
|
||||
|
||||
const placeholder = `Select a ${ariaLabel.toLowerCase()}`;
|
||||
const optionsHaveIcon = options.some(
|
||||
(opt) => opt.type === "item" && !!opt.icon
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <InputSelectSeparator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InputSelectItem key={option.value} value={option.value}>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
</InputSelectItem>
|
||||
);
|
||||
},
|
||||
[optionsHaveIcon]
|
||||
);
|
||||
|
||||
const onValueChange = React.useCallback(
|
||||
async (val: string) => {
|
||||
setLocalValue(val);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange, setLocalValue]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileSelect
|
||||
{...props}
|
||||
value={localValue}
|
||||
onChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper short={short}>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<InputSelectRoot
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
value={localValue}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<InputSelectTrigger
|
||||
ref={triggerRef}
|
||||
placeholder={placeholder}
|
||||
{...triggerProps}
|
||||
/>
|
||||
<InputSelectContent
|
||||
ref={contentRef}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
{options.map(renderOption)}
|
||||
</InputSelectContent>
|
||||
</InputSelectRoot>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileSelectProps = Props & {
|
||||
placeholder: string;
|
||||
optionsHaveIcon: boolean;
|
||||
};
|
||||
|
||||
function MobileSelect(props: MobileSelectProps) {
|
||||
const {
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
label,
|
||||
hideLabel,
|
||||
disabled,
|
||||
short,
|
||||
placeholder,
|
||||
optionsHaveIcon,
|
||||
...triggerProps
|
||||
} = props;
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
const selectedOption = React.useMemo(
|
||||
() =>
|
||||
value
|
||||
? options.find((opt) => opt.type === "item" && opt.value === value)
|
||||
: undefined,
|
||||
[value, options]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
async (val: string) => {
|
||||
setOpen(false);
|
||||
onChange(val);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const renderOption = React.useCallback(
|
||||
(option: Option) => {
|
||||
if (option.type === "separator") {
|
||||
return <Separator />;
|
||||
}
|
||||
|
||||
const isSelected = option === selectedOption;
|
||||
|
||||
return (
|
||||
<SelectItemWrapper
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
data-state={isSelected ? "checked" : "unchecked"}
|
||||
>
|
||||
<Option option={option} optionsHaveIcon={optionsHaveIcon} />
|
||||
{isSelected && <SelectItemIndicator />}
|
||||
</SelectItemWrapper>
|
||||
);
|
||||
},
|
||||
[handleSelect, selectedOption, optionsHaveIcon]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label text={label} hidden={hideLabel ?? false} />
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<SelectButton
|
||||
{...triggerProps}
|
||||
neutral
|
||||
disclosure
|
||||
data-placeholder={selectedOption ? false : ""}
|
||||
>
|
||||
{selectedOption ? (
|
||||
<Option
|
||||
option={selectedOption as Item}
|
||||
optionsHaveIcon={optionsHaveIcon}
|
||||
/>
|
||||
) : (
|
||||
<>{placeholder}</>
|
||||
)}
|
||||
</SelectButton>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
ref={contentRef}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
<DrawerTitle hidden={!label}>{label ?? ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>
|
||||
{options.map(renderOption)}
|
||||
</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({ text, hidden }: { text: string; hidden: boolean }) {
|
||||
const labelText = <LabelText>{text}</LabelText>;
|
||||
|
||||
return hidden ? (
|
||||
<VisuallyHidden.Root>{labelText}</VisuallyHidden.Root>
|
||||
) : (
|
||||
labelText
|
||||
);
|
||||
}
|
||||
|
||||
function Option({
|
||||
option,
|
||||
optionsHaveIcon,
|
||||
}: {
|
||||
option: Item;
|
||||
optionsHaveIcon: boolean;
|
||||
}) {
|
||||
const icon = optionsHaveIcon ? (
|
||||
option.icon ? (
|
||||
<IconWrapper>{option.icon}</IconWrapper>
|
||||
) : (
|
||||
<IconSpacer />
|
||||
)
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<OptionContainer align="center">
|
||||
{icon}
|
||||
{option.label}
|
||||
{option.description && (
|
||||
<>
|
||||
|
||||
<Description type="tertiary" size="small" ellipsis>
|
||||
– {option.description}
|
||||
</Description>
|
||||
</>
|
||||
)}
|
||||
</OptionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.label<{ short?: boolean }>`
|
||||
display: block;
|
||||
max-width: ${(props) => (props.short ? "350px" : "100%")};
|
||||
`;
|
||||
|
||||
const OptionContainer = styled(Flex)`
|
||||
min-height: 24px;
|
||||
`;
|
||||
|
||||
const Description = styled(Text)`
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: ${(props) => transparentize(0.5, props.theme.accentText)};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const IconSpacer = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
@@ -1,54 +1,63 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { $Diff } from "utility-types";
|
||||
import { s } from "@shared/styles";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { InputSelect, Option } from "~/components/InputSelect";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
import InputSelect, { Props, Option, InputSelectRef } from "./InputSelect";
|
||||
|
||||
function InputSelectPermission(
|
||||
props: $Diff<
|
||||
Props,
|
||||
{
|
||||
options: Array<Option>;
|
||||
ariaLabel: string;
|
||||
}
|
||||
>,
|
||||
ref: React.RefObject<InputSelectRef>
|
||||
) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const { t } = useTranslation();
|
||||
type Props = {
|
||||
shrink?: boolean;
|
||||
} & Pick<
|
||||
React.ComponentProps<typeof InputSelect>,
|
||||
"value" | "onChange" | "disabled" | "hideLabel" | "nude" | "help"
|
||||
>;
|
||||
|
||||
return (
|
||||
<Select
|
||||
ref={ref}
|
||||
label={t("Permission")}
|
||||
options={[
|
||||
export const InputSelectPermission = React.forwardRef<HTMLButtonElement, Props>(
|
||||
(props, ref) => {
|
||||
const { value, onChange, shrink, ...rest } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options = React.useMemo<Option[]>(
|
||||
() => [
|
||||
{
|
||||
type: "item",
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("Can edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
label: t("No access"),
|
||||
value: EmptySelectValue,
|
||||
},
|
||||
]}
|
||||
ariaLabel={t("Default access")}
|
||||
value={value || EmptySelectValue}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const Select = styled(InputSelect)`
|
||||
return (
|
||||
<Select
|
||||
ref={ref}
|
||||
options={options}
|
||||
value={value || EmptySelectValue}
|
||||
onChange={onChange}
|
||||
label={t("Permission")}
|
||||
$shrink={shrink}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
InputSelectPermission.displayName = "InputSelectPermission";
|
||||
|
||||
const Select = styled(InputSelect)<{ $shrink?: boolean }>`
|
||||
color: ${s("textSecondary")};
|
||||
${({ $shrink }) => !$shrink && "margin-bottom: 16px;"}
|
||||
`;
|
||||
|
||||
export default React.forwardRef(InputSelectPermission);
|
||||
|
||||
@@ -231,6 +231,7 @@ const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
|
||||
font-size: ${(props) => (props.$small ? 13 : 14)}px;
|
||||
color: ${s("textTertiary")};
|
||||
margin-top: -2px;
|
||||
overflow-wrap: break-word;
|
||||
`;
|
||||
|
||||
export const Actions = styled(Flex)<{ $selected?: boolean }>`
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import styled from "styled-components";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import {
|
||||
DropdownMenu as DropdownMenuRoot,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import { actionV2ToMenuItem } from "~/actions";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import {
|
||||
ActionContext,
|
||||
ActionV2Variant,
|
||||
ActionV2WithChildren,
|
||||
MenuItem,
|
||||
MenuItemWithChildren,
|
||||
} from "~/types";
|
||||
import { toDropdownMenuItems, toMobileMenuItems } from "./transformer";
|
||||
import { observer } from "mobx-react";
|
||||
import { useComputed } from "~/hooks/useComputed";
|
||||
|
||||
type Props = {
|
||||
/** Root action with children representing the menu items */
|
||||
action: ActionV2WithChildren;
|
||||
/** Action context to use - new context will be created if not provided */
|
||||
context?: ActionContext;
|
||||
/** Trigger for the menu */
|
||||
children: React.ReactNode;
|
||||
/** Alignment w.r.t trigger - defaults to start */
|
||||
align?: "start" | "end";
|
||||
/** ARIA label for the menu */
|
||||
ariaLabel: string;
|
||||
/** Additional component to display at the bottom of the top-level menu */
|
||||
append?: React.ReactNode;
|
||||
/** Callback when menu is opened */
|
||||
onOpen?: () => void;
|
||||
/** Callback when menu is closed */
|
||||
onClose?: () => void;
|
||||
// TODO: Invert the dependency chain by forwarding dropdown ref and props to Tooltip component
|
||||
} & React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>;
|
||||
|
||||
export const DropdownMenu = observer(
|
||||
React.forwardRef<React.ElementRef<typeof TooltipPrimitive.Trigger>, Props>(
|
||||
(
|
||||
{
|
||||
action,
|
||||
context,
|
||||
children,
|
||||
align = "start",
|
||||
ariaLabel,
|
||||
append,
|
||||
onOpen,
|
||||
onClose,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const isMobile = useMobile();
|
||||
const contentRef =
|
||||
React.useRef<React.ElementRef<typeof DropdownMenuContent>>(null);
|
||||
|
||||
const actionContext =
|
||||
context ??
|
||||
useActionContext({
|
||||
isContextMenu: true,
|
||||
});
|
||||
|
||||
const menuItems = useComputed(() => {
|
||||
if (!open) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (action.children as ActionV2Variant[]).map((childAction) =>
|
||||
actionV2ToMenuItem(childAction, actionContext)
|
||||
);
|
||||
}, [open, action.children, actionContext]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
setOpen(open);
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose]
|
||||
);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloseAutoFocus = React.useCallback(
|
||||
(e: Event) => e.preventDefault(),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileDropdown
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
items={menuItems}
|
||||
trigger={children}
|
||||
ariaLabel={ariaLabel}
|
||||
append={append}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const content = toDropdownMenuItems(menuItems);
|
||||
|
||||
return (
|
||||
<DropdownMenuRoot open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger ref={ref} aria-label={ariaLabel} {...rest}>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
aria-label={ariaLabel}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
{content}
|
||||
{append}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
type MobileDropdownProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
items: MenuItem[];
|
||||
trigger: React.ReactNode;
|
||||
} & Pick<Props, "ariaLabel" | "append">;
|
||||
|
||||
function MobileDropdown({
|
||||
open,
|
||||
onOpenChange,
|
||||
items,
|
||||
trigger,
|
||||
ariaLabel,
|
||||
append,
|
||||
}: MobileDropdownProps) {
|
||||
const [submenuName, setSubmenuName] = React.useState<string>();
|
||||
const contentRef = React.useRef<React.ElementRef<typeof DrawerContent>>(null);
|
||||
|
||||
const enablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "auto";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disablePointerEvents = React.useCallback(() => {
|
||||
if (contentRef.current) {
|
||||
contentRef.current.style.pointerEvents = "none";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeDrawer = React.useCallback(() => {
|
||||
onOpenChange(false);
|
||||
setTimeout(() => setSubmenuName(undefined), 500); // needed for a Vaul bug where 'onAnimationEnd' is not called for controlled state.
|
||||
}, [onOpenChange]);
|
||||
|
||||
const resetSubmenu = React.useCallback((isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setSubmenuName(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const menuItems = React.useMemo(() => {
|
||||
if (!items.length || !submenuName) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const submenu = items.find(
|
||||
(item) =>
|
||||
item.type === "submenu" && (item.title as string) === submenuName
|
||||
)! as MenuItemWithChildren;
|
||||
|
||||
return submenu.items;
|
||||
}, [items, submenuName]);
|
||||
|
||||
const content = toMobileMenuItems(menuItems, closeDrawer, setSubmenuName);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onAnimationEnd={resetSubmenu}
|
||||
>
|
||||
<DrawerTrigger aria-label={ariaLabel} asChild>
|
||||
{trigger}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
ref={contentRef}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={undefined}
|
||||
onAnimationStart={disablePointerEvents}
|
||||
onAnimationEnd={enablePointerEvents}
|
||||
>
|
||||
<DrawerTitle>{ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>
|
||||
{content}
|
||||
{!submenuName ? append : null}
|
||||
</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { MoreIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import Button from "~/components/Button";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
|
||||
type Props = React.ComponentPropsWithoutRef<
|
||||
typeof DropdownMenuPrimitive.Trigger
|
||||
> & {
|
||||
neutral?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const OverflowMenuButton = React.forwardRef<HTMLButtonElement, Props>(
|
||||
({ neutral, className, ...rest }, ref) =>
|
||||
neutral ? (
|
||||
<Button ref={ref} icon={<MoreIcon />} neutral borderOnHover {...rest} />
|
||||
) : (
|
||||
<NudeButton ref={ref} className={className} {...rest}>
|
||||
<MoreIcon />
|
||||
</NudeButton>
|
||||
)
|
||||
);
|
||||
OverflowMenuButton.displayName = "OverflowMenuButton";
|
||||
@@ -0,0 +1,285 @@
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import {
|
||||
DropdownMenuButton,
|
||||
DropdownMenuExternalLink,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuInternalLink,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
} from "~/components/primitives/DropdownMenu";
|
||||
import {
|
||||
MenuButton,
|
||||
MenuIconWrapper,
|
||||
MenuInternalLink,
|
||||
MenuExternalLink,
|
||||
MenuLabel,
|
||||
MenuSeparator,
|
||||
MenuDisclosure,
|
||||
SelectedIconWrapper,
|
||||
} from "~/components/primitives/components/Menu";
|
||||
import { MenuItem } from "~/types";
|
||||
|
||||
export function toDropdownMenuItems(items: MenuItem[]) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
|
||||
if (!filteredItems.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showIcon = filteredItems.find(
|
||||
(item) =>
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading" &&
|
||||
item.type !== "group" &&
|
||||
!!item.icon
|
||||
);
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<DropdownMenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
tooltip={item.tooltip}
|
||||
selected={item.selected}
|
||||
dangerous={item.dangerous}
|
||||
onClick={item.onClick}
|
||||
/>
|
||||
);
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<DropdownMenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
to={item.to}
|
||||
/>
|
||||
);
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<DropdownMenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
const submenuItems = toDropdownMenuItems(item.items);
|
||||
|
||||
if (!submenuItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownSubMenu key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownSubMenuTrigger
|
||||
label={item.title as string}
|
||||
icon={icon}
|
||||
disabled={item.disabled}
|
||||
/>
|
||||
<DropdownSubMenuContent>{submenuItems}</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
);
|
||||
}
|
||||
|
||||
case "group": {
|
||||
const groupItems = toDropdownMenuItems(item.items);
|
||||
|
||||
if (!groupItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuGroup
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
label={item.title as string}
|
||||
items={groupItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <DropdownMenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function toMobileMenuItems(
|
||||
items: MenuItem[],
|
||||
closeMenu: () => void,
|
||||
openSubmenu: (submenuName: string) => void
|
||||
) {
|
||||
const filteredItems = filterMenuItems(items);
|
||||
|
||||
if (!filteredItems.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showIcon = filteredItems.find(
|
||||
(item) =>
|
||||
item.type !== "separator" &&
|
||||
item.type !== "heading" &&
|
||||
item.type !== "group" &&
|
||||
!!item.icon
|
||||
);
|
||||
|
||||
return filteredItems.map((item, index) => {
|
||||
const icon = showIcon ? (
|
||||
<MenuIconWrapper aria-hidden>
|
||||
{"icon" in item ? item.icon : null}
|
||||
</MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
switch (item.type) {
|
||||
case "button":
|
||||
return (
|
||||
<MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
$dangerous={item.dangerous}
|
||||
onClick={(e) => {
|
||||
closeMenu();
|
||||
item.onClick(e);
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
{item.selected !== undefined && (
|
||||
<SelectedIconWrapper aria-hidden>
|
||||
{item.selected ? <CheckmarkIcon /> : null}
|
||||
</SelectedIconWrapper>
|
||||
)}
|
||||
</MenuButton>
|
||||
);
|
||||
|
||||
case "route":
|
||||
return (
|
||||
<MenuInternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
to={item.to}
|
||||
disabled={item.disabled}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuInternalLink>
|
||||
);
|
||||
|
||||
case "link":
|
||||
return (
|
||||
<MenuExternalLink
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
href={typeof item.href === "string" ? item.href : item.href.url}
|
||||
target={
|
||||
typeof item.href === "string" ? undefined : item.href.target
|
||||
}
|
||||
disabled={item.disabled}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
</MenuExternalLink>
|
||||
);
|
||||
|
||||
case "submenu": {
|
||||
const submenuItems = toMobileMenuItems(
|
||||
item.items,
|
||||
closeMenu,
|
||||
openSubmenu
|
||||
);
|
||||
|
||||
if (!submenuItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
key={`${item.type}-${item.title}-${index}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
openSubmenu(item.title as string);
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<MenuLabel>{item.title}</MenuLabel>
|
||||
<MenuDisclosure />
|
||||
</MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
case "group": {
|
||||
const groupItems = toMobileMenuItems(
|
||||
item.items,
|
||||
closeMenu,
|
||||
openSubmenu
|
||||
);
|
||||
|
||||
if (!groupItems?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${item.type}-${item.title}-${index}`}>
|
||||
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
||||
{groupItems}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "separator":
|
||||
return <MenuSeparator key={`${item.type}-${index}`} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function filterMenuItems(items: MenuItem[]): MenuItem[] {
|
||||
return items
|
||||
.filter((item) => item.visible !== false)
|
||||
.reduce((acc, item) => {
|
||||
// trim separator when the previous item is also a separator.
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
acc[acc.length - 1]?.type === "separator"
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, item];
|
||||
}, [] as MenuItem[])
|
||||
.filter((item, index, arr) => {
|
||||
// trim when first or last item is a separator.
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
(index === 0 || index === arr.length - 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
+59
-111
@@ -1,10 +1,9 @@
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, BackIcon } from "outline-icons";
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { DefaultTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -13,17 +12,14 @@ import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useUnmount from "~/hooks/useUnmount";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
import { fadeAndScaleIn, fadeIn } from "~/styles/animations";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
|
||||
let openModals = 0;
|
||||
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
fullscreen?: boolean;
|
||||
title?: React.ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
onRequestClose: () => void;
|
||||
@@ -32,32 +28,14 @@ type Props = {
|
||||
const Modal: React.FC<Props> = ({
|
||||
children,
|
||||
isOpen,
|
||||
fullscreen = true,
|
||||
title = "Untitled",
|
||||
style,
|
||||
onRequestClose,
|
||||
}: Props) => {
|
||||
const [depth, setDepth] = React.useState(0);
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
const isMobile = useMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!wasOpen && isOpen) {
|
||||
setDepth(openModals++);
|
||||
}
|
||||
|
||||
if (wasOpen && !isOpen) {
|
||||
setDepth(openModals--);
|
||||
}
|
||||
}, [wasOpen, isOpen]);
|
||||
|
||||
useUnmount(() => {
|
||||
if (isOpen) {
|
||||
openModals--;
|
||||
}
|
||||
});
|
||||
|
||||
if (!isOpen && !wasOpen) {
|
||||
return null;
|
||||
}
|
||||
@@ -68,86 +46,71 @@ const Modal: React.FC<Props> = ({
|
||||
onOpenChange={(open) => !open && onRequestClose()}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<StyledOverlay $fullscreen={fullscreen}>
|
||||
<StyledContent
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={fullscreen ? undefined : onRequestClose}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{fullscreen || isMobile ? (
|
||||
<Fullscreen
|
||||
$nested={!!depth}
|
||||
style={
|
||||
isMobile
|
||||
? undefined
|
||||
: {
|
||||
marginLeft: `${depth * 12}px`,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && (
|
||||
<Text size="xlarge" weight="bold">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</Content>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} />
|
||||
<Text>{t("Back")} </Text>
|
||||
</Back>
|
||||
</Fullscreen>
|
||||
) : (
|
||||
<Small>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent style={style} shadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Header>
|
||||
<StyledOverlay />
|
||||
<Dialog.Title asChild>
|
||||
<VisuallyHidden.Root>{title}</VisuallyHidden.Root>
|
||||
</Dialog.Title>
|
||||
<StyledContent
|
||||
onEscapeKeyDown={onRequestClose}
|
||||
onPointerDownOutside={onRequestClose}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Mobile>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && (
|
||||
<Text size="xlarge" weight="bold">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</Centered>
|
||||
</Small>
|
||||
)}
|
||||
</StyledContent>
|
||||
</StyledOverlay>
|
||||
</Content>
|
||||
<Close onClick={onRequestClose}>
|
||||
<CloseIcon size={32} />
|
||||
</Close>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} />
|
||||
<Text>{t("Back")} </Text>
|
||||
</Back>
|
||||
</Mobile>
|
||||
) : (
|
||||
<Small>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent style={style} shadow>
|
||||
<ErrorBoundary component="div">{children}</ErrorBoundary>
|
||||
</SmallContent>
|
||||
<Header>
|
||||
{title && <Text size="large">{title}</Text>}
|
||||
<NudeButton onClick={onRequestClose}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Header>
|
||||
</Centered>
|
||||
</Small>
|
||||
)}
|
||||
</StyledContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledOverlay = styled(Dialog.Overlay)<{ $fullscreen?: boolean }>`
|
||||
const StyledOverlay = styled(Dialog.Overlay)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: ${(props) =>
|
||||
props.$fullscreen
|
||||
? transparentize(0.25, props.theme.background)
|
||||
: props.theme.modalBackdrop} !important;
|
||||
background-color: ${(props) => props.theme.modalBackdrop} !important;
|
||||
z-index: ${depths.overlay};
|
||||
transition: opacity 50ms ease-in-out;
|
||||
opacity: 0;
|
||||
|
||||
&[data-state="open"] {
|
||||
opacity: 1;
|
||||
}
|
||||
animation: ${fadeIn} 200ms ease;
|
||||
`;
|
||||
|
||||
const StyledContent = styled(Dialog.Content)`
|
||||
@@ -163,12 +126,7 @@ const StyledContent = styled(Dialog.Content)`
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
type FullscreenProps = {
|
||||
$nested: boolean;
|
||||
theme: DefaultTheme;
|
||||
};
|
||||
|
||||
const Fullscreen = styled.div<FullscreenProps>`
|
||||
const Mobile = styled.div`
|
||||
animation: ${fadeAndScaleIn} 250ms ease;
|
||||
|
||||
position: absolute;
|
||||
@@ -182,16 +140,6 @@ const Fullscreen = styled.div<FullscreenProps>`
|
||||
align-items: flex-start;
|
||||
background: ${s("background")};
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
${(props: FullscreenProps) =>
|
||||
props.$nested &&
|
||||
`
|
||||
box-shadow: 0 -2px 10px ${props.theme.shadow};
|
||||
border-radius: 8px 0 0 8px;
|
||||
overflow: hidden;
|
||||
`}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Content = styled(Scrollable)`
|
||||
@@ -256,7 +204,7 @@ const Header = styled(Flex)`
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
padding: 24px 24px 4px;
|
||||
padding: 24px 24px 12px;
|
||||
`;
|
||||
|
||||
const Small = styled.div`
|
||||
@@ -290,7 +238,7 @@ const Small = styled.div`
|
||||
`;
|
||||
|
||||
const SmallContent = styled(Scrollable)`
|
||||
padding: 12px 24px 24px;
|
||||
padding: 8px 24px 24px;
|
||||
`;
|
||||
|
||||
export default observer(Modal);
|
||||
|
||||
@@ -35,7 +35,7 @@ function Notifications(
|
||||
const context = useActionContext();
|
||||
const { notifications } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isEmpty = notifications.orderedData.length === 0;
|
||||
const isEmpty = notifications.active.length === 0;
|
||||
|
||||
// Update the notification count in the dock icon, if possible.
|
||||
React.useEffect(() => {
|
||||
@@ -80,7 +80,7 @@ function Notifications(
|
||||
<PaginatedList<Notification>
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={notifications.orderedData}
|
||||
items={notifications.active}
|
||||
renderItem={(item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import { fadeAndSlideUp } from "~/styles/animations";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "~/components/primitives/Popover";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Notifications from "./Notifications";
|
||||
|
||||
type Props = {
|
||||
@@ -14,13 +15,16 @@ type Props = {
|
||||
|
||||
const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { notifications } = useStores();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const scrollableRef = React.useRef<HTMLDivElement>(null);
|
||||
const closeRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
void notifications.fetchPage({ archived: false });
|
||||
}, [notifications]);
|
||||
|
||||
const handleRequestClose = React.useCallback(() => {
|
||||
if (closeRef.current) {
|
||||
closeRef.current.click();
|
||||
}
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleAutoFocus = React.useCallback((event: Event) => {
|
||||
@@ -35,51 +39,22 @@ const NotificationsPopover: React.FC = ({ children }: Props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>{children}</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<StyledContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
avoidCollisions={true}
|
||||
aria-label={t("Notifications")}
|
||||
onOpenAutoFocus={handleAutoFocus}
|
||||
>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
<VisuallyHidden>
|
||||
<Popover.Close ref={closeRef} />
|
||||
</VisuallyHidden>
|
||||
</StyledContent>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
aria-label={t("Notifications")}
|
||||
side="top"
|
||||
align="start"
|
||||
onOpenAutoFocus={handleAutoFocus}
|
||||
shrink
|
||||
>
|
||||
<Notifications
|
||||
onRequestClose={handleRequestClose}
|
||||
ref={scrollableRef}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContent = styled(Popover.Content)`
|
||||
z-index: ${depths.menu};
|
||||
display: flex;
|
||||
animation: ${fadeAndSlideUp} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: 6px 0;
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
width: 380px;
|
||||
overflow: hidden;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
position: fixed;
|
||||
z-index: ${depths.menu};
|
||||
top: 50px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(NotificationsPopover);
|
||||
|
||||
@@ -56,7 +56,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<>
|
||||
<label style={{ marginBottom: "1em" }}>
|
||||
<label style={{ marginBottom: "1em", display: "block" }}>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -140,8 +140,8 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
|
||||
? `${t("Saving")}…`
|
||||
: t("Save")
|
||||
: formState.isSubmitting
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as OneTimePasswordField from "@radix-ui/react-one-time-password-field";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
|
||||
type Props = React.ComponentProps<typeof OneTimePasswordRoot> & {
|
||||
/** The length of the OTP */
|
||||
length?: number;
|
||||
};
|
||||
|
||||
export const OneTimePasswordInput = React.forwardRef(
|
||||
function _OneTimePasswordInput(
|
||||
{ length = 6, ...rest }: Props,
|
||||
ref: React.RefObject<HTMLInputElement>
|
||||
) {
|
||||
return (
|
||||
<OneTimePasswordRoot {...rest}>
|
||||
{Array.from({ length }, (_, i) => (
|
||||
<OneTimePasswordInputField key={i} />
|
||||
))}
|
||||
<OneTimePasswordField.HiddenInput ref={ref} />
|
||||
</OneTimePasswordRoot>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const OneTimePasswordRoot = styled(OneTimePasswordField.Root)`
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const OneTimePasswordInputField = styled(OneTimePasswordField.Input)`
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
font-size: 15px;
|
||||
color: ${s("text")};
|
||||
background: ${s("background")};
|
||||
box-shadow: 0 0 0 1px ${s("inputBorder")};
|
||||
padding: 0;
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
line-height: 1;
|
||||
transition: box-shadow 0.1s ease-in-out;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2px ${s("inputBorderFocused")};
|
||||
}
|
||||
&::selection {
|
||||
background-color: ${s("background")};
|
||||
color: ${s("text")};
|
||||
}
|
||||
`;
|
||||
@@ -8,11 +8,18 @@ type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const MobileWrapper = styled.div`
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
const StableWrapper = styled.div<{ $shouldApplyMobileStyles: boolean }>`
|
||||
${({ $shouldApplyMobileStyles }) =>
|
||||
$shouldApplyMobileStyles
|
||||
? `
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
`
|
||||
: `
|
||||
display: contents;
|
||||
`}
|
||||
`;
|
||||
|
||||
/**
|
||||
@@ -27,12 +34,17 @@ const PageScroll = ({ children }: Props) => {
|
||||
const isPrinting = useMediaQuery("print");
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
return isMobile && !isPrinting ? (
|
||||
<ScrollContext.Provider value={ref}>
|
||||
<MobileWrapper ref={ref}>{children}</MobileWrapper>
|
||||
const shouldApplyMobileStyles = isMobile && !isPrinting;
|
||||
|
||||
return (
|
||||
<ScrollContext.Provider value={shouldApplyMobileStyles ? ref : undefined}>
|
||||
<StableWrapper
|
||||
ref={ref}
|
||||
$shouldApplyMobileStyles={shouldApplyMobileStyles}
|
||||
>
|
||||
{children}
|
||||
</StableWrapper>
|
||||
</ScrollContext.Provider>
|
||||
) : (
|
||||
<>{children}</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
document={item}
|
||||
showPin={!!options?.collectionId}
|
||||
showParentDocuments={showParentDocuments}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Document from "~/models/Document";
|
||||
import Event from "~/models/Event";
|
||||
import Revision from "~/models/Revision";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import EventListItem, { type Event } from "./EventListItem";
|
||||
import EventListItem from "./EventListItem";
|
||||
import RevisionListItem from "./RevisionListItem";
|
||||
|
||||
type Item = Revision | Event<Document>;
|
||||
|
||||
type Props = {
|
||||
events: Event[];
|
||||
items: Item[];
|
||||
document: Document;
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Event[]>;
|
||||
fetch: (options: Record<string, any> | undefined) => Promise<Item[]>;
|
||||
options?: Record<string, any>;
|
||||
heading?: React.ReactNode;
|
||||
empty?: JSX.Element;
|
||||
@@ -16,7 +21,7 @@ type Props = {
|
||||
const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
empty,
|
||||
heading,
|
||||
events,
|
||||
items,
|
||||
fetch,
|
||||
options,
|
||||
document,
|
||||
@@ -24,14 +29,18 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
|
||||
}: Props) {
|
||||
return (
|
||||
<StyledPaginatedList
|
||||
items={events}
|
||||
items={items}
|
||||
empty={empty}
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderItem={(item: Event) => (
|
||||
<EventListItem key={item.id} event={item} document={document} />
|
||||
)}
|
||||
renderItem={(item: Item) =>
|
||||
item instanceof Revision ? (
|
||||
<RevisionListItem key={item.id} item={item} document={document} />
|
||||
) : (
|
||||
<EventListItem key={item.id} item={item} document={document} />
|
||||
)
|
||||
}
|
||||
renderHeading={(name) => <Heading>{name}</Heading>}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -278,8 +278,8 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
"updatedAt" in item && item.updatedAt
|
||||
? item.updatedAt
|
||||
: "createdAt" in item && item.createdAt
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
? item.createdAt
|
||||
: previousHeading;
|
||||
const currentHeading = dateToHeading(
|
||||
currentDate,
|
||||
t,
|
||||
@@ -308,7 +308,10 @@ const PaginatedList = <T extends PaginatedItem>({
|
||||
</ArrowKeyNavigation>
|
||||
{allowLoadMore && (
|
||||
<div style={{ height: "1px" }}>
|
||||
<Waypoint key={renderCount} onEnter={loadMoreResults} />
|
||||
<Waypoint
|
||||
key={items?.length + renderCount}
|
||||
onEnter={loadMoreResults}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -86,8 +86,8 @@ function PinnedDocuments({
|
||||
overPos === 0
|
||||
? fractionalIndex(null, overIndex)
|
||||
: activePos > overPos
|
||||
? fractionalIndex(prevIndex, overIndex)
|
||||
: fractionalIndex(overIndex, nextIndex),
|
||||
? fractionalIndex(prevIndex, overIndex)
|
||||
: fractionalIndex(overIndex, nextIndex),
|
||||
})
|
||||
.catch(() => setItems(existing));
|
||||
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Dialog } from "reakit/Dialog";
|
||||
import { Popover as ReakitPopover, PopoverProps } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { depths, s } from "@shared/styles";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { fadeAndScaleIn } from "~/styles/animations";
|
||||
|
||||
type Props = PopoverProps & {
|
||||
children: React.ReactNode;
|
||||
/** The width of the popover, defaults to 380px. */
|
||||
width?: number;
|
||||
/** The minimum width of the popover, use instead of width if contents adjusts size. */
|
||||
minWidth?: number;
|
||||
/** Shrink the padding of the popover */
|
||||
shrink?: boolean;
|
||||
/** Make the popover flex */
|
||||
flex?: boolean;
|
||||
/** The tab index of the popover */
|
||||
tabIndex?: number;
|
||||
/** Whether the popover should be scrollable, defaults to true. */
|
||||
scrollable?: boolean;
|
||||
/** The position of the popover on mobile, defaults to "top". */
|
||||
mobilePosition?: "top" | "bottom";
|
||||
/** Function to show the popover */
|
||||
show: () => void;
|
||||
/** Function to hide the popover */
|
||||
hide: () => void;
|
||||
};
|
||||
|
||||
const Popover = (
|
||||
{
|
||||
children,
|
||||
shrink,
|
||||
width = 380,
|
||||
minWidth,
|
||||
scrollable = true,
|
||||
flex,
|
||||
mobilePosition,
|
||||
...rest
|
||||
}: Props,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) => {
|
||||
const isMobile = useMobile();
|
||||
|
||||
// Custom Escape handler rather than using hideOnEsc from reakit so we can
|
||||
// prevent default behavior of exiting fullscreen.
|
||||
useKeyDown(
|
||||
"Escape",
|
||||
(event) => {
|
||||
if (rest.visible && rest.hideOnEsc !== false) {
|
||||
event.preventDefault();
|
||||
rest.hide();
|
||||
}
|
||||
},
|
||||
{
|
||||
allowInInput: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Dialog {...rest} modal>
|
||||
<Contents
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
$mobilePosition={mobilePosition}
|
||||
>
|
||||
{children}
|
||||
</Contents>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledPopover {...rest} hideOnEsc={false} hideOnClickOutside>
|
||||
<Contents
|
||||
ref={ref}
|
||||
$shrink={shrink}
|
||||
$width={width}
|
||||
$minWidth={minWidth}
|
||||
$scrollable={scrollable}
|
||||
$flex={flex}
|
||||
>
|
||||
{children}
|
||||
</Contents>
|
||||
</StyledPopover>
|
||||
);
|
||||
};
|
||||
|
||||
type ContentsProps = {
|
||||
$shrink?: boolean;
|
||||
$width?: number;
|
||||
$minWidth?: number;
|
||||
$flex?: boolean;
|
||||
$scrollable: boolean;
|
||||
$mobilePosition?: "top" | "bottom";
|
||||
};
|
||||
|
||||
const StyledPopover = styled(ReakitPopover)`
|
||||
z-index: ${depths.modal};
|
||||
`;
|
||||
|
||||
const Contents = styled.div<ContentsProps>`
|
||||
display: ${(props) => (props.$flex ? "flex" : "block")};
|
||||
animation: ${fadeAndScaleIn} 200ms ease;
|
||||
transform-origin: 75% 0;
|
||||
background: ${s("menuBackground")};
|
||||
border-radius: 6px;
|
||||
padding: ${(props) => (props.$shrink ? "6px 0" : "12px 24px")};
|
||||
max-height: 75vh;
|
||||
box-shadow: ${s("menuShadow")};
|
||||
${(props) => props.$width && `width: ${props.$width}px`};
|
||||
${(props) => props.$minWidth && `min-width: ${props.$minWidth}px`};
|
||||
|
||||
${(props) =>
|
||||
props.$scrollable
|
||||
? `
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
`
|
||||
: `
|
||||
overflow: hidden;
|
||||
`}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
position: fixed;
|
||||
z-index: ${depths.menu};
|
||||
|
||||
// 50 is a magic number that positions us nicely under the top bar
|
||||
top: ${(props: ContentsProps) =>
|
||||
props.$mobilePosition === "bottom" ? "auto" : "50px"};
|
||||
bottom: ${(props: ContentsProps) =>
|
||||
props.$mobilePosition === "bottom" ? "0" : "auto"};
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
width: auto;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default React.forwardRef(Popover);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user