Compare commits

..

1 Commits

Author SHA1 Message Date
tommoor 514566ee75 chore: Compressed inefficient images automatically 2025-10-15 00:38:04 +00:00
1848 changed files with 52579 additions and 158115 deletions
+1 -12
View File
@@ -24,18 +24,7 @@
"include": ["SOURCE_COMMIT", "SOURCE_VERSION"]
}
],
[
"module-resolver",
{
"root": ["./"],
"alias": {
"@server": "./server",
"@shared": "./shared",
"~": "./app",
"plugins": "./plugins"
}
}
]
"tsconfig-paths-module-resolver"
],
"env": {
"production": {
+1 -9
View File
@@ -1,3 +1,4 @@
__mocks__
.git
.vscode
.github
@@ -7,19 +8,10 @@
.eslint*
.oxlintrc*
.log
*.md
Makefile
Procfile
app.json
crowdin.yml
lint-staged.config.mjs
build
docker-compose.yml
node_modules
.yarn
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.jsx
**/__tests__
**/__mocks__
+5 -50
View File
@@ -1,21 +1,5 @@
NODE_ENV=production
# –––––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE-BASED SECRETS ––––––––
# –––––––––––––––––––––––––––––––––––––––––
#
# Any environment variable can be loaded from a file by appending _FILE to the
# variable name and setting the value to the path of the file. This is useful
# for Docker secrets and other file-based secret management systems.
#
# For example, instead of:
# SECRET_KEY=your_secret_key
# You can use:
# SECRET_KEY_FILE=/run/secrets/outline_secret_key
#
# The file contents will be trimmed of leading/trailing whitespace. If both the
# variable and the _FILE variant are set, the direct variable takes precedence.
# This URL should point to the fully qualified, publicly accessible, URL. If using a
# proxy this will be the proxy's URL.
URL=
@@ -77,11 +61,6 @@ DATABASE_CONNECTION_POOL_MAX=
# DOCS: https://docs.getoutline.com/s/hosting/doc/redis-LGM4BFXYp4
REDIS_URL=redis://redis:6379
# To enable horizontal scaling of the collaboration service you must provide a Redis URL, it may
# be the same as above, or a different server.
# DOCS: https://docs.getoutline.com/s/hosting/doc/horizontal-scaling-hkfU5Stao7
REDIS_COLLABORATION_URL=
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– FILE STORAGE –––––––––––
@@ -135,23 +114,14 @@ SSL_CERT=
# false if you can be sure that SSL is terminated at an external loadbalancer.
FORCE_HTTPS=true
# When behind a reverse proxy, the header to use for the client IP.
# The default value is "X-Forwarded-For", common values are "X-Real-IP"
# and "X-Client-IP".
# PROXY_IP_HEADER=
# Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
# X-Forwarded-Proto) set by an upstream proxy. Set to false if not
# running behind a proxy in production.
# PROXY_HEADERS_TRUSTED=true
# ––––––––––––––––––––––––––––––––––––––
# –––––––––– AUTHENTICATION ––––––––––
# ––––––––––––––––––––––––––––––––––––––
# Third party signin credentials, at least ONE OF these is required for a
# working installation or you'll have no sign-in options.
# Third party signin credentials, at least ONE OF EITHER Google, Slack,
# Discord, or Microsoft is required for a working installation or you'll
# have no sign-in options.
# Slack sign-in provider
# DOCS: https://docs.getoutline.com/s/hosting/doc/slack-sgMujR8J9J
@@ -223,18 +193,12 @@ RATE_LIMITER_ENABLED=true
RATE_LIMITER_REQUESTS=1000
RATE_LIMITER_DURATION_WINDOW=60
# Multiplier applied to the hardcoded per-endpoint API rate limits. Use values
# greater than 1 to make the limits more lenient (e.g. 2 doubles the allowed
# requests), or less than 1 to make them stricter. Effective limits are rounded
# to the nearest integer with a minimum of 1. Defaults to 1.
RATE_LIMITER_MULTIPLIER=1
# ––––––––––––––––––––––––––––––––––––––
# ––––––––––– INTEGRATIONS –––––––––––
# ––––––––––––––––––––––––––––––––––––––
# GitHub integration allows previewing issue and pull request links
# The GitHub integration allows previewing issue and pull request links
# DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
@@ -243,12 +207,7 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# The GitLab integration allows previewing issue and merge request links
# DOCS:
GITLAB_CLIENT_ID=
GITLAB_CLIENT_SECRET=
# Linear integration allows previewing issue links as rich mentions
# The Linear integration allows previewing issue links as rich mentions
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
@@ -259,10 +218,6 @@ SLACK_VERIFICATION_TOKEN=your_token
SLACK_APP_ID=A0XXXXXXX
SLACK_MESSAGE_ACTIONS=true
# Figma integration allows previewing design files as rich mentions
FIGMA_CLIENT_ID=
FIGMA_CLIENT_SECRET=
# For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup
# and do not forget to whitelist your domain name in the app settings
DROPBOX_APP_KEY=
-23
View File
@@ -12,16 +12,10 @@ GOOGLE_CLIENT_SECRET=123
SLACK_CLIENT_ID=123
SLACK_CLIENT_SECRET=123
SLACK_VERIFICATION_TOKEN=test-token-123
GITHUB_CLIENT_ID=123;
GITHUB_CLIENT_SECRET=123;
GITHUB_APP_NAME=outline-test;
GITHUB_APP_ID=123
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----"
GITLAB_CLIENT_ID=123
GITLAB_CLIENT_SECRET=123
OIDC_CLIENT_ID=client-id
OIDC_CLIENT_SECRET=client-secret
@@ -31,24 +25,7 @@ OIDC_USERINFO_URI=http://localhost/userinfo
IFRAMELY_API_KEY=123
NOTION_CLIENT_ID=123
NOTION_CLIENT_SECRET=123
LINEAR_CLIENT_ID=123
LINEAR_CLIENT_SECRET=123
FIGMA_CLIENT_ID=123
FIGMA_CLIENT_SECRET=123
RATE_LIMITER_ENABLED=false
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/tmp
URL=http://localhost:3000
COLLABORATION_URL=
REDIS_URL=redis://localhost:6379
UTILS_SECRET=test-utils-secret
DEBUG=
LOG_LEVEL=error
-16
View File
@@ -1,16 +0,0 @@
name: Install
description: Set up Node.js, Corepack, and install dependencies with yarn cache
runs:
using: composite
steps:
- name: Enable Corepack
shell: bash
run: corepack enable
- name: Use Node.js 24.x
uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "yarn"
- name: Install dependencies
shell: bash
run: yarn install --immutable
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Close unsigned PRs
uses: actions/github-script@v8
uses: actions/github-script@v6
with:
script: |
const now = new Date();
@@ -31,12 +31,6 @@ jobs:
if (prAge < TWO_WEEKS) continue;
const hasSkipLabel = pr.labels.some(label =>
label.name === 'pinned'
);
if (hasSkipLabel) continue;
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -40,21 +40,20 @@ jobs:
github.event.pull_request.head.repo.full_name == github.repository)
steps:
- name: Checkout Branch
uses: actions/checkout@v5
uses: actions/checkout@v2
- name: Compress Images
id: calibre
uses: calibreapp/image-actions@3d5873ac3e7bf1a38b24d9778d8dc639d5706d8b # main
uses: calibreapp/image-actions@main
with:
githubToken: ${{ secrets.GITHUB_TOKEN }}
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
compressOnly: ${{ github.event_name != 'pull_request' }}
minPctChange: "10"
- name: Create Pull Request
# If it's not a Pull Request then commit any changes as a new PR.
if: |
github.event_name != 'pull_request' &&
steps.calibre.outputs.markdown != ''
uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 # v3
uses: peter-evans/create-pull-request@v3
with:
title: "chore: Auto Compress Images"
branch-suffix: timestamp
+68 -43
View File
@@ -18,23 +18,61 @@ env:
SMTP_USERNAME: localhost
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
- name: Install dependencies
run: yarn install --frozen-lockfile --prefer-offline
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 --quiet
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
changes:
runs-on: ubuntu-latest
outputs:
config: ${{ steps.filter.outputs.config }}
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
deps: ${{ steps.filter.outputs.deps }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
id: filter
with:
filters: |
config:
- '.github/**'
- 'vite.config.ts'
- 'vitest.config.ts'
server:
- 'server/**'
- 'shared/**'
@@ -45,48 +83,25 @@ jobs:
- 'shared/**'
- 'package.json'
- 'yarn.lock'
deps:
- 'package.json'
- 'yarn.lock'
- '.yarnrc.yml'
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- run: yarn lint --quiet
types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- run: yarn tsc
audit:
needs: changes
if: ${{ needs.changes.outputs.deps == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- run: yarn npm audit --severity high --recursive --environment production
test:
needs: changes
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [app, shared]
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn test:${{ matrix.test-group }}
test-server:
needs: changes
needs: [build, changes]
if: ${{ needs.changes.outputs.server == 'true' || needs.changes.outputs.config == 'true' }}
runs-on: ubuntu-latest
services:
@@ -109,24 +124,34 @@ jobs:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- 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: yarn test:server --maxWorkers=2 --shard=${{ matrix.shard }}/4
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: changes
if: ${{ (needs.changes.outputs.app == 'true' || needs.changes.outputs.config == 'true') && github.repository == 'outline/outline' }}
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/install
- 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@38328454d6a23942175eba485fca4fbb807b1f03 # v2
uses: relative-ci/agent-action@v2
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
+32 -26
View File
@@ -1,10 +1,9 @@
name: Publish build
name: Docker
on:
push:
tags:
- "v*"
workflow_dispatch:
env:
IMAGE_NAME: outlinewiki/outline
@@ -12,17 +11,17 @@ env:
jobs:
build-arm:
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -31,14 +30,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
@@ -46,11 +45,13 @@ jobs:
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -60,7 +61,7 @@ jobs:
- name: Build and push
id: build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -68,6 +69,8 @@ jobs:
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
@@ -87,17 +90,17 @@ jobs:
retention-days: 1
build-amd:
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
@@ -106,14 +109,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
@@ -121,11 +124,13 @@ jobs:
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
@@ -135,7 +140,7 @@ jobs:
- name: Build and push
id: build
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
@@ -143,6 +148,8 @@ jobs:
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
@@ -162,11 +169,10 @@ jobs:
retention-days: 1
merge:
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
environment: dockerhub
steps:
- name: Download digests
uses: actions/download-artifact@v4
@@ -176,17 +182,17 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup Blacksmith Builder
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
+30
View File
@@ -0,0 +1,30 @@
name: Lint
on:
pull_request:
branches: [main]
jobs:
run-linters:
if: startsWith(github.actor, 'codegen-sh')
name: Run linters
runs-on: ubuntu-latest
permissions:
# Give the default GITHUB_TOKEN write permission to commit and push the
# added or changed files to the repository.
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
cache: "yarn"
- run: yarn install --frozen-lockfile --prefer-offline
- run: yarn lint --fix
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Applied automatic fixes"
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v5
with:
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
-94
View File
@@ -1,94 +0,0 @@
name: Update Node.js LTS
on:
schedule:
# Run every Monday at 9:00 UTC
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-node:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Check for Node.js LTS update
id: check
run: |
# Get current Node version from Dockerfile
CURRENT_VERSION=$(grep -oP 'FROM node:\K[0-9]+\.[0-9]+\.[0-9]+' Dockerfile.base)
echo "current=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
echo "Current Node.js version: $CURRENT_VERSION"
# Fetch the latest LTS release (any major version) from nodejs.org
LATEST_VERSION=$(curl -s https://nodejs.org/dist/index.json | \
jq -r '[.[] | select(.lts != false)][0].version' | \
sed 's/^v//')
if ! [[ "$LATEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Failed to fetch a valid LTS version (got '$LATEST_VERSION')"
exit 1
fi
echo "latest=$LATEST_VERSION" >> "$GITHUB_OUTPUT"
echo "Latest Node.js LTS version: $LATEST_VERSION"
if [ "$CURRENT_VERSION" = "$LATEST_VERSION" ]; then
echo "updated=false" >> "$GITHUB_OUTPUT"
echo "Already up to date."
else
echo "updated=true" >> "$GITHUB_OUTPUT"
echo "Update available: $CURRENT_VERSION -> $LATEST_VERSION"
fi
- name: Update Node.js version references
if: steps.check.outputs.updated == 'true'
env:
CURRENT: ${{ steps.check.outputs.current }}
LATEST: ${{ steps.check.outputs.latest }}
run: |
CURRENT_MAJOR=$(echo "$CURRENT" | cut -d. -f1)
LATEST_MAJOR=$(echo "$LATEST" | cut -d. -f1)
# Update Dockerfiles
sed -i "s/node:${CURRENT}-slim/node:${LATEST}-slim/g" Dockerfile
sed -i "s/node:${CURRENT} /node:${LATEST} /g" Dockerfile.base
# Update references that depend on major version
if [ "$CURRENT_MAJOR" != "$LATEST_MAJOR" ]; then
# .nvmrc
echo "$LATEST_MAJOR" > .nvmrc
# CI workflow: step name, node-version, and cache keys
sed -i "s/Use Node.js ${CURRENT_MAJOR}.x/Use Node.js ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
sed -i "s/node-version: ${CURRENT_MAJOR}.x/node-version: ${LATEST_MAJOR}.x/g" .github/workflows/ci.yml
# Update cache keys: replace node-modules-[optional old version] with new version
sed -i -E "s/node-modules-([0-9]+\.x-)?/node-modules-${LATEST_MAJOR}.x-/g" .github/workflows/ci.yml
# package.json engines field: append new major version
sed -i "s/\"node\": \"\(.*\)\"/\"node\": \"\1 || ${LATEST_MAJOR}\"/" package.json
fi
echo "Updated Node.js from $CURRENT to $LATEST"
- name: Create pull request
if: steps.check.outputs.updated == 'true'
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
with:
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
body: |
Automated update of Node.js in Docker images.
- **Previous version:** ${{ steps.check.outputs.current }}
- **New version:** ${{ steps.check.outputs.latest }}
[Release notes](https://nodejs.org/en/blog/release/v${{ steps.check.outputs.latest }})
branch: automated/update-node-lts
delete-branch: true
labels: dependencies
-9
View File
@@ -14,12 +14,3 @@ data/*
*.pem
*.key
*.cert
.history
# Yarn Berry
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.yarn/releases
!.yarn/sdks
+61
View File
@@ -0,0 +1,61 @@
{
"workerIdleMemoryLimit": "0.75",
"maxWorkers": "50%",
"projects": [
{
"displayName": "server",
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
},
{
"displayName": "app",
"roots": ["<rootDir>/app"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"modulePaths": ["<rootDir>/app"],
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"setupFilesAfterEnv": ["<rootDir>/app/test/setup.ts"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
},
{
"displayName": "shared-node",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^@server/(.*)$": "<rootDir>/server/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1"
},
"setupFiles": ["<rootDir>/__mocks__/console.js"],
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
"testEnvironment": "node"
},
{
"displayName": "shared-jsdom",
"roots": ["<rootDir>/shared"],
"moduleNameMapper": {
"^~/(.*)$": "<rootDir>/app/$1",
"^@shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
},
"setupFiles": ["<rootDir>/__mocks__/window.js"],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost"
}
}
]
}
+1 -1
View File
@@ -1 +1 @@
24
22
+3 -29
View File
@@ -31,7 +31,7 @@
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-explicit-any": "error",
"no-explicit-any": "warn",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
@@ -73,30 +73,9 @@
"eqeqeq": "error",
"curly": "error",
"no-console": "error",
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "prosemirror-tables",
"importNames": [
"addRowBefore",
"addRowAfter",
"addColumnBefore",
"addColumnAfter"
],
"message": "Use the wrappers from shared/editor/commands/table instead, which respect the target index and place the cursor in the inserted cell."
}
]
}
],
"no-unused-expressions": "error",
"arrow-body-style": ["error", "as-needed"],
"no-useless-escape": "off",
"react/react-in-jsx-scope": "off",
"typescript/await-thenable": "error",
"typescript/no-duplicate-type-constituents": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/require-array-sort-compare": "error",
"react/self-closing-comp": [
"error",
{
@@ -107,10 +86,6 @@
"@typescript-eslint/no-require-imports": "off",
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"typescript/consistent-type-imports": "error",
"typescript/restrict-template-expressions": "error",
"typescript/no-floating-promises": "error",
"typescript/no-useless-default-assignment": "error",
"no-unused-vars": [
"error",
{
@@ -119,8 +94,7 @@
"args": "after-used",
"ignoreRestSiblings": true
}
],
"react/rules-of-hooks": "error"
]
},
"plugins": ["eslint", "oxc", "react", "typescript", "import"]
}
-14
View File
@@ -1,14 +0,0 @@
nodeLinker: node-modules
enableScripts: false
npmMinimalAgeGate: 4320
npmPreapprovedPackages:
- outline-icons
# Build-time advisories that don't affect runtime request handling.
# Re-evaluate when bumping the relevant dev/build dep.
npmAuditIgnoreAdvisories:
- "1113517" # GHSA-mw96-cpmx-2vgc rollup <2.80.0 path traversal (workbox-build, build-time)
- "1113686" # GHSA-5c6j-r48x-rmvq serialize-javascript RCE (@rollup/plugin-terser, build-time)
-208
View File
@@ -1,208 +0,0 @@
Outline is a fast, collaborative knowledge base built for teams. It's built with React and TypeScript in both frontend and backend, uses a real-time collaboration engine, and is designed for excellent performance and user experience. The backend is a Koa server with an RPC API and uses PostgreSQL and Redis. The application can be self-hosted or used as a cloud service.
There is a web client which is fully responsive and works on mobile devices.
**Monorepo Structure:**
- **`app/`** - React web application with MobX state management
- **`server/`** - Koa API server with Sequelize ORM and background workers
- **`shared/`** - Shared TypeScript types, utilities, and editor components
- **`plugins/`** - Plugin system for extending functionality
- **`public/`** - Static assets served directly
- **Various config files** - TypeScript, Vite, Vitest, Prettier, Oxlint configurations
Refer to /docs/ARCHITECTURE.md for detailed architecture documentation.
## Instructions
You're an expert in the following areas:
- TypeScript
- React and React Router
- MobX and MobX-React
- Node.js and Koa
- Sequelize ORM
- PostgreSQL
- Redis
- HTML, CSS and Styled Components
- Prosemirror (rich text editor)
- WebSockets and real-time collaboration
## General Guidelines
- Critical Do not create new markdown (.md) files.
- Use early returns for readability.
- Emphasize type safety and static analysis.
- Follow consistent Prettier formatting.
- Do not replace smart quotes ("") or ('') with simple quotes ("").
- Do not add translation strings manually; they will be extracted automatically from the codebase.
## Dependencies and Upgrading
- Use yarn for all dependency management.
- After updating dependency versions, install to update lockfiles:
```bash
yarn install
```
- When adding a `resolutions` entry to address a security advisory in a transitive dependency, target only the specific vulnerable descriptors using the `name@npm:<range>` syntax rather than overriding the package globally. Inspect `yarn.lock` to find the exact ranges requested by upstream packages and add one entry per vulnerable range, e.g.:
```json
"resolutions": {
"qs@npm:^6.5.2": "^6.14.2",
"qs@npm:^6.11.0": "^6.14.2",
"qs@npm:^6.14.0": "^6.14.2"
}
```
This keeps overrides scoped to the affected dependents and avoids forcing unrelated consumers onto an incompatible version.
## TypeScript Usage
- Use strict mode.
- Avoid "unknown" unless absolutely necessary.
- Never use "any".
- Prefer type definitions; avoid type assertions (as, !).
- Always use curly braces for if statements.
- Avoid # for private properties.
- Prefer interface over type for object shapes.
## Classes & Code Organization
### Class Member Order
1. Public static variables
2. Public static methods
3. Public variables
4. Public methods
5. Protected variables & methods
6. Private variables & methods
### Exports
- Exported members must appear at the top of the file.
- Always use named exports for new components & classes.
- Document ALL public/exported functions with JSDoc.
## React Usage
- Use functional components with hooks.
- Event handlers should be prefixed with "handle", like "handleClick" for onClick.
- Avoid unnecessary re-renders by using React.memo, useMemo, and useCallback appropriately.
- Use descriptive prop types with TypeScript interfaces.
- Do not import React unless it is used directly.
- Use styled-components for component styling.
- Ensure high accessibility (a11y) standards using ARIA roles and semantic HTML.
## MobX State Management
- Use MobX stores for global state management.
- Keep stores in `app/stores/`.
- Use `observable`, `action`, and `computed` decorators appropriately.
- Prefer computed values over manual calculations in render.
- Keep business logic in stores, not components.
## Database & ORM
- Use Sequelize models in `server/models/`.
- Generate migrations with Sequelize CLI:
```bash
yarn sequelize migration:create --name=add-field-to-table
```
- Run migrations with `yarn db:migrate`.
- Use transactions for multi-table operations.
- Add appropriate indexes for query performance.
- Always handle database errors gracefully.
## API Design
- RESTful endpoints under `/api/`.
- Authentication endpoints under `/auth/`.
- Use consistent error responses.
- Validate request data using the validation middleware and schemas
- Use presenters to format API responses.
- Keep API routes thin, use model methods for business logic, or commands if logic spans multiple models.
## Authentication & Authorization
- JWT tokens for authentication.
- Policies in `server/policies/` for authorization.
- Use cancan-style ability checks.
- Use authenticated middleware for protected routes.
- Always verify user permissions before data access.
## Real-time Collaboration
- WebSocket connections for real-time updates.
- Use Y.js for collaborative editing.
- Handle connection state changes gracefully.
## Documentation
- All public/exported functions & classes must have JSDoc.
- Include:
- Description
- @param and @return (start lowercase, end with period)
- @throws if applicable
- Add a newline between the description and the @ block.
- Use correct punctuation.
## Testing
- Run tests with Vitest:
```bash
# Run a specific test file (preferred)
yarn test path/to/test.spec.ts
# Run every test (avoid)
yarn test
# Run test suites (avoid)
yarn test:app # All frontend tests
yarn test:server # All backend tests
yarn test:shared # All shared code tests
```
- Write unit tests for utilities and business logic in a collocated .test.ts file.
- Do not create new test directories
- Mock external dependencies appropriately in **mocks** folder.
- Aim for high code coverage but focus on critical paths.
## Code Quality
- Use Oxlint for linting: `yarn lint`
- Format code with Prettier: `yarn format`
- Check types with TypeScript: `yarn tsc`
- Pre-commit hooks run automatically via Husky.
- Fix linting issues before committing.
## Error Handling
- Use custom error classes in `server/errors.ts`.
- Always catch and handle errors appropriately.
- Log errors with appropriate context.
- Return user-friendly error messages.
- Never expose sensitive information in errors.
## Performance
- Use React.memo for expensive components.
- Implement pagination for large lists.
- Use database indexes effectively.
- Cache expensive computations.
- Monitor performance with appropriate tools.
- Lazy load routes and components where appropriate.
## Security
- Sanitize all user input.
- Always use `sanitizeUrl()` when setting `href` or `src` from user-controlled data in ProseMirror `toDOM` methods, regardless of whether it is imported via an alias or a relative path. Unlike React components, `toDOM` writes raw DOM and does not sanitize attribute values.
- Use CSRF protection.
- Use rateLimiter middleware for sensitive endpoints.
- Follow OWASP guidelines.
- Never store sensitive data in plain text.
- Use environment variables for secrets.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+15 -15
View File
@@ -6,7 +6,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:24.16.0-slim AS runner
FROM node:22-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
@@ -16,26 +16,26 @@ ENV NODE_ENV=production
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline && \
chown -R nodejs:nodejs $APP_PATH
adduser --uid 1001 --ingroup nodejs nodejs && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
COPY --from=base --chown=nodejs:nodejs $APP_PATH/build ./build
COPY --from=base --chown=nodejs:nodejs $APP_PATH/server ./server
COPY --from=base --chown=nodejs:nodejs $APP_PATH/public ./public
COPY --from=base --chown=nodejs:nodejs $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base --chown=nodejs:nodejs $APP_PATH/node_modules ./node_modules
COPY --from=base --chown=nodejs:nodejs $APP_PATH/package.json ./package.json
COPY --from=base $APP_PATH/server ./server
COPY --from=base $APP_PATH/public ./public
COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json
# Install wget to healthcheck the server
RUN apt-get update \
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
&& apt-get install -y wget \
&& rm -rf /var/lib/apt/lists/*
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
chown -R nodejs:nodejs "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
chmod 1777 "$FILE_STORAGE_LOCAL_ROOT_DIR"
VOLUME /var/lib/outline/data
@@ -44,4 +44,4 @@ USER nodejs
HEALTHCHECK --interval=1m CMD wget -qO- "http://localhost:${PORT:-3000}/_health" | grep -q "OK" || exit 1
EXPOSE 3000
CMD ["node", "build/server/index.js"]
CMD ["yarn", "start"]
+6 -5
View File
@@ -1,23 +1,24 @@
ARG APP_PATH=/opt/outline
FROM node:24.16.0 AS deps
FROM node:22 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN corepack enable
RUN yarn install --immutable --network-timeout 1000000 && \
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
COPY . .
ARG CDN_URL
RUN yarn build
RUN yarn workspaces focus --production && \
RUN rm -rf node_modules
RUN yarn install --production=true --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
ENV PORT=3000
+3 -3
View File
@@ -3,8 +3,8 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 1.8.1
The Licensed Work is (c) 2026 General Outline, Inc.
Licensed Work: Outline 0.87.4
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
Service.
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2030-06-06
Change Date: 2029-09-18
Change License: Apache License, Version 2.0
+1 -1
View File
@@ -1,7 +1,7 @@
up:
docker compose up -d redis postgres
yarn install-local-ssl
yarn install --immutable
yarn install --pure-lockfile
yarn dev:watch
build:
+12 -16
View File
@@ -1,9 +1,5 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./public/logos/outline-logo-dark.png" height="29">
<source media="(prefers-color-scheme: light)" srcset="./public/logos/outline-logo-light.png" height="29">
<img src="./public/logos/outline-logo-light.png" height="29" alt="Outline" />
</picture>
<img src="https://user-images.githubusercontent.com/31465/34380645-bd67f474-eb0b-11e7-8d03-0151c1730654.png" height="29" />
</p>
<p align="center">
<i>A fast, collaborative, knowledge base for your team built using React and Node.js.<br/>Try out Outline using our hosted version at <a href="https://www.getoutline.com">www.getoutline.com</a>.</i>
@@ -27,23 +23,23 @@ Please see the [documentation](https://docs.getoutline.com/s/hosting/) for runni
If you have questions or improvements for the docs please create a thread in [GitHub discussions](https://github.com/outline/outline/discussions).
# Contributing
# Development
> **Note:** Please do not submit AI-generated pull requests. We receive a high volume of mass, low-quality PRs generated by AI tools like Claude, ChatGPT, and Copilot from contributors who are unfamiliar with the codebase. These PRs are almost never mergeable and waste maintainer time reviewing them. If youd like to contribute, please take the time to understand the codebase and write your changes thoughtfully.
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
Before submitting a pull request _you must_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) wed also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written and that you have read these instructions. This will result in a much higher likelihood of your code being accepted.
## Contributing
If youre looking for ways to get started, heres a list of ways to help us improve Outline:
Outline is built and maintained by a small team we'd love your help to fix bugs and add features!
Before submitting a pull request _please_ discuss with the core team by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues) we'd also love to hear from you in the [discussions](https://www.github.com/outline/outline/discussions). This way we can ensure that an approach is agreed on before code is written. This will result in a much higher likelihood of your code being accepted.
If youre looking for ways to get started, here's a list of ways to help us improve Outline:
- [Translation](docs/TRANSLATION.md) into other languages
- Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
- Performance improvements, both on server and frontend
- Developer happiness and documentation
- Bugs, quality fixes, and other issues listed on GitHub
# Development
There is a short guide for [setting up a development environment](https://docs.getoutline.com/s/hosting/doc/local-development-5hEhFRXow7) if you wish to contribute changes, fixes, and improvements to Outline.
- Bugs and other issues listed on GitHub
## Architecture
@@ -61,7 +57,7 @@ can be enabled for all categories by setting `DEBUG=*` or for specific categorie
We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested.
To add new tests, write your tests with [Vitest](https://vitest.dev/) and add a file with `.test.ts` extension next to the tested code.
To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.ts` extension next to the tested code.
```shell
# To run all tests
@@ -72,7 +68,7 @@ make watch
```
Once the test database is created with `make test` you may individually run
frontend and backend tests directly with vitest:
frontend and backend tests directly with jest:
```shell
# To run backend tests
+3 -28
View File
@@ -21,23 +21,7 @@
}
],
"scripts": {
"postdeploy": "yarn sequelize db:migrate",
"pr-predeploy": "yarn sequelize db:migrate"
},
"environments": {
"review": {
"scripts": {
"postdeploy": "yarn sequelize db:migrate"
},
"addons": [
{
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:essential-0"
}
]
}
"postdeploy": "yarn sequelize db:migrate"
},
"env": {
"NODE_ENV": {
@@ -59,12 +43,8 @@
"required": true
},
"URL": {
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to. For review apps, this is auto-generated.",
"required": false
},
"HEROKU_APP_NAME": {
"description": "Automatically set by Heroku for review apps",
"required": false
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
"required": true
},
"GOOGLE_CLIENT_ID": {
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
@@ -214,11 +194,6 @@
"description": "Use a secure SMTP connection (optional)",
"required": false
},
"SMTP_DISABLE_STARTTLS": {
"value": "false",
"description": "Disable STARTTLS even if the server supports it (optional)",
"required": false
},
"SMTP_TLS_CIPHERS": {
"description": "Override SMTP cipher configuration (optional)",
"required": false
-8
View File
@@ -1,18 +1,10 @@
{
"extends": ["../.oxlintrc.json"],
"ignorePatterns": ["**/*.d.ts"],
"plugins": ["oxc", "eslint", "typescript", "react"],
"overrides": [
{
"files": ["**/*.{jsx,tsx}"],
"rules": {
"no-restricted-globals": [
"error",
{
"name": "crypto",
"message": "Do not use, does not work in environments without SSL."
}
],
"no-restricted-imports": [
"error",
{
+4 -23
View File
@@ -1,12 +1,9 @@
import copy from "copy-to-clipboard";
import { CopyIcon, PlusIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import { PlusIcon, TrashIcon } from "outline-icons";
import stores from "~/stores";
import env from "~/env";
import type ApiKey from "~/models/ApiKey";
import ApiKey from "~/models/ApiKey";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import ApiKeyRevokeDialog from "~/scenes/Settings/components/ApiKeyRevokeDialog";
import { createAction } from "..";
import { createAction, createActionV2 } from "..";
import { SettingsSection } from "../sections";
export const createApiKey = createAction({
@@ -28,24 +25,8 @@ export const createApiKey = createAction({
},
});
export const copyApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
name: ({ t }) => t("Copy"),
analyticsName: "Copy API key",
section: SettingsSection,
icon: <CopyIcon />,
visible: () => !!apiKey.value,
perform: ({ t }) => {
copy(apiKey.value, {
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});
toast.success(t("API key copied"));
},
});
export const revokeApiKeyFactory = ({ apiKey }: { apiKey: ApiKey }) =>
createAction({
createActionV2({
name: ({ t, isMenu }) =>
isMenu
? apiKey.isExpired
+213 -153
View File
@@ -1,12 +1,12 @@
import {
SortAlphabeticalReverseIcon,
SortAlphabeticalIcon,
AlphabeticalReverseSortIcon,
AlphabeticalSortIcon,
ArchiveIcon,
CollectionIcon,
EditIcon,
ExportIcon,
ImportIcon,
SortManualIcon,
ManualSortIcon,
NewDocumentIcon,
PadlockIcon,
PlusIcon,
@@ -29,8 +29,9 @@ import DynamicCollectionIcon from "~/components/Icons/CollectionIcon";
import { getHeaderExpandedKey } from "~/components/Sidebar/components/Header";
import {
createAction,
createInternalLinkAction,
createActionWithChildren,
createActionV2,
createActionV2WithChildren,
createInternalLinkActionV2,
} from "~/actions";
import { ActiveCollectionSection, CollectionSection } from "~/actions/sections";
import { setPersistedState } from "~/hooks/usePersistedState";
@@ -51,7 +52,7 @@ const SharePopover = lazyWithRetry(
() => import("~/components/Sharing/Collection/SharePopover")
);
export const openCollection = createActionWithChildren({
export const openCollection = createAction({
name: ({ t }) => t("Open collection"),
analyticsName: "Open collection",
section: CollectionSection,
@@ -59,17 +60,15 @@ export const openCollection = createActionWithChildren({
icon: <CollectionIcon />,
children: ({ stores }) => {
const collections = stores.collections.orderedData;
return collections.map((collection) =>
createInternalLinkAction({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
to: collection.path,
})
);
return collections.map((collection) => ({
// Note: using url which includes the slug rather than id here to bust
// cache if the collection is renamed
id: collection.path,
name: collection.name,
icon: <ColorCollectionIcon collection={collection} />,
section: CollectionSection,
to: collection.path,
}));
},
});
@@ -91,16 +90,16 @@ export const createCollection = createAction({
},
});
export const editCollection = createAction({
export const editCollection = createActionV2({
name: ({ t, isMenu }) => (isMenu ? `${t("Edit")}` : t("Edit collection")),
analyticsName: "Edit collection",
section: ActiveCollectionSection,
icon: <EditIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
@@ -109,29 +108,34 @@ export const editCollection = createAction({
content: (
<CollectionEdit
onSubmit={stores.dialogs.closeAllModals}
collectionId={collection.id}
collectionId={activeCollectionId}
/>
),
});
},
});
export const editCollectionPermissions = createAction({
export const editCollectionPermissions = createActionV2({
name: ({ t, isMenu }) =>
isMenu ? `${t("Permissions")}` : t("Collection permissions"),
analyticsName: "Collection permissions",
section: ActiveCollectionSection,
icon: <PadlockIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
perform: ({ t, getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update,
perform: ({ t, activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
title: t("Share this collection"),
style: { marginBottom: -12 },
content: (
<SharePopover
collection={collection}
@@ -143,39 +147,40 @@ export const editCollectionPermissions = createAction({
},
});
export const importDocument = createAction({
export const importDocument = createActionV2({
name: ({ t }) => t("Import document"),
analyticsName: "Import document",
section: ActiveCollectionSection,
icon: <ImportIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
),
perform: ({ t, getActiveModel, stores }) => {
const { documents } = stores;
const collection = getActiveModel(Collection);
if (!collection) {
return;
visible: ({ activeCollectionId, stores }) => {
if (activeCollectionId) {
return !!stores.policies.abilities(activeCollectionId).createDocument;
}
return false;
},
perform: ({ activeCollectionId, stores }) => {
const { documents } = stores;
const input = document.createElement("input");
input.type = "file";
input.accept = documents.importFileTypesString;
input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev) => {
const files = getEventFiles(ev);
const file = files[0];
const toastId = toast.loading(`${t("Uploading")}`);
try {
const document = await documents.import(file, null, collection.id, {
publish: true,
});
history.push(document.path);
const document = await documents.import(
file,
null,
activeCollectionId,
{
publish: true,
}
);
history.push(document.url);
} catch (err) {
toast.error(err.message);
} finally {
toast.dismiss(toastId);
}
};
@@ -183,39 +188,40 @@ export const importDocument = createAction({
},
});
export const sortCollection = createActionWithChildren({
export const sortCollection = createActionV2WithChildren({
name: ({ t }) => t("Sort in sidebar"),
section: ActiveCollectionSection,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.update),
icon: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) =>
!!activeCollectionId &&
!!stores.policies.abilities(activeCollectionId).update,
icon: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
const sortAlphabetical = collection?.sort.field === "title";
const sortDir = collection?.sort.direction;
return sortAlphabetical ? (
sortDir === "asc" ? (
<SortAlphabeticalIcon />
<AlphabeticalSortIcon />
) : (
<SortAlphabeticalReverseIcon />
<AlphabeticalReverseSortIcon />
)
) : (
<SortManualIcon />
<ManualSortIcon />
);
},
children: [
createAction({
createActionV2({
name: ({ t }) => t("A-Z sort"),
section: ActiveCollectionSection,
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "asc"
);
},
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
@@ -224,18 +230,18 @@ export const sortCollection = createActionWithChildren({
});
},
}),
createAction({
createActionV2({
name: ({ t }) => t("Z-A sort"),
section: ActiveCollectionSection,
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return (
collection?.sort.field === "title" &&
collection?.sort.direction === "desc"
);
},
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "title",
@@ -244,15 +250,15 @@ export const sortCollection = createActionWithChildren({
});
},
}),
createAction({
createActionV2({
name: ({ t }) => t("Manual sort"),
section: ActiveCollectionSection,
selected: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
selected: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.sort.field !== "title";
},
perform: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: ({ activeCollectionId, stores }) => {
const collection = stores.collections.get(activeCollectionId);
return collection?.save({
sort: {
field: "index",
@@ -264,24 +270,27 @@ export const sortCollection = createActionWithChildren({
],
});
export const searchInCollection = createInternalLinkAction({
export const searchInCollection = createInternalLinkActionV2({
name: ({ t }) => t("Search in collection"),
analyticsName: "Search collection",
section: ActiveCollectionSection,
icon: <SearchIcon />,
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection?.isActive) {
return false;
}
return stores.policies.abilities(collection.id).readDocument;
return stores.policies.abilities(activeCollectionId).readDocument;
},
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = searchPath({
collectionId: collection?.id,
collectionId: activeCollectionId,
}).split("?");
return {
@@ -292,124 +301,143 @@ export const searchInCollection = createInternalLinkAction({
},
});
export const starCollection = createAction({
export const starCollection = createActionV2({
name: ({ t }) => t("Star"),
analyticsName: "Star collection",
section: ActiveCollectionSection,
icon: <StarredIcon />,
keywords: "favorite bookmark",
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!collection.isStarred && stores.policies.abilities(collection.id).star
!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).star
);
},
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
if (!collection) {
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
await collection.star();
const collection = stores.collections.get(activeCollectionId);
await collection?.star();
setPersistedState(getHeaderExpandedKey("starred"), true);
},
});
export const unstarCollection = createAction({
export const unstarCollection = createActionV2({
name: ({ t }) => t("Unstar"),
analyticsName: "Unstar collection",
section: ActiveCollectionSection,
icon: <UnstarredIcon />,
keywords: "unfavorite unbookmark",
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection.isStarred && stores.policies.abilities(collection.id).unstar
!!collection?.isStarred &&
stores.policies.abilities(activeCollectionId).unstar
);
},
perform: async ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
perform: async ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
await collection?.unstar();
},
});
export const subscribeCollection = createAction({
export const subscribeCollection = createActionV2({
name: ({ t }) => t("Subscribe"),
analyticsName: "Subscribe to collection",
section: ActiveCollectionSection,
icon: <SubscribeIcon />,
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection.isActive &&
!collection.isSubscribed &&
stores.policies.abilities(collection.id).subscribe
!!collection?.isActive &&
!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).subscribe
);
},
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
await collection.subscribe();
const collection = stores.collections.get(activeCollectionId);
await collection?.subscribe();
toast.success(t("Subscribed to document notifications"));
},
});
export const unsubscribeCollection = createAction({
export const unsubscribeCollection = createActionV2({
name: ({ t }) => t("Unsubscribe"),
analyticsName: "Unsubscribe from collection",
section: ActiveCollectionSection,
icon: <UnsubscribeIcon />,
visible: ({ getActiveModel, stores }) => {
const collection = getActiveModel(Collection);
if (!collection) {
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
const collection = stores.collections.get(activeCollectionId);
return (
!!collection.isActive &&
!!collection.isSubscribed &&
stores.policies.abilities(collection.id).unsubscribe
!!collection?.isActive &&
!!collection?.isSubscribed &&
stores.policies.abilities(activeCollectionId).unsubscribe
);
},
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
if (!collection) {
perform: async ({ activeCollectionId, currentUserId, stores, t }) => {
if (!activeCollectionId || !currentUserId) {
return;
}
await collection.unsubscribe();
const collection = stores.collections.get(activeCollectionId);
await collection?.unsubscribe();
toast.success(t("Unsubscribed from document notifications"));
},
});
export const archiveCollection = createAction({
export const archiveCollection = createActionV2({
name: ({ t }) => `${t("Archive")}`,
analyticsName: "Archive collection",
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.archive),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).archive;
},
perform: async ({ activeCollectionId, stores, t }) => {
const { dialogs, collections } = stores;
if (!activeCollectionId) {
return;
}
const collection = collections.get(activeCollectionId);
if (!collection) {
return;
}
stores.dialogs.openModal({
dialogs.openModal({
title: t("Archive collection"),
content: (
<ConfirmationDialog
@@ -429,15 +457,22 @@ export const archiveCollection = createAction({
},
});
export const restoreCollection = createAction({
export const restoreCollection = createActionV2({
name: ({ t }) => t("Restore"),
analyticsName: "Restore collection",
section: CollectionSection,
icon: <RestoreIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.restore),
perform: async ({ getActiveModel, t }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return !!stores.policies.abilities(activeCollectionId).restore;
},
perform: async ({ activeCollectionId, stores, t }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
@@ -447,16 +482,24 @@ export const restoreCollection = createAction({
},
});
export const deleteCollection = createAction({
export const deleteCollection = createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete collection",
section: ActiveCollectionSection,
dangerous: true,
icon: <TrashIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, t, stores }) => {
const collection = getActiveModel(Collection);
visible: ({ activeCollectionId, stores }) => {
if (!activeCollectionId) {
return false;
}
return stores.policies.abilities(activeCollectionId).delete;
},
perform: ({ activeCollectionId, t, stores }) => {
if (!activeCollectionId) {
return;
}
const collection = stores.collections.get(activeCollectionId);
if (!collection) {
return;
}
@@ -473,15 +516,26 @@ export const deleteCollection = createAction({
},
});
export const exportCollection = createAction({
export const exportCollection = createActionV2({
name: ({ t }) => `${t("Export")}`,
analyticsName: "Export collection",
section: ActiveCollectionSection,
icon: <ExportIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some((policy) => policy.abilities.export),
perform: async ({ getActiveModel, stores, t }) => {
const collection = getActiveModel(Collection);
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;
}
@@ -498,19 +552,19 @@ export const exportCollection = createAction({
},
});
export const createDocument = createInternalLinkAction({
export const createDocument = createInternalLinkActionV2({
name: ({ t }) => t("New document"),
analyticsName: "New document",
section: ActiveCollectionSection,
icon: <NewDocumentIcon />,
keywords: "new create document",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createDocument
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ getActiveModel, sidebarContext }) => {
const collection = getActiveModel(Collection);
const [pathname, search] = newDocumentPath(collection?.id).split("?");
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(activeCollectionId).split("?");
return {
pathname,
@@ -520,19 +574,25 @@ export const createDocument = createInternalLinkAction({
},
});
export const createTemplate = createInternalLinkAction({
export const createTemplate = createInternalLinkActionV2({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: ActiveCollectionSection,
icon: <ShapesIcon />,
keywords: "new create template",
visible: ({ getActivePolicies }) =>
getActivePolicies(Collection).some(
(policy) => policy.abilities.createTemplate
visible: ({ activeCollectionId, stores }) =>
!!(
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).createDocument
),
to: ({ getActiveModel }) => {
const collection = getActiveModel(Collection);
return newTemplatePath(collection?.id);
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newTemplatePath(activeCollectionId).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
+6 -6
View File
@@ -1,9 +1,9 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import { toast } from "sonner";
import type Comment from "~/models/Comment";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ViewReactionsDialog from "~/components/Reactions/ViewReactionsDialog";
import { createAction } from "..";
import { createActionV2 } from "..";
import { ActiveDocumentSection } from "../sections";
export const deleteCommentFactory = ({
@@ -13,7 +13,7 @@ export const deleteCommentFactory = ({
comment: Comment;
onDelete: () => void;
}) =>
createAction({
createActionV2({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete comment",
section: ActiveDocumentSection,
@@ -39,7 +39,7 @@ export const resolveCommentFactory = ({
comment: Comment;
onResolve: () => void;
}) =>
createAction({
createActionV2({
name: ({ t }) => t("Mark as resolved"),
analyticsName: "Resolve thread",
section: ActiveDocumentSection,
@@ -61,7 +61,7 @@ export const unresolveCommentFactory = ({
comment: Comment;
onUnresolve: () => void;
}) =>
createAction({
createActionV2({
name: ({ t }) => t("Mark as unresolved"),
analyticsName: "Unresolve thread",
section: ActiveDocumentSection,
@@ -80,7 +80,7 @@ export const viewCommentReactionsFactory = ({
}: {
comment: Comment;
}) =>
createAction({
createActionV2({
name: ({ t }) => `${t("View reactions")}`,
analyticsName: "View comment reactions",
section: ActiveDocumentSection,
+5 -32
View File
@@ -9,7 +9,7 @@ import {
UserIcon,
} from "outline-icons";
import { toast } from "sonner";
import { createAction, createActionWithChildren } from "~/actions";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
import env from "~/env";
import { client } from "~/utils/ApiClient";
@@ -17,19 +17,9 @@ import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
import Logger from "~/utils/Logger";
import { deleteAllDatabases } from "~/utils/developer";
import history from "~/utils/history";
import { homePath, debugPath } from "~/utils/routeHelpers";
import { homePath } from "~/utils/routeHelpers";
export const goToDebug = createAction({
name: "Go to debug screen",
icon: <BeakerIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: () => {
history.push(debugPath());
},
});
export const copyId = createActionWithChildren({
export const copyId = createAction({
name: ({ t }) => t("Copy ID"),
icon: <CopyIcon />,
keywords: "uuid",
@@ -186,22 +176,7 @@ export const toggleDebugLogging = createAction({
},
});
export const toggleDebugSafeArea = createAction({
name: () => "Toggle menu safe area debugging",
icon: <ToolsIcon />,
section: DeveloperSection,
visible: () => env.ENVIRONMENT === "development",
perform: ({ stores }) => {
stores.ui.toggleDebugSafeArea();
toast.message(
stores.ui.debugSafeArea
? "Menu safe area debugging enabled"
: "Menu safe area debugging disabled"
);
},
});
export const toggleFeatureFlag = createActionWithChildren({
export const toggleFeatureFlag = createAction({
name: "Toggle feature flag",
icon: <BeakerIcon />,
section: DeveloperSection,
@@ -225,17 +200,15 @@ export const toggleFeatureFlag = createActionWithChildren({
),
});
export const developer = createActionWithChildren({
export const developer = createAction({
name: ({ t }) => t("Development"),
keywords: "debug",
icon: <ToolsIcon />,
iconInContextMenu: false,
section: DeveloperSection,
children: [
goToDebug,
copyId,
toggleDebugLogging,
toggleDebugSafeArea,
toggleFeatureFlag,
createToast,
createTestUsers,
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
import { PlusIcon } from "outline-icons";
import { createAction } from "~/actions";
import { TeamSection } from "../sections";
import stores from "~/stores";
import { EmojiCreateDialog } from "~/components/EmojiDialog/EmojiCreateDialog";
export const createEmoji = createAction({
name: ({ t }) => `${t("New emoji")}`,
analyticsName: "Create emoji",
icon: <PlusIcon />,
keywords: "emoji custom upload image",
section: TeamSection,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Upload emoji"),
content: <EmojiCreateDialog onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
+3 -22
View File
@@ -2,28 +2,9 @@ import { TrashIcon } from "outline-icons";
import stores from "~/stores";
import { createAction } from "..";
import { SettingsSection } from "../sections";
import type Integration from "~/models/Integration";
import { DisconnectAnalyticsDialog } from "~/scenes/Settings/components/DisconnectAnalyticsDialog";
import type { IntegrationType } from "@shared/types";
import { settingsPath } from "@shared/utils/routeHelpers";
import history from "~/utils/history";
export const disconnectIntegrationFactory = (integration?: Integration) =>
createAction({
name: ({ t }) => t("Disconnect"),
analyticsName: "Disconnect integration",
section: SettingsSection,
icon: <TrashIcon />,
keywords: "disconnect",
visible: () => !!integration,
perform: async ({ event }) => {
event?.preventDefault();
event?.stopPropagation();
await integration?.delete();
history.push(settingsPath("integrations"));
},
});
import Integration from "~/models/Integration";
import { IntegrationType } from "@shared/types";
import { DisconnectAnalyticsDialog } from "~/components/DisconnectAnalyticsDialog";
export const disconnectAnalyticsIntegrationFactory = (
integration?: Integration<IntegrationType.Analytics>
+31 -31
View File
@@ -17,12 +17,13 @@ import {
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isMac } from "@shared/utils/browser";
import stores from "~/stores";
import type SearchQuery from "~/models/SearchQuery";
import SearchQuery from "~/models/SearchQuery";
import KeyboardShortcuts from "~/scenes/KeyboardShortcuts";
import {
createAction,
createExternalLinkAction,
createInternalLinkAction,
createActionV2,
createExternalLinkActionV2,
createInternalLinkActionV2,
} from "~/actions";
import { NavigationSection, RecentSearchesSection } from "~/actions/sections";
import Desktop from "~/utils/Desktop";
@@ -36,7 +37,7 @@ import {
settingsPath,
} from "~/utils/routeHelpers";
export const navigateToHome = createInternalLinkAction({
export const navigateToHome = createAction({
name: ({ t }) => t("Home"),
analyticsName: "Navigate to home",
section: NavigationSection,
@@ -47,7 +48,7 @@ export const navigateToHome = createInternalLinkAction({
});
export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
createInternalLinkAction({
createAction({
section: RecentSearchesSection,
name: searchQuery.query,
analyticsName: "Navigate to recent search query",
@@ -55,7 +56,7 @@ export const navigateToRecentSearchQuery = (searchQuery: SearchQuery) =>
to: searchPath({ query: searchQuery.query }),
});
export const navigateToDrafts = createInternalLinkAction({
export const navigateToDrafts = createAction({
name: ({ t }) => t("Drafts"),
analyticsName: "Navigate to drafts",
section: NavigationSection,
@@ -64,7 +65,7 @@ export const navigateToDrafts = createInternalLinkAction({
visible: ({ location }) => location.pathname !== draftsPath(),
});
export const navigateToSearch = createInternalLinkAction({
export const navigateToSearch = createAction({
name: ({ t }) => t("Search"),
analyticsName: "Navigate to search",
section: NavigationSection,
@@ -73,7 +74,7 @@ export const navigateToSearch = createInternalLinkAction({
visible: ({ location }) => location.pathname !== searchPath(),
});
export const navigateToArchive = createInternalLinkAction({
export const navigateToArchive = createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Navigate to archive",
section: NavigationSection,
@@ -83,7 +84,7 @@ export const navigateToArchive = createInternalLinkAction({
visible: ({ location }) => location.pathname !== archivePath(),
});
export const navigateToTrash = createInternalLinkAction({
export const navigateToTrash = createAction({
name: ({ t }) => t("Trash"),
analyticsName: "Navigate to trash",
section: NavigationSection,
@@ -92,7 +93,7 @@ export const navigateToTrash = createInternalLinkAction({
visible: ({ location }) => location.pathname !== trashPath(),
});
export const navigateToSettings = createInternalLinkAction({
export const navigateToSettings = createAction({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to settings",
section: NavigationSection,
@@ -102,7 +103,7 @@ export const navigateToSettings = createInternalLinkAction({
to: settingsPath(),
});
export const navigateToWorkspaceSettings = createInternalLinkAction({
export const navigateToWorkspaceSettings = createInternalLinkActionV2({
name: ({ t }) => t("Settings"),
analyticsName: "Navigate to workspace settings",
section: NavigationSection,
@@ -111,7 +112,7 @@ export const navigateToWorkspaceSettings = createInternalLinkAction({
to: settingsPath("details"),
});
export const navigateToProfileSettings = createInternalLinkAction({
export const navigateToProfileSettings = createInternalLinkActionV2({
name: ({ t }) => t("Profile"),
analyticsName: "Navigate to profile settings",
section: NavigationSection,
@@ -120,7 +121,7 @@ export const navigateToProfileSettings = createInternalLinkAction({
to: settingsPath(),
});
export const navigateToTemplateSettings = createInternalLinkAction({
export const navigateToTemplateSettings = createAction({
name: ({ t }) => t("Templates"),
analyticsName: "Navigate to template settings",
section: NavigationSection,
@@ -129,7 +130,7 @@ export const navigateToTemplateSettings = createInternalLinkAction({
to: settingsPath("templates"),
});
export const navigateToNotificationSettings = createInternalLinkAction({
export const navigateToNotificationSettings = createInternalLinkActionV2({
name: ({ t, isMenu }) =>
isMenu ? t("Notification settings") : t("Notifications"),
analyticsName: "Navigate to notification settings",
@@ -139,7 +140,7 @@ export const navigateToNotificationSettings = createInternalLinkAction({
to: settingsPath("notifications"),
});
export const navigateToAccountPreferences = createInternalLinkAction({
export const navigateToAccountPreferences = createInternalLinkActionV2({
name: ({ t }) => t("Preferences"),
analyticsName: "Navigate to account preferences",
section: NavigationSection,
@@ -148,7 +149,7 @@ export const navigateToAccountPreferences = createInternalLinkAction({
to: settingsPath("preferences"),
});
export const openDocumentation = createExternalLinkAction({
export const openDocumentation = createExternalLinkActionV2({
name: ({ t }) => t("Documentation"),
analyticsName: "Open documentation",
section: NavigationSection,
@@ -158,7 +159,7 @@ export const openDocumentation = createExternalLinkAction({
target: "_blank",
});
export const openAPIDocumentation = createExternalLinkAction({
export const openAPIDocumentation = createExternalLinkActionV2({
name: ({ t }) => t("API documentation"),
analyticsName: "Open API documentation",
section: NavigationSection,
@@ -176,7 +177,7 @@ export const toggleSidebar = createAction({
perform: () => stores.ui.toggleCollapsedSidebar(),
});
export const openFeedbackUrl = createExternalLinkAction({
export const openFeedbackUrl = createExternalLinkActionV2({
name: ({ t }) => t("Send us feedback"),
analyticsName: "Open feedback",
section: NavigationSection,
@@ -186,7 +187,7 @@ export const openFeedbackUrl = createExternalLinkAction({
target: "_blank",
});
export const openBugReportUrl = createExternalLinkAction({
export const openBugReportUrl = createExternalLinkActionV2({
name: ({ t }) => t("Report a bug"),
analyticsName: "Open bug report",
section: NavigationSection,
@@ -196,7 +197,7 @@ export const openBugReportUrl = createExternalLinkAction({
target: "_blank",
});
export const openChangelog = createExternalLinkAction({
export const openChangelog = createExternalLinkActionV2({
name: ({ t }) => t("Changelog"),
analyticsName: "Open changelog",
section: NavigationSection,
@@ -206,7 +207,7 @@ export const openChangelog = createExternalLinkAction({
target: "_blank",
});
export const openKeyboardShortcuts = createAction({
export const openKeyboardShortcuts = createActionV2({
name: ({ t }) => t("Keyboard shortcuts"),
analyticsName: "Open keyboard shortcuts",
section: NavigationSection,
@@ -221,30 +222,29 @@ export const openKeyboardShortcuts = createAction({
},
});
export const downloadApp = createExternalLinkAction({
export const downloadApp = createAction({
name: ({ t }) =>
t("Download {{ platform }} app", {
platform: isMac ? "macOS" : "Windows",
platform: isMac() ? "macOS" : "Windows",
}),
analyticsName: "Download app",
section: NavigationSection,
iconInContextMenu: false,
icon: <BrowserIcon />,
visible: () => !Desktop.isElectron() && isMac && isCloudHosted,
url: "https://desktop.getoutline.com",
target: "_blank",
visible: () => !Desktop.isElectron() && isMac() && isCloudHosted,
to: {
url: "https://desktop.getoutline.com",
target: "_blank",
},
});
export const logout = createAction({
export const logout = createActionV2({
name: ({ t }) => t("Log out"),
analyticsName: "Log out",
section: NavigationSection,
icon: <LogoutIcon />,
perform: async () => {
await stores.auth.logout({
userInitiated: true,
clearCache: true,
});
await stores.auth.logout({ userInitiated: true });
},
});
+3 -34
View File
@@ -1,7 +1,6 @@
import { ArchiveIcon, CheckmarkIcon, MarkAsReadIcon } from "outline-icons";
import { createAction } from "..";
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import { createAction, createActionV2 } from "..";
import { NotificationSection } from "../sections";
import type Notification from "~/models/Notification";
export const markNotificationsAsRead = createAction({
name: ({ t }) => t("Mark notifications as read"),
@@ -13,7 +12,7 @@ export const markNotificationsAsRead = createAction({
visible: ({ stores }) => stores.notifications.approximateUnreadCount > 0,
});
export const markNotificationsAsArchived = createAction({
export const markNotificationsAsArchived = createActionV2({
name: ({ t }) => t("Archive all notifications"),
analyticsName: "Mark notifications as archived",
section: NotificationSection,
@@ -23,36 +22,6 @@ export const markNotificationsAsArchived = createAction({
visible: ({ stores }) => stores.notifications.orderedData.length > 0,
});
export const notificationMarkRead = (notification: Notification) =>
createAction({
name: ({ t }) => t("Mark as read"),
analyticsName: "Mark notification read",
section: NotificationSection,
icon: <CheckmarkIcon />,
perform: () => notification.toggleRead(),
visible: () => !notification.viewedAt,
});
export const notificationMarkUnread = (notification: Notification) =>
createAction({
name: ({ t }) => t("Mark as unread"),
analyticsName: "Mark notification unread",
section: NotificationSection,
icon: <CheckmarkIcon />,
perform: () => notification.toggleRead(),
visible: () => !!notification.viewedAt,
});
export const notificationArchive = (notification: Notification) =>
createAction({
name: ({ t }) => t("Archive"),
analyticsName: "Mark notification as archived",
section: NotificationSection,
icon: <ArchiveIcon />,
perform: () => notification.archive(),
visible: () => !notification.archivedAt,
});
export const rootNotificationActions = [
markNotificationsAsRead,
markNotificationsAsArchived,
+34 -109
View File
@@ -1,28 +1,24 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon, TrashIcon, DownloadIcon } from "outline-icons";
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import { ExportContentType } from "@shared/types";
import Revision from "~/models/Revision";
import stores from "~/stores";
import { createAction, createActionWithChildren } from "~/actions";
import { createAction, createActionV2 } from "~/actions";
import { RevisionSection } from "~/actions/sections";
import env from "~/env";
import history from "~/utils/history";
import {
documentHistoryPath,
matchDocumentHistory,
urlify,
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
export const restoreRevision = createActionV2({
name: ({ t }) => t("Restore"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ event, location, activeDocumentId, getActiveModel }) => {
perform: async ({ event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
@@ -31,10 +27,7 @@ export const restoreRevision = createAction({
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = getActiveModel(Revision)?.id ?? match?.params.revisionId;
if (!revisionId) {
return;
}
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
@@ -80,105 +73,37 @@ export const deleteRevision = createAction({
},
});
export const copyLinkToRevision = (revisionId: string) =>
createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, t }) => {
if (!activeDocumentId) {
return;
}
export const copyLinkToRevision = createActionV2({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
icon: <LinkIcon />,
section: RevisionSection,
perform: async ({ activeDocumentId, t }) => {
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const url = urlify(documentHistoryPath(document, revisionId));
const url = `${window.location.origin}${documentHistoryPath(
document,
revisionId
)}`;
copy(url, {
format: "text/plain",
onCopy: () => {
toast.message(t("Link copied"));
},
});
},
});
export const downloadRevisionAsHTML = (revisionId: string) =>
createAction({
name: ({ t }) => t("HTML"),
analyticsName: "Download revision as HTML",
section: RevisionSection,
keywords: "html export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) =>
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download,
perform: async () => {
const revision = stores.revisions.get(revisionId);
await revision?.download(ExportContentType.Html);
},
});
export const downloadRevisionAsPDF = (revisionId: string) =>
createAction({
name: ({ t }) => t("PDF"),
analyticsName: "Download revision as PDF",
section: RevisionSection,
keywords: "export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) =>
!!(
activeDocumentId &&
stores.policies.abilities(activeDocumentId).download &&
env.PDF_EXPORT_ENABLED
),
perform: ({ t }) => {
const id = toast.loading(`${t("Exporting")}`);
const revision = stores.revisions.get(revisionId);
return revision
?.download(ExportContentType.Pdf)
.finally(() => id && toast.dismiss(id));
},
});
export const downloadRevisionAsMarkdown = (revisionId: string) =>
createAction({
name: ({ t }) => t("Markdown"),
analyticsName: "Download revision as Markdown",
section: RevisionSection,
keywords: "md markdown export",
icon: <DownloadIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId }) =>
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download,
perform: async () => {
const revision = stores.revisions.get(revisionId);
await revision?.download(ExportContentType.Markdown);
},
});
export const downloadRevision = (revisionId: string) =>
createActionWithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Download") : t("Download revision")),
analyticsName: "Download revision",
section: RevisionSection,
icon: <DownloadIcon />,
keywords: "export",
visible: ({ activeDocumentId }) =>
!!activeDocumentId &&
stores.policies.abilities(activeDocumentId).download,
children: [
downloadRevisionAsHTML(revisionId),
downloadRevisionAsPDF(revisionId),
downloadRevisionAsMarkdown(revisionId),
],
});
copy(url, {
format: "text/plain",
onCopy: () => {
toast.message(t("Link copied"));
},
});
},
});
export const rootRevisionActions = [];
+6 -21
View File
@@ -1,9 +1,9 @@
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import { Theme } from "~/stores/UiStore";
import { createAction, createActionWithChildren } from "~/actions";
import { createActionV2, createActionV2WithChildren } from "~/actions";
import { SettingsSection } from "~/actions/sections";
export const changeToDarkTheme = createAction({
export const changeToDarkTheme = createActionV2({
name: ({ t }) => t("Dark"),
analyticsName: "Change to dark theme",
icon: <MoonIcon />,
@@ -14,7 +14,7 @@ export const changeToDarkTheme = createAction({
perform: ({ stores }) => stores.ui.setTheme(Theme.Dark),
});
export const changeToLightTheme = createAction({
export const changeToLightTheme = createActionV2({
name: ({ t }) => t("Light"),
analyticsName: "Change to light theme",
icon: <SunIcon />,
@@ -25,22 +25,7 @@ export const changeToLightTheme = createAction({
perform: ({ stores }) => stores.ui.setTheme(Theme.Light),
});
export const toggleTheme = createAction({
name: ({ t }) => t("Toggle theme"),
analyticsName: "Change theme",
iconInContextMenu: false,
icon: ({ stores }) =>
stores.ui.resolvedTheme === "light" ? <MoonIcon /> : <SunIcon />,
keywords: "theme light day",
section: SettingsSection,
shortcut: ["Meta+Shift+l"],
perform: ({ stores }) =>
stores.ui.setTheme(
stores.ui.resolvedTheme === "light" ? Theme.Dark : Theme.Light
),
});
export const changeToSystemTheme = createAction({
export const changeToSystemTheme = createActionV2({
name: ({ t }) => t("System"),
analyticsName: "Change to system theme",
icon: <BrowserIcon />,
@@ -51,7 +36,7 @@ export const changeToSystemTheme = createAction({
perform: ({ stores }) => stores.ui.setTheme(Theme.System),
});
export const changeTheme = createActionWithChildren({
export const changeTheme = createActionV2WithChildren({
name: ({ t, isMenu }) => (isMenu ? t("Appearance") : t("Change theme")),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
@@ -62,4 +47,4 @@ export const changeTheme = createActionWithChildren({
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
});
export const rootSettingsActions = [changeTheme, toggleTheme];
export const rootSettingsActions = [changeTheme];
+5 -5
View File
@@ -1,13 +1,13 @@
import copy from "copy-to-clipboard";
import type Share from "~/models/Share";
import { createAction, createInternalLinkAction } from "..";
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 }) =>
createAction({
createActionV2({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy share link",
section: ShareSection,
@@ -22,7 +22,7 @@ export const copyShareUrlFactory = ({ share }: { share: Share }) =>
});
export const goToShareSourceFactory = ({ share }: { share: Share }) =>
createInternalLinkAction({
createInternalLinkActionV2({
name: ({ t }) =>
share.collectionId ? t("Go to collection") : t("Go to document"),
analyticsName: "Go to share source",
@@ -41,7 +41,7 @@ export const revokeShareFactory = ({
share: Share;
can: Record<string, boolean>;
}) =>
createAction({
createActionV2({
name: ({ t }) => t("Revoke link"),
analyticsName: "Revoke share",
section: ShareSection,
+10 -10
View File
@@ -1,22 +1,22 @@
import { ArrowIcon, PlusIcon } from "outline-icons";
import styled from "styled-components";
import { stringToColor } from "@shared/utils/color";
import type RootStore from "~/stores/RootStore";
import RootStore from "~/stores/RootStore";
import { LoginDialog } from "~/scenes/Login/components/LoginDialog";
import TeamNew from "~/scenes/TeamNew";
import TeamLogo from "~/components/TeamLogo";
import {
createAction,
createActionWithChildren,
createExternalLinkAction,
createActionV2,
createActionV2WithChildren,
createExternalLinkActionV2,
} from "~/actions";
import type { ActionContext, ExternalLinkAction } from "~/types";
import { ActionContext, ExternalLinkActionV2 } from "~/types";
import Desktop from "~/utils/Desktop";
import { TeamSection } from "../sections";
export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
stores.auth.availableTeams?.map<ExternalLinkAction>((session) =>
createExternalLinkAction({
stores.auth.availableTeams?.map<ExternalLinkActionV2>((session) =>
createExternalLinkActionV2({
id: `switch-${session.id}`,
name: session.name,
analyticsName: "Switch workspace",
@@ -41,7 +41,7 @@ export const switchTeamsList = ({ stores }: { stores: RootStore }) =>
})
) ?? [];
export const switchTeam = createActionWithChildren({
export const switchTeam = createActionV2WithChildren({
name: ({ t }) => t("Switch workspace"),
placeholder: ({ t }) => t("Select a workspace"),
analyticsName: "Switch workspace",
@@ -52,7 +52,7 @@ export const switchTeam = createActionWithChildren({
children: switchTeamsList,
});
export const createTeam = createAction({
export const createTeam = createActionV2({
name: ({ t }) => `${t("New workspace")}`,
analyticsName: "New workspace",
keywords: "create change switch workspace organization team",
@@ -74,7 +74,7 @@ export const createTeam = createAction({
},
});
export const desktopLoginTeam = createAction({
export const desktopLoginTeam = createActionV2({
name: ({ t }) => t("Login to workspace"),
analyticsName: "Login to workspace",
keywords: "change switch workspace organization team",
-230
View File
@@ -1,230 +0,0 @@
import copy from "copy-to-clipboard";
import {
CaseSensitiveIcon,
CollectionIcon,
CopyIcon,
MoveIcon,
NewDocumentIcon,
PlusIcon,
PrintIcon,
TrashIcon,
} from "outline-icons";
import { Trans } from "react-i18next";
import { toast } from "sonner";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import TemplateMove from "~/components/DocumentExplorer/TemplateMove";
import {
createAction,
createActionWithChildren,
createInternalLinkAction,
} from "~/actions";
import history from "~/utils/history";
import {
newDocumentPath,
newTemplatePath,
settingsPath,
urlify,
} from "~/utils/routeHelpers";
import { ProsemirrorHelper } from "~/models/helpers/ProsemirrorHelper";
import { ActiveTemplateSection, TemplateSection } from "../sections";
import Template from "~/models/Template";
import { AvatarSize } from "~/components/Avatar";
import TeamLogo from "~/components/TeamLogo";
export const createTemplate = createInternalLinkAction({
name: ({ t }) => t("New template"),
analyticsName: "New template",
section: TemplateSection,
icon: <PlusIcon />,
keywords: "new create template",
visible: ({ currentTeamId, stores }) =>
!!stores.policies.abilities(currentTeamId!).createTemplate,
to: newTemplatePath(),
});
export const deleteTemplate = createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete template",
section: ActiveTemplateSection,
icon: <TrashIcon />,
dangerous: true,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.delete),
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Delete {{ documentName }}", {
documentName: t("template"),
}),
content: (
<ConfirmationDialog
onSubmit={async () => {
await template.delete();
history.push(settingsPath("templates"));
toast.success(t("Template deleted"));
}}
savingText={`${t("Deleting")}`}
danger
>
<Trans
defaults="Are you sure about that? Deleting the <em>{{ templateName }}</em> template is permanent."
values={{
templateName: template.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>
</ConfirmationDialog>
),
});
},
});
export const moveTemplateToWorkspace = createAction({
name: ({ t }) => t("Move to workspace"),
analyticsName: "Move template to workspace",
section: ActiveTemplateSection,
icon: ({ stores }) => {
const { team } = stores.auth;
return <TeamLogo model={team} size={AvatarSize.Small} />;
},
visible: ({ getActiveModel }) => {
const template = getActiveModel(Template);
return !!template?.collectionId;
},
perform: async ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
try {
await template.save({ collectionId: null });
toast.success(t("Template moved"));
stores.dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldn't move the template, try again?"));
}
},
});
export const moveTemplateToCollection = createAction({
name: ({ t }) => t("Move to collection"),
analyticsName: "Move template to collection",
section: ActiveTemplateSection,
icon: <CollectionIcon />,
perform: ({ getActiveModel, stores, t }) => {
const template = getActiveModel(Template);
if (!template) {
return;
}
stores.dialogs.openModal({
title: t("Move template"),
content: <TemplateMove template={template} />,
});
},
});
export const moveTemplate = createActionWithChildren({
name: ({ t }) => t("Move"),
analyticsName: "Move template",
section: ActiveTemplateSection,
icon: <MoveIcon />,
visible: ({ getActivePolicies }) =>
getActivePolicies(Template).some((policy) => policy.abilities.move),
children: [moveTemplateToWorkspace, moveTemplateToCollection],
});
export const createDocumentFromTemplate = createInternalLinkAction({
name: ({ t }) => t("New document"),
analyticsName: "New document from template",
section: ActiveTemplateSection,
icon: <NewDocumentIcon />,
keywords: "create",
visible: ({ currentTeamId, getActiveModel, stores }) => {
const template = getActiveModel(Template);
if (!template || !currentTeamId) {
return false;
}
if (template.collectionId) {
return !!stores.policies.abilities(template.collectionId).createDocument;
}
return !!stores.policies.abilities(currentTeamId).createDocument;
},
to: ({ getActiveModel, activeCollectionId, sidebarContext }) => {
const template = getActiveModel(Template);
if (!template) {
return "";
}
const collectionId = template?.collectionId ?? activeCollectionId;
const [pathname, search] = newDocumentPath(collectionId, {
templateId: template.id,
}).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
});
export const copyTemplateLink = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy template link",
section: ActiveTemplateSection,
icon: <CopyIcon />,
iconInContextMenu: false,
perform: ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
copy(urlify(template.path));
toast.success(t("Link copied to clipboard"));
}
},
});
export const copyTemplateAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
analyticsName: "Copy template as text",
section: ActiveTemplateSection,
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
perform: async ({ getActiveModel, t }) => {
const template = getActiveModel(Template);
if (template) {
copy(ProsemirrorHelper.toPlainText(template));
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyTemplate = createActionWithChildren({
name: ({ t }) => t("Copy"),
analyticsName: "Copy template",
section: ActiveTemplateSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyTemplateLink, copyTemplateAsPlainText],
});
export const printTemplate = createAction({
name: ({ t, isMenu }) => (isMenu ? t("Print") : t("Print template")),
analyticsName: "Print template",
section: ActiveTemplateSection,
icon: <PrintIcon />,
visible: ({ getActiveModel }) => !!getActiveModel(Template) && !!window.print,
perform: () => {
setTimeout(window.print, 0);
},
});
export const rootTemplateActions = [moveTemplate, createDocumentFromTemplate];
+5 -6
View File
@@ -1,14 +1,14 @@
import { PlusIcon } from "outline-icons";
import type { UserRole } from "@shared/types";
import { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import stores from "~/stores";
import type User from "~/models/User";
import User from "~/models/User";
import Invite from "~/scenes/Invite";
import {
UserChangeRoleDialog,
UserDeleteDialog,
} from "~/components/UserDialogs";
import { createAction } from "~/actions";
import { createAction, createActionV2 } from "~/actions";
import { UserSection } from "~/actions/sections";
export const inviteUser = createAction({
@@ -22,14 +22,13 @@ export const inviteUser = createAction({
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Invite to workspace"),
width: "500px",
content: <Invite onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
createAction({
createActionV2({
name: ({ t }) =>
UserRoleHelper.isRoleHigher(role, user!.role)
? `${t("Promote to {{ role }}", {
@@ -64,7 +63,7 @@ export const updateUserRoleActionFactory = (user: User, role: UserRole) =>
});
export const deleteUserActionFactory = (userId: string) =>
createAction({
createActionV2({
name: ({ t }) => `${t("Delete user")}`,
analyticsName: "Delete user",
keywords: "leave",
+203 -60
View File
@@ -1,37 +1,186 @@
import type { LocationDescriptor } from "history";
import { LocationDescriptor } from "history";
import flattenDeep from "lodash/flattenDeep";
import { toast } from "sonner";
import type { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import type {
ActionContext,
import { Optional } from "utility-types";
import {
Action,
ActionGroup,
ActionSeparator as TActionSeparator,
ActionVariant,
ActionWithChildren,
ExternalLinkAction,
InternalLinkAction,
ActionContext,
ActionV2,
ActionV2Group,
ActionV2Separator as TActionV2Separator,
ActionV2Variant,
ActionV2WithChildren,
ExternalLinkActionV2,
InternalLinkActionV2,
MenuExternalLink,
MenuInternalLink,
MenuItem,
MenuItemButton,
MenuItemWithChildren,
} from "~/types";
import Analytics from "~/utils/Analytics";
import history from "~/utils/history";
import type { Action as KbarAction } from "kbar";
import { Action as KbarAction } from "kbar";
export function resolve<T>(value: unknown, context: ActionContext): T {
return (
typeof value === "function"
? (value as (context: ActionContext) => T)(context)
: value
) as T;
export function resolve<T>(value: any, context: ActionContext): T {
return typeof value === "function" ? value(context) : value;
}
export const ActionSeparator: TActionSeparator = {
export function createAction(definition: Optional<Action, "id">): Action {
return {
...definition,
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);
}
: undefined,
id: definition.id ?? crypto.randomUUID(),
};
}
export function actionToMenuItem(
action: Action,
context: ActionContext
): MenuItemButton | MenuExternalLink | MenuInternalLink | MenuItemWithChildren {
const resolvedIcon = resolve<React.ReactElement<any>>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const visible = action.visible ? action.visible(context) : true;
const title = resolve<string>(action.name, context);
const icon =
resolvedIcon && action.iconInContextMenu !== false
? resolvedIcon
: undefined;
if (resolvedChildren) {
const items = resolvedChildren
.map((a) => actionToMenuItem(a, context))
.filter(Boolean)
.filter((a) => a.visible);
return {
type: "submenu",
title,
icon,
items,
visible: visible && items.length > 0,
};
}
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,
icon,
visible,
dangerous: action.dangerous,
onClick: () => performAction(action, context),
selected: action.selected?.(context),
};
}
export function actionToKBar(
action: Action,
context: ActionContext
): KbarAction[] {
if (typeof action.visible === "function" && !action.visible(context)) {
return [];
}
const resolvedIcon = resolve<React.ReactElement>(action.icon, context);
const resolvedChildren = resolve<Action[]>(action.children, context);
const resolvedSection = resolve<string>(action.section, context);
const resolvedName = resolve<string>(action.name, context);
const resolvedPlaceholder = resolve<string>(action.placeholder, context);
const children = resolvedChildren
? flattenDeep(resolvedChildren.map((a) => actionToKBar(a, context))).filter(
(a) => !!a
)
: [];
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
? ((action.section.priority as number) ?? 0)
: 0;
return [
{
id: action.id,
name: resolvedName,
analyticsName: action.analyticsName,
section: resolvedSection,
placeholder: resolvedPlaceholder,
keywords: action.keywords ?? "",
shortcut: action.shortcut || [],
icon: resolvedIcon,
priority: (1 + (action.priority ?? 0)) * (1 + (sectionPriority ?? 0)),
perform:
action.perform || action.to
? () => performAction(action, context)
: undefined,
},
].concat(
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
children.map((child) => ({ ...child, parent: child.parent ?? action.id }))
);
}
export async function performAction(action: Action, context: ActionContext) {
const result = action.perform
? action.perform(context)
: action.to
? typeof action.to === "string"
? history.push(action.to)
: window.open(action.to.url, action.to.target)
: undefined;
if (result instanceof Promise) {
return result.catch((err: Error) => {
toast.error(err.message);
});
}
return result;
}
/** Actions V2 */
export const ActionV2Separator: TActionV2Separator = {
type: "action_separator",
};
export function createAction(
definition: Optional<Omit<Action, "type" | "variant">, "id">
): Action {
export function createActionV2(
definition: Optional<Omit<ActionV2, "type" | "variant">, "id">
): ActionV2 {
return {
...definition,
type: "action",
@@ -52,46 +201,46 @@ export function createAction(
return definition.perform(context);
}
: () => {},
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
export function createInternalLinkAction(
definition: Optional<Omit<InternalLinkAction, "type" | "variant">, "id">
): InternalLinkAction {
export function createInternalLinkActionV2(
definition: Optional<Omit<InternalLinkActionV2, "type" | "variant">, "id">
): InternalLinkActionV2 {
return {
...definition,
type: "action",
variant: "internal_link",
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
export function createExternalLinkAction(
definition: Optional<Omit<ExternalLinkAction, "type" | "variant">, "id">
): ExternalLinkAction {
export function createExternalLinkActionV2(
definition: Optional<Omit<ExternalLinkActionV2, "type" | "variant">, "id">
): ExternalLinkActionV2 {
return {
...definition,
type: "action",
variant: "external_link",
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
export function createActionWithChildren(
definition: Optional<Omit<ActionWithChildren, "type" | "variant">, "id">
): ActionWithChildren {
export function createActionV2WithChildren(
definition: Optional<Omit<ActionV2WithChildren, "type" | "variant">, "id">
): ActionV2WithChildren {
return {
...definition,
type: "action",
variant: "action_with_children",
id: definition.id ?? uuidv4(),
id: definition.id ?? crypto.randomUUID(),
};
}
export function createActionGroup(
definition: Omit<ActionGroup, "type">
): ActionGroup {
export function createActionV2Group(
definition: Omit<ActionV2Group, "type">
): ActionV2Group {
return {
...definition,
type: "action_group",
@@ -99,10 +248,10 @@ export function createActionGroup(
}
export function createRootMenuAction(
actions: (ActionVariant | ActionGroup | TActionSeparator)[]
): ActionWithChildren {
actions: (ActionV2Variant | ActionV2Group | TActionV2Separator)[]
): ActionV2WithChildren {
return {
id: uuidv4(),
id: crypto.randomUUID(),
type: "action",
variant: "action_with_children",
name: "root_action",
@@ -111,8 +260,8 @@ export function createRootMenuAction(
};
}
export function actionToMenuItem(
action: ActionVariant | ActionGroup | TActionSeparator,
export function actionV2ToMenuItem(
action: ActionV2Variant | ActionV2Group | TActionV2Separator,
context: ActionContext
): MenuItem {
switch (action.type) {
@@ -136,8 +285,7 @@ export function actionToMenuItem(
tooltip: resolve<React.ReactChild>(action.tooltip, context),
selected: resolve<boolean>(action.selected, context),
dangerous: action.dangerous,
shortcut: action.shortcut,
onClick: () => performAction(action, context),
onClick: () => performActionV2(action, context),
};
case "internal_link": {
@@ -148,7 +296,6 @@ export function actionToMenuItem(
icon,
visible,
disabled,
shortcut: action.shortcut,
to,
};
}
@@ -160,7 +307,6 @@ export function actionToMenuItem(
icon,
visible,
disabled,
shortcut: action.shortcut,
href: action.target
? { url: action.url, target: action.target }
: action.url,
@@ -168,10 +314,10 @@ export function actionToMenuItem(
case "action_with_children": {
const children = resolve<
(ActionVariant | ActionGroup | TActionSeparator)[]
(ActionV2Variant | ActionV2Group | TActionV2Separator)[]
>(action.children, context);
const subMenuItems = children.map((a) =>
actionToMenuItem(a, context)
actionV2ToMenuItem(a, context)
);
return {
type: "submenu",
@@ -190,7 +336,7 @@ export function actionToMenuItem(
case "action_group": {
const groupItems = action.actions.map((a) =>
actionToMenuItem(a, context)
actionV2ToMenuItem(a, context)
);
return {
type: "group",
@@ -205,8 +351,8 @@ export function actionToMenuItem(
}
}
export function actionToKBar(
action: ActionVariant,
export function actionV2ToKBar(
action: ActionV2Variant,
context: ActionContext
): KbarAction[] {
const visible = resolve<boolean>(action.visible, context);
@@ -217,7 +363,6 @@ export function actionToKBar(
const name = resolve<string>(action.name, context);
const icon = resolve<React.ReactElement>(action.icon, context);
const section = resolve<string>(action.section, context);
const subtitle = resolve<string>(action.description, context);
const sectionPriority =
typeof action.section !== "string" && "priority" in action.section
@@ -237,21 +382,20 @@ export function actionToKBar(
section,
keywords: action.keywords,
shortcut: action.shortcut,
subtitle,
icon,
priority,
perform: () => performAction(action, context),
perform: () => performActionV2(action, context),
},
];
}
case "action_with_children": {
const resolvedChildren = resolve<ActionVariant[]>(
const resolvedChildren = resolve<ActionV2Variant[]>(
action.children,
context
);
const children = resolvedChildren
.map((a) => actionToKBar(a, context))
.map((a) => actionV2ToKBar(a, context))
.flat()
.filter(Boolean);
@@ -263,7 +407,6 @@ export function actionToKBar(
keywords: action.keywords,
shortcut: action.shortcut,
icon,
subtitle,
priority,
},
...children.map((child) => ({
@@ -278,8 +421,8 @@ export function actionToKBar(
}
}
export async function performAction(
action: Exclude<ActionVariant, ActionWithChildren>,
export async function performActionV2(
action: Exclude<ActionV2Variant, ActionV2WithChildren>,
context: ActionContext
) {
const perform =
+2 -20
View File
@@ -1,4 +1,4 @@
import type { ActionContext } from "~/types";
import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
@@ -13,15 +13,8 @@ ActiveCollectionSection.priority = 0.8;
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DateSection = ({ t }: ActionContext) => t("Date");
DateSection.priority = 1;
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SearchResultsSection = ({ t }: ActionContext) =>
t("Search results");
export const DocumentsSection = ({ t }: ActionContext) => t("Documents");
export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
@@ -31,15 +24,6 @@ export const ActiveDocumentSection = ({ t, stores }: ActionContext) => {
ActiveDocumentSection.priority = 0.9;
export const TemplateSection = ({ t }: ActionContext) => t("Template");
export const ActiveTemplateSection = ({ t, stores }: ActionContext) => {
const activeTemplate = stores.templates.active;
return `${t("Template")} · ${activeTemplate?.titleWithDefault}`;
};
ActiveTemplateSection.priority = 0.9;
export const RecentSection = ({ t }: ActionContext) => t("Recently viewed");
RecentSection.priority = 1;
@@ -54,8 +38,6 @@ export const NotificationSection = ({ t }: ActionContext) => t("Notification");
export const GroupSection = ({ t }: ActionContext) => t("Groups");
export const EmojiSecion = ({ t }: ActionContext) => t("Emoji");
export const UserSection = ({ t }: ActionContext) => t("People");
UserSection.priority = 0.5;
@@ -65,7 +47,7 @@ export const ShareSection = ({ t }: ActionContext) => t("Share");
export const TeamSection = ({ t }: ActionContext) => t("Workspace");
export const RecentSearchesSection = ({ t }: ActionContext) =>
t("Recently viewed");
t("Recent searches");
RecentSearchesSection.priority = -0.1;
+16 -15
View File
@@ -1,12 +1,10 @@
/* oxlint-disable react/prop-types */
import { observer } from "mobx-react";
import * as React from "react";
import type { Props as TooltipProps } from "~/components/Tooltip";
import Tooltip from "~/components/Tooltip";
import { performAction, resolve } from "~/actions";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { performAction, performActionV2, resolve } from "~/actions";
import useIsMounted from "~/hooks/useIsMounted";
import { Action, ActionV2Variant, ActionV2WithChildren } from "~/types";
import useActionContext from "~/hooks/useActionContext";
import type { ActionVariant, ActionWithChildren } from "~/types";
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
@@ -14,7 +12,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Hide the button entirely if action is not applicable */
hideOnActionDisabled?: boolean;
/** Action to use on button */
action?: Exclude<ActionVariant, ActionWithChildren>;
action?: Action | Exclude<ActionV2Variant, ActionV2WithChildren>;
/** If tooltip props are provided the button will be wrapped in a tooltip */
tooltip?: Omit<TooltipProps, "children">;
};
@@ -23,7 +21,7 @@ export type Props = React.HTMLAttributes<HTMLButtonElement> & {
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function ActionButton_(
function _ActionButton(
{ action, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) {
@@ -32,20 +30,20 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
});
const isMounted = useIsMounted();
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
if (!actionContext || !action) {
return <button {...rest} ref={ref} />;
}
const actionIsDisabled =
action.visible && !resolve<boolean>(action.visible, actionContext);
if (actionIsDisabled && hideOnActionDisabled) {
if (
action.visible &&
!resolve<boolean>(action.visible, actionContext) &&
hideOnActionDisabled
) {
return null;
}
const disabled = rest.disabled || actionIsDisabled;
const label =
rest["aria-label"] ??
(typeof action.name === "function"
@@ -63,7 +61,10 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
? (ev) => {
ev.preventDefault();
ev.stopPropagation();
const response = performAction(action, actionContext);
const response =
"variant" in action
? performActionV2(action, actionContext)
: performAction(action, actionContext);
if (response?.finally) {
setExecuting(true);
void response.finally(
@@ -86,4 +87,4 @@ const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
}
);
export default observer(ActionButton);
export default ActionButton;
+2 -3
View File
@@ -1,9 +1,8 @@
/* oxlint-disable prefer-rest-params */
/* global ga */
import { escape } from "es-toolkit/compat";
import escape from "lodash/escape";
import * as React from "react";
import type { PublicEnv } from "@shared/types";
import { IntegrationService } from "@shared/types";
import { IntegrationService, PublicEnv } from "@shared/types";
import env from "~/env";
type Props = {
+14
View File
@@ -0,0 +1,14 @@
export default function Arrow() {
return (
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z" />
<path d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z" />
</svg>
);
}
+8 -25
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -16,7 +16,6 @@ const Authenticated = ({ children }: Props) => {
const { i18n } = useTranslation();
const user = useCurrentUser({ rejectOnEmpty: false });
const language = user?.language;
const hasLoggedOut = useRef(false);
// Watching for language changes here as this is the earliest point we might have the user
// available and means we can start loading translations faster
@@ -24,36 +23,20 @@ const Authenticated = ({ children }: Props) => {
void changeLanguage(language, i18n);
}, [i18n, language]);
const shouldLogout = !auth.authenticated && !auth.isFetching;
// Passive logout when we land here without an authenticated session note we
// intentionally do not revoke the server-side token, as that would clobber
// the session in any other tab that may have already re-authenticated.
useEffect(() => {
if (shouldLogout && !hasLoggedOut.current) {
hasLoggedOut.current = true;
void auth.logout({
savePath: true,
clearCache: false,
revokeToken: false,
});
}
}, [shouldLogout, auth]);
useEffect(() => {
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
}
}, [auth.logoutRedirectUri]);
if (auth.authenticated) {
return children;
}
if (auth.isFetching || auth.logoutRedirectUri) {
if (auth.isFetching) {
return <LoadingIndicator />;
}
void auth.logout({ savePath: true });
if (auth.logoutRedirectUri) {
window.location.href = auth.logoutRedirectUri;
return null;
}
return <Redirect to="/" />;
};
+67 -51
View File
@@ -1,34 +1,42 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { DndProvider } from "react-dnd";
import { useLocation } from "react-router-dom";
import { EditorAwareHTML5Backend } from "~/components/EditorAwareHTML5Backend";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
import RegisterKeyDown from "~/components/RegisterKeyDown";
import { RightSidebarProvider } from "~/components/RightSidebarContext";
import Sidebar from "~/components/Sidebar";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useKeyDown from "~/hooks/useKeyDown";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import Logger from "~/utils/Logger";
import history from "~/utils/history";
import { isModKey } from "@shared/utils/keyboard";
import lazyWithRetry from "~/utils/lazyWithRetry";
import {
searchPath,
newDocumentPath,
settingsPath,
homePath,
matchDocumentHistory,
matchDocumentSlug as slug,
} from "~/utils/routeHelpers";
import { DocumentContextProvider } from "./DocumentContext";
import Fade from "./Fade";
import NotificationBadge from "./NotificationBadge";
import { PortalContext } from "./Portal";
import CommandBar from "./CommandBar";
const DocumentComments = lazyWithRetry(
() => import("~/scenes/Document/components/Comments")
);
const DocumentHistory = lazyWithRetry(
() => import("~/scenes/Document/components/History")
);
const SettingsSidebar = lazyWithRetry(
() => import("~/components/Sidebar/Settings")
);
@@ -41,16 +49,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
const layoutRef = React.useRef<HTMLDivElement>(null);
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
useKeyDown(".", (event) => {
if (isModKey(event)) {
ui.toggleCollapsedSidebar();
}
});
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
ev.preventDefault();
@@ -70,54 +73,67 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
history.push(newDocumentPath(activeCollectionId));
};
React.useEffect(() => {
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
try {
history.replace(postLoginPath);
} catch (err) {
Logger.warn("Failed to navigate to post login path, falling back", {
path: postLoginPath,
error: err,
});
history.replace(homePath());
}
}
}, [spendPostLoginPath]);
if (auth.isSuspended) {
return <ErrorSuspended />;
}
const isSettings = location.pathname.startsWith(settingsPath());
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<React.Suspense fallback={null}>
{isSettings && <SettingsSidebar />}
</React.Suspense>
<div style={isSettings ? { display: "none" } : undefined}>
<Sidebar />
</div>
<Switch>
<Route path={settingsPath()} component={SettingsSidebar} />
<Route component={Sidebar} />
</Switch>
</Fade>
);
const showHistory =
!!matchPath(location.pathname, {
path: matchDocumentHistory,
}) && can.listRevisions;
const showComments =
!showHistory &&
can.comment &&
ui.activeDocumentId &&
ui.commentsExpanded &&
!!team.getPreference(TeamPreference.Commenting);
const sidebarRight = (
<AnimatePresence
initial={false}
key={ui.activeDocumentId ? "active" : "inactive"}
>
{(showHistory || showComments) && (
<Route path={`/doc/${slug}`}>
<React.Suspense fallback={null}>
{showHistory && <DocumentHistory />}
{showComments && <DocumentComments />}
</React.Suspense>
</Route>
)}
</AnimatePresence>
);
return (
<DocumentContextProvider>
<RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<DndProvider backend={EditorAwareHTML5Backend}>
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
<NotificationBadge />
</Layout>
</DndProvider>
</PortalContext.Provider>
</RightSidebarProvider>
<PortalContext.Provider value={layoutRef.current}>
<Layout
title={team.name}
sidebar={sidebar}
sidebarRight={sidebarRight}
ref={layoutRef}
>
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
<RegisterKeyDown trigger="t" handler={goToSearch} />
<RegisterKeyDown trigger="/" handler={goToSearch} />
{children}
<CommandBar />
</Layout>
</PortalContext.Provider>
</DocumentContextProvider>
);
};
+3 -17
View File
@@ -1,9 +1,7 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import useBoolean from "~/hooks/useBoolean";
import Initials from "./Initials";
import Tooltip from "../Tooltip";
export enum AvatarSize {
Small = 16,
@@ -24,7 +22,6 @@ export interface IAvatar {
avatarUrl: string | null;
color?: string;
initial?: string;
name?: string;
id?: string;
}
@@ -45,8 +42,6 @@ type Props = {
className?: string;
/** Optional style */
style?: React.CSSProperties;
/** Whether to show a tooltip */
showTooltip?: boolean;
};
function Avatar(props: Props) {
@@ -55,15 +50,12 @@ function Avatar(props: Props) {
style,
variant = AvatarVariant.Round,
className,
showTooltip,
...rest
} = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
const initial =
model?.initial || (model?.name ? model.name[0] : "").toUpperCase();
const content = (
return (
<Relative
style={style}
$variant={variant}
@@ -74,19 +66,13 @@ function Avatar(props: Props) {
<Image onError={handleError} src={src} {...rest} />
) : model ? (
<Initials color={model.color} {...rest}>
{initial}
{model.initial}
</Initials>
) : (
<Initials {...rest} />
)}
</Relative>
);
return showTooltip ? (
<Tooltip content={props.alt || model?.name || ""}>{content}</Tooltip>
) : (
content
);
}
Avatar.defaultProps = {
@@ -110,4 +96,4 @@ const Image = styled.img<{ size: number }>`
height: ${(props) => props.size}px;
`;
export default observer(Avatar);
export default Avatar;
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import type User from "~/models/User";
import User from "~/models/User";
import Tooltip from "~/components/Tooltip";
import Avatar, { AvatarSize } from "./Avatar";
+1 -2
View File
@@ -1,7 +1,7 @@
import { GroupIcon } from "outline-icons";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import type Group from "~/models/Group";
import Group from "~/models/Group";
import { AvatarSize } from "../Avatar/Avatar";
type Props = {
@@ -26,7 +26,6 @@ export function GroupAvatar({
return (
<Squircle color={color ?? theme.text} size={size} className={className}>
<GroupIcon
data-fixed-color
color={backgroundColor ?? theme.background}
size={size * 0.75}
/>
+1 -2
View File
@@ -1,5 +1,4 @@
import type { IAvatar } from "./Avatar";
import Avatar, { AvatarSize, AvatarVariant } from "./Avatar";
import Avatar, { IAvatar, AvatarSize, AvatarVariant } from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
+3 -4
View File
@@ -2,8 +2,8 @@ import { transparentize } from "polished";
import styled from "styled-components";
const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
padding: 1.5px 5.5px;
margin: 0 2px;
margin-left: 10px;
padding: 1px 5px 2px;
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
color: ${({ primary, yellow, theme }) =>
@@ -17,11 +17,10 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
primary || yellow
? "transparent"
: transparentize(0.4, theme.textTertiary)};
border-radius: 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
user-select: none;
white-space: nowrap;
`;
export default Badge;
+18 -23
View File
@@ -1,5 +1,4 @@
import { GoToIcon } from "outline-icons";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -7,17 +6,17 @@ import { s, ellipsis } from "@shared/styles";
import Flex from "~/components/Flex";
import BreadcrumbMenu from "~/menus/BreadcrumbMenu";
import { undraggableOnDesktop } from "~/styles";
import type { InternalLinkAction, MenuInternalLink } from "~/types";
import { actionToMenuItem } from "~/actions";
import { InternalLinkActionV2, MenuInternalLink } from "~/types";
import { actionV2ToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { useComputed } from "~/hooks/useComputed";
type TopLevelAction =
| InternalLinkAction
| { type: "menu"; actions: InternalLinkAction[] };
| InternalLinkActionV2
| { type: "menu"; actions: InternalLinkActionV2[] };
type Props = React.PropsWithChildren<{
actions: InternalLinkAction[];
actions: InternalLinkActionV2[];
max?: number;
highlightFirstItem?: boolean;
}>;
@@ -47,7 +46,7 @@ function Breadcrumb(
const menuActions = topLevelActions.splice(
halfMax,
totalVisibleActions - max
) as InternalLinkAction[];
) as InternalLinkActionV2[];
topLevelActions.splice(halfMax, 0, {
type: "menu",
@@ -55,29 +54,22 @@ function Breadcrumb(
});
}
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.currentTarget.querySelector('[data-state="open"]')) {
event.preventDefault();
}
},
[]
);
const toBreadcrumb = React.useCallback(
(action: TopLevelAction, index: number) => {
if (action.type === "menu") {
return <BreadcrumbMenu key="menu" actions={action.actions} />;
}
const item = actionToMenuItem(action, actionContext) as MenuInternalLink;
const item = actionV2ToMenuItem(
action,
actionContext
) as MenuInternalLink;
return (
<>
{item.icon}
<Item
to={item.to}
onClick={handleClick}
$withIcon={!!item.icon}
$highlight={!!highlightFirstItem && index === 0}
>
@@ -86,7 +78,7 @@ function Breadcrumb(
</>
);
},
[actionContext, handleClick, highlightFirstItem]
[actionContext, highlightFirstItem]
);
return (
@@ -113,20 +105,23 @@ const Item = styled(Link)<{ $highlight: boolean; $withIcon: boolean }>`
${ellipsis()}
${undraggableOnDesktop()}
display: flex;
flex-shrink: 1;
min-width: 0;
cursor: var(--pointer);
color: ${s("text")};
font-size: 15px;
height: 24px;
line-height: 24px;
font-weight: ${(props) => (props.$highlight ? "500" : "inherit")};
margin-inline-start: ${(props) => (props.$withIcon ? "4px" : "0")};
max-width: 460px;
margin-left: ${(props) => (props.$withIcon ? "4px" : "0")};
svg {
flex-shrink: 0;
}
&:hover {
text-decoration: underline;
}
`;
export default observer(React.forwardRef<HTMLDivElement, Props>(Breadcrumb));
export default React.forwardRef<HTMLDivElement, Props>(Breadcrumb);
+7 -17
View File
@@ -1,13 +1,12 @@
import type { LocationDescriptor } from "history";
import { LocationDescriptor } from "history";
import { DisclosureIcon } from "outline-icons";
import { darken, lighten, transparentize } from "polished";
import * as React from "react";
import styled from "styled-components";
import type { HapticInput } from "web-haptics";
import { useWebHaptics } from "web-haptics/react";
import { s } from "@shared/styles";
import type { Props as ActionButtonProps } from "~/components/ActionButton";
import ActionButton from "~/components/ActionButton";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
import { undraggableOnDesktop } from "~/styles";
type RealProps = {
@@ -35,7 +34,6 @@ const RealButton = styled(ActionButton)<RealProps>`
cursor: var(--pointer);
user-select: none;
appearance: none !important;
transition: background 200ms ease-out;
${undraggableOnDesktop()}
&::-moz-focus-inner {
@@ -46,7 +44,6 @@ const RealButton = styled(ActionButton)<RealProps>`
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${(props) => darken(0.05, props.theme.accent)};
transition: background 0s;
}
&:disabled {
@@ -81,7 +78,6 @@ const RealButton = styled(ActionButton)<RealProps>`
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${
props.theme.buttonNeutralBorder
} 0 0 0 1px inset;
transition: background 0s;
}
&:focus-visible {
@@ -107,7 +103,6 @@ const RealButton = styled(ActionButton)<RealProps>`
&:hover:not(:disabled),
&[aria-expanded="true"] {
background: ${darken(0.05, props.theme.danger)};
transition: background 0s;
}
&:disabled {
@@ -125,7 +120,7 @@ const Label = styled.span<{ hasIcon?: boolean }>`
white-space: nowrap;
text-overflow: ellipsis;
${(props) => props.hasIcon && "padding-inline-start: 4px;"};
${(props) => props.hasIcon && "padding-left: 4px;"};
`;
export const Inner = styled.span<{
@@ -135,13 +130,13 @@ export const Inner = styled.span<{
}>`
display: flex;
padding: 0 8px;
padding-inline-end: ${(props) => (props.disclosure ? 2 : 8)}px;
padding-right: ${(props) => (props.disclosure ? 2 : 8)}px;
line-height: ${(props) => (props.hasIcon ? 24 : 32)}px;
justify-content: center;
align-items: center;
min-height: 32px;
${(props) => props.hasIcon && props.hasText && "padding-inline-start: 4px;"};
${(props) => props.hasIcon && props.hasText && "padding-left: 4px;"};
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
`;
@@ -154,8 +149,6 @@ export type Props<T> = ActionButtonProps & {
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
/** Haptic feedback to trigger on click. Pass a preset name or custom pattern. */
haptic?: HapticInput;
borderOnHover?: boolean;
hideIcon?: boolean;
href?: string;
@@ -180,13 +173,11 @@ const Button = <T extends React.ElementType = "button">(
hideIcon,
fullwidth,
danger,
haptic,
...rest
} = props;
const hasText = !!children || value !== undefined;
const ic = hideIcon ? undefined : (action?.icon ?? icon);
const hasIcon = ic !== undefined;
const { trigger } = useWebHaptics();
return (
<RealButton
@@ -197,7 +188,6 @@ const Button = <T extends React.ElementType = "button">(
$danger={danger}
$fullwidth={fullwidth}
$borderOnHover={borderOnHover}
onClickCapture={haptic ? () => void trigger(haptic) : undefined}
{...rest}
>
<Inner hasIcon={hasIcon} hasText={hasText} disclosure={disclosure}>
+5 -3
View File
@@ -1,7 +1,6 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
type Props = {
children?: React.ReactNode;
@@ -23,9 +22,12 @@ const Container = styled.div<Props>`
type ContentProps = { $maxWidth?: string };
const Content = styled.div<ContentProps>`
max-width: ${(props: ContentProps) =>
props.$maxWidth ?? EditorStyleHelper.documentWidth};
max-width: ${(props) => props.$maxWidth ?? "46em"};
margin: 0 auto;
${breakpoint("desktopLarge")`
max-width: ${(props: ContentProps) => props.$maxWidth ?? "52em"};
`};
`;
const CenteredContent: React.FC<Props> = ({
+3 -1
View File
@@ -22,7 +22,9 @@ const Circle = ({
if (percentage) {
// because the circle is so small, anything greater than 85% appears like 100%
percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
strokePercentage = ((100 - percentage) * circumference) / 100;
strokePercentage = percentage
? ((100 - percentage) * circumference) / 100
: 0;
}
return (
+9 -13
View File
@@ -1,8 +1,11 @@
import { filter, isEqual, orderBy, uniq } from "es-toolkit/compat";
import filter from "lodash/filter";
import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import type Document from "~/models/Document";
import Document from "~/models/Document";
import { AvatarSize, AvatarWithPresence } from "~/components/Avatar";
import DocumentViews from "~/components/DocumentViews";
import Facepile from "~/components/Facepile";
@@ -79,10 +82,10 @@ function Collaborators(props: Props) {
// Memoize ids to avoid unnecessary effect executions
const missingUserIds = useMemo(
() =>
uniq([...collaboratorIdsSet, ...presentIds])
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
.filter((userId) => !users.get(userId))
.sort(),
[collaboratorIdsSet, presentIds, users]
[document.collaboratorIds, presentIds, users]
);
useEffect(() => {
@@ -122,8 +125,8 @@ function Collaborators(props: Props) {
return (
<AvatarWithPresence
key={collaborator.id}
{...rest}
key={collaborator.id}
user={collaborator}
isPresent={isPresent}
isEditing={isEditing}
@@ -143,14 +146,7 @@ function Collaborators(props: Props) {
/>
);
},
[
presentIds,
editingIds,
observingUserId,
currentUserId,
handleAvatarClick,
t,
]
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
);
if (!document.insightsEnabled) {
-112
View File
@@ -1,112 +0,0 @@
import * as RadixCollapsible from "@radix-ui/react-collapsible";
import { ExpandedIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
interface CollapsibleProps {
/** The label displayed on the trigger button. */
label: React.ReactNode;
/** The content to show/hide inside the collapsible panel. */
children: React.ReactNode;
/** Whether the collapsible is open by default. */
defaultOpen?: boolean;
/** Controlled open state. */
open?: boolean;
/** Callback fired when the open state changes. */
onOpenChange?: (open: boolean) => void;
/** Additional class name for the root element. */
className?: string;
}
/**
* An accessible collapsible section built on Radix UI Collapsible.
* Renders a trigger button with a disclosure chevron and animated content panel.
*
* @param props - component props.
* @returns the collapsible component.
*/
export function Collapsible({
label,
children,
defaultOpen = false,
open,
onOpenChange,
className,
}: CollapsibleProps) {
return (
<RadixCollapsible.Root
defaultOpen={defaultOpen}
open={open}
onOpenChange={onOpenChange}
className={className}
>
<StyledTrigger>
<StyledExpandedIcon aria-hidden="true" />
{label}
</StyledTrigger>
<StyledContent>{children}</StyledContent>
</RadixCollapsible.Root>
);
}
const StyledExpandedIcon = styled(ExpandedIcon)`
flex-shrink: 0;
transition: transform 150ms ease-out;
margin-left: -4px;
`;
const StyledTrigger = styled(RadixCollapsible.Trigger)`
display: flex;
align-items: center;
background: none;
border: none;
padding: 0 0 8px 0;
cursor: var(--pointer);
color: ${s("textTertiary")};
font-size: 14px;
&:hover {
color: ${s("textSecondary")};
}
&[data-state="closed"] {
${StyledExpandedIcon} {
transform: rotate(-90deg);
}
}
`;
const StyledContent = styled(RadixCollapsible.Content)`
overflow: hidden;
&[data-state="open"] {
animation: slideDown 200ms ease-out;
}
&[data-state="closed"] {
animation: slideUp 200ms ease-out;
}
@keyframes slideDown {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes slideUp {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
`;
+1 -2
View File
@@ -2,8 +2,7 @@ import { observer } from "mobx-react";
import { useCallback } from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import type { FormData } from "./CollectionForm";
import { CollectionForm } from "./CollectionForm";
import { CollectionForm, FormData } from "./CollectionForm";
type Props = {
collectionId: string;
+59 -131
View File
@@ -1,4 +1,4 @@
import { uniq } from "es-toolkit/compat";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import { useMemo, useEffect, useCallback, Suspense } from "react";
import { Controller, useForm } from "react-hook-form";
@@ -7,15 +7,13 @@ import styled from "styled-components";
import Icon from "@shared/components/Icon";
import { randomElement } from "@shared/random";
import { CollectionPermission, TeamPreference } from "@shared/types";
import type { Option } from "~/components/InputSelect";
import { IconLibrary } from "@shared/utils/IconLibrary";
import { colorPalette } from "@shared/utils/collections";
import { CollectionValidation } from "@shared/validations";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import Button from "~/components/Button";
import { Collapsible } from "~/components/Collapsible";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import { InputSelect } from "~/components/InputSelect";
import { InputSelectPermission } from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
@@ -24,20 +22,17 @@ import useBoolean from "~/hooks/useBoolean";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
import { HStack } from "../primitives/HStack";
import { useDialogContext } from "~/components/DialogContext";
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
export type FormData = {
export interface FormData {
name: string;
icon: string;
color: string | null;
sharing: boolean;
permission: CollectionPermission | undefined;
commenting?: boolean | null;
templateManagement: CollectionPermission;
};
}
const useIconColor = (collection?: Collection) => {
const { collections } = useStores();
@@ -68,34 +63,11 @@ export const CollectionForm = observer(function CollectionForm_({
}) {
const team = useCurrentTeam();
const { t } = useTranslation();
const dialog = useDialogContext();
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
const templateManagementOptions = useMemo<Option[]>(
() => [
{
type: "item",
label: t("Managers"),
value: CollectionPermission.Admin,
},
{
type: "item",
label: t("Members"),
value: CollectionPermission.ReadWrite,
},
],
[t]
);
const iconColor = useIconColor(collection);
const fallbackIcon = (
<Icon
value="collection"
initial={collection?.initial ?? "?"}
color={iconColor}
/>
);
const fallbackIcon = <Icon value="collection" color={iconColor} />;
const {
register,
@@ -113,8 +85,6 @@ export const CollectionForm = observer(function CollectionForm_({
sharing: collection?.sharing ?? true,
permission: collection?.permission,
commenting: collection?.commenting ?? true,
templateManagement:
collection?.templateManagement ?? CollectionPermission.Admin,
color: iconColor,
},
});
@@ -157,34 +127,60 @@ export const CollectionForm = observer(function CollectionForm_({
const initial = values.name.charAt(0).toUpperCase();
const options = (
<>
<Controller
control={control}
name="templateManagement"
render={({ field }) => (
<>
<InputSelect
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
<Trans>
Collections are used to group documents and choose permissions
</Trans>
</Text>
<Flex gap={8}>
<Input
type="text"
placeholder={t("Name")}
{...register("name", {
required: true,
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={initial}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</Suspense>
}
autoComplete="off"
autoFocus
flex
/>
</Flex>
{/* Following controls are available in create flow, but moved elsewhere for edit */}
{!collection && (
<Controller
control={control}
name="permission"
render={({ field }) => (
<InputSelectPermission
ref={field.ref}
value={field.value}
onChange={(value: string) => {
field.onChange(value as CollectionPermission);
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
field.onChange(value === EmptySelectValue ? null : value);
}}
options={templateManagementOptions}
label={t("Manage templates")}
/>
<Text
type="secondary"
size="small"
as="p"
style={{ paddingTop: 4 }}
>
{t(
"Choose who can create and edit templates in this collection."
help={t(
"The default access for workspace members, you can share with more users or groups later."
)}
</Text>
</>
)}
/>
/>
)}
/>
)}
{team.sharing && (
<Controller
@@ -219,76 +215,8 @@ export const CollectionForm = observer(function CollectionForm_({
)}
/>
)}
</>
);
return (
<form onSubmit={formHandleSubmit(handleSubmit)}>
<Text as="p">
<Trans>
Collections are used to group documents and choose permissions
</Trans>
</Text>
<HStack>
<Input
type="text"
label={t("Name")}
{...register("name", {
required: true,
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
initial={initial}
popoverPosition="right"
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</Suspense>
}
autoComplete="off"
autoFocus
flex
/>
</HStack>
{/* Following controls are available in create flow, but moved elsewhere for edit */}
{!collection && (
<Controller
control={control}
name="permission"
render={({ field }) => (
<InputSelectPermission
ref={field.ref}
value={field.value}
onChange={(
value: CollectionPermission | typeof EmptySelectValue
) => {
field.onChange(value === EmptySelectValue ? null : value);
}}
help={t(
"The default access for workspace members, you can share with more users or groups later."
)}
/>
)}
/>
)}
{collection ? (
options
) : (
<Collapsible
label={t("Advanced options")}
onOpenChange={() => dialog.setAnimating(true)}
>
{options}
</Collapsible>
)}
<HStack justify="flex-end">
<Flex justify="flex-end">
<Button
type="submit"
disabled={formState.isSubmitting || !formState.isValid}
@@ -301,7 +229,7 @@ export const CollectionForm = observer(function CollectionForm_({
? `${t("Creating")}`
: t("Create")}
</Button>
</HStack>
</Flex>
</form>
);
});
+1 -2
View File
@@ -4,8 +4,7 @@ import { useCallback } from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import type { FormData } from "./CollectionForm";
import { CollectionForm } from "./CollectionForm";
import { CollectionForm, FormData } from "./CollectionForm";
type Props = {
onSubmit: () => void;
+5 -5
View File
@@ -1,11 +1,11 @@
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { archivePath, collectionPath } from "~/utils/routeHelpers";
import Breadcrumb from "./Breadcrumb";
import { createInternalLinkAction } from "~/actions";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveCollectionSection } from "~/actions/sections";
type Props = {
@@ -17,18 +17,18 @@ export const CollectionBreadcrumb: React.FC<Props> = ({ collection }) => {
const actions = React.useMemo(
() => [
createInternalLinkAction({
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveCollectionSection,
icon: <ArchiveIcon />,
visible: collection.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
createInternalLinkActionV2({
name: collection.name,
section: ActiveCollectionSection,
icon: <CollectionIcon collection={collection} expanded />,
to: collectionPath(collection),
to: collectionPath(collection.path),
}),
],
[collection, t]
+1 -1
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useCurrentTeam from "~/hooks/useCurrentTeam";
-63
View File
@@ -1,63 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import NudeButton from "./NudeButton";
import { hover, s } from "@shared/styles";
type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** The current color value in hex format. If no color is passed a radial gradient will be shown */
color?: string;
/** Whether the button is currently active/selected */
active?: boolean;
/** The size of the button in pixels */
size?: number;
};
export const ColorButton = React.forwardRef(
(
{ color, active = false, size = 24, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) => (
<ColorButtonInternal
$active={active}
$size={size}
{...rest}
style={{ "--color": color, ...rest.style } as React.CSSProperties}
ref={ref}
>
<Selected />
</ColorButtonInternal>
)
);
const Selected = styled.span`
width: 10px;
height: 5px;
border-left: 2px solid white;
border-bottom: 2px solid white;
transform: translateY(-25%) rotate(-45deg);
`;
const ColorButtonInternal = styled(NudeButton)<{
$active: boolean;
$size: number;
}>`
display: inline-flex;
justify-content: center;
align-items: center;
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
border-radius: 50%;
background: var(
--color,
linear-gradient(135deg, #ff5858 0%, #fbcc34 50%, #00c6ff 100%)
);
&: ${hover} {
outline: 2px solid ${s("menuBackground")} !important;
box-shadow: 0px 0px 3px 3px var(--color);
}
& ${Selected} {
display: ${({ $active }) => ($active ? "block" : "none")};
}
`;
+9 -32
View File
@@ -1,14 +1,12 @@
import type { ActionImpl } from "kbar";
import { ActionImpl } from "kbar";
import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay, shortcutSeparator } from "@shared/utils/keyboard";
import Highlight from "~/components/Highlight";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
import { HStack } from "../primitives/HStack";
type Props = {
action: ActionImpl;
@@ -16,22 +14,14 @@ type Props = {
currentRootActionId: string | null | undefined;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
function replaceResultMarks(tag: string) {
// don't use SEARCH_RESULT_REGEX here as it causes
// an infinite loop to trigger a regex inside it's own callback
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function CommandBarItem(
{ action, active, currentRootActionId }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const theme = useTheme();
const ancestors = React.useMemo(() => {
if (!currentRootActionId || !action.ancestors) {
return action.ancestors ?? [];
if (!currentRootActionId) {
return action.ancestors;
}
const index = action.ancestors.findIndex(
(ancestor) => ancestor.id === currentRootActionId
@@ -45,7 +35,7 @@ function CommandBarItem(
return (
<Item active={active} ref={ref}>
<Content>
<Content align="center" gap={8}>
<Icon>
{action.icon ? (
// @ts-expect-error no icon on ActionImpl
@@ -65,16 +55,6 @@ function CommandBarItem(
))}
{action.name}
{action.children?.length ? "…" : ""}
{action.subtitle && (
<Text type="secondary" ellipsis>
&nbsp;&nbsp;
<Highlight
text={action.subtitle}
highlight={SEARCH_RESULT_REGEX}
processResult={replaceResultMarks}
/>
</Text>
)}
</Content>
{action.shortcut?.length ? (
<Shortcut>
@@ -90,12 +70,9 @@ function CommandBarItem(
) : (
""
)}
{sc.split("+").flatMap((key, i, arr) => {
const el = <Key key={key}>{normalizeKeyDisplay(key)}</Key>;
return i < arr.length - 1 && shortcutSeparator
? [el, shortcutSeparator]
: [el];
})}
{sc.split("+").map((key) => (
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
))}
</React.Fragment>
))}
</Shortcut>
@@ -123,7 +100,7 @@ const Ancestor = styled.span`
color: ${s("textSecondary")};
`;
const Content = styled(HStack)`
const Content = styled(Flex)`
${ellipsis()}
flex-shrink: 1;
`;
@@ -43,8 +43,7 @@ const Container = styled.div`
const Header = styled(Text).attrs({ as: "h3" })`
letter-spacing: 0.03em;
margin: 0;
padding-block: 16px 4px;
padding-inline: 20px 0;
padding: 16px 0 4px 20px;
height: 36px;
cursor: default;
`;
@@ -1,94 +0,0 @@
import { useKBar, KBarPositioner, KBarAnimator, KBarSearch } from "kbar";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Portal } from "react-portal";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
import CommandBarResults from "./CommandBarResults";
import SharedSearchActions from "./SharedSearchActions";
/**
* A simplified command bar for public shares that only provides search.
*/
function SharedCommandBar() {
const { t } = useTranslation();
return (
<>
<SharedSearchActions />
<KBarPortal>
<Positioner>
<Animator>
<SearchInput defaultPlaceholder={`${t("Search")}`} />
<CommandBarResults />
</Animator>
</Positioner>
</KBarPortal>
</>
);
}
type Props = {
children?: React.ReactNode;
};
const KBarPortal: React.FC = ({ children }: Props) => {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
if (!showing) {
return null;
}
return <Portal>{children}</Portal>;
};
const Positioner = styled(KBarPositioner)`
z-index: ${depths.commandBar};
`;
const SearchInput = styled(KBarSearch)`
position: relative;
padding: 16px 12px;
margin: 0 8px;
width: calc(100% - 16px);
outline: none;
border: none;
background: ${s("menuBackground")};
color: ${s("text")};
&:not(:last-child) {
border-bottom: 1px solid ${s("inputBorder")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
opacity: 1;
}
`;
const Animator = styled(KBarAnimator)`
max-width: 600px;
max-height: 75vh;
width: 90vw;
background: ${s("menuBackground")};
color: ${s("text")};
border-radius: 8px;
overflow: hidden;
box-shadow: rgb(0 0 0 / 40%) 0px 16px 60px;
transition: max-width 0.2s ease-in-out;
${breakpoint("desktopLarge")`
max-width: 740px;
`};
@media print {
display: none;
}
`;
export default observer(SharedCommandBar);
@@ -1,187 +0,0 @@
import { useKBar } from "kbar";
import { escapeRegExp } from "es-toolkit/compat";
import { observer } from "mobx-react";
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import Icon from "@shared/components/Icon";
import useShare from "@shared/hooks/useShare";
import { Minute } from "@shared/utils/time";
import { createAction } from "~/actions";
import {
RecentSearchesSection,
SearchResultsSection,
} from "~/actions/sections";
import useCommandBarActions from "~/hooks/useCommandBarActions";
import useStores from "~/hooks/useStores";
import type Document from "~/models/Document";
import history from "~/utils/history";
import { sharedModelPath } from "~/utils/routeHelpers";
import type { SearchResult } from "~/types";
interface CacheEntry {
timestamp: number;
results: SearchResult[];
}
const cacheTTL = Minute.ms * 5;
const maxRecentDocs = 5;
/**
* Strip server-generated `<b>` highlight tags from context and re-apply them
* using the current search query. This prevents stale highlights when the
* displayed results are from a previous (in-flight) query.
*
* @param context the server-generated context string with `<b>` tags.
* @param query the current search query to highlight.
* @returns the context string with highlights matching the current query.
*/
function rehighlightContext(
context: string | undefined,
query: string
): string | undefined {
if (!context) {
return context;
}
const plain = context.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
const trimmed = query.trim();
if (!trimmed) {
return plain;
}
const terms = trimmed.split(/\s+/).filter(Boolean);
const patterns = [escapeRegExp(trimmed)];
if (terms.length > 1) {
patterns.push(...terms.map((t) => `\\b${escapeRegExp(t)}\\b`));
}
const regex = new RegExp(patterns.join("|"), "gi");
return plain.replace(regex, "<b>$&</b>");
}
/**
* Registers search result actions in the command bar scoped to a public share.
*/
function SharedSearchActions() {
const { documents } = useStores();
const { shareId } = useShare();
const searchCache = React.useRef<Map<string, CacheEntry>>(new Map());
const [results, setResults] = React.useState<SearchResult[]>([]);
const recentDocsRef = React.useRef<Document[]>([]);
const [recentDocs, setRecentDocs] = React.useState<Document[]>([]);
const { searchQuery } = useKBar((state) => ({
searchQuery: state.searchQuery,
}));
const searchQueryRef = React.useRef(searchQuery);
searchQueryRef.current = searchQuery;
React.useEffect(() => {
if (!searchQuery || !shareId) {
setResults([]);
return;
}
const now = Date.now();
const cachedEntry = searchCache.current.get(searchQuery);
const isExpired = cachedEntry
? now - cachedEntry.timestamp > cacheTTL
: true;
if (cachedEntry && !isExpired) {
setResults(cachedEntry.results);
return;
}
const currentQuery = searchQuery;
void documents.search({ query: searchQuery, shareId }).then((res) => {
searchCache.current.set(currentQuery, { timestamp: now, results: res });
if (searchQueryRef.current === currentQuery) {
setResults(res);
}
});
}, [documents, searchQuery, shareId]);
const addRecentDoc = React.useCallback((doc: Document) => {
const prev = recentDocsRef.current;
const filtered = prev.filter((d) => d.id !== doc.id);
const next = [doc, ...filtered].slice(0, maxRecentDocs);
recentDocsRef.current = next;
setRecentDocs(next);
}, []);
const documentIcon = React.useCallback(
(doc: Document) =>
doc.icon ? (
<Icon
value={doc.icon}
initial={doc.initial}
color={doc.color ?? undefined}
/>
) : (
<DocumentIcon />
),
[]
);
const actions = React.useMemo(
() =>
results.map((result) =>
createAction({
id: `shared-search-${result.document.id}`,
name: result.document.titleWithDefault,
description: rehighlightContext(result.context, searchQuery),
keywords: searchQuery,
analyticsName: "Open shared search result",
section: SearchResultsSection,
icon: documentIcon(result.document),
perform: () => {
if (shareId) {
const currentQuery = searchQueryRef.current;
addRecentDoc(result.document);
history.push({
pathname: sharedModelPath(shareId, result.document.url),
search: currentQuery
? `?q=${encodeURIComponent(currentQuery)}`
: undefined,
});
}
},
})
),
[results, shareId, searchQuery, addRecentDoc, documentIcon]
);
const recentDocActions = React.useMemo(
() =>
recentDocs.map((doc) =>
createAction({
id: `shared-recent-doc-${doc.id}`,
name: doc.titleWithDefault,
analyticsName: "Open recent shared document",
section: RecentSearchesSection,
icon: documentIcon(doc),
perform: () => {
if (shareId) {
history.push(sharedModelPath(shareId, doc.url));
}
},
})
),
[recentDocs, shareId, documentIcon]
);
useCommandBarActions(searchQuery ? actions : recentDocActions, [
searchQuery
? actions.map((a) => a.id).join("")
: recentDocActions.map((a) => a.id).join(""),
searchQuery,
]);
return null;
}
export default observer(SharedSearchActions);
@@ -1,16 +1,13 @@
import { DocumentIcon } from "outline-icons";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import Icon from "@shared/components/Icon";
import { createInternalLinkAction } from "~/actions";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
import { documentBreadcrumbText } from "~/components/DocumentBreadcrumb";
import useStores from "~/hooks/useStores";
import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
const { documents, ui } = useStores();
const { t } = useTranslation();
return useMemo(
() =>
@@ -18,24 +15,19 @@ const useRecentDocumentActions = (count = 6) => {
.filter((document) => document.id !== ui.activeDocumentId)
.slice(0, count)
.map((item) =>
createInternalLinkAction({
createAction({
name: item.titleWithDefault,
analyticsName: "Recently viewed document",
section: RecentSection,
description: documentBreadcrumbText(item, t),
icon: item.icon ? (
<Icon
value={item.icon}
initial={item.initial}
color={item.color ?? undefined}
/>
<Icon value={item.icon} color={item.color ?? undefined} />
) : (
<DocumentIcon outline={item.isDraft} />
<DocumentIcon />
),
to: documentPath(item),
})
),
[count, ui.activeDocumentId, documents.recentlyViewed, t]
[count, ui.activeDocumentId, documents.recentlyViewed]
);
};
@@ -1,6 +1,6 @@
import { SettingsIcon } from "outline-icons";
import { useMemo } from "react";
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useSettingsConfig from "~/hooks/useSettingsConfig";
@@ -10,20 +10,20 @@ const useSettingsAction = () => {
() =>
config.map((item) => {
const Icon = item.icon;
return createInternalLinkAction({
return {
id: item.path,
name: item.name,
icon: <Icon />,
section: NavigationSection,
to: item.path,
});
};
}),
[config]
);
const navigateToSettings = useMemo(
() =>
createActionWithChildren({
createAction({
id: "settings",
name: ({ t }) => t("Settings"),
section: NavigationSection,
@@ -1,37 +1,34 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import { useEffect, useMemo } from "react";
import Icon from "@shared/components/Icon";
import { createActionWithChildren, createInternalLinkAction } from "~/actions";
import { createAction } from "~/actions";
import {
ActiveCollectionSection,
DocumentSection,
TeamSection,
} from "~/actions/sections";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { templates } = useStores();
const { documents } = useStores();
useEffect(() => {
void templates.fetchAll();
}, [templates]);
void documents.fetchAllTemplates();
}, [documents]);
const actions = useMemo(
() =>
templates.alphabetical.map((template) =>
createInternalLinkAction({
documents.templatesAlphabetical.map((template) =>
createAction({
name: template.titleWithDefault,
analyticsName: "New document",
section: template.isWorkspaceTemplate
? TeamSection
: ActiveCollectionSection,
icon: template.icon ? (
<Icon
value={template.icon}
initial={template.initial}
color={template.color ?? undefined}
/>
<Icon value={template.icon} color={template.color ?? undefined} />
) : (
<NewDocumentIcon />
),
@@ -50,28 +47,23 @@ const useTemplatesAction = () => {
template.isWorkspaceTemplate
);
},
to: ({ activeCollectionId, sidebarContext }) => {
const [pathname, search] = newDocumentPath(
template.collectionId ?? activeCollectionId,
{
perform: ({ activeCollectionId, sidebarContext }) =>
history.push(
newDocumentPath(template.collectionId ?? activeCollectionId, {
templateId: template.id,
}),
{
sidebarContext,
}
).split("?");
return {
pathname,
search,
state: { sidebarContext },
};
},
),
})
),
[templates.alphabetical]
[documents.templatesAlphabetical]
);
const newFromTemplate = useMemo(
() =>
createActionWithChildren({
createAction({
id: "templates",
name: ({ t }) => t("New from template"),
placeholder: ({ t }) => t("Choose a template"),
@@ -86,7 +78,7 @@ const useTemplatesAction = () => {
stores.policies.abilities(currentTeamId).createDocument
);
},
children: actions,
children: () => actions,
}),
[actions]
);
+1 -1
View File
@@ -1,7 +1,7 @@
import { observer } from "mobx-react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type Comment from "~/models/Comment";
import Comment from "~/models/Comment";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
+1 -2
View File
@@ -1,8 +1,7 @@
import { observer } from "mobx-react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import { CollectionPermission } from "@shared/types";
import { CollectionPermission, NavigationNode } from "@shared/types";
import type Collection from "~/models/Collection";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import useStores from "~/hooks/useStores";
+11 -19
View File
@@ -31,7 +31,7 @@ export type RefHandle = {
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
const ContentEditable = React.forwardRef(function ContentEditable_(
const ContentEditable = React.forwardRef(function _ContentEditable(
{
disabled,
onChange,
@@ -87,23 +87,22 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
}));
const wrappedEvent =
<E extends React.SyntheticEvent<HTMLSpanElement>>(
callback: ((event: E) => void) | undefined
(
callback:
| React.FocusEventHandler<HTMLSpanElement>
| React.FormEventHandler<HTMLSpanElement>
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) =>
(event: E) => {
(event: any) => {
if (readOnly) {
return;
}
const text = event.currentTarget.textContent || "";
if (
maxLength &&
event.nativeEvent instanceof KeyboardEvent &&
isPrintableKeyEvent(event.nativeEvent) &&
text.length >= maxLength
) {
event.preventDefault();
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event?.preventDefault();
return;
}
@@ -129,14 +128,7 @@ const ContentEditable = React.forwardRef(function ContentEditable_(
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
if (document.activeElement === contentRef.current) {
// Don't reset content while the user is actively editing. Update
// lastValue so that the next input or blur event will push the
// current DOM text back to the model via onChange.
lastValue.current = value;
} else {
setInnerValue(value);
}
setInnerValue(value);
}
}, [value, contentRef]);
+60 -106
View File
@@ -1,17 +1,9 @@
import {
CollectionIcon as CollectionIconComponent,
HomeIcon,
PrivateCollectionIcon,
} from "outline-icons";
import { observer } from "mobx-react";
import { getLuminance } from "polished";
import { HomeIcon } from "outline-icons";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import type { Option } from "~/components/InputSelect";
import { InputSelect } from "~/components/InputSelect";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { InputSelect, Option } from "~/components/InputSelect";
import useStores from "~/hooks/useStores";
type DefaultCollectionInputSelectProps = {
@@ -19,112 +11,74 @@ type DefaultCollectionInputSelectProps = {
defaultCollectionId: string | null;
};
const DefaultCollectionInputSelect = observer(
({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections, ui } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
const DefaultCollectionInputSelect = ({
onSelectCollection,
defaultCollectionId,
}: DefaultCollectionInputSelectProps) => {
const { t } = useTranslation();
const { collections } = useStores();
const [fetching, setFetching] = useState(false);
const [fetchError, setFetchError] = useState();
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
}
React.useEffect(() => {
async function fetchData() {
if (!collections.isLoaded && !fetching && !fetchError) {
try {
setFetching(true);
await collections.fetchPage({
limit: 100,
});
} catch (error) {
toast.error(
t("Collections could not be loaded, please reload the app")
);
setFetchError(error);
} finally {
setFetching(false);
}
}
void fetchData();
}, [fetchError, t, fetching, collections]);
if (fetching) {
return null;
}
void fetchData();
}, [fetchError, t, fetching, collections]);
const isDark = ui.resolvedTheme === "dark";
// Eagerly resolve collection icon properties within this observer context
// to avoid MobX warnings when Radix Select clones elements for the trigger.
const options: Option[] = collections.nonPrivate.reduce(
(acc, collection) => {
const collectionIcon = collection.icon;
const rawColor = collection.color ?? colorPalette[0];
let icon: React.ReactElement;
if (!collectionIcon || collectionIcon === "collection") {
const color =
isDark && rawColor !== "currentColor"
? getLuminance(rawColor) > 0.09
? rawColor
: "currentColor"
: rawColor;
const Component = collection.isPrivate
? PrivateCollectionIcon
: CollectionIconComponent;
icon = <Component color={color} />;
} else {
let color = rawColor;
if (color !== "currentColor") {
if (isDark) {
color = getLuminance(color) > 0.09 ? color : "currentColor";
} else {
color = getLuminance(color) < 0.9 ? color : "currentColor";
}
}
icon = (
<Icon
value={collectionIcon}
color={color}
initial={collection.initial}
forceColor
/>
);
}
return [
const options: Option[] = React.useMemo(
() =>
collections.nonPrivate.reduce(
(acc, collection) => [
...acc,
{
type: "item" as const,
type: "item",
label: collection.name,
value: collection.id,
icon,
icon: <CollectionIcon collection={collection} />,
},
];
},
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
);
],
[
{
type: "item",
label: t("Home"),
value: "home",
icon: <HomeIcon />,
},
] satisfies Option[]
),
[collections.nonPrivate, t]
);
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
labelHidden
short
/>
);
if (fetching) {
return null;
}
);
return (
<InputSelect
options={options}
value={defaultCollectionId ?? "home"}
onChange={onSelectCollection}
label={t("Start view")}
hideLabel
short
/>
);
};
export default DefaultCollectionInputSelect;
+1 -1
View File
@@ -15,7 +15,7 @@ export default function DesktopEventHandler() {
const hasDisabledUpdateMessage = useRef(false);
useEffect(() => {
Desktop.bridge?.redirect((path: string, replace: boolean) => {
Desktop.bridge?.redirect((path: string, replace = false) => {
if (replace) {
history.replace(path);
} else {
-32
View File
@@ -1,32 +0,0 @@
import { createContext, useContext, useMemo, useState } from "react";
export type DialogContext = {
animating: boolean;
setAnimating: (isAnimating: boolean) => void;
};
/**
* Context for the dialogs (Guide/Modal) being rendered.
* This helps control the dialog's behavior from within any nested component.
*/
const DialogContext = createContext<DialogContext>({
animating: false,
setAnimating: () => {},
});
export function DialogProvider({ children }: { children: React.ReactNode }) {
const [animating, setAnimating] = useState(false);
const ctx = useMemo<DialogContext>(
() => ({
animating,
setAnimating,
}),
[animating]
);
return (
<DialogContext.Provider value={ctx}>{children}</DialogContext.Provider>
);
}
export const useDialogContext = () => useContext(DialogContext);
+25 -30
View File
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
import { Suspense } from "react";
import useStores from "~/hooks/useStores";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { DialogProvider } from "./DialogContext";
const Guide = lazyWithRetry(() => import("~/components/Guide"));
const Modal = lazyWithRetry(() => import("~/components/Modal"));
@@ -13,35 +12,31 @@ function Dialogs() {
const modals = [...modalStack];
return (
<DialogProvider>
<Suspense fallback={null}>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{modals.map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
width={modal.width}
height={modal.height}
>
{modal.content}
</Modal>
))}
</Suspense>
</DialogProvider>
<Suspense fallback={null}>
{guide ? (
<Guide
isOpen={guide.isOpen}
onRequestClose={dialogs.closeGuide}
title={guide.title}
>
{guide.content}
</Guide>
) : undefined}
{modals.map(([id, modal]) => (
<Modal
key={id}
isOpen={modal.isOpen}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
>
{modal.content}
</Modal>
))}
</Suspense>
);
}
@@ -1,13 +1,13 @@
import { Trans, useTranslation } from "react-i18next";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Text from "~/components/Text";
import type { IntegrationType } from "@shared/types";
import type Integration from "~/models/Integration";
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 "es-toolkit/compat";
import capitalize from "lodash/capitalize";
type Props = {
integration: Integration<IntegrationType.Analytics>;
+11
View File
@@ -0,0 +1,11 @@
import styled from "styled-components";
import { s } from "@shared/styles";
const Divider = styled.hr`
border: 0;
border-bottom: 1px solid ${s("divider")};
margin: 0;
padding: 0;
`;
export default Divider;
+37 -145
View File
@@ -1,75 +1,20 @@
import type { TFunction } from "i18next";
import { observer } from "mobx-react";
import { ArchiveIcon, GoToIcon, TrashIcon } from "outline-icons";
import { ArchiveIcon, GoToIcon, ShapesIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Icon from "@shared/components/Icon";
import type Collection from "~/models/Collection";
import type Document from "~/models/Document";
import type { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import Breadcrumb from "~/components/Breadcrumb";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { ContextMenu } from "~/components/Menu/ContextMenu";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useCollectionMenuAction } from "~/hooks/useCollectionMenuAction";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { archivePath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkAction } from "~/actions";
import { archivePath, settingsPath, trashPath } from "~/utils/routeHelpers";
import { createInternalLinkActionV2 } from "~/actions";
import { ActiveDocumentSection } from "~/actions/sections";
/**
* Returns the breadcrumb parts leading up to a document, separating the
* (possibly deleted) collection label from ancestor document titles. The
* document itself is not included.
*
* @param document - the document to compute the breadcrumb for.
* @param t - translation function for fallback titles.
* @returns the collection label and ancestor titles.
*/
export function documentBreadcrumbParts(
document: Document,
t: TFunction
): { collection: string | undefined; ancestors: string[] } {
let collectionLabel: string | undefined;
if (document.isCollectionDeleted) {
collectionLabel = t("Deleted Collection");
} else if (document.collection?.name) {
collectionLabel = document.collection.name;
}
return {
collection: collectionLabel,
ancestors: document.pathTo
.slice(0, -1)
.map((node) => node.title || t("Untitled")),
};
}
/**
* Returns the breadcrumb path leading up to a document as a plain text
* string. Includes the collection name (or "Deleted Collection" fallback)
* and any ancestor document titles, slash-separated.
*
* @param document - the document to compute the breadcrumb for.
* @param t - translation function for fallback titles.
* @returns the breadcrumb as a slash-separated string, or undefined if the
* document has no resolvable parent context.
*/
export function documentBreadcrumbText(
document: Document,
t: TFunction
): string | undefined {
const parts = documentBreadcrumbParts(document, t);
const segments = [
...(parts.collection ? [parts.collection] : []),
...parts.ancestors,
];
return segments.length ? segments.join(" / ") : undefined;
}
type Props = {
children?: React.ReactNode;
document: Document;
@@ -108,24 +53,29 @@ function DocumentBreadcrumb(
}
const outputActions = [
createInternalLinkAction({
createInternalLinkActionV2({
name: t("Trash"),
section: ActiveDocumentSection,
icon: <TrashIcon />,
visible: document.isDeleted,
to: trashPath(),
}),
createInternalLinkAction({
createInternalLinkActionV2({
name: t("Archive"),
section: ActiveDocumentSection,
icon: <ArchiveIcon />,
visible: document.isArchived,
to: archivePath(),
}),
createInternalLinkAction({
name: collection ? (
<CollectionName collection={collection} />
) : undefined,
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 />
@@ -138,7 +88,7 @@ function DocumentBreadcrumb(
}
: "",
}),
createInternalLinkAction({
createInternalLinkActionV2({
name: t("Deleted Collection"),
section: ActiveDocumentSection,
visible: document.isCollectionDeleted,
@@ -146,21 +96,14 @@ function DocumentBreadcrumb(
}),
...path.map((node) => {
const title = node.title || t("Untitled");
return createInternalLinkAction({
name: (
<DocumentName
documentId={node.id}
collection={collection}
title={title}
/>
return createInternalLinkActionV2({
name: node.icon ? (
<>
<StyledIcon value={node.icon} color={node.color} /> {title}
</>
) : (
title
),
icon: node.icon ? (
<Icon
value={node.icon}
color={node.color}
initial={title.charAt(0).toUpperCase()}
/>
) : undefined,
section: ActiveDocumentSection,
to: {
pathname: node.url,
@@ -197,25 +140,22 @@ function DocumentBreadcrumb(
return <></>;
}
const { collection: collectionLabel, ancestors: ancestorLabels } =
documentBreadcrumbParts(document, t);
const slicedAncestors = reverse
? ancestorLabels.slice(depth && -depth)
: ancestorLabels.slice(0, depth);
const slicedPath = reverse
? path.slice(depth && -depth)
: path.slice(0, depth);
const showCollection =
!!collectionLabel &&
(!reverse || depth === undefined || slicedAncestors.length < depth);
collection &&
(!reverse || depth === undefined || slicedPath.length < depth);
return (
<>
{showCollection && collectionLabel}
{slicedAncestors.map((label, index) => (
<React.Fragment key={index}>
{showCollection && collection.name}
{slicedPath.map((node: NavigationNode, index: number) => (
<React.Fragment key={node.id}>
{showCollection && <SmallSlash />}
{label}
{!showCollection && index !== slicedAncestors.length - 1 && (
{node.title || t("Untitled")}
{!showCollection && index !== slicedPath.length - 1 && (
<SmallSlash />
)}
</React.Fragment>
@@ -231,57 +171,9 @@ function DocumentBreadcrumb(
);
}
/** Renders a collection name wrapped in a context menu. */
const CollectionName = observer(function CollectionName_({
collection,
}: {
collection: Collection;
}) {
const { t } = useTranslation();
const menuAction = useCollectionMenuAction({
collectionId: collection.id,
});
return (
<ActionContextProvider value={{ activeModels: [collection] }}>
<ContextMenu action={menuAction} ariaLabel={t("Collection options")}>
<span>{collection.name}</span>
</ContextMenu>
</ActionContextProvider>
);
});
/** Renders a document name wrapped in a context menu. */
const DocumentName = observer(function DocumentName_({
documentId,
collection,
title,
}: {
documentId: string;
collection: Collection | undefined;
title: string;
}) {
const { t } = useTranslation();
const { documents } = useStores();
const doc = documents.get(documentId);
const menuAction = useDocumentMenuAction({ documentId });
if (!doc) {
return <>{title}</>;
}
return (
<ActionContextProvider
value={{
activeModels: [doc, ...(collection ? [collection] : [])],
}}
>
<ContextMenu action={menuAction} ariaLabel={t("Document options")}>
<span>{title}</span>
</ContextMenu>
</ActionContextProvider>
);
});
const StyledIcon = styled(Icon)`
margin-right: 2px;
`;
const SmallSlash = styled(GoToIcon)`
width: 12px;
+5 -5
View File
@@ -13,8 +13,8 @@ import Squircle from "@shared/components/Squircle";
import { s, hover, ellipsis } from "@shared/styles";
import { IconType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import type Document from "~/models/Document";
import type Pin from "~/models/Pin";
import Document from "~/models/Document";
import Pin from "~/models/Pin";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
@@ -110,7 +110,7 @@ function DocumentCard(props: Props) {
dir={document.dir}
$isDragging={isDragging}
to={{
pathname: document.path,
pathname: document.url,
state: {
title: document.titleWithDefault,
},
@@ -187,12 +187,12 @@ function DocumentCard(props: Props) {
const DocumentSquircle = ({
icon,
initial,
color,
initial,
}: {
icon: string;
initial: string;
color?: string;
initial?: string;
}) => {
const theme = useTheme();
const iconType = determineIconType(icon)!;
+4 -5
View File
@@ -1,9 +1,8 @@
import { action, computed, observable } from "mobx";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useMemo } from "react";
import type { Heading } from "@shared/utils/ProsemirrorHelper";
import type Document from "~/models/Document";
import type { Editor } from "~/editor";
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import Document from "~/models/Document";
import { Editor } from "~/editor";
class DocumentContext {
/** The current document */
@@ -3,15 +3,15 @@ import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import type { NavigationNode } from "@shared/types";
import type Document from "~/models/Document";
import { NavigationNode } from "@shared/types";
import Document from "~/models/Document";
import { FlexContainer, Footer, StyledText } from "~/scenes/DocumentMove";
import Button from "~/components/Button";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import DocumentExplorer from "~/components/DocumentExplorer";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
import Switch from "./Switch";
import Text from "./Text";
type Props = {
/** The original document to duplicate */
@@ -24,7 +24,6 @@ function DocumentCopy({ document, onSubmit }: Props) {
const { policies } = useStores();
const collectionTrees = useCollectionTrees();
const [publish, setPublish] = React.useState<boolean>(!!document.publishedAt);
const [copying, setCopying] = React.useState<boolean>(false);
const [recursive, setRecursive] = React.useState<boolean>(true);
const [selectedPath, selectPath] = React.useState<NavigationNode | null>(
null
@@ -37,31 +36,35 @@ function DocumentCopy({ document, onSubmit }: Props) {
: true
);
if (document.isTemplate) {
return nodes
.filter((node) => node.type === "collection")
.map((node) => ({ ...node, children: [] }));
}
return nodes;
}, [policies, collectionTrees]);
}, [policies, collectionTrees, document.isTemplate]);
const copy = async (path = selectedPath) => {
if (!path) {
const copy = async () => {
if (!selectedPath) {
toast.message(t("Select a location to copy"));
return;
}
try {
setCopying(true);
const result = await document.duplicate({
publish,
recursive,
title: document.title,
collectionId: path.collectionId,
...(path.type === "document" ? { parentDocumentId: path.id } : {}),
collectionId: selectedPath.collectionId,
...(selectedPath.type === "document"
? { parentDocumentId: selectedPath.id }
: {}),
});
toast.success(t("Document copied"));
onSubmit(result);
} catch (_err) {
toast.error(t("Couldnt copy the document, try again?"));
} finally {
setCopying(false);
}
};
@@ -73,32 +76,34 @@ function DocumentCopy({ document, onSubmit }: Props) {
onSelect={selectPath}
defaultValue={document.parentDocumentId || document.collectionId || ""}
/>
<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>
{!document.isTemplate && (
<OptionsContainer>
{document.collectionId && (
<Text size="small">
<Switch
name="publish"
label={t("Publish")}
labelPosition="right"
checked={publish}
onChange={setPublish}
/>
</Text>
)}
{document.publishedAt && document.childDocuments.length > 0 && (
<Text size="small">
<Switch
name="recursive"
label={t("Include nested documents")}
labelPosition="right"
checked={recursive}
onChange={setRecursive}
/>
</Text>
)}
</OptionsContainer>
)}
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
<StyledText type="secondary">
{selectedPath ? (
<Trans
defaults="Copy to <em>{{ location }}</em>"
@@ -108,9 +113,9 @@ function DocumentCopy({ document, onSubmit }: Props) {
) : (
t("Select a location to copy")
)}
</Text>
<Button disabled={!selectedPath || copying} onClick={() => copy()}>
{copying ? `${t("Copying")}` : t("Copy")}
</StyledText>
<Button disabled={!selectedPath} onClick={copy}>
{t("Copy")}
</Button>
</Footer>
</FlexContainer>
-185
View File
@@ -1,185 +0,0 @@
import { observer } from "mobx-react";
import { useCallback, useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { ExportContentType, NotificationEventType } from "@shared/types";
import type Document from "~/models/Document";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
type Props = {
document: Document;
onSubmit: () => void;
};
export const DocumentDownload = observer(({ document, onSubmit }: Props) => {
const { t } = useTranslation();
const { ui } = useStores();
const user = useCurrentUser();
const hasChildDocuments = !!document.childDocuments.length;
const [contentType, setContentType] = useState<ExportContentType>(
ExportContentType.Markdown
);
const [includeChildDocuments, setIncludeChildDocuments] =
useState<boolean>(hasChildDocuments);
const handleContentTypeChange = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setContentType(ev.target.value as ExportContentType);
},
[]
);
const handleIncludeChildDocumentsChange = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
setIncludeChildDocuments(ev.target.checked);
},
[]
);
const handleSubmit = useCallback(async () => {
const response = await document.download({
contentType,
includeChildDocuments,
});
if (includeChildDocuments && response?.data?.fileOperation) {
const fileOperationId = response.data.fileOperation.id;
const toastId = `export-${fileOperationId}`;
const timeoutId = setTimeout(() => {
toast.success(t("Export started"), {
id: toastId,
description: t("A link to your file will be sent through email soon"),
duration: 3000,
});
ui.exportToasts.delete(fileOperationId);
}, 6000);
ui.registerExportToast(fileOperationId, toastId, timeoutId);
toast.loading(t("Export started"), {
id: toastId,
description: `${t("Preparing your download")}`,
duration: Infinity,
});
}
onSubmit();
}, [t, ui, document, contentType, includeChildDocuments, onSubmit]);
const items = useMemo(() => {
const radioItems = [
{
title: "Markdown",
description: t(
"A file containing the selected documents in Markdown format."
),
value: ExportContentType.Markdown,
},
{
title: "HTML",
description: t(
"A file containing the selected documents in HTML format."
),
value: ExportContentType.Html,
},
];
if (env.PDF_EXPORT_ENABLED) {
radioItems.push({
title: "PDF",
description: t(
"A file containing the selected documents in PDF format."
),
value: ExportContentType.Pdf,
});
}
return radioItems;
}, [t]);
return (
<ConfirmationDialog
onSubmit={handleSubmit}
submitText={includeChildDocuments ? t("Export") : t("Download")}
>
<Flex gap={12} column>
{items.map((item) => (
<Option key={item.value}>
<StyledInput
type="radio"
name="format"
value={item.value}
checked={contentType === item.value}
onChange={handleContentTypeChange}
/>
<div>
<Text as="p" size="small" weight="bold">
{item.title}
</Text>
{item.description ? (
<Text size="small" type="secondary">
{item.description}
</Text>
) : null}
</div>
</Option>
))}
</Flex>
{hasChildDocuments && (
<>
<hr style={{ margin: "16px 0 " }} />
<Option>
<StyledInput
type="checkbox"
name="includeChildDocuments"
checked={includeChildDocuments}
onChange={handleIncludeChildDocumentsChange}
/>
<Flex column gap={4}>
<Text as="p" size="small" weight="bold">
{t("Include child documents")}
</Text>
<Text as="p" size="small" type="secondary">
<Trans
defaults="When selected, exporting the document <em>{{documentName}}</em> may take some time."
values={{
documentName: document.titleWithDefault,
}}
components={{
em: <strong />,
}}
/>{" "}
{user.subscribedToEventType(
NotificationEventType.ExportCompleted
) && t("You will receive an email when it's complete.")}
</Text>
</Flex>
</Option>
</>
)}
</ConfirmationDialog>
);
});
const Option = styled.label`
display: flex;
align-items: baseline;
gap: 16px;
p {
margin: 0;
}
`;
const StyledInput = styled.input`
position: relative;
top: 1.5px;
`;
@@ -1,76 +1,46 @@
import FuzzySearch from "fuzzy-search";
import {
concat,
difference,
fill,
filter,
flatten,
includes,
map,
} from "es-toolkit/compat";
import concat from "lodash/concat";
import difference from "lodash/difference";
import fill from "lodash/fill";
import filter from "lodash/filter";
import flatten from "lodash/flatten";
import includes from "lodash/includes";
import map from "lodash/map";
import { observer } from "mobx-react";
import { StarredIcon, DocumentIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import AutoSizer from "react-virtualized-auto-sizer";
import { FixedSizeList as List } from "react-window";
import scrollIntoView from "scroll-into-view-if-needed";
import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Icon from "@shared/components/Icon";
import type { NavigationNode } from "@shared/types";
import { NavigationNode } from "@shared/types";
import { isModKey } from "@shared/utils/keyboard";
import { ancestors, descendants, flattenTree } from "@shared/utils/tree";
import DocumentExplorerNode from "./DocumentExplorerNode";
import DocumentExplorerSearchResult from "./DocumentExplorerSearchResult";
import DocumentExplorerNode from "~/components/DocumentExplorerNode";
import DocumentExplorerSearchResult from "~/components/DocumentExplorerSearchResult";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import { Outline } from "~/components/Input";
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";
type Props = {
/** Action taken upon submission of selected item, could be publish, move etc. */
onSubmit: (item: NavigationNode | null) => void;
onSubmit: () => void;
/** A side-effect of item selection */
onSelect: (item: NavigationNode | null) => void;
/** Items to be shown in explorer */
items: NavigationNode[];
/** Automatically expand to and select item with the given id */
defaultValue?: string;
/** Whether to show child documents */
showDocuments?: boolean;
};
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
function DocumentExplorer({
onSubmit,
onSelect,
items,
defaultValue,
showDocuments,
}: Props) {
function DocumentExplorer({ onSubmit, onSelect, items, defaultValue }: Props) {
const isMobile = useMobile();
const { collections, documents } = useStores();
const { t } = useTranslation();
@@ -89,6 +59,8 @@ function DocumentExplorer({
return node || null;
}
);
const [initialScrollOffset, setInitialScrollOffset] =
React.useState<number>(0);
const [activeNode, setActiveNode] = React.useState<number>(0);
const [expandedNodes, setExpandedNodes] = React.useState<string[]>(() => {
if (defaultValue) {
@@ -111,6 +83,9 @@ function DocumentExplorer({
);
const listRef = React.useRef<List<NavigationNode[]>>(null);
const VERTICAL_PADDING = 6;
const HORIZONTAL_PADDING = 24;
const searchIndex = React.useMemo(
() =>
new FuzzySearch(flatten(items.map(flattenTree)), ["title"], {
@@ -161,18 +136,24 @@ function DocumentExplorer({
setTimeout(() => listRef.current?.scrollToItem(index, "center"), 50);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue]);
}, [defaultValue, selectedNode, nodes]);
const baseDepth = nodes.reduce(
(min, node) => (node.depth ? Math.min(min, node.depth) : min),
Infinity
);
const normalizedBaseDepth =
(baseDepth === Infinity ? 0 : baseDepth) + (showDocuments ? 0 : 1);
const normalizedBaseDepth = baseDepth === Infinity ? 0 : baseDepth;
const scrollNodeIntoView = React.useCallback((node: number) => {
listRef.current?.scrollToItem(node, "smart");
}, []);
const scrollNodeIntoView = React.useCallback(
(node: number) => {
if (itemRefs[node] && itemRefs[node].current) {
scrollIntoView(itemRefs[node].current as HTMLSpanElement, {
behavior: "auto",
block: "center",
});
}
},
[itemRefs]
);
const handleSearch = (ev: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(ev.target.value);
@@ -180,16 +161,16 @@ function DocumentExplorer({
const isExpanded = (node: number) => includes(expandedNodes, nodes[node].id);
const preserveScrollOffset = (itemCount: number) => {
const calculateInitialScrollOffset = (itemCount: number) => {
if (listRef.current) {
const { height, itemSize } = listRef.current.props;
const { scrollOffset } = listRef.current.state as {
scrollOffset: number;
};
const itemsHeight = itemCount * itemSize;
const offset = itemsHeight < Number(height) ? 0 : scrollOffset;
setTimeout(() => listRef.current?.scrollTo(offset), 0);
return itemsHeight < Number(height) ? 0 : scrollOffset;
}
return 0;
};
const collapse = (node: number) => {
@@ -200,7 +181,8 @@ function DocumentExplorer({
// remove children
const newNodes = filter(nodes, (n) => !includes(descendantIds, n.id));
preserveScrollOffset(newNodes.length);
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
const expand = (node: number) => {
@@ -209,7 +191,8 @@ function DocumentExplorer({
// add children
const newNodes = nodes.slice();
newNodes.splice(node + 1, 0, ...descendants(nodes[node], 1));
preserveScrollOffset(newNodes.length);
const scrollOffset = calculateInitialScrollOffset(newNodes.length);
setInitialScrollOffset(scrollOffset);
};
React.useEffect(() => {
@@ -233,8 +216,7 @@ function DocumentExplorer({
};
const hasChildren = (node: number) =>
nodes[node].children.length > 0 ||
(showDocuments !== false && nodes[node].type === "collection");
nodes[node].children.length > 0 || nodes[node].type === "collection";
const toggleCollapse = (node: number) => {
if (!hasChildren(node)) {
@@ -255,13 +237,6 @@ function DocumentExplorer({
}
};
const submitNode = (node: number) => {
const selectedNode = nodes[node];
selectNode(selectedNode);
onSubmit(selectedNode);
};
const ListItem = observer(
({
index,
@@ -293,9 +268,7 @@ function DocumentExplorer({
title = doc?.title ?? node.title;
if (icon) {
renderedIcon = (
<Icon value={icon} initial={node.title} color={color} />
);
renderedIcon = <Icon value={icon} color={color} />;
} else if (doc?.isStarred) {
renderedIcon = <StarredIcon color={theme.yellow} />;
} else {
@@ -318,8 +291,7 @@ function DocumentExplorer({
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => selectNode(nodes[index])}
onDoubleClick={() => submitNode(index)}
onClick={() => toggleSelect(index)}
icon={renderedIcon}
title={title}
path={path}
@@ -333,8 +305,7 @@ function DocumentExplorer({
width: `calc(${style.width} - ${HORIZONTAL_PADDING * 2}px)`,
}}
onPointerMove={() => setActiveNode(index)}
onClick={() => selectNode(nodes[index])}
onDoubleClick={() => submitNode(index)}
onClick={() => toggleSelect(index)}
onDisclosureClick={(ev) => {
ev.stopPropagation();
toggleCollapse(index);
@@ -396,7 +367,7 @@ function DocumentExplorer({
}
case "Enter": {
if (isModKey(ev)) {
onSubmit(selectedNode);
onSubmit();
} else {
toggleSelect(activeNode);
}
@@ -405,16 +376,31 @@ function DocumentExplorer({
}
};
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
<ListSearch
ref={inputSearchRef}
onChange={handleSearch}
placeholder={
showDocuments
? `${t("Search collections & documents")}`
: `${t("Search collections")}`
}
placeholder={`${t("Search collections & documents")}`}
autoFocus
/>
<ListContainer>
@@ -424,12 +410,14 @@ function DocumentExplorer({
<Flex role="listbox" column>
<List
ref={listRef}
key={nodes.length}
width={width}
height={height}
itemData={nodes}
itemCount={nodes.length}
itemSize={isMobile ? 48 : 32}
innerElementType={innerElementType}
initialScrollOffset={initialScrollOffset}
itemKey={(index, results) => results[index].id}
>
{ListItem}
@@ -457,7 +445,10 @@ const FlexContainer = styled(Flex)`
justify-content: center;
`;
const ListSearch = styled(InputSearch).attrs({ round: true })`
const ListSearch = styled(InputSearch)`
${Outline} {
border-radius: 16px;
}
margin-bottom: 4px;
padding-left: 24px;
padding-right: 24px;
@@ -1,17 +0,0 @@
import styled from "styled-components";
import Flex from "../Flex";
export const FlexContainer = styled(Flex)`
margin-left: -24px;
margin-right: -24px;
margin-bottom: -24px;
outline: none;
`;
export const Footer = styled(Flex)`
height: 64px;
border-top: 1px solid ${(props) => props.theme.horizontalRule};
padding-left: 24px;
padding-right: 24px;
flex-shrink: 0;
`;
@@ -1,86 +0,0 @@
import { observer } from "mobx-react";
import { useState, useMemo } from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import type { NavigationNode } from "@shared/types";
import type Template from "~/models/Template";
import Button from "~/components/Button";
import Text from "~/components/Text";
import useCollectionTrees from "~/hooks/useCollectionTrees";
import useStores from "~/hooks/useStores";
import { FlexContainer, Footer } from "./Components";
import DocumentExplorer from "./DocumentExplorer";
type Props = {
template: Template;
};
function TemplateMove({ template }: Props) {
const { dialogs, policies } = useStores();
const { t } = useTranslation();
const collectionTrees = useCollectionTrees();
const [selectedPath, selectPath] = useState<NavigationNode | null>(null);
const items = useMemo(
() =>
collectionTrees
.map((node) => ({ ...node, children: [] }))
.filter((node) =>
node.collectionId
? policies.get(node.collectionId)?.abilities.createDocument
: true
),
[policies, collectionTrees]
);
const move = async (path = selectedPath) => {
if (!path) {
toast.message(t("Select a location to move"));
return;
}
try {
const collectionId = (path.collectionId ?? path.id) as string;
await template.save({ collectionId });
toast.success(t("Template moved"));
dialogs.closeAllModals();
} catch (_err) {
toast.error(t("Couldnt move the template, try again?"));
}
};
return (
<FlexContainer column>
<DocumentExplorer
items={items}
onSubmit={move}
onSelect={selectPath}
showDocuments={false}
/>
<Footer justify="space-between" align="center" gap={8}>
<Text ellipsis type="secondary">
{selectedPath ? (
<Trans
defaults="Move to <em>{{ location }}</em>"
values={{
location: selectedPath.title,
}}
components={{
em: <strong />,
}}
/>
) : (
t("Select a location to move")
)}
</Text>
<Button disabled={!selectedPath} onClick={() => move()}>
{t("Move")}
</Button>
</Footer>
</FlexContainer>
);
}
export default observer(TemplateMove);
-3
View File
@@ -1,3 +0,0 @@
import DocumentExplorer from "./DocumentExplorer";
export default DocumentExplorer;
@@ -9,30 +9,18 @@ import Disclosure from "~/components/Sidebar/components/Disclosure";
import Text from "~/components/Text";
type Props = {
/** Whether this node is the chosen destination (committed pick via click or Enter). */
selected: boolean;
/** Whether this node is currently highlighted by pointer hover or keyboard navigation. */
active: boolean;
/** Inline style passed in by the virtualized list for absolute positioning. */
style: React.CSSProperties;
/** Whether this node's children are currently revealed in the tree. */
expanded: boolean;
/** Icon rendered before the title (document icon, emoji, or star). */
icon?: React.ReactNode;
/** Display title for the node. */
title: string;
/** Zero-based nesting depth, used to indent the node. */
depth: number;
/** Whether this node has descendants and should render a disclosure chevron. */
hasChildren: boolean;
/** Fired when the disclosure chevron is clicked to expand or collapse the node. */
onDisclosureClick: (ev: React.MouseEvent) => void;
/** Fired on pointer movement over the node; used to update the active highlight. */
onPointerMove: (ev: React.MouseEvent) => void;
/** Fired when the node is clicked to select it. */
onClick: (ev: React.MouseEvent) => void;
/** Fired when the node is double-clicked to submit the current selection. */
onDoubleClick: (ev: React.MouseEvent) => void;
};
function DocumentExplorerNode(
@@ -48,13 +36,14 @@ function DocumentExplorerNode(
onDisclosureClick,
onPointerMove,
onClick,
onDoubleClick,
}: Props,
ref: React.RefObject<HTMLSpanElement>
) {
const { t } = useTranslation();
const DISCLOSURE = 24;
const width = (depth + 2) * DISCLOSURE;
const OFFSET = 12;
const DISCLOSURE = 20;
const width = depth ? depth * DISCLOSURE + OFFSET : DISCLOSURE;
return (
<Node
@@ -62,11 +51,9 @@ function DocumentExplorerNode(
selected={selected}
active={active}
onClick={onClick}
onDoubleClick={onDoubleClick}
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
<Spacer width={width}>
{hasChildren && (
@@ -92,11 +79,7 @@ const Title = styled(Text)`
const StyledDisclosure = styled(Disclosure)`
position: relative;
left: auto;
margin: 2px 0;
&&[aria-expanded="true"]:not(:hover) {
background: none;
}
margin-top: 2px;
`;
const Spacer = styled(Flex)<{ width: number }>`
@@ -1,9 +1,10 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import scrollIntoView from "scroll-into-view-if-needed";
import styled from "styled-components";
import { ellipsis } from "@shared/styles";
import { Node as SearchResult } from "./DocumentExplorerNode";
import { Node as SearchResult } from "~/components/DocumentExplorerNode";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
@@ -17,7 +18,6 @@ type Props = {
onPointerMove: (ev: React.MouseEvent) => void;
onClick: (ev: React.MouseEvent) => void;
onDoubleClick: (ev: React.MouseEvent) => void;
};
function DocumentExplorerSearchResult({
@@ -29,20 +29,31 @@ function DocumentExplorerSearchResult({
path,
onPointerMove,
onClick,
onDoubleClick,
}: Props) {
const { t } = useTranslation();
const ref = React.useCallback(
(node: HTMLSpanElement | null) => {
if (active && node) {
scrollIntoView(node, {
scrollMode: "if-needed",
behavior: "auto",
block: "nearest",
});
}
},
[active]
);
return (
<SearchResult
ref={ref}
selected={selected}
active={active}
onClick={onClick}
onDoubleClick={onDoubleClick}
style={style}
onPointerMove={onPointerMove}
role="option"
aria-selected={selected}
>
{icon}
<Flex>
+72 -99
View File
@@ -5,15 +5,13 @@ import {
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { Link } from "react-router-dom";
import { DocumentIcon } from "outline-icons";
import styled, { css, useTheme } from "styled-components";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import Icon from "@shared/components/Icon";
import { s, hover } from "@shared/styles";
import type Document from "~/models/Document";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import Flex from "~/components/Flex";
@@ -23,12 +21,10 @@ import StarButton, { AnimatedStar } from "~/components/Star";
import Tooltip from "~/components/Tooltip";
import useBoolean from "~/hooks/useBoolean";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import DocumentMenu from "~/menus/DocumentMenu";
import { documentPath } from "~/utils/routeHelpers";
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
import { useDragDocument } from "./Sidebar/hooks/useDragAndDrop";
import { ActionContextProvider } from "~/hooks/useActionContext";
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
import { ContextMenu } from "./Menu/ContextMenu";
@@ -42,6 +38,7 @@ type Props = {
showCollection?: boolean;
showPublished?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -57,11 +54,9 @@ function DocumentListItem(
) {
const { t } = useTranslation();
const user = useCurrentUser();
const theme = useTheme();
const { userMemberships, groupMemberships } = useStores();
const locationSidebarContext = useLocationSidebarContext();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
const isMobile = useMobile();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
@@ -78,6 +73,7 @@ function DocumentListItem(
showCollection,
showPublished,
showDraft = true,
showTemplate,
highlight,
context,
...rest
@@ -85,7 +81,7 @@ function DocumentListItem(
const queryIsInTitle =
!!highlight &&
!!document.title.toLowerCase().includes(highlight.toLowerCase());
const canStar = !document.isArchived;
const canStar = !document.isArchived && !document.isTemplate;
const isShared = !!(
userMemberships.getByDocumentId(document.id) ||
@@ -100,30 +96,14 @@ function DocumentListItem(
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
const [{ isDragging }, draggableRef] = useDragDocument(
document.asNavigationNode,
0,
document,
false,
false
);
const mergedRef = React.useMemo(
() =>
mergeRefs<HTMLAnchorElement>([
itemRef,
draggableRef,
] as React.Ref<HTMLAnchorElement>[]),
[itemRef, draggableRef]
);
return (
<ActionContextProvider
value={{
activeModels: [
document,
...(!isShared && document.collection ? [document.collection] : []),
],
activeDocumentId: document.id,
activeCollectionId:
!isShared && document.collectionId
? document.collectionId
: undefined,
}}
>
<ContextMenu
@@ -133,16 +113,12 @@ function DocumentListItem(
onClose={handleMenuClose}
>
<DocumentLink
ref={mergedRef}
ref={itemRef}
dir={document.dir}
$isStarred={document.isStarred}
$isDragging={isDragging}
$menuOpen={menuOpen}
to={{
pathname: documentPath(document),
search: highlight
? `?q=${encodeURIComponent(highlight)}`
: undefined,
state: {
title: document.titleWithDefault,
sidebarContext,
@@ -151,55 +127,56 @@ function DocumentListItem(
{...rest}
{...rovingTabIndex}
>
<Flex gap={4} auto>
<IconWrapper>
{document.icon ? (
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
) : (
<DocumentIcon
outline={document.isDraft}
color={theme.textSecondary}
/>
<Content>
<Heading dir={document.dir}>
{document.icon && (
<>
<Icon
value={document.icon}
color={document.color ?? undefined}
initial={document.initial}
/>
&nbsp;
</>
)}
</IconWrapper>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && !isMobile && <StarButton document={document} />}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
</Content>
</Flex>
{document.isBadgedNew && document.createdBy?.id !== user.id && (
<Badge yellow>{t("New")}</Badge>
)}
{document.isDraft && showDraft && (
<Tooltip content={t("Only visible to you")} placement="top">
<Badge>{t("Draft")}</Badge>
</Tooltip>
)}
{canStar && (
<StarPositioner>
<StarButton document={document} />
</StarPositioner>
)}
{document.isTemplate && showTemplate && (
<Badge primary>{t("Template")}</Badge>
)}
</Heading>
{!queryIsInTitle && (
<ResultContext
text={context}
highlight={highlight ? SEARCH_RESULT_REGEX : undefined}
processResult={replaceResultMarks}
/>
)}
<DocumentMeta
document={document}
showCollection={showCollection}
showPublished={showPublished}
showParentDocuments={showParentDocuments}
showLastViewed
/>
</Content>
<Actions>
<DocumentMenu
document={document}
@@ -213,14 +190,6 @@ function DocumentListItem(
);
}
const IconWrapper = styled.div`
flex-shrink: 0;
display: flex;
align-items: flex-start;
justify-content: flex-start;
width: 24px;
`;
const Content = styled.div`
flex-grow: 1;
flex-shrink: 1;
@@ -235,9 +204,12 @@ const Actions = styled(EventBoundary)`
flex-grow: 0;
color: ${s("textSecondary")};
${NudeButton}:${hover},
${NudeButton}[aria-expanded= "true"] {
background: ${s("sidebarControlHoverBackground")};
${NudeButton} {
&:
${hover},
&[aria-expanded= "true"] {
background: ${s("sidebarControlHoverBackground")};
}
}
${breakpoint("tablet")`
@@ -247,7 +219,6 @@ const Actions = styled(EventBoundary)`
const DocumentLink = styled(Link)<{
$isStarred?: boolean;
$isDragging?: boolean;
$menuOpen?: boolean;
}>`
display: flex;
@@ -258,8 +229,6 @@ const DocumentLink = styled(Link)<{
max-height: 50vh;
width: calc(100vw - 8px);
cursor: var(--pointer);
transition: opacity 250ms ease;
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
&:focus-visible {
outline: none;
@@ -316,14 +285,18 @@ const Heading = styled.span<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
margin-top: 0;
margin-bottom: 0.1em;
margin-bottom: 0.25em;
white-space: nowrap;
color: ${s("text")};
font-family: ${s("fontFamily")};
font-weight: 500;
font-size: 18px;
font-size: 20px;
line-height: 1.2;
gap: 4px;
`;
const StarPositioner = styled(Flex)`
margin-left: 4px;
align-items: center;
`;
const Title = styled(Highlight)`
+9 -8
View File
@@ -1,12 +1,12 @@
import type { LocationDescriptor } from "history";
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import type Document from "~/models/Document";
import type Revision from "~/models/Revision";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import DocumentTasks from "~/components/DocumentTasks";
import Flex from "~/components/Flex";
@@ -52,6 +52,7 @@ const DocumentMeta: React.FC<Props> = ({
isDraft,
lastViewedAt,
isTasks,
isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -141,7 +142,7 @@ const DocumentMeta: React.FC<Props> = ({
const nestedDocumentsCount = collection
? collection.getChildrenForDocument(document.id).length
: 0;
const canShowProgressBar = isTasks;
const canShowProgressBar = isTasks && !isTemplate;
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
@@ -169,7 +170,7 @@ const DocumentMeta: React.FC<Props> = ({
};
return (
<Container align="center" $rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
{to ? (
<Link to={to} replace={replace}>
{content}
@@ -181,7 +182,7 @@ const DocumentMeta: React.FC<Props> = ({
<span>
&nbsp;{t("in")}&nbsp;
<Strong>
<DocumentBreadcrumb document={document} maxDepth={1} onlyText />
<DocumentBreadcrumb document={document} onlyText />
</Strong>
</span>
)}
@@ -218,8 +219,8 @@ const Strong = styled.strong`
font-weight: 550;
`;
const Container = styled(Flex)<{ $rtl?: boolean }>`
justify-content: ${(props) => (props.$rtl ? "flex-end" : "flex-start")};
const Container = styled(Flex)<{ rtl?: boolean }>`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${s("textTertiary")};
font-size: 13px;
white-space: nowrap;
+5 -6
View File
@@ -1,13 +1,12 @@
import type { TFunction } from "i18next";
import { TFunction } from "i18next";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import type Document from "~/models/Document";
import Document from "~/models/Document";
import CircularProgressBar from "~/components/CircularProgressBar";
import usePrevious from "~/hooks/usePrevious";
import { bounceIn } from "~/styles/animations";
import Flex from "./Flex";
type Props = {
document: Document;
@@ -42,7 +41,7 @@ function DocumentTasks({ document }: Props) {
const message = getMessage(t, total, completed);
return (
<Flex align="center" style={{ padding: "0 1px" }} gap={2} shrink={false}>
<>
{completed === total ? (
<Done
color={theme.accent}
@@ -52,8 +51,8 @@ function DocumentTasks({ document }: Props) {
) : (
<CircularProgressBar percentage={tasksPercentage} />
)}
{message}
</Flex>
&nbsp;{message}
</>
);
}
+4 -3
View File
@@ -1,10 +1,11 @@
import { compact, sortBy } from "es-toolkit/compat";
import compact from "lodash/compact";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import type Document from "~/models/Document";
import type User from "~/models/User";
import Document from "~/models/Document";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
+1 -1
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import * as React from "react";
import type Collection from "~/models/Collection";
import Collection from "~/models/Collection";
type Props = {
enabled: boolean;
+24 -48
View File
@@ -1,8 +1,7 @@
import * as React from "react";
import { toast } from "sonner";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import EventBoundary from "@shared/components/EventBoundary";
import { s, truncateMultiline } from "@shared/styles";
type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onSubmit"> & {
/** A callback when the title is submitted. */
@@ -43,47 +42,27 @@ function EditableTitle(
setValue(title);
}, [title]);
const handleChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
},
[]
);
const handleChange = React.useCallback((event) => {
setValue(event.target.value);
}, []);
const handleDoubleClick = React.useCallback(
(event: React.MouseEvent<HTMLSpanElement>) => {
if (event.altKey) {
return;
}
event.preventDefault();
event.stopPropagation();
setIsEditing(true);
},
[]
);
const handleDoubleClick = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
setIsEditing(true);
}, []);
const stopPropagation = React.useCallback(
(event: React.MouseEvent<HTMLSpanElement>) => {
event.preventDefault();
event.stopPropagation();
},
[]
);
const stopPropagation = React.useCallback((event) => {
event.preventDefault();
event.stopPropagation();
}, []);
const handleFocus = React.useCallback(
(event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
},
[]
);
const handleFocus = React.useCallback((event) => {
event.target.select();
}, []);
const handleSave = React.useCallback(
async (
ev:
| React.FocusEvent<HTMLInputElement>
| React.KeyboardEvent<HTMLInputElement>
| React.FormEvent<HTMLFormElement>
) => {
async (ev) => {
ev.preventDefault();
ev.stopPropagation();
@@ -104,22 +83,20 @@ function EditableTitle(
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
setIsEditing(false);
} catch (error) {
setValue(value);
setIsEditing(true);
setValue(originalValue);
toast.error(error.message);
throw error;
} finally {
setIsSubmitting(false);
setIsEditing(false);
}
},
[originalValue, value, onCancel, onSubmit, isSubmitting]
);
const handleKeyDown = React.useCallback(
async (ev: React.KeyboardEvent<HTMLInputElement>) => {
async (ev) => {
if (ev.nativeEvent.isComposing) {
return;
}
@@ -142,12 +119,11 @@ function EditableTitle(
return (
<>
{isEditing ? (
<EventBoundary as="form" onSubmit={handleSave}>
<form onSubmit={handleSave}>
<Input
dir="auto"
type="text"
lang=""
name="title"
value={value}
onClick={stopPropagation}
onKeyDown={handleKeyDown}
@@ -157,7 +133,7 @@ function EditableTitle(
autoFocus
{...rest}
/>
</EventBoundary>
</form>
) : (
<Text
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
@@ -170,8 +146,8 @@ function EditableTitle(
);
}
const Text = styled.div`
${ellipsis()}
const Text = styled.span`
${truncateMultiline(3)}
`;
const Input = styled.input`
+23 -89
View File
@@ -1,12 +1,10 @@
import { difference } from "es-toolkit/compat";
import difference from "lodash/difference";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { mergeRefs } from "react-merge-refs";
import type { Optional } from "utility-types";
import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
@@ -17,19 +15,25 @@ import ClickablePadding from "~/components/ClickablePadding";
import ErrorBoundary from "~/components/ErrorBoundary";
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
import useCurrentUser from "~/hooks/useCurrentUser";
import useDictionary from "~/hooks/useDictionary";
import useEditorClickHandlers from "~/hooks/useEditorClickHandlers";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import { uploadFile, uploadFileFromUrl } from "~/utils/files";
import lazyWithRetry from "~/utils/lazyWithRetry";
import useShare from "@shared/hooks/useShare";
const LazyLoadedEditor = lazyWithRetry(() => import("~/editor"));
export type Props = Optional<
EditorProps,
"placeholder" | "defaultValue" | "onClickLink" | "embeds" | "extensions"
| "placeholder"
| "defaultValue"
| "onClickLink"
| "embeds"
| "dictionary"
| "extensions"
> & {
shareId?: string | undefined;
embedsDisabled?: boolean;
onSynced?: () => Promise<void>;
onPublish?: (event: React.MouseEvent) => void;
@@ -37,43 +41,20 @@ export type Props = Optional<
};
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const {
id,
onChange,
onCreateCommentMark,
onDeleteCommentMark,
onFileUploadStart,
onFileUploadStop,
} = props;
const { id, shareId, onChange, onCreateCommentMark, onDeleteCommentMark } =
props;
const { comments } = useStores();
const { shareId } = useShare();
const { t } = useTranslation();
const dictionary = useDictionary();
const embeds = useEmbeds(!shareId);
const localRef = React.useRef<SharedEditor>();
const preferences = useCurrentUser({ rejectOnEmpty: false })?.preferences;
const previousCommentIds = React.useRef<string[]>();
// Upload progress tracking for delayed toast
const progressMap = React.useMemo(() => new Map<string, number>(), []);
const uploadState = React.useRef<{
toastId?: string | number;
timeoutId?: ReturnType<typeof setTimeout>;
progress: Map<string, number>;
}>({ progress: progressMap });
const handleUploadFile = React.useCallback(
async (
file: File | string,
uploadOptions?: {
id?: string;
onProgress?: (fractionComplete: number) => void;
}
) => {
async (file: File | string) => {
const options = {
id: uploadOptions?.id,
documentId: id,
preset: AttachmentPreset.DocumentAttachment,
onProgress: uploadOptions?.onProgress,
};
const result =
file instanceof File
@@ -86,49 +67,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const { handleClickLink } = useEditorClickHandlers({ shareId });
// Show toast only after uploads have been running for 2 seconds
const handleFileUploadStart = React.useCallback(() => {
uploadState.current.timeoutId = setTimeout(() => {
uploadState.current.toastId = toast.loading(
t("Uploading… {{ progress }}%", { progress: 0 })
);
}, 2000);
onFileUploadStart?.();
}, [onFileUploadStart, t]);
const handleFileUploadProgress = React.useCallback(
(fileId: string, fractionComplete: number) => {
uploadState.current.progress.set(fileId, fractionComplete);
// Calculate average progress across all files
const progressValues = Array.from(uploadState.current.progress.values());
const avgProgress =
progressValues.reduce((a, b) => a + b, 0) / progressValues.length;
const percent = Math.round(avgProgress * 100);
// Update toast if visible
if (uploadState.current.toastId) {
toast.loading(t("Uploading… {{ progress }}%", { progress: percent }), {
id: uploadState.current.toastId,
});
}
},
[t]
);
const handleFileUploadStop = React.useCallback(() => {
if (uploadState.current.timeoutId) {
clearTimeout(uploadState.current.timeoutId);
uploadState.current.timeoutId = undefined;
}
if (uploadState.current.toastId) {
toast.dismiss(uploadState.current.toastId);
uploadState.current.toastId = undefined;
}
uploadState.current.progress.clear();
onFileUploadStop?.();
}, [onFileUploadStop]);
const focusAtEnd = React.useCallback(() => {
localRef?.current?.focusAtEnd();
}, [localRef]);
@@ -175,17 +113,17 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
return insertFiles(view, event, pos, files, {
uploadFile: handleUploadFile,
onFileUploadStart: handleFileUploadStart,
onFileUploadStop: handleFileUploadStop,
onFileUploadProgress: handleFileUploadProgress,
onFileUploadStart: props.onFileUploadStart,
onFileUploadStop: props.onFileUploadStop,
dictionary,
isAttachment,
});
},
[
localRef,
handleFileUploadStart,
handleFileUploadStop,
handleFileUploadProgress,
props.onFileUploadStart,
props.onFileUploadStop,
dictionary,
handleUploadFile,
]
);
@@ -204,7 +142,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const commentMarks = localRef.current.getComments();
const commentIds = comments.orderedData.map((c) => c.id);
const commentMarkIds = commentMarks?.map((c) => c.id);
const focus = previousCommentIds.current !== undefined;
const newCommentIds = difference(
commentMarkIds,
previousCommentIds.current ?? [],
@@ -214,7 +151,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
newCommentIds.forEach((commentId) => {
const mark = commentMarks.find((c) => c.id === commentId);
if (mark) {
onCreateCommentMark(mark.id, mark.userId, { focus });
onCreateCommentMark(mark.id, mark.userId);
}
});
@@ -260,12 +197,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
<>
{paragraphs ? (
<EditorContainer
$rtl={props.dir === "rtl"}
rtl={props.dir === "rtl"}
grow={props.grow}
style={props.style}
editorStyle={props.editorStyle}
commenting={!!props.onClickCommentMark}
lang={props.lang}
>
<div className="ProseMirror">
{paragraphs.map((paragraph, index) => (
@@ -282,12 +218,10 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
uploadFile={handleUploadFile}
embeds={embeds}
userPreferences={preferences}
dictionary={dictionary}
{...props}
onClickLink={handleClickLink}
onChange={handleChange}
onFileUploadStart={handleFileUploadStart}
onFileUploadStop={handleFileUploadStop}
onFileUploadProgress={handleFileUploadProgress}
placeholder={props.placeholder || ""}
defaultValue={props.defaultValue || ""}
/>

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