Compare commits

..

7 Commits

Author SHA1 Message Date
codegen-sh[bot] e6d552b92d Add emoji settings page with table management (#9323)
* Add emoji settings page with table management

- Add EmojisTable component with sortable columns for emoji, name, creator, and creation date
- Add EmojiMenu component for emoji management actions (delete)
- Add EmojiUploadDialog with drag-and-drop file upload and name validation
- Add emoji actions for create and delete operations
- Integrate emoji settings page into workspace settings navigation
- Follow existing Table component patterns and styling
- Include proper permissions checking and error handling

* fixes

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-06-14 12:09:44 -04:00
codegen-sh[bot] cc1ec6cb71 Add frontend EmojiModel and EmojisStore (#9321)
* Add frontend EmojiModel and EmojisStore

- Create Emoji model with properties matching server presenter
- Add EmojisStore with team-scoped emoji filtering
- Register EmojisStore in RootStore
- Follow existing patterns for model and store implementation

* Remove teamId and teamEmojis, add @Relation decorator

- Remove teamId field from Emoji model (not needed in team context)
- Remove teamEmojis computed property from EmojisStore
- Add @Relation decorator to createdBy property
- Update emojisByName to use orderedData directly

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-27 08:57:14 -04:00
Tom Moor 3ad0e58d35 refactor 2025-05-24 19:14:22 -04:00
Tom Moor 4c75f31b2a Add emojis.info, minor fixes 2025-05-24 19:06:34 -04:00
codegen-sh[bot] 72e4fca1c4 test: Add comprehensive test coverage for emoji API routes
- Add buildEmoji factory method to test/factories.ts
- Create emojis.test.ts with full test coverage for all endpoints
- Test authentication, authorization, validation, and business logic
- Follow existing test patterns and handle all edge cases
2025-05-24 17:20:16 +00:00
codegen-sh[bot] 495b0e1a56 refactor: Improve emoji backend implementation
- Move max length values to EmojiValidation class in shared/validations
- Remove teamId override ability from emojis.list for better security
- Use createWithCtx and destroyWithCtx methods for automatic transaction handling
- Consolidate emoji delete policy into single rule using 'or' utility
- Clean up unused imports

These changes improve security, maintainability, and consistency with existing patterns.
2025-05-24 16:43:14 +00:00
codegen-sh[bot] 03de720f6f feat: Add custom emoji backend implementation
- Add Emoji model with team and user associations
- Create migration for emojis table with proper indexes
- Implement API routes: emojis.list, emojis.create, emojis.delete
- Add emoji presenter for consistent API responses
- Implement emoji policies for team-based authorization
- Register emoji routes and exports in main API

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

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