mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcd4c07ace | |||
| 982ab2b48e | |||
| 74d9409cc3 | |||
| 0a6cfe5a6a | |||
| 4a16124a94 | |||
| 294521f162 | |||
| 00481d2bfc | |||
| eace258a86 | |||
| de4b515e64 | |||
| c35c566fef | |||
| d9dc6aa2d7 | |||
| 192802d360 | |||
| cb9773ad85 | |||
| f9d9a82e47 | |||
| 383bac241e | |||
| ea28dc46eb | |||
| 2794057738 | |||
| b7b1f5e1fd | |||
| 8fdd5bf734 | |||
| 086c3ec2d8 | |||
| f370b0296b | |||
| 9b837763e6 | |||
| 3d9a8be867 | |||
| c8caeebdba | |||
| 2c7d5ac3d8 | |||
| 30190866f8 | |||
| 53a08cf307 | |||
| 1c5864deee | |||
| 865e6d048e | |||
| 5e852170f9 | |||
| 71da57773e | |||
| ec35af4bc5 | |||
| 870d9ed41e | |||
| 24170e8684 | |||
| 7ae892fe06 | |||
| 4f537c7578 | |||
| 4bca081faa | |||
| 87b4c9fdba | |||
| 6fe4b1cba1 | |||
| ef2abf824e | |||
| 4b4b0f7037 | |||
| 80d50e3d88 | |||
| ba264974cf | |||
| 71dd4f267b | |||
| 5758ff3459 | |||
| 6568b16ef9 | |||
| ef0412c449 | |||
| 031a7d396f | |||
| c3f5563e7f | |||
| 4ee3929e9d | |||
| 9ab409a640 | |||
| 9cafe75bf2 | |||
| 630b4eff8a | |||
| bf8ca59442 | |||
| 9dd28def67 | |||
| d785389fde | |||
| 1ccd770bce | |||
| 7719d378b0 | |||
| f26f8d4bb9 | |||
| 89d4aeac67 | |||
| dc94a683e7 | |||
| 04f5b08ba1 | |||
| 5924f4909f | |||
| c00bad38e2 | |||
| 11e1ef455f | |||
| 4af69b2758 | |||
| dee87f15af | |||
| 67885e7339 | |||
| 0b0a1b0169 | |||
| de18196fd8 | |||
| 96d1c4997b | |||
| 95f4fb2424 | |||
| 1247bb411e | |||
| 7ffb182034 | |||
| fc414e2dd4 | |||
| c3ec7b0877 | |||
| e509719c77 | |||
| a16cf72b73 | |||
| acabc00643 | |||
| e989999d6e | |||
| c3e149eb86 | |||
| 4c05fe422c | |||
| 47e73cee4e | |||
| d1b01d28e6 | |||
| 973cfc3fa3 | |||
| dd6084d044 | |||
| 206545f350 | |||
| e92d68a0a3 | |||
| 66dbcde29b | |||
| 465a8bd505 | |||
| aef62d1356 | |||
| 35e82beaf7 | |||
| 8bb88b8550 | |||
| da4a10e877 | |||
| caaf6dd76b | |||
| 2893924e9a | |||
| 32b7a7df00 | |||
| 97f8c0813c | |||
| 746dc30aeb | |||
| 4a46d19846 | |||
| 98106e7f6f | |||
| 1e808fc52c | |||
| ec8c0645ba | |||
| f90309e781 | |||
| d8f125f413 | |||
| c36e7bfbb6 | |||
| 831df67358 | |||
| c6fdffba77 | |||
| 4e189b8970 | |||
| 2f3dcb2520 | |||
| f36f5f13f4 | |||
| 5d498632c6 | |||
| 9cd26168e1 | |||
| ee10e1407a | |||
| c9af7ff889 | |||
| 27978b8fc4 | |||
| 62d9bf7105 | |||
| 1f3a1d4b86 | |||
| 8ebe4b27b1 | |||
| 0c30d2bb34 | |||
| f744d488f6 | |||
| 8ebf6e884f | |||
| 4438c80ea1 | |||
| 863f22750f | |||
| ee22a127f6 | |||
| c9cd424a8d | |||
| 108b5b934a | |||
| 94824af6e7 | |||
| 1c6eef3509 | |||
| 4e09356982 | |||
| 4b166432e6 | |||
| adb55fa965 | |||
| 7ce57c9c83 | |||
| b44dc726f3 | |||
| 117421b4cb | |||
| 930bfd5391 | |||
| 10f86ed218 | |||
| 9a6e09bafa | |||
| c65a88fc9f | |||
| e24a5adbd5 | |||
| cddb6b2c32 | |||
| ac467b2936 | |||
| 68ce304b48 | |||
| 50456c3b89 | |||
| 51230a55e5 | |||
| 6d4da176d1 | |||
| 88b3b50333 | |||
| 305de71e8b | |||
| 9cd3ec0868 | |||
| 6975d76faf | |||
| 4b27feff61 | |||
| e0d2b6cace | |||
| 188c1e409b | |||
| 9faa5dd011 | |||
| 1a62926909 | |||
| c4edfb8ebc | |||
| 8421e1f773 | |||
| 118e5da345 | |||
| 1c7c478a4a | |||
| 32cdb3f961 | |||
| d99d84d97d | |||
| aed8d7a649 | |||
| 80ad6cfec8 | |||
| 892146a563 | |||
| 56393f39b7 | |||
| 0de6650aa5 | |||
| ac551a3c44 | |||
| 14b9259a47 | |||
| e5b524e4c2 | |||
| 4bccb4c4ec | |||
| cdd4f0f315 | |||
| 728790e38f | |||
| c4c5b6289e | |||
| e337123cfd | |||
| ac07724f21 | |||
| 28439d315d | |||
| 4eb3b61c7a | |||
| 6fc608c8c1 | |||
| 2dc930bfe2 | |||
| bf233b209b | |||
| 1dfd1e0681 | |||
| 4054afe6f9 | |||
| 2d7dd558a1 | |||
| 68dd76cfa3 | |||
| 9113989635 | |||
| 293ce2ba72 | |||
| fa1ce950e8 | |||
| 0a77733500 | |||
| 41e425756d | |||
| 876f788f59 | |||
| 0ae559f7bf | |||
| da87fd422d | |||
| 1e84872bab | |||
| 4f0051ed5e | |||
| 317ed1f041 | |||
| 8a29a3523a | |||
| 2babf42cda | |||
| df14da01b0 | |||
| 62bb13047a | |||
| 6413797c34 | |||
| ef5e3f0b29 | |||
| 51249fd6f7 | |||
| 151c2c731a | |||
| 519ed1ac2c | |||
| f1ce28cd8f | |||
| adb56a3c31 | |||
| 280e1c1d86 | |||
| 3c8b9725e1 | |||
| 73de15fd5d | |||
| a78ad8dec2 | |||
| 45c082f137 | |||
| 4a9892c2e1 | |||
| 6d7f008af0 | |||
| bc7052b7ca | |||
| c4006cef7b | |||
| 2a6d6f5804 | |||
| bf0ff6c823 | |||
| 6c8b127ff9 | |||
| f2be756cf4 | |||
| 67049a7868 | |||
| d9706d4735 | |||
| ec748f9914 | |||
| ef668c2fa0 | |||
| 594a004c0f | |||
| 468478d06d | |||
| 02caf88d2a | |||
| 50f26929a1 | |||
| 0f93e92bc6 | |||
| c08940ca3c | |||
| ee8324ad73 | |||
| 96a32c98e7 | |||
| 5c741e3d98 | |||
| ba7b3fff05 | |||
| 90ca8655af | |||
| 0577c73f06 | |||
| 39e146b4e6 | |||
| 34576dd008 | |||
| 585a34d27e | |||
| 3c002f82cc | |||
| 51001cfac1 | |||
| 18e0d936ef | |||
| 5658090d7e | |||
| 19de348c85 | |||
| b8a02df7ba | |||
| 4c15f27bb2 | |||
| b152b9f17b | |||
| 40e41b26a1 | |||
| 4c01f6268e | |||
| 8815a58ff5 | |||
| 36a3ae4b01 | |||
| bca66f7415 | |||
| 06d966ad0c | |||
| c205ffbfe9 | |||
| b75a6928cb | |||
| 0ba792317b | |||
| e0cf873a36 | |||
| 1782c08195 | |||
| d9e7baf072 | |||
| ec1bc801a4 | |||
| 9117b7479f | |||
| eeb8008927 | |||
| 669575fc89 | |||
| 247208e5f5 | |||
| 25dce04046 | |||
| 5cd4ecd34a | |||
| bb074edb0d | |||
| a736022c39 |
@@ -35,6 +35,14 @@
|
||||
"displayName": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"ignore": [
|
||||
"**/*.test.ts"
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"ignore": [
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -16,7 +16,15 @@ DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# For redis you can either specify an ioredis compatible url like this
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# or alternatively, if you would like to provide addtional connection options,
|
||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||
# for a list of available options.
|
||||
# Example: Use Redis Sentinel for high availability
|
||||
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
|
||||
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
@@ -57,8 +65,8 @@ AWS_S3_ACL=private
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
|
||||
# https://<URL>/auth/slack.callback
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
SLACK_CLIENT_ID=get_a_key_from_slack
|
||||
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
@@ -129,10 +137,6 @@ MAXIMUM_IMPORT_SIZE=5120000
|
||||
# requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Comma separated list of domains to be allowed to signin to the wiki. If not
|
||||
# set, all domains are allowed by default when using Google OAuth to signin
|
||||
ALLOWED_DOMAINS=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
@@ -21,7 +20,6 @@
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"eslint-plugin-react-hooks",
|
||||
"import"
|
||||
],
|
||||
"rules": {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -1,22 +0,0 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 120
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 14
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- security
|
||||
- pinned
|
||||
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
Hey! The issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed soon if no further activity occurs. Please
|
||||
reply here if you wish for the issue to be kept open.
|
||||
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -67,4 +67,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: "Close Stale PRs"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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"
|
||||
close-pr-message: "Automatically closed due to inactivity"
|
||||
close-issue-message: "Automatically closed due to inactivity"
|
||||
days-before-issue-stale: 120
|
||||
days-before-pr-stale: 60
|
||||
days-before-close: 5
|
||||
operations-per-run: 60
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: "security,pinned"
|
||||
- name: Print outputs
|
||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||
@@ -3,6 +3,7 @@ build
|
||||
node_modules/*
|
||||
.env
|
||||
.log
|
||||
.vscode/*
|
||||
npm-debug.log
|
||||
stats.json
|
||||
.DS_Store
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.63.0
|
||||
Licensed Work: Outline 0.64.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2026-04-15
|
||||
Change Date: 2026-05-23
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -43,10 +43,6 @@
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all domains are allowed by default when using Google OAuth to signin. Consider putting {your app name}.herokuapp.com and any domain you are binding on in this list.",
|
||||
"required": false
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
|
||||
"required": true
|
||||
@@ -106,11 +102,11 @@
|
||||
"value": "openid profile email",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_KEY": {
|
||||
"SLACK_CLIENT_ID": {
|
||||
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_SECRET": {
|
||||
"SLACK_CLIENT_SECRET": {
|
||||
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
|
||||
"required": false
|
||||
},
|
||||
@@ -209,4 +205,4 @@
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc"
|
||||
"../.eslintrc",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
"plugins": [
|
||||
"eslint-plugin-react-hooks",
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ToolsIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { DebugSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Delete IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear database",
|
||||
section: DebugSection,
|
||||
perform: async ({ t }) => {
|
||||
await deleteAllDatabases();
|
||||
stores.toasts.showToast(t("IndexedDB cache deleted"));
|
||||
},
|
||||
});
|
||||
|
||||
export const development = createAction({
|
||||
name: ({ t }) => t("Development"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DebugSection,
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB],
|
||||
});
|
||||
|
||||
export const rootDebugActions = [development];
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Delete IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear database",
|
||||
section: DeveloperSection,
|
||||
perform: async ({ t }) => {
|
||||
await deleteAllDatabases();
|
||||
stores.toasts.showToast(t("IndexedDB cache deleted"));
|
||||
},
|
||||
});
|
||||
|
||||
export const createTestUsers = createAction({
|
||||
name: "Create test users",
|
||||
icon: <UserIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: async () => {
|
||||
const count = 10;
|
||||
|
||||
try {
|
||||
await client.post("/developer.create_test_users", { count });
|
||||
stores.toasts.showToast(`${count} test users created`);
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, { type: "error" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const developer = createAction({
|
||||
name: ({ t }) => t("Developer"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB, createTestUsers],
|
||||
});
|
||||
|
||||
export const rootDeveloperActions = [developer];
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SearchIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
@@ -260,8 +260,8 @@ export const importDocument = createAction({
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
|
||||
input.onchange = async (ev: Event) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
@@ -299,7 +299,8 @@ export const createTemplate = createAction({
|
||||
return (
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update &&
|
||||
!document?.isTemplate
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { rootCollectionActions } from "./definitions/collections";
|
||||
import { rootDebugActions } from "./definitions/debug";
|
||||
import { rootDeveloperActions } from "./definitions/developer";
|
||||
import { rootDocumentActions } from "./definitions/documents";
|
||||
import { rootNavigationActions } from "./definitions/navigation";
|
||||
import { rootSettingsActions } from "./definitions/settings";
|
||||
@@ -11,5 +11,5 @@ export default [
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootSettingsActions,
|
||||
...rootDebugActions,
|
||||
...rootDeveloperActions,
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
export const DebugSection = ({ t }: ActionContext) => t("Debug");
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { isCustomSubdomain } from "@shared/utils/domains";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
|
||||
@@ -25,29 +23,11 @@ const Authenticated = ({ children }: Props) => {
|
||||
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
const { hostname } = window.location;
|
||||
|
||||
if (!team || !user) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
// If we're authenticated but viewing a domain that doesn't match the
|
||||
// current team then kick the user to the teams correct domain.
|
||||
if (team.domain) {
|
||||
if (team.domain !== hostname) {
|
||||
window.location.href = `${team.url}${window.location.pathname}`;
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
} else if (
|
||||
env.SUBDOMAINS_ENABLED &&
|
||||
team.subdomain &&
|
||||
isCustomSubdomain(hostname) &&
|
||||
!hostname.startsWith(`${team.subdomain}.`)
|
||||
) {
|
||||
window.location.href = `${team.url}${window.location.pathname}`;
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function Breadcrumb({
|
||||
return (
|
||||
<Flex justify="flex-start" align="center">
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={item.to || index}>
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten } from "polished";
|
||||
import * as React from "react";
|
||||
@@ -155,7 +156,7 @@ export type Props<T> = {
|
||||
primary?: boolean;
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: string;
|
||||
to?: LocationDescriptor;
|
||||
borderOnHover?: boolean;
|
||||
href?: string;
|
||||
"data-on"?: string;
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const ButtonLink: React.FC<Props> = React.forwardRef(
|
||||
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
|
||||
return <Button {...props} ref={ref} />;
|
||||
}
|
||||
);
|
||||
|
||||
const Button = styled.button`
|
||||
const ButtonLink = styled.button`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
@@ -12,7 +12,7 @@ const Container = styled.div<Props>`
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props: any) =>
|
||||
padding: ${(props: Props) =>
|
||||
props.withStickyHeader ? "4px 44px 60px" : "60px 44px"};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -42,8 +42,9 @@ function Collaborators(props: Props) {
|
||||
filter(
|
||||
users.orderedData,
|
||||
(user) =>
|
||||
presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)
|
||||
(presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)) &&
|
||||
!user.isSuspended
|
||||
),
|
||||
(user) => presentIds.includes(user.id)
|
||||
),
|
||||
|
||||
@@ -3,12 +3,10 @@ import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -16,39 +14,29 @@ type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { showToast } = useToasts();
|
||||
const { ui } = useStores();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
const redirect = collection.id === ui.activeCollectionId;
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
if (redirect) {
|
||||
history.push(homePath());
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
[collection, history, onSubmit, showToast, ui.activeCollectionId]
|
||||
);
|
||||
const handleSubmit = async () => {
|
||||
const redirect = collection.id === ui.activeCollectionId;
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
if (redirect) {
|
||||
history.push(homePath());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
|
||||
@@ -73,12 +61,9 @@ function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
/>
|
||||
</Text>
|
||||
) : null}
|
||||
<Button type="submit" disabled={isDeleting} autoFocus danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
</>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CollectionDelete);
|
||||
export default observer(CollectionDeleteDialog);
|
||||
@@ -5,7 +5,7 @@ import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import { icons } from "~/components/IconPicker";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/logger";
|
||||
import Logger from "~/utils/Logger";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
|
||||
@@ -7,20 +7,23 @@ import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
children: JSX.Element;
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Text to display on the submit button */
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
savingText?: string;
|
||||
/** If true, the submit button will be a dangerous red */
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
function ConfirmationDialog({
|
||||
const ConfirmationDialog: React.FC<Props> = ({
|
||||
onSubmit,
|
||||
children,
|
||||
submitText,
|
||||
savingText,
|
||||
danger,
|
||||
}: Props) {
|
||||
}) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
@@ -53,6 +56,6 @@ function ConfirmationDialog({
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default observer(ConfirmationDialog);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { hover } from "~/styles";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
to?: string;
|
||||
to?: LocationDescriptor;
|
||||
href?: string;
|
||||
target?: "_blank";
|
||||
as?: string | React.ComponentType<any>;
|
||||
@@ -132,16 +132,18 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
&:${hover},
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
}
|
||||
`};
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: any) {
|
||||
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
|
||||
@@ -69,29 +69,27 @@ const Submenu = React.forwardRef(
|
||||
);
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unnecessary separators
|
||||
filtered = filtered.reduce((acc, item, index) => {
|
||||
// trim separators from start / end
|
||||
if (item.type === "separator" && index === 0) {
|
||||
return acc;
|
||||
}
|
||||
if (item.type === "separator" && index === filtered.length - 1) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// trim double separators looking ahead / behind
|
||||
const prev = filtered[index - 1];
|
||||
if (prev && prev.type === "separator" && item.type === "separator") {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// otherwise, continue
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered;
|
||||
return items
|
||||
.filter((item) => item.visible !== false)
|
||||
.reduce((acc, item) => {
|
||||
// trim separator if the previous item was a separator
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
acc[acc.length - 1]?.type === "separator"
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, item];
|
||||
}, [] as TMenuItem[])
|
||||
.filter((item, index, arr) => {
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
(index === 0 || index === arr.length - 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, ...menu }: Props) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
@@ -92,6 +93,19 @@ const ContextMenu: React.FC<Props> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||
// element can be passed into body-scroll-lock. See:
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (rest.visible && scrollElement) {
|
||||
disableBodyScroll(scrollElement);
|
||||
}
|
||||
return () => {
|
||||
scrollElement && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
@@ -101,7 +115,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
|
||||
{(props) => {
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import * as React from "react";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
@@ -14,7 +15,7 @@ class CopyToClipboard extends React.PureComponent<Props> {
|
||||
const elem = React.Children.only(children);
|
||||
|
||||
copy(text, {
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -35,7 +36,7 @@ type Props = {
|
||||
showLastViewed?: boolean;
|
||||
showParentDocuments?: boolean;
|
||||
document: Document;
|
||||
to?: string;
|
||||
to?: LocationDescriptor;
|
||||
};
|
||||
|
||||
const DocumentMeta: React.FC<Props> = ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useObserver } from "mobx-react";
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
@@ -12,7 +13,7 @@ import useStores from "~/hooks/useStores";
|
||||
type Props = {
|
||||
document: Document;
|
||||
isDraft: boolean;
|
||||
to?: string;
|
||||
to?: LocationDescriptor;
|
||||
rtl?: boolean;
|
||||
};
|
||||
|
||||
@@ -83,4 +84,4 @@ const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default DocumentMetaWithViews;
|
||||
export default observer(DocumentMetaWithViews);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, TFunction } from "react-i18next";
|
||||
@@ -60,4 +61,4 @@ const Done = styled(DoneIcon)<{ $animated: boolean }>`
|
||||
transform-origin: center center;
|
||||
`;
|
||||
|
||||
export default DocumentTasks;
|
||||
export default observer(DocumentTasks);
|
||||
|
||||
+71
-13
@@ -1,14 +1,17 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import mergeRefs from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { supportedImageMimeTypes } from "@shared/utils/files";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
@@ -45,12 +48,13 @@ export type Props = Optional<
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
grow?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
|
||||
const { id, shareId } = props;
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
||||
const { documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
@@ -58,6 +62,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
|
||||
activeLinkEvent,
|
||||
setActiveLinkEvent,
|
||||
] = React.useState<MouseEvent | null>(null);
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
|
||||
const handleLinkActive = React.useCallback((event: MouseEvent) => {
|
||||
setActiveLinkEvent(event);
|
||||
@@ -165,7 +170,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
|
||||
);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
ref.current?.focusAtEnd();
|
||||
ref?.current?.focusAtEnd();
|
||||
}, [ref]);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
@@ -173,21 +178,41 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const files = getDataTransferFiles(event);
|
||||
const view = ref.current?.view;
|
||||
|
||||
const view = ref?.current?.view;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a valid position at the end of the document to insert our content
|
||||
const pos = TextSelection.near(
|
||||
view.state.doc.resolve(view.state.doc.nodeSize - 2)
|
||||
).from;
|
||||
|
||||
// If there are no files in the drop event attempt to parse the html
|
||||
// as a fragment and insert it at the end of the document
|
||||
if (files.length === 0) {
|
||||
const text =
|
||||
event.dataTransfer.getData("text/html") ||
|
||||
event.dataTransfer.getData("text/plain");
|
||||
|
||||
const dom = new DOMParser().parseFromString(text, "text/html");
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.insert(
|
||||
pos,
|
||||
ProsemirrorDOMParser.fromSchema(view.state.schema).parse(dom)
|
||||
)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert all files as attachments if any of the files are not images.
|
||||
const isAttachment = files.some(
|
||||
(file) => !supportedImageMimeTypes.includes(file.type)
|
||||
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
|
||||
);
|
||||
|
||||
// Find a valid position at the end of the document
|
||||
const pos = TextSelection.near(
|
||||
view.state.doc.resolve(view.state.doc.nodeSize - 2)
|
||||
).from;
|
||||
|
||||
insertFiles(view, event, pos, files, {
|
||||
uploadFile: onUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
@@ -216,11 +241,43 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
|
||||
[]
|
||||
);
|
||||
|
||||
// Calculate if headings have changed and trigger callback if so
|
||||
const updateHeadings = React.useCallback(() => {
|
||||
if (onHeadingsChange) {
|
||||
const headings = ref?.current?.getHeadings();
|
||||
if (
|
||||
headings &&
|
||||
headings.map((h) => h.level + h.title).join("") !==
|
||||
previousHeadings.current?.map((h) => h.level + h.title).join("")
|
||||
) {
|
||||
previousHeadings.current = headings;
|
||||
onHeadingsChange(headings);
|
||||
}
|
||||
}
|
||||
}, [ref, onHeadingsChange]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
},
|
||||
[onChange, updateHeadings]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node && !previousHeadings.current) {
|
||||
updateHeadings();
|
||||
}
|
||||
},
|
||||
[updateHeadings]
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={ref}
|
||||
ref={mergeRefs([ref, handleRefChanged])}
|
||||
uploadFile={onUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
@@ -229,6 +286,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
|
||||
onHoverLink={handleLinkActive}
|
||||
onClickLink={onClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@ import styled from "styled-components";
|
||||
|
||||
const Empty = styled.p`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Empty;
|
||||
|
||||
@@ -9,8 +9,8 @@ import CenteredContent from "~/components/CenteredContent";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import Logger from "~/utils/logger";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
reloadOnChunkMissing?: boolean;
|
||||
@@ -59,7 +59,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && isHosted;
|
||||
const isReported = !!env.SENTRY_DSN && isCloudHosted;
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
CheckboxIcon,
|
||||
UnpublishIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -85,6 +86,11 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
|
||||
case "documents.unpublish":
|
||||
icon = <UnpublishIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} unpublished", opts);
|
||||
break;
|
||||
|
||||
case "documents.move":
|
||||
icon = <MoveIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
@@ -113,7 +119,10 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
tooltipDelay={500}
|
||||
format="MMM do, h:mm a"
|
||||
format={{
|
||||
en_US: "MMM do, h:mm a",
|
||||
fr_FR: "'Le 'd MMMM 'à' H:mm",
|
||||
}}
|
||||
relative={false}
|
||||
addSuffix
|
||||
onClick={handleTimeClick}
|
||||
|
||||
@@ -11,11 +11,19 @@ const Flex = styled.div<{
|
||||
align?: AlignValues;
|
||||
justify?: JustifyValues;
|
||||
shrink?: boolean;
|
||||
reverse?: boolean;
|
||||
gap?: number;
|
||||
}>`
|
||||
display: flex;
|
||||
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
|
||||
flex-direction: ${({ column }) => (column ? "column" : "row")};
|
||||
flex-direction: ${({ column, reverse }) =>
|
||||
reverse
|
||||
? column
|
||||
? "column-reverse"
|
||||
: "row-reverse"
|
||||
: column
|
||||
? "column"
|
||||
: "row"};
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = RootStore & {
|
||||
membership?: CollectionGroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (arg0: { openMembersModal: () => void }) => React.ReactNode;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LabelText } from "~/components/Input";
|
||||
@@ -200,18 +201,7 @@ export const icons = {
|
||||
keywords: "warning alert error",
|
||||
},
|
||||
};
|
||||
const colors = [
|
||||
"#4E5C6E",
|
||||
"#0366d6",
|
||||
"#9E5CF7",
|
||||
"#FF825C",
|
||||
"#FF5C80",
|
||||
"#FFBE0B",
|
||||
"#42DED1",
|
||||
"#00D084",
|
||||
"#FF4DFA",
|
||||
"#2F362F",
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -272,7 +262,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(color) => onChange(color.hex, icon)}
|
||||
colors={colors}
|
||||
colors={colorPalette}
|
||||
triangle="hide"
|
||||
styles={{
|
||||
default: {
|
||||
|
||||
+36
-29
@@ -38,6 +38,13 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0px 1000px ${(props) => props.theme.background}
|
||||
inset;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
@@ -97,30 +104,17 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
export type Props = React.InputHTMLAttributes<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
> & {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
value?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
labelHidden?: boolean;
|
||||
label?: string;
|
||||
flex?: boolean;
|
||||
short?: boolean;
|
||||
margin?: string | number;
|
||||
icon?: React.ReactNode;
|
||||
name?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
autoComplete?: boolean | string;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange?: (
|
||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
|
||||
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
innerRef?: React.Ref<any>;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
};
|
||||
@@ -163,8 +157,6 @@ class Input extends React.Component<Props> {
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const InputComponent: React.ComponentType =
|
||||
type === "textarea" ? RealTextarea : RealInput;
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
@@ -178,15 +170,24 @@ class Input extends React.Component<Props> {
|
||||
))}
|
||||
<Outline focused={this.focused} margin={margin}>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<InputComponent
|
||||
// @ts-expect-error no idea why this is not working
|
||||
ref={this.input}
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
type={type === "textarea" ? undefined : type}
|
||||
{...rest}
|
||||
/>
|
||||
{type === "textarea" ? (
|
||||
<RealTextarea
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.props.onBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<RealInput
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.props.onBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
type={type}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
@@ -194,4 +195,10 @@ class Input extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export const ReactHookWrappedInput = React.forwardRef(
|
||||
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
|
||||
return <Input {...{ ...props, innerRef: ref }} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default Input;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { loadPolyfills } from "~/utils/polyfills";
|
||||
|
||||
/**
|
||||
* Asyncronously load required polyfills. Should wrap the React tree.
|
||||
*/
|
||||
export const LazyPolyfill: React.FC = ({ children }) => {
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadPolyfills().then(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default LazyPolyfill;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DisconnectedIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Empty from "~/components/Empty";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import { OfflineError } from "~/utils/errors";
|
||||
import ButtonLink from "../ButtonLink";
|
||||
import Flex from "../Flex";
|
||||
|
||||
type Props = {
|
||||
error: Error;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
export default function LoadingError({ error, retry, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
useEventListener("online", retry);
|
||||
|
||||
const message =
|
||||
error instanceof OfflineError ? (
|
||||
<>
|
||||
<DisconnectedIcon color="currentColor" /> {t("You’re offline.")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WarningIcon color="currentColor" /> {t("Sorry, an error occurred.")}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Content {...rest}>
|
||||
<Flex align="center" gap={4}>
|
||||
{message}{" "}
|
||||
<ButtonLink onClick={() => retry()}>{t("Click to retry")}…</ButtonLink>
|
||||
</Flex>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Empty)`
|
||||
padding: 8px 0;
|
||||
white-space: nowrap;
|
||||
|
||||
${ButtonLink} {
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -69,7 +69,11 @@ const ListItem = (
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
|
||||
const Wrapper = styled.a<{
|
||||
$small?: boolean;
|
||||
$border?: boolean;
|
||||
to?: string;
|
||||
}>`
|
||||
display: flex;
|
||||
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
|
||||
margin: ${(props) =>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { format as formatDate, formatDistanceToNow } from "date-fns";
|
||||
import * as React from "react";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateLocale } from "~/utils/i18n";
|
||||
import { dateLocale, locales } from "~/utils/i18n";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
@@ -26,7 +26,7 @@ type Props = {
|
||||
addSuffix?: boolean;
|
||||
shorten?: boolean;
|
||||
relative?: boolean;
|
||||
format?: string;
|
||||
format?: Partial<Record<keyof typeof locales, string>>;
|
||||
};
|
||||
|
||||
const LocaleTime: React.FC<Props> = ({
|
||||
@@ -38,7 +38,13 @@ const LocaleTime: React.FC<Props> = ({
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}) => {
|
||||
const userLocale = useUserLocale();
|
||||
const userLocale: string = useUserLocale() || "";
|
||||
const dateFormatLong = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
@@ -66,17 +72,13 @@ const LocaleTime: React.FC<Props> = ({
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
const tooltipContent = formatDate(
|
||||
Date.parse(dateTime),
|
||||
"MMMM do, yyyy h:mm a",
|
||||
{
|
||||
locale,
|
||||
}
|
||||
);
|
||||
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(Date.parse(dateTime), format || "MMMM do, yyyy h:mm a", {
|
||||
: formatDate(Date.parse(dateTime), formatLocale, {
|
||||
locale,
|
||||
});
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const Modal: React.FC<Props> = ({
|
||||
<Backdrop $isCentered={isCentered} {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hideOnClickOutside={!!isCentered}
|
||||
@@ -75,7 +76,12 @@ const Modal: React.FC<Props> = ({
|
||||
{(props) =>
|
||||
isCentered && !isMobile ? (
|
||||
<Small {...props}>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent shadow>{children}</SmallContent>
|
||||
<Header>
|
||||
{title && (
|
||||
<Text as="span" size="large">
|
||||
@@ -88,7 +94,6 @@ const Modal: React.FC<Props> = ({
|
||||
</NudeButton>
|
||||
</Text>
|
||||
</Header>
|
||||
<SmallContent shadow>{children}</SmallContent>
|
||||
</Centered>
|
||||
</Small>
|
||||
) : (
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import * as React from "react";
|
||||
import { NavLink, Route } from "react-router-dom";
|
||||
import { match, NavLink, Route } from "react-router-dom";
|
||||
|
||||
type Props = React.ComponentProps<typeof NavLink> & {
|
||||
children?: (match: any) => React.ReactNode;
|
||||
children?: (
|
||||
match:
|
||||
| match<{
|
||||
[x: string]: string | undefined;
|
||||
}>
|
||||
| boolean
|
||||
| null
|
||||
) => React.ReactNode;
|
||||
exact?: boolean;
|
||||
activeStyle?: React.CSSProperties;
|
||||
to: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentListItem from "~/components/DocumentListItem";
|
||||
import Error from "~/components/List/Error";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
|
||||
type Props = {
|
||||
@@ -40,6 +41,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderError={(props) => <Error {...props} />}
|
||||
renderItem={(item: Document, _index, compositeProps) => (
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -34,12 +34,19 @@ type Props<T> = WithTranslation &
|
||||
index: number,
|
||||
compositeProps: CompositeStateReturn
|
||||
) => React.ReactNode;
|
||||
renderError?: (options: {
|
||||
error: Error;
|
||||
retry: () => void;
|
||||
}) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
@observer
|
||||
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
@observable
|
||||
error?: Error;
|
||||
|
||||
@observable
|
||||
isFetchingMore = false;
|
||||
|
||||
@@ -80,6 +87,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
|
||||
@action
|
||||
fetchResults = async () => {
|
||||
if (!this.props.fetch) {
|
||||
return;
|
||||
@@ -87,25 +95,30 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
this.isFetching = true;
|
||||
const counter = ++this.fetchCounter;
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
this.error = undefined;
|
||||
|
||||
const results = await this.props.fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
});
|
||||
try {
|
||||
const results = await this.props.fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
});
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += limit;
|
||||
}
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += limit;
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
|
||||
// only the most recent fetch should end the loading state
|
||||
if (counter >= this.fetchCounter) {
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
this.renderCount += limit;
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
// only the most recent fetch should end the loading state
|
||||
if (counter >= this.fetchCounter) {
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -119,7 +132,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
// of lazy rendering then show another page.
|
||||
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
|
||||
|
||||
if (leftToRender > 1) {
|
||||
if (leftToRender > 0) {
|
||||
this.renderCount += DEFAULT_PAGINATION_LIMIT;
|
||||
}
|
||||
|
||||
@@ -138,9 +151,9 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
auth,
|
||||
empty = null,
|
||||
renderHeading,
|
||||
renderError,
|
||||
onEscape,
|
||||
} = this.props;
|
||||
let previousHeading = "";
|
||||
|
||||
const showLoading =
|
||||
this.isFetching &&
|
||||
@@ -158,6 +171,10 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
}
|
||||
|
||||
if (items?.length === 0) {
|
||||
if (this.error && renderError) {
|
||||
return renderError({ error: this.error, retry: this.fetchResults });
|
||||
}
|
||||
|
||||
return empty;
|
||||
}
|
||||
|
||||
@@ -168,8 +185,9 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
aria-label={this.props["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
>
|
||||
{(composite: CompositeStateReturn) =>
|
||||
items.slice(0, this.renderCount).map((item, index) => {
|
||||
{(composite: CompositeStateReturn) => {
|
||||
let previousHeading = "";
|
||||
return items.slice(0, this.renderCount).map((item, index) => {
|
||||
const children = this.props.renderItem(item, index, composite);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
@@ -202,8 +220,8 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
}
|
||||
|
||||
return children;
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
collection: Collection | null | undefined;
|
||||
onSuccess?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
ref?: (arg0: React.ElementRef<"div"> | null | undefined) => void;
|
||||
ref?: (element: React.ElementRef<"div"> | null | undefined) => void;
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -10,6 +10,7 @@ import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import { inviteUser } from "~/actions/definitions/users";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
@@ -34,12 +35,15 @@ function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
React.useEffect(() => {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
}, [documents]);
|
||||
if (!user.isViewer) {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
}
|
||||
}, [documents, user.isViewer]);
|
||||
|
||||
const [dndArea, setDndArea] = React.useState();
|
||||
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
|
||||
|
||||
@@ -8,7 +8,7 @@ import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import Section from "./components/Section";
|
||||
@@ -51,7 +51,7 @@ function SettingsSidebar() {
|
||||
</Header>
|
||||
</Section>
|
||||
))}
|
||||
{!isHosted && (
|
||||
{!isCloudHosted && (
|
||||
<Section>
|
||||
<Header title={t("Installation")} />
|
||||
<Version />
|
||||
|
||||
@@ -65,8 +65,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { NavigationNode } from "~/types";
|
||||
import DropToImport from "./DropToImport";
|
||||
@@ -49,7 +48,6 @@ const CollectionLink: React.FC<Props> = ({
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const inStarredSection = useStarredContext();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (name: string) => {
|
||||
@@ -64,17 +62,16 @@ const CollectionLink: React.FC<Props> = ({
|
||||
// Drop to re-parent document
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
drop: (item: DragObject, monitor) => {
|
||||
const { id, collectionId } = item;
|
||||
const document = documents.get(id);
|
||||
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection || !document) {
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = documents.get(id);
|
||||
if (collection.id === collectionId && !document?.parentDocumentId) {
|
||||
return;
|
||||
}
|
||||
@@ -100,21 +97,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
const undo = document.metaData;
|
||||
await document.move(collection.id);
|
||||
showToast(t("Document moved"), {
|
||||
type: "info",
|
||||
action: {
|
||||
text: "undo",
|
||||
onClick: async () => {
|
||||
await document.move(
|
||||
undo.collectionId,
|
||||
undo.parentDocumentId,
|
||||
undo.index
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
documents.move(id, collection.id);
|
||||
}
|
||||
},
|
||||
canDrop: () => canUpdate,
|
||||
|
||||
@@ -37,7 +37,7 @@ function CollectionLinkChildren({
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort) {
|
||||
if (!manualSort && item.collectionId === collection?.id) {
|
||||
showToast(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
|
||||
@@ -3,12 +3,13 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Error from "~/components/List/Error";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { createCollection } from "~/actions/definitions/collections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import DraggableCollectionLink from "./DraggableCollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
@@ -18,39 +19,10 @@ import SidebarAction from "./SidebarAction";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [fetchError, setFetchError] = React.useState();
|
||||
const { documents, collections } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const isPreloaded = !!collections.orderedData.length;
|
||||
const { t } = useTranslation();
|
||||
const orderedCollections = collections.orderedData;
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
if (!collections.isLoaded && !isFetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(
|
||||
t("Collections could not be loaded, please reload the app"),
|
||||
{
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}, [collections, isFetching, showToast, fetchError, t]);
|
||||
|
||||
const [
|
||||
{ isCollectionDropping, isDraggingAnyCollection },
|
||||
dropToReorderCollection,
|
||||
@@ -71,45 +43,46 @@ function Collections() {
|
||||
}),
|
||||
});
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{isDraggingAnyCollection && (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{orderedCollections.map((collection: Collection, index: number) => (
|
||||
<DraggableCollectionLink
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
))}
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (!collections.isLoaded || fetchError) {
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<PlaceholderCollections />
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</Relative>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Collections")}
|
||||
items={collections.orderedData}
|
||||
fetch={collections.fetchPage}
|
||||
options={{ limit: 100 }}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledError = styled(Error)`
|
||||
font-size: 15px;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
export default observer(Collections);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useDrag, useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
@@ -148,7 +148,7 @@ function InnerDocumentLink(
|
||||
collectionId: collection?.id || "",
|
||||
}),
|
||||
collect: (monitor) => ({
|
||||
isDragging: !!monitor.isDragging(),
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => {
|
||||
return (
|
||||
@@ -178,13 +178,10 @@ function InnerDocumentLink(
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = documents.get(item.id);
|
||||
document?.moveWithUndo(collection.id, node.id);
|
||||
documents.move(item.id, collection.id, node.id);
|
||||
},
|
||||
canDrop: (_item, monitor) =>
|
||||
!isDraft &&
|
||||
@@ -216,7 +213,7 @@ function InnerDocumentLink(
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: !!monitor.isOver({
|
||||
isOverReparent: monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
@@ -247,35 +244,32 @@ function InnerDocumentLink(
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDocumentId = expanded ? node.id : parentId;
|
||||
const droppedDocumentIndex = expanded ? 0 : index + 1;
|
||||
const document = documents.get(item.id);
|
||||
document?.moveWithUndo(
|
||||
collection.id,
|
||||
parentDocumentId,
|
||||
droppedDocumentIndex
|
||||
);
|
||||
if (expanded) {
|
||||
documents.move(item.id, collection.id, node.id, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
documents.move(item.id, collection.id, parentId, index + 1);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
isDraggingAnyDocument: !!monitor.canDrop(),
|
||||
isOverReorder: monitor.isOver(),
|
||||
isDraggingAnyDocument: monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
const nodeChildren = React.useMemo(() => {
|
||||
if (
|
||||
collection &&
|
||||
const insertDraftDocument =
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.parentDocumentId === node.id
|
||||
) {
|
||||
return sortNavigationNodes(
|
||||
[activeDocument?.asNavigationNode, ...node.children],
|
||||
collection.sort
|
||||
);
|
||||
}
|
||||
activeDocument?.parentDocumentId === node.id;
|
||||
|
||||
return node.children;
|
||||
return collection && insertDraftDocument
|
||||
? sortNavigationNodes(
|
||||
[activeDocument?.asNavigationNode, ...node.children],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: node.children;
|
||||
}, [
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
@@ -325,7 +319,7 @@ function InnerDocumentLink(
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={handleTitleEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
/>
|
||||
}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This file is pulled almost 100% from react-router with the addition of one
|
||||
// thing, automatic scroll to the active link. It's worth the copy paste because
|
||||
// it avoids recalculating the link match again.
|
||||
import { Location, createLocation } from "history";
|
||||
import { Location, createLocation, LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import {
|
||||
__RouterContext as RouterContext,
|
||||
@@ -13,12 +13,12 @@ import { Link } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
|
||||
const resolveToLocation = (
|
||||
to: string | Record<string, any>,
|
||||
to: LocationDescriptor | ((location: Location) => LocationDescriptor),
|
||||
currentLocation: Location
|
||||
) => (typeof to === "function" ? to(currentLocation) : to);
|
||||
|
||||
const normalizeToLocation = (
|
||||
to: string | Record<string, any>,
|
||||
to: LocationDescriptor,
|
||||
currentLocation: Location
|
||||
) => {
|
||||
return typeof to === "string"
|
||||
@@ -30,17 +30,15 @@ const joinClassnames = (...classnames: (string | undefined)[]) => {
|
||||
return classnames.filter((i) => i).join(" ");
|
||||
};
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLAnchorElement> & {
|
||||
export type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
activeClassName?: string;
|
||||
activeStyle?: React.CSSProperties;
|
||||
className?: string;
|
||||
scrollIntoViewIfNeeded?: boolean;
|
||||
exact?: boolean;
|
||||
isActive?: (match: match | null, location: Location) => boolean;
|
||||
location?: Location;
|
||||
strict?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
to: string | Record<string, any>;
|
||||
to: LocationDescriptor;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -14,8 +15,7 @@ export type DragObject = NavigationNode & {
|
||||
};
|
||||
|
||||
type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: string | Record<string, any>;
|
||||
href?: string | Record<string, any>;
|
||||
to?: LocationDescriptor;
|
||||
innerRef?: (ref: HTMLElement | null | undefined) => void;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Badge from "~/components/Badge";
|
||||
import { version } from "../../../../package.json";
|
||||
@@ -6,6 +7,7 @@ import SidebarLink from "./SidebarLink";
|
||||
|
||||
export default function Version() {
|
||||
const [releasesBehind, setReleasesBehind] = React.useState(0);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function loadReleases() {
|
||||
@@ -30,6 +32,7 @@ export default function Version() {
|
||||
|
||||
return (
|
||||
<SidebarLink
|
||||
target="_blank"
|
||||
href="https://github.com/outline/outline/releases"
|
||||
label={
|
||||
<>
|
||||
@@ -37,10 +40,11 @@ export default function Version() {
|
||||
<br />
|
||||
<LilBadge>
|
||||
{releasesBehind === 0
|
||||
? "Up to date"
|
||||
: `${releasesBehind} version${
|
||||
releasesBehind === 1 ? "" : "s"
|
||||
} behind`}
|
||||
? t("Up to date")
|
||||
: t(`{{ releasesBehind }} versions behind`, {
|
||||
releasesBehind,
|
||||
count: releasesBehind,
|
||||
})}
|
||||
</LilBadge>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -12,19 +12,19 @@ export default function useCollectionDocuments(
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
const insertDraftDocument =
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.collectionId === collection.id &&
|
||||
!activeDocument?.parentDocumentId
|
||||
) {
|
||||
return sortNavigationNodes(
|
||||
[activeDocument.asNavigationNode, ...collection.documents],
|
||||
collection.sort
|
||||
);
|
||||
}
|
||||
!activeDocument?.parentDocumentId;
|
||||
|
||||
return collection.documents;
|
||||
return insertDraftDocument
|
||||
? sortNavigationNodes(
|
||||
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: collection.sortedDocuments;
|
||||
}, [
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
@@ -32,7 +32,7 @@ export default function useCollectionDocuments(
|
||||
activeDocument?.parentDocumentId,
|
||||
activeDocument?.asNavigationNode,
|
||||
collection,
|
||||
collection?.documents,
|
||||
collection?.sortedDocuments,
|
||||
collection?.id,
|
||||
collection?.sort,
|
||||
]);
|
||||
|
||||
@@ -3,22 +3,17 @@ import { find } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import io from "socket.io-client";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import withStores from "~/components/withStores";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
|
||||
|
||||
type SocketWithAuthentication = {
|
||||
type SocketWithAuthentication = Socket & {
|
||||
authenticated?: boolean;
|
||||
disconnected: boolean;
|
||||
disconnect: () => void;
|
||||
close: () => void;
|
||||
on: (event: string, callback: (data: any) => void) => void;
|
||||
emit: (event: string, data: any) => void;
|
||||
io: any;
|
||||
};
|
||||
|
||||
export const SocketContext: any = React.createContext<SocketWithAuthentication | null>(
|
||||
export const SocketContext = React.createContext<SocketWithAuthentication | null>(
|
||||
null
|
||||
);
|
||||
|
||||
@@ -98,7 +93,7 @@ class SocketProvider extends React.Component<Props> {
|
||||
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
this.socket.on("reconnect_attempt", () => {
|
||||
this.socket.io.on("reconnect_attempt", () => {
|
||||
if (this.socket) {
|
||||
this.socket.io.opts.transports = auth?.team?.domain
|
||||
? ["websocket"]
|
||||
@@ -154,7 +149,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.remove(documentId);
|
||||
return;
|
||||
}
|
||||
@@ -216,7 +214,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
@@ -245,7 +246,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
groups.remove(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import NavLinkWithChildrenFunc from "~/components/NavLink";
|
||||
import NavLink from "~/components/NavLink";
|
||||
|
||||
type Props = Omit<
|
||||
React.ComponentProps<typeof NavLinkWithChildrenFunc>,
|
||||
"children"
|
||||
> & {
|
||||
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
|
||||
to: string;
|
||||
exact?: boolean;
|
||||
};
|
||||
|
||||
const TabLink = styled(NavLinkWithChildrenFunc)`
|
||||
const TabLink = styled(NavLink)`
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
@@ -72,4 +73,4 @@ const TableFromParams = (
|
||||
);
|
||||
};
|
||||
|
||||
export default TableFromParams;
|
||||
export default observer(TableFromParams);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
|
||||
import { darken, lighten } from "polished";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import { fadeAndScaleIn, pulse } from "~/styles/animations";
|
||||
@@ -69,17 +69,17 @@ function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
|
||||
|
||||
const Action = styled.span`
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
margin-left: 8px;
|
||||
padding: 10px 12px;
|
||||
height: 100%;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.toastText};
|
||||
background: ${(props) => darken(0.05, props.theme.toastBackground)};
|
||||
border-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => lighten(0.1, props.theme.toastBackground)};
|
||||
background: ${(props) => darken(0.1, props.theme.toastBackground)};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,15 +6,13 @@ import useStores from "~/hooks/useStores";
|
||||
type StoreProps = keyof RootStore;
|
||||
|
||||
function withStores<
|
||||
P extends React.ComponentType<React.ComponentProps<P> & RootStore>,
|
||||
P extends React.ComponentType<ResolvedProps & RootStore>,
|
||||
ResolvedProps = JSX.LibraryManagedAttributes<
|
||||
P,
|
||||
Omit<React.ComponentProps<P>, StoreProps>
|
||||
>
|
||||
>(WrappedComponent: P): React.FC<Omit<ResolvedProps, StoreProps>> {
|
||||
const ComponentWithStore = (
|
||||
props: Omit<React.ComponentProps<P>, StoreProps>
|
||||
) => {
|
||||
>(WrappedComponent: P): React.FC<ResolvedProps> {
|
||||
const ComponentWithStore = (props: ResolvedProps) => {
|
||||
const stores = useStores();
|
||||
return <WrappedComponent {...(props as any)} {...stores} />;
|
||||
};
|
||||
|
||||
@@ -11,8 +11,8 @@ import { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
|
||||
import { depths } from "@shared/styles";
|
||||
import { supportedImageMimeTypes } from "@shared/utils/files";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Input from "./Input";
|
||||
@@ -36,7 +36,7 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
onFileUploadStop?: () => void;
|
||||
onShowToast: (message: string) => void;
|
||||
onLinkToolbarOpen?: () => void;
|
||||
onClose: () => void;
|
||||
onClose: (insertNewLine?: boolean) => void;
|
||||
onClearSearch: () => void;
|
||||
embeds?: EmbedDescriptor[];
|
||||
renderMenuItem: (
|
||||
@@ -123,7 +123,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
if (item) {
|
||||
this.insertItem(item);
|
||||
} else {
|
||||
this.props.onClose();
|
||||
this.props.onClose(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,9 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
insertItem = (item: any) => {
|
||||
switch (item.name) {
|
||||
case "image":
|
||||
return this.triggerFilePick(supportedImageMimeTypes.join(", "));
|
||||
return this.triggerFilePick(
|
||||
AttachmentValidation.imageContentTypes.join(", ")
|
||||
);
|
||||
case "attachment":
|
||||
return this.triggerFilePick("*");
|
||||
case "embed":
|
||||
@@ -275,7 +277,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
};
|
||||
|
||||
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = getDataTransferFiles(event);
|
||||
const files = getEventFiles(event);
|
||||
|
||||
const {
|
||||
view,
|
||||
@@ -424,7 +426,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
const embedItems: EmbedDescriptor[] = [];
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title && embed.icon) {
|
||||
if (embed.title) {
|
||||
embedItems.push({
|
||||
...embed,
|
||||
name: "embed",
|
||||
|
||||
@@ -21,7 +21,7 @@ const searcher = new FuzzySearch<{
|
||||
sort: true,
|
||||
});
|
||||
|
||||
class EmojiMenu extends React.Component<
|
||||
class EmojiMenu extends React.PureComponent<
|
||||
Omit<
|
||||
Props<Emoji>,
|
||||
| "renderMenuItem"
|
||||
|
||||
@@ -11,8 +11,7 @@ import { setTextSelection } from "prosemirror-utils";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import isUrl from "@shared/editor/lib/isUrl";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { isInternalUrl, sanitizeHref } from "@shared/utils/urls";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ToastOptions } from "~/types";
|
||||
@@ -45,7 +44,7 @@ type Props = {
|
||||
href: string,
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onShowToast: (message: string, options: ToastOptions) => void;
|
||||
onShowToast: (message: string, options?: ToastOptions) => void;
|
||||
view: EditorView;
|
||||
};
|
||||
|
||||
@@ -71,7 +70,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
get href(): string {
|
||||
return this.props.mark ? this.props.mark.attrs.href : "";
|
||||
return sanitizeHref(this.props.mark?.attrs.href) ?? "";
|
||||
}
|
||||
|
||||
get suggestedLinkTitle(): string {
|
||||
@@ -114,17 +113,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
|
||||
this.discardInputValue = true;
|
||||
const { from, to } = this.props;
|
||||
|
||||
// Make sure a protocol is added to the beginning of the input if it's
|
||||
// likely an absolute URL that was entered without one.
|
||||
if (
|
||||
!isUrl(href) &&
|
||||
!href.startsWith("/") &&
|
||||
!href.startsWith("#") &&
|
||||
!href.startsWith("mailto:")
|
||||
) {
|
||||
href = `https://${href}`;
|
||||
}
|
||||
href = sanitizeHref(href) ?? "";
|
||||
|
||||
this.props.onSelectLink({ href, title, from, to });
|
||||
};
|
||||
@@ -240,7 +229,12 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
|
||||
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.preventDefault();
|
||||
this.props.onClickLink(this.href, event);
|
||||
|
||||
try {
|
||||
this.props.onClickLink(this.href, event);
|
||||
} catch (err) {
|
||||
this.props.onShowToast(this.props.dictionary.openLinkError);
|
||||
}
|
||||
};
|
||||
|
||||
handleCreateLink = async (value: string) => {
|
||||
|
||||
@@ -42,7 +42,7 @@ type Props = {
|
||||
|
||||
function isVisible(props: Props) {
|
||||
const { view } = props;
|
||||
const { selection } = view.state;
|
||||
const { selection, doc } = view.state;
|
||||
|
||||
if (isMarkActive(view.state.schema.marks.link)(view.state)) {
|
||||
return true;
|
||||
@@ -63,6 +63,11 @@ function isVisible(props: Props) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectionText = doc.cut(selection.from, selection.to).textContent;
|
||||
if (selection instanceof TextSelection && !selectionText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const slice = selection.content();
|
||||
const fragment = slice.content;
|
||||
const nodes = (fragment as any).content;
|
||||
@@ -192,7 +197,6 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
const link = isMarkActive(state.schema.marks.link)(state);
|
||||
const range = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection = selection.node?.type?.name === "image";
|
||||
let isTextSelection = false;
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
if (isTableSelection) {
|
||||
@@ -207,7 +211,6 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
isTextSelection = true;
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
@@ -226,15 +229,6 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectionText = state.doc.cut(
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
).textContent;
|
||||
|
||||
if (isTextSelection && !selectionText && !link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
view={view}
|
||||
|
||||
+103
-15
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
import { lighten } from "polished";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
|
||||
const EditorStyles = styled.div<{
|
||||
rtl: boolean;
|
||||
@@ -359,6 +360,7 @@ const EditorStyles = styled.div<{
|
||||
|
||||
.heading-actions {
|
||||
opacity: 0;
|
||||
z-index: ${depths.editorHeadingActions};
|
||||
background: ${(props) => props.theme.background};
|
||||
margin-${(props) => (props.rtl ? "right" : "left")}: -26px;
|
||||
flex-direction: ${(props) => (props.rtl ? "row-reverse" : "row")};
|
||||
@@ -403,7 +405,10 @@ const EditorStyles = styled.div<{
|
||||
padding: 0;
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
|
||||
svg {
|
||||
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
|
||||
pointer-events: none;
|
||||
}
|
||||
transition-delay: 0.1s;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -429,10 +434,12 @@ const EditorStyles = styled.div<{
|
||||
.notice-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.noticeInfoBackground};
|
||||
background: ${(props) =>
|
||||
transparentize(0.9, props.theme.noticeInfoBackground)};
|
||||
border-left: 4px solid ${(props) => props.theme.noticeInfoBackground};
|
||||
color: ${(props) => props.theme.noticeInfoText};
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
padding: 8px 10px 8px 8px;
|
||||
margin: 8px 0;
|
||||
|
||||
a {
|
||||
@@ -462,21 +469,34 @@ const EditorStyles = styled.div<{
|
||||
height: 24px;
|
||||
align-self: flex-start;
|
||||
margin-${(props) => (props.rtl ? "left" : "right")}: 4px;
|
||||
color: ${(props) => props.theme.noticeInfoBackground};
|
||||
}
|
||||
|
||||
.notice-block.tip {
|
||||
background: ${(props) => props.theme.noticeTipBackground};
|
||||
background: ${(props) =>
|
||||
transparentize(0.9, props.theme.noticeTipBackground)};
|
||||
border-left: 4px solid ${(props) => props.theme.noticeTipBackground};
|
||||
color: ${(props) => props.theme.noticeTipText};
|
||||
|
||||
.icon {
|
||||
color: ${(props) => props.theme.noticeTipBackground};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.noticeTipText};
|
||||
}
|
||||
}
|
||||
|
||||
.notice-block.warning {
|
||||
background: ${(props) => props.theme.noticeWarningBackground};
|
||||
background: ${(props) =>
|
||||
transparentize(0.9, props.theme.noticeWarningBackground)};
|
||||
border-left: 4px solid ${(props) => props.theme.noticeWarningBackground};
|
||||
color: ${(props) => props.theme.noticeWarningText};
|
||||
|
||||
.icon {
|
||||
color: ${(props) => props.theme.noticeWarningBackground};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.noticeWarningText};
|
||||
}
|
||||
@@ -756,20 +776,45 @@ const EditorStyles = styled.div<{
|
||||
|
||||
select,
|
||||
button {
|
||||
background: ${(props) => props.theme.background};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-width: 1px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
color: ${(props) => props.theme.buttonNeutralText};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${(props) =>
|
||||
props.theme.buttonNeutralBorder} 0 0 0 1px inset;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
height: 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
appearance: none !important;
|
||||
padding: 6px 8px;
|
||||
display: none;
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) =>
|
||||
darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${(props) =>
|
||||
props.theme.buttonNeutralBorder} 0 0 0 1px inset;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 2px 4px;
|
||||
select {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" d="M9.03087 9C8.20119 9 7.73238 9.95209 8.23824 10.6097L11.2074 14.4696C11.6077 14.99 12.3923 14.99 12.7926 14.4696L15.7618 10.6097C16.2676 9.95209 15.7988 9 14.9691 9L9.03087 9Z" fill="currentColor"/> </svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center right;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
&:focus-within,
|
||||
&:hover {
|
||||
select {
|
||||
display: ${(props) => (props.readOnly ? "none" : "inline")};
|
||||
@@ -786,6 +831,49 @@ const EditorStyles = styled.div<{
|
||||
button:active {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
button.show-source-button {
|
||||
display: none;
|
||||
}
|
||||
button.show-diagram-button {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
&.code-hidden {
|
||||
button,
|
||||
select,
|
||||
button.show-diagram-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button.show-source-button {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-diagram-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${(props) => props.theme.codeBackground};
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${(props) => props.theme.codeBorder};
|
||||
padding: 8px;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
|
||||
* {
|
||||
font-family: ${(props) => props.theme.fontFamily};
|
||||
}
|
||||
|
||||
&.diagram-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
|
||||
+16
-31
@@ -18,7 +18,8 @@ import * as React from "react";
|
||||
import { DefaultTheme, ThemeProps } from "styled-components";
|
||||
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import headingToSlug from "@shared/editor/lib/headingToSlug";
|
||||
import getHeadings from "@shared/editor/lib/getHeadings";
|
||||
import getTasks from "@shared/editor/lib/getTasks";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
@@ -28,7 +29,7 @@ import { EmbedDescriptor, EventType } from "@shared/editor/types";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/logger";
|
||||
import Logger from "~/utils/Logger";
|
||||
import BlockMenu from "./components/BlockMenu";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
@@ -471,7 +472,7 @@ export class Editor extends React.PureComponent<
|
||||
try {
|
||||
const element = document.querySelector(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
setTimeout(() => element.scrollIntoView({ behavior: "smooth" }), 0);
|
||||
}
|
||||
} catch (err) {
|
||||
// querySelector will throw an error if the hash begins with a number
|
||||
@@ -553,7 +554,14 @@ export class Editor extends React.PureComponent<
|
||||
this.setState({ blockMenuOpen: true, blockMenuSearch: search });
|
||||
};
|
||||
|
||||
private handleCloseBlockMenu = () => {
|
||||
private handleCloseBlockMenu = (insertNewLine?: boolean) => {
|
||||
if (insertNewLine) {
|
||||
const transaction = this.view.state.tr.split(
|
||||
this.view.state.selection.to
|
||||
);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
}
|
||||
if (!this.state.blockMenuOpen) {
|
||||
return;
|
||||
}
|
||||
@@ -575,34 +583,11 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
public getHeadings = () => {
|
||||
const headings: { title: string; level: number; id: string }[] = [];
|
||||
const previouslySeen = {};
|
||||
return getHeadings(this.view.state.doc);
|
||||
};
|
||||
|
||||
this.view.state.doc.forEach((node) => {
|
||||
if (node.type.name === "heading") {
|
||||
// calculate the optimal slug
|
||||
const slug = headingToSlug(node);
|
||||
let id = slug;
|
||||
|
||||
// check if we've already used it, and if so how many times?
|
||||
// Make the new id based on that number ensuring that we have
|
||||
// unique ID's even when headings are identical
|
||||
if (previouslySeen[slug] > 0) {
|
||||
id = headingToSlug(node, previouslySeen[slug]);
|
||||
}
|
||||
|
||||
// record that we've seen this slug for the next loop
|
||||
previouslySeen[slug] =
|
||||
previouslySeen[slug] !== undefined ? previouslySeen[slug] + 1 : 1;
|
||||
|
||||
headings.push({
|
||||
title: node.textContent,
|
||||
level: node.attrs.level,
|
||||
id,
|
||||
});
|
||||
}
|
||||
});
|
||||
return headings;
|
||||
public getTasks = () => {
|
||||
return getTasks(this.view.state.doc);
|
||||
};
|
||||
|
||||
public render() {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { isInTable } from "prosemirror-tables";
|
||||
import isInCode from "@shared/editor/queries/isInCode";
|
||||
import isInList from "@shared/editor/queries/isInList";
|
||||
import isMarkActive from "@shared/editor/queries/isMarkActive";
|
||||
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||
@@ -28,6 +29,7 @@ export default function formattingMenuItems(
|
||||
const { schema } = state;
|
||||
const isTable = isInTable(state);
|
||||
const isList = isInList(state);
|
||||
const isCode = isInCode(state);
|
||||
const allowBlocks = !isTable && !isList;
|
||||
|
||||
return [
|
||||
@@ -47,19 +49,21 @@ export default function formattingMenuItems(
|
||||
tooltip: dictionary.strong,
|
||||
icon: BoldIcon,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCode,
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
tooltip: dictionary.strikethrough,
|
||||
icon: StrikethroughIcon,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCode,
|
||||
},
|
||||
{
|
||||
name: "highlight",
|
||||
tooltip: dictionary.mark,
|
||||
icon: HighlightIcon,
|
||||
active: isMarkActive(schema.marks.highlight),
|
||||
visible: !isTemplate,
|
||||
visible: !isTemplate && !isCode,
|
||||
},
|
||||
{
|
||||
name: "code_inline",
|
||||
@@ -69,7 +73,7 @@ export default function formattingMenuItems(
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: allowBlocks,
|
||||
visible: allowBlocks && !isCode,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
@@ -77,7 +81,7 @@ export default function formattingMenuItems(
|
||||
icon: Heading1Icon,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 1 }),
|
||||
attrs: { level: 1 },
|
||||
visible: allowBlocks,
|
||||
visible: allowBlocks && !isCode,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
@@ -85,7 +89,7 @@ export default function formattingMenuItems(
|
||||
icon: Heading2Icon,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 2 }),
|
||||
attrs: { level: 2 },
|
||||
visible: allowBlocks,
|
||||
visible: allowBlocks && !isCode,
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
@@ -93,11 +97,11 @@ export default function formattingMenuItems(
|
||||
icon: BlockQuoteIcon,
|
||||
active: isNodeActive(schema.nodes.blockquote),
|
||||
attrs: { level: 2 },
|
||||
visible: allowBlocks,
|
||||
visible: allowBlocks && !isCode,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: allowBlocks || isList,
|
||||
visible: (allowBlocks || isList) && !isCode,
|
||||
},
|
||||
{
|
||||
name: "checkbox_list",
|
||||
@@ -105,24 +109,25 @@ export default function formattingMenuItems(
|
||||
icon: TodoListIcon,
|
||||
keywords: "checklist checkbox task",
|
||||
active: isNodeActive(schema.nodes.checkbox_list),
|
||||
visible: allowBlocks || isList,
|
||||
visible: (allowBlocks || isList) && !isCode,
|
||||
},
|
||||
{
|
||||
name: "bullet_list",
|
||||
tooltip: dictionary.bulletList,
|
||||
icon: BulletedListIcon,
|
||||
active: isNodeActive(schema.nodes.bullet_list),
|
||||
visible: allowBlocks || isList,
|
||||
visible: (allowBlocks || isList) && !isCode,
|
||||
},
|
||||
{
|
||||
name: "ordered_list",
|
||||
tooltip: dictionary.orderedList,
|
||||
icon: OrderedListIcon,
|
||||
active: isNodeActive(schema.nodes.ordered_list),
|
||||
visible: allowBlocks || isList,
|
||||
visible: (allowBlocks || isList) && !isCode,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: !isCode,
|
||||
},
|
||||
{
|
||||
name: "link",
|
||||
@@ -130,6 +135,7 @@ export default function formattingMenuItems(
|
||||
icon: LinkIcon,
|
||||
active: isMarkActive(schema.marks.link),
|
||||
attrs: { href: "" },
|
||||
visible: !isCode,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
DownloadIcon,
|
||||
WebhooksIcon,
|
||||
} from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,11 +26,12 @@ import Security from "~/scenes/Settings/Security";
|
||||
import Shares from "~/scenes/Settings/Shares";
|
||||
import Slack from "~/scenes/Settings/Slack";
|
||||
import Tokens from "~/scenes/Settings/Tokens";
|
||||
import Webhooks from "~/scenes/Settings/Webhooks";
|
||||
import Zapier from "~/scenes/Settings/Zapier";
|
||||
import SlackIcon from "~/components/SlackIcon";
|
||||
import ZapierIcon from "~/components/ZapierIcon";
|
||||
import env from "~/env";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import usePolicy from "./usePolicy";
|
||||
|
||||
@@ -46,6 +48,7 @@ type SettingsPage =
|
||||
| "Shares"
|
||||
| "Import"
|
||||
| "Export"
|
||||
| "Webhooks"
|
||||
| "Slack"
|
||||
| "Zapier";
|
||||
|
||||
@@ -146,7 +149,7 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: t("Import"),
|
||||
path: "/settings/import",
|
||||
component: Import,
|
||||
enabled: can.manage,
|
||||
enabled: can.createImport,
|
||||
group: t("Team"),
|
||||
icon: NewDocumentIcon,
|
||||
},
|
||||
@@ -154,16 +157,24 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: t("Export"),
|
||||
path: "/settings/export",
|
||||
component: Export,
|
||||
enabled: can.export,
|
||||
enabled: can.createExport,
|
||||
group: t("Team"),
|
||||
icon: DownloadIcon,
|
||||
},
|
||||
// Intergrations
|
||||
// Integrations
|
||||
Webhooks: {
|
||||
name: t("Webhooks"),
|
||||
path: "/settings/webhooks",
|
||||
component: Webhooks,
|
||||
enabled: can.createWebhookSubscription,
|
||||
group: t("Integrations"),
|
||||
icon: WebhooksIcon,
|
||||
},
|
||||
Slack: {
|
||||
name: "Slack",
|
||||
path: "/settings/integrations/slack",
|
||||
component: Slack,
|
||||
enabled: can.update && (!!env.SLACK_KEY || isHosted),
|
||||
enabled: can.update && (!!env.SLACK_CLIENT_ID || isCloudHosted),
|
||||
group: t("Integrations"),
|
||||
icon: SlackIcon,
|
||||
},
|
||||
@@ -171,12 +182,19 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: "Zapier",
|
||||
path: "/settings/integrations/zapier",
|
||||
component: Zapier,
|
||||
enabled: can.update && isHosted,
|
||||
enabled: can.update && isCloudHosted,
|
||||
group: t("Integrations"),
|
||||
icon: ZapierIcon,
|
||||
},
|
||||
}),
|
||||
[can.createApiKey, can.export, can.manage, can.update, t]
|
||||
[
|
||||
can.createApiKey,
|
||||
can.createWebhookSubscription,
|
||||
can.createExport,
|
||||
can.createImport,
|
||||
can.update,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
const enabledConfigs = React.useMemo(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
export default function useDebouncedCallback(
|
||||
callback: (arg0: any) => unknown,
|
||||
export default function useDebouncedCallback<T>(
|
||||
callback: (...params: T[]) => unknown,
|
||||
wait: number
|
||||
) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = React.useRef();
|
||||
const argsRef = React.useRef<T[]>();
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
function cleanup() {
|
||||
@@ -16,12 +16,11 @@ export default function useDebouncedCallback(
|
||||
|
||||
// make sure our timeout gets cleared if consuming component gets unmounted
|
||||
React.useEffect(() => cleanup, []);
|
||||
return function (...args: any) {
|
||||
return function (...args: T[]) {
|
||||
argsRef.current = args;
|
||||
cleanup();
|
||||
timeout.current = setTimeout(() => {
|
||||
if (argsRef.current) {
|
||||
// @ts-expect-error ts-migrate(2556) FIXME: Expected 1 arguments, but got 0 or more.
|
||||
callback(...argsRef.current);
|
||||
}
|
||||
}, wait);
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function useDictionary() {
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
copy: t("Copy"),
|
||||
createLink: t("Create link"),
|
||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||
createNewDoc: t("Create a new doc"),
|
||||
@@ -52,6 +53,7 @@ export default function useDictionary() {
|
||||
noResults: t("No results"),
|
||||
openLink: t("Open link"),
|
||||
goToLink: t("Go to link"),
|
||||
openLinkError: t("Sorry, that type of link is not supported"),
|
||||
orderedList: t("Ordered list"),
|
||||
pageBreak: t("Page break"),
|
||||
pasteLink: `${t("Paste a link")}…`,
|
||||
@@ -69,6 +71,8 @@ export default function useDictionary() {
|
||||
table: t("Table"),
|
||||
tip: t("Tip"),
|
||||
tipNotice: t("Tip notice"),
|
||||
showDiagram: t("Show diagram"),
|
||||
showSource: t("Show source"),
|
||||
warning: t("Warning"),
|
||||
warningNotice: t("Warning notice"),
|
||||
insertDate: t("Current date"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Storage from "~/utils/Storage";
|
||||
import Logger from "~/utils/logger";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
/**
|
||||
|
||||
+16
-13
@@ -15,9 +15,10 @@ import ScrollToTop from "~/components/ScrollToTop";
|
||||
import Theme from "~/components/Theme";
|
||||
import Toasts from "~/components/Toasts";
|
||||
import env from "~/env";
|
||||
import LazyPolyfill from "./components/LazyPolyfills";
|
||||
import Routes from "./routes";
|
||||
import Logger from "./utils/Logger";
|
||||
import history from "./utils/history";
|
||||
import Logger from "./utils/logger";
|
||||
import { initSentry } from "./utils/sentry";
|
||||
|
||||
initI18n();
|
||||
@@ -75,18 +76,20 @@ if (element) {
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
</>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
</>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
|
||||
@@ -16,16 +16,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionDelete from "~/scenes/CollectionDelete";
|
||||
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||
import CollectionExport from "~/scenes/CollectionExport";
|
||||
import CollectionPermissions from "~/scenes/CollectionPermissions";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ContextMenu, { Placement } from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import Modal from "~/components/Modal";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -54,27 +53,43 @@ function CollectionMenu({
|
||||
modal,
|
||||
placement,
|
||||
});
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const team = useCurrentTeam();
|
||||
const { documents, dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
const [
|
||||
showCollectionPermissions,
|
||||
setShowCollectionPermissions,
|
||||
] = React.useState(false);
|
||||
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
|
||||
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
|
||||
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setRenderModals(true);
|
||||
const handlePermissions = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Collection permissions"),
|
||||
content: <CollectionPermissions collection={collection} />,
|
||||
});
|
||||
}, [collection, dialogs, t]);
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}, [onOpen]);
|
||||
const handleEdit = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Edit collection"),
|
||||
content: (
|
||||
<CollectionEdit
|
||||
collectionId={collection.id}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [collection.id, dialogs, t]);
|
||||
|
||||
const handleExport = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Export collection"),
|
||||
content: (
|
||||
<CollectionExport
|
||||
collection={collection}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [collection, dialogs, t]);
|
||||
|
||||
const handleNewDocument = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
@@ -102,8 +117,8 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const handleFilePicked = React.useCallback(
|
||||
async (ev: React.FormEvent<HTMLInputElement>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
// Because this is the onChange handler it's possible for the change to be
|
||||
// from previously selecting a file to not selecting a file – aka empty
|
||||
@@ -145,7 +160,7 @@ function CollectionMenu({
|
||||
isCentered: true,
|
||||
title: t("Delete collection"),
|
||||
content: (
|
||||
<CollectionDelete
|
||||
<CollectionDeleteDialog
|
||||
collection={collection}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
@@ -238,21 +253,21 @@ function CollectionMenu({
|
||||
type: "button",
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
onClick: handleEdit,
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: () => setShowCollectionPermissions(true),
|
||||
onClick: handlePermissions,
|
||||
icon: <PadlockIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && canUserInTeam.export),
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
visible: !!(collection && canUserInTeam.createExport),
|
||||
onClick: handleExport,
|
||||
icon: <ExportIcon />,
|
||||
},
|
||||
{
|
||||
@@ -269,19 +284,22 @@ function CollectionMenu({
|
||||
],
|
||||
[
|
||||
t,
|
||||
handleUnstar,
|
||||
collection,
|
||||
can.unstar,
|
||||
can.star,
|
||||
can.update,
|
||||
can.delete,
|
||||
can.star,
|
||||
can.unstar,
|
||||
handleStar,
|
||||
handleUnstar,
|
||||
alphabeticalSort,
|
||||
handleChangeSort,
|
||||
handleNewDocument,
|
||||
handleImportDocument,
|
||||
alphabeticalSort,
|
||||
handleEdit,
|
||||
handlePermissions,
|
||||
canUserInTeam.createExport,
|
||||
handleExport,
|
||||
handleDelete,
|
||||
collection,
|
||||
canUserInTeam.export,
|
||||
handleChangeSort,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -311,43 +329,12 @@ function CollectionMenu({
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
onOpen={handleOpen}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
aria-label={t("Collection")}
|
||||
>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={() => setShowCollectionPermissions(false)}
|
||||
isOpen={showCollectionPermissions}
|
||||
>
|
||||
<CollectionPermissions collection={collection} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
isOpen={showCollectionEdit}
|
||||
onRequestClose={() => setShowCollectionEdit(false)}
|
||||
>
|
||||
<CollectionEdit
|
||||
onSubmit={() => setShowCollectionEdit(false)}
|
||||
collectionId={collection.id}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Export collection")}
|
||||
isOpen={showCollectionExport}
|
||||
onRequestClose={() => setShowCollectionExport(false)}
|
||||
>
|
||||
<CollectionExport
|
||||
onSubmit={() => setShowCollectionExport(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
@@ -219,8 +219,8 @@ function DocumentMenu({
|
||||
);
|
||||
|
||||
const handleFilePicked = React.useCallback(
|
||||
async (ev: React.FormEvent<HTMLInputElement>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
// Because this is the onChange handler it's possible for the change to be
|
||||
// from previously selecting a file to not selecting a file – aka empty
|
||||
@@ -447,7 +447,7 @@ function DocumentMenu({
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showDisplayOptions && !isMobile && (
|
||||
{showDisplayOptions && !isMobile && can.update && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
|
||||
@@ -108,9 +108,7 @@ function UserMenu({ user }: Props) {
|
||||
const handleRevoke = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
users.delete(user, {
|
||||
confirmation: true,
|
||||
});
|
||||
users.delete(user);
|
||||
},
|
||||
[users, user]
|
||||
);
|
||||
|
||||
@@ -20,7 +20,10 @@ export default abstract class BaseModel {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
save = async (params?: Record<string, any>) => {
|
||||
save = async (
|
||||
params?: Record<string, any>,
|
||||
options?: Record<string, string | boolean | number | undefined>
|
||||
) => {
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
@@ -29,7 +32,7 @@ export default abstract class BaseModel {
|
||||
params = this.toAPI();
|
||||
}
|
||||
|
||||
const model = await this.store.save({ ...params, id: this.id });
|
||||
const model = await this.store.save({ ...params, id: this.id }, options);
|
||||
|
||||
// if saving is successful set the new values on the model itself
|
||||
set(this, { ...params, ...model });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { trim } from "lodash";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import CollectionsStore from "~/stores/CollectionsStore";
|
||||
import Document from "~/models/Document";
|
||||
import ParanoidModel from "~/models/ParanoidModel";
|
||||
@@ -95,6 +96,11 @@ export default class Collection extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get sortedDocuments() {
|
||||
return sortNavigationNodes(this.documents, this.sort);
|
||||
}
|
||||
|
||||
@action
|
||||
updateDocument(document: Document) {
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
@@ -130,35 +136,12 @@ export default class Collection extends ParanoidModel {
|
||||
};
|
||||
|
||||
if (this.documents) {
|
||||
travelNodes(this.documents);
|
||||
travelNodes(this.sortedDocuments);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
documentIndexInCollection(documentId: string) {
|
||||
let index: number | undefined;
|
||||
const findIndex = (nodes: NavigationNode[]) => {
|
||||
if (index) {
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach((node, i) => {
|
||||
if (node.id === documentId) {
|
||||
index = i;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
nodes.forEach((node) => {
|
||||
findIndex(node.children);
|
||||
});
|
||||
};
|
||||
|
||||
findIndex(this.documents);
|
||||
return index;
|
||||
}
|
||||
|
||||
pathToDocument(documentId: string) {
|
||||
let path: NavigationNode[] | undefined;
|
||||
const document = this.store.rootStore.documents.get(documentId);
|
||||
|
||||
+30
-85
@@ -1,11 +1,11 @@
|
||||
import { addDays, differenceInDays } from "date-fns";
|
||||
import { floor } from "lodash";
|
||||
import { action, autorun, computed, observable } from "mobx";
|
||||
import { action, autorun, computed, observable, set } from "mobx";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import unescape from "@shared/utils/unescape";
|
||||
import DocumentsStore from "~/stores/DocumentsStore";
|
||||
import User from "~/models/User";
|
||||
import { NavigationNode } from "~/types";
|
||||
import type { NavigationNode } from "~/types";
|
||||
import Storage from "~/utils/Storage";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
import View from "./View";
|
||||
@@ -63,7 +63,6 @@ export default class Document extends ParanoidModel {
|
||||
@observable
|
||||
title: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
template: boolean;
|
||||
|
||||
@@ -219,6 +218,13 @@ export default class Document extends ParanoidModel {
|
||||
return floor((this.tasks.completed / this.tasks.total) * 100);
|
||||
}
|
||||
|
||||
@action
|
||||
updateTasks(total: number, completed: number) {
|
||||
if (total !== this.tasks.total || completed !== this.tasks.completed) {
|
||||
this.tasks = { total, completed };
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
share = async () => {
|
||||
return this.store.rootStore.shares.create({
|
||||
@@ -285,6 +291,8 @@ export default class Document extends ParanoidModel {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastViewedAt = new Date().toString();
|
||||
|
||||
return this.store.rootStore.views.create({
|
||||
documentId: this.id,
|
||||
});
|
||||
@@ -300,105 +308,42 @@ export default class Document extends ParanoidModel {
|
||||
return this.store.templatize(this.id);
|
||||
};
|
||||
|
||||
@action
|
||||
update = async (
|
||||
options: SaveOptions & {
|
||||
title?: string;
|
||||
lastRevision?: number;
|
||||
}
|
||||
) => {
|
||||
if (this.isSaving) {
|
||||
return this;
|
||||
}
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
if (options.lastRevision) {
|
||||
return await this.store.update(
|
||||
{
|
||||
id: this.id,
|
||||
title: options.title || this.title,
|
||||
fullWidth: this.fullWidth,
|
||||
},
|
||||
{
|
||||
lastRevision: options.lastRevision,
|
||||
publish: options?.publish,
|
||||
done: options?.done,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error("Attempting to update without a lastRevision");
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
save = async (options?: SaveOptions | undefined) => {
|
||||
if (this.isSaving) {
|
||||
return this;
|
||||
const params = this.toAPI();
|
||||
const collaborativeEditing = this.store.rootStore.auth.team
|
||||
?.collaborativeEditing;
|
||||
|
||||
if (collaborativeEditing) {
|
||||
delete params.text;
|
||||
}
|
||||
const isCreating = !this.id;
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
if (isCreating) {
|
||||
return await this.store.create(
|
||||
{
|
||||
parentDocumentId: this.parentDocumentId,
|
||||
collectionId: this.collectionId,
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
},
|
||||
{
|
||||
publish: options?.publish,
|
||||
done: options?.done,
|
||||
autosave: options?.autosave,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return await this.store.update(
|
||||
{
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
fullWidth: this.fullWidth,
|
||||
templateId: this.templateId,
|
||||
},
|
||||
const model = await this.store.save(
|
||||
{ ...params, id: this.id },
|
||||
{
|
||||
lastRevision: options?.lastRevision || this.revision,
|
||||
publish: options?.publish,
|
||||
done: options?.done,
|
||||
autosave: options?.autosave,
|
||||
...options,
|
||||
}
|
||||
);
|
||||
|
||||
// if saving is successful set the new values on the model itself
|
||||
set(this, { ...params, ...model });
|
||||
|
||||
this.persistedAttributes = this.toAPI();
|
||||
|
||||
return model;
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
move = (
|
||||
collectionId: string,
|
||||
parentDocumentId?: string | null,
|
||||
index?: number | null
|
||||
) => {
|
||||
return this.store.move(this.id, collectionId, parentDocumentId, index);
|
||||
move = (collectionId: string, parentDocumentId?: string | undefined) => {
|
||||
return this.store.move(this.id, collectionId, parentDocumentId);
|
||||
};
|
||||
|
||||
@computed
|
||||
get metaData() {
|
||||
const collection = this.store.rootStore.collections.get(this.collectionId);
|
||||
const undo = {
|
||||
id: this.id,
|
||||
collectionId: this.collectionId,
|
||||
parentDocumentId: this.parentDocumentId,
|
||||
index: collection?.documentIndexInCollection?.(this.id),
|
||||
};
|
||||
return undo;
|
||||
}
|
||||
|
||||
duplicate = () => {
|
||||
return this.store.duplicate(this);
|
||||
};
|
||||
|
||||
@@ -55,6 +55,10 @@ class Team extends BaseModel {
|
||||
|
||||
url: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
allowedDomains: string[] | null | undefined;
|
||||
|
||||
@computed
|
||||
get signinMethods(): string {
|
||||
return "SSO";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import { Role } from "@shared/types";
|
||||
import type { Role } from "@shared/types";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { observable } from "mobx";
|
||||
import BaseModel from "./BaseModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
class WebhookSubscription extends BaseModel {
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
url: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
enabled: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export default WebhookSubscription;
|
||||
@@ -1,11 +1,10 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
|
||||
import Archive from "~/scenes/Archive";
|
||||
import Collection from "~/scenes/Collection";
|
||||
import DocumentNew from "~/scenes/DocumentNew";
|
||||
import Drafts from "~/scenes/Drafts";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import Search from "~/scenes/Search";
|
||||
import Templates from "~/scenes/Templates";
|
||||
import Trash from "~/scenes/Trash";
|
||||
import Layout from "~/components/AuthenticatedLayout";
|
||||
@@ -13,6 +12,8 @@ import CenteredContent from "~/components/CenteredContent";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import SocketProvider from "~/components/SocketProvider";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
||||
|
||||
const SettingsRoutes = React.lazy(
|
||||
@@ -29,6 +30,13 @@ const Document = React.lazy(
|
||||
"~/scenes/Document"
|
||||
)
|
||||
);
|
||||
const Collection = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "collection" */
|
||||
"~/scenes/Collection"
|
||||
)
|
||||
);
|
||||
const Home = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
@@ -36,8 +44,13 @@ const Home = React.lazy(
|
||||
"~/scenes/Home"
|
||||
)
|
||||
);
|
||||
|
||||
const NotFound = () => <Search notFound />;
|
||||
const Search = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "search" */
|
||||
"~/scenes/Search"
|
||||
)
|
||||
);
|
||||
|
||||
const RedirectDocument = ({
|
||||
match,
|
||||
@@ -49,7 +62,10 @@ const RedirectDocument = ({
|
||||
/>
|
||||
);
|
||||
|
||||
export default function AuthenticatedRoutes() {
|
||||
function AuthenticatedRoutes() {
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
return (
|
||||
<SocketProvider>
|
||||
<Layout>
|
||||
@@ -61,14 +77,24 @@ export default function AuthenticatedRoutes() {
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
{can.createDocument && (
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
)}
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Redirect exact from="/starred" to="/home" />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
@@ -86,10 +112,12 @@ export default function AuthenticatedRoutes() {
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<SettingsRoutes />
|
||||
<Route component={NotFound} />
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</SocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(AuthenticatedRoutes);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect } from "react-router-dom";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
||||
|
||||
@@ -20,6 +21,7 @@ export default function SettingsRoutes() {
|
||||
<Redirect from="/settings/import-export" to="/settings/export" />
|
||||
<Redirect from="/settings/people" to="/settings/members" />
|
||||
<Redirect from="/settings/profile" to="/settings" />
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ function CollectionScene() {
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
direction: collection.sort.direction,
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import IconPicker from "~/components/IconPicker";
|
||||
@@ -93,6 +94,7 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
@@ -104,7 +106,7 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
|
||||
label={t("Sort in sidebar")}
|
||||
options={[
|
||||
{
|
||||
label: t("Alphabetical"),
|
||||
label: t("Alphabetical sort"),
|
||||
value: "title.asc",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,9 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
@@ -29,7 +32,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
icon = "";
|
||||
|
||||
@observable
|
||||
color = "#4E5C6E";
|
||||
color = randomElement(colorPalette);
|
||||
|
||||
@observable
|
||||
sharing = true;
|
||||
@@ -127,6 +130,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={this.handleNameChange}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
value={this.name}
|
||||
required
|
||||
autoFocus
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -103,4 +104,4 @@ const Select = styled(InputSelect)`
|
||||
}
|
||||
` as React.ComponentType<SelectProps>;
|
||||
|
||||
export default MemberListItem;
|
||||
export default observer(MemberListItem);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -45,4 +46,4 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default UserListItem;
|
||||
export default observer(UserListItem);
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { useTheme } from "styled-components";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RouteComponentProps, useLocation } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { setCookie } from "tiny-cookie";
|
||||
import DocumentModel from "~/models/Document";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import Layout from "~/components/Layout";
|
||||
import Sidebar from "~/components/Sidebar/Shared";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { OfflineError } from "~/utils/errors";
|
||||
import { AuthorizationError, OfflineError } from "~/utils/errors";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Login from "../Login";
|
||||
import Document from "./components/Document";
|
||||
import Loading from "./components/Loading";
|
||||
|
||||
@@ -28,6 +34,14 @@ type Props = RouteComponentProps<{
|
||||
location: Location<{ title?: string }>;
|
||||
};
|
||||
|
||||
// Parse the canonical origin from the SSR HTML, only needs to be done once.
|
||||
const canonicalUrl = document
|
||||
.querySelector("link[rel=canonical]")
|
||||
?.getAttribute("href");
|
||||
const canonicalOrigin = canonicalUrl
|
||||
? new URL(canonicalUrl).origin
|
||||
: window.location.origin;
|
||||
|
||||
/**
|
||||
* Find the document UUID from the slug given the sharedTree
|
||||
*
|
||||
@@ -63,6 +77,8 @@ function useDocumentId(documentSlug: string, response?: Response) {
|
||||
function SharedDocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const [response, setResponse] = React.useState<Response>();
|
||||
const [error, setError] = React.useState<Error | null | undefined>();
|
||||
const { documents } = useStores();
|
||||
@@ -95,7 +111,29 @@ function SharedDocumentScene(props: Props) {
|
||||
}, [documents, documentSlug, shareId, ui]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||
if (error instanceof OfflineError) {
|
||||
return <ErrorOffline />;
|
||||
} else if (error instanceof AuthorizationError) {
|
||||
setCookie("postLoginRedirectPath", props.location.pathname);
|
||||
return (
|
||||
<Login>
|
||||
{(config) =>
|
||||
config?.name && isCloudHosted ? (
|
||||
<GetStarted>
|
||||
{t(
|
||||
"{{ teamName }} is using Outline to share documents, please login to continue.",
|
||||
{
|
||||
teamName: config.name,
|
||||
}
|
||||
)}
|
||||
</GetStarted>
|
||||
) : null
|
||||
}
|
||||
</Login>
|
||||
);
|
||||
} else {
|
||||
return <Error404 />;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
@@ -107,16 +145,29 @@ function SharedDocumentScene(props: Props) {
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Layout title={response.document.title} sidebar={sidebar}>
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
readOnly
|
||||
/>
|
||||
</Layout>
|
||||
<>
|
||||
<Helmet>
|
||||
<link
|
||||
rel="canonical"
|
||||
href={canonicalOrigin + location.pathname.replace(/\/$/, "")}
|
||||
/>
|
||||
</Helmet>
|
||||
<Layout title={response.document.title} sidebar={sidebar}>
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
readOnly
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const GetStarted = styled(Text)`
|
||||
text-align: center;
|
||||
margin-top: -8px;
|
||||
`;
|
||||
|
||||
export default observer(SharedDocumentScene);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Text from "~/components/Text";
|
||||
@@ -48,11 +49,12 @@ export default function Contents({ headings, isFullWidth }: Props) {
|
||||
Infinity
|
||||
);
|
||||
const headingAdjustment = minHeading - 1;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Wrapper isFullWidth={isFullWidth}>
|
||||
<Sticky>
|
||||
<Heading>Contents</Heading>
|
||||
<Heading>{t("Contents")}</Heading>
|
||||
{headings.length ? (
|
||||
<List>
|
||||
{headings.map((heading) => (
|
||||
@@ -66,7 +68,9 @@ export default function Contents({ headings, isFullWidth }: Props) {
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Empty>Headings you add to the document will appear here</Empty>
|
||||
<Empty>
|
||||
{t("Headings you add to the document will appear here")}
|
||||
</Empty>
|
||||
)}
|
||||
</Sticky>
|
||||
</Wrapper>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { RouteComponentProps, StaticContext } from "react-router";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import withStores from "~/components/withStores";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { NotFoundError, OfflineError } from "~/utils/errors";
|
||||
import history from "~/utils/history";
|
||||
@@ -16,146 +14,104 @@ import { matchDocumentEdit } from "~/utils/routeHelpers";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
|
||||
type Props = RootStore &
|
||||
RouteComponentProps<
|
||||
{
|
||||
documentSlug: string;
|
||||
revisionId?: string;
|
||||
shareId?: string;
|
||||
title?: string;
|
||||
},
|
||||
StaticContext,
|
||||
{
|
||||
title?: string;
|
||||
}
|
||||
> & {
|
||||
children: (arg0: any) => React.ReactNode;
|
||||
};
|
||||
type Params = {
|
||||
documentSlug: string;
|
||||
revisionId?: string;
|
||||
shareId?: string;
|
||||
};
|
||||
|
||||
@observer
|
||||
class DataLoader extends React.Component<Props> {
|
||||
sharedTree: NavigationNode | null | undefined;
|
||||
type LocationState = {
|
||||
title?: string;
|
||||
restore?: boolean;
|
||||
revisionId?: string;
|
||||
};
|
||||
|
||||
@observable
|
||||
document: Document | null | undefined;
|
||||
type Children = (options: {
|
||||
document: Document;
|
||||
revision: Revision | undefined;
|
||||
abilities: Record<string, boolean>;
|
||||
isEditing: boolean;
|
||||
readOnly: boolean;
|
||||
onCreateLink: (title: string) => Promise<string>;
|
||||
sharedTree: NavigationNode | undefined;
|
||||
}) => React.ReactNode;
|
||||
|
||||
@observable
|
||||
revision: Revision | null | undefined;
|
||||
type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
|
||||
children: Children;
|
||||
};
|
||||
|
||||
@observable
|
||||
shapshot: Blob | null | undefined;
|
||||
function DataLoader({ match, children }: Props) {
|
||||
const { ui, shares, documents, auth, revisions } = useStores();
|
||||
const { team } = auth;
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
const { revisionId, shareId, documentSlug } = match.params;
|
||||
|
||||
@observable
|
||||
error: Error | null | undefined;
|
||||
// Allows loading by /doc/slug-<urlId> or /doc/<id>
|
||||
const document =
|
||||
documents.getByUrl(match.params.documentSlug) ??
|
||||
documents.get(match.params.documentSlug);
|
||||
|
||||
componentDidMount() {
|
||||
const { documents, match } = this.props;
|
||||
this.document = documents.getByUrl(match.params.documentSlug);
|
||||
this.sharedTree = this.document
|
||||
? documents.getSharedTree(this.document.id)
|
||||
: undefined;
|
||||
this.loadDocument();
|
||||
}
|
||||
const revision = revisionId ? revisions.get(revisionId) : undefined;
|
||||
const sharedTree = document
|
||||
? documents.getSharedTree(document.id)
|
||||
: undefined;
|
||||
const isEditRoute = match.path === matchDocumentEdit;
|
||||
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
|
||||
const can = usePolicy(document ? document.id : "");
|
||||
const location = useLocation<LocationState>();
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
// If we have the document in the store, but not it's policy then we need to
|
||||
// reload from the server otherwise the UI will not know which authorizations
|
||||
// the user has
|
||||
if (this.document) {
|
||||
const document = this.document;
|
||||
const policy = this.props.policies.get(document.id);
|
||||
|
||||
if (
|
||||
!policy &&
|
||||
!this.error &&
|
||||
this.props.auth.user &&
|
||||
this.props.auth.user.id
|
||||
) {
|
||||
this.loadDocument();
|
||||
}
|
||||
}
|
||||
|
||||
// Also need to load the revision if it changes
|
||||
const { revisionId } = this.props.match.params;
|
||||
|
||||
if (
|
||||
prevProps.match.params.revisionId !== revisionId &&
|
||||
revisionId &&
|
||||
revisionId !== "latest"
|
||||
) {
|
||||
this.loadRevision();
|
||||
}
|
||||
}
|
||||
|
||||
get isEditRoute() {
|
||||
return this.props.match.path === matchDocumentEdit;
|
||||
}
|
||||
|
||||
get isEditing() {
|
||||
return this.isEditRoute || this.props.auth?.team?.collaborativeEditing;
|
||||
}
|
||||
|
||||
onCreateLink = async (title: string) => {
|
||||
const document = this.document;
|
||||
invariant(document, "document must be loaded to create link");
|
||||
|
||||
const newDocument = await this.props.documents.create({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
title,
|
||||
text: "",
|
||||
});
|
||||
|
||||
return newDocument.url;
|
||||
};
|
||||
|
||||
loadRevision = async () => {
|
||||
const { revisionId } = this.props.match.params;
|
||||
|
||||
if (revisionId) {
|
||||
this.revision = await this.props.revisions.fetch(revisionId);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocument = async () => {
|
||||
const { shareId, documentSlug, revisionId } = this.props.match.params;
|
||||
|
||||
// sets the document as active in the sidebar if we already have it loaded
|
||||
if (this.document) {
|
||||
this.props.ui.setActiveDocument(this.document);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.props.documents.fetchWithSharedTree(
|
||||
documentSlug,
|
||||
{
|
||||
React.useEffect(() => {
|
||||
async function fetchDocument() {
|
||||
try {
|
||||
await documents.fetchWithSharedTree(documentSlug, {
|
||||
shareId,
|
||||
}
|
||||
);
|
||||
this.sharedTree = response.sharedTree;
|
||||
this.document = response.document;
|
||||
|
||||
if (revisionId && revisionId !== "latest") {
|
||||
await this.loadRevision();
|
||||
} else {
|
||||
this.revision = undefined;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
return;
|
||||
}
|
||||
fetchDocument();
|
||||
}, [ui, documents, document, shareId, documentSlug]);
|
||||
|
||||
const document = this.document;
|
||||
React.useEffect(() => {
|
||||
async function fetchRevision() {
|
||||
if (revisionId && revisionId !== "latest") {
|
||||
try {
|
||||
await revisions.fetch(revisionId);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchRevision();
|
||||
}, [revisions, revisionId]);
|
||||
|
||||
const onCreateLink = React.useCallback(
|
||||
async (title: string) => {
|
||||
if (!document) {
|
||||
throw new Error("Document not loaded yet");
|
||||
}
|
||||
|
||||
const newDocument = await documents.create({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
title,
|
||||
text: "",
|
||||
});
|
||||
|
||||
return newDocument.url;
|
||||
},
|
||||
[document, documents]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (document) {
|
||||
const can = this.props.policies.abilities(document.id);
|
||||
// sets the document as active in the sidebar, ideally in the future this
|
||||
// will be route driven.
|
||||
this.props.ui.setActiveDocument(document);
|
||||
// sets the current document as active in the sidebar
|
||||
ui.setActiveDocument(document);
|
||||
|
||||
// If we're attempting to update an archived, deleted, or otherwise
|
||||
// uneditable document then forward to the canonical read url.
|
||||
if (!can.update && this.isEditRoute) {
|
||||
if (!can.update && isEditRoute) {
|
||||
history.push(document.url);
|
||||
return;
|
||||
}
|
||||
@@ -163,72 +119,51 @@ class DataLoader extends React.Component<Props> {
|
||||
// Prevents unauthorized request to load share information for the document
|
||||
// when viewing a public share link
|
||||
if (can.read) {
|
||||
this.props.shares.fetch(document.id).catch((err) => {
|
||||
shares.fetch(document.id).catch((err) => {
|
||||
if (!(err instanceof NotFoundError)) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [can.read, can.update, document, isEditRoute, shares, ui]);
|
||||
|
||||
render() {
|
||||
const { location, policies, auth, match, ui } = this.props;
|
||||
const { revisionId } = match.params;
|
||||
|
||||
if (this.error) {
|
||||
return this.error instanceof OfflineError ? (
|
||||
<ErrorOffline />
|
||||
) : (
|
||||
<Error404 />
|
||||
);
|
||||
}
|
||||
|
||||
const team = auth.team;
|
||||
const document = this.document;
|
||||
const revision = this.revision;
|
||||
|
||||
if (!document || !team || (revisionId && !revision)) {
|
||||
return (
|
||||
<>
|
||||
<Loading location={location} />
|
||||
{this.isEditing && !team?.collaborativeEditing && (
|
||||
<HideSidebar ui={ui} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const abilities = policies.abilities(document.id);
|
||||
// We do not want to remount the document when changing from view->edit
|
||||
// on the multiplayer flag as the doc is guaranteed to be upto date.
|
||||
const key = team.collaborativeEditing
|
||||
? ""
|
||||
: this.isEditing
|
||||
? "editing"
|
||||
: "read-only";
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||
}
|
||||
|
||||
if (!document || !team || (revisionId && !revision)) {
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{this.isEditing && !team.collaborativeEditing && (
|
||||
<HideSidebar ui={ui} />
|
||||
)}
|
||||
{this.props.children({
|
||||
document,
|
||||
revision,
|
||||
abilities,
|
||||
isEditing: this.isEditing,
|
||||
readOnly:
|
||||
!this.isEditing ||
|
||||
!abilities.update ||
|
||||
document.isArchived ||
|
||||
!!revisionId,
|
||||
onCreateLink: this.onCreateLink,
|
||||
sharedTree: this.sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
<>
|
||||
<Loading location={location} />
|
||||
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// We do not want to remount the document when changing from view->edit
|
||||
// on the multiplayer flag as the doc is guaranteed to be upto date.
|
||||
const key = team.collaborativeEditing
|
||||
? ""
|
||||
: isEditing
|
||||
? "editing"
|
||||
: "read-only";
|
||||
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{isEditing && !team.collaborativeEditing && <HideSidebar ui={ui} />}
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
abilities: can,
|
||||
isEditing,
|
||||
readOnly:
|
||||
!isEditing || !can.update || document.isArchived || !!revisionId,
|
||||
onCreateLink,
|
||||
sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default withStores(DataLoader);
|
||||
export default observer(DataLoader);
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "react-router";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import getTasks from "@shared/utils/getTasks";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
@@ -29,9 +31,9 @@ import PageTitle from "~/components/PageTitle";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import withStores from "~/components/withStores";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { isCustomDomain } from "~/utils/domains";
|
||||
import { emojiToUrl } from "~/utils/emoji";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import {
|
||||
@@ -53,13 +55,21 @@ import References from "./References";
|
||||
|
||||
const AUTOSAVE_DELAY = 3000;
|
||||
|
||||
type Params = {
|
||||
documentSlug: string;
|
||||
revisionId?: string;
|
||||
shareId?: string;
|
||||
};
|
||||
|
||||
type LocationState = {
|
||||
title?: string;
|
||||
restore?: boolean;
|
||||
revisionId?: string;
|
||||
};
|
||||
|
||||
type Props = WithTranslation &
|
||||
RootStore &
|
||||
RouteComponentProps<
|
||||
Record<string, string>,
|
||||
StaticContext,
|
||||
{ restore?: boolean; revisionId?: string }
|
||||
> & {
|
||||
RouteComponentProps<Params, StaticContext, LocationState> & {
|
||||
sharedTree?: NavigationNode;
|
||||
abilities: Record<string, any>;
|
||||
document: Document;
|
||||
@@ -73,7 +83,7 @@ type Props = WithTranslation &
|
||||
@observer
|
||||
class DocumentScene extends React.Component<Props> {
|
||||
@observable
|
||||
editor = React.createRef<typeof Editor>();
|
||||
editor = React.createRef<TEditor>();
|
||||
|
||||
@observable
|
||||
isUploading = false;
|
||||
@@ -96,6 +106,9 @@ class DocumentScene extends React.Component<Props> {
|
||||
@observable
|
||||
title: string = this.props.document.title;
|
||||
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
getEditorText: () => string = () => this.props.document.text;
|
||||
|
||||
componentDidMount() {
|
||||
@@ -158,7 +171,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'view' does not exist on type 'unknown'.
|
||||
const { view, parser } = editorRef;
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
@@ -281,7 +293,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
autosave?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const { document, auth } = this.props;
|
||||
const { document } = this.props;
|
||||
// prevent saves when we are already saving
|
||||
if (document.isSaving) {
|
||||
return;
|
||||
@@ -307,22 +319,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.isPublishing = !!options.publish;
|
||||
|
||||
try {
|
||||
let savedDocument = document;
|
||||
|
||||
if (auth.team?.collaborativeEditing) {
|
||||
// update does not send "text" field to the API, this is a workaround
|
||||
// while the multiplayer editor is toggleable. Once it's finalized
|
||||
// this can be cleaned up to single code path
|
||||
savedDocument = await document.update({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
} else {
|
||||
savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
}
|
||||
const savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
|
||||
this.isEditorDirty = false;
|
||||
this.lastRevision = savedDocument.revision;
|
||||
@@ -375,13 +375,15 @@ class DocumentScene extends React.Component<Props> {
|
||||
const { document, auth } = this.props;
|
||||
this.getEditorText = getEditorText;
|
||||
|
||||
// If the multiplayer editor is enabled then we still want to keep the local
|
||||
// text value in sync as it is used as a cache.
|
||||
// Keep derived task list in sync
|
||||
const tasks = this.editor.current?.getTasks();
|
||||
const total = tasks?.length ?? 0;
|
||||
const completed = tasks?.filter((t) => t.completed).length ?? 0;
|
||||
document.updateTasks(total, completed);
|
||||
|
||||
// If the multiplayer editor is enabled we're done here as changes are saved
|
||||
// through the persistence protocol. The rest of this method is legacy.
|
||||
if (auth.team?.collaborativeEditing) {
|
||||
action(() => {
|
||||
document.text = this.getEditorText();
|
||||
document.tasks = getTasks(document.text);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -399,6 +401,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
onHeadingsChange = (headings: Heading[]) => {
|
||||
this.headings = headings;
|
||||
};
|
||||
|
||||
onChangeTitle = action((value: string) => {
|
||||
this.title = value;
|
||||
this.props.document.title = value;
|
||||
@@ -429,12 +435,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
const embedsDisabled =
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
const headings = this.editor.current
|
||||
? // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
|
||||
this.editor.current.getHeadings()
|
||||
: [];
|
||||
|
||||
const hasHeadings = headings.length > 0;
|
||||
const hasHeadings = this.headings.length > 0;
|
||||
const showContents =
|
||||
ui.tocVisible &&
|
||||
((readOnly && hasHeadings) || team?.collaborativeEditing);
|
||||
@@ -456,6 +457,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
to={{
|
||||
pathname: canonicalUrl,
|
||||
state: this.props.location.state,
|
||||
hash: this.props.location.hash,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -548,7 +550,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
sharedTree={this.props.sharedTree}
|
||||
onSelectTemplate={this.replaceDocument}
|
||||
onSave={this.onSave}
|
||||
headings={headings}
|
||||
headings={this.headings}
|
||||
/>
|
||||
<MaxWidth
|
||||
archived={document.isArchived}
|
||||
@@ -563,7 +565,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
<Flex auto={!readOnly}>
|
||||
{showContents && (
|
||||
<Contents
|
||||
headings={headings}
|
||||
headings={this.headings}
|
||||
isFullWidth={document.fullWidth}
|
||||
/>
|
||||
)}
|
||||
@@ -587,6 +589,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.onChangeTitle}
|
||||
onChange={this.onChange}
|
||||
onHeadingsChange={this.onHeadingsChange}
|
||||
onSave={this.onSave}
|
||||
onPublish={this.onPublish}
|
||||
onCancel={this.goBack}
|
||||
@@ -614,7 +617,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
</Flex>
|
||||
</React.Suspense>
|
||||
</MaxWidth>
|
||||
{isShare && !isCustomDomain() && (
|
||||
{isShare && !parseDomain(window.location.origin).custom && (
|
||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@@ -2,13 +2,13 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||
import { light } from "@shared/styles/theme";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
} from "@shared/utils/date";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||
import Star, { AnimatedStar } from "~/components/Star";
|
||||
@@ -132,7 +132,7 @@ const EditableTitle = React.forwardRef(
|
||||
$emojiWidth={emojiWidth}
|
||||
$isStarred={document.isStarred}
|
||||
autoFocus={!value}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
readOnly={readOnly}
|
||||
dir="auto"
|
||||
ref={ref}
|
||||
|
||||
@@ -57,8 +57,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
const handleBlur = React.useCallback(() => {
|
||||
props.onSave({ autosave: true });
|
||||
setTimeout(() => props.onSave({ autosave: true }), 250);
|
||||
}, [props]);
|
||||
|
||||
const handleGoToNextInput = React.useCallback(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user